From 10281117f923fabb19010726426666428df686fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:08:25 +0200 Subject: [PATCH] move: delete Memex.Portal.Shared and its suite from core (delete half) The project and its tests now live in MeshWeaver.Plugins src/ (paired add half). Drops the slnx entries, the guard whose whole subject moved (IdentityReadsRunAsSystemGuard), the moved inventory from three ratchets (impersonation 61->45, layout-area bake 19->18, awaited mesh read 2->0), SeoResolver from the svg guard's expected set, and the suite's shard weight. CarvingProjectsOutOfCore records the decision: GitHub sign-in moves with Portal.Shared and keeps calling core's GitSync, which stays here for now. Refs core slimming. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../scripts/check-module-platform-floor.py | 3 +- .github/scripts/release-channels.py | 2 +- .github/scripts/shard-assign.sh | 3 +- .github/workflows/dotnet-test.yml | 4 +- MeshWeaver.slnx | 2 - memex/Memex.Portal.Shared/Api/BoundedBody.cs | 73 - .../Api/ConsumerInventoryEndpoints.cs | 80 - .../Api/ControlLaneEndpoints.cs | 72 - .../Api/InstanceAuthResponses.cs | 95 - .../Api/InstanceKeyEndpoints.cs | 204 -- .../Api/InstanceRegistrationEndpoints.cs | 129 - .../Api/InstanceTokenEndpoints.cs | 206 -- .../Api/MeshApiEndpoints.cs | 581 ---- .../Api/ModulePublishedBroadcaster.cs | 188 -- .../Api/PluginBundleEndpoints.cs | 2478 ----------------- .../Api/PluginRegistryEndpoints.cs | 384 --- .../Api/RegistrationCouponEndpoints.cs | 191 -- .../Api/RegistryAnswerDeadline.cs | 244 -- .../Api/ReleaseGateEndpoints.cs | 495 ---- memex/Memex.Portal.Shared/Api/SeoEndpoints.cs | 741 ----- .../Api/WebhookInboxEndpoints.cs | 189 -- .../ApiTokenAuthenticationHandler.cs | 272 -- .../Authentication/ApiTokenController.cs | 257 -- .../Authentication/ApiTokenService.cs | 1303 --------- .../Authentication/BootstrapController.cs | 196 -- .../Authentication/BootstrapSecretGate.cs | 90 - .../Authentication/DevAuthController.cs | 298 -- .../Authentication/EaConsentController.cs | 238 -- .../Authentication/EaGraphAuth.cs | 874 ------ .../Authentication/EntraMetadataCache.cs | 33 - .../Authentication/EntraStepUp.cs | 137 - .../Authentication/EntraStepUpTokenCheck.cs | 92 - .../Authentication/ExternalAuthController.cs | 168 -- .../Authentication/GlobalAdminSeed.cs | 107 - .../Authentication/IdentityReadOutcome.cs | 92 - .../Authentication/InvitationService.cs | 144 - .../McpAuthenticationExtensions.cs | 191 -- .../McpBackConnectionService.cs | 151 - .../Authentication/McpEndpointRoutes.cs | 107 - .../MeshWeaverInstanceService.cs | 796 ------ .../Authentication/MicrosoftTenant.cs | 133 - .../Authentication/OAuthCodeNodeType.cs | 53 - .../Authentication/OAuthCodeStore.cs | 304 -- .../Authentication/OAuthConnectController.cs | 672 ----- .../Authentication/OAuthCredentialBound.cs | 34 - .../Authentication/OAuthCredentialEviction.cs | 60 - .../Authentication/OnboardingMiddleware.cs | 723 ----- .../Authentication/PasskeyStepUp.cs | 172 -- .../Authentication/ReturnUrlPolicy.cs | 26 - .../Authentication/ServiceIdentities.cs | 158 -- .../Authentication/SetupHandOffController.cs | 182 -- .../Authentication/SetupHandOffVerifier.cs | 124 - .../Authentication/SetupSecretWriter.cs | 105 - .../Authentication/StepUpClaims.cs | 74 - .../Authentication/StepUpController.Portal.cs | 663 ----- .../Authentication/StepUpController.cs | 433 --- .../Authentication/StepUpFactorStore.cs | 95 - .../Authentication/StepUpLadder.cs | 130 - .../Authentication/StepUpPages.cs | 274 -- .../Authentication/StepUpPendingStore.cs | 72 - .../Authentication/StepUpSingleUse.cs | 146 - .../Authentication/TokenRefusal.cs | 68 - .../Authentication/Totp.cs | 118 - .../Authentication/UserOnboardingService.cs | 381 --- .../Authentication/UserRoleResolver.cs | 98 - .../Authentication/VirtualUserMiddleware.cs | 177 -- .../Email/EmailConfigurationGuard.cs | 172 -- .../Email/EmailDeliveryGuard.cs | 159 -- .../Email/InvitationEmailSender.cs | 278 -- .../Email/NoOpEmailSender.cs | 95 - .../Email/OutboundEmailSender.cs | 259 -- .../Email/OutboundSendQueue.cs | 165 -- memex/Memex.Portal.Shared/GuiShellSwitch.cs | 112 - .../Memex.Portal.Shared.csproj | 173 -- .../Memex.Portal.Shared/MemexConfiguration.cs | 1123 -------- .../MemexFeatureOptions.cs | 215 -- .../Memex.Portal.Shared/Pages/ErrorRoutes.cs | 25 - memex/Memex.Portal.Shared/README.md | 19 - .../SelfUpdate/ComboClearance.cs | 184 -- .../SelfUpdate/ComboVerificationGate.cs | 221 -- .../SelfUpdate/IComboGateRunner.cs | 54 - .../NewerPlatformFromUpdatePolicy.cs | 25 - .../SelfUpdate/OciTagLister.cs | 380 --- .../SelfUpdate/PackageHoldHandover.cs | 72 - .../SelfUpdate/PlatformUpdateChip.cs | 163 -- .../SelfUpdate/PlatformUpdateStatus.cs | 215 -- .../SelfUpdate/ReleaseAvailabilityService.cs | 681 ----- .../SelfUpdate/SelfUpdateConfiguration.cs | 107 - .../SelfUpdate/SelfUpdateHandover.cs | 641 ----- .../SelfUpdate/SelfUpdateHostedService.cs | 2081 -------------- .../SelfUpdate/SelfUpdateVerdict.cs | 713 ----- .../SelfUpdate/UnavailableUpdateMechanics.cs | 107 - .../SelfUpdate/UpdateAdvisory.cs | 47 - .../SelfUpdate/UpdatePolicyNodeType.cs | 627 ----- .../SelfUpdate/VersionSelect.cs | 451 --- .../Memex.Portal.Shared/Seo/IconRasterizer.cs | 147 - .../Memex.Portal.Shared/Seo/OgCardRenderer.cs | 802 ------ .../Seo/OpenSans-Regular.ttf | Bin 107280 -> 0 bytes memex/Memex.Portal.Shared/Seo/PublicSite.cs | 303 -- memex/Memex.Portal.Shared/Seo/SeoResolver.cs | 1032 ------- .../Settings/AboutSettingsTab.cs | 215 -- .../Settings/AdminAppOverviewTab.cs | 90 - .../Settings/AdminMenuGate.cs | 66 - .../Settings/ApiTokensSettingsTab.cs | 372 --- .../Settings/ControlLaneSettingsTab.cs | 124 - .../Settings/InboxSettingsTab.cs | 197 -- .../Settings/InstancesSettingsTab.cs | 208 -- .../Settings/InvitationsSettingsTab.cs | 237 -- .../Settings/NotificationsSettingsTab.cs | 137 - .../Settings/PlatformSettingsTabAreas.cs | 273 -- .../Settings/PrivacySettingsTab.cs | 72 - .../Settings/PrivacyStatementNode.cs | 238 -- .../Settings/PublishedSettingsTab.cs | 126 - .../Settings/ServiceIdentitiesSettingsTab.cs | 371 --- .../Settings/StepUpSettingsTab.cs | 58 - .../Settings/UpdatePolicySettingsTab.cs | 592 ---- .../Settings/WhatsNewSettingsTab.cs | 203 -- .../Setup/InstanceIdRules.cs | 27 - ...InstanceManifestConfigurationExtensions.cs | 73 - .../Setup/InstanceSetupStatusAccessor.cs | 44 - .../Setup/SetupAccessToken.cs | 70 - .../Memex.Portal.Shared/Setup/SetupCatalog.cs | 165 -- .../Setup/SetupComposition.cs | 387 --- .../Setup/SetupConsentDocuments.cs | 49 - .../Setup/SetupEndpoints.cs | 510 ---- .../Setup/SetupOnlyHost.cs | 154 - memex/Memex.Portal.Shared/Setup/SetupPage.cs | 506 ---- .../Setup/SetupRegistryClient.cs | 197 -- .../Memex.Portal.Shared/Setup/SetupStrings.cs | 246 -- .../Setup/StorageBackendCatalog.cs | 62 - .../Memex.Portal.Shared/ShippedReleaseSeed.cs | 451 --- .../Social/GitHubAppManifestEndpoints.cs | 169 -- .../Social/GitHubConnectEndpoints.cs | 220 -- .../Social/GitHubLoginEndpoints.cs | 199 -- .../Social/GitHubWebhookEndpoints.cs | 227 -- .../StaticRepoSyncExtensions.cs | 146 - .../Storage/MemexInfraOptions.cs | 90 - .../Architecture/CarvingProjectsOutOfCore.md | 55 +- test/AwaitedMeshReadSites.allow | 2 - test/ImpersonationScopeSites.allow | 10 - test/LayoutAreaDataBakeSites.allow | 1 - ...ProtectedSegmentSurvivesTheBootHealTest.cs | 351 --- ...aultAppDeclarationSurvivesDiscoveryTest.cs | 65 - ...ymentNamedCommercialPackageInstallsTest.cs | 404 --- ...InstallIsRepairedWhenTheFeedAnswersTest.cs | 286 -- .../AFailedPackageIsRetriedNotHeldTest.cs | 137 - .../AFailedPackageNamesItsCauseTest.cs | 190 -- ...SegmentDeclarationSurvivesDiscoveryTest.cs | 108 - ...erAnOlderComparisonRuleIsNoBaselineTest.cs | 202 -- .../ARepairBindsToATrustedSourceTest.cs | 105 - .../AReservedIdIsNeverASignInTest.cs | 65 - .../AStaleRecordDoesNotUndoTheGateTest.cs | 227 -- .../AboutSettingsTabTest.cs | 140 - .../AbsentUpdatePolicyFailsClosedTest.cs | 64 - ...vationReadIsNotQueuedBehindALandingTest.cs | 111 - .../ActivationRecordPublicationTest.cs | 143 - .../AdminAppFirstFrameTest.cs | 191 -- .../AdminChosenPolicyReachesTheRecordTest.cs | 349 --- .../AdminTabsUseStandardControlsTest.cs | 134 - .../AdminVerdictFailsClosedTest.cs | 86 - ...entoryReadsDeclarationLessNodeTypesTest.cs | 164 -- .../AnInstallHoldsItsRootTest.cs | 165 -- ...claresItsActionHandlerRuleNamespaceTest.cs | 110 - .../AnInstanceReportsWhatItRunsTest.cs | 322 --- .../ApiTokenBasicAuthTest.cs | 75 - .../AreaRouteIdWithColonTest.cs | 91 - .../AuthHostRedirectTest.cs | 157 -- .../AuthoredShareImageTest.cs | 385 --- .../BootInstallLandsOnTheSealedCommitTest.cs | 580 ---- .../BootstrapSecretGateTest.cs | 70 - .../BoundedBodyTest.cs | 160 -- .../BuildPrincipalAuthenticationTest.cs | 662 ----- .../BuildPrincipalDecisionTest.cs | 185 -- .../BundleServeRaceIsTransientTest.cs | 132 - .../BundleServeRaceOnTheRouteTest.cs | 144 - .../BundleTransferFailsOnSilenceTest.cs | 446 --- ...BundleTransferIsStreamedAndMeasuredTest.cs | 212 -- ...CallerFacingReadsDecideForTheCallerTest.cs | 314 --- .../CapturingLogger.cs | 35 - ...logAdminActionsNeedTheAdministratorTest.cs | 279 -- .../CatalogCategoryFirstTest.cs | 326 --- .../CatalogOrphanActionIdentityTest.cs | 252 -- ...osedTypeSetBundleAdoptionIsNotAMissTest.cs | 129 - ...edTypeSetInstallHoldsNoNodeTypeRowsTest.cs | 624 ----- .../ComboClearanceTest.cs | 264 -- .../ComboGateRollTest.cs | 655 ----- .../ComboVerdictRecordingTest.cs | 405 --- .../ComboVerificationDeadlineTest.cs | 122 - .../ComboVerifiesTheRecordedCommitTest.cs | 367 --- .../ConcurrentModuleLandingTest.cs | 163 -- .../ConcurrentUninstallTest.cs | 159 -- .../ConfigurationHandOverTest.cs | 132 - .../ConfiguredSourceListingEvictionTest.cs | 147 - ...figuredSourceReconcilesOnGreenBuildTest.cs | 318 --- .../ControlLaneKeyRoundTripTest.cs | 259 -- .../DataVolumeHealthCheckTest.cs | 102 - .../DataVolumeUsageHealthCheckTest.cs | 70 - ...claredAccessAssignmentIsRowFilteredTest.cs | 136 - .../DeclaredBreakRollHoldTest.cs | 223 -- .../DeclaredBuildPrincipalTest.cs | 277 -- ...eclaredModuleThatIsNotServedIsAMissTest.cs | 134 - ...eclaredModuleWithNoBinaryIsReportedTest.cs | 136 - .../DeclaredNodeIsRowFilteredInQueriesTest.cs | 232 -- .../DeleteTokenReportsWhatItRemovedTest.cs | 203 -- .../DeploymentPathsSupplyStorageTest.cs | 81 - .../DeploymentPinnedReferencesTest.cs | 737 ----- .../DevLoginIsNotAdminTest.cs | 88 - ...agnosticsNameTheContentTypeAssemblyTest.cs | 116 - .../DrainEndpointTest.cs | 249 -- .../EaConnectOpenRedirectTest.cs | 132 - .../EaConnectReconsentTest.cs | 140 - .../EaConsentOutcomeTest.cs | 138 - .../EaCredentialNodeTest.cs | 40 - .../EaCredentialReadTest.cs | 526 ---- .../EaMailboxesTest.cs | 260 -- .../EmailCopiedRecipientsTest.cs | 349 --- .../EmailMisconfigurationRefusalTest.cs | 362 --- .../EmailSendAsCapabilityTest.cs | 384 --- .../EmailStartupConfigurationGuardTest.cs | 396 --- .../EmailWatchesReopenAfterAFaultTest.cs | 121 - .../EntraStepUpTest.cs | 274 -- ...ClientNamesItsOwnResiliencePipelineTest.cs | 142 - .../ExecuteScriptPreflightTest.cs | 233 -- .../FakeOciRegistry.cs | 246 -- .../Fakes/FakeRegistry.cs | 117 - ...FirstAdministratorHasNoLearningPathTest.cs | 38 - .../FrozenHoldIsHistoryTest.cs | 164 -- .../GitHubAppInstallationSelectionTest.cs | 78 - .../GitHubAppTokenMintFailureTest.cs | 245 -- .../GitHubAppTokenRefreshTest.cs | 150 - .../GitHubBuildTokenTest.cs | 304 -- .../GitHubLoginEndpointsTest.cs | 37 - .../GitHubPackageSourceInstallReadTest.cs | 108 - .../GitHubTokenFactory.cs | 142 - .../GitHubWebhookSignerScopeTest.cs | 53 - .../GitSyncNamesItsReadIdentityTest.cs | 181 -- .../GlobalAdminSeedRefusesBlankIdsTest.cs | 83 - .../GlobalSettingsNavigationRouteTest.cs | 111 - .../GuiShellSwitchTest.cs | 83 - .../HealthCensusTest.cs | 462 --- .../HealthTimingIsPublishedTest.cs | 211 -- .../HeldNodeTypeBundleRetryTest.cs | 382 --- .../HeldUpdatesConvergeTest.cs | 639 ----- .../IconRasterizerTest.cs | 155 -- .../IncompleteInstallIsReassertedTest.cs | 426 --- .../IncrementalUpdateReadsOneSnapshotTest.cs | 300 -- test/Memex.Portal.Shared.Test/InlineIoPool.cs | 52 - .../InstallCompletenessTest.cs | 2101 -------------- .../InstallLegTimeoutTest.cs | 69 - ...tallNeverInheritsRepoCompileVerdictTest.cs | 151 - .../InstallRecordFollowsItsPartitionTest.cs | 234 -- .../InstallRecordUpdateRacingDeleteTest.cs | 98 - .../InstallRecordsTheServedCommitTest.cs | 256 -- .../InstallTimePrebuiltAdoptionTest.cs | 301 -- .../InstanceComboReaderDiscoveryTest.cs | 73 - .../InstanceIdRulesMatchTheRegistryTest.cs | 61 - .../InstanceKeyAdoptionTest.cs | 98 - .../InstanceKeyLiveResolutionTest.cs | 265 -- .../InstanceKeyRotationRulesTest.cs | 154 - .../InstanceKeyRotationTest.cs | 298 -- .../InstanceLookupHasNoBoundOfItsOwnTest.cs | 134 - ...InstanceLookupWaitsForEveryProviderTest.cs | 186 -- .../InstanceManifestProjectionTest.cs | 393 --- .../InstanceManifestTest.cs | 167 -- .../InstanceMasterKeyTest.cs | 141 - .../InstanceOpenRegistrationTest.cs | 176 -- .../InstancePlanSetByGlobalAdminTest.cs | 120 - .../InstanceRebootTest.cs | 715 ----- .../InstanceRebootWatchdogTest.cs | 563 ---- .../InstanceRepositoryAccessUnitTest.cs | 545 ---- ...nceSourceSelectionsUnderWinningNameTest.cs | 45 - ...stanceSourcesWaitForAHealthyCatalogTest.cs | 61 - .../InvitationEmailTest.cs | 54 - .../LegacySpacePrerenderTest.cs | 100 - .../LinkVerdictPerPlatformTest.cs | 158 -- .../LoadedPackageModuleReaderTest.cs | 177 -- .../LocalCheckoutPredicateTest.cs | 57 - .../LocalPartCollisionTest.cs | 25 - .../LocalSourceContentVersionTest.cs | 60 - .../LocalizedReleaseAdvisoriesTest.cs | 188 -- .../McpBackConnectionEvictionTest.cs | 78 - .../McpRoutingTest.cs | 238 -- ...pUnavailableValidationIsNotASignOutTest.cs | 115 - .../Memex.Portal.Shared.Test.csproj | 63 - .../MemexReadinessTest.cs | 156 -- .../MeshApiCookieAuthTest.cs | 345 --- .../MeshApiRecycleReasonBodyTest.cs | 176 -- .../MeshApiSentinelStatusTest.cs | 115 - .../MeshApiUploadBodyTest.cs | 172 -- .../MeshServicePrerenderedBodyTest.cs | 83 - .../MicrosoftTenantAuthorityTest.cs | 121 - .../MinMeshVersionHoldsTheUpdateTest.cs | 436 --- .../MintedTokenCarriesItsOwnStampTest.cs | 46 - .../ModuleActivationPostMergeTest.cs | 344 --- .../ModuleActivationStoredLayerTest.cs | 186 -- .../ModuleEndpointsSwapLiveTest.cs | 287 -- .../ModuleFloorAdvisoryFunnelTest.cs | 147 - .../ModuleFloorAdvisoryTest.cs | 464 --- .../ModuleGcOffReadinessPathTest.cs | 105 - .../ModuleGenerationDivergenceTest.cs | 238 -- .../ModuleLandingRecordsTest.cs | 320 --- .../ModuleLinkVersionTest.cs | 510 ---- .../ModuleOnlyBundleIsNotAMissTest.cs | 171 -- .../ModulePlatformFloorScriptParityTest.cs | 208 -- .../ModulePlatformLinkTest.cs | 899 ------ .../ModulePlatformMemberLinkTest.cs | 569 ---- .../ModulePlatformSurfaceJsonTest.cs | 237 -- .../ModuleProbeInputsCostTest.cs | 231 -- .../ModulePublishShelfTest.cs | 594 ---- .../ModuleReloadTest.cs | 1303 --------- .../ModuleSeedStampProducerTest.cs | 143 - .../ModuleSetConvergenceTest.cs | 701 ----- ...uleSetStoreReadsOnlyDecidingRecordsTest.cs | 175 -- .../ModuleUnloadableMarkerTest.cs | 212 -- .../ModuleUpdateIdentityTest.cs | 425 --- .../ModuleUpdatesGoLiveTest.cs | 413 --- .../ModuleVersionLabelTruthTest.cs | 236 -- ...ModulesGoLiveWhileARollIsHandedOverTest.cs | 401 --- ...lesUpdateIndependentlyOfThePlatformTest.cs | 182 -- ...MountedCheckoutReassertsOnEveryBootTest.cs | 233 -- ...tedPackageWaitsForGovernedProvisionTest.cs | 43 - .../NoOpEmailSenderRefusalTest.cs | 250 -- .../NodeFaviconBackplateTest.cs | 124 - .../NodeIconEndpointTest.cs | 141 - .../NonAdminUpdateStatusTest.cs | 220 -- .../NonexistentHostIsNotRetriedTest.cs | 70 - .../OAuthAbandonedExchangeMintsNothingTest.cs | 316 --- .../OAuthAuthorizeReturnUrlIsLocalTest.cs | 132 - .../OAuthBoundedLiveCredentialsTest.cs | 184 -- .../OAuthClientIdDerivationTest.cs | 64 - .../OAuthCodeStoreTest.cs | 251 -- .../OAuthCredentialEvictionTest.cs | 171 -- .../OAuthSupersedeLineNamesTheRowsTest.cs | 193 -- .../OAuthTokensExpireWhenIdleTest.cs | 155 -- .../OciRegistryClientTest.cs | 203 -- .../OciTagListerTest.cs | 1090 -------- .../OgCardRendererTest.cs | 440 --- .../OnboardingMiddlewareAbortedRequestTest.cs | 146 - .../OnboardingMiddlewareExclusionTest.cs | 159 -- .../OnboardingPinsAreSeededOnceTest.cs | 189 -- .../OnePartitionOneBookkeepingTest.cs | 675 ----- .../OperationSentinelPrefixTest.cs | 59 - ...cleWaitsForTheInstallHoldingTheRootTest.cs | 149 - .../OrphanedPackageRecordTest.cs | 181 -- .../OutboundEmailWatchQueryTest.cs | 68 - .../OutboundSendQueueTest.cs | 182 -- .../OutboundSubjectTest.cs | 56 - .../PackageConfigRequirementTest.cs | 75 - .../PackageDependencyFloorHoldTest.cs | 504 ---- .../PackageFloorHoldTest.cs | 542 ---- .../PackageInstallRebuildsDependentsTest.cs | 207 -- .../PackageListingCacheIsArmedTest.cs | 307 -- .../PackageListingCacheTest.cs | 502 ---- ...eOriginAnchorAnswersFromItsSnapshotTest.cs | 136 - ...PackageReasonsNeverSayTheRollIsHeldTest.cs | 103 - .../PackageUninstallTest.cs | 261 -- .../PackageUpdateReminderIdempotenceTest.cs | 308 -- .../PackagesAutoUpdateTest.cs | 222 -- .../PatchUnknownContentMembersTest.cs | 163 -- .../PendingModuleActivationsCostTest.cs | 226 -- .../PlanTierRefusalTest.cs | 446 --- .../PlatformBuildIdentityTest.cs | 128 - .../PlatformReferences.cs | 41 - ...latformRollsIndependentlyOfPackagesTest.cs | 588 ---- .../PlatformSettingsTabSeedTest.cs | 205 -- .../PlatformUpdateChipTest.cs | 246 -- .../PlatformUpdateStatusTest.cs | 273 -- .../PluginBundleAnchorTest.cs | 363 --- .../PluginBundleArtifactFetchTest.cs | 343 --- .../PluginBundleAuthTest.cs | 110 - .../PluginBundleEntitlementTest.cs | 282 -- .../PluginBundleIndexArtifactTest.cs | 219 -- .../PluginBundleLaneResolutionTest.cs | 144 - .../PluginBundlePlanTest.cs | 376 --- .../PluginBundlePublishAssetsTest.cs | 221 -- .../PluginBundlePublishNativesTest.cs | 353 --- .../PluginBundleSealedLaneTest.cs | 439 --- .../PluginBundleStalledReadTest.cs | 258 -- .../PluginCatalogStandingWatchesReopenTest.cs | 325 --- .../PluginSourceCredentialTest.cs | 531 ---- .../PortalStepUpRungsTest.cs | 349 --- ...PrebuiltBundleRetentionIsRegisteredTest.cs | 109 - .../PrebuiltPublicationTest.cs | 576 ---- ...ProbeAnswersWithoutWalkingTheVolumeTest.cs | 354 --- .../ProbeSeparationTest.cs | 194 -- .../ProcessLivenessHeartbeatTest.cs | 141 - ...PublicHostRedirectAsksAsTheStrangerTest.cs | 150 - .../PublicSiteTest.cs | 185 -- .../PublicationGenerationTest.cs | 467 ---- .../PublicationSealReadTest.cs | 74 - .../PublishStorageFaultIsNotAConflictTest.cs | 49 - .../PublishedModuleVersionShelfTest.cs | 182 -- .../PublishedSurfaceTest.cs | 93 - .../RedirectSinksUseOnePolicyGuard.cs | 152 - .../RedirectSourcesMintLocalTargetsGuard.cs | 211 -- .../RefusedReadLooksAbsentTest.cs | 143 - .../RegistrationCouponTest.cs | 298 -- .../RegistryAnswersOrRefusesTest.cs | 356 --- .../RegistryFilesRouteAnswersOrRefusesTest.cs | 225 -- .../RegistryNeverMislabelsModuleBytesTest.cs | 278 -- .../RegistryReconcileDeferralTest.cs | 242 -- ...yStatesTheServedBuildsModuleVersionTest.cs | 277 -- .../RegistryUpdateReconcilerTest.cs | 366 --- .../RejectedCertificateIsNotRetriedTest.cs | 230 -- .../ReleaseGateApplicabilityTest.cs | 82 - .../ReleaseGateAuthTest.cs | 166 -- .../ReleaseGateDenominatorTest.cs | 468 ---- .../ReleaseIdentityFromRegistryTest.cs | 786 ------ .../ReleaseLinkGateTest.cs | 929 ------ .../ReleaseSchemaMarkerTest.cs | 149 - .../RepairJsonTest.cs | 225 -- .../RetiredAndUnknownPlanTest.cs | 125 - .../ReturnUrlPolicyTest.cs | 27 - .../RetypedRootRecycleNeedsAJobTest.cs | 104 - .../RollGateReadinessOnlyTest.cs | 360 --- .../RollMidSessionDrainTest.cs | 402 --- .../RollSelectionInventoryTest.cs | 332 --- .../RollSelectionTest.cs | 510 ---- .../RollTargetIsThePollersSelectionTest.cs | 239 -- .../RunTestsBudgetCoversThePreflightTest.cs | 71 - .../RunTestsStreamsIntoAnActivityTest.cs | 73 - .../SealedBundleFloorCacheTest.cs | 530 ---- .../SealedModuleCompositionTest.cs | 245 -- .../SealedSetConsistencyTest.cs | 684 ----- ...earchBasePathKeepsTheQuerysLocationTest.cs | 168 -- .../SearchRefusesUnanchoredQueriesTest.cs | 190 -- .../SearchReportsPartialReadsTest.cs | 134 - .../SearchResolvesInQueryLocationsTest.cs | 81 - ...elfUpdateChecksOnlyAtDecisionPointsTest.cs | 343 --- .../SelfUpdateFailureIsPublicTest.cs | 289 -- .../SelfUpdateHandoverTest.cs | 410 --- ...SelfUpdateHandsOverToTheControlLaneTest.cs | 457 --- .../SelfUpdateMigratesBeforeItRollsTest.cs | 183 -- .../SelfUpdatePendingRestartTest.cs | 337 --- .../SelfUpdateRecordIsAuthoritativeTest.cs | 277 -- .../SelfUpdateSchemaBumpRefusalTest.cs | 473 ---- .../SelfUpdateStrandRecoveryTest.cs | 326 --- .../SelfUpdateVerdictTest.cs | 470 ---- .../SeoIconLinksTest.cs | 128 - .../SeoOwnerReadContextTest.cs | 91 - .../SeoPageBodyTest.cs | 156 -- .../SeoPublicAncestorCardTest.cs | 256 -- .../SeoPublicPreviewOptInTest.cs | 383 --- .../SeoResolverContentTest.cs | 156 -- .../SeoResolverIconTest.cs | 92 - ...vedActivationIsMaintainedNotScannedTest.cs | 235 -- .../ServedModuleBytesTest.cs | 413 --- .../ServiceIdentityTest.cs | 311 --- .../SessionDenialIsAnAnswerTest.cs | 436 --- ...ettingsRowActionsActOnTheClickedRowTest.cs | 266 -- .../SettingsUseStandardControlsTest.cs | 354 --- .../SetupCompositionTest.cs | 300 -- .../SetupHandOffVerifierTest.cs | 126 - .../SetupIdentityPhaseTest.cs | 477 ---- .../SetupProbeEndpointsTest.cs | 209 -- .../SetupSurfaceTest.cs | 429 --- .../SignInReadsAreAnchoredTest.cs | 167 -- .../SitemapDescentTest.cs | 92 - .../SitemapRefusesACensusItDidNotTakeTest.cs | 228 -- .../SitemapRootWindowTest.cs | 73 - .../SlashBearingNodeIdKeyingTest.cs | 321 --- .../StandInCompile.cs | 84 - ...dInCompileFailureNamesTheEmitCanaryTest.cs | 40 - .../StepUpFactorStoreTest.cs | 230 -- .../StepUpPagesCarryNoInlineScriptTest.cs | 190 -- ...geSectionWithoutTypeIsNotConfiguredTest.cs | 63 - .../StoreBootstrapLandsModulesFirstTest.cs | 718 ----- .../SyncTokenScopeTest.cs | 133 - .../TierInUseDeletionGuardTest.cs | 199 -- .../TieredPgoIsOffInEveryCompilingHostTest.cs | 56 - ...intedOnOneReplicaValidatesOnAnotherTest.cs | 89 - .../TokenProvisionedInstanceIsKeyedTest.cs | 67 - ...TokenRevocationIsIssuedOffTheRouterTest.cs | 344 --- .../TokenValidationHotPathTest.cs | 203 -- ...TokenValidationIsIssuedOffTheRouterTest.cs | 254 -- .../TokenValidationNegativeStagesTest.cs | 122 - .../TokenValidationReadinessTest.cs | 52 - .../UnanchoredQueryCensusTest.cs | 222 -- .../UndeclaredCatalogFormatIsDetectedTest.cs | 90 - .../UndeliveredModuleIsRecordedTest.cs | 217 -- .../UngrantedGetIsNotFoundTest.cs | 151 - .../UnhandledRequestIsNamedTest.cs | 190 -- ...nreadablePolicyRecordIsNotClobberedTest.cs | 300 -- .../UpdateAdvisoriesOnTheTabTest.cs | 95 - .../UpdateBindsToTheInstallOriginTest.cs | 332 --- .../UpdateKeepsTheSyncClaimTest.cs | 132 - .../UpdatePolicyMcpPatchTest.cs | 302 -- .../UploadIsDecidedWhereItIsWrittenTest.cs | 320 --- .../UploadRequiresUpdateTest.cs | 125 - .../UserDirectoryOpensAtStartTest.cs | 333 --- ...ndexReopenDoesNotServeADeadSnapshotTest.cs | 220 -- .../VersionEndpointTest.cs | 192 -- .../VersionSelectTest.cs | 730 ----- .../ViewerZoneTimestampTest.cs | 98 - .../VirtualUserMiddlewareAuthContextTest.cs | 76 - .../VirtualUserMiddlewareExclusionTest.cs | 113 - .../WebhookBodyFailureStatusTest.cs | 121 - .../WebhookInboxAnswersOnCommitTest.cs | 263 -- .../WebhookInboxStalledReadTest.cs | 244 -- .../WhatsNewSatelliteEntryTest.cs | 78 - .../WhatsNewTabIsATemplateTest.cs | 90 - .../WhoAmIAnswersAccessPerNodeTest.cs | 185 -- .../HubReachableAsyncGuard.cs | 13 +- .../IdentityReadsRunAsSystemGuard.cs | 104 - .../ImpersonationScopeSiteRatchetGuard.cs | 6 +- .../LayoutAreaDataBakeRatchetGuard.cs | 5 +- .../InlineSvgEmissionBackplateGuard.cs | 4 +- 507 files changed, 66 insertions(+), 126122 deletions(-) delete mode 100644 memex/Memex.Portal.Shared/Api/BoundedBody.cs delete mode 100644 memex/Memex.Portal.Shared/Api/ConsumerInventoryEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Api/ControlLaneEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Api/InstanceAuthResponses.cs delete mode 100644 memex/Memex.Portal.Shared/Api/InstanceKeyEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Api/InstanceRegistrationEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Api/InstanceTokenEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Api/MeshApiEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Api/ModulePublishedBroadcaster.cs delete mode 100644 memex/Memex.Portal.Shared/Api/PluginBundleEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Api/PluginRegistryEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Api/RegistrationCouponEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Api/RegistryAnswerDeadline.cs delete mode 100644 memex/Memex.Portal.Shared/Api/ReleaseGateEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Api/SeoEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Api/WebhookInboxEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/ApiTokenAuthenticationHandler.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/ApiTokenController.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/ApiTokenService.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/BootstrapController.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/BootstrapSecretGate.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/DevAuthController.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/EaConsentController.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/EaGraphAuth.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/EntraMetadataCache.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/EntraStepUp.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/EntraStepUpTokenCheck.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/ExternalAuthController.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/GlobalAdminSeed.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/IdentityReadOutcome.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/InvitationService.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/McpAuthenticationExtensions.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/McpBackConnectionService.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/McpEndpointRoutes.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/MeshWeaverInstanceService.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/MicrosoftTenant.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/OAuthCodeNodeType.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/OAuthCodeStore.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/OAuthCredentialBound.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/OAuthCredentialEviction.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/PasskeyStepUp.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/ReturnUrlPolicy.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/ServiceIdentities.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/SetupHandOffController.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/SetupHandOffVerifier.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/SetupSecretWriter.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/StepUpClaims.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/StepUpController.Portal.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/StepUpController.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/StepUpFactorStore.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/StepUpLadder.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/StepUpPages.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/StepUpPendingStore.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/StepUpSingleUse.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/TokenRefusal.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/Totp.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/UserRoleResolver.cs delete mode 100644 memex/Memex.Portal.Shared/Authentication/VirtualUserMiddleware.cs delete mode 100644 memex/Memex.Portal.Shared/Email/EmailConfigurationGuard.cs delete mode 100644 memex/Memex.Portal.Shared/Email/EmailDeliveryGuard.cs delete mode 100644 memex/Memex.Portal.Shared/Email/InvitationEmailSender.cs delete mode 100644 memex/Memex.Portal.Shared/Email/NoOpEmailSender.cs delete mode 100644 memex/Memex.Portal.Shared/Email/OutboundEmailSender.cs delete mode 100644 memex/Memex.Portal.Shared/Email/OutboundSendQueue.cs delete mode 100644 memex/Memex.Portal.Shared/GuiShellSwitch.cs delete mode 100644 memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj delete mode 100644 memex/Memex.Portal.Shared/MemexConfiguration.cs delete mode 100644 memex/Memex.Portal.Shared/MemexFeatureOptions.cs delete mode 100644 memex/Memex.Portal.Shared/Pages/ErrorRoutes.cs delete mode 100644 memex/Memex.Portal.Shared/README.md delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/ComboClearance.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/ComboVerificationGate.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/IComboGateRunner.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/NewerPlatformFromUpdatePolicy.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/OciTagLister.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/PackageHoldHandover.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/PlatformUpdateChip.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/PlatformUpdateStatus.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/ReleaseAvailabilityService.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateConfiguration.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHandover.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHostedService.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateVerdict.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/UnavailableUpdateMechanics.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/UpdateAdvisory.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/UpdatePolicyNodeType.cs delete mode 100644 memex/Memex.Portal.Shared/SelfUpdate/VersionSelect.cs delete mode 100644 memex/Memex.Portal.Shared/Seo/IconRasterizer.cs delete mode 100644 memex/Memex.Portal.Shared/Seo/OgCardRenderer.cs delete mode 100644 memex/Memex.Portal.Shared/Seo/OpenSans-Regular.ttf delete mode 100644 memex/Memex.Portal.Shared/Seo/PublicSite.cs delete mode 100644 memex/Memex.Portal.Shared/Seo/SeoResolver.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/AboutSettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/AdminAppOverviewTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/AdminMenuGate.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/ApiTokensSettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/ControlLaneSettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/InboxSettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/InstancesSettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/InvitationsSettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/NotificationsSettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/PlatformSettingsTabAreas.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/PrivacySettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/PrivacyStatementNode.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/PublishedSettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/ServiceIdentitiesSettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/StepUpSettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/UpdatePolicySettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Settings/WhatsNewSettingsTab.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/InstanceIdRules.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/InstanceManifestConfigurationExtensions.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/InstanceSetupStatusAccessor.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/SetupAccessToken.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/SetupCatalog.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/SetupComposition.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/SetupConsentDocuments.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/SetupEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/SetupOnlyHost.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/SetupPage.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/SetupRegistryClient.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/SetupStrings.cs delete mode 100644 memex/Memex.Portal.Shared/Setup/StorageBackendCatalog.cs delete mode 100644 memex/Memex.Portal.Shared/ShippedReleaseSeed.cs delete mode 100644 memex/Memex.Portal.Shared/Social/GitHubAppManifestEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Social/GitHubConnectEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Social/GitHubLoginEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/Social/GitHubWebhookEndpoints.cs delete mode 100644 memex/Memex.Portal.Shared/StaticRepoSyncExtensions.cs delete mode 100644 memex/Memex.Portal.Shared/Storage/MemexInfraOptions.cs delete mode 100644 test/Memex.Portal.Shared.Test/ADeclaredProtectedSegmentSurvivesTheBootHealTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ADefaultAppDeclarationSurvivesDiscoveryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ADeploymentNamedCommercialPackageInstallsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AFailedDefaultInstallIsRepairedWhenTheFeedAnswersTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AFailedPackageIsRetriedNotHeldTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AFailedPackageNamesItsCauseTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AProtectedSegmentDeclarationSurvivesDiscoveryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ARecordUnderAnOlderComparisonRuleIsNoBaselineTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ARepairBindsToATrustedSourceTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AReservedIdIsNeverASignInTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AStaleRecordDoesNotUndoTheGateTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AboutSettingsTabTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AbsentUpdatePolicyFailsClosedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ActivationReadIsNotQueuedBehindALandingTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ActivationRecordPublicationTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AdminAppFirstFrameTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AdminChosenPolicyReachesTheRecordTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AdminTabsUseStandardControlsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AdminVerdictFailsClosedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AdoptedInventoryReadsDeclarationLessNodeTypesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AnInstallHoldsItsRootTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AnInstalledPackageDeclaresItsActionHandlerRuleNamespaceTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AnInstanceReportsWhatItRunsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ApiTokenBasicAuthTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AreaRouteIdWithColonTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AuthHostRedirectTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/AuthoredShareImageTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/BootInstallLandsOnTheSealedCommitTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/BootstrapSecretGateTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/BoundedBodyTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/BuildPrincipalAuthenticationTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/BuildPrincipalDecisionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/BundleServeRaceIsTransientTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/BundleServeRaceOnTheRouteTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/BundleTransferFailsOnSilenceTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/BundleTransferIsStreamedAndMeasuredTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/CallerFacingReadsDecideForTheCallerTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/CapturingLogger.cs delete mode 100644 test/Memex.Portal.Shared.Test/CatalogAdminActionsNeedTheAdministratorTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/CatalogCategoryFirstTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/CatalogOrphanActionIdentityTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ClosedTypeSetBundleAdoptionIsNotAMissTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ComboClearanceTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ComboGateRollTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ComboVerdictRecordingTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ComboVerificationDeadlineTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ComboVerifiesTheRecordedCommitTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ConcurrentModuleLandingTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ConcurrentUninstallTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ConfigurationHandOverTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ConfiguredSourceListingEvictionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ConfiguredSourceReconcilesOnGreenBuildTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ControlLaneKeyRoundTripTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DataVolumeHealthCheckTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DataVolumeUsageHealthCheckTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DeclaredAccessAssignmentIsRowFilteredTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DeclaredBreakRollHoldTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DeclaredBuildPrincipalTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DeclaredModuleThatIsNotServedIsAMissTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DeclaredModuleWithNoBinaryIsReportedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DeclaredNodeIsRowFilteredInQueriesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DeleteTokenReportsWhatItRemovedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DeploymentPathsSupplyStorageTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DeploymentPinnedReferencesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DevLoginIsNotAdminTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DiagnosticsNameTheContentTypeAssemblyTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/DrainEndpointTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EaConnectOpenRedirectTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EaConnectReconsentTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EaConsentOutcomeTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EaCredentialNodeTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EaCredentialReadTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EaMailboxesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EmailCopiedRecipientsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EmailMisconfigurationRefusalTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EmailSendAsCapabilityTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EmailStartupConfigurationGuardTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EmailWatchesReopenAfterAFaultTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EntraStepUpTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/EveryClientNamesItsOwnResiliencePipelineTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ExecuteScriptPreflightTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/FakeOciRegistry.cs delete mode 100644 test/Memex.Portal.Shared.Test/Fakes/FakeRegistry.cs delete mode 100644 test/Memex.Portal.Shared.Test/FirstAdministratorHasNoLearningPathTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/FrozenHoldIsHistoryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/GitHubAppInstallationSelectionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/GitHubAppTokenMintFailureTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/GitHubAppTokenRefreshTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/GitHubBuildTokenTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/GitHubLoginEndpointsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/GitHubPackageSourceInstallReadTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/GitHubTokenFactory.cs delete mode 100644 test/Memex.Portal.Shared.Test/GitHubWebhookSignerScopeTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/GitSyncNamesItsReadIdentityTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/GlobalAdminSeedRefusesBlankIdsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/GlobalSettingsNavigationRouteTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/GuiShellSwitchTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/HealthCensusTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/HealthTimingIsPublishedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/HeldNodeTypeBundleRetryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/HeldUpdatesConvergeTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/IconRasterizerTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/IncompleteInstallIsReassertedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/IncrementalUpdateReadsOneSnapshotTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InlineIoPool.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstallCompletenessTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstallLegTimeoutTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstallNeverInheritsRepoCompileVerdictTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstallRecordFollowsItsPartitionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstallRecordUpdateRacingDeleteTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstallRecordsTheServedCommitTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstallTimePrebuiltAdoptionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceComboReaderDiscoveryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceIdRulesMatchTheRegistryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceKeyAdoptionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceKeyLiveResolutionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceKeyRotationRulesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceKeyRotationTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceLookupHasNoBoundOfItsOwnTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceLookupWaitsForEveryProviderTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceManifestProjectionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceManifestTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceMasterKeyTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceOpenRegistrationTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstancePlanSetByGlobalAdminTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceRebootTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceRebootWatchdogTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceRepositoryAccessUnitTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceSourceSelectionsUnderWinningNameTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InstanceSourcesWaitForAHealthyCatalogTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/InvitationEmailTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/LegacySpacePrerenderTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/LinkVerdictPerPlatformTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/LoadedPackageModuleReaderTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/LocalCheckoutPredicateTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/LocalPartCollisionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/LocalSourceContentVersionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/LocalizedReleaseAdvisoriesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/McpBackConnectionEvictionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/McpRoutingTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/McpUnavailableValidationIsNotASignOutTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/Memex.Portal.Shared.Test.csproj delete mode 100644 test/Memex.Portal.Shared.Test/MemexReadinessTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/MeshApiCookieAuthTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/MeshApiRecycleReasonBodyTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/MeshApiSentinelStatusTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/MeshApiUploadBodyTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/MeshServicePrerenderedBodyTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/MicrosoftTenantAuthorityTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/MinMeshVersionHoldsTheUpdateTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/MintedTokenCarriesItsOwnStampTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleActivationPostMergeTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleActivationStoredLayerTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleEndpointsSwapLiveTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleFloorAdvisoryFunnelTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleFloorAdvisoryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleGcOffReadinessPathTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleGenerationDivergenceTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleLandingRecordsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleLinkVersionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleOnlyBundleIsNotAMissTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModulePlatformFloorScriptParityTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModulePlatformLinkTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModulePlatformMemberLinkTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModulePlatformSurfaceJsonTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleProbeInputsCostTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModulePublishShelfTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleReloadTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleSeedStampProducerTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleSetConvergenceTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleSetStoreReadsOnlyDecidingRecordsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleUnloadableMarkerTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleUpdateIdentityTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleUpdatesGoLiveTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModuleVersionLabelTruthTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModulesGoLiveWhileARollIsHandedOverTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ModulesUpdateIndependentlyOfThePlatformTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/MountedCheckoutReassertsOnEveryBootTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/NewlyListedPackageWaitsForGovernedProvisionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/NoOpEmailSenderRefusalTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/NodeFaviconBackplateTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/NodeIconEndpointTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/NonAdminUpdateStatusTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/NonexistentHostIsNotRetriedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OAuthAbandonedExchangeMintsNothingTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OAuthAuthorizeReturnUrlIsLocalTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OAuthBoundedLiveCredentialsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OAuthClientIdDerivationTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OAuthCodeStoreTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OAuthCredentialEvictionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OAuthSupersedeLineNamesTheRowsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OAuthTokensExpireWhenIdleTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OciRegistryClientTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OciTagListerTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OgCardRendererTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OnboardingMiddlewareAbortedRequestTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OnboardingMiddlewareExclusionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OnboardingPinsAreSeededOnceTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OnePartitionOneBookkeepingTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OperationSentinelPrefixTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OperatorRecycleWaitsForTheInstallHoldingTheRootTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OrphanedPackageRecordTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OutboundEmailWatchQueryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OutboundSendQueueTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/OutboundSubjectTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PackageConfigRequirementTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PackageDependencyFloorHoldTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PackageFloorHoldTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PackageInstallRebuildsDependentsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PackageListingCacheIsArmedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PackageListingCacheTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PackageOriginAnchorAnswersFromItsSnapshotTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PackageReasonsNeverSayTheRollIsHeldTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PackageUninstallTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PackagesAutoUpdateTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PatchUnknownContentMembersTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PendingModuleActivationsCostTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PlanTierRefusalTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PlatformBuildIdentityTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PlatformReferences.cs delete mode 100644 test/Memex.Portal.Shared.Test/PlatformRollsIndependentlyOfPackagesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PlatformSettingsTabSeedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PlatformUpdateChipTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PlatformUpdateStatusTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginBundleAnchorTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginBundleArtifactFetchTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginBundleAuthTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginBundleEntitlementTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginBundleIndexArtifactTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginBundleLaneResolutionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginBundlePlanTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginBundlePublishAssetsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginBundlePublishNativesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginBundleSealedLaneTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginBundleStalledReadTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginCatalogStandingWatchesReopenTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PluginSourceCredentialTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PortalStepUpRungsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PrebuiltBundleRetentionIsRegisteredTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PrebuiltPublicationTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ProbeAnswersWithoutWalkingTheVolumeTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ProbeSeparationTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ProcessLivenessHeartbeatTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PublicHostRedirectAsksAsTheStrangerTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PublicSiteTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PublicationGenerationTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PublicationSealReadTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PublishStorageFaultIsNotAConflictTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PublishedModuleVersionShelfTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/PublishedSurfaceTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RedirectSinksUseOnePolicyGuard.cs delete mode 100644 test/Memex.Portal.Shared.Test/RedirectSourcesMintLocalTargetsGuard.cs delete mode 100644 test/Memex.Portal.Shared.Test/RefusedReadLooksAbsentTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RegistrationCouponTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RegistryAnswersOrRefusesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RegistryFilesRouteAnswersOrRefusesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RegistryNeverMislabelsModuleBytesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RegistryReconcileDeferralTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RegistryStatesTheServedBuildsModuleVersionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RegistryUpdateReconcilerTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RejectedCertificateIsNotRetriedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ReleaseGateApplicabilityTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ReleaseGateAuthTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ReleaseGateDenominatorTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ReleaseIdentityFromRegistryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ReleaseLinkGateTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ReleaseSchemaMarkerTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RepairJsonTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RetiredAndUnknownPlanTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ReturnUrlPolicyTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RetypedRootRecycleNeedsAJobTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RollGateReadinessOnlyTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RollMidSessionDrainTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RollSelectionInventoryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RollSelectionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RollTargetIsThePollersSelectionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RunTestsBudgetCoversThePreflightTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/RunTestsStreamsIntoAnActivityTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SealedBundleFloorCacheTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SealedModuleCompositionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SealedSetConsistencyTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SearchBasePathKeepsTheQuerysLocationTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SearchRefusesUnanchoredQueriesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SearchReportsPartialReadsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SearchResolvesInQueryLocationsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SelfUpdateChecksOnlyAtDecisionPointsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SelfUpdateFailureIsPublicTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SelfUpdateHandoverTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SelfUpdateHandsOverToTheControlLaneTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SelfUpdateMigratesBeforeItRollsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SelfUpdatePendingRestartTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SelfUpdateRecordIsAuthoritativeTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SelfUpdateSchemaBumpRefusalTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SelfUpdateStrandRecoveryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SelfUpdateVerdictTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SeoIconLinksTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SeoOwnerReadContextTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SeoPageBodyTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SeoPublicAncestorCardTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SeoPublicPreviewOptInTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SeoResolverContentTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SeoResolverIconTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ServedActivationIsMaintainedNotScannedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ServedModuleBytesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ServiceIdentityTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SessionDenialIsAnAnswerTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SettingsRowActionsActOnTheClickedRowTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SettingsUseStandardControlsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SetupCompositionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SetupHandOffVerifierTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SetupIdentityPhaseTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SetupProbeEndpointsTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SetupSurfaceTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SignInReadsAreAnchoredTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SitemapDescentTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SitemapRefusesACensusItDidNotTakeTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SitemapRootWindowTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SlashBearingNodeIdKeyingTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/StandInCompile.cs delete mode 100644 test/Memex.Portal.Shared.Test/StandInCompileFailureNamesTheEmitCanaryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/StepUpFactorStoreTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/StepUpPagesCarryNoInlineScriptTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/StorageSectionWithoutTypeIsNotConfiguredTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/StoreBootstrapLandsModulesFirstTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/SyncTokenScopeTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/TierInUseDeletionGuardTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/TieredPgoIsOffInEveryCompilingHostTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/TokenMintedOnOneReplicaValidatesOnAnotherTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/TokenProvisionedInstanceIsKeyedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/TokenRevocationIsIssuedOffTheRouterTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/TokenValidationHotPathTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/TokenValidationIsIssuedOffTheRouterTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/TokenValidationNegativeStagesTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/TokenValidationReadinessTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UnanchoredQueryCensusTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UndeclaredCatalogFormatIsDetectedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UndeliveredModuleIsRecordedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UngrantedGetIsNotFoundTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UnhandledRequestIsNamedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UnreadablePolicyRecordIsNotClobberedTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UpdateAdvisoriesOnTheTabTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UpdateBindsToTheInstallOriginTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UpdateKeepsTheSyncClaimTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UpdatePolicyMcpPatchTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UploadIsDecidedWhereItIsWrittenTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UploadRequiresUpdateTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UserDirectoryOpensAtStartTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/UserIndexReopenDoesNotServeADeadSnapshotTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/VersionEndpointTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/VersionSelectTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/ViewerZoneTimestampTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/VirtualUserMiddlewareAuthContextTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/VirtualUserMiddlewareExclusionTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/WebhookBodyFailureStatusTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/WebhookInboxAnswersOnCommitTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/WebhookInboxStalledReadTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/WhatsNewSatelliteEntryTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/WhatsNewTabIsATemplateTest.cs delete mode 100644 test/Memex.Portal.Shared.Test/WhoAmIAnswersAccessPerNodeTest.cs delete mode 100644 test/MeshWeaver.Documentation.Test/IdentityReadsRunAsSystemGuard.cs diff --git a/.github/scripts/check-module-platform-floor.py b/.github/scripts/check-module-platform-floor.py index cd0a5def5d..0bcbd4214e 100644 --- a/.github/scripts/check-module-platform-floor.py +++ b/.github/scripts/check-module-platform-floor.py @@ -34,7 +34,8 @@ `src/MeshWeaver.Plugin.Packaging/NuGetVersionComparer.cs` exactly — numeric core parts compared as numbers, a release outranking any pre-release, dot-separated pre-release identifiers compared numerically when both are numeric, numeric ranking below alphanumeric, otherwise ordinal — and -`ModulePlatformFloorScriptParityTest` in `test/Memex.Portal.Shared.Test` pins the two together on +`ModulePlatformFloorScriptParityTest` in MeshWeaver.Plugins' `src/Memex.Portal.Shared.Test` (it reads +this script from the pinned platform checkout) pins the two together on a table of cases. Two call sites computing the same fold differently either never converge or never fire, and both are silent. diff --git a/.github/scripts/release-channels.py b/.github/scripts/release-channels.py index 1d7fd706eb..0dd52ec50c 100644 --- a/.github/scripts/release-channels.py +++ b/.github/scripts/release-channels.py @@ -47,7 +47,7 @@ REQUIRED_FIELDS = ("id", "tag", "edition", "meaning", "writers", "notation", "status") BINDING_FIELDS = ("workflow", "job", "repositories") # A label must look like a tag and must NEVER be SELECTABLE as a version. The self-updater -# (memex/Memex.Portal.Shared/SelfUpdate/VersionSelect.cs) keeps every tag matching +# (MeshWeaver.Plugins src/Memex.Portal.Shared/SelfUpdate/VersionSelect.cs) keeps every tag matching # `PlatformVersionTag` = ^\d+\.\d+\.\d+([-+].*)?$ EXCEPT one ending in `-latest` (`MovingPointer`), so # a label of that dotted shape with any other suffix (`3.1.0-mac`) would be rolled to as if it were # a build. Both regexes are mirrored here; VersionSelectTest pins the C# side. diff --git a/.github/scripts/shard-assign.sh b/.github/scripts/shard-assign.sh index 6593e59ab8..37c6d032ca 100755 --- a/.github/scripts/shard-assign.sh +++ b/.github/scripts/shard-assign.sh @@ -78,7 +78,7 @@ # the numerator instead — PluginCatalog's two parts run ~174 s each, ~36% of # the cap — and it costs nothing, because the parts land on different runners. # -# Memex.Portal.Shared.Test was the next one: listed at 25 s, it measured 431 s solo on +# Memex.Portal.Shared.Test (now in MeshWeaver.Plugins) was the next one: listed at 25 s, it measured 431 s solo on # run 36816633012 (90% of the cap) and was killed at 480 s exit=124 on run 36817124160, # still progressing through its classes, once #5894 added ~28 s of deliberate waits. It # now runs in three parts: the two parts measured 327 and 324 s on @@ -153,7 +153,6 @@ fi # " ", heaviest first. WEIGHTS=$(cat <<'EOF' -651 Memex.Portal.Shared.Test 3 269 MeshWeaver.Hosting.Orleans.Test 91 MeshWeaver.Data.Test 435 MeshWeaver.Messaging.Hub.Test 2 diff --git a/.github/workflows/dotnet-test.yml b/.github/workflows/dotnet-test.yml index 744f61ba0a..c0c1798a78 100644 --- a/.github/workflows/dotnet-test.yml +++ b/.github/workflows/dotnet-test.yml @@ -927,8 +927,8 @@ jobs: # 🚨 This job used to pre-pull `pgvector/pgvector:pg17` from Docker Hub, anonymously, on every # shard. Since the Postgres suites left core (the test-move tranches, #2847 and its successors) # NO core suite starts a container: nothing references Testcontainers.PostgreSql, and the one - # project naming MeshWeaver.Testcontainers (Memex.Portal.Shared.Test) exercises readiness logic - # against an in-process host. So the pull fetched an image nothing used — and on 2026-10-09 it + # project that named MeshWeaver.Testcontainers (Memex.Portal.Shared.Test, now in + # MeshWeaver.Plugins) exercises readiness logic against an in-process host. So the pull fetched an image nothing used — and on 2026-10-09 it # was the ONLY red of six shards × several PRs (#6381 among them), Docker Hub answering # `toomanyrequests` to the runner pool's shared egress IP. Retrying a per-IP window is not a # fix; not asking is. diff --git a/MeshWeaver.slnx b/MeshWeaver.slnx index 5a04bf2695..7f999446b3 100644 --- a/MeshWeaver.slnx +++ b/MeshWeaver.slnx @@ -14,7 +14,6 @@ - @@ -93,7 +92,6 @@ - diff --git a/memex/Memex.Portal.Shared/Api/BoundedBody.cs b/memex/Memex.Portal.Shared/Api/BoundedBody.cs deleted file mode 100644 index 750007f0a3..0000000000 --- a/memex/Memex.Portal.Shared/Api/BoundedBody.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Text; - -namespace Memex.Portal.Shared.Api; - -/// -/// Reads a request body up to a hard byte cap, and says so when the cap is exceeded. -/// -/// 🚨 WebhookInboxEndpoints checked Content-Length and then read the body with a -/// plain StreamReader.ReadToEndAsync() — while a comment beside it described "the capped -/// reader below" that did not exist (#2302). A chunked request carries no Content-Length, so that -/// path buffered an unbounded body into memory on a PUBLIC endpoint. This is the reader the comment -/// promised. Pure over a so it is unit-tested with a MemoryStream. -/// -public static class BoundedBody -{ - /// - /// The body as UTF-8 text, or null if it exceeds . Reads at - /// most maxBytes + 1 bytes, so an oversized body is refused without being buffered. - /// - public static async Task ReadAsync(Stream body, long maxBytes, CancellationToken ct) - { - var buffer = await ReadBufferAsync(body, maxBytes, ct); - return buffer is null ? null : Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length); - } - - /// - /// The body as RAW BYTES, or null if it exceeds . Same reader, - /// same cap. - /// - /// 🚨 Byte-exact, and that is the point: a signature is computed over the bytes GitHub - /// sent. Reading through and re-encoding the string would round-trip - /// through UTF-8, and any byte sequence that is not valid UTF-8 comes back as U+FFFD — a body - /// that verifies at the sender would fail here, and worse, one crafted around the substitution - /// could differ from what was verified. Signature verification must never see a decoded copy. - /// - public static async Task ReadBytesAsync(Stream body, long maxBytes, CancellationToken ct) - { - var buffer = await ReadBufferAsync(body, maxBytes, ct); - return buffer?.ToArray(); - } - - /// - /// The one reader both public forms share, so the cap semantics cannot drift apart. - /// - /// 🚨 A client that aborts mid-body is NOT the same outcome as an oversized one, and the - /// two must never be folded together. Over the cap returns null, which both call sites - /// answer with 413 Payload Too Large. A truncated delivery instead surfaces Kestrel's - /// ("Unexpected end of request - /// content"), which is a 400 — catching it here and returning null would tell a - /// caller whose connection dropped that its payload was too large, and log a cap warning about - /// a body that was never over the cap (#4860). The exception is deliberately left to propagate - /// so the distinction survives to whoever decides the status code. - /// - private static async Task ReadBufferAsync(Stream body, long maxBytes, CancellationToken ct) - { - var buffer = new MemoryStream(); - var chunk = new byte[16 * 1024]; - while (true) - { - // 🚨 Ask for no more than the remaining budget PLUS ONE byte. A full-chunk request let the - // socket read overshoot the cap by up to a chunk before this returned null — the buffer - // never exceeded the cap, but the stated contract ("at most max+1 bytes READ") was not what - // the code did. Caught in review. The +1 is what detects "over the cap" without holding it. - var remaining = maxBytes + 1 - buffer.Length; - var want = (int)Math.Min(chunk.Length, remaining); - var read = await body.ReadAsync(chunk.AsMemory(0, want), ct); - if (read == 0) break; - if (buffer.Length + read > maxBytes) return null; - buffer.Write(chunk, 0, read); - } - return buffer; - } -} diff --git a/memex/Memex.Portal.Shared/Api/ConsumerInventoryEndpoints.cs b/memex/Memex.Portal.Shared/Api/ConsumerInventoryEndpoints.cs deleted file mode 100644 index 14abf340e8..0000000000 --- a/memex/Memex.Portal.Shared/Api/ConsumerInventoryEndpoints.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// POST /api/instances/inventory — a consumer delivers its to -/// the plugin registry it pulls from (#6989). -/// -/// A registry serves each consumer bundles keyed on the framework identity that consumer runs -/// and adopts, out of the registry's OWN prebuilt store, and the store's retention may not delete an -/// identity a consumer still uses. Its expected consumers are the Hosting/Deployment records -/// that mount it; until this route existed their reports all went to the control instance, so the -/// registry's retention refused on every pass and the shared volume was never reclaimed. -/// -/// 🚨 The caller's registry credential is the whole authorization, and it decides the -/// key. The report is filed under the instance id the credential AUTHENTICATED as -/// (), never under the deployment the body names: -/// a report can only protect MORE identities, and the one way it could do harm — making another -/// instance's record read as freshly reported — is closed by binding the key to the sender. The -/// write itself runs as System (), after the -/// check. Mapped by MapInstanceRegistration, so every host that registers instances accepts -/// their reports. -/// -public static class ConsumerInventoryEndpoints -{ - /// Maps the consumer-report route. - /// The route builder. - /// The same builder, for chaining. - public static IEndpointRouteBuilder MapConsumerInventory(this IEndpointRouteBuilder endpoints) - { - endpoints.MapPost(DeploymentReportService.StoreInboxRoute, Receive).AllowAnonymous(); - return endpoints; - } - - private static Task Receive( - HttpContext http, IMessageHub rootHub, DeploymentReport? body, CancellationToken ct) - { - var logger = http.RequestServices.GetService() - ?.CreateLogger(typeof(ConsumerInventoryEndpoints)); - if (body is null || !string.Equals(body.Event, DeploymentReportService.EventName, StringComparison.Ordinal)) - return Task.FromResult(Results.Json( - new { error = $"the body must be a module inventory report (event: {DeploymentReportService.EventName})." }, - statusCode: StatusCodes.Status400BadRequest)); - - var authenticator = http.RequestServices.GetRequiredService(); - return authenticator.AuthenticateOutcome(http.Request.Headers.Authorization.ToString()) - .SelectMany(outcome => - { - if (outcome.IsUnavailable) - return Observable.Return(InstanceAuthResponses.Unavailable(http, outcome.UnavailableReason, logger)); - if (outcome.Instance?.Instance.InstanceId is not { Length: > 0 } instanceId) - return Observable.Return(Results.Json( - new { error = "A registered instance key (or its token) is required. Nothing was filed." }, - statusCode: StatusCodes.Status401Unauthorized)); - return DeploymentReportService.FileStoreReport(rootHub, instanceId, body) - .Do(path => logger?.LogInformation( - "Consumer report from registered instance {InstanceId} (deployment {Deployment}) filed at {Path}", - instanceId, body.Deployment, path)) - .Select(path => Results.Json(new { path, registeredInstance = instanceId })); - }) - .Catch((Exception ex) => - { - logger?.LogWarning(ex, "Consumer report could not be filed"); - return Observable.Return(Results.Json( - new { error = ex.Message }, statusCode: StatusCodes.Status502BadGateway)); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, "Consumer report faulted after the response had already been sent"), - ct)!; - } -} diff --git a/memex/Memex.Portal.Shared/Api/ControlLaneEndpoints.cs b/memex/Memex.Portal.Shared/Api/ControlLaneEndpoints.cs deleted file mode 100644 index 1828ae0f07..0000000000 --- a/memex/Memex.Portal.Shared/Api/ControlLaneEndpoints.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Graph.ControlLane; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// The TARGET's control-lane endpoint: POST /api/control-lane hands one signed request to -/// and answers its verdict as the status code -/// () — 202 accepted, 401 bad signature, 403 wrong -/// deployment, 409 replay, 410 expired, 422 refused, 503 not armed. Anonymous by design: the -/// signature IS the authentication, verified before a single field is read (Doc/Architecture/ControlLane). -/// -/// An ACCEPTED answer's body is signed with the same key (X-Hub-Signature-256), so the -/// control instance can tell an acceptance by this deployment from a 2xx by anything else in the -/// path. Refusals are not signed: they carry no authority. -/// -public static class ControlLaneEndpoints -{ - /// Maps POST /api/control-lane. Call alongside MapWebhookInbox. - public static IEndpointRouteBuilder MapControlLane(this IEndpointRouteBuilder endpoints) - { - endpoints.MapPost(ControlLaneClient.EndpointRoute, - (HttpRequest request, HttpResponse response, IMessageHub hub, IConfiguration config, CancellationToken ct) => - Receive(request, response, hub, config, ct)) - .AllowAnonymous(); - return endpoints; - } - - // The sanctioned Task boundary (a minimal-API handler): read the bounded body, receive, answer. - private static async Task Receive( - HttpRequest request, HttpResponse response, IMessageHub hub, IConfiguration config, CancellationToken ct) - { - var logger = hub.ServiceProvider.GetService()?.CreateLogger(typeof(ControlLaneEndpoints)); - var receiver = hub.ServiceProvider.GetService(); - if (receiver is null) - { - logger?.LogError("[ControlLane] a delivery arrived but this host does not register the control lane (AddControlLane)"); - return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); - } - if (request.ContentLength is > MeshWeaver.Graph.Configuration.WebhookInbox.MaxBodyBytes) - return Results.StatusCode(StatusCodes.Status413PayloadTooLarge); - string? body; - try - { - body = await BoundedBody.ReadAsync(request.Body, MeshWeaver.Graph.Configuration.WebhookInbox.MaxBodyBytes, ct); - } - catch (BadHttpRequestException ex) - { - return Results.StatusCode(ex.StatusCode); - } - if (body is null) - return Results.StatusCode(StatusCodes.Status413PayloadTooLarge); - - var signature = request.Headers[ControlLaneWire.SignatureHeader].ToString(); - var receipt = (await receiver.Receive(body, signature) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, "[ControlLane] a delivery faulted after its answer had already been sent"), - ct))!; - var answer = ControlLaneReceiver.Answer(receipt, config); - if (answer.Signature is { } signed) - response.Headers[ControlLaneWire.SignatureHeader] = signed; - return Results.Content(answer.Body, "application/json", statusCode: answer.StatusCode); - } -} diff --git a/memex/Memex.Portal.Shared/Api/InstanceAuthResponses.cs b/memex/Memex.Portal.Shared/Api/InstanceAuthResponses.cs deleted file mode 100644 index 684cbd3c04..0000000000 --- a/memex/Memex.Portal.Shared/Api/InstanceAuthResponses.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Globalization; -using System.Reactive.Linq; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// The ONE answer every instance-key-gated endpoint gives when the registry could not FIND OUT -/// whether a presented key is valid (#2695). -/// -/// 🚨 It is not a 401, and the difference is the caller's entire next move. A 401 says "your -/// key is unknown", which sends an operator hunting for a missing registration or grant — precisely -/// the wrong hunt when the grant was there all along and one read was slow. That is not -/// hypothetical: MeshWeaver.Crm's gate (run 33269921011) read a transient 401 as "this instance -/// needs a whole-source grant" while `Admin/_PluginGrant/ci-crm` sat unchanged, and a re-run minutes -/// later passed with nothing altered anywhere. -/// -/// Shared rather than copied into each endpoint on purpose: four endpoints gate on the same -/// authenticator, and a distinction that only three of them make is a distinction callers cannot -/// rely on. Mirrors the identity side's 503 + Retry-After (#637), including the retry -/// budget, so a client sees one convention across both legs. -/// -internal static class InstanceAuthResponses -{ - /// 503 + Retry-After, logged with the reason and the path. - internal static IResult Unavailable(HttpContext http, string? reason, ILogger? logger) - { - logger?.LogWarning( - "Instance-key resolution UNAVAILABLE for {Path} ({Reason}) — answering 503 + Retry-After, " - + "NOT 401: nothing was established about the presented key", - http.Request.Path, reason ?? "no reason given"); - http.Response.Headers.RetryAfter = - InstanceRegistryAuthenticator.RetryAfterSeconds.ToString(CultureInfo.InvariantCulture); - return Results.Json( - new - { - error = "Instance-key resolution is temporarily unavailable — retry shortly. " - + "This is NOT a statement about your key or your grant.", - }, - statusCode: StatusCodes.Status503ServiceUnavailable); - } - - /// - /// 🚨 The answer for "the key was fine, but the mesh READ behind the route could not be - /// answered" — a stalled query provider (, - /// policy query-fanin-stall-terminal) or a transient store connect fault. The same - /// 503 + Retry-After convention as , and for the same reason: the - /// fault says, in its own text, that it is an availability failure and retryable, so the wire - /// has to say so too. - /// - /// Before this, such a fault escaped the route as an UNHANDLED exception: ASP.NET's - /// exception middleware answered a bare 500 with no Retry-After and logged the stall as - /// "An unhandled exception has occurred while executing the request" (MeshWeaver#5345) — a - /// defect report aimed at the endpoint for a read the endpoint had no part in stalling. The - /// cause stays logged here, with the exception, naming the route; it is the stall's own - /// reporters (the fan-in's probe, the silo's health monitor) that attribute it. - /// - /// The request — the 503 carries Retry-After on its response. - /// The availability fault the route's read terminated with. - /// Diagnostic sink, resolved while the request scope was alive. - /// The body's error text, naming what this ROUTE could not read; the - /// registry's catalogue wording when omitted. - internal static IResult ReadUnavailable( - HttpContext http, Exception cause, ILogger? logger, string? error = null) - { - logger?.LogWarning(cause, - "Mesh read behind {Path} is UNAVAILABLE — answering 503 + Retry-After rather than an " - + "unhandled 500: the read produced no answer, which says nothing about the request", - http.Request.Path); - http.Response.Headers.RetryAfter = - InstanceRegistryAuthenticator.RetryAfterSeconds.ToString(CultureInfo.InvariantCulture); - return Results.Json( - new { error = error ?? "The registry could not read its catalogue just now — retry shortly." }, - statusCode: StatusCodes.Status503ServiceUnavailable); - } - - /// - /// Maps an availability fault on a route's answer to , and lets - /// every OTHER fault through unchanged — a real defect keeps surfacing as one. The rule is - /// , the one definition - /// the GUI already classifies the same faults by. - /// - /// The route's answer. - /// The request. - /// Diagnostic sink, resolved while the request scope was alive. - /// The 503 body's error text (see ). - internal static IObservable UnavailableOnAStalledRead( - this IObservable answer, HttpContext http, ILogger? logger, string? error = null) => - answer.Catch(ex => - MeshWeaver.Layout.AreaErrorClassifier.IsStorageUnavailable(ex) - ? Observable.Return(ReadUnavailable(http, ex, logger, error)) - : Observable.Throw(ex)); -} diff --git a/memex/Memex.Portal.Shared/Api/InstanceKeyEndpoints.cs b/memex/Memex.Portal.Shared/Api/InstanceKeyEndpoints.cs deleted file mode 100644 index 8bf0a1b525..0000000000 --- a/memex/Memex.Portal.Shared/Api/InstanceKeyEndpoints.cs +++ /dev/null @@ -1,204 +0,0 @@ -using System.Reactive.Linq; -using System.Text.Json; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// The wire shape of the registry's key-lifecycle surface () — the -/// routes and the two JSON bodies. The consumer is the hosting operator's shell -/// (hosting-kv-rotate, hosting-registry-key), which reads instanceId and -/// key with jq; renaming a field here breaks it silently, so the names are pinned by -/// InstanceKeyRotationTest. -/// -public static class InstanceKeyPayloads -{ - /// GET — which instance the presented key belongs to, and which of its slots. - public const string SelfRoute = "/api/instances/self"; - - /// POST — stage the next key's hash. - public const string StageRoute = "/api/instances/self/key/stage"; - - /// POST — commit the staged key; must present the staged key. - public const string CommitRoute = "/api/instances/self/key/commit"; - - /// POST — revoke the presented key without a successor. - public const string RevokeRoute = "/api/instances/self/key/revoke"; - - /// The slot word for the current key. - public const string CurrentKey = "current"; - - /// The slot word for a staged key. - public const string StagedKey = "staged"; - - /// The body of a stage request: the lowercase SHA-256 hex of the new raw key — never the key. - /// 64 lowercase hex characters. - public sealed record StageRequest(string KeyHash); - - /// The answer of every key endpoint. - /// The instance the presented key belongs to. - /// The slot the PRESENTED key occupies after the call (, - /// ), or revoked. - /// Whether a staged key is outstanding after the call. - public sealed record State(string InstanceId, string Key, bool Staged); - - /// The serializer the endpoints answer with — camelCase, so the shell reads .instanceId. - public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); -} - -/// -/// The registry's key-lifecycle surface — how an instance's plugin-registry key is ROTATED and -/// REVOKED at the registry itself (MeshWeaver#2802). -/// -/// -/// GET /api/instances/selfwhich instance the presented key is, -/// and whether it is its current or its staged key -/// POST /api/instances/self/key/stagestage the next key's HASH; -/// both keys then authenticate -/// POST /api/instances/self/key/commitpresenting the STAGED key, -/// promote it and retire the old one -/// POST /api/instances/self/key/revokestop the presented key -/// authenticating, with no successor -/// -/// -/// 🚨 Why it lives HERE, on the registry, and not in the control plane. The first rotation -/// design adopted the new hash through whatever the control -/// instance's hub happened to resolve — and every Memex portal registers one, while the instances -/// live only in the registry's store. On the control instance the adoption therefore ran against a -/// store that does not hold the instance, AFTER the operator had already written the new key to Key -/// Vault. Serving the lifecycle from the registry makes the question "which store?" unaskable: the -/// store that answers is the one that authenticated the key. -/// -/// 🚨 Authorised by POSSESSION, never by a standing credential. Every call presents a key of -/// the instance (Authorization: Bearer mwi_…) and may act only on THAT instance, in the slot -/// that key occupies (). There is deliberately no admin token that -/// can re-key someone else's instance: the keys this protocol replaces sat in plaintext pod specs -/// (MeshWeaver#3201), and a credential that could re-key the whole fleet is the worst thing such a -/// leak could have carried. A short-lived mwa_ token is refused here — it may read the -/// catalog, never re-key or revoke. Only hashes cross in the other direction. -/// -public static class InstanceKeyEndpoints -{ - /// Maps the key-lifecycle routes. Called by MapInstanceRegistration, so every - /// host that registers instances also lets them rotate. - /// The route builder. - /// The same builder, for chaining. - public static IEndpointRouteBuilder MapInstanceKeyRotation(this IEndpointRouteBuilder endpoints) - { - endpoints.MapGet(InstanceKeyPayloads.SelfRoute, Self).AllowAnonymous(); - endpoints.MapPost(InstanceKeyPayloads.StageRoute, Stage).AllowAnonymous(); - endpoints.MapPost(InstanceKeyPayloads.CommitRoute, Commit).AllowAnonymous(); - endpoints.MapPost(InstanceKeyPayloads.RevokeRoute, Revoke).AllowAnonymous(); - return endpoints; - } - - private static Task Self(HttpContext http, IMessageHub rootHub, CancellationToken ct) => - WithCaller(http, rootHub, ct, (caller, presented, _) => - Observable.Return(Answer(caller.Instance, presented))); - - private static Task Stage( - HttpContext http, IMessageHub rootHub, InstanceKeyPayloads.StageRequest? body, CancellationToken ct) => - WithCaller(http, rootHub, ct, (caller, presented, instances) => - InstanceKeyRotation.IsKeyHash(body?.KeyHash) - ? instances.StageKeyHash(caller.InstancePath!, presented, body!.KeyHash) - .Select(next => Answer(next, presented)) - : Observable.Return(Results.Json( - new { error = "keyHash must be the lowercase SHA-256 hex of the new raw key (64 hex chars). " - + "Send the HASH — the registry never receives a key." }, - statusCode: StatusCodes.Status400BadRequest))); - - private static Task Commit(HttpContext http, IMessageHub rootHub, CancellationToken ct) => - WithCaller(http, rootHub, ct, (caller, presented, instances) => - instances.CommitStagedKey(caller.InstancePath!, presented) - .Select(next => Answer(next, presented))); - - private static Task Revoke(HttpContext http, IMessageHub rootHub, CancellationToken ct) => - WithCaller(http, rootHub, ct, (caller, presented, instances) => - instances.RevokePresentedKey(caller.InstancePath!, presented) - .Select(next => Results.Json( - new InstanceKeyPayloads.State(next.InstanceId, "revoked", !string.IsNullOrEmpty(next.PendingKeyHash)), - InstanceKeyPayloads.Json))); - - private static IResult Answer(MeshWeaverInstance instance, string presentedHash) => - Results.Json( - new InstanceKeyPayloads.State( - instance.InstanceId, - InstanceKeyRotation.SlotOf(instance, presentedHash) == InstanceKeySlot.Staged - ? InstanceKeyPayloads.StagedKey - : InstanceKeyPayloads.CurrentKey, - !string.IsNullOrEmpty(instance.PendingKeyHash)), - InstanceKeyPayloads.Json); - - /// - /// The one gate every key route passes: a durable instance key in the header (a token is - /// refused), resolved by the registry's own authenticator (unavailable → 503, unknown → 401), - /// then the route's work, with a refused transition answered 409 and nothing else leaking a - /// stack. The key's HASH is what the route works with; the key itself never leaves this method. - /// - private static Task WithCaller( - HttpContext http, IMessageHub rootHub, CancellationToken ct, - Func> work) - { - var logger = http.RequestServices.GetService()?.CreateLogger(typeof(InstanceKeyEndpoints)); - var header = http.Request.Headers.Authorization.ToString(); - var rawKey = InstanceKeys.ExtractKey(header); - if (rawKey is null) - return Task.FromResult(Results.Json( - new { error = "The instance's own key is required (Authorization: Bearer mwi_…). A short-lived " - + "token may read the catalog; it can never re-key or revoke." }, - statusCode: StatusCodes.Status401Unauthorized)); - var presented = InstanceKeys.Hash(rawKey); - - var authenticator = http.RequestServices.GetRequiredService(); - // Request services first, then the mesh's — the same resolution MapInstanceRegistration uses. - var instances = http.RequestServices.GetService() - ?? rootHub.ServiceProvider.GetRequiredService(); - - return authenticator.AuthenticateOutcome(header) - .SelectMany(outcome => - { - if (outcome.IsUnavailable) - return Observable.Return(InstanceAuthResponses.Unavailable(http, outcome.UnavailableReason, logger)); - if (outcome.Instance is not { InstancePath: { Length: > 0 } } caller) - { - // 🚨 THE answer a portal that is not the registry gives — and the one a rotation - // must meet BEFORE anything is minted: this store does not know the key, so it - // holds no instance to rotate. Not "maybe later"; a definitive 401. - logger?.LogWarning( - "Key-lifecycle call {Path} presented an instance key this registry does not accept " - + "(hash prefix {Prefix})", http.Request.Path, InstanceKeys.HashPrefix(presented)); - return Observable.Return(Results.Json( - new { error = "This registry does not accept the presented instance key — it holds no " - + "instance that key belongs to. Nothing was changed." }, - statusCode: StatusCodes.Status401Unauthorized)); - } - return work(caller, presented, instances); - }) - .Catch((InstanceKeyRefusedException ex) => - { - logger?.LogWarning("Key-lifecycle call {Path} refused for {InstanceId}: {Reason}", - http.Request.Path, ex.InstanceId, ex.Reason); - return Observable.Return(Results.Json( - new { error = ex.Reason }, statusCode: StatusCodes.Status409Conflict)); - }) - .Catch((Exception ex) => - { - logger?.LogWarning(ex, "Key-lifecycle call {Path} failed", http.Request.Path); - return Observable.Return(Results.Json( - new { error = ex.Message }, statusCode: StatusCodes.Status502BadGateway)); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Key-lifecycle call faulted after the response had already been sent"), - ct)!; - } -} diff --git a/memex/Memex.Portal.Shared/Api/InstanceRegistrationEndpoints.cs b/memex/Memex.Portal.Shared/Api/InstanceRegistrationEndpoints.cs deleted file mode 100644 index 00f343852f..0000000000 --- a/memex/Memex.Portal.Shared/Api/InstanceRegistrationEndpoints.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Reactive.Linq; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// POST /api/instances/register — first-startup instance auto-registration. A NEW deployment -/// presents a registration bootstrap key (mwr_…, minted by a platform admin) and its desired -/// instance id; the registry creates the — owned by the admin who -/// minted the bootstrap key, exactly as if they had registered it by hand — and returns the -/// instance's own mwi_ key ONCE. PluginCatalog:DefaultGrants seeding applies, so with -/// the platform default configured the new instance can pull Plugins/* immediately. -/// -/// 🚨 The endpoint is anonymous at the ASP.NET layer — the bootstrap key in the body IS the -/// authentication (: hash → index → record, revocation -/// and expiry enforced). An invalid key is 401; a taken id is 409 and never reveals whose; a -/// malformed id is 400. The consumer half is ; the -/// wire shape is so the two cannot drift. -/// -public static class InstanceRegistrationEndpoints -{ - /// Maps the registration endpoint. Call alongside MapPluginRegistry. - public static IEndpointRouteBuilder MapInstanceRegistration(this IEndpointRouteBuilder endpoints) - { - endpoints.MapPost(InstanceRegistrationPayloads.Route, Register).AllowAnonymous(); - // Every host that registers instances also lets them ROTATE and REVOKE their keys, here, on - // the registry that holds them (MeshWeaver#2802) — mapped from this one call so no host can - // serve registration while the key lifecycle 404s. - endpoints.MapInstanceKeyRotation(); - // …and the coupon mint (a plan-bearing, single-use registration key minted into an - // operator's vault without anyone seeing it) — 404 until the registry configures it. - endpoints.MapRegistrationCoupons(); - // …and the consumer reports a registry's prebuilt-bundle retention needs to account for the - // instances it serves (#6989) — mapped here so no registry can serve bundles without hearing - // what its consumers run. - endpoints.MapConsumerInventory(); - return endpoints; - } - - private static Task Register( - HttpContext http, IMessageHub rootHub, InstanceRegistrationPayloads.Request body, CancellationToken ct) - { - var logger = http.RequestServices.GetService() - ?.CreateLogger(typeof(InstanceRegistrationEndpoints)); - // Request services first, then the mesh's — the same two-step resolution the bundle routes - // use for their anchor, so a host (or a test) can wire an instance service with its own - // configuration without a second resolution rule. - var instances = http.RequestServices.GetService() - ?? rootHub.ServiceProvider.GetRequiredService(); - - if (!MeshWeaverInstanceService.IsValidInstanceId(body.InstanceId)) - return Task.FromResult(Results.Json( - new { error = "instanceId must be 3–48 chars: lowercase letters, digits and single " - + "hyphens, not starting or ending with a hyphen." }, - statusCode: StatusCodes.Status400BadRequest)); - - // No key at all is OPEN registration — accepted only where the registry's operator - // configured a key for un-keyed callers (the free plan on memex.meshweaver.cloud), refused - // with the same 401 as an invalid key everywhere else. The caller never learns which. - var open = string.IsNullOrWhiteSpace(body.BootstrapKey); - return (open - ? instances.RegisterOpen(body.InstanceId, body.DisplayName, body.Description, body.HomeUrl, - OwnershipOf(body)) - : instances.RegisterWithBootstrapKey( - body.BootstrapKey, body.InstanceId, body.DisplayName, body.Description, body.HomeUrl, - OwnershipOf(body))) - .Select(registration => (IResult)Results.Json( - new InstanceRegistrationPayloads.Response( - registration.Instance.InstanceId, registration.RawKey) - { - Plan = registration.Instance.Plan ?? PlanTierRanks.BaselinePlan, - }, - InstanceRegistrationPayloads.Json)) - .Catch((InvalidBootstrapKeyException ex) => - { - if (open) - logger?.LogWarning( - "OPEN instance registration rejected for id '{InstanceId}' — this registry " - + "configures no {Key}", body.InstanceId, - MeshWeaverInstanceService.OpenRegistrationKeyConfigKey); - else - logger?.LogWarning( - "Instance registration rejected for id '{InstanceId}' — invalid, revoked, " - + "expired or used-up (a spent coupon) bootstrap key", body.InstanceId); - return Observable.Return((IResult)Results.Json( - new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized)); - }) - .Catch((InstanceIdTakenException ex) => Observable.Return((IResult)Results.Json( - new { error = ex.Message }, statusCode: StatusCodes.Status409Conflict))) - .Catch((Exception ex) => - { - logger?.LogWarning(ex, "Instance registration for '{InstanceId}' failed", body.InstanceId); - return Observable.Return((IResult)Results.Json( - new { error = ex.Message }, statusCode: StatusCodes.Status502BadGateway)); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Instance registration for '{InstanceId}' faulted after the response had already been sent", - body.InstanceId), - ct)!; - } - - /// - /// The ownership the registrant stated, or null when they stated nothing. - /// - /// Null rather than an empty record on purpose: an all-blank ownership must not override - /// the lane's existing owner fallback with emptiness, and InstanceOwnership.IsStated is - /// what draws that line once instead of at every call site. - /// - private static InstanceOwnership? OwnershipOf(InstanceRegistrationPayloads.Request body) - { - var ownership = new InstanceOwnership - { - Company = body.Company, - Name = body.OwnerName, - Email = body.OwnerEmail, - }; - return ownership.IsStated ? ownership : null; - } -} diff --git a/memex/Memex.Portal.Shared/Api/InstanceTokenEndpoints.cs b/memex/Memex.Portal.Shared/Api/InstanceTokenEndpoints.cs deleted file mode 100644 index 2c38b08388..0000000000 --- a/memex/Memex.Portal.Shared/Api/InstanceTokenEndpoints.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// POST /api/instances/token — a registered instance exchanges its durable mwi_ key -/// for a short-lived, scoped mwa_ access token. -/// -/// Why it exists. Without it a consumer must present its long-lived credential on every -/// call, which is the property that makes a standing per-repo PAT unacceptable. With it, a build -/// agent mints a minutes-long token narrowed to the one package it needs, and holds nothing durable -/// beyond the run. -/// -/// 🚨 A token cannot be exchanged for another token. The endpoint accepts ONLY the -/// durable instance key: allowing a token to mint its successor would turn a minutes-long credential -/// into a perpetual one by renewal, defeating the entire point of the expiry. The check is on the -/// credential's SHAPE (mwi_ vs mwa_, disjoint by construction), not on a claim the -/// presenter could edit. -/// -/// The signing key is a MESH NODE (Admin/SyncTokenSigningKey/current), minted on first -/// use and shared by every replica — there is nothing to configure and nothing for an operator to -/// copy between environments. Minting happens only AFTER the caller authenticates, so an anonymous -/// request cannot provoke a node write. -/// -/// The token narrows; it never widens. The effective scope is what the caller asked for -/// intersected with what its sync licence already allows, and the live grant is re-read on every -/// subsequent request — so revoking a licence takes effect at once rather than when the token -/// expires. -/// -public static class InstanceTokenEndpoints -{ - /// Maps the token-exchange endpoint. Call alongside MapPluginRegistry. - /// The route builder. - /// The same builder, for chaining. - public static IEndpointRouteBuilder MapInstanceTokenExchange(this IEndpointRouteBuilder endpoints) - { - endpoints.MapPost(SyncTokenPayloads.Route, Exchange).AllowAnonymous(); - return endpoints; - } - - private static Task Exchange( - HttpContext http, IMessageHub rootHub, SyncTokenPayloads.Request? body, CancellationToken ct) - { - var logger = http.RequestServices.GetService() - ?.CreateLogger(typeof(InstanceTokenEndpoints)); - - var header = http.Request.Headers.Authorization.ToString(); - - // Shape check FIRST — a token may never mint its successor (see the type remarks). - if (InstanceKeys.ExtractKey(header) is null) - { - var wasToken = SyncAccessToken.ExtractToken(header) is not null; - if (wasToken) - logger?.LogWarning( - "A sync access token was presented to the exchange endpoint — only the durable " - + "instance key may mint a token."); - return Task.FromResult(Results.Json( - new { error = wasToken - ? "A short-lived token cannot be exchanged for another token. Present the " - + "instance key (Authorization: Bearer mwi_…)." - : "A registered instance key is required (Authorization: Bearer mwi_…)." }, - statusCode: StatusCodes.Status401Unauthorized)); - } - - var authenticator = http.RequestServices.GetRequiredService(); - var keys = rootHub.ServiceProvider.GetRequiredService(); - var now = DateTimeOffset.UtcNow; - - // 🚨 Authenticate BEFORE touching the signing key. Resolve() mints this registry's key if it - // has none, and minting is a node write — so doing it first would let an unauthenticated - // caller provoke one. The order is the access control. - return authenticator.AuthenticateOutcome(header) - .SelectMany(outcome => - { - if (outcome.IsUnavailable) - return Observable.Return(InstanceAuthResponses.Unavailable(http, outcome.UnavailableReason, logger)); - - var caller = outcome.Instance; - if (caller is null) - return Observable.Return(Results.Json( - new { error = "A registered instance key is required (Authorization: Bearer mwi_…)." }, - statusCode: StatusCodes.Status401Unauthorized)); - - var effective = EffectiveScope(caller, body?.Scope, now); - - // A caller that ASKED for something its licence does not cover is told so (403) - // rather than handed a token that will 404 on every call. A caller that asked for - // nothing in particular gets an UNNARROWED token — identity only — even when its - // grant is empty today: since #2804 the token is what every registry call carries, - // authority is re-decided per request against the live grant and the live plan on - // the record, and a freshly registered instance must be able to authenticate before - // an admin has granted it anything. - if (body?.Scope is { Count: > 0 } && effective.Count == 0) - return Observable.Return(Results.Json( - new { error = "This instance holds no current licence for the requested scope." }, - statusCode: StatusCodes.Status403Forbidden)); - - var lifetime = body?.LifetimeSeconds is > 0 - ? TimeSpan.FromSeconds(body.LifetimeSeconds.Value) - : SyncAccessToken.DefaultLifetime; - - // The key is a MESH NODE, minted once per registry and shared by every replica — no - // configuration, and nothing for an operator to copy between environments. - return keys.Resolve().Select(material => - { - var token = SyncAccessToken.Mint( - // Bound to the key the caller PRESENTED — during a rotation that may be the - // staged key, which the commit promotes, so the token outlives the commit - // instead of dying with the key it was not minted from (MeshWeaver#2802). - caller.Instance.InstanceId, caller.PresentedKeyHash ?? caller.Instance.KeyHash, effective, now, lifetime, - material.Current, Issuer(http)); - var claims = SyncAccessToken.Verify(token, now, material.Current)!; - - logger?.LogInformation( - "Minted a sync access token for {InstanceId}, scope [{Scope}], expiring {Expiry}", - caller.Instance.InstanceId, string.Join(", ", effective), claims.ExpiresAt); - - return Results.Json( - new SyncTokenPayloads.Response( - token, SyncAccessToken.Scheme, - (int)(claims.ExpiresAt - now).TotalSeconds, effective), - SyncTokenPayloads.Json); - }); - }) - .Catch((Exception ex) => - { - logger?.LogWarning(ex, "Sync access token exchange failed"); - return Observable.Return(Results.Json( - new { error = ex.Message }, statusCode: StatusCodes.Status502BadGateway)); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Sync access token exchange faulted after the response had already been sent"), - ct)!; - } - - /// The iss claim: this registry as the caller reached it. Informational — the - /// signature is what verifies — and what a second-issuer verifier (#2483) keys its trust on. - private static string Issuer(HttpContext http) => - http.Request.Host.HasValue ? $"{http.Request.Scheme}://{http.Request.Host}" : ""; - - /// - /// What the token will actually carry: the caller's CURRENT licence entries, narrowed to what - /// was requested. - /// - /// Expired and revoked entries are dropped here, so a token is never minted for a licence - /// that has already ended — the grant would refuse it on use anyway, and handing out a token - /// that cannot work is a worse answer than saying so. - /// - internal static IReadOnlyCollection EffectiveScope( - AuthenticatedInstance caller, IReadOnlyCollection? requested, DateTimeOffset now) - { - if (caller.Grant.IsRevoked) - return []; - - var licensed = caller.Grant.Entries - .Where(e => e.IsValidAt(now)) - .ToList(); - - if (requested is not { Count: > 0 }) - return [.. licensed.Select(e => e.ToString()).Distinct(StringComparer.Ordinal)]; - - // Keep only what the licence actually covers, INTERSECTING rather than filtering. Dropping - // what is not covered rather than refusing it: a token can only narrow, so an over-broad - // request is a stale caller, not an attack. - // - // 🚨 A whole-source request must EXPAND to the licensed packages in that source, not be - // matched against them. `Plugins/*` is the natural way to say "everything I am licensed for - // here", and a naive `licensed.Any(l => l.Matches(source, "*"))` answers false for an - // instance licensed per-package — so the effective scope came out EMPTY and the exchange - // returned 403 to a caller asking a perfectly reasonable question (Copilot, #1844). - var effective = new List(); - foreach (var entry in requested.Select(PluginGrantEntry.TryParse).Where(e => e is not null)) - { - if (entry!.PackageId == PluginGrantEntry.AllPackages) - { - // Everything licensed in that source — which may itself be a whole-source licence - // (kept as `Source/*`) or a set of per-package ones (kept individually). - effective.AddRange(licensed - .Where(l => string.Equals(l.Source, entry.Source, StringComparison.OrdinalIgnoreCase)) - .Select(l => l.ToString())); - continue; - } - - // Emit the LICENCE's source casing, not the request's — the response describes what was - // granted rather than echoing what was typed, and the two must not disagree with the - // wildcard branch above. The PACKAGE stays the requested one: taking the licence's would - // turn an exact request under a whole-source licence back into `Source/*`, widening the - // very scope the caller asked to narrow. - var match = licensed.FirstOrDefault(l => l.Matches(entry.Source, entry.PackageId)); - if (match is not null) - effective.Add($"{match.Source}/{entry.PackageId}"); - } - - return [.. effective.Distinct(StringComparer.Ordinal)]; - } -} diff --git a/memex/Memex.Portal.Shared/Api/MeshApiEndpoints.cs b/memex/Memex.Portal.Shared/Api/MeshApiEndpoints.cs deleted file mode 100644 index 061b0c5ab4..0000000000 --- a/memex/Memex.Portal.Shared/Api/MeshApiEndpoints.cs +++ /dev/null @@ -1,581 +0,0 @@ -// 🚨 MeshOperations lives in ASSEMBLY MeshWeaver.Mesh.Operations but keeps NAMESPACE -// MeshWeaver.AI, and that mismatch is deliberate (#2370): the type is reached by already-published -// modules through [assembly: TypeForwardedTo] (src/MeshWeaver.AI/TypeForwards.cs), and a forwarder -// cannot rename — so the namespace is frozen at whatever it was when those modules were built. Do -// not "tidy" the using below to match the assembly name; that is what broke main at 17:26. -using MeshWeaver.Mesh; -using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; -using System.Text.Json; -using MeshWeaver.Hosting.AspNetCore.Portal; // PortalApplication -using MeshWeaver.Hosting.AspNetCore; // SessionHubResolver, McpConfiguration -using MeshWeaver.Mesh.Security; // WellKnownUsers -using MeshWeaver.Mesh.Services; -using MeshWeaver.AI; // MeshOperations — the TYPE ships in - // MeshWeaver.Mesh.Operations, but its namespace is a - // FROZEN binary contract (#2370), so this import is - // load-bearing and is NOT an AI-assembly dependency. - // Removed as a 'phantom' in #2349 and restored here. -using MeshWeaver.Messaging; // AccessService -using Memex.Portal.Shared.Authentication; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Memex.Portal.Shared.Api; - -/// -/// REST surface for the mesh — a transport-mirror of McpMeshPlugin. -/// -/// -/// Every endpoint is a thin wrapper over (the same -/// shared core that backs the MCP tools), so REST and MCP cannot drift: a change -/// to a verb's semantics happens once, in MeshOperations, and both -/// transports inherit it. -/// -/// -/// -/// Auth: mutating verbs are gated by McpAuthenticationExtensions.PolicyName — -/// same Authorization: Bearer mw_… token format as /mcp, validated by -/// ApiTokenAuthenticationHandler. The READ-ONLY verbs additionally accept the -/// portal's own session cookie (McpAuthenticationExtensions.ReadPolicyName) so a -/// server-side renderer holding the user's cookie never has to mint an API token — every -/// mint writes two permanent mesh nodes, which made ordinary page traffic grow a user's -/// partition without bound (issue #1477). -/// -/// -/// -/// Session hub: each request resolves a per-caller hosted hub via -/// (shared with the MCP plugin), so REST callers -/// get the same routing semantics that MCP already has — kernel dispatch, workspace -/// isolation, response routing back to the caller's stream. -/// -/// -/// -/// Shape: RPC-mirror — POST /api/mesh/<verb> with JSON body, 1:1 -/// with MCP tool names. Multipart for binary upload. -/// -/// -public static class MeshApiEndpoints -{ - public const string RoutePrefix = "/api/mesh"; - - /// - /// Maps the /api/mesh/* endpoint group. Call after UseAuthentication / - /// UseAuthorization, alongside MapMeshMcp. - /// - public static IEndpointRouteBuilder MapMeshApi(this IEndpointRouteBuilder endpoints) - { - // Bearer-only — EVERY verb that mutates, compiles, executes or uploads. A session - // cookie must never be able to drive one of these (see ReadPolicyName's remarks). - var group = endpoints.MapGroup(RoutePrefix) - .RequireAuthorization(Memex.Portal.Shared.Authentication.McpAuthenticationExtensions.PolicyName); - - // Cookie-OR-Bearer — the READ-ONLY subset the portal-next server renderer needs to - // paint a page for an already-signed-in visitor. 🚨 Only pure reads belong here; - // moving a verb into this group is a security decision, not a convenience. - var reads = endpoints.MapGroup(RoutePrefix) - .RequireAuthorization(Memex.Portal.Shared.Authentication.McpAuthenticationExtensions.ReadPolicyName); - - reads.MapPost("/get", (HttpContext http, IMessageHub rootHub, GetBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Get(body.Path))); - - // Who is calling — the caller's resolved mesh identity ({userId, name, email}), or a - // null userId when the request resolved to no real user. Replaces "mint a token and - // read the home partition out of its nodePath" as the SSR's way of learning whose - // dashboard to render: a read, answered from the identity the request already carries. - // - // With a `path` in the body it ALSO answers what the caller may do there (MeshWeaver#5189): - // access is per node, so the question is asked at an address and answered by the same - // evaluator the gates use. No body (the SSR's call) is the identity-only answer, unchanged. - reads.MapPost("/whoami", (HttpContext http, IMessageHub rootHub, WhoAmIBody? body, CancellationToken ct) => - AsksForAccess(body, out var path) - ? RunString(http, rootHub, ct, ops => ops.WhoAmI(path)) - : Task.FromResult(HandleWhoAmI(http))); - - // The caller opened a page — the access log "last used" orders by (MeshOperations.Visit). - // Bearer-only: it writes, if only to the caller's own partition. - group.MapPost("/visit", (HttpContext http, IMessageHub rootHub, PathBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Visit(body.Path))); - - group.MapPost("/search", (HttpContext http, IMessageHub rootHub, SearchBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Search(body.Query, body.BasePath))); - - // Plugin registry — memex as the distribution point. `/catalog` lists installable plugins - // (partitions that ship NodeTypes); `/catalog/download` returns a plugin's definition - // (Space + NodeTypes + Source/Test Code + docs) as {name, nodeCount, nodes:[…]} for a - // consumer to import via `/update` — no GitHub creds on the consumer, the credential stays - // here, encapsulated in the registry (npm/NuGet-style). - group.MapPost("/catalog", (HttpContext http, IMessageHub rootHub, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Catalog())); - - group.MapPost("/catalog/download", (HttpContext http, IMessageHub rootHub, CatalogDownloadBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.CatalogDownload(body.Plugin))); - - group.MapPost("/create", (HttpContext http, IMessageHub rootHub, CreateBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Create(body.Node))); - - group.MapPost("/update", (HttpContext http, IMessageHub rootHub, UpdateBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Update(body.Nodes))); - - group.MapPost("/patch", (HttpContext http, IMessageHub rootHub, PatchBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Patch(body.Path, body.Fields))); - - group.MapPost("/delete", (HttpContext http, IMessageHub rootHub, DeleteBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Delete(body.Paths))); - - group.MapPost("/move", (HttpContext http, IMessageHub rootHub, MoveBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Move(body.SourcePath, body.TargetPath))); - - group.MapPost("/copy", (HttpContext http, IMessageHub rootHub, CopyBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Copy(body.SourcePath, body.TargetNamespace, body.Force))); - - // 🚨 RecycleBody, not the shared PathBody (MeshWeaver#4782). `reason` belongs to recycle - // alone — /compile and /diagnostics bind the same shape and have no use for one, and a field - // that means nothing on two of three routes is a field callers guess about. `Reason` is - // nullable with a default, so `{"path":"…"}` — what `mw recycle` has always posted — binds - // with Reason null, which is the value that selects the framework's self-describing fallback. - group.MapPost("/recycle", (HttpContext http, IMessageHub rootHub, RecycleBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Recycle(body.Path, body.Reason))); - - group.MapPost("/compile", (HttpContext http, IMessageHub rootHub, PathBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Compile(body.Path))); - - group.MapPost("/diagnostics", (HttpContext http, IMessageHub rootHub, PathBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.GetDiagnostics(body.Path))); - - group.MapPost("/execute-script", (HttpContext http, IMessageHub rootHub, ExecuteScriptBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.ExecuteScript(body.Path, body.TimeoutSeconds ?? 120))); - - // Runs a node's Tests area as an ACTIVITY (one area subscription for the whole run) and - // answers {status, activityPath} at once — `memex tests` polls the activity, never the area, - // because every render of a Tests area runs the suite again. - group.MapPost("/run-tests", (HttpContext http, IMessageHub rootHub, ExecuteScriptBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.RunTests(body.Path, body.TimeoutSeconds ?? MeshOperations.DefaultRunTestsSeconds))); - - // First-full-frame render of a layout area — the SSR seeding verb (portal-next): - // returns {areas, data} EXACTLY as the sync-stream wire delivers it. Read-only, and - // on the cookie-or-Bearer policy: this is the verb an SSR page render is FOR. - reads.MapPost("/render-area", (HttpContext http, IMessageHub rootHub, RenderAreaBody body, CancellationToken ct) => - HandleRenderArea(http, rootHub, body, ct)); - - // Server-side Markdig render — the ONE markdown parser (the twin of the Blazor MarkdownView - // pipeline). Returns {html, codeSubmissions}: the HTML keeps the executable-code-cell, - // mermaid, @@-embed and __KERNEL_ADDRESS__ markers so the React client hydrates them into - // live views instead of falling back to its limited local parser (which escapes raw-HTML - // blocks — doc hero banners — into literal source text). - group.MapPost("/render-markdown", (HttpContext http, IMessageHub rootHub, RenderMarkdownBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.RenderMarkdown(body.Markdown, body.NodePath))); - - // GUI-shell verbs (portal-next chrome) — the browser twins of the reads the Blazor shell - // gets in-process: full-node query (search-bar suggestions, notification bell) and URL→ - // (node, area) navigation resolution. - group.MapPost("/query-nodes", (HttpContext http, IMessageHub rootHub, QueryNodesBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.QueryNodes(body.Query, body.Limit ?? 50))); - - reads.MapPost("/resolve", (HttpContext http, IMessageHub rootHub, PathBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.Resolve(body.Path))); - - // Content-collection directory listing — the read half of the React FileBrowser. Path is - // "{node}/{collection}[/{dir}]"; download uses the existing /content|/api/content URLs, add uses - // /upload. Returns { collection, path, editable, items:[…] } (or "Error: …"). - group.MapPost("/content/list", (HttpContext http, IMessageHub rootHub, PathBody body, CancellationToken ct) => - RunString(http, rootHub, ct, ops => ops.ContentList(body.Path))); - - // Mirror Push/Pull — these talk to the mesh hub directly (same as MCP plugin's PostMirror). - group.MapPost("/mirror", HandleMirror); - - // Local helpers — same logic as the MCP plugin's NavigateTo / GetBaseUrl. - group.MapPost("/navigate-to", HandleNavigateTo); - group.MapPost("/base-url", HandleBaseUrl); - - // Binary upload — multipart so `curl -F file=@logo.png -F path=@Foo/content/logo.png` works. - // DisableAntiforgery: bearer-auth form posts can't carry an antiforgery token; the request - // is already authenticated by ApiTokenAuthenticationHandler, which is the protection here. - // The body limit is DECLARED on the route (#5135): FormOptions below only raises the multipart - // FORM limit, and Kestrel still cut the whole request at its own 30 MB default, so the 200 MB - // this route promised was never reachable. Same ceiling as the MCP `upload` tool, one constant. - group.MapPost("/upload", HandleUpload).DisableAntiforgery() - .WithMetadata(new UploadBodyLimit()); - - return endpoints; - } - - /// - /// POST /api/mesh/whoami — the caller's resolved mesh identity, as - /// {userId, name, email}. userId is the mesh User node's Id (the caller's - /// home partition, e.g. rbuergi) and is null when the request resolved to - /// no real user. - /// - /// - /// Reads the identity UserContextMiddleware already stamped on the portal hub's - /// — the SAME source ApiTokenController uses, and for the - /// same reason: it is the mesh User Id, never the email-shaped preferred_username an - /// OIDC provider supplies. An email-shaped id is refused rather than echoed, because a caller - /// that routed off it would target a {email} partition owning none of the user's data. - /// - /// - /// - /// This exists so a server-side renderer can learn whose home partition to paint WITHOUT - /// minting an API token (issue #1477). It is deliberately not an MCP tool: MCP callers - /// already know who they are — they presented the token. - /// - /// - private static IResult HandleWhoAmI(HttpContext http) - { - // Portal hub when the Blazor shell registered one; the mesh root hub on a next-only - // portal (Features:Gui:Blazor=false). AccessService is the mesh-wide singleton either - // way — see UserContextMiddleware, which stamps the identity this reads. - var caller = (http.RequestServices.GetService()?.Hub - ?? http.RequestServices.GetRequiredService()) - .ServiceProvider.GetRequiredService().Context; - - var userId = caller?.ObjectId; - var resolved = !string.IsNullOrEmpty(userId) - && !userId.Contains('@') - && userId != WellKnownUsers.Anonymous; - - return Results.Json(resolved - ? new WhoAmIResponse(userId, caller!.Name, caller.Email) - : new WhoAmIResponse(null, null, null)); - } - - /// Response shape of POST /api/mesh/whoami without a path. - public record WhoAmIResponse(string? UserId, string? Name, string? Email); - - /// Optional body of POST /api/mesh/whoami. With a the answer - /// adds the caller's effective permissions at that node and whether they are a platform admin - /// (MeshOperations.WhoAmI); without one it is the identity-only - /// . - public record WhoAmIBody(string? Path = null); - - /// - /// Which answer a /whoami body asks for: the access answer at when - /// it names a node, the identity-only answer when there is no body, no path, or a blank - /// one — the last two being what every existing caller ({}) sends. - /// - internal static bool AsksForAccess(WhoAmIBody? body, out string path) - { - path = body?.Path?.Trim() ?? ""; - return path.Length > 0; - } - - /// Default budget for /render-area; clamped so a caller can neither hang the - /// request forever nor force a sub-second flake. - private const int DefaultRenderAreaTimeoutSeconds = 30; - - /// - /// POST /api/mesh/render-area — subscribes the node's layout area server-side (the - /// same GetRemoteStream primitive the Blazor portal binds with), takes the first - /// fully-materialised Full {areas,data} frame, disposes the subscription on every - /// normal path, and ships the frame verbatim: hub serializer options, - /// $type discriminators, JSON-encoded instance keys — byte-identical to the gRPC - /// wire's Full DataChangedEvent, so an SSR client seeds its area source without - /// 🚨 On a CLIENT ABORT the wait stops but the subscription is NOT disposed: the - /// cancellation token passed to ObserveCompletion cancels the returned Task, and - /// that method deliberately keeps its error arm attached so a late fault is reported instead - /// of vanishing. The render therefore runs to completion server-side after an abort. Said - /// plainly because the earlier wording promised teardown the code does not perform; if a real - /// abort-cancels-the-render is wanted, it needs an explicit disposal path, not a doc edit. - /// - /// translation. A timeout returns a 504 JSON error instead of hanging; every other failure - /// ships the "Error: …"/"Not found: …" sentinel - /// exactly like the sibling verbs. - /// - private static Task HandleRenderArea( - HttpContext http, IMessageHub rootHub, RenderAreaBody body, CancellationToken ct) - { - var sessionHub = ResolveSession(http, rootHub); - var ops = new MeshOperations(sessionHub); - var timeout = Math.Clamp(body.TimeoutSeconds ?? DefaultRenderAreaTimeoutSeconds, 1, 120); - return ops.RenderArea(body.Path, body.Area, body.Id, timeout) - .Select(Ship) - .Catch((TimeoutException _) => Observable.Return((IResult)Results.Json( - new - { - error = $"Timed out after {timeout}s waiting for the first full frame of area " + - $"'{body.Area ?? "(default)"}' at '{body.Path}'.", - }, - statusCode: StatusCodes.Status504GatewayTimeout))) - .FirstAsync() - .ObserveCompletion(LateFault(http, $"/api/mesh/render-area '{body.Path}'"), ct)!; - } - - /// - /// POST /api/mesh/mirror. Shaped like : the body is one - /// composed observable and the ONLY bridge to is the trailing - /// .FirstAsync().ObserveCompletion(…, ct) at the ASP.NET edge (never .ToTask(), - /// which resumes the awaiter inline on the signalling thread — forbidden since 2026-08-30). It - /// used to await the hub - /// round-trip mid-method — the shape that reaches into hub code from an await chain, and the - /// one the endpoint's own Task<IResult> signature is not licence for. - /// - private static Task HandleMirror( - HttpContext http, IMessageHub rootHub, MirrorRequest body, CancellationToken ct) - { - var sessionHub = ResolveSession(http, rootHub); - return sessionHub.Observe(body, o => o.WithTarget(new Address("mesh"))) - .Catch((Exception _) => Observable.Return((IMessageDelivery)null!)) - .Select(delivery => delivery?.Message ?? new MirrorResult - { - Status = "Error", - Direction = body.Direction, - SourcePath = body.SourcePath, - TargetPath = body.TargetPath ?? body.SourcePath, - Error = "No response from mirror handler — is the mesh hub reachable and AddPersistence configured?", - }) - .Select(result => (IResult)Results.Content( - JsonSerializer.Serialize(result, sessionHub.JsonSerializerOptions), "application/json")) - .FirstAsync() - .ObserveCompletion(LateFault(http, $"/api/mesh/mirror '{body.SourcePath}'"), ct)!; - } - - private static IResult HandleNavigateTo(HttpContext http, IOptions? mcp, NavigateBody body) - { - var baseUrl = ResolveBaseUrl(http, mcp); - var resolved = MeshOperations.ResolvePath(body.Path).TrimStart('/'); - return Results.Json(new { url = $"{baseUrl}/{resolved}" }); - } - - private static IResult HandleBaseUrl(HttpContext http, IOptions? mcp) => - Results.Json(new { url = ResolveBaseUrl(http, mcp) }); - - /// - /// POST /api/mesh/upload. The multipart read stays async — ReadFormAsync and - /// CopyToAsync over the request body are ASP.NET's own IO and belong at this edge — but it - /// is quarantined in , which never touches the mesh. The MESH call - /// is composed, not awaited: the single bridge back to is the trailing - /// .FirstAsync().ObserveCompletion(…, ct). An await that reaches into hub code is the shape the - /// endpoint's Task<IResult> signature is not licence for, however far from a hub - /// scheduler this particular request thread happens to be. - /// - private static Task HandleUpload(HttpContext http, IMessageHub rootHub, CancellationToken ct) => - ReadUploadAsync(http, ct).ToObservable() - .SelectMany(upload => upload.Failure is not null - ? Observable.Return(upload.Failure) - : new MeshOperations(ResolveSession(http, rootHub)) - .Upload(upload.Path!, upload.Bytes!) - .Select(Ship)) - .FirstAsync() - .ObserveCompletion(LateFault(http, "/api/mesh/upload"), ct)!; - - /// - /// Reads the multipart body: either a Failure result to ship as-is, or the target path - /// and the file's bytes. Pure ASP.NET — no mesh call, so nothing here can park a hub, and - /// internal so MeshApiUploadBodyTest can pin each refusal without standing up an - /// authenticated pipeline and a mesh just to assert a 400. - /// - internal static async Task<(IResult? Failure, string? Path, byte[]? Bytes)> ReadUploadAsync( - HttpContext http, CancellationToken ct) - { - if (!http.Request.HasFormContentType) - return (Results.BadRequest(new { error = "Content-Type must be multipart/form-data." }), null, null); - - var form = await http.Request.ReadFormAsync(ct); - var path = form["path"].FirstOrDefault(); - var file = form.Files.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(path)) - return (Results.BadRequest(new { error = "Form field 'path' is required." }), null, null); - if (file is null || file.Length == 0) - return (Results.BadRequest(new { error = "Form file 'file' is required." }), null, null); - - using var ms = new MemoryStream(); - await using (var stream = file.OpenReadStream()) - await stream.CopyToAsync(ms, ct); - return (null, path, ms.ToArray()); - } - - /// - /// The request-body limit POST /api/mesh/upload declares: a - /// file plus its multipart envelope. Declared as - /// endpoint metadata so Kestrel applies it to this route instead of its 30 MB default. - /// - internal sealed class UploadBodyLimit : Microsoft.AspNetCore.Http.Metadata.IRequestSizeLimitMetadata - { - /// - public long? MaxRequestBodySize => UploadLimits.MultipartRequestBodyLimit; - } - - /// - /// Registers the bits the REST module needs that aren't already in DI from the - /// MCP wiring: lift the multipart upload size cap (default 30 MB is too small - /// for typical document uploads) and ensure is - /// bound (shared with MCP — same Mcp__BaseUrl env var). - /// - public static IServiceCollection AddMeshApi(this IServiceCollection services) - { - services.Configure(o => - { - // 200 MB — generous but bounded. Matches the working assumption that - // document / image / spreadsheet uploads are the common case; binaries - // larger than this should go through a different ingest path. - o.MultipartBodyLengthLimit = UploadLimits.MultipartRequestBodyLimit; - o.ValueLengthLimit = int.MaxValue; - o.MultipartHeadersLengthLimit = int.MaxValue; - }); - - // 🚨 This binding is the REST surface's OWN — it must not depend on the MCP module being - // listed. AddMeshMcp() binds the same options when MeshWeaver.Mcp IS installed; - // BindConfiguration is idempotent, so both lanes coexist and /api/mesh/base-url keeps - // answering when MCP is delisted. - services.AddOptions().BindConfiguration("Mcp"); - - return services; - } - - /// - /// The late-fault sink for this surface's - /// bridges. A fault that lands AFTER the response has already settled can no longer change the - /// answer, but it is the only record that anything went wrong on the way out — discarding it is - /// half of what ObserveCompletion exists to remove. - /// - /// The logger is resolved EAGERLY, while the request scope is still alive, and captured: - /// a late fault arrives after has been disposed, so - /// resolving from it inside the lambda would throw exactly when the report is needed. - /// - private static Action LateFault(HttpContext http, string what) - { - var logger = http.RequestServices.GetRequiredService() - .CreateLogger(typeof(MeshApiEndpoints)); - return ex => logger.LogWarning( - ex, "{What}: faulted after its HTTP response had already settled", what); - } - - private static IMessageHub ResolveSession(HttpContext http, IMessageHub rootHub) - { - var logger = http.RequestServices.GetRequiredService().CreateLogger(typeof(MeshApiEndpoints)); - return SessionHubResolver.ResolveSessionHub(rootHub, http, "api", logger); - } - - /// - /// The shared body of every string-returning verb on this surface. work's observable is - /// composed, never awaited: the single bridge to is the trailing - /// .FirstAsync().ObserveCompletion(…, ct), which is the ASP.NET edge itself. Rx's own - /// .ToTask() is NOT that bridge — it resumes the awaiter inline on the signalling thread, - /// which on this surface is a mesh hub's action block (forbidden since 2026-08-30). - /// - /// 🚨 A verb's bounded wait that EXPIRES is an answer, not a crash (MeshWeaver#6790). A - /// that a verb lets escape used to leave this method as a faulted - /// , which ASP.NET reports as an unhandled 500 through - /// ExceptionHandlerMiddleware — a line naming neither the verb nor the route, because an - /// Rx .Timeout(…) creates its exception without throwing it, so it has no frames. It is - /// now answered the way /render-area answers its own deadline: a 504 whose body names the - /// route, logged at Warning with the method and route. Every OTHER fault still escapes to the - /// exception handler — a fault that is not a deadline is a defect, and turning it into a - /// response here would hide it. - /// - internal static Task RunString( - HttpContext http, - IMessageHub rootHub, - CancellationToken ct, - Func> work) - { - var sessionHub = ResolveSession(http, rootHub); - var ops = new MeshOperations(sessionHub); - var timedOut = TimedOut(http); - return work(ops) - .Select(Ship) - .Catch((TimeoutException ex) => Observable.Return(timedOut(ex))) - .FirstAsync() - .ObserveCompletion(LateFault(http, $"{http.Request.Path}"), ct)!; - } - - /// The of a verb whose bounded wait expired. - public const string TimeoutKind = "Timeout"; - - /// - /// The answer to a verb whose bounded wait expired: 504 with - /// { "error": "Timed out: POST /api/mesh/get did not complete in time (…)", "kind": "Timeout" }, - /// logged once at Warning naming the method and the route template (never the body, which carries - /// the caller's paths). - /// Everything it needs from the request is read EAGERLY, like : the - /// deadline can expire after a client abort, when is - /// already disposed. - /// - private static Func TimedOut(HttpContext http) - { - var logger = http.RequestServices.GetRequiredService() - .CreateLogger(typeof(MeshApiEndpoints)); - var method = http.Request.Method; - var route = RequestName.Route(http.GetEndpoint()); - return ex => - { - logger.LogWarning(ex, - "{RequestMethod} {RequestRoute}: the verb's bounded wait expired — answered 504", - method, route); - return Results.Json( - new SentinelBody($"Timed out: {method} {route} did not complete in time ({ex.Message})", TimeoutKind), - statusCode: StatusCodes.Status504GatewayTimeout); - }; - } - - /// - /// Ships one verb result over HTTP. A JSON document goes out - /// verbatim as 200 application/json; a sentinel sentence ("Error: …", - /// "Not found: …", "Unavailable: …") goes out as the status - /// assigns it — 500 / 404 / 503 — with a JSON body - /// { "error": <the sentence>, "kind": <kind> }. - /// - /// 🚨 It used to ship the sentence raw with a 200 and application/json, on the - /// theory that "the error string is just a JSON-quoted value the client can branch on". It was - /// never quoted: a bare Unavailable: … is not JSON, so response.ok() passed and - /// the caller died inside JSON.parse with nothing naming the path or the reason - /// (MeshWeaver.Plugins#1699, Education's install e2e). Every HTTP client of these verbs — the - /// grpc-web SDK, portal-next's SSR, the CLI — checks the status FIRST and only then sniffs the - /// prefix, so a non-2xx lands them on the branch they already have for the sentinel; none of - /// them handled Unavailable: at all. The MCP tool result is untouched: there the - /// sentence IS the contract, and the reader is a model. - /// - internal static IResult Ship(string result) - { - var verdict = OperationSentinel.Classify(result); - return verdict is null - ? Results.Content(result, "application/json") - : Results.Json(new SentinelBody(result, verdict.Kind.ToString()), statusCode: verdict.HttpStatus); - } - - /// The JSON body of a non-2xx verb answer: the sentinel sentence and its kind. Minimal - /// APIs serialize it camelCase — { "error": …, "kind": … } on the wire. - /// The sentence, verbatim — it names the path and the cause. - /// Error, NotFound or Unavailable. - public sealed record SentinelBody(string Error, string Kind); - - private static string ResolveBaseUrl(HttpContext http, IOptions? mcp) - { - var configured = mcp?.Value.BaseUrl; - if (!string.IsNullOrEmpty(configured)) - return configured.TrimEnd('/'); - var req = http.Request; - return $"{req.Scheme}://{req.Host.Value}".TrimEnd('/'); - } - - // Request DTOs — the framework's System.Text.Json infrastructure binds JSON bodies - // by property name (case-insensitive). All optional fields default to null / false. - public record GetBody(string Path); - public record SearchBody(string Query, string? BasePath); - public record CatalogDownloadBody(string Plugin); - public record QueryNodesBody(string Query, int? Limit = null); - public record CreateBody(string Node); - public record UpdateBody(string Nodes); - public record PatchBody(string Path, string Fields); - public record DeleteBody(string Paths); - public record MoveBody(string SourcePath, string TargetPath); - public record CopyBody(string SourcePath, string TargetNamespace, bool Force = false); - public record PathBody(string Path); - - /// - /// POST /api/mesh/recycle. Separate from so that only this route - /// carries Reason — the operator's own why, which lands in the target's - /// DisposeRequest.Reason beside the framework's sentence rather than replacing it - /// (MeshWeaver#4782). Optional: a body of {"path":"…"} binds with Reason null. - /// - public record RecycleBody(string Path, string? Reason = null); - public record ExecuteScriptBody(string Path, int? TimeoutSeconds); - public record RenderAreaBody(string Path, string? Area = null, string? Id = null, int? TimeoutSeconds = null); - public record RenderMarkdownBody(string Markdown, string? NodePath = null); - public record NavigateBody(string Path); -} diff --git a/memex/Memex.Portal.Shared/Api/ModulePublishedBroadcaster.cs b/memex/Memex.Portal.Shared/Api/ModulePublishedBroadcaster.cs deleted file mode 100644 index 6d430afd13..0000000000 --- a/memex/Memex.Portal.Shared/Api/ModulePublishedBroadcaster.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System.Collections.Immutable; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using MeshWeaver.Data; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// The registry's half of eager adoption (#3650): the moment a module bundle is published, tell -/// every registered consumer — so "ships" → "used" is minutes, not the consumer's next boot. -/// -/// Who is told. Every registered on this registry that -/// is not disabled and recorded a — the URL a consumer -/// hands over when it registers (PluginCatalog:HomeUrl). The registry does not know which -/// instances installed the package; it does not need to — the consumer's reconcile answers that from -/// its own install record, and a package that is not installed there costs one record read. -/// -/// How. One record, POSTed to each consumer's generic -/// webhook inbox at — the same inbox every other event -/// reaches an installation through — signed GitHub-style when -/// is configured, unsigned otherwise (the -/// consumer treats the delivery as a wake-up and reads the truth from this registry's authenticated -/// index, so an unsigned delivery cannot make it land anything it would not have landed). A 404 -/// means the consumer has not allowlisted the target; a 401 means the two halves of the secret -/// drifted — both are LOGGED per consumer, never thrown, because a publish that landed on the shelf -/// has succeeded whatever the fan-out did, and the consumer's safety net -/// () bounds what a lost delivery costs. -/// -/// Reactive end to end: the instance listing is a mesh query as System, each POST runs on the -/// Http , deliveries run one at a time and are isolated. Cold — nothing -/// runs until subscribed, and the publish route subscribes it detached from the publisher's own -/// response so a slow consumer never holds a CI job. -/// -public sealed class ModulePublishedBroadcaster -{ - private readonly IMessageHub hub; - private readonly ILogger? logger; - private readonly HttpClient http; - private readonly IIoPool httpPool; - - /// The named HttpClient the broadcast sends through, so a host (or a test) can - /// route it — the same seam DeploymentReportService uses. - public const string HttpClientName = "module-published-broadcast"; - - private static readonly TimeSpan DeliveryBudget = TimeSpan.FromSeconds(30); - - /// Creates the broadcaster over the mesh's services. - public ModulePublishedBroadcaster(IMessageHub hub, ILogger? logger = null) - { - this.hub = hub; - this.logger = logger; - httpPool = hub.ServiceProvider.GetService()?.Get(IoPoolNames.Http) ?? IoPool.Unbounded; - http = hub.ServiceProvider.GetService()?.CreateClient(HttpClientName) - ?? new HttpClient { Timeout = DeliveryBudget }; - } - - /// One consumer's delivery outcome. - /// The consumer's logical App ID. - /// Where the delivery was POSTed. - /// Whether the consumer answered 2xx. - /// The status and body of a refusal, or null. - public sealed record Delivery(string InstanceId, string Url, bool Ok, string? Detail); - - /// - /// Broadcasts to every reachable registered instance. Emits the - /// per-consumer outcomes once, as one list; never throws — a listing that cannot be made, an - /// unreachable consumer and a refusal are each an outcome to read. - /// - public IObservable> Broadcast(ModulePublished published) - { - var body = published.Serialize(); - var secret = hub.ServiceProvider.GetService()?[ModulePublished.BroadcastSecretConfigKey]; - return ListReachableInstances() - .SelectMany(instances => - { - if (instances.Length == 0) - { - logger?.LogInformation( - "[ModulePublished] {Package} at {Version}: no registered instance records a home " - + "URL — nobody to tell; consumers reconcile on their safety net and at boot.", - published.Package, published.Version ?? "(unversioned)"); - return Observable.Return(ImmutableArray.Empty); - } - return Observable - .Concat(instances.Select(instance => DeliverOne(instance, body, secret))) - .ToList() - .Select(results => - { - var outcome = results.ToImmutableArray(); - var failed = outcome.Where(d => !d.Ok).ToArray(); - logger?.LogInformation( - "[ModulePublished] {Package} at {Version}: {Ok}/{Total} consumer(s) told{Failed}.", - published.Package, published.Version ?? "(unversioned)", - outcome.Length - failed.Length, outcome.Length, - failed.Length == 0 - ? "" - : " — " + failed.Length + " refused or unreachable: " - + string.Join("; ", failed.Select(d => $"{d.InstanceId} ({d.Detail})"))); - return outcome; - }); - }) - .Catch((Exception ex) => - { - logger?.LogWarning(ex, - "[ModulePublished] could not list the registered instances to tell about {Package} — " - + "consumers reconcile on their safety net and at boot. Cause: {Cause}", - published.Package, ex.Message); - return Observable.Return(ImmutableArray.Empty); - }); - } - - /// Every registered, enabled instance with a home URL — a mesh-wide query as System, - /// because instances live under whichever user registered them (#3202: fan-out is opt-in). - private IObservable> ListReachableInstances() - { - var meshService = hub.ServiceProvider.GetRequiredService(); - var accessService = hub.ServiceProvider.GetService(); - var request = new MeshQueryRequest - { - Query = MeshWideQuery.Declare($"nodeType:{MeshWeaverInstanceNodeType.NodeType}"), - }; - return accessService.RunAsSystem(() => meshService.Query(request)) - .Take(1) - .Timeout(DeliveryBudget) - .SelectMany(results => results - // The routing index (MeshWeaverInstance/{hashPrefix}) shares the node type; an - // instance RECORD lives under its owner. A query answers paths, not content, so - // each record is then read once — the fleet is a handful of instances. - .Where(result => result.State == MeshNodeState.Active - && !string.Equals(result.Namespace, MeshWeaverInstanceNodeType.IndexNamespace, StringComparison.Ordinal)) - .Select(result => result.Path) - .Distinct(StringComparer.Ordinal) - .Select(path => accessService.RunAsSystem(() => hub.GetMeshNode(path, DeliveryBudget)) - .Take(1) - .Select(node => node.ContentAs(hub.JsonSerializerOptions)) - .Catch((Exception ex) => - { - logger?.LogWarning(ex, - "[ModulePublished] could not read the instance record at {Path}; that consumer is not told.", - path); - return Observable.Return(null); - })) - .ToObservable() - .Concat() - .ToList()) - .Select(instances => instances - .Where(instance => instance is { IsDisabled: false } - && Uri.TryCreate(instance.HomeUrl?.Trim(), UriKind.Absolute, out var url) - && url.Scheme is "http" or "https") - .Select(instance => instance!) - .ToImmutableArray()); - } - - // One consumer, already isolated: a non-2xx and a thrown exception both become Delivery(Ok:false). - private IObservable DeliverOne(MeshWeaverInstance instance, string body, string? secret) - { - var url = instance.HomeUrl.Trim().TrimEnd('/') + ModulePublished.InboxRoute; - return httpPool.Invoke(ct => PostAsync(instance.InstanceId, url, body, secret, ct)) - .Catch((Exception ex) => Observable.Return(new Delivery(instance.InstanceId, url, false, ex.Message))); - } - - private async Task PostAsync(string instanceId, string url, string body, string? secret, CancellationToken ct) - { - using var request = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }; - if (!string.IsNullOrWhiteSpace(secret)) - request.Headers.Add(WebhookInbox.SignatureHeader, DeploymentReportService.Sign(body, secret)); - using var response = await http.SendAsync(request, ct).ConfigureAwait(false); - if (response.IsSuccessStatusCode) - return new Delivery(instanceId, url, true, null); - var detail = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - return new Delivery(instanceId, url, false, - $"{(int)response.StatusCode}: {(detail.Length <= 200 ? detail : detail[..200] + "…")}"); - } -} diff --git a/memex/Memex.Portal.Shared/Api/PluginBundleEndpoints.cs b/memex/Memex.Portal.Shared/Api/PluginBundleEndpoints.cs deleted file mode 100644 index b9108b4f9f..0000000000 --- a/memex/Memex.Portal.Shared/Api/PluginBundleEndpoints.cs +++ /dev/null @@ -1,2478 +0,0 @@ -using System.Collections.Immutable; -using System.Globalization; -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Messaging; -using PackagingManifest = MeshWeaver.Plugin.Packaging.PluginManifest; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// Serves a plugin's prebuilt assemblies to another instance — the distribution half of -/// Doc/Architecture/PluginPackaging. -/// -/// A consumer needs three things to skip a compile: the bytes, the framework identity they -/// were built against, and which node each belongs to. One bundle carries exactly that, over two -/// routes — an index and a download. There is no package protocol here because nothing restores: -/// NodeType compilation runs in-process with Roslyn against MetadataReferences, so a service -/// index and dependency ranges would be surfaces that can drift with no client to read them. -/// -/// The portal serves the bytes rather than handing out storage access. The assembly -/// store is already the durable transport: BlobAssemblyStore keeps one blob per -/// (nodeTypePath, version) and hydrates it into a process-local cache on demand. Reading -/// through here means the bundle is assembled from the very bytes this -/// portal loads and runs, and it needs no second credential — a scoped SAS handed to each consumer -/// would be a second entitlement path to keep honest, and revoking it is not the same operation as -/// revoking the install's . -/// -/// 🚨 Authorization is the instance key, not a bundle-specific scheme. A caller presents -/// its mwi_ key — as Bearer or Basic — and it resolves to the admin-owned -/// that says which packages that install may read. Same gate as -/// /api/plugins, deliberately: a second entitlement path is a second thing to get wrong, and -/// this one is already what purchases are recorded against. -/// -/// 🚨 The key is TWO decisions, and for a long time only the first one ran (#1772). The -/// filter below authenticates — a valid mwi_ key or 401 — and -/// authorizes, per package, on both routes. Until #1772 the authenticated caller was written -/// into and never read back, so any registered instance could -/// download every installed package's bundle, paid courses included, while this very paragraph said -/// otherwise. An instance key is provisioned to every registered installation; it is identity, never -/// entitlement. The grant model is — the same match -/// InstallByDefault and /api/plugins make, so there is exactly one thing to keep -/// honest. -/// -/// 🚨 The (source, package) binding the grant is matched against is anchored on the -/// REGISTRY (#1782 gap 2), not on this instance's install records. It used to come from the -/// record's and from nowhere else, which made two things true -/// that should not have been: a package this instance had not itself installed could not be served -/// at all (however plainly its content sat here), and "I cannot tell which source this is from" was -/// answered as "you are not entitled to it". The record is now read as what it is — a CACHE of the -/// registry's binding — and its absence sends the question upstream. See -/// for the three outcomes and -/// for the read; the GRANT match itself is untouched. -/// -/// 🚨 A refusal is byte-identical to "no such bundle" (), and -/// an ungranted package is absent from the index. The URL scheme is fully predictable, so a -/// distinguishable refusal would be an inventory oracle over the whole catalogue — the same reasoning -/// that made /api/content's refusal identical to its not-found (#587). -/// -public static class PluginBundleEndpoints -{ - /// Route the bundles are mounted at. - public const string RoutePrefix = "/api/plugins/bundles"; - - /// The Retry-After (seconds) every transient 503 of these routes carries — a - /// catalogue read that stalled, a directory being republished, or resolved bytes that were - /// evicted before the archive opened them (#3876, #5345). One value, so a consumer's back-off - /// and a test's expectation read the same number. - public const int TransientRetryAfterSeconds = 30; - - /// key holding the authenticated caller. - private const string CallerItemKey = "PluginBundle.Caller"; - - /// key holding the authenticated BUILD principal — a - /// different caller class from an installation, so a different slot (#2483). - private const string BuildCallerItemKey = "PluginBundle.BuildCaller"; - - /// Maps the instance-key-gated bundle routes. Call alongside MapPluginRegistry. - public static IEndpointRouteBuilder MapPluginBundles(this IEndpointRouteBuilder endpoints) - { - // AllowAnonymous at the ASP.NET layer for the same reason the registry does it: callers are - // INSTANCES, not signed-in users, so the user auth schemes do not apply. The filter below - // is the real gate. - var group = endpoints.MapGroup(RoutePrefix).AllowAnonymous(); - - group.AddEndpointFilter(async (ctx, next) => - { - var http = ctx.HttpContext; - var logger = http.RequestServices.GetService() - ?.CreateLogger(typeof(PluginBundleEndpoints)); - var authenticator = http.RequestServices.GetRequiredService(); - // The request's stage ledger starts HERE — its arrival at the route — so the answer - // deadline counts the authentication too (RegistryAnswerDeadline, #4963). - var stages = RegistryAnswerDeadline.Stages(http); - - var outcome = (await authenticator.AuthenticateOutcome(http.Request.Headers.Authorization) - .InStage(stages, "instance-key authentication") - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Plugin bundles: instance authentication for {Path} faulted after the request had already been answered", - http.Request.Path), - http.RequestAborted))!; - - if (outcome.IsUnavailable) - return InstanceAuthResponses.Unavailable(http, outcome.UnavailableReason, logger); - - var caller = outcome.Instance; - var build = outcome.Build; - // 🚨 A BUILD principal (#2483) is admitted on the PREBUILT routes ONLY. It is a CI run, - // not an installation: it has no instance record, no plan and no PluginGrant, so every - // other bundle route — which decides per package against exactly those — keeps refusing - // it with the same 401 as before. Narrowing here rather than inside each handler means - // a route added later is refused by default rather than by remembering to. - if (caller is null && (build is null || !IsPrebuiltRoute(http.Request.Path))) - { - // 🚨 Fails CLOSED, with no anonymous escape hatch — unlike /api/plugins, which - // keeps one for local dev. These are compiled assemblies for PAID modules; "open - // when unconfigured" is how the registry served private sources to anyone who knew - // the URL (2026-08-06), and there is no reason to repeat it here. - logger?.LogWarning("Plugin bundles: rejected {Path} — no valid instance key presented", - http.Request.Path); - return Results.Json( - new { error = "A registered instance key is required (Authorization: Bearer mwi_… or Basic)." }, - statusCode: StatusCodes.Status401Unauthorized); - } - - if (caller is not null) - http.Items[CallerItemKey] = caller; - if (build is not null) - http.Items[BuildCallerItemKey] = build; - return await next(ctx); - }); - - - - // 🚨 The hub is resolved from RequestServices INSIDE the handler, never bound as a - // parameter. Minimal-API binds handler arguments BEFORE endpoint filters run, so a bound - // IMessageHub makes an UNAUTHENTICATED request depend on the mesh being resolvable — it - // throws (500) instead of the 401 the filter would have returned. The rejection path must - // not need anything but the header. - // 🚨 `identity`/`arch` are the CONSUMER's lane here for exactly the reason they are on the - // bundle route below (#1751) — and until #3768 the index did NOT read them, which made that - // route's lane-awareness unreachable: the index announced the registry's OWN bake, and - // `PluginBundleClient.Adopt` declines the whole index on a mismatch before it ever asks for - // a package. Absent ⇒ this instance's own lane, which is what every pre-#3768 client asks - // for, so the answer for them is unchanged. - group.MapGet("/index.json", (HttpContext http, CancellationToken ct) => - RefuseMalformedIdentity(http) is { } refused - ? Task.FromResult(refused) - : Index( - http, RootHub(http), - Requested(http, "identity", FrameworkMvid), - Requested(http, "arch", ReleaseArchitecture.Live), - Caller(http), ct)); - - // 🚨 `identity`/`arch` are the CONSUMER's lane (#1751), not a filter the caller invents: they - // say which framework build identity and which architecture the caller can actually run, and - // the assemblies are resolved through each NodeType's Release node for exactly that pair. - // Absent ⇒ this instance's own lane, which is what every pre-#1751 client asks for, so the - // route's behaviour for them is unchanged. - group.MapGet("/{plugin}/{version}", - (HttpContext http, string plugin, string version, CancellationToken ct) => - RefuseMalformedIdentity(http) is { } refused - ? Task.FromResult(refused) - : Bundle( - http, RootHub(http), plugin, version, - Requested(http, "identity", FrameworkMvid), - Requested(http, "arch", ReleaseArchitecture.Live), - Caller(http), - ct)); - - MapPrebuilt(group); - MapPublish(endpoints); - return endpoints; - } - - /// - /// The PUBLISH route — the registry's acquisition half (#1664 step 13). - /// - /// A registry serves the module bytes it itself runs, and that folder is written by the - /// platform image's publish layout. So a module whose source has LEFT the platform repo could - /// be packed, declared and installed — and still reach nobody. This is how the repo that owns - /// it hands the bytes over: POST /api/plugins/bundles/{plugin} with the packed - /// .module.nupkg, after which the existing serve → fetch → floor-gate → land chain - /// carries it to every consumer unchanged. - /// - /// 🚨 Mapped only when a publish token is configured - /// () — an unconfigured registry has no publish - /// surface at all, rather than one that answers 401. Same shape as the log-ingest route, and - /// for the same reason: the caller is a build, not a signed-in user, so this is deliberately - /// NOT the instance-key group above — a read grant says which packages an instance may PULL, - /// while publishing writes bytes every consumer will then load. - /// - // ─────────────────── prebuilt: the registry SERVES sealed publications ─────────────────── - // - // Step 2 of the plugin build contract (Doc/Architecture/PluginBuildContract): a downstream - // repo INSTALLS its upstream from the upstream's sealed publication. Until this route existed - // the only reader of `prebuilt-bundles///` was an Azure OIDC identity whose - // federated credentials live in the Entra tenant — none of them for `pull_request`, so a gate - // could not fetch on the one event it exists for (AADSTS700213, measured 2026-08-27). The - // portal already mounts the share (PreWarm:PrebuiltBundleRoot); serving it here puts the read - // behind the SAME authenticator and grant model as every other bundle route, so who may fetch - // which source is a `PluginGrantEntry` on a mesh node — auditable, listable, revocable — and - // never a rule in a cloud tenant nobody can query. - // - // Authority: a whole-source grant (`/*`) on the caller. The identity that PUBLISHES a - // source is the one that may fetch what it depends on, scoped per source — a satellite holds - // `plugins/*` to fetch, never a grant to publish AS plugins. - // - // 🚨 Serves ONLY what the `_complete` seal lists (PublishedBundleCatalogue.SealedBundlesOf): - // a torn publication — no sentinel, or a listed bundle absent — is refused wholesale, exactly - // as the boot seeder refuses it. A consumer must never be handed a set the portal itself - // would decline. - private const string PrebuiltSegment = "prebuilt"; - - private static void MapPrebuilt(RouteGroupBuilder group) - { - group.MapGet($"/{PrebuiltSegment}/{{identity}}/{{source}}", - (HttpContext http, string identity, string source) => - PrebuiltIndex(http, identity, source, Caller(http), BuildCaller(http))); - group.MapGet($"/{PrebuiltSegment}/{{identity}}/{{source}}/{{bundle}}", - (HttpContext http, string identity, string source, string bundle) => - PrebuiltBundle(http, identity, source, bundle, Caller(http), BuildCaller(http))); - // The module set a publication was sealed against (MeshWeaver#2698) — a consumer pinned to - // this identity composes THESE bytes, never the package endpoint's. The literal segment wins - // over {bundle} above, so a NodeType bundle named "modules" is not served here. - group.MapGet($"/{PrebuiltSegment}/{{identity}}/{{source}}/{PublishedBundleCatalogue.ModulesDirectoryName}", - (HttpContext http, string identity, string source) => - PrebuiltModuleSet(http, identity, source, Caller(http), BuildCaller(http))); - group.MapGet($"/{PrebuiltSegment}/{{identity}}/{{source}}/{PublishedBundleCatalogue.ModulesDirectoryName}/{{bundle}}", - (HttpContext http, string identity, string source, string bundle) => - PrebuiltModule(http, identity, source, bundle, Caller(http), BuildCaller(http))); - } - - /// The sealed publication's bundle names and its GENERATION, or 404 when nothing is - /// published for that identity/source, 503 while it is being republished, or 403 when the - /// caller holds no whole-source grant on it. - private static IResult PrebuiltIndex( - HttpContext http, string identity, string source, - AuthenticatedInstance? caller, AuthenticatedBuild? build) - { - if (PrebuiltDecision(http, identity, source, caller, build) is { } refused) - return refused; - var directory = PrebuiltDirectory(http, identity, source); - if (directory is null) - return NothingPublished(identity, source); - var reading = PublishedBundleCatalogue.SealedPublicationOf(directory, Log(http)); - if (reading.Bundles is null) - return BeingRepublished(http, identity, source, directory, reading.TornReason!); - http.Response.Headers.ETag = $"\"{reading.Generation}\""; - return Results.Json( - new { identity, source, bundles = reading.Bundles, generation = reading.Generation }); - } - - /// Nothing has ever been published here — a permanent answer, unlike - /// . - private static IResult NothingPublished(string identity, string source) => - Results.Json( - new { error = $"no sealed publication for source '{source}' under framework identity '{identity}'" }, - statusCode: StatusCodes.Status404NotFound); - - /// - /// 🚨 503, NOT 404 (#3401). The publisher UNSEALS before it republishes and re-seals LAST, on - /// purpose, so that nobody can read a mix of two publications. A request that lands in that - /// window is looking at a publication that exists and is being replaced — "temporarily - /// unavailable, come back", which is what 503 + Retry-After means and what every - /// transient-aware client already does with it. Answering 404 told MeshWeaver.Manufacturing on - /// 2026-09-06 that a bundle was GONE; the same bundle was served intact 20 minutes later, and - /// the wrong status cost the whole investigation. - /// - private static IResult BeingRepublished( - HttpContext http, string identity, string source, string directory, string reason) - { - // Retention may remove the directory after PrebuiltDirectory observed it (#3876). - if (!Directory.Exists(directory)) - return NothingPublished(identity, source); - http.Response.Headers.RetryAfter = TransientRetryAfterSeconds.ToString(CultureInfo.InvariantCulture); - return Results.Json( - new { error = $"{reason} — source '{source}', framework identity '{identity}'" }, - statusCode: StatusCodes.Status503ServiceUnavailable); - } - - /// - /// The caller pinned a generation with If-Match and the publication has moved since - /// (#3401). Its remaining fetches would mix bytes from two publications, which no status it - /// could otherwise receive would tell it — so refuse the read and name both generations. - /// - private static IResult GenerationMoved(string held, string current) => - Results.Json( - new - { - error = "the publication was resealed after you read its index — restart the fetch " - + "against the current generation rather than mixing two publications", - held, - current, - }, - statusCode: StatusCodes.Status412PreconditionFailed); - - /// The generation the caller pinned via If-Match, or null when it pinned none. - /// Quotes are optional so a hand-written curl -H 'If-Match: abc' works. - private static string? HeldGeneration(HttpContext http) => - http.Request.Headers.IfMatch.ToString() is { Length: > 0 } raw - ? raw.Trim().Trim('"') - : null; - - /// - /// One sealed bundle's bytes — and THREE distinct answers where there used to be one 404 - /// (#3401): 404 when the seal does not list this name (a permanent mistake, even if the file - /// exists — an unsealed file is not part of the publication), 503 while the publication is - /// being republished (transient), and 412 when the caller pinned a generation that has since - /// moved. Collapsing these is what made a republish window read as a missing bundle. - /// - private static IResult PrebuiltBundle( - HttpContext http, string identity, string source, string bundle, - AuthenticatedInstance? caller, AuthenticatedBuild? build) - { - if (PrebuiltDecision(http, identity, source, caller, build) is { } refused) - return refused; - var directory = PrebuiltDirectory(http, identity, source); - if (directory is null) - return NothingPublished(identity, source); - var reading = PublishedBundleCatalogue.SealedPublicationOf(directory, Log(http)); - if (reading.Bundles is null) - return BeingRepublished(http, identity, source, directory, reading.TornReason!); - if (HeldGeneration(http) is { } held && held != reading.Generation) - return GenerationMoved(held, reading.Generation!); - // 🚨 The SEAL's own spelling, never the request's (#3876). The match is - // case-INSENSITIVE while the share is not, so composing the requested name served - // `store.zip` out of a publication that sealed `Store.zip` — an open that fails on Linux - // for a permanent client mistake, which would now wear the transient answer and have that - // caller retry for ever. The listing verified THIS name present; serve THIS name. - if (reading.Bundles.FirstOrDefault( - name => name.Equals(bundle, StringComparison.OrdinalIgnoreCase)) is not { } sealedName) - return NoSuchBundle(); - // 🚨 #3461: the bytes come from the directory the READING resolved — the generation this - // source's `_current` names, or the source directory in the flat layout. Composing under - // `directory` here would serve the flat publication's bytes under the generation this - // response's ETag pins, which is precisely the mix the generation exists to prevent. - var path = Path.Combine(reading.Directory ?? directory, sealedName); - return SealedBytes(http, identity, source, directory, path, sealedName); - } - - /// The module bundles the sealed publication composed — its NodeType assemblies' - /// dependency records point at exactly these bytes. 404 with the reason when the publication - /// is torn, or predates module sealing (republish it); an EMPTY list when the bake composed - /// nothing. Same grant as the bundle index: a whole-source grant on the source. - private static IResult PrebuiltModuleSet( - HttpContext http, string identity, string source, - AuthenticatedInstance? caller, AuthenticatedBuild? build) - { - if (PrebuiltDecision(http, identity, source, caller, build) is { } refused) - return refused; - var directory = PrebuiltDirectory(http, identity, source); - if (directory is null) - return NothingPublished(identity, source); - // The republish window reaches the module set too, and it is transient there for the same - // reason (#3401) — separate it from a module set that is genuinely unusable. - var publication = PublishedBundleCatalogue.SealedPublicationOf(directory, Log(http)); - if (publication.Bundles is null) - return BeingRepublished(http, identity, source, directory, publication.TornReason!); - var reading = PublishedBundleCatalogue.SealedModulesOf(directory, Log(http)); - if (reading.PublicationUnavailable) - return BeingRepublished(http, identity, source, directory, reading.Refusal!); - if (reading.Modules is null) - return Results.Json( - new { error = $"{reading.Refusal} — source '{source}', framework identity '{identity}'" }, - statusCode: StatusCodes.Status404NotFound); - http.Response.Headers.ETag = $"\"{publication.Generation}\""; - return Results.Json( - new { identity, source, modules = reading.Modules, generation = publication.Generation }); - } - - /// One sealed module bundle's bytes. A name the module index does not list is 404 - /// even if the file exists — an unlisted file is not part of the sealed set. - private static IResult PrebuiltModule( - HttpContext http, string identity, string source, string bundle, - AuthenticatedInstance? caller, AuthenticatedBuild? build) - { - if (PrebuiltDecision(http, identity, source, caller, build) is { } refused) - return refused; - if (!IsBareName(bundle)) - return Results.Json(new { error = "bundle must be a bare name" }, - statusCode: StatusCodes.Status400BadRequest); - var directory = PrebuiltDirectory(http, identity, source); - if (directory is null) - return NothingPublished(identity, source); - var publication = PublishedBundleCatalogue.SealedPublicationOf(directory, Log(http)); - if (publication.Bundles is null) - return BeingRepublished(http, identity, source, directory, publication.TornReason!); - if (HeldGeneration(http) is { } held && held != publication.Generation) - return GenerationMoved(held, publication.Generation!); - var reading = PublishedBundleCatalogue.SealedModulesOf(directory, Log(http)); - if (reading.PublicationUnavailable) - return BeingRepublished(http, identity, source, directory, reading.Refusal!); - // The index's own spelling, for the reason given on the bundle route above (#3876). - if (reading.Modules?.FirstOrDefault( - name => name.Equals(bundle, StringComparison.OrdinalIgnoreCase)) is not { } sealedName) - return NoSuchBundle(); - var path = Path.Combine( - reading.Directory ?? directory, PublishedBundleCatalogue.ModulesDirectoryName, sealedName); - return SealedBytes(http, identity, source, directory, path, sealedName); - } - - /// - /// 🚨 #3876 — THE serve, and the OPEN is what decides. Results.File(path, …) resolves the - /// path again when the RESULT executes, which is after the handler has returned: the seal read - /// that listed this name (and stat'ed it present) is by then several steps in the past, and the - /// publisher replaces a publication several times an hour during a release while retention - /// removes whole identity directories. A file that went away in between threw - /// — or , which is - /// what a removed parent throws on Linux — out of result execution, past every handler, to - /// ExceptionHandlerMiddleware: a 500 for a condition this route has had a correct answer - /// for since #3401. The listing check upstream cannot close it; only the open can. - /// - /// Opening HERE and handing the result an already-open handle also removes the window - /// rather than merely reporting it: on POSIX the bytes stay readable through an open descriptor - /// after the directory entry is gone, so a publication replaced mid-response is served whole - /// from the generation the ETag pins instead of being torn across two. The share is opened - /// so the publisher is never blocked by a read in flight. - /// - /// 🚨 Only ABSENCE is classified, exactly as - /// ShippedPrebuiltBundles.ReadSealLines does it. A file that is present but unreadable — - /// denied, locked, a share fault — is NOT a republish window, and must keep surfacing: dressing - /// a permanent fault as "come back in thirty seconds" would have every consumer retry forever - /// against a publication that will never be readable. - /// - private static IResult SealedBytes( - HttpContext http, string identity, string source, string directory, string path, string bundle) - { - // Obtained before the open so the vanishing can be REPORTED — this is the event the - // incident behind #3876 was filed from, and a 503 with nothing in the log behind it is not - // something the next reader can act on. - var logger = Log(http); - FileStream bytes; - try - { - bytes = new FileStream( - path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - } - catch (Exception error) when (error is FileNotFoundException or DirectoryNotFoundException) - { - logger?.LogInformation(error, - "PrebuiltPublication: '{Bundle}' was listed by the seal read a moment ago and was gone at " - + "the open — source '{Source}', framework identity '{Identity}'. The publication is " - + "being replaced or reclaimed; answering transiently rather than 500", - bundle, source, identity); - return BeingRepublished(http, identity, source, directory, - $"the publication's bytes moved while it was being served — '{bundle}' is listed by " - + "the seal that was read a moment ago and was gone at the open"); - } - return Results.File(bytes, "application/zip", fileDownloadName: bundle); - } - - /// - /// The three refusals, in the order that leaks least: no caller (401, the group filter's - /// message), a path segment that is not a bare name (400 — `..` or a separator would walk - /// the share), a caller without a whole-source grant (403). Records the decision on the - /// ledger like every other bundle route, so a refused fetch is as visible as a refused serve. - /// - private static IResult? PrebuiltDecision( - HttpContext http, string identity, string source, - AuthenticatedInstance? caller, AuthenticatedBuild? build) - { - if (caller is null && build is null) - return Results.Json(new { error = "a registered instance key is required" }, - statusCode: StatusCodes.Status401Unauthorized); - if (!IsBareName(identity) || !IsBareName(source)) - return Results.Json(new { error = "identity and source must be bare names" }, - statusCode: StatusCodes.Status400BadRequest); - - // 🚨 The BUILD leg (#2483). A verified GitHub Actions token establishes WHICH repository - // asked; the admin-owned BuildPrincipal node decides whether it may fetch THIS source, on - // THIS event. It is the same shape as the instance leg one line below — credential resolves - // to identity, admin-owned node decides — so there is one thing to keep honest, not two. - if (caller is null) - return BuildDecision(http, source, build!); - - // A PLAN-LESS whole-source entry, specifically: a sealed publication carries every plan's - // bundles, so a plan-scoped `/*@pro` — which licenses that source's packages one by - // one, by tier — must never fetch the publication whole. - var allowed = caller.Grant.AllowsWholeSource(source); - Ledger(http)?.Record(new EntitlementDecision( - $"{PrebuiltSegment}/{source}", - allowed ? EntitlementOutcome.Granted : EntitlementOutcome.Denied, - EntitlementAnchorKind.Registry, - source, - true, - allowed - ? $"whole-source grant '{source}/*' held by {caller.Instance.InstanceId}" - : $"{caller.Instance.InstanceId} holds no '{source}/*' grant — a fetch needs the whole source")); - return allowed - ? null - : Results.Json( - new { error = $"instance '{caller.Instance.InstanceId}' is not granted source '{source}'" }, - statusCode: StatusCodes.Status403Forbidden); - } - - /// - /// The build principal's half of : fetch:<source> on - /// the repository's own , decided against the run's event_name - /// and ref (#2483). - /// - /// 🚨 The refusal BODY names the repository and the source and nothing else. The reason — - /// which check failed — goes to the log and the entitlement ledger, because the URL space here - /// is fully predictable and a reason that distinguishes "wrong event" from "no scope" from "no - /// such publication" is an inventory oracle over every source this registry holds. - /// - private static IResult? BuildDecision(HttpContext http, string source, AuthenticatedBuild build) - { - var refusal = build.Refuse(BuildVerbs.Fetch, source, DateTimeOffset.UtcNow); - var allowed = refusal is null; - Ledger(http)?.Record(new EntitlementDecision( - $"{PrebuiltSegment}/{source}", - allowed ? EntitlementOutcome.Granted : EntitlementOutcome.Denied, - EntitlementAnchorKind.Registry, - source, - true, - allowed - ? $"build principal {build.PrincipalPath} holds '{BuildPrincipal.Scope(BuildVerbs.Fetch, source)}' " - + $"for {build.Repository} on event '{build.Claims.EventName}'" - : $"build principal {build.PrincipalPath} ({build.Repository}, event " - + $"'{build.Claims.EventName}'): {refusal}")); - if (allowed) - return null; - - Log(http)?.LogWarning( - "Plugin bundles: build principal {Path} refused for source {Source} — {Reason}", - build.PrincipalPath, source, refusal); - return Results.Json( - new { error = $"repository '{build.Repository}' may not fetch source '{source}'" }, - statusCode: StatusCodes.Status403Forbidden); - } - - private static string? PrebuiltDirectory(HttpContext http, string identity, string source) - { - var root = http.RequestServices.GetService()?[PublishedBundleCatalogue.PublishedRootConfigKey]; - if (string.IsNullOrWhiteSpace(root)) - return null; - var directory = Path.Combine(root, identity, source); - return Directory.Exists(directory) ? directory : null; - } - - private static bool IsBareName(string segment) => - segment.Length > 0 - && segment.IndexOfAny(['/', '\\', ':']) < 0 - && segment != "." && segment != ".."; - - private static ILogger? Log(HttpContext http) => - http.RequestServices.GetService()?.CreateLogger(typeof(PluginBundleEndpoints)); - - /// - /// Copies an upload into a temporary file that deletes itself on close, and returns it - /// positioned at its start — the publish endpoint's one buffer, and one that is not the managed - /// heap (#5501). Internal so a test can pin that the spool is a FILE and holds the body - /// byte-exact, including a chunked body that carries no Content-Length. - /// - /// The request body. - /// The request's cancellation. - /// The spooled upload, readable and seekable; the caller disposes it. - internal static async Task SpoolUploadAsync(Stream body, CancellationToken ct) - { - var spool = new FileStream( - Path.Combine(Path.GetTempPath(), "mw-publish-" + Guid.NewGuid().ToString("N") + ".bundle"), - FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, bufferSize: 81920, - FileOptions.DeleteOnClose | FileOptions.Asynchronous | FileOptions.SequentialScan); - try - { - await body.CopyToAsync(spool, ct); - await spool.FlushAsync(ct); - spool.Position = 0; - return spool; - } - catch - { - // Not a swallow: the fault propagates unchanged. The spool is closed (and, by - // DeleteOnClose, removed) because nothing else will ever hold a reference to it. - await spool.DisposeAsync(); - throw; - } - } - - private static Stream Rewound(Stream stream) - { - stream.Position = 0; - return stream; - } - - private static void MapPublish(IEndpointRouteBuilder endpoints) - { - var config = endpoints.ServiceProvider.GetService(); - var token = config?[ModulePublish.TokenConfigKey]; - if (string.IsNullOrWhiteSpace(token)) - return; - - endpoints.MapPost($"{RoutePrefix}/{{plugin}}", async ( - HttpContext http, string plugin, CancellationToken ct) => - { - var logger = http.RequestServices.GetService() - ?.CreateLogger(typeof(PluginBundleEndpoints)); - - if (ModulePublish.DeclineAuthorization(token, http.Request.Headers.Authorization) is { } denied) - { - logger?.LogWarning("Module publish for {Plugin} REJECTED: {Reason}", plugin, denied); - return Results.Json(new { error = denied }, statusCode: StatusCodes.Status401Unauthorized); - } - - var landing = http.RequestServices.GetService() - ?? RootHub(http).ServiceProvider.GetService(); - if (landing is null) - return Results.Json( - new { error = "this instance cannot land modules (no ModuleLandingService)" }, - statusCode: StatusCodes.Status503ServiceUnavailable); - - // 🚨 SPOOL THE UPLOAD TO DISK, NEVER TO THE HEAP (#5501). The body used to be copied - // into a growing MemoryStream and then ToArray()'d: every capacity doubling left its - // predecessor for the GC and the final copy held the whole bundle once more, so a - // bundle of N bytes needed up to ~3N of contiguous large-object heap — and the - // endpoint died with OutOfMemoryException in MemoryStream.ToArray (2026-09-23) and - // in MemoryStream.set_Capacity before it (2026-09-05). The archive only has to be - // SEEKABLE for ZipArchive to read it in place, and a file is; the three readers - // below each open it from the start, and DeleteOnClose removes it with the request. - await using var bundle = await SpoolUploadAsync(http.Request.Body, ct); - if (bundle.Length == 0) - return Results.Json(new { error = "the request carried no bundle" }, - statusCode: StatusCodes.Status400BadRequest); - - BundleReader.Manifest? manifest; - IReadOnlyList files; - IReadOnlyList assets; - IReadOnlyList natives; - try - { - (manifest, files) = BundleReader.ReadModuleFrom(Rewound(bundle)); - // Inside the SAME guard: a bundle whose assemblies read cleanly can still carry - // a corrupt asset entry, and reading that outside here would throw out of the - // handler as a 500 — telling the publisher "the server broke" for what is - // simply an unreadable upload, the case this catch already classifies. - assets = BundleReader.ReadModuleAssetsFrom(Rewound(bundle)); - // Same guard, same reason (#4126): a declared native at a layout the loader - // never probes makes ReadModuleNativeAssets throw, and that is an unreadable - // UPLOAD — a 400 naming it, never a 500 telling the publisher the server broke. - natives = BundleReader.ReadModuleNativeAssetsFrom(Rewound(bundle)); - } - catch (Exception exception) - { - logger?.LogWarning(exception, "Module publish for {Plugin}: unreadable bundle", plugin); - return Results.Json(new { error = "the upload is not a readable module bundle" }, - statusCode: StatusCodes.Status400BadRequest); - } - - var (accepted, decline) = ModulePublish.Validate( - plugin, manifest, files, http.Request.Query["version"], - http.Request.Query["packagePath"], - // 🚨 THE SHELF MUST CARRY THE ASSETS TOO. A consumer never reads this upload's - // archive — it fetches what the shelf holds — so an asset dropped at the - // warehouse door is unreachable for every instance downstream, however complete - // the packed bundle was. Both halves of this were already built (Validate takes - // them, Accepted carries them, ShelveModule lands them); only these two - // arguments were missing, and that is why every landed view pack held - // assemblies and no wwwroot: DefaultViews' bundle declares 254 static assets - // and its landed generation had 11 files with no wwwroot, EntityViews' scoped - // CSS 404'd in production from #2188 on, and the Views/Graph image copies were - // the only reason a portal was styled at all (#2221). Same read the consumer's - // own landing does (PluginBundleClient.LandFromBundle), so shelf and adopt - // place byte-identical content. - assets, - // 🚨 AND THE NATIVES (#4126), for the same reason one step harder: a consumer - // fetches what the shelf holds, so an engine dropped here is a module that - // lands everywhere downstream, loads, and throws DllNotFoundException at its - // first P/Invoke — with nothing in any log to connect the two. - natives); - if (accepted is null) - { - logger?.LogWarning("Module publish for {Plugin} REFUSED: {Reason}", plugin, decline); - return Results.Json(new { error = decline }, statusCode: StatusCodes.Status400BadRequest); - } - - // 🚨 #3211's last refusal is ARMED (#3240), and it lives in ModulePublish.Validate - // above — so an unstated framework identity has already left through the 400 path a - // few lines up, named, and `accepted.FrameworkMvid` is non-blank from here on. - // - // A WARNING used to stand here instead. It was the runway, not the fix: it named - // WHICH publisher was still emitting nulls so the refusal could be armed on - // evidence rather than on faith (arming it while producers still packed - // "built-against MVID (unrecorded)" would have taken the fleet's publishes down - // instead of the nulls). Both halves of #3240's criterion were measured on - // 2026-09-05 — every producer's pin past #3237, and a full publish wave from each - // stating an identity — so the runway is spent and the warning is gone with it. - // Do NOT reintroduce a warning here: a null that is merely logged is a null that - // gets shelved, and this is the one place that can still refuse it. - - ModuleLandingOutcome outcome; - try - { - // 🚨 The SHELF landing, not the adopt one (2026-08-22): publishing stocks the - // registry's warehouse, and a warehouse may carry modules for platforms NEWER - // than itself. An upload whose bytes this instance cannot LINK therefore lands - // as HELD — bytes on the shelf, served to consumers (whose own landing - // measures them against THEIR platform), parked by this instance's boot until - // a platform update carries the types — instead of the 409 that deadlocked - // extracted modules against the very platform update that needed them - // (rc6→rc7, 2026-08-22). 🚨 A declared minMeshVersion above this instance's - // platform no longer holds anything (#3648): it is recorded and logged as an - // advisory, and bytes that link land unheld. Real refusals (the app-closure - // same-identity trap-door, malformed names) still surface as the observable's - // error. - outcome = (await landing.ShelveModule( - accepted.Module, accepted.Files, - frameworkMvid: accepted.FrameworkMvid, - packagePath: accepted.PackagePath, - version: accepted.Version, - minMeshVersion: accepted.MinMeshVersion, - staticAssets: accepted.StaticAssets, - sourceCommit: accepted.SourceCommit, - nativeAssets: accepted.NativeAssets) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Module publish for {Plugin}: landing '{Module}' faulted after the " - + "publish had already been answered", plugin, accepted.Module), - ct))!; - } - catch (Exception exception) - { - logger?.LogWarning(exception, - "Module publish for {Plugin}: landing refused '{Module}'", plugin, accepted.Module); - return LandingFailure(exception); - } - - // 🚨 #3996 — the upload is ACCEPTED but did not become the head. Deliberately a 200 - // and not a 409: the publisher did its job (its artifact is on the shelf), and the - // reason it is not the head is the OTHER lane's timing, not a fault in this upload. - // Failing the build for it would red a repo's CI for a race it cannot see. - if (outcome.ShelfOnly) - logger?.LogInformation( - "Module publish: SHELVED '{Module}' for {Plugin} ({Files} file(s), version " - + "{Version}) but NOT as the head — {Reason} Two lanes publish this module " - + "(#3461); the head is the highest version the shelf holds, never the last " - + "upload to arrive.", - accepted.Module, plugin, accepted.Files.Count, - accepted.Version ?? "(unversioned)", outcome.ShelfOnlyReason); - else if (outcome.Held) - logger?.LogInformation( - "Module publish: SHELVED '{Module}' for {Plugin} ({Files} file(s), version " - + "{Version}) — HELD from local activation ({Reason}); it serves from this " - + "registry, and this instance loads it once its platform carries the types " - + "it links against", - accepted.Module, plugin, accepted.Files.Count, - accepted.Version ?? "(unversioned)", outcome.HoldReason); - else - logger?.LogInformation( - "Module publish: landed '{Module}' for {Plugin} ({Files} file(s), version {Version}, " - + "declared floor {Floor} — advisory) — it serves from this registry and loads " - + "here on the next restart", - accepted.Module, plugin, accepted.Files.Count, - accepted.Version ?? "(unversioned)", accepted.MinMeshVersion ?? "(none)"); - - // 🚨 #3395 — a publish is a landing wave of one module, and a wave that does not - // propose its module set never activates ANYWHERE: boot loads the mesh's set, not - // the activation record it was derived from. Held or not, the set has to name it: - // a held entry is parked by the link probe at boot exactly as before, and it must - // still be in the set for the boot AFTER a platform update to be able to load it - // (which is the whole shelf contract). Never fails the publish — an - // unproposable set leaves this instance on the set it is already on, the bytes - // still serve to consumers, and the next wave proposes again. - try - { - await landing.ProposeModuleSet() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Module publish for {Plugin}: proposing the module set after " - + "'{Module}' faulted late", plugin, accepted.Module), - ct); - } - catch (Exception exception) - { - logger?.LogWarning(exception, - "Module publish for {Plugin}: '{Module}' landed but its module set could " - + "not be proposed — this instance stays on its current set and the next " - + "landing wave proposes again; the bytes still serve to consumers", - plugin, accepted.Module); - } - - // 🚨 #3650 — the bytes are on the shelf: tell every consumer NOW, so the one that - // installed this package reconciles it within minutes instead of at its next boot - // (rule R3 of Doc/Architecture/ModuleAdoptionPolicy). Detached from this response - // on purpose: the publisher is a CI job, and a slow or unreachable consumer must - // hold neither it nor the publish's own verdict — the broadcaster absorbs every - // per-consumer failure into a logged outcome, and a consumer the broadcast never - // reaches is caught by its own safety net. - BroadcastPublished(http, plugin, accepted, logger); - - // held/holdReason let the publisher tell "shelved, will serve" apart from - // "activated here"; pendingRestart is honest for the held case — a restart of - // THIS instance would not load a held module, so nothing is pending on one. - // 🚨 shelfOnly/shelfOnlyReason are the third answer (#3996): the bytes are on the - // shelf but a HIGHER version is the head, so this registry serves that one and no - // restart is pending on this upload either. Additive fields — an older publisher - // reads the response exactly as it did before. - // retainedAsFallback says whether a shelf-only upload's bytes were KEPT (the head's - // fallback, listed and downloadable at their own version) or lost the one fallback - // slot to a better generation; headVersion names what the head is after this upload. - // pendingRestart is the landing's own verdict (ModuleLandingOutcome.RestartRequired). - return Results.Json(new - { - plugin, - module = accepted.Module, - version = accepted.Version, - files = accepted.Files.Count, - held = outcome.Held, - holdReason = outcome.HoldReason, - shelfOnly = outcome.ShelfOnly, - shelfOnlyReason = outcome.ShelfOnlyReason, - retainedAsFallback = outcome.RetainedAsFallback, - headVersion = outcome.HeadVersion, - pendingRestart = outcome.RestartRequired, - }); - }) - .AllowAnonymous(); - } - - /// - /// Fires the module-published broadcast for one accepted publish (#3650), subscribed detached - /// from the request: the registry names itself by its configured public URL - /// (PluginCatalog:HomeUrl) when it has one, else by the host this publish arrived on — - /// which is what consumers matched their PluginCatalog:Registries:N:Url against. - /// - private static void BroadcastPublished( - HttpContext http, string plugin, ModulePublish.Accepted accepted, ILogger? logger) - { - // Request services first, then the mesh's — the same two-step resolution the landing - // service above uses, and OPTIONAL at both steps: a host (or a test) that wires the - // landing service without a mesh publishes fine and simply tells nobody. - var rootHub = http.RequestServices.GetService(); - var broadcaster = http.RequestServices.GetService() - ?? rootHub?.ServiceProvider.GetService(); - if (broadcaster is null) - return; - var configuredHome = (http.RequestServices.GetService() - ?? rootHub?.ServiceProvider.GetService())?.HomeUrl; - var registryUrl = string.IsNullOrWhiteSpace(configuredHome) - ? $"{http.Request.Scheme}://{http.Request.Host}" - : configuredHome.Trim(); - broadcaster.Broadcast(new ModulePublished - { - Registry = registryUrl, - Package = plugin, - Module = accepted.Module, - Version = accepted.Version, - FrameworkMvid = accepted.FrameworkMvid, - PublishedAt = DateTimeOffset.UtcNow, - }) - .Subscribe( - _ => { }, - ex => logger?.LogWarning(ex, - "Module publish for {Plugin}: the module-published broadcast faulted — consumers " - + "reconcile on their safety net and at boot", plugin)); - } - - /// - /// The authenticated caller the filter stamped, or null. - /// - /// 🚨 Null can only mean the filter did not run — it has no anonymous branch, unlike - /// /api/plugins. It is therefore treated as "granted nothing" rather than "unscoped", so a - /// route accidentally mapped outside the group serves an empty index and a 404, never the whole - /// catalogue. The absence of an answer is never a yes (#1772). - /// - private static AuthenticatedInstance? Caller(HttpContext http) => - http.Items.TryGetValue(CallerItemKey, out var value) ? value as AuthenticatedInstance : null; - - /// The authenticated BUILD principal the filter stamped, or null (#2483). Same - /// contract as : null is "granted nothing", never "unscoped". - private static AuthenticatedBuild? BuildCaller(HttpContext http) => - http.Items.TryGetValue(BuildCallerItemKey, out var value) ? value as AuthenticatedBuild : null; - - /// Whether is one of the prebuilt-publication routes — the only - /// ones a build principal may reach. - private static bool IsPrebuiltRoute(PathString path) => - path.StartsWithSegments($"{RoutePrefix}/{PrebuiltSegment}"); - - /// - /// 🚨 THE PER-PACKAGE AUTHORIZATION (#1772), now anchored on the REGISTRY (#1782 gap 2) — - /// whether may pull , decided by the - /// admin-owned its key resolved to. - /// - /// The grant is a set of (source, package) pairs, and the whole question is where - /// the source half comes from. It used to come from the install record's - /// and nowhere else — so a package this instance had not - /// itself installed had NO binding, and "I cannot tell which source this is from" came out as - /// "you are not entitled to it". inverts that: the - /// registry's own catalog is the authority, the install record is a cache of it, and a cache - /// miss asks upstream. The GRANT match is untouched — the same - /// /api/plugins and InstallByDefault - /// make. - /// - /// 🚨 An unstamped record no longer fails dark. A record whose Source is null - /// used to match no grant entry at all and was servable to nobody, silently. Now it simply - /// carries no CACHED binding, so the anchor answers for it — from the registry's catalog, never - /// from an invented source. If the anchor cannot be consulted either, the outcome is - /// : the bytes are still withheld (the consumer's - /// PluginBundleClient reads that 404 as "no prebuilt bundle — will compile", so the cost - /// is a compile), but it is recorded as UNKNOWN rather than asserted as a denial. - /// - /// 🚨 A caller the filter did not stamp is granted NOTHING, and that is a real - /// negative rather than an unanswerable one: there is no anonymous branch on these routes, so a - /// null caller can only mean a route mapped outside the group (#1772). - /// - private static EntitlementDecision Decide( - AuthenticatedInstance? caller, BundleEntry package, PackageOriginSnapshot anchor) => - caller is null - ? new EntitlementDecision( - package.PluginId, EntitlementOutcome.Denied, EntitlementAnchorKind.None, null, true, - "no authenticated caller was stamped — these routes have no anonymous branch") - : PackageEntitlementAnchor.Resolve( - package.PluginId, - anchor.SourceOf(package.PluginId), - package.Source, - anchor.IsComplete, - // The package's PLAN, registry-first like its source: the anchor's catalog says - // which tier a package declares today, the install record's stamp is the cached - // observation for a package the anchor does not carry. - source => caller.Allows( - source, package.PluginId, anchor.TierOf(package.PluginId) ?? package.Tier)); - - /// - /// 🚨 The ONE answer for a bundle this caller cannot have — used for "no such package", "no such - /// version" and "not granted to you" alike, so the three are byte-identical: same status, same - /// (empty) body, same headers. - /// - /// The URL is /{plugin}/{version} and every plugin id in the catalogue is public - /// knowledge, so a distinguishable refusal (403, or a 404 with a different body) would let any - /// registered instance enumerate what this registry carries and at which versions — the exact - /// existence oracle /api/content closed in #587 by making its refusal identical to its - /// not-found. WHICH of the three it was is written to the LOG, where the caller cannot read - /// it. - /// - private static IResult NoSuchBundle() => Results.NotFound(); - - /// One query-string value, or the serving instance's own value when the caller did not - /// state one. Blank is treated as absent — an empty ?identity= is a client bug, and - /// answering it with "nothing resolves" would look identical to an incompatible lane. - /// - /// 🚨 Refuses a STATED framework identity that is not a bare name, before anything composes a - /// path with it — 400, the same answer and the same rule the prebuilt routes apply to - /// their {identity} segment. - /// - /// The identity used to be compared as an opaque string and nothing else; since #3768 it - /// selects a directory under the mounted publication root, so ?identity=../… would reach - /// outside the lane it names. restates the check at - /// the layer that builds the path; this one makes a malformed request an ERROR rather than a - /// silent fallback, so a client bug is visible instead of looking like an unbaked lane. A - /// caller that states no identity is untouched — that is every consumer older than #3768. - /// - private static IResult? RefuseMalformedIdentity(HttpContext http) - { - var stated = http.Request.Query["identity"].ToString(); - if (string.IsNullOrWhiteSpace(stated) || SealedLaneBundles.IsBareName(stated)) - return null; - Log(http)?.LogWarning( - "Plugin bundles: refused {Path} — the stated framework identity is not a bare name", - http.Request.Path); - return Results.Json( - new { error = "identity must be a bare name" }, - statusCode: StatusCodes.Status400BadRequest); - } - - private static string Requested(HttpContext http, string key, string fallback) => - http.Request.Query[key].ToString() is { Length: > 0 } value && !string.IsNullOrWhiteSpace(value) - ? value - : fallback; - - private static IMessageHub RootHub(HttpContext http) => - http.RequestServices.GetRequiredService(); - - /// - /// 🚨 The ENTITLEMENT ANCHOR (#1782 gap 2) — the registry's own catalog, read as the authority - /// on which source carries which package. - /// - /// Resolved from the REQUEST's services first, then the mesh's, exactly like - /// ModuleLandingService: it lets a host wire a different anchor (a test, an instance - /// serving a curated subset) without a second resolution rule. An instance with no anchor - /// registered at all is an authority on nothing — , which - /// falls back to the local cache and never to a denial. - /// - private static PackageOriginAnchor? Anchor(HttpContext http) => - http.RequestServices.GetService() - ?? RootHub(http).ServiceProvider.GetService(); - - /// - /// The activation reader the INDEX uses: the landing service's maintained snapshot - /// (), so a poll costs no scan of the - /// share (#4963). Null when this host lands no modules. - /// - private static Func>? ServedActivation(IMessageHub rootHub) => - rootHub.ServiceProvider.GetService() is { } landing - ? landing.GetServedActivation - : null; - - /// - /// The activation reader the DOWNLOAD uses: the authoritative derivation - /// (), because it decides the bytes. Null when - /// this host lands no modules. - /// - private static Func>? AuthoritativeActivation(IMessageHub rootHub) => - rootHub.ServiceProvider.GetService() is { } landing - ? landing.GetActivation - : null; - - /// - /// The registry's record of bundles pushed to the fleet's OCI registry, read once. Same - /// two-step resolution as ; a host that registers none renders no - /// artifact, which is exactly what the platform default answers. - /// - private static IObservable> Artifacts(HttpContext http) => - (http.RequestServices.GetService() - ?? RootHub(http).ServiceProvider.GetService()) - ?.Read().Take(1) - ?? Observable.Return((IReadOnlyList)[]); - - /// The record that makes a degraded entitlement answer legible (#1782 gap 2). Same - /// two-step resolution as . - private static PackageEntitlementLedger? Ledger(HttpContext http) => - http.RequestServices.GetService() - ?? RootHub(http).ServiceProvider.GetService(); - - /// Reads the anchor, or reports it unconfigured when this instance registers none. - private static IObservable ReadAnchor(PackageOriginAnchor? anchor) => - anchor?.Read() - ?? Observable.Return( - PackageOriginSnapshot.Empty(AnchorState.Unconfigured, DateTimeOffset.UtcNow)); - - /// - /// What this instance can serve, and the framework identity it serves it for. - /// - /// The framework MVID is at the TOP, not per-bundle: every bundle listed here is resolved - /// for ONE lane, so a consumer compares it once and skips the whole fetch when it does not - /// match — downloading bundles it is then obliged to decline is pure waste, and the decline - /// itself is silent (see ). - /// - /// 🚨 That lane is the one the CALLER asked for, not this portal's own bake - /// (#3768). It used to be unconditionally, on the premise that - /// "every assembly here was produced by this portal's own bake" — a premise #1751 retired when - /// the bundle route learned to resolve another lane, and #3244 retired again for the module - /// half. The consequence was measured on 2026-09-09 and again on 2026-09-11: a consumer one - /// image ahead of its registry declined the whole index — 25 attempts, 0 adopted, 25 - /// FrameworkDeclined — while the publication sealed for its identity sat on the very - /// share this registry mounts. The stamp now names the lane the answer is FOR, which is the - /// same value each served bundle's own manifest carries, and it is claimed only when this - /// registry HOLDS a sealed publication for it: with none, the caller is told this portal's own - /// identity and declines exactly as before, because a bake that does not exist here is a bake - /// gap and must not read as a serving offer. - /// - /// Absolute URLs are built from the REQUEST rather than configuration: an instance reached - /// through an ingress, a port-forward or a custom domain must advertise the host the caller - /// actually used, or every follow-up request goes somewhere it cannot reach. - /// - /// 🚨 Scoped to what the caller was GRANTED (#1772, ) — an - /// ungranted package is simply not listed, so a caller cannot even learn it is installed here. - /// That is what makes the download route's refusal non-informative: with the index filtered, "not - /// in your index" and "404 on fetch" agree, and neither confirms existence. A caller granted - /// nothing gets an empty bundles array, indistinguishable from a registry with nothing - /// installed. - /// - /// 🚨 Only the DEGRADED decisions are recorded here (#1782 gap 2). An index request - /// resolves every servable package at once and is polled by every consumer, so recording all of - /// them would fill a bounded ledger with routine grants and evict the answers worth keeping. The - /// download route records every decision it makes — one per actual fetch. - /// - private static Task Index( - HttpContext http, IMessageHub rootHub, string identity, string architecture, - AuthenticatedInstance? caller, CancellationToken ct) - { - var baseUrl = $"{http.Request.Scheme}://{http.Request.Host}{RoutePrefix}"; - var ledger = Ledger(http); - // The lane this answer is FOR. On this instance's own lane it is FrameworkMvid and every - // bundle resolves from LastCompiledVersion, exactly as before. Off it, the claim is only - // made when a sealed publication for that identity is on the share this registry mounts — - // the one durable cross-lane source it has (SealedLaneBundles). - var servesOwnLane = - string.Equals(identity, FrameworkMvid, StringComparison.Ordinal) - && ReleaseArchitecture.Matches(ReleaseArchitecture.Live, architecture); - var servedLane = servesOwnLane - ? new ServedLane(FrameworkMvid, ReleaseArchitecture.Live) - : ServableLane(http, identity, architecture, caller); - // Resolved NOW, while the request scope is alive: a late fault arrives after - // HttpContext.RequestServices has been disposed, so resolving inside the lambda would throw - // exactly when the report is needed. - var lateFaultLogger = Log(http); - // The record of pushed publications (Doc/Architecture/PluginBundlesInTheRegistry) — resolved - // NOW for the same reason as the logger above, and read once per index request. The - // platform default records nothing, so `artifact` reads null until a host records pushes. - var artifacts = Artifacts(http); - // 🚨 The ONE host besides this registry's own to which a consumer presents the key it holds - // here (MeshWeaver#4123): this registry's OWN declaration, from its config (the chart renders - // it from registry.host), never a publisher-written bundle entry. Blank ⇒ omitted, and a - // consumer then fetches every off-host artifact over the HTTP route below, keyless toward it. - var artifactRegistry = (http.RequestServices.GetService() - ?[PluginBundleClient.ArtifactRegistryConfigKey] ?? "").Trim(); - - // 🚨 The SERVED activation list (#4963): a maintained snapshot, not a scan of every - // module's records on the share per poll — see ModuleLandingService.GetServedActivation. - var stages = RegistryAnswerDeadline.Stages(http); - return Servable(rootHub, Anchor(http), ServedActivation(rootHub), ct, stages) - .Select(state => - { - var decisions = state.Entries - .Select(entry => (Entry: entry, Decision: Decide(caller, entry, state.Anchor))) - .ToArray(); - foreach (var (_, decision) in decisions.Where(d => d.Decision.IsDegraded)) - ledger?.Record(decision); - WarnAboutDegradedResolution(rootHub, caller, state.Anchor, - decisions.Select(d => d.Decision).ToArray()); - var granted = (IReadOnlyList)decisions - .Where(d => d.Decision.Serves).Select(d => d.Entry).ToArray(); - WarnAboutAnEmptyIndex(rootHub, caller, state.Entries, granted); - return granted; - }) - .SelectMany(packages => ServableModules(rootHub, packages, ServedActivation(rootHub)) - .InStage(stages, "servable modules (module activation list)") - .SelectMany(modules => artifacts.InStage(stages, "pushed artifacts record").Select(pushed => Results.Json(new - { - frameworkMvid = servedLane.Identity, - // The architecture that identity belongs to (#1751). The identity already FOLDS - // the architecture in — the amd64 and arm64 variants of one image resolve - // different identities — but it is opaque, so a consumer on the other variant - // sees only "not adoptable" with no way to tell an incompatible framework from - // the wrong lane. Stating the architecture makes that miss diagnosable. Additive: - // a pre-#1751 client ignores it, and its BundleIndex simply reads null. - architecture = servedLane.Architecture, - // Additive: an older client's BundleIndex ignores it; null when undeclared. - artifactRegistry = artifactRegistry.Length == 0 ? null : artifactRegistry, - bundles = packages.Select(p => new - { - plugin = p.PluginId, - version = p.Version, - url = $"{baseUrl}/{Uri.EscapeDataString(p.PluginId)}/{Uri.EscapeDataString(p.Version)}", - // The compiled module this bundle carries (#1664) — stamped ONLY when this - // instance can actually serve its bytes, so a consumer never downloads for - // a module section that will not be there. Additive: an older client's - // BundleRef simply ignores it. - module = modules.TryGetValue(BundleVersionKey(p.PluginId, p.Version), out var servable) - ? servable.Name : null, - // The module's declared platform FLOOR — the consumer's gate (a semver - // floor, never MVID equality; the index-level frameworkMvid above stays - // the NodeType lane's strict gate). - minMeshVersion = servable is null ? null : p.MinMeshVersion, - // 🚨 The framework identity of THIS bundle's MODULE bytes, as their - // producer recorded it at publish (Plugins#931). Not the index-level - // identity above: that is this portal's own bake, and a module is not - // baked here. A consumer compares it against what it has landed, so a - // rebuild of unchanged source against a new platform — which republishes - // under the SAME version — is visible without downloading a byte - // (Plugins#723: without it the updater goes quiet and the fleet cannot - // converge). Additive: a pre-#931 client ignores it, and a pre-#931 - // registry simply omits it, which reads as unknown rather than as a match. - frameworkMvid = servable?.FrameworkMvid, - // The bundle as an OCI artifact in the fleet's registry - // (Doc/Architecture/PluginBundlesInTheRegistry): the digest-addressed - // reference a consumer fetches by, verified against the digest. Null on - // every bundle this registry has not pushed — the consumer then takes the - // HTTP route above, byte for byte as before. Additive. - artifact = pushed.ReferenceFor(p.Source, p.PluginId, p.Version), - }).ToArray(), - })))) - // A stalled catalogue read is 503 + Retry-After, never an unhandled 500 (#5345). - .UnavailableOnAStalledRead(http, lateFaultLogger) - // …and a stage with no bound of its own is too: the index ANSWERS or REFUSES within the - // deadline, naming what it was waiting on — never 0 bytes for the consumer's whole - // budget with no line logged here (#4963). - .AnsweredWithin(http, RegistryAnswerDeadline.Budget(http, RegistryAnswerDeadline.AnswerBudget), TransientRetryAfterSeconds, lateFaultLogger) - .FirstAsync() - .ObserveCompletion( - ex => lateFaultLogger?.LogWarning(ex, - "Plugin bundles: the index faulted after the response had already been sent"), - ct)!; - } - - /// The lane an index answer is for: the framework build identity its bundles resolve - /// against, and the architecture that identity belongs to. - private readonly record struct ServedLane(string Identity, string Architecture); - - /// - /// 🚨 The OFF-LANE half of the index's stamp (#3768): the caller's lane when this registry - /// holds a sealed publication for it, this instance's own lane when it does not. - /// - /// Why holding a publication is the right evidence, and the only cheap one. Off - /// this instance's lane the bundle route serves from - /// — the publication the producing repo's bake sealed for that - /// identity, on the share this registry already mounts and already serves at - /// …/prebuilt/{identity}/{source}/{bundle}. Its presence is one directory enumeration - /// and one seal read per source, and it answers exactly the question the stamp asks. The - /// alternative — resolving every package's Release artifacts — is a subtree query per - /// package on an endpoint every consumer polls, AND it cannot find bytes anyway: an artifact - /// for a lane this portal does not run was minted by a compile in a pod that has been replaced, - /// and its assembly is collection: "local" to that pod. - /// - /// 🚨 Claiming the caller's lane with nothing sealed for it would be worse than the - /// decline it replaces: the consumer would fetch every package, each resolving no - /// assemblies, and a cheap named FrameworkDeclined would become N empty downloads. A - /// bake gap must keep reading as a bake gap. - /// - private static ServedLane ServableLane( - HttpContext http, string identity, string architecture, AuthenticatedInstance? caller) - { - var logger = Log(http); - var publishedRoot = http.RequestServices.GetService() - ?[PublishedBundleCatalogue.PublishedRootConfigKey]; - var sealedBundles = SealedLaneBundles.ServableFor(publishedRoot, identity, logger); - if (sealedBundles.IsEmpty) - { - // Named, because this is the state that makes a consumer compile everything on every - // boot and it is otherwise visible only from the consumer's own health payload. - logger?.LogInformation( - "Plugin bundles: {Instance} asks on framework {Identity}/{Architecture}; this " - + "registry bakes {OwnIdentity}/{OwnArchitecture} and holds no sealed publication " - + "for the caller's lane, so the index states its own — every package will be " - + "declined there and COMPILED. Publishing a bake for the caller's identity is " - + "what closes it.", - caller?.Instance.InstanceId ?? "an unauthenticated caller", identity, architecture, - FrameworkMvid, ReleaseArchitecture.Live); - return new ServedLane(FrameworkMvid, ReleaseArchitecture.Live); - } - - logger?.LogInformation( - "Plugin bundles: serving {Instance} on framework {Identity}/{Architecture} from the " - + "{Count} bundle(s) sealed for that lane (this registry bakes {OwnIdentity})", - caller?.Instance.InstanceId ?? "an unauthenticated caller", identity, architecture, - sealedBundles.Count, FrameworkMvid); - return new ServedLane(identity, architecture); - } - - /// - /// 🚨 Names, on the index request, every install record with NO - /// — the records can never match, and which are therefore servable to - /// nobody. - /// - /// It belongs HERE rather than only on the download path, because the filtered index means - /// that path is never reached for such a record: a consumer polls the index, does not see the - /// package, and never asks for it. Without this line the whole distribution lane could go dark - /// with nothing in the log at all — every consumer just quietly compiling, which looks exactly - /// like a healthy day. Normally this logs nothing, since a record installed through any registry - /// lane carries its source. - /// - /// - /// Everything this registry can serve, and the state of the anchor the entitlement decision - /// will be made against. - /// - /// THREE contributors, in precedence order: - /// - /// its own INSTALL RECORDS (the catalog lane) — a deliberate install is the most - /// intentional statement, so it wins a PluginId collision; - /// the modules PUBLISHED onto it (the activation sidecar). The union with (1) is what - /// makes a GitSync-native registry serve at all: memex-cloud provisions its packages as - /// Spaces and never runs the catalog install, so it has NO install records — with a - /// record-only index its bundle feed was permanently empty and every consumer read - /// SkipNoBundle (found live, 2026-08-20, the first real remote consumer); - /// 🚨 the packages the REGISTRY ANCHOR advertises whose content this instance actually - /// holds (#1782 gap 2). This is the half that could not exist before the anchor: a package - /// provisioned as a Space, with compiled NodeTypes and no install record and no published - /// module, had nothing to bind a grant to and was therefore unreachable through the index - /// however plainly it was installed. - /// - /// - /// 🚨 The sidecar's PackagePath source in (2) is now read as a CACHED claim rather - /// than as authority — lets the anchor override it whenever the registry - /// carries the package. It is the publisher's own assertion about which source it belongs to, - /// and believing an uploader's assertion outright is exactly the invented anchor #1782 warned - /// would weaken #1777. - /// - private static IObservable<(IReadOnlyList Entries, PackageOriginSnapshot Anchor)> Servable( - IMessageHub rootHub, PackageOriginAnchor? anchor, - Func>? activation, CancellationToken ct, - RegistryRequestStages? stages = null) => - // Each wait is a named stage of the request (RegistryAnswerDeadline, #4963): a route that - // has not answered by the deadline says WHICH of these it was still waiting on. - InstalledPackages(rootHub, ct) - .InStage(stages, "installed packages (mesh query)") - .Do(packages => WarnAboutUnstampedRecords(rootHub, packages)) - .SelectMany(records => WithPublishedModules(records, activation) - .InStage(stages, "module activation list (module records on the share)")) - .SelectMany(local => ReadAnchor(anchor) - .InStage(stages, "package origin anchor") - .SelectMany(snapshot => WithAnchoredPackages(rootHub, local, snapshot) - .InStage(stages, "held partitions (mesh query)") - .Select(entries => (Entries: entries, Anchor: snapshot)))); - - /// Contributor (2): the modules published onto this instance. Both the activation - /// head and its retained fallback are advertised when they carry distinct versions; the - /// versioned download route resolves the matching generation. An install record still wins an - /// identical package/version entry. - private static IObservable> WithPublishedModules( - IReadOnlyList records, Func>? readActivation) - { - if (readActivation is null) - return Observable.Return(records); - return readActivation().Take(1).Select(activation => - { - var published = activation.Entries - .Where(e => e.Enabled - && !string.IsNullOrWhiteSpace(e.Version) - && e.PackagePath?.Split('/') is { Length: 2 }) - .SelectMany(e => PublishedVersions(e)); - - // Head before fallback, and newer before older for every package. PluginBundleClient - // selects the first entry of a package for the ordinary update path; listing an older - // retained generation first would reintroduce #3996 at the index boundary. A local - // install record wins an identical package/version because `OrderBy` is stable and the - // records enter the sequence first. When an identical install record exists, keep its - // entitlement/package metadata but join it to the published generation pointer. - var ordered = records.Concat(published) - .OrderBy(p => p.PluginId, StringComparer.OrdinalIgnoreCase) - .ThenByDescending(p => p.Version, NuGetVersionComparer.Instance); - var unique = ordered.Aggregate(ImmutableList.Empty, (kept, candidate) => - { - var match = kept.FindIndex(current => - string.Equals(current.PluginId, candidate.PluginId, - StringComparison.OrdinalIgnoreCase) - // Exact TEXT, the rule the download route matches by: the SemVer comparer - // reads unparseable parts as 0, so two non-SemVer labels would collapse. - && string.Equals(current.Version, candidate.Version, - StringComparison.OrdinalIgnoreCase)); - if (match < 0) - return kept.Add(candidate); - return candidate.ShelfVersion is not null && kept[match].ShelfVersion is null - ? kept.SetItem(match, kept[match] with - { - Module = candidate.Module ?? kept[match].Module, - ShelfVersion = candidate.ShelfVersion, - }) - : kept; - }); - return (IReadOnlyList)unique; - - static IEnumerable PublishedVersions(ModuleActivationEntry head) - { - yield return ToBundle(head, head.MinMeshVersion); - var previous = ModuleActivationBoot.PreviousGeneration(head); - if (previous is { Version.Length: > 0 }) - // The activation record predates a previous-floor field. Null is the honest - // answer; copying the head's floor would state a claim the older bytes did not. - yield return ToBundle(previous, minMeshVersion: null); - - static BundleEntry ToBundle( - ModuleActivationEntry entry, string? minMeshVersion) - { - var segments = entry.PackagePath!.Split('/'); - return new BundleEntry( - segments[1], entry.Version!, segments[1], - Module: entry.Name, - MinMeshVersion: minMeshVersion, - Source: segments[0], - ShelfVersion: entry.Version); - } - } - }); - } - - /// - /// Contributor (3): the packages the anchor advertises that nothing local already covers — the - /// gap-2 half. - /// - /// 🚨 Gated on this instance actually HOLDING the package's partition, because the - /// bundle is assembled from the NodeType assemblies under it. Advertising a package whose - /// content is not here would hand every consumer an empty archive to download and then count as - /// a miss — an index full of bundles that cannot carry anything is a worse answer than a - /// filtered one. - /// - /// 🚨 …and that gate FAILS OPEN. If the partition list comes back empty — the query could - /// not answer, or this mesh does not register partitions the way it is read here — the - /// candidates are kept rather than dropped. A gate whose inability to answer removes entries is - /// the very shape this change exists to remove; it is an optimization, and an optimization must - /// never be the thing that denies. - /// - private static IObservable> WithAnchoredPackages( - IMessageHub rootHub, IReadOnlyList local, PackageOriginSnapshot anchor) - { - var covered = local.Select(e => e.PluginId).ToHashSet(StringComparer.OrdinalIgnoreCase); - var candidates = anchor.Origins.Values - .Where(origin => !covered.Contains(origin.PackageId) - && !string.IsNullOrWhiteSpace(origin.ReleasedVersion)) - .ToArray(); - if (candidates.Length == 0) - return Observable.Return(local); - - return HeldPartitions(rootHub).Select(held => - { - var servable = held.Count == 0 - ? candidates - : candidates.Where(origin => held.Contains(origin.Partition)).ToArray(); - return (IReadOnlyList)local - .Concat(servable.Select(origin => new BundleEntry( - PackagingManifest.IdPrefix + origin.PackageId, - origin.ReleasedVersion!, - origin.PackageId, - Module: origin.Module, - MinMeshVersion: origin.MinMeshVersion, - // No CACHED binding: this entry exists only because the anchor advertises it, - // and Decide reads that binding straight off the snapshot. - Source: null, - Tier: origin.Tier))) - .ToArray(); - }); - } - - /// - /// The partitions this mesh holds, from the Admin/Partition registry every Space writes. - /// An empty set means "could not answer" as much as "none", and both callers treat it that way - /// (see ) — never as evidence of absence. - /// - private static IObservable> HeldPartitions(IMessageHub rootHub) => - rootHub.ServiceProvider.GetRequiredService() - .Query(MeshQueryRequest.FromQuery( - $"namespace:{PartitionNodeType.Namespace} nodeType:{PartitionNodeType.NodeType}")) - .Where(c => c.ChangeType == QueryChangeType.Initial) - .Take(1) - .Select(c => (IReadOnlySet)c.Items - .Select(node => node.Id) - .Where(id => !string.IsNullOrWhiteSpace(id)) - .ToHashSet(StringComparer.OrdinalIgnoreCase)) - .Catch((Exception _) => - Observable.Return((IReadOnlySet)new HashSet(StringComparer.OrdinalIgnoreCase))); - - /// - /// 🚨 Says so when an index request serves NOTHING — the one outcome that is silent on both - /// sides and looks exactly like a healthy day. - /// - /// A consumer that receives {"bundles": []} concludes SkipNoBundle for every - /// module, lands nothing, and logs nothing worth reading; the registry logs nothing at all. That - /// is how the whole distribution lane went dark on 2026-08-20 with every consumer quietly - /// compiling: the Plugins partition was invisible to the record query, so - /// returned an empty list and the emptiness had no author - /// (#1950). The two empties need different responses, so they get different lines: nothing - /// SERVABLE at all points at this instance's own state, while entries filtered down to zero - /// points at the caller's grant. - /// - private static void WarnAboutAnEmptyIndex( - IMessageHub rootHub, AuthenticatedInstance? caller, - IReadOnlyList servable, IReadOnlyList granted) - { - if (granted.Count > 0) - return; - - var logger = rootHub.ServiceProvider.GetService() - ?.CreateLogger(typeof(PluginBundleEndpoints)); - if (logger is null) - return; - - if (servable.Count == 0) - logger.LogWarning( - "Plugin bundles: serving an EMPTY index to {Instance} — this instance has nothing " - + "servable at all (no install records in the '{Partition}' partition and no " - + "published module entries). Every consumer will read SkipNoBundle for every " - + "package. If packages ARE installed here, the records partition is not readable " - + "by the query — check that its '_Policy' exists as a DURABLE node (#1950).", - caller?.Instance.InstanceId ?? "an unauthenticated caller", - PackageInstaller.InstalledPartition); - else - logger.LogWarning( - "Plugin bundles: serving an EMPTY index to {Instance} — {Count} servable " - + "package(s) exist here, but none is covered by that instance's grant, so it will " - + "read SkipNoBundle for every package. Grant it the sources it needs.", - caller?.Instance.InstanceId ?? "an unauthenticated caller", servable.Count); - } - - /// - /// 🚨 Says so when entitlement was resolved WITHOUT the anchor (#1782 gap 2) — the state that - /// otherwise looks exactly like a normal day from both sides. - /// - /// A registry that cannot list its sources still answers every request: granted packages - /// keep flowing from the cached bindings, and the packages nothing ever observed simply do not - /// appear. Nothing is red, nothing 500s, and the one difference — that some answers were - /// guesses the system declined to make — is invisible unless it is said out loud. The two - /// degradations get different lines because they need different responses: an anchor that - /// cannot be read at all is an operational failure to go and fix, while an UNKNOWN package is - /// the consumer-visible consequence of it. - /// - private static void WarnAboutDegradedResolution( - IMessageHub rootHub, AuthenticatedInstance? caller, PackageOriginSnapshot anchor, - IReadOnlyList decisions) - { - if (anchor.IsComplete) - return; - - var logger = rootHub.ServiceProvider.GetService() - ?.CreateLogger(typeof(PluginBundleEndpoints)); - if (logger is null) - return; - - var unknown = decisions - .Where(d => d.Outcome == EntitlementOutcome.Indeterminate).ToArray(); - logger.LogWarning( - "Plugin bundles: entitlement for {Instance} was resolved WITHOUT a complete registry " - + "answer — {Anchor}. {Cached} package(s) were answered from a previously observed " - + "binding (served as before, deliberately: an unreachable registry is not evidence of " - + "a missing entitlement) and {Unknown} could not be answered at all — those are " - + "UNKNOWN, not denials: {Names}", - caller?.Instance.InstanceId ?? "an unauthenticated caller", - anchor.Describe(), - decisions.Count(d => d.Anchor == EntitlementAnchorKind.Cache), - unknown.Length, - unknown.Length == 0 - ? "(none)" - : string.Join(", ", unknown.Select(d => d.PackageId).Take(MissesReported))); - } - - private static void WarnAboutUnstampedRecords( - IMessageHub rootHub, IReadOnlyList packages) - { - var unstamped = packages.Where(p => string.IsNullOrWhiteSpace(p.Source)).ToArray(); - if (unstamped.Length == 0) - return; - - rootHub.ServiceProvider.GetService() - ?.CreateLogger(typeof(PluginBundleEndpoints)) - .LogWarning( - "Plugin bundles: {Count} install record(s) carry no registry source, so they have " - + "no CACHED binding and their entitlement can only be answered by the registry " - + "anchor: {Packages}. Before #1782 gap 2 that made them servable to NO instance, " - + "silently; they are now resolved upstream, and become UNKNOWN only when the " - + "anchor is unreachable too. Re-install them through the registry to stamp the " - + "source (#1772) and the answer stops depending on the registry being up.", - unstamped.Length, - string.Join(", ", unstamped.Select(p => p.PluginId).Take(MissesReported))); - } - - /// - /// Which of the installed packages' declared modules this instance can serve right now: - /// (plugin id, version) → module assembly name, for exactly the entries whose bytes exist under - /// modules/<name>/ and were not uninstalled (). - /// A HELD landing — floor above THIS instance's platform, the registry-shelf state (2026-08-22) — - /// is listed too, deliberately: the index surfaces its minMeshVersion and each - /// consumer's own gate decides loadability THERE, before a byte travels. Both retained - /// generations can be listed under their own versions; one activation-sidecar read resolves - /// the whole index. - /// - /// 🚨 Each entry also carries the framework identity the SHELF recorded for those module - /// bytes (Plugins#931). It is the producer's value, written when the owning repo's CI published - /// them (ModulePublish.Accepted.FrameworkMvid → ShelveModule) — never this - /// portal's own bake identity, which the index states separately and which says nothing about a - /// module the registry did not build. Without it a consumer cannot tell a REBUILD of the same - /// source against a new platform from a no-op, because such a rebuild republishes under the - /// same version; that is the whole defect. Null when the producer recorded none, or for a - /// module that rides the image and has no sidecar entry — read downstream as "unknown", never - /// as "matches", and never inferred from anything. - /// - private static IObservable> ServableModules( - IMessageHub rootHub, IReadOnlyList packages, - Func>? readActivation) - { - var landing = rootHub.ServiceProvider.GetService(); - var declaring = packages.Where(p => !string.IsNullOrWhiteSpace(p.Module)).ToArray(); - if (landing is null || readActivation is null || declaring.Length == 0) - return Observable.Return>( - new Dictionary()); - - return readActivation().Take(1) - // 🚨 MeshWeaver#6067 — resolved AT THE ADVERTISED VERSION, never "the head" for an - // install-record entry: a record that moved ahead of the shelf (content path) must not - // advertise a module section for bytes shelved under another version. - // ModuleBundleSource.GenerationVersionFor is the one rule, shared with the download. - .Select(activation => (IReadOnlyDictionary)declaring - .Select(p => (Package: p, Generation: ModuleBundleSource.GenerationVersionFor( - activation, p.Module!, p.Version, p.ShelfVersion))) - .Where(x => ModuleBundleSource.CollectVersion( - landing.BaseDirectory, x.Package.Module!, activation, x.Generation) - .DeclineReason is null) - .ToDictionary( - x => BundleVersionKey(x.Package.PluginId, x.Package.Version), - x => new ServableModule( - x.Package.Module!, - ModuleBundleSource.ResolveEntry(activation, x.Package.Module!, x.Generation) - ?.FrameworkMvid), - StringComparer.OrdinalIgnoreCase)); - } - - /// A stable dictionary key for a package VERSION. The separator cannot occur in a - /// package id or NuGet version, so case-insensitive package identity cannot conflate releases. - private static string BundleVersionKey(string pluginId, string version) => - pluginId + "\u001f" + version; - - /// One servable module on the index: its assembly name and the framework identity the - /// shelf recorded for its bytes (null = the producer stated none). - private sealed record ServableModule(string Name, string? FrameworkMvid); - - /// - /// One plugin's bundle: each of its NodeTypes' compiled assemblies, plus the manifest saying - /// which node each belongs to and what framework they were built against. - /// - /// Assemblies only — deliberately no node content. The consumer already installs - /// content through the registry (PackageInstaller), so shipping it again would be weight - /// on every fetch AND a second copy that can disagree with the one the installer wrote. The - /// bundle carries the one thing the consumer cannot otherwise obtain: bytes it would have had to - /// spend a compile producing. - /// - /// Assembled on request rather than stored, because the inputs ARE the storage — this - /// portal has the bake's assemblies, and a second copy kept "for distribution" is a copy that - /// can disagree with what the portal runs. - /// - /// 🚨 The caller's grant is part of the RESOLUTION, not a check bolted on after it - /// (#1772) — an ungranted package does not match, so there is no branch in which bytes are - /// assembled first and refused later, and nothing downstream has to remember to ask. Every miss - /// answers ; which of the three it was goes to the log only. - /// - private static Task Bundle( - HttpContext http, IMessageHub rootHub, string plugin, string version, string identity, - string architecture, AuthenticatedInstance? caller, CancellationToken ct) - { - var anchorService = Anchor(http); - var ledger = Ledger(http); - // Resolved NOW — see Index: the request scope is gone by the time a late fault lands. - var lateFaultLogger = Log(http); - // The published bundle root this registry mounts (#3244) — read here, in the request scope, - // for the same reason the logger is. Null on a deployment that consumes no CI bakes, which - // leaves the module section exactly as it was before this existed. - var publishedRoot = - http.RequestServices.GetService() - ?[PublishedBundleCatalogue.PublishedRootConfigKey]; - // The download stays on the AUTHORITATIVE read: it decides which bytes a consumer lands, - // so it must resolve a version another replica published a moment ago. - var stages = RegistryAnswerDeadline.Stages(http); - return Servable(rootHub, anchorService, AuthoritativeActivation(rootHub), ct, stages) - .SelectMany(state => - { - // Named apart from the query-string reader Requested(HttpContext, …) above — one - // shadowing the other reads as a call to the wrong thing. - bool IsAsked(BundleEntry p) => - string.Equals(p.PluginId, plugin, StringComparison.OrdinalIgnoreCase) - && string.Equals(p.Version, version, StringComparison.OrdinalIgnoreCase); - - var asked = state.Entries.FirstOrDefault(IsAsked); - - // 🚨 The entitlement question is answered even when this instance holds nothing that - // matches, and it is answered the SAME way — because "I hold no bytes for it" and - // "you may not have it" are different facts and the second must never be inferred - // from the first. A package with no local entry has no cached binding, so the - // decision falls to the anchor: granted, denied, or UNKNOWN. - var decision = Decide( - caller, - asked ?? new BundleEntry(plugin, version, plugin), - state.Anchor); - // Every download decision is recorded — one per actual fetch, which is the rate a - // bounded diagnostic can carry and the granularity an operator asks about. - ledger?.Record(decision); - - if (asked is not null && decision.Serves) - // 🚨 The source the ENTITLEMENT decision resolved, not the entry's cached stamp - // — the anchor is the authority on which source carries a package, and the - // sealed-lane lookup must not be able to cross the (source, package) pair this - // caller was granted. Falls back to the cached binding only where the decision - // states none. - return Assemble( - rootHub, asked, identity, architecture, publishedRoot, - decision.Source ?? asked.Source) - .InStage(stages, "bundle assembly (nodes, assemblies, archive)"); - - // The refusal is uniform on the wire; the LOG is where it is diagnosable, naming - // which instance asked, what the entitlement answer actually was, and whether this - // instance holds anything to serve. 🚨 The two halves are reported separately: an - // Indeterminate outcome is NOT a refusal of entitlement, and reporting it as one is - // how an unreachable registry would come to read as an unpaid customer. - rootHub.ServiceProvider.GetService() - ?.CreateLogger(typeof(PluginBundleEndpoints)) - .LogWarning( - "Plugin bundles: {Plugin}@{Version} not served to instance {Instance} — " - + "entitlement: {Decision}; bytes: {Bytes}. Anchor: {Anchor}", - plugin, version, caller?.Instance.InstanceId ?? "(none)", - decision.Describe(), - asked is null - ? "this instance holds no servable entry at that id and version" - : "held", - state.Anchor.Describe()); - return Observable.Return(NoSuchBundle()); - }) - // Bytes the resolution located and the archive then could not open are a serve RACE, - // not a defect of the request: 503 + Retry-After (#3876). - .TransientWhenServedBytesMoved(http, plugin, version, lateFaultLogger) - // See Index: a stalled catalogue read is 503 + Retry-After (#5345), and so is a route - // that has produced no answer by the deadline (#4963). - .UnavailableOnAStalledRead(http, lateFaultLogger) - .AnsweredWithin(http, RegistryAnswerDeadline.Budget(http, RegistryAnswerDeadline.DownloadBudget), TransientRetryAfterSeconds, lateFaultLogger) - .FirstAsync() - .ObserveCompletion( - ex => lateFaultLogger?.LogWarning(ex, - "Plugin bundles: {Plugin}@{Version} faulted after the response had already been sent", - plugin, version), - ct)!; - } - - /// - /// Reads the plugin's nodes and their assemblies, then builds the archive. - /// - /// 🚨 A caller on ANOTHER lane is answered by resolving through each NodeType's - /// Release node (#1751) — the release records, per framework identity and per - /// architecture, which assembly-store version holds bytes PROVEN built for that lane. That is - /// what lets this route serve at all across lanes: the amd64 and arm64 variants of one image - /// resolve different framework identities, so before the link existed an arm64 caller could only - /// be told "not adoptable" and never "here is yours". The Release/ nodes arrive on the - /// very same subtree read the NodeTypes do, so resolving through them costs no extra query. - /// - /// 🚨 The instance's OWN lane is still served from LastCompiledVersion, and is - /// checked first. Not a legacy fallback — the correct answer. A Release record can legitimately - /// LAG the current build (adoption stamps LastCompiledVersion without minting a release at - /// all), so resolving the own-lane case through releases would quietly ship an older assembly than - /// this portal itself runs. On its own lane the identity claim is true by construction; off it, - /// only a record written beside the bytes can make that claim, and a type without one contributes - /// nothing and is COUNTED as a miss. - /// - private static IObservable Assemble( - IMessageHub rootHub, BundleEntry package, string identity, string architecture, - string? publishedRoot, string? source) - { - var meshService = rootHub.ServiceProvider.GetRequiredService(); - var store = rootHub.ServiceProvider.GetService() ?? NullAssemblyStore.Instance; - var options = rootHub.JsonSerializerOptions; - var logger = rootHub.ServiceProvider.GetService() - ?.CreateLogger(typeof(PluginBundleEndpoints)); - var servesOwnLane = - string.Equals(identity, FrameworkMvid, StringComparison.Ordinal) - && ReleaseArchitecture.Matches(ReleaseArchitecture.Live, architecture); - // The served bytes' MVID is read (#6831) on the FILESYSTEM pool, never the request thread. - var fileSystemPool = rootHub.ServiceProvider.GetService()?.Get(IoPoolNames.FileSystem) - ?? IoPool.Unbounded; - - // 🚨 OFF THIS INSTANCE'S LANE, the publication SEALED for the caller's identity is the - // answer — not a reconstruction from this mesh's Release records (#3768). Those records are - // right about WHICH build proves a lane and stay the rule below; what they cannot do off - // this lane is FIND bytes. A ReleaseArtifact is minted in exactly one place — a compile, in - // this mesh, stamping the compiling process's own identity — and an in-portal compile - // writes `collection: "local"`, the pod's own cache. So every artifact recorded for a lane - // this portal no longer runs points at a pod that has been replaced. Measured on the fleet - // registry 2026-09-11: `Store/Plugin` did hold an artifact for the consumer's identity, and - // it was `local`. The sealed publication is the same bytes the consumer's own boot seeder - // reads off the share and the same bytes this registry already serves at - // `…/prebuilt/{identity}/{source}/{bundle}` — reachable here under the PER-PACKAGE grant - // the caller already passed, which is why the decision is taken server-side (the reasoning - // ServedModuleBytes records for the module half: fetching the prebuilt route directly needs - // a whole-source grant, and a whole-source grant bypasses plan tiering). - if (!servesOwnLane - && SealedLaneBundles.Locate(publishedRoot, identity, package.PluginId, source, logger) - is { } sealedLane) - return FromSealedLane( - rootHub, package, sealedLane, identity, architecture, publishedRoot, logger); - - return meshService - .Query(MeshQueryRequest.FromQuery( - $"namespace:{package.PluginId} scope:subtree")) - .Where(c => c.ChangeType == QueryChangeType.Initial) - .Take(1) - .SelectMany(change => - { - var nodes = change.Items.ToArray(); - var releases = ReleasesByType(nodes, options); - var misses = new List(); - - // Each NodeType contributes the assembly the bake produced for it. A type with no - // usable build contributes nothing rather than failing the bundle: a plugin whose - // types are still compiling is incomplete, not broken, and the caller can retry. - // The manifest lists what IS here, so a consumer can tell the difference. - var lookups = nodes - .Select(n => (Node: n, Definition: n.ContentAs(options))) - .Where(x => x.Definition?.LastCompiledVersion is not null) - .Select(x => (x.Node, x.Definition, Version: ResolveStoreVersion( - x.Node.Path, x.Definition!, releases, identity, architecture, servesOwnLane, - misses))) - .Where(x => x.Version is not null) - .Select(x => ServeBuild( - store, fileSystemPool, x.Node.Path, x.Definition!, x.Version!.Value, servesOwnLane)) - .ToArray(); - - var assemblies = lookups.Length == 0 - ? Observable.Return(Array.Empty()) - : lookups.CombineLatest().Select(x => x.ToArray()); - - if (misses.Count > 0) - // 🚨 LOUD and COUNTABLE. A fetch that quietly yields nothing is indistinguishable - // from one that yielded everything, and the adopted-vs-compiled count is the only - // evidence the distribution lane works at all. - logger?.LogWarning( - "Plugin bundles: {Plugin} could resolve NO artifact for {Missed} of its " - + "NodeType(s) on framework {Identity}/{Architecture} — those types are NOT " - + "in the bundle and the consumer will compile them: {Misses}", - package.PluginId, misses.Count, identity, architecture, - string.Join(" | ", misses.Take(MissesReported))); - - return assemblies.SelectMany(found => - { - // What the bytes THIS archive is about to carry were compiled against — the - // evidence that decides which build of the module goes with them (#3244). - // 🚨 The SAME filter BuildResult writes by: a type whose assembly could not be - // located contributes no bytes, so its record must not vote on which module - // rides beside bytes that are not there. - var recorded = ServedModuleBytes.RecordedFor( - found.Where(a => a.Open is not null).Select(a => a.Dependencies), - package.Module ?? ""); - return ModuleFiles(rootHub, package, recorded, publishedRoot, identity) - .Select(module => - { - IReadOnlyList reported = module.Divergence is null - ? misses - : misses.Append( - $"module '{package.Module}': {module.Divergence}") - .ToArray(); - return BuildResult(package, found, module, identity, architecture, reported); - }); - }); - }); - } - - /// - /// 🚨 Maps a file the bundle archive could not OPEN — after the resolution had located it — to - /// 503 + Retry-After (MeshWeaver#3876), and lets every other fault through - /// unchanged. - /// - /// Why this is a race and not a defect of the request. The route resolves each - /// NodeType's bytes to a PATH () and each module - /// file to a shelf path, and the archive opens them only when it is written. Between the two, the - /// store's own housekeeping can remove the file: FileSystemAssemblyStore evicts every - /// version of a type beyond the newest three on each write, so a type recompiled a few times - /// while a consumer is downloading loses the version the route resolved. Measured on - /// memex-cloud 2026-09-26 17:38Z (pod memex-portal-deployment-6c7669df84-9b4rz, six - /// occurrences): FileNotFoundException for - /// /data/assembly-cache/Collaboration_Review/v1172-….dll out of - /// NuGetPackageWriter.Write → the route's File.OpenRead, answered by ASP.NET's - /// exception middleware as an unhandled 500. The type's record has moved on by then, so the - /// consumer's re-read resolves the version that now exists — which is exactly what 503 + - /// Retry-After tells it to do, and the same answer the prebuilt routes give a removal - /// between their seal read and their open (#3957). - /// - /// Only a MISSING file or directory is mapped: a permission fault, a torn read or anything - /// else is a real defect and keeps surfacing as one. - /// - /// - /// The answer for a publish whose shelf landing FAULTED (#6640). A refusal of the upload itself - /// (the app-closure same-identity trap-door, a malformed name) is a 409 — the publisher must change - /// what it sends. A STORAGE fault (, - /// — e.g. access denied on modules/activation.d/<Name>.json) says nothing about the - /// upload: it is this replica failing to write, so it is a 500 that says so, never a 409 that - /// reads as a version clash and sends the reader after a conflict that does not exist. - /// - internal static IResult LandingFailure(Exception exception) - { - var storage = StorageFault(exception); - return storage is null - ? Results.Json(new { error = exception.Message }, statusCode: StatusCodes.Status409Conflict) - : Results.Json(new - { - error = "The registry could not write the module to its own storage — an infrastructure " - + "fault on the replica that answered, not a conflict with this upload: " + storage.Message, - }, statusCode: StatusCodes.Status500InternalServerError); - } - - /// The storage fault inside (itself, or an inner/aggregated - /// one), or null when the landing refused the upload rather than failed to write it. - private static Exception? StorageFault(Exception exception) => exception switch - { - IOException or UnauthorizedAccessException => exception, - AggregateException aggregate => aggregate.InnerExceptions.Select(StorageFault).FirstOrDefault(e => e is not null), - { InnerException: { } inner } => StorageFault(inner), - _ => null, - }; - - internal static IObservable TransientWhenServedBytesMoved( - this IObservable answer, HttpContext http, string plugin, string version, - ILogger? logger) => - answer.Catch(ex => - { - if (ex is not (FileNotFoundException or DirectoryNotFoundException)) - return Observable.Throw(ex); - logger?.LogWarning(ex, - "Plugin bundles: {Plugin}@{Version} — a file the bundle resolved was removed before " - + "the archive could open it (the store replaced it meanwhile); answering 503 + " - + "Retry-After so the consumer re-reads and resolves what exists now", - plugin, version); - http.Response.Headers.RetryAfter = TransientRetryAfterSeconds.ToString(CultureInfo.InvariantCulture); - return Observable.Return(Results.Json( - new - { - error = $"The bytes for {plugin}@{version} were replaced while the bundle was " - + "being assembled — retry shortly.", - }, - statusCode: StatusCodes.Status503ServiceUnavailable)); - }); - - /// How many per-type misses the warning names before it truncates — enough to diagnose, - /// bounded so a wholesale lane mismatch cannot write a log line per type. - private const int MissesReported = 10; - - /// - /// One assembly this bundle will carry: the NodeType it implements, how to open its bytes - /// (null = it could not be located and it contributes nothing), the dependency record the - /// producer wrote beside it, and the producer's source fingerprint when one is recorded. - /// - /// A rather than a path so the two producers can be the - /// same shape: this instance's assembly store hands back a file, a sealed publication hands - /// back bytes already inflated from its archive. - internal sealed record ServedAssembly( - string NodePath, - Func? Open, - IReadOnlyDictionary? Dependencies, - string? SourceFingerprint = null) - { - /// The module version these bytes were built at, or null when unknown (#6831). - public string? ModuleVersion { get; init; } - } - - /// - /// The bundle for a caller on ANOTHER lane, taken from the publication this registry holds - /// SEALED for that caller's framework identity (#3768) — the NodeType counterpart of the - /// module half's sealed read (#3244). - /// - /// The assemblies come out of the sealed archive verbatim, with the dependency records - /// and source fingerprints its producer wrote; the MODULE section is composed exactly as on - /// every other serve, so an off-lane consumer keeps landing modules as it does today. Nothing - /// here decides adoption: the archive this writes states , the - /// consumer re-checks it with the unchanged PrebuiltAssemblySeeder.DeclineReason, and - /// then again per assembly as it seeds. - /// - /// An unreadable or empty sealed bundle answers rather than - /// falling through to the Release-record path: falling through would hand back an archive with - /// no assemblies and a manifest claiming the caller's lane, which reads as a successful - /// adoption of nothing. A 404 is the one answer the consumer already turns into a NAMED miss - /// (NotServed) and a compile. - /// - private static IObservable FromSealedLane( - IMessageHub rootHub, BundleEntry package, SealedLaneBundle sealedLane, - string identity, string architecture, string? publishedRoot, ILogger? logger) - { - // 🚨 The archive is opened, parsed and inflated on the FILESYSTEM pool, never on the - // request thread: this is a mounted network share and a bundle is the whole weight of a - // package, so several concurrent boot downloads would otherwise hold request threads on a - // slow mount. The same discipline PublishedBundleCatalogue.Observe applies to this read. - var pool = rootHub.ServiceProvider.GetService()?.Get(IoPoolNames.FileSystem) - ?? IoPool.Unbounded; - - return pool.InvokeBlocking(_ => ReadSealed(sealedLane, identity)) - .SelectMany(read => - { - if (read.Refusal is not null) - { - // Not a fall-through to the release-record path: that would hand back an - // archive with no assemblies and a manifest claiming the caller's lane, which - // reads as a successful adoption of nothing. A 404 is the one answer the - // consumer already turns into a NAMED miss (NotServed) and a compile. - logger?.LogWarning( - "Plugin bundles: {Plugin} — the bundle sealed for framework {Identity} by " - + "source '{Source}' ({Bundle}) is not servable: {Reason}. Serving nothing " - + "for that lane; the consumer will compile.", - package.PluginId, identity, sealedLane.Source, sealedLane.BundleName, - read.Refusal); - return Observable.Return(NoSuchBundle()); - } - - var served = read.Assemblies - .Select(payload => new ServedAssembly( - payload.NodePath, - () => new MemoryStream(payload.Assembly, writable: false), - payload.Dependencies, - payload.SourceFingerprint) - { - // #6831 — carried from the sealed archive, exactly as its producer stated it. - ModuleVersion = payload.ModuleVersion, - }) - .ToArray(); - - logger?.LogInformation( - "Plugin bundles: {Plugin} served on framework {Identity}/{Architecture} from " - + "the publication source '{Source}' sealed for that lane ({Bundle}, generation " - + "{Generation}) — {Count} assembly/assemblies", - package.PluginId, identity, architecture, sealedLane.Source, - sealedLane.BundleName, sealedLane.Generation ?? "(unrecorded)", served.Length); - - var recorded = ServedModuleBytes.RecordedFor( - served.Select(a => a.Dependencies), package.Module ?? ""); - IReadOnlyList sealedMisses = read.Misses ?? []; - return ModuleFiles(rootHub, package, recorded, publishedRoot, identity) - .Select(module => BuildResult( - package, served, module, identity, architecture, - module.Divergence is null - ? sealedMisses - : sealedMisses - .Append($"module '{package.Module}': {module.Divergence}") - .ToArray())); - }); - } - - /// - /// Reads one sealed bundle and says whether it may be served for . - /// The file read; everything else is a decision. - /// - /// 🚨 The archive's OWN framework identity is checked before anything is served, - /// through the very function the consumer applies to the same bytes - /// (, ordinal equality). The - /// directory a bundle sits in is a filing convention; the manifest is the producer's claim, and - /// only the claim may be believed. Without this a bundle mislabelled into an identity directory - /// would be RESTAMPED with the requested identity by — and the - /// consumer's gate, seeing a manifest that agrees with its own live identity, would adopt bytes - /// baked for another framework. That is the one outcome this lane exists to prevent, and it is - /// why the check reads the claim rather than inferring it from where the file was found. - /// - private static SealedRead ReadSealed(SealedLaneBundle sealedLane, string identity) - { - try - { - var (manifest, assemblies) = BundleReader.ReadFile(sealedLane.Path); - if (manifest is null) - return new SealedRead([], null, "it carries no manifest"); - if (PrebuiltAssemblySeeder.DeclineReason(manifest.FrameworkMvid, identity) - is { } mismatch) - return new SealedRead([], null, - $"the archive's own manifest says it was {mismatch} — the directory it is " - + "filed under is not evidence of what it was built against"); - if (assemblies.Count == 0) - return new SealedRead([], null, "it carries no assemblies"); - return new SealedRead(assemblies, manifest.Misses, null); - } - catch (Exception exception) - when (exception is IOException or InvalidDataException or JsonException) - { - return new SealedRead([], null, $"it is unreadable: {exception.Message}"); - } - } - - /// What a sealed bundle yielded: its assemblies and the per-type misses its producer - /// recorded, or the NAMED reason it may not be served. - private sealed record SealedRead( - IReadOnlyList Assemblies, - IReadOnlyList? Misses, - string? Refusal); - - /// - /// The Release nodes from a package's subtree, grouped by the NodeType they belong to. - /// Keyed off the release CONTENT's NodeTypePath rather than the node's path, because the - /// content is what the resolver reasons about and a path-derived key would silently disagree with - /// it if the release were ever moved. - /// - private static IReadOnlyDictionary> ReleasesByType( - IReadOnlyList nodes, System.Text.Json.JsonSerializerOptions options) => - nodes - .Where(n => string.Equals( - n.NodeType, ReleaseNodeType.NodeType, StringComparison.OrdinalIgnoreCase)) - .Select(n => n.ContentAs(options)) - .Where(r => r is { NodeTypePath.Length: > 0 }) - .GroupBy(r => r!.NodeTypePath, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - g => g.Key, - g => (IReadOnlyList)g.Select(r => r!).ToArray(), - StringComparer.OrdinalIgnoreCase); - - /// - /// 🚨 MeshWeaver#6831 — the bytes served for one NodeType and the module version stated for - /// THEM. Several builds can share one store key (an adoption at a version a compile already - /// used, a recompile that did not move it, a rollback), so "the newest file under the key" is - /// not an identity and must not carry the record's version label. - /// - /// - /// On this instance's own lane the record's current build is served, resolved by the - /// identity the record NAMES (LatestAssemblyPath + LatestAssemblyMvid, through - /// ) — the same bytes the next activation binds. - /// Off it, the Release artifact's store version is served exactly as before. - /// Either way the record's version is stated only when the selected file's MVID IS the - /// record's LatestAssemblyMvid. TryGetBuildPath falls back to the version-only - /// answer when it cannot find the named bytes, and an unknown record MVID proves nothing, so - /// anything short of that match is UNKNOWN (null) — never the record's version on other - /// bytes. - /// - /// - /// Internal for RegistryStatesTheServedBuildsModuleVersionTest. - internal static IObservable ServeBuild( - IAssemblyStore store, IIoPool pool, string nodePath, NodeTypeDefinition definition, - long version, bool servesOwnLane) - => (servesOwnLane - ? store.TryGetBuildPath( - nodePath, version, definition.LatestAssemblyPath, definition.LatestAssemblyMvid) - : store.TryGetAssemblyPath(nodePath, version)) - .Take(1) - .Catch(_ => Observable.Return(null)) - .SelectMany(path => path is null - ? Observable.Return(new ServedAssembly(nodePath, null, definition.CompiledDependencies)) - : pool.InvokeBlocking(_ => ServedBuildIdentity.OfFile(path)) - .Select(servedMvid => new ServedAssembly( - nodePath, () => File.OpenRead(path), definition.CompiledDependencies) - { - ModuleVersion = !string.IsNullOrEmpty(servedMvid) - && string.Equals(servedMvid, definition.LatestAssemblyMvid, - StringComparison.OrdinalIgnoreCase) - ? PrebuiltAssemblySeeder.ModuleVersionOfBuild(definition) - : null, - })); - - /// - /// Which assembly-store version to serve for one NodeType in the caller's lane, or null when - /// nothing may be served for it (the miss is appended to ). - /// - /// Internal rather than private for PluginBundleLaneResolutionTest: this is the - /// whole distribution decision and both of its branches fail SILENTLY when wrong — serving a - /// lagging build looks healthy, and serving a foreign lane's build fails only at activation. - /// Reaching it through an HTTP round trip would need a live portal to pin a pure choice. - internal static long? ResolveStoreVersion( - string nodePath, - NodeTypeDefinition definition, - IReadOnlyDictionary> releases, - string identity, - string architecture, - bool servesOwnLane, - List misses) - { - // 🚨 OWN LANE FIRST, and deliberately not through the releases. The bytes this portal runs - // were produced (or adopted) under its own live identity, so the claim is true by - // construction — and they are the CURRENT build, which a Release record can legitimately lag: - // PrebuiltAssemblySeeder stamps LastCompiledVersion without minting a release at all, so a - // release-first rule would ship the older assembly of the two while looking perfectly - // healthy. This branch is byte-for-byte the pre-#1751 behaviour. - if (servesOwnLane) - return definition.LastCompiledVersion; - - var candidates = releases.TryGetValue(nodePath, out var forType) ? forType : []; - var match = ReleaseArtifactResolver.Resolve(candidates, identity, architecture); - - // Off this instance's lane, ONLY a record written beside the bytes can prove them. The - // artifact's own store version, and never LastCompiledVersion as a fallback: that is a - // DIFFERENT lane's build, and handing it over under the requested identity is exactly the - // unprovable adoption the whole gate exists to prevent. - if (match.IsResolved && match.Artifact!.AssemblyStoreVersion is { } storeVersion) - return storeVersion; - - misses.Add($"{nodePath}: {(match.IsResolved - ? "an artifact was recorded for this lane but without an assembly-store version, so its " - + "bytes cannot be located" - : match.DeclineReason)}"); - return null; - } - - /// - /// The MODULE closure files this bundle carries (#1664) — the instance's own - /// modules/<name>/ bytes, resolved through (which - /// refuses uninstalled landings; a HELD landing — floor above this instance's platform, the - /// registry-shelf state, 2026-08-22 — is served, its floor riding the manifest for the CONSUMER's - /// gate). Empty for a package that declares no module or whose bytes this instance cannot - /// serve — the bundle then simply has no module section, which a consumer reads as "nothing - /// to land". - /// - private static IObservable ModuleFiles( - IMessageHub rootHub, BundleEntry package, RecordedModuleId recorded, - string? publishedRoot, string identity) - { - var landing = rootHub.ServiceProvider.GetService(); - if (landing is null || string.IsNullOrWhiteSpace(package.Module)) - return Observable.Return( - new ServedModule([], [], "no module section", null)); - - var logger = rootHub.ServiceProvider.GetService() - ?.CreateLogger(typeof(PluginBundleEndpoints)); - - return landing.GetActivation().Take(1) - .Select(activation => - { - // 🚨 MeshWeaver#6067 — the generation shelved AT THE VERSION THIS URL NAMES, by the - // same rule the index used to decide it may advertise a module section here. An - // install record that moved ahead of the shelf used to resolve the HEAD and serve - // its bytes under the record's version. - var generation = ModuleBundleSource.GenerationVersionFor( - activation, package.Module!, package.Version, package.ShelfVersion); - var (files, assets, decline) = ModuleBundleSource.CollectVersion( - landing.BaseDirectory, package.Module!, activation, generation); - // 🚨 The shelf's NATIVE tree (#4126) — a separate call rather than a fourth tuple - // element, because widening that tuple rewrites a signature a sibling repository - // already destructures into three. It resolves the SAME generation by the same - // rule, so the two readings cannot name different bytes. - var natives = ModuleBundleSource.NativeAssetsOf( - landing.BaseDirectory, package.Module!, activation, generation); - if (decline is not null) - logger?.LogInformation( - "Plugin bundles: {Plugin} declares module '{Module}' but it is not served: {Reason}", - package.PluginId, package.Module, decline); - - // 🚨 ONE PRODUCER IN TIME (#3244). The shelf is content-versioned and identity-blind - // — it serves whatever the module's own lane published last — while the assemblies - // above are resolved for the CALLER's identity and record exactly which build of - // this module they were compiled against. Hand over the bytes they record, which is - // the shelf whenever the shelf IS that build and otherwise the module bundle the - // publication sealed for this identity. - var served = ServedModuleBytes.Resolve( - package.Module, package.PluginId, files, assets, recorded, - publishedRoot, identity, logger, natives); - // 🚨 A module the package DECLARES but this registry cannot serve at this version - // (the shelf holds no such generation — typically the package record is ahead of - // the module's own publish) used to leave NOTHING on the wire: no module section - // (it is written only when files exist), no miss, and an Information log line. The - // manifest was then byte-for-byte the one an empty archive that declared nothing - // carries, which every consumer reads as NoAssemblies with no reason (/health - // bundle_adoption Degraded, "the bundle carried no assemblies"). State the decline - // as the same `module '': …` miss a divergence is, so it is countable and - // attributable on BOTH sides. - if (served.Files.Count == 0 && served.Divergence is null && decline is not null) - served = served with - { - Divergence = BundleReader.ModuleNotServedReason - + $"the package declares this module but this registry cannot " - + $"serve it at {package.Version}: {decline}", - }; - if (served.Divergence is not null) - logger?.LogWarning( - "Plugin bundles: {Plugin} on framework {Identity} — {Divergence}", - package.PluginId, identity, served.Divergence); - else if (files.Count > 0 && recorded.Mvid is not null) - logger?.LogInformation( - "Plugin bundles: {Plugin} serves module '{Module}' {Mvid} from {Provenance}", - package.PluginId, package.Module, recorded.Mvid, served.Provenance); - // The version the served bytes were SHELVED at, stated on the wire so the consumer - // can refuse a label that disagrees with what it was advertised (#6067). Only the - // shelf's own generation can state it; a sealed-publication substitution leaves it - // unstated rather than borrowing a version those bytes never carried. - return served.Files.Count > 0 - && string.Equals(served.Provenance, ServedModuleBytes.ShelfProvenance, StringComparison.Ordinal) - ? served with - { - Version = ModuleBundleSource.ResolveEntry(activation, package.Module!, generation)?.Version, - } - : served; - }); - } - - /// - /// The framework identity these assemblies are bound to — the RESOLVED framework build - /// identity of this process (: surface - /// hash on manifest-bearing portals, not a raw assembly MVID). - /// - /// 🚨 This used to derive MeshWeaver.Graph's raw MVID "because FrameworkVersion is - /// internal" — correct while the identity WAS that MVID, silently broken by #1696: portals - /// resolve the s<hash> surface identity, so every registry-served bundle recorded a - /// mismatched identity and the consumer's gate DECLINED it (adoption quietly regressed to - /// compile-everything). LiveFrameworkMvid is the public reading of the one resolution — - /// producer and gate can no longer disagree. - /// - private static string FrameworkMvid => PrebuiltAssemblySeeder.LiveFrameworkMvid; - - /// - /// 🚨 Buffered, not streamed straight to the response: writes a - /// ZIP central directory at the END, so a half-written archive cannot be un-sent once bytes have - /// gone out. A failure mid-assembly must become a clean 500, not a truncated archive the caller - /// caches as valid. - /// - private static IResult BuildResult( - BundleEntry package, - IReadOnlyList assemblies, - ServedModule module, - string identity, - string architecture, - IReadOnlyList misses) - { - var moduleFiles = module.Files; - var moduleAssets = module.Assets; - var moduleNatives = module.Natives; - var entries = new List(); - var assemblyRecords = new List(); - - foreach (var served in assemblies.Where(a => a.Open is not null)) - { - // EntryPathFor states the naming rule (and why it is not slash-replaced) once, shared - // with the reader. The manifest still carries the mapping — the consumer must read the - // node path the producer wrote, never recover it from a file name. - var nodePath = served.NodePath; - var dependencies = served.Dependencies; - entries.Add(new NuGetPackageWriter.Entry( - NuGetPackageWriter.EntryPathFor(nodePath), served.Open!)); - // The per-type dependency record (#1707 slice 2) rides the manifest so the consumer - // validates module/toolchain bindings before adopting and stamps them on adopt. - // - // 🚨 The producer's SOURCE FINGERPRINT rides it too, but ONLY when these bytes came - // with one — a sealed publication's manifest carries it, this instance's own store does - // not record one per assembly. It is what lets the consuming hub refuse an adoption - // whose source disagrees with the source that mesh holds (#2813); passing a value this - // route invented would be worse than passing none, and passing none is exactly what - // every own-lane serve did before and still does. - // 🚨 #6831 — and the module version of THESE bytes, per assembly: the manifest's - // top-level version is the package's advertised one, which the bytes need not be. - var moduleVersion = served.ModuleVersion; - assemblyRecords.Add(served.SourceFingerprint is null - ? new { nodePath, assembly = $"{nodePath}.dll", dependencies, moduleVersion } - : (object)new - { - nodePath, assembly = $"{nodePath}.dll", dependencies, - sourceFingerprint = served.SourceFingerprint, - moduleVersion, - }); - } - - // The module closure, under its own folder (#1664): these bytes land beside the consumer's - // app via ModuleLandingService, a different lane than the assembly store above — the two - // must never mix (a module DLL seeded as a NodeType assembly fails only at activation). - foreach (var moduleFile in moduleFiles) - { - var local = moduleFile; - entries.Add(new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleEntryPathFor(local.FileName), local.Open)); - } - - // The module's STATIC WEB ASSETS ride under their own folder, keeping the relative path a - // component's _content//… URL asks for. Without this the serve path re-creates the - // defect the publish path was just fixed for (#2221): the shelf would hold a complete pack - // and hand consumers an assemblies-only bundle, so every downstream portal renders - // unstyled while the registry's own copy looks fine. - foreach (var moduleAsset in moduleAssets) - { - var local = moduleAsset; - entries.Add(new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleAssetEntryPathFor(local.RelativePath), local.Open)); - } - - // 🚨 And the module's RID-specific NATIVE payloads, under the third folder (#4126). Same - // argument as the assets one step harder: a registry whose shelf holds the engine and - // serves a bundle without it hands every consumer downstream a module that lands, loads, - // renders — and throws DllNotFoundException at the first P/Invoke, with nothing in any log - // connecting that to the serve. The relative path is preserved because it IS the layout - // ModuleNativeAssets probes. - foreach (var moduleNative in moduleNatives) - { - var local = moduleNative; - entries.Add(new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleNativeEntryPathFor(local.RelativePath), local.Open)); - } - - var manifest = new PackagingManifest( - package.PluginId, package.PackageId, package.Version, package.PluginId, null, []); - - var manifestJson = JsonSerializer.Serialize( - new - { - plugin = package.PluginId, - version = package.Version, - // 🚨 The framework identity these bytes were RESOLVED for (#1751) — every assembly - // above either carries a Release artifact recorded under exactly this identity, or - // was served on this instance's own lane, where this equals FrameworkMvid. It is - // never inferred: a lane that could not be proven contributed no bytes at all, so - // the claim is always backed by the record the producer wrote beside them. - frameworkMvid = identity, - // The architecture that identity belongs to, so a consumer's decline can name the - // lane instead of only the opaque hash. - architecture, - assemblies = assemblyRecords, - // 🚨 #6831 — every assembly above states its OWN module version; one that states - // none is UNKNOWN to the consumer and never inherits `version`, which is the - // package's advertised version and not a statement about these bytes. - moduleVersionPerAssembly = true, - // NodeTypes this bundle could NOT cover for the requested lane, each with its reason. - // Carried in the manifest — not merely logged on the server — so the CONSUMER can - // count and surface the miss too: a fetch that silently returns fewer assemblies - // than the package has types is exactly how adoption regresses unnoticed. - misses = misses.Count == 0 ? null : misses.ToArray(), - // The module section — the manifest names every closure file; a consumer reads - // this list, never the folder (BundleReader.ReadModule). Null (omitted) when the - // bundle carries no module. minMeshVersion is the consumer's landing gate (a - // semver floor — MVID equality is the NodeType lane's gate, and the frameworkMvid - // above is, for the module, diagnostics). - module = moduleFiles.Count == 0 - ? null - : new - { - assemblyName = package.Module, - // 🚨 #6067 — the version these module bytes were SHELVED at, which the - // consumer stamps and compares against the advertised one. Omitted when - // the route cannot state it (see ModuleFiles). - version = module.Version, - assemblies = moduleFiles.Select(f => f.FileName).ToArray(), - minMeshVersion = package.MinMeshVersion, - // Declared, never inferred from the archive — BundleReader.ReadModuleAssets - // reads THIS list and treats a declared-but-absent entry as an incomplete - // bundle, the same all-or-nothing rule the assemblies follow. - staticAssets = moduleAssets.Count == 0 - ? null - : moduleAssets.Select(a => a.RelativePath).ToArray(), - // #4126 — declared for exactly the same reason, and read back by - // BundleReader.ReadModuleNativeAssets under the same all-or-nothing rule. - // Omitted when there are none, so a bundle from a module that ships no - // engine and one from before this section existed read identically. - nativeAssets = moduleNatives.Count == 0 - ? null - : moduleNatives.Select(n => n.RelativePath).ToArray(), - // 🚨 Where these bytes came from, and — when the registry could not supply - // the build this bundle's own assemblies record — what disagreed (#3244). - // On the wire, not merely in a log, for the same reason `misses` is: a - // consumer that will decline every NodeType binding this module must be - // able to say WHY without correlating against the registry's logs. - provenance = module.Provenance, - divergence = module.Divergence, - }, - }, - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - }); - - var buffer = new MemoryStream(); - NuGetPackageWriter.Write(buffer, manifest, "3.0.0", entries, manifestJson); - buffer.Position = 0; - - return Results.File(buffer, "application/octet-stream", - $"{package.PackageId}.{package.Version}.nupkg"); - } - - /// - /// The plugins this instance can serve, from the install records in the Plugins - /// partition — the same records the catalog reads. - /// - /// The version is ReleasedVersion, off the module's manifest.lock. Neither - /// neighbour substitutes: Version is the whole-repo commit sha and ModuleVersion - /// is a content hash, which is exact but unordered — it cannot answer "which is newer", the one - /// question a distribution index is asked. - /// - /// 🚨 Unscoped by design — every caller of this method must apply - /// . It is the full inventory, which is precisely what no caller may - /// see; the grant filter lives at the two route handlers because the download route also needs - /// the unfiltered list to write a diagnosable log line. The each - /// entry carries is the only input that decision needs. - /// - private static IObservable> InstalledPackages( - IMessageHub rootHub, CancellationToken ct) => - rootHub.ServiceProvider.GetRequiredService() - .Query(MeshQueryRequest.FromQuery( - $"namespace:{PackageInstaller.InstalledPartition} " - + $"nodeType:{PackageInstaller.PackageNodeType}")) - .Where(c => c.ChangeType == QueryChangeType.Initial) - .Select(c => (IReadOnlyList)c.Items - .Select(node => node.ContentAs( - rootHub.JsonSerializerOptions)) - .Where(m => m is not null && !string.IsNullOrWhiteSpace(m.ReleasedVersion)) - .Select(m => new BundleEntry( - PackagingManifest.IdPrefix + m!.Id, m.ReleasedVersion!, m.Id, m.Module, - m.MinMeshVersion, m.Source, m.Tier)) - .OrderBy(e => e.PluginId, StringComparer.OrdinalIgnoreCase) - .ToArray()); - - /// One servable bundle: its package id, its released version, the plugin it is, the - /// compiled module the plugin's install record declares (null for content-only plugins), that - /// module's declared platform floor, and the registry SOURCE the record was installed from. - /// The NuGet-shaped package id the archive is named after. - /// The released SemVer this bundle is served at. - /// The plugin's catalog id — also the id a grant entry names. - /// The compiled module the install record declares, or null. - /// The module's declared platform floor, or null. - /// 🚨 The CACHED registry-source binding — what a local observation says - /// this package came from ( on an install record, the - /// declared package path on a published module). It is the half of the grant pair that is not - /// the package id, but it is no longer the authority on it: prefers the - /// registry anchor and uses this when the anchor does not carry the package or cannot be - /// consulted (#1782 gap 2). Null on a record written before the field existed, on something - /// installed from a source that is not configured, and on an entry that exists only because the - /// anchor advertises it — in every one of those cases the absence sends the question upstream - /// rather than answering it. - /// The plan the package declares (), or - /// null for a baseline package — the cached observation; prefers the - /// anchor's, exactly as it does for . - /// The retained published generation this entry resolves, or null - /// for an installed/anchored entry. Kept separate from because an - /// install record's package version is not evidence that a sidecar generation with that - /// version exists — 🚨 and since #6067 a null here no longer means "serve the head": such an - /// entry resolves the generation shelved at its OWN , or serves no - /// module section (); only a module with - /// no activation entry (the image's copy) still follows the unversioned lookup. - private sealed record BundleEntry( - string PackageId, string Version, string PluginId, string? Module = null, - string? MinMeshVersion = null, string? Source = null, string? Tier = null, - string? ShelfVersion = null); -} diff --git a/memex/Memex.Portal.Shared/Api/PluginRegistryEndpoints.cs b/memex/Memex.Portal.Shared/Api/PluginRegistryEndpoints.cs deleted file mode 100644 index 7798a4d8b4..0000000000 --- a/memex/Memex.Portal.Shared/Api/PluginRegistryEndpoints.cs +++ /dev/null @@ -1,384 +0,0 @@ -using System.Linq; -using System.Reactive.Linq; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// The plugin-registry surface — this instance acting as the distribution point for plugin -/// modules. GET /api/plugins lists the catalog; POST /api/plugins/files returns one -/// package's files. Both are backed by the registry's configured git s -/// (PluginCatalog:Sources:N:* — e.g. the plugins repo AND an education repo — or the legacy -/// single PluginCatalog:SourceRepoPath, via GitSync), so a consuming instance -/// browses/installs over HTTP with NO git/GitHub credentials of its own: the registry's GitHub App -/// credential stays here and is never handed out (npm/NuGet-style encapsulation). -/// -/// 🚨 The surface is NOT public: it serves only registered MeshWeaver instances. A -/// caller presents its instance key (Authorization: Bearer mwi_…); the key resolves to a -/// node and the admin-owned that says -/// which (source, package) pairs it may read — see . -/// Requests without a valid key are 401, and an authenticated caller sees ONLY its granted packages, -/// in both the listing and the file fetch. -/// -/// This replaced a flat PluginCatalog:RegistryTokens allowlist that was open when -/// unset — which is how this registry served its private sources to anonymous callers until -/// 2026-08-06. The gate now fails closed: PluginCatalog:RequireInstanceKey defaults to -/// true, and the anonymous mode must be asked for explicitly (local dev / the e2e stub) and warns on -/// every request. Consumers need no config change — the existing -/// PluginCatalog:RegistryToken key now carries the instance key. -/// -/// Consumed by ; the wire shapes are produced by -/// so producer and consumer cannot drift. -/// -public static class PluginRegistryEndpoints -{ - /// Maps the token-gated /api/plugins registry group. Call alongside MapMeshApi. - public static IEndpointRouteBuilder MapPluginRegistry(this IEndpointRouteBuilder endpoints) - { - // AllowAnonymous at the ASP.NET auth layer: callers are INSTANCES, not signed-in users, so - // the user auth schemes don't apply. The real gate is the instance-token filter below — - // only registered instances (their issued token in PluginCatalog:RegistryTokens) get through. - var group = endpoints.MapGroup(RegistryPackageSource.RoutePrefix).AllowAnonymous(); - - group.AddEndpointFilter(async (ctx, next) => - { - var http = ctx.HttpContext; - var config = http.RequestServices.GetRequiredService(); - var logger = http.RequestServices.GetService() - ?.CreateLogger(typeof(PluginRegistryEndpoints)); - - var authenticator = http.RequestServices.GetRequiredService(); - // The request's stage ledger starts at its arrival here, so the answer deadline counts - // the authentication too (RegistryAnswerDeadline, #4963). - var stages = RegistryAnswerDeadline.Stages(http); - var outcome = (await authenticator.AuthenticateOutcome(http.Request.Headers.Authorization) - .InStage(stages, "instance-key authentication") - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Plugin registry: instance authentication for {Path} faulted after the request had already been answered", - http.Request.Path), - http.RequestAborted))!; - - // 🚨 BEFORE the anonymous fallback below, deliberately. "I could not find out" must not - // fall through to serving this registry's sources anonymously — that would turn a - // transient read failure into an open door on any instance where the legacy mode is on. - if (outcome.IsUnavailable) - return InstanceAuthResponses.Unavailable(http, outcome.UnavailableReason, logger); - - var caller = outcome.Instance; - if (caller is not null) - { - http.Items[CallerItemKey] = caller; - return await next(ctx); - } - - // Fail CLOSED by default. The legacy open mode survives only for local dev / the e2e - // stub, and only when explicitly asked for — it is the mode that served this registry's - // private sources to anyone who knew the URL (2026-08-06). - if (!RequiresInstanceKey(config)) - { - logger?.LogWarning( - "Plugin registry: serving {Path} ANONYMOUSLY — {Key} is false. Every configured " - + "source is readable by any caller. Register instances and remove this setting.", - http.Request.Path, RequireInstanceKeyConfigKey); - return await next(ctx); - } - - logger?.LogWarning("Plugin registry: rejected {Path} — no valid instance key presented", - http.Request.Path); - return Results.Json( - new { error = "A registered instance key is required (Authorization: Bearer mwi_…)." }, - statusCode: StatusCodes.Status401Unauthorized); - }); - - group.MapGet("", (HttpContext http, IMessageHub rootHub, IConfiguration config, CancellationToken ct) => - List(http, rootHub, config, Caller(http), ct)); - - group.MapPost("/files", (HttpContext http, IMessageHub rootHub, IConfiguration config, - FilesBody body, CancellationToken ct) => - Files(http, rootHub, config, body, Caller(http), ct)); - - return endpoints; - } - - - - /// key holding the authenticated caller. - private const string CallerItemKey = "PluginRegistry.Caller"; - - /// Config flag gating the legacy anonymous mode. Absent ⇒ true ⇒ a key is required. - private const string RequireInstanceKeyConfigKey = "PluginCatalog:RequireInstanceKey"; - - /// Whether an instance key is required. Defaults to true — a registry that - /// configures nothing refuses anonymous callers rather than serving them everything. - private static bool RequiresInstanceKey(IConfiguration config) => - !bool.TryParse(config[RequireInstanceKeyConfigKey], out var required) || required; - - /// The authenticated caller, or null in the legacy anonymous mode (which sees - /// everything — there is no instance to scope the catalog to). - private static AuthenticatedInstance? Caller(HttpContext http) => - http.Items.TryGetValue(CallerItemKey, out var v) ? v as AuthenticatedInstance : null; - - // The registry's git sources, read from PluginCatalog config by the SHARED reader - // (PackageSources.FromConfiguration) — the same list the default install (InstanceAutoRegistrationService) installs the - // default packages out of on this very instance. One reading, so serving and pre-installing - // can never disagree about what this registry carries. - private static IReadOnlyList Sources(IMessageHub hub, IConfiguration config) - => PackageSources.FromConfiguration(hub, config, - hub.ServiceProvider.GetService()?.CreateLogger(typeof(PluginRegistryEndpoints))); - - // Lists one source's packages. With SEVERAL sources a single failing repo must not take the whole - // catalog down (degrade to empty + log); with exactly ONE source the failure propagates so List - // can surface it as a 502 (the historical single-source behavior — the consumer sees the cause). - private static IObservable> ListFrom( - ConfiguredPackageSource s, bool soleSource, ILogger? logger, RegistryRequestStages? stages = null) - => soleSource - ? s.Source.ListPackages(s.GitRef).InStage(stages, $"source listing '{s.Name}'") - : s.Source.ListPackages(s.GitRef).InStage(stages, $"source listing '{s.Name}'").Catch((Exception ex) => - { - logger?.LogWarning(ex, "Plugin registry: listing packages from {Name} @ {Ref} failed", - s.Name, s.GitRef); - return Observable.Return((IReadOnlyList)[]); - }); - - /// - /// The ref a files request is read at: when it is a snapshot token - /// — a full commit sha reachable from the source's configured ref — else the configured ref - /// (MeshWeaver#6718). A branch name, HEAD, an absent ref, or a commit whose ancestry the - /// source cannot establish all read the configured ref, exactly as before. - /// - internal static IObservable ReadRef(ConfiguredPackageSource s, string? requested) => - PackageFetch.IsCommitSha(requested) - && !string.Equals(requested, s.GitRef, StringComparison.OrdinalIgnoreCase) - ? s.Source.PublishesCommit(requested!, s.GitRef) - .Take(1) - .DefaultIfEmpty(false) - .Select(published => published ? requested! : s.GitRef) - : Observable.Return(s.GitRef); - - // Merged catalog across all sources; on an id collision the FIRST configured source wins - // (the registry curates the order). - // - // 🚨 Scoped to what the CALLER was granted. The grant is per (source, package) — an instance may - // hold a whole source (Plugins/*) or a single plugin out of one (Reinsurance/UWDeepfield) — so - // the filter runs BEFORE the merge, while each package still knows which source it came from. - // A caller therefore cannot even learn that an ungranted package exists. - // - // 🚨 #4097 — with ONE exception that is not enumeration: a package this registry itself - // declares in the caller's DEFAULT SET (preInstalled) from a source the caller's grant - // REACHES, refused only by the caller's PLAN. That verdict used to be logged here and omitted - // from the wire, so the instance showed every consequence (canPatch=False, "required module - // not installed — install it from the registry", which it cannot) and never the cause. It now - // rides beside the listing as a typed refusal — package, required tier, instance plan. A - // package from an UNGRANTED source stays absent, tier or no tier: TierRefusal answers only - // when some entry reaches the package. - /// 🚨 Internal for the #4222 DISCLOSURE control (InternalsVisibleTo): the - /// listing cache under ListFrom is shared across callers, so a test has to be able to - /// drive TWO callers through ONE cached source list and see each get only its own grant. A - /// cache that leaked would otherwise be invisible until it was in production. - internal static IObservable ListAll( - IReadOnlyList sources, AuthenticatedInstance? caller, - IObservable> artifacts, ILogger? logger, - RegistryRequestStages? stages = null) - => Observable.CombineLatest(sources.Select(s => - ListFrom(s, sources.Count == 1, logger, stages).Select(list => (Source: s, Packages: list)))) - .SelectMany(perSource => artifacts.InStage(stages, "pushed artifacts record").Select(pushed => new RegistryListing - { - // Stamp the source each package came from BEFORE the merge — afterwards the - // provenance is gone. Consumers scope source-specific actions on it (notably - // PluginCatalog:InstallByDefault, which must distinguish the platform repo from - // paid content the same instance may also be granted). - Packages = perSource - .SelectMany(x => x.Packages - .Where(p => IsGranted(caller, x.Source, p)) - .Select(p => p with - { - Source = x.Source.Name, - // The bundle as an OCI artifact in the fleet's registry - // (Doc/Architecture/PluginBundlesInTheRegistry), when this registry has - // pushed it — matched on the content version manifest.lock gives the - // bundle, then the catalog's own version. Null otherwise; additive. - Artifact = pushed.ReferenceFor( - x.Source.Name, p.Id, p.ReleasedVersion, p.ModuleVersion, p.Version), - })) - .DistinctBy(p => p.Id, StringComparer.Ordinal) - .ToList(), - Refused = TierRefusals(caller, perSource), - })); - - /// The default-set packages the caller's plan refuses (#4097): pre-installed, from a - /// source the grant reaches, not covered by the plan. Empty for the legacy anonymous caller - /// (nothing is refused to it) and for every package the enumeration defence must hide. - internal static IReadOnlyList TierRefusals( - AuthenticatedInstance? caller, - IEnumerable<(ConfiguredPackageSource Source, IReadOnlyList Packages)> perSource) - => caller is null - ? [] - : perSource - .SelectMany(x => x.Packages - .Where(p => p.PreInstalled && !IsGranted(caller, x.Source, p)) - .Select(p => caller.TierRefusal(x.Source.Name, p.Id, p.Tier, p.Module)) - .Where(r => r is not null) - .Select(r => r!)) - .DistinctBy(r => r.PackageId, StringComparer.Ordinal) - .OrderBy(r => r.PackageId, StringComparer.Ordinal) - .ToList(); - - /// The registry's record of pushed publications, read once per listing; the platform - /// default records nothing. - private static IObservable> Artifacts(IMessageHub hub) => - hub.ServiceProvider.GetService()?.Read().Take(1) - ?? Observable.Return((IReadOnlyList)[]); - - // Legacy anonymous mode has no instance to scope to and sees everything — that mode is gated - // by PluginCatalog:RequireInstanceKey and warns on every request. - // The package's declared PLAN rides into the decision: a plan-scoped grant entry - // (Plugins/*@personal) licenses exactly the packages its plan covers, decided against the - // ladder the authenticator resolved on the caller (PlanTierRanks.Covers). - private static bool IsGranted(AuthenticatedInstance? caller, ConfiguredPackageSource source, PackageManifest package) - => caller is null || caller.Allows(source.Name, package.Id, package.Tier); - - private static Task List( - HttpContext http, IMessageHub hub, IConfiguration config, AuthenticatedInstance? caller, CancellationToken ct) - { - var logger = hub.ServiceProvider.GetService()?.CreateLogger(typeof(PluginRegistryEndpoints)); - var sources = Sources(hub, config); - if (sources.Count == 0) - return Task.FromResult(Results.Content(PluginRegistryPayloads.List([]), "application/json")); - return ListAll(sources, caller, Artifacts(hub), logger, RegistryAnswerDeadline.Stages(http)) - .Do(listing => - { - // Named on the registry too, as before #4097 — now beside the wire answer rather - // than instead of it. - if (listing.Refused.Count > 0) - logger?.LogInformation( - "Plugin registry: {Count} default-set package(s) refused to {Instance} by plan tier: {Refused}", - listing.Refused.Count, caller?.Instance.InstanceId ?? "(anonymous)", - string.Join("; ", listing.Refused.Select(r => r.Describe()))); - }) - .Select(listing => (IResult)Results.Content( - PluginRegistryPayloads.List(listing.Packages, listing.Refused), "application/json")) - .Catch((Exception ex) => - { - // Surface the failure (502) rather than hide it as an empty catalog — the consumer's - // source catches it, logs, and degrades to empty; the registry log names the cause. - logger?.LogWarning(ex, "Plugin registry: listing packages failed"); - return Observable.Return((IResult)Results.Json( - new { error = ex.Message }, statusCode: StatusCodes.Status502BadGateway)); - }) - // The listing ANSWERS or REFUSES within the deadline, naming the stage it was waiting - // on — a shared source listing that hangs must not hold every catalog request open - // with nothing written and nothing logged (#4963). - .AnsweredWithin(http, RegistryAnswerDeadline.Budget(http, RegistryAnswerDeadline.AnswerBudget), - InstanceRegistryAuthenticator.RetryAfterSeconds, logger) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Plugin registry: listing faulted after the response had already been sent"), - ct)!; - } - - private static Task Files( - HttpContext http, IMessageHub hub, IConfiguration config, FilesBody body, AuthenticatedInstance? caller, - CancellationToken ct) - { - var logger = hub.ServiceProvider.GetService()?.CreateLogger(typeof(PluginRegistryEndpoints)); - // A missing id is a malformed request → 400 (don't disguise it as a valid empty package). - if (string.IsNullOrWhiteSpace(body.Id)) - return Task.FromResult(Results.Json( - new { error = "Field 'id' is required." }, statusCode: StatusCodes.Status400BadRequest)); - var sources = Sources(hub, config); - if (sources.Count == 0) // registry not configured → an empty (not erroneous) response - return Task.FromResult(Results.Content(PluginRegistryPayloads.Files([]), "application/json")); - - // 🚨 Resolve the requested package from the registry's CURATED catalog by ID and fetch what THAT - // package ships — never a client-supplied path. The logic is "the id must match a published - // package", independent of source format; trusting a caller-provided folder would let an - // anonymous caller read arbitrary repo files. An unknown id is 404. A security property, not a hint. - // Sources resolve SEQUENTIALLY in configured order (same precedence as the merged list) and - // short-circuit on the first hit — later sources are never even listed when an earlier one - // has the package (Concat subscribes lazily; FirstOrDefaultAsync unsubscribes on the match). - // - // 🚨 The caller's grant is part of the resolution, not a check bolted on after it: a package - // the caller was not granted simply does not match, so an ungranted id can neither be - // fetched NOR shadow a granted package of the same id in a later source. - var stages = RegistryAnswerDeadline.Stages(http); - var perSource = sources.Select(s => Observable.Defer(() => ListFrom(s, sources.Count == 1, logger, stages) - .Select(packages => (Source: s, Package: packages.FirstOrDefault( - p => string.Equals(p.Id, body.Id, StringComparison.Ordinal) && IsGranted(caller, s, p)))))); - return perSource.Concat() - .FirstOrDefaultAsync(hit => hit.Package is not null) - .SelectMany(hit => - { - if (hit.Package is null) - { - // 404, never 403 — the listing already hides ungranted packages, so answering - // "forbidden" here would turn /files into an oracle for what else this registry - // carries. The log records which instance asked, so operators can still see it. - logger?.LogWarning( - "Plugin registry: files requested for package '{Id}' — unknown, or not granted to {Instance}", - body.Id, caller?.Instance.InstanceId ?? "(anonymous)"); - return Observable.Return((IResult)Results.Json( - new { error = $"Unknown package '{body.Id}'" }, statusCode: StatusCodes.Status404NotFound)); - } - // The paths subset (manifest-diff fast path) only FILTERS within the resolved - // package's own files — the curated-id resolution above stays the security gate. - // 🚨 The answer names the COMMIT it served (MeshWeaver#3848, decision "Record commit - // on install"): the consumer's install record stores it, and the combo verifier - // then checks the installed content against that commit — never against the - // source's HEAD, which has moved past every install by the time anyone verifies. - // - // 🚨 The ref read is the registry's CONFIGURED ref — except for a SNAPSHOT TOKEN - // (MeshWeaver#6718): a full commit sha the consumer's manifest read was served at, - // so its delta read lands on the same snapshot even when the configured ref moved - // in between. Honoured only when that commit is reachable from the configured ref - // (`PublishesCommit`) — the token can never read a package at a commit that exists - // only on an unmerged branch. Not established ⇒ the configured ref, as before, and - // the consumer refuses the mismatched snapshot by itself and installs in full. - return ReadRef(hit.Source, body.Ref) - .SelectMany(readRef => hit.Source.Source.FetchPackage(hit.Package, readRef, body.Paths)) - .InStage(stages, $"package files '{hit.Package.Id}' from '{hit.Source.Name}'") - .Select(fetched => (IResult)Results.Content(PluginRegistryPayloads.Files(fetched), "application/json")); - }) - .Catch((Exception ex) => - { - logger?.LogWarning(ex, "Plugin registry: fetching files for {Id} failed", body.Id); - return Observable.Return((IResult)Results.Json( - new { error = ex.Message }, statusCode: StatusCodes.Status502BadGateway)); - }) - // 🚨 The files route ANSWERS or REFUSES within the deadline too (MeshWeaver#5825). It was - // the one registry route #6253 left without one, and it is the route every booting - // consumer's default install walks package by package: a folder fetch queued behind - // the process pool held the request with nothing written and nothing logged, until the - // consumer's 30 s attempt cut it (memex, 2026-10-10 07:44Z and 08:55Z: eight such cuts, - // no registry-side line). Now the refusal names the stage that held it. - .AnsweredWithin(http, RegistryAnswerDeadline.Budget(http, RegistryAnswerDeadline.AnswerBudget), - InstanceRegistryAuthenticator.RetryAfterSeconds, logger) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Plugin registry: fetching files for {Id} faulted after the response had already been sent", - body.Id), - ct)!; - } - - /// Fetch-files request: only the package is authoritative — the - /// registry resolves the folder from its curated catalog. is advisory (the - /// registry serves its own configured ref), except for a snapshot token — a full commit sha - /// reachable from the configured ref, which is read instead (MeshWeaver#6718, ). - /// optionally narrows the - /// response to a subset of the package's own repo-relative paths (the manifest-diff incremental - /// fast path); null = all files. Unknown paths are simply absent from the response. The answer - /// carries commit, the exact commit the files were read at, whenever the source can - /// name it (MeshWeaver#3848). - public record FilesBody(string Id, string? Ref = null, IReadOnlyList? Paths = null); -} diff --git a/memex/Memex.Portal.Shared/Api/RegistrationCouponEndpoints.cs b/memex/Memex.Portal.Shared/Api/RegistrationCouponEndpoints.cs deleted file mode 100644 index 8069e6850b..0000000000 --- a/memex/Memex.Portal.Shared/Api/RegistrationCouponEndpoints.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System.Globalization; -using System.Reactive.Linq; -using System.Text.Json; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// The wire shape of the registry's COUPON mint (). The -/// consumer is the hosting operator's shell (hosting-registry-coupon), which reads -/// coupon, tier and id with jq; renaming a field breaks it silently. -/// -public static class RegistrationCouponPayloads -{ - /// POST — mint one coupon. - public const string Route = "/api/instances/coupons"; - - /// The header the mint credential is presented in — never a query parameter. - public const string SecretHeader = "X-MeshWeaver-Coupon-Secret"; - - /// The registry's configuration key holding the mint credential. Unset ⇒ the route does not exist (404). - public const string SecretConfigKey = "PluginCatalog:Coupons:MintSecret"; - - /// The registry's configuration key naming the user (partition) that owns minted coupons — - /// and therefore every instance registered with one, exactly as for a key minted in the tab. - public const string OwnerConfigKey = "PluginCatalog:Coupons:Owner"; - - /// The longest a coupon may live. A coupon is minted for a ramp-up that is days away, not a standing credential. - public const int MaxExpiresDays = 90; - - /// The most registrations one coupon may perform. - public const int MaxUsagesCeiling = 10; - - /// A mint request. - /// The plan the coupon enrols its instance into — an id of the registry's Admin/Tiers/* ladder. Required. - /// What it is for — name the client deployment. Required. - /// Days until it expires, 1–. Required. - /// How many registrations it may perform, 1–; null ⇒ 1. - public sealed record Request(string? Tier, string? Description, int? ExpiresDays, int? MaxUsages); - - /// The answer: the raw coupon (shown ONCE — the caller stores it and forgets it) and its facts. - /// The raw mwr_ key. - /// The key node's id (its hash prefix) — what the Registration keys list shows. - /// The canonical plan it carries. - /// When it expires. - /// How many registrations it may perform. - public sealed record Response(string Coupon, string Id, string Tier, DateTimeOffset ExpiresAt, int MaxUsages); - - /// camelCase, so the shell reads .coupon. - public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); - - /// - /// Why a request may not be minted, or null — the bounds, as a PURE function. The ladder check - /// is separate (it needs the registry's tier nodes). - /// - public static string? Refusal(Request? request) - { - if (request is null) - return "a JSON body {tier, description, expiresDays, maxUsages} is required"; - if (PlanTierRanks.Canonical(request.Tier).Length == 0) - return "tier is required — a coupon without a plan is not a coupon"; - if (string.IsNullOrWhiteSpace(request.Description)) - return "description is required — name the deployment the coupon is for"; - if (request.ExpiresDays is not { } days || days < 1 || days > MaxExpiresDays) - return $"expiresDays is required, 1–{MaxExpiresDays} — a coupon never lives for ever"; - if (request.MaxUsages is { } uses && (uses < 1 || uses > MaxUsagesCeiling)) - return $"maxUsages must be 1–{MaxUsagesCeiling} (omit it for a single-use coupon)"; - return null; - } -} - -/// -/// POST /api/instances/coupons — mint a registration COUPON: a plan-bearing, expiring, -/// usage-limited registration key () for ONE client -/// deployment, so its Provision registers it on its plan from the beginning instead of on -/// free and a person raising the plan afterwards. -/// -/// 🚨 A credential scoped to THIS act, not the bootstrap secret. Bootstrap:Secret -/// also materialises a platform administrator (), so handing it to -/// an operator lane would hand that lane the registry. The mint credential -/// () can do one thing: mint a coupon that -/// carries a plan the registry's ladder knows, expires within -/// days and registers at most -/// instances. Unset ⇒ the route answers 404. -/// -/// The coupon is owned by — a platform -/// administrator of the registry — so instances registered with it land in that partition, as for a -/// key minted in Instance grants ▸ Registration keys, and that administrator sees and revokes it there. -/// The raw value is the response body, once, with Cache-Control: no-store. -/// -public static class RegistrationCouponEndpoints -{ - /// Maps the coupon mint. Called by MapInstanceRegistration, so every registry host serves it (404 while unconfigured). - public static IEndpointRouteBuilder MapRegistrationCoupons(this IEndpointRouteBuilder endpoints) - { - endpoints.MapPost(RegistrationCouponPayloads.Route, Mint).AllowAnonymous(); - return endpoints; - } - - private static Task Mint( - HttpContext http, IMessageHub rootHub, RegistrationCouponPayloads.Request? body, CancellationToken ct) - { - var logger = http.RequestServices.GetService()?.CreateLogger(typeof(RegistrationCouponEndpoints)); - var config = http.RequestServices.GetService() ?? rootHub.ServiceProvider.GetService(); - http.Response.Headers.CacheControl = "no-store, no-cache, must-revalidate, max-age=0"; - http.Response.Headers.Pragma = "no-cache"; - - switch (BootstrapSecretGate.Decide(config?[RegistrationCouponPayloads.SecretConfigKey], - http.Request.Headers[RegistrationCouponPayloads.SecretHeader].ToString())) - { - case BootstrapAuthResult.Disabled: - return Task.FromResult(Results.NotFound()); - case BootstrapAuthResult.Accepted: - break; - default: - logger?.LogWarning("Coupon mint refused: missing or invalid {Header}", RegistrationCouponPayloads.SecretHeader); - return Task.FromResult(Results.Json(new { error = "invalid or missing mint credential" }, - statusCode: StatusCodes.Status401Unauthorized)); - } - - var owner = config?[RegistrationCouponPayloads.OwnerConfigKey]?.Trim().ToLowerInvariant(); - if (string.IsNullOrEmpty(owner)) - return Task.FromResult(Results.Json( - new { error = $"this registry configures no {RegistrationCouponPayloads.OwnerConfigKey} — nobody would own the coupon or the instances it registers. Nothing was minted." }, - statusCode: StatusCodes.Status503ServiceUnavailable)); - if (RegistrationCouponPayloads.Refusal(body) is { } refusal) - return Task.FromResult(Results.Json(new { error = refusal }, statusCode: StatusCodes.Status400BadRequest)); - - var request = body!; - var tier = PlanTierRanks.Canonical(request.Tier); - var uses = request.MaxUsages ?? 1; - var expiresAt = DateTimeOffset.UtcNow.AddDays(request.ExpiresDays!.Value); - var ladder = rootHub.ServiceProvider.GetService(); - var keys = rootHub.ServiceProvider.GetRequiredService(); - var access = rootHub.ServiceProvider.GetRequiredService(); - - // 🚨 The ladder is read AS SYSTEM, after the credential was checked: the HTTP caller is - // anonymous, and on a registry with Access:DenyAnonymous a query under that identity answers - // an EMPTY Initial — every plan would read "not on the ladder" and the empty result would be - // cached for a minute. - return (ladder is null - ? Observable.Return(PlanTierRanks.Empty) - : access.RunAsSystem(() => ladder.Read().Take(1))) - .SelectMany(ranks => - { - // 🚨 The ladder decides, never the caller: a plan the registry does not know licenses - // nothing (PlanTierRanks — fail closed), so a coupon for it would register an instance - // that installs nothing. Refused here, before anything is written. - if (ranks.RankOf(tier) is null) - return Observable.Return(Results.Json(new - { - error = $"plan '{tier}' is not on this registry's tier ladder (Admin/Tiers: " - + (ranks.Ids.Count == 0 ? "none seeded" : string.Join(", ", ranks.Ids)) - + "). Nothing was minted.", - }, statusCode: StatusCodes.Status400BadRequest)); - // RunAs, never Observable.Using over SwitchAccessContext: the scope is AsyncLocal and - // the mint terminates on another hub's thread, so a Using would restore the caller - // there and leave the subscribing flow latched as the coupon owner. - return access.RunAs(new AccessContext { ObjectId = owner, Name = owner }, - () => keys.Mint(owner, owner, "", request.Description!.Trim(), expiresAt, tier, uses)) - .Select(result => - { - logger?.LogInformation( - "Coupon {Id} minted for plan {Tier} ({Uses} use(s), expires {ExpiresAt:u}) — {Description}", - result.Node.Id, tier, uses, expiresAt, request.Description); - return Results.Json(new RegistrationCouponPayloads.Response( - result.RawKey, result.Node.Id, tier, expiresAt, uses), - RegistrationCouponPayloads.Json); - }); - }) - .Catch((Exception ex) => - { - logger?.LogWarning(ex, "Coupon mint failed"); - return Observable.Return(Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status502BadGateway)); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, "Coupon mint faulted after the response had already been sent"), - ct)!; - } -} diff --git a/memex/Memex.Portal.Shared/Api/RegistryAnswerDeadline.cs b/memex/Memex.Portal.Shared/Api/RegistryAnswerDeadline.cs deleted file mode 100644 index 4cbaa54c6d..0000000000 --- a/memex/Memex.Portal.Shared/Api/RegistryAnswerDeadline.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System.Collections.Immutable; -using System.Diagnostics; -using System.Globalization; -using System.Reactive.Concurrency; -using System.Reactive.Linq; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// 🚨 The registry ANSWERS or REFUSES — it never holds a consumer's request open with nothing -/// written (MeshWeaver#4963). -/// -/// What it closes. The registry routes (/api/plugins, /api/plugins/bundles/*) -/// bounded each of their live reads separately — the instance-key legs at 10 s, the query fan-in at -/// 15 s — and nothing bounded the REQUEST. A stage with no bound of its own (the served activation -/// list's shared derivation over the module records on the share, the catalog's shared source -/// listing, a host-supplied seam) could therefore hold the request for as long as it held, with no -/// status line written and no line logged. Measured on 2026-10-07: consumers in two namespaces got -/// 0 bytes for 120 s from memex.meshweaver.cloud (memex-cloud's own three replicas -/// 13:53–13:57Z, the control instance 08:35–09:12Z) while the registry's replicas logged no line for -/// any of those requests and their liveness heartbeats ticked every 10 s on time. -/// -/// What it does. Every request carries a ledger, -/// started at the auth filter (the request's arrival at the route). Each stage that can wait — -/// authentication, each mesh read, the activation list, the source listing — enters it while it -/// runs. If the route has produced no answer within its budget after arrival, it answers -/// 503 + Retry-After and logs ONE warning naming the stage(s) still waiting and how -/// long each finished one took. The abandoned stage is not cancelled for anyone else: the shared -/// reads behind it are service-owned and keep running for the next request. -/// -/// Why this is the discriminator the incident lacked. From now on a consumer-side -/// NoResponse with NO registry-side "produced no answer" line means the request never reached -/// the route (ingress, network, the consumer's own connection) — and a line names the stage that held -/// it. Before, both readings looked identical: silence on both sides. -/// -/// The budget is a property of the CONSUMERS, not a dial. A route answers before the -/// shortest client attempt that reads it gives up, so a stalled route reaches that client as a -/// retryable refusal its resilience pipeline honours (Retry-After) instead of a cut with no -/// cause; and later than the slowest HEALTHY answer measured, so a slow-but-working route is never -/// turned into a refusal. Index and catalog: — under -/// plugin-registry-standard's 30 s attempt, above the 19.4 s slowest healthy index -/// (memex-cloud, 2026-10-07 13:57:35Z). Bundle download: — a healthy -/// download takes ~15–16 s on this registry because it re-derives the activation list twice (a -/// 1,156-byte bundle took 15,318 ms, memex, 2026-10-07 13:58:54Z), and the bundle client's own -/// budget is 120 s. -/// -internal static class RegistryAnswerDeadline -{ - /// Configuration key overriding every route's budget, in seconds (a test's short clock). - public const string BudgetSecondsConfigKey = "PluginCatalog:RegistryAnswerBudgetSeconds"; - - /// The index's, the catalog's and the package-files route's budget: 25 s, between 19.4 s - /// and 30 s (class remarks). POST /api/plugins/files is read by the same - /// plugin-registry-standard client, with the same 30 s attempt (MeshWeaver#5825). - public static readonly TimeSpan AnswerBudget = TimeSpan.FromSeconds(25); - - /// A bundle download's budget: 60 s, between ~16 s and 120 s (class remarks). - public static readonly TimeSpan DownloadBudget = TimeSpan.FromSeconds(60); - - private const string StagesItemKey = "PluginRegistry.AnswerStages"; - - /// The request's stage ledger, started on first use — the auth filter's, so its clock - /// is the request's arrival at the route. - public static RegistryRequestStages Stages(HttpContext http) - { - if (http.Items.TryGetValue(StagesItemKey, out var existing) && existing is RegistryRequestStages stages) - return stages; - var started = new RegistryRequestStages(); - http.Items[StagesItemKey] = started; - return started; - } - - /// The configured override, or the route family's own . - public static TimeSpan Budget(HttpContext http, TimeSpan budget) => - double.TryParse(http.RequestServices.GetService()?[BudgetSecondsConfigKey], - NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) && seconds > 0 - ? TimeSpan.FromSeconds(seconds) - : budget; - - /// Runs as the named stage of the request: entered on - /// subscribe, finished on its first value, its completion or its fault — whichever comes first. - /// 🚨 NOT on unsubscribe: the deadline unsubscribes the stalled stage before it describes the - /// request, and a stage abandoned while it waited is exactly the stage it must name. - public static IObservable InStage(this IObservable source, RegistryRequestStages? stages, string name) => - stages is null ? source : Observable.Defer(() => - { - var stage = stages.Enter(name); - return source.Do(_ => stage.Finish(), _ => stage.Finish(), stage.Finish); - }); - - /// - /// The route's answer, or — when it has produced none after the - /// request arrived — 503 + Retry-After and one warning naming what it was waiting on. - /// - /// The route's answer. - /// The request: the refusal's Retry-After goes on its response. - /// How long after arrival the route may take to answer. - /// The route family's own Retry-After value. - /// Resolved while the request scope was alive. - /// The clock — virtual in tests, the default scheduler otherwise. - public static IObservable AnsweredWithin( - this IObservable answer, HttpContext http, TimeSpan budget, int retryAfterSeconds, - ILogger? logger, IScheduler? scheduler = null) - { - var stages = Stages(http); - var clock = scheduler ?? DefaultScheduler.Instance; - return Observable.Defer(() => - { - // 🚨 Everything the refusal needs from the REQUEST is read HERE, at subscribe — while the - // request is alive — and never inside the timeout. The timeout fires on a timer thread, - // possibly after the client has hung up and the host has disposed the request's feature - // collection; reading `http.Request.Method` there threw `ObjectDisposedException: - // IFeatureCollection has been disposed` (fleet registry, 2026-10-09 20:03:29Z/20:03:32Z), - // the late-fault shape of #5999 in the deadline's own write path. - var request = new RefusedRequest(http.Request.Method, http.Request.Path.ToString(), http.RequestAborted); - var remaining = budget - stages.Elapsed; - if (remaining < TimeSpan.Zero) - remaining = TimeSpan.Zero; - return answer.Take(1).Timeout(remaining, - Observable.Defer(() => Observable.Return(Unanswered(request, stages, budget, retryAfterSeconds, logger))), - clock); - }); - } - - private static IResult Unanswered( - RefusedRequest request, RegistryRequestStages stages, TimeSpan budget, int retryAfterSeconds, ILogger? logger) - { - var account = stages.Describe(); - if (request.Aborted.IsCancellationRequested) - // The client is already gone: nothing will be written, so this is not a refusal anybody - // receives — but the stage account is still the reading the incident needs. - logger?.LogInformation( - "Plugin registry: {Method} {Path} produced no answer within {Budget:F1} s of arriving, and the " - + "client had already disconnected — nothing is written. {Account}", - request.Method, request.Path, budget.TotalSeconds, account); - else - logger?.LogWarning( - "Plugin registry: {Method} {Path} produced no answer within {Budget:F1} s of arriving — answering " - + "503 + Retry-After instead of holding the connection open with nothing written. {Account}", - request.Method, request.Path, budget.TotalSeconds, account); - // The Retry-After header is written when the result EXECUTES, against the context the host - // hands it — so a refusal whose client has gone never touches a disposed response. - return new RetryAfterResult( - Results.Json( - new - { - error = $"The registry could not answer within {budget.TotalSeconds:F1} s — retry shortly. " - + "This says nothing about your key, your grant or the package.", - waitingOn = stages.Pending(), - }, - statusCode: StatusCodes.Status503ServiceUnavailable), - retryAfterSeconds); - } - - /// What the refusal needs from the request, captured while the request was alive. - private sealed record RefusedRequest(string Method, string Path, CancellationToken Aborted); -} - -/// -/// A refusal that carries its Retry-After to the moment it is written: the header goes on the -/// response the host executes it against, never on a context captured earlier (#4963). -/// -internal sealed class RetryAfterResult(IResult inner, int retryAfterSeconds) : IResult, IStatusCodeHttpResult -{ - /// The seconds the client is told to wait before asking again. - public int RetryAfterSeconds { get; } = retryAfterSeconds; - - /// - public int? StatusCode => (inner as IStatusCodeHttpResult)?.StatusCode; - - /// - public Task ExecuteAsync(HttpContext httpContext) - { - httpContext.Response.Headers.RetryAfter = RetryAfterSeconds.ToString(CultureInfo.InvariantCulture); - return inner.ExecuteAsync(httpContext); - } -} - -/// -/// One request's stages, in the order they were entered: which have finished and in how long, and -/// which are still running. Lock-free (stages finish on whatever thread delivered their frame); -/// per request, never shared. -/// -internal sealed class RegistryRequestStages -{ - private readonly long arrivedAt = Stopwatch.GetTimestamp(); - private ImmutableList stages = ImmutableList.Empty; - - /// Time since the request arrived at the route. - public TimeSpan Elapsed => Stopwatch.GetElapsedTime(arrivedAt); - - /// Enters a stage; the returned handle finishes it (idempotently). - public Stage Enter(string name) - { - var stage = new Stage(name, Stopwatch.GetTimestamp()); - ImmutableInterlocked.Update(ref stages, list => list.Add(stage)); - return stage; - } - - /// The names of the stages still running, in the order they were entered. - public IReadOnlyList Pending() => - Volatile.Read(ref stages).Where(s => !s.IsFinished).Select(s => s.Name).Distinct().ToArray(); - - /// One sentence: what the route was waiting on, and what it had finished. - public string Describe() - { - var all = Volatile.Read(ref stages); - if (all.IsEmpty) - return "No stage had started — the request was held before the route read anything."; - var pending = all.Where(s => !s.IsFinished) - .Select(s => $"{s.Name} (running {s.Duration.TotalSeconds:F1} s)").ToArray(); - var done = all.Where(s => s.IsFinished) - .Select(s => $"{s.Name} {s.Duration.TotalSeconds:F1} s").ToArray(); - return (pending.Length > 0 - ? $"Still waiting on: {string.Join("; ", pending)}." - : "Every stage had finished — the answer was being composed.") - + (done.Length > 0 ? $" Finished: {string.Join("; ", done)}." : " Nothing had finished."); - } - - /// One entered stage. - internal sealed class Stage(string name, long startedAt) - { - private long finishedAt; - - /// The stage's name. - public string Name { get; } = name; - - /// Whether it finished. - public bool IsFinished => Interlocked.Read(ref finishedAt) != 0; - - /// How long it ran, or has been running. - public TimeSpan Duration => - Interlocked.Read(ref finishedAt) is var end && end != 0 - ? Stopwatch.GetElapsedTime(startedAt, end) - : Stopwatch.GetElapsedTime(startedAt); - - /// Finishes the stage; a second call keeps the first time. - public void Finish() => Interlocked.CompareExchange(ref finishedAt, Stopwatch.GetTimestamp(), 0); - } -} diff --git a/memex/Memex.Portal.Shared/Api/ReleaseGateEndpoints.cs b/memex/Memex.Portal.Shared/Api/ReleaseGateEndpoints.cs deleted file mode 100644 index 94584ad75d..0000000000 --- a/memex/Memex.Portal.Shared/Api/ReleaseGateEndpoints.cs +++ /dev/null @@ -1,495 +0,0 @@ -using System.Reactive.Concurrency; -using System.Reactive.Linq; -using System.Text.Json; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Data; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// The release gate as a READABLE service: GET /api/plugins/is-updatable?version=… answers -/// #1754's question — may this environment be rolled to that release? — for the instance -/// serving the request. -/// -/// It exists because the verdict has to be reachable by the paths that roll a version but do -/// not run inside the portal: CD's own post-promote assertion, and an operator about to -/// kubectl set image. Those paths must not re-derive the rule — the whole point of -/// is that there is ONE rule — so they read the answer instead -/// of recomputing it. The portal's own poller calls the same -/// in-process. -/// -/// 🚨 Auth is an instance key or a build granted verify:combo, the same mwi_ gate as the bundle routes, and it -/// fails CLOSED: the response names installed packages and the reasons a release is unsafe, which -/// is deployment inventory, not public information. -/// -/// Scope, stated rather than implied: an instance answers for ITSELF. The registry -/// records that an instance exists (InstanceAutoRegistrationService) but not what it has -/// installed, so no third party can answer for it today; each environment is asked at its own URL. -/// When #1735's per-environment composition lands, the declared package set becomes answerable -/// centrally and is the one place that changes — the rule -/// above does not. -/// -public static class ReleaseGateEndpoints -{ - /// - /// Route the gate is mounted at: GET /api/plugins/is-updatable?version=…. - /// - /// 🚨 It answers the ROLL question for that version (policy - /// platform-rolls-independently-of-packages): isUpdatable is the platform-roll - /// decision and is true even when a package is not available for the version — that - /// package answer is waived, kept verbatim on waivedHold, leads advisories, and - /// every package keeps its own status in packages. holdReason is then - /// null. A consumer that needs the PACKAGE answer ("is every package available for this - /// version?") reads waivedHold and the package statuses, never isUpdatable; - /// indeterminate still tells "could not determine" apart from a determined refusal, and - /// enforced: false means the gate does not apply to this deployment at all. - /// - public const string Route = "/api/plugins/is-updatable"; - - /// - /// 🚨 Route the SELECTOR is mounted at (#3479): GET /api/plugins/roll-target — which - /// release should this environment be on? - /// - /// answers about a version the caller already chose; this answers - /// the choice: the newest admitted release, with what the packages say about it as advisories - /// (policy platform-rolls-independently-of-packages). Same auth, same scope, same shared - /// predicate — this is the selection over it, not a second one. - /// - public const string SelectRoute = "/api/plugins/roll-target"; - - /// - /// 🚨 Route the instance's COMBO is served at (#3544): GET /api/plugins/combo — which - /// modules, at which refs, does this instance actually run? - /// - /// It exists for the same reason as its two siblings and for one caller in particular: - /// the combo GATE (ComboVerificationGate) consults a verdict that only an off-cluster - /// producer can mint, because producing one needs docker, a materialisation root and repo - /// credentials a portal pod does not have. That producer — mw-combo-verify — needs the - /// instance's combo as its FIRST input, and until this route existed there was no way to get - /// it out of a running portal: InstanceComboReader had no HTTP, MCP or layout surface at - /// all, so nothing ever ran the verifier and every roll in the fleet was UNVERIFIED (#3544). - /// - /// 🚨 The bytes are the contract. The body is an - /// serialized with — the very options - /// mw-combo-verify deserializes with — so the response IS combo.json and no caller - /// has to reshape it. There is deliberately no lossy alternative: the nearest existing node - /// (Hosting/ModuleInventory) drops readAt, isComplete, caveats and - /// the per-module sync detail, and a verdict derived from it would be about something other - /// than this instance's real module set. - /// - /// Auth is the same mwi_ instance key, failing CLOSED, for the same reason: a - /// combo names every module repository and pinned commit this deployment carries, which is - /// deployment inventory rather than public information — strictly narrower than nothing, and - /// the same class the two routes above already return. - /// - public const string ComboRoute = "/api/plugins/combo"; - - /// Accepts one compatibility verdict from an explicitly trusted build. - public const string VerificationRoute = "/api/plugins/combo-verification"; - - /// The local resource named by a build's verify:combo grant. - public const string VerificationResource = "combo"; - - /// Maps the instance-key-gated release gate. Call alongside MapPluginBundles. - public static IEndpointRouteBuilder MapReleaseGate(this IEndpointRouteBuilder endpoints) - { - endpoints.MapGet(Route, (HttpContext http, string? version, CancellationToken ct) => - Verdict(http, version, ct)) - .AllowAnonymous(); - endpoints.MapGet(SelectRoute, (HttpContext http, string? current, CancellationToken ct) => - Selection(http, current, ct)) - .AllowAnonymous(); - endpoints.MapGet(ComboRoute, (HttpContext http, CancellationToken ct) => Combo(http, ct)) - .AllowAnonymous(); - endpoints.MapPost(VerificationRoute, (HttpContext http, CancellationToken ct) => - RecordVerification(http, ct)) - .AllowAnonymous(); - return endpoints; - } - - /// - /// The roll SELECTION for this instance. Authenticated exactly like and - /// for the same reason: the answer names installed packages and the releases that cannot serve - /// them, which is deployment inventory rather than public information. - /// - private static Task Selection(HttpContext http, string? current, CancellationToken ct) - { - var authenticator = http.RequestServices - .GetRequiredService(); - - var logger = http.RequestServices.GetService() - ?.CreateLogger(typeof(ReleaseGateEndpoints)); - - return authenticator.AuthenticateOutcome(http.Request.Headers.Authorization) - .SelectMany(outcome => outcome.IsUnavailable - ? Observable.Return(InstanceAuthResponses.Unavailable(http, outcome.UnavailableReason, logger)) - : outcome.Instance is null && !CanVerify(outcome) - ? Observable.Return(Results.Json( - new { error = "A registered instance key or an authorized build identity is required." }, - statusCode: StatusCodes.Status401Unauthorized)) - : Choose(http, current)) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Roll selection faulted after the response had already been sent"), - ct)!; - } - - /// - /// 🚨 The answer is the POLLER's own selection (#4413) — the registry's portal tag listing under - /// this instance's update policy, walked by the availability gate — never a second selection - /// over the published root's _releases markers. A marker exists for a set the fleet has - /// not armed yet, so that universe named releases with no portal image, and combo verification - /// (the route's one automated caller) then pulled an image that does not exist. A host without - /// the poller has no roll target and says so as . - /// - internal static IObservable Choose(HttpContext http, string? current) => - (http.RequestServices.GetService() is { } poller - // 🚨 The running version is READ by the poller, not taken from the caller: "different - // from current ⇒ update" is a claim about what this instance runs, and a caller that - // could assert it could ask for a rollback by lying. The query parameter is a - // diagnostic override for an operator asking "what would you pick from there", never - // the default. - ? poller.SelectRollTarget(current) - : Observable.Return(new RollSelectionOutcome( - RollSelectionKind.Indeterminate, null, - string.IsNullOrWhiteSpace(current) ? ShippedReleaseSeed.InstalledPlatformVersion : current, - 0, 0, [], - "this host runs no self-update poller, so it has no roll target to name."))) - .Select(outcome => Results.Json(new - { - environment = outcome.Summary, - kind = outcome.Kind.ToString(), - current = outcome.CurrentVersion, - selected = outcome.SelectedVersion, - shouldUpdate = outcome.ShouldUpdate, - // 🚨 Distinguishes an availability failure from "we looked and nothing is - // complete" — the same split IsUpdatable's `indeterminate` makes. - indeterminate = outcome.IsIndeterminate, - // 🚨 THE DENOMINATOR, always. A completeness answer whose expected count nobody can - // read is one nobody can tell from a vacuous one. - requiredPlugins = outcome.RequiredPlugins, - satisfiedPlugins = outcome.SatisfiedPlugins, - // 🚨 #3651 — what the selected release's verdict said without deciding on it, - // and the packages it would recompile at boot. - advisories = outcome.Advisories.IsDefault - ? Array.Empty() - : outcome.Advisories.ToArray(), - bootCompiles = outcome.BootCompiles.IsDefault - ? Array.Empty() - : outcome.BootCompiles.ToArray(), - declined = outcome.Declined - .Select(d => new - { - version = d.Version, - reason = d.Reason, - blockers = d.Blockers - .Select(b => new { package = b.Package, status = b.Kind.ToString(), reason = b.Reason }) - .ToArray(), - }) - .ToArray(), - })); - - private static Task Verdict(HttpContext http, string? version, CancellationToken ct) - { - var authenticator = http.RequestServices - .GetRequiredService(); - - var logger = http.RequestServices.GetService() - ?.CreateLogger(typeof(ReleaseGateEndpoints)); - - return authenticator.AuthenticateOutcome(http.Request.Headers.Authorization) - .SelectMany(outcome => outcome.IsUnavailable - ? Observable.Return(InstanceAuthResponses.Unavailable(http, outcome.UnavailableReason, logger)) - : outcome.Instance is null && !CanVerify(outcome) - ? Observable.Return(Results.Json( - new { error = "A registered instance key or an authorized build identity is required." }, - statusCode: StatusCodes.Status401Unauthorized)) - : Answer(http, version)) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Release gate for version '{Version}' faulted after the response had already been sent", - version), - ct)!; - } - - - - /// The authenticated answer of . Internal so the JSON contract — - /// which CI scripts and operators consume — is pinned by a test without an instance key. - internal static IObservable Answer(HttpContext http, string? version) - { - if (string.IsNullOrWhiteSpace(version)) - return Observable.Return(Results.Json( - new { error = "Query parameter 'version' is required — the release to be rolled to." }, - statusCode: StatusCodes.Status400BadRequest)); - - var service = http.RequestServices.GetRequiredService(); - var logger = http.RequestServices.GetService() - ?.CreateLogger(typeof(ReleaseGateEndpoints)); - - return service.IsUpdatable(version) - .Select(raw => - { - // 🚨 The platform rolls independently of its packages (policy - // platform-rolls-independently-of-packages). This endpoint - // answers the ROLL question for a manual `kubectl set image` exactly as the poller - // and /roll-target do: a package-availability refusal is waived into advisories - // (`waivedHold` keeps it verbatim, `packages` still names every lagging package), - // never a "no". - var verdict = raw.ForPlatformRoll(); - if (verdict.WaivedHold is { } waived) - // Information, not Debug: the packages that will not follow must be findable - // in the logs without turning anything on. - logger?.LogInformation( - "Release gate: {Version} rolls; package advisory (the platform rolls independently " - + "of packages) — {Reason}", version, waived); - - return Results.Json(new - { - version, - isUpdatable = verdict.IsUpdatable, - // 🚨 What the package verdict would have held on before policy - // platform-rolls-independently-of-packages — the - // packages that do not follow this roll and catch up on their own. - waivedHold = verdict.WaivedHold, - // Distinguishes "the gate does not apply here" from "the gate passed" — the - // two must never render as the same tick. - enforced = verdict.NotEnforcedReason is null, - notEnforcedReason = verdict.NotEnforcedReason, - // 🚨 Distinguishes an availability failure from a compatibility verdict. - indeterminate = verdict.IsIndeterminate, - holdReason = verdict.HoldReason, - // 🚨 #3648 — what the installed modules DECLARE about the target, beside the - // verdict and never inside it: a declared floor the target does not rank - // above is an advisory, not a hold. - advisories = verdict.Advisories.IsDefault - ? Array.Empty() - : verdict.Advisories.ToArray(), - // 🚨 #3651 — the packages the roll would Roslyn-compile at boot, by name: a - // cost the caller reads, never a reason in isUpdatable. - bootCompiles = verdict.BootCompiles.IsDefault - ? Array.Empty() - : verdict.BootCompiles.ToArray(), - packages = verdict.Packages - .Select(p => new - { - package = p.Package, - status = p.Kind.ToString(), - reason = p.Reason, - // Whether the status is a cost the roll accepts rather than a hold. - advisory = p.IsAdvisory, - }) - .ToArray(), - }); - }); - } - - /// - /// The instance's own combo, authenticated exactly like and - /// . See for why it exists and why the bytes - /// are the contract. - /// - private static Task Combo(HttpContext http, CancellationToken ct) - { - var authenticator = http.RequestServices - .GetRequiredService(); - - var logger = http.RequestServices.GetService() - ?.CreateLogger(typeof(ReleaseGateEndpoints)); - - return authenticator.AuthenticateOutcome(http.Request.Headers.Authorization) - .SelectMany(outcome => outcome.IsUnavailable - ? Observable.Return(InstanceAuthResponses.Unavailable(http, outcome.UnavailableReason, logger)) - : outcome.Instance is null && !CanVerify(outcome) - ? Observable.Return(Results.Json( - new { error = "A registered instance key or an authorized build identity is required." }, - statusCode: StatusCodes.Status401Unauthorized)) - : StateCombo(http, logger)) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Instance combo read faulted after the response had already been sent"), - ct)!; - } - - private static bool CanVerify(InstanceAuthResult outcome) => - outcome.Build?.Allows(BuildVerbs.Verify, VerificationResource) == true; - - private static Task RecordVerification(HttpContext http, CancellationToken ct) - { - var logger = http.RequestServices.GetService() - ?.CreateLogger(typeof(ReleaseGateEndpoints)); - return http.RequestServices.GetRequiredService() - .AuthenticateOutcome(http.Request.Headers.Authorization) - .SelectMany(outcome => outcome.IsUnavailable - ? Observable.Return(InstanceAuthResponses.Unavailable(http, outcome.UnavailableReason, logger)) - : !CanVerify(outcome) - ? Observable.Return(Results.Json( - new { error = "An authorized build identity is required." }, - statusCode: StatusCodes.Status401Unauthorized)) - : ReadAndRecordVerification(http, outcome.Build!, logger, ct)) - .FirstAsync() - .ObserveCompletion(ex => logger?.LogWarning(ex, - "Combo verification recording failed after the response was sent"), ct)!; - } - - private static IObservable ReadAndRecordVerification( - HttpContext http, AuthenticatedBuild build, ILogger? logger, CancellationToken ct) - { - // Authenticate before reading the body or resolving any mesh service. The caller never - // becomes System: only the existing, narrowly scoped RecordVerification primitive writes. - var hub = http.RequestServices.GetRequiredService(); - var pool = hub.ServiceProvider.GetRequiredService().Get(IoPoolNames.Http); - return pool.Invoke(_ => http.Request.ReadFromJsonAsync( - InstanceComboAssembler.Json, ct).AsTask()) - .SelectMany(verdict => - { - if (verdict is null || string.IsNullOrWhiteSpace(verdict.CandidateTag) - || verdict.VerifiedAt == default || !Enum.IsDefined(verdict.Verdict) - || verdict.Modules is null || verdict.Caveats is null - || verdict.Modules.Any(m => m is null || string.IsNullOrWhiteSpace(m.ModuleId) - || !Enum.IsDefined(m.Outcome) || m.Failures is null) - || verdict.Modules.Select(m => m.ModuleId).Distinct(StringComparer.OrdinalIgnoreCase) - .Count() != verdict.Modules.Count - // Retired modules (#3848) are reported apart and never ALSO verified. - || verdict.RetiredModules is null - || verdict.RetiredModules.Any(r => r is null || string.IsNullOrWhiteSpace(r.ModuleId) - || verdict.Modules.Any(m => string.Equals(m.ModuleId, r.ModuleId, - StringComparison.OrdinalIgnoreCase))) - || verdict.RetiredModules.Select(r => r.ModuleId).Distinct(StringComparer.OrdinalIgnoreCase) - .Count() != verdict.RetiredModules.Count - || (verdict.Verdict == ComboVerdictKind.Green - && (verdict.Modules.Count == 0 || string.IsNullOrWhiteSpace(verdict.ImageDigest) - || string.IsNullOrWhiteSpace(verdict.VerifiedPlatform) - || verdict.ComboReadAt == default - || verdict.Modules.Any(m => m.Outcome != ModuleVerificationOutcome.Passed)))) - return Observable.Return(Results.BadRequest(new - { error = "A complete, internally consistent combo verification is required." })); - - var expected = JsonSerializer.Serialize(verdict, InstanceComboAssembler.Json); - return RecordAndConfirm( - UpdatePolicyNodeType.RecordVerification(hub, verdict), - // Update is optimistic across hubs. A 200 promises that the owning node has - // recorded this exact verdict, so observe its reconciled state before replying. - () => Observable.Create(observer => - { - using (AccessContextScope.AsSystem(hub.ServiceProvider.GetRequiredService())) - return hub.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Select(node => UpdatePolicyNodeType.Parse(node, hub.JsonSerializerOptions) - .VerificationFor(verdict.CandidateTag)) - .Where(recorded => JsonSerializer.Serialize(recorded, - InstanceComboAssembler.Json) == expected) - .Take(1) - .Do(_ => logger?.LogInformation( - "Build {Repository} run {RunId} recorded combo verdict {Verdict} for {Candidate}", - build.Repository, build.Claims.RunId, verdict.Verdict, verdict.CandidateTag)) - .Select(_ => (IResult)Results.Ok(new - { recorded = true, candidateTag = verdict.CandidateTag })) - .Subscribe(observer); - }), - VerificationDeadline, - Scheduler.Default, - phase => VerificationTimedOut(phase, verdict, build, logger)); - }) - .Catch(_ => Observable.Return(Results.BadRequest(new - { error = "The combo verification body is not valid JSON." }))); - } - - /// How long a verdict's write and its confirmation may take, together. - internal static readonly TimeSpan VerificationDeadline = TimeSpan.FromSeconds(30); - - /// The verdict's write to Admin/UpdatePolicy had not completed when the deadline expired. - internal const string WritePhase = "write"; - - /// The write completed, but Admin/UpdatePolicy never showed this exact verdict. - internal const string ConfirmPhase = "confirm"; - - /// - /// Records a verdict and confirms it, under ONE deadline, and answers an expired deadline as a - /// response naming the phase that had not completed (MeshWeaver#6790). - /// - /// 🚨 The deadline used to escape as a bare : Rx creates it - /// without throwing it, so it has no frames, and the endpoint's faulted into - /// an unhandled 500 that named neither the route nor which of the two waits had stalled. Measured - /// on 2026-10-15: the CI verifier's POST for one candidate answered 500 after 31 s, and the - /// node's version history shows the verdict was not written — the stall was in the write, which - /// nothing in the response or the log could say. The expiry is now a 504 carrying - /// phase = or . - /// - /// 🚨 Only THIS deadline is answered: it is Timeout's FALLBACK, never a - /// Catch of . A raised - /// upstream (a write's own initial-state bound, say) is a fault, not this route's deadline, and - /// still propagates to the exception handler like every other fault. - /// - /// 🚨 A 504 does NOT say the verdict is absent: unsubscribing detaches this request from a - /// write already queued, it does not cancel it, so the verdict may still land after the answer. - /// The outcome is UNKNOWN; the caller re-reads Admin/UpdatePolicy or re-posts (the record - /// is an upsert by candidate tag, so a re-post is idempotent). - /// - internal static IObservable RecordAndConfirm( - IObservable write, - Func> confirmed, - TimeSpan deadline, - IScheduler scheduler, - Func timedOut) => - Observable.Defer(() => - { - // Per subscription: which wait the deadline found unfinished. - var phase = WritePhase; - return write - .Do(_ => Volatile.Write(ref phase, ConfirmPhase)) - .SelectMany(_ => confirmed()) - .Timeout(deadline, - Observable.Defer(() => Observable.Return(timedOut(Volatile.Read(ref phase)))), - scheduler); - }); - - private static IResult VerificationTimedOut( - string phase, ComboVerification verdict, AuthenticatedBuild build, ILogger? logger) - { - var seconds = VerificationDeadline.TotalSeconds; - var what = phase == WritePhase - ? $"the write to {UpdatePolicyNodeType.NodePath} had not completed within {seconds:0} s" - : $"the write completed, but {UpdatePolicyNodeType.NodePath} did not show this exact verdict within {seconds:0} s"; - what += $"; whether the verdict is recorded is UNKNOWN (a queued write may still land) — re-read {UpdatePolicyNodeType.NodePath}, or re-post (an upsert by candidate tag, so idempotent)"; - logger?.LogWarning( - "Combo verdict {Verdict} for {Candidate} from {Repository} run {RunId}: {Phase} phase did not complete — answered 504: {What}", - verdict.Verdict, verdict.CandidateTag, build.Repository, build.Claims.RunId, phase, what); - return Results.Json( - new { error = $"Timed out: {what}.", phase, candidateTag = verdict.CandidateTag }, - statusCode: StatusCodes.Status504GatewayTimeout); - } - - - private static IObservable StateCombo(HttpContext http, ILogger? logger) => - http.RequestServices.GetRequiredService().Read() - .Select(combo => - { - // 🚨 Information, and stated rather than swallowed. The reader never faults — an - // unreadable source sets IsComplete=false and a caveat — so an incomplete combo - // would otherwise leave the portal silent while the verifier folds it into a - // NotVerifiable nobody can attribute. THE DENOMINATOR travels with it. - if (!combo.IsComplete) - logger?.LogInformation( - "Instance combo served INCOMPLETE: {Modules} module(s), caveats: {Caveats}", - combo.Modules.Count, string.Join(" | ", combo.Caveats)); - - // The producer deserializes with these exact options, so this response IS - // combo.json. Results.Json would re-serialize with ASP.NET's options instead. - return (IResult)Results.Content( - JsonSerializer.Serialize(combo, InstanceComboAssembler.Json), "application/json"); - }); -} diff --git a/memex/Memex.Portal.Shared/Api/SeoEndpoints.cs b/memex/Memex.Portal.Shared/Api/SeoEndpoints.cs deleted file mode 100644 index 19f21fee97..0000000000 --- a/memex/Memex.Portal.Shared/Api/SeoEndpoints.cs +++ /dev/null @@ -1,741 +0,0 @@ -using System.Collections.Immutable; -using System.Reactive.Linq; -using System.Xml.Linq; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Graph; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// The crawler plumbing: a real /robots.txt and /sitemap.xml. Without these the -/// Blazor catch-all served the SPA HTML shell on both URLs — a crawler asking for robots.txt got -/// a web page. The sitemap enumerates exactly the ANONYMOUS surface: every top-level node that -/// passes (public covers, the Store, Space landings) -/// plus each store plugin's declared public segments (the marketing brochures). -/// -/// 🚨 It does NOT fail open to an empty sitemap. It used to, and that is issue #4751: -/// a well-formed <urlset> with zero <loc> is not a smaller answer, it is -/// the OPPOSITE answer — an affirmative census saying this deployment publishes nothing. The -/// crawler-facing concern the old sentence was protecting against is real and is answered with -/// 503 + Retry-After, which is what "ask again later" means on the wire; a 200 that -/// declares zero roots is the one response a crawler cannot tell from the truth. See -/// . -/// -/// -/// One page that is live on the public internet: the node, and the path a logged-out visitor -/// reaches it at. Produced by . -/// -/// The published node. -/// Its mesh path, which is also its public URL path — publishing never moves a -/// node, so this is the same path it has always had. -public sealed record PublishedPage(MeshNode Node, string Path); - -/// -/// The published surface AND whether the enumeration that produced it actually decided it. -/// -/// 🚨 The second field exists because a list cannot carry it and every consumer needs -/// it (#4751). answers a TRI-state — granted, denied, or -/// when the permission fold reached no verdict -/// — and SeoEndpoints used to project that onto a bool, on the stated grounds that omitting -/// an undecidable root "states nothing". That is true of ONE root and false of all of them: N -/// omissions that each state nothing compose into a census that states everything. So the reason -/// travels out with the pages, and the consumer decides. -/// -/// Every page a logged-out visitor may open, as far as this run established. -/// Null when every root was decided and nothing faulted; otherwise the -/// first reason a root's publicness could not be established, ready to log. -public sealed record PublishedSurface(IReadOnlyList Pages, string? Undecided) -{ - /// A surface every root was decided for — a census, and still a census when empty. - /// The decided pages. - public static PublishedSurface Decided(IReadOnlyList pages) => new(pages, null); - - /// - /// 🚨 THE ONE RULE, and it is deliberately narrow: true only when publishing this list - /// would ASSERT something the enumeration never established — no pages at all, and at least one - /// root whose publicness could not be decided. - /// - /// A PARTIAL surface is fine and stays a 200: a sitemap is a hint, the protocol never - /// promised completeness, and dropping one undecidable root out of a thousand costs one URL - /// until the next crawl. Zero is the only value that reads as a statement, which is why - /// it is the only one withheld. The rule is also why a genuinely empty portal still answers - /// 200 with an empty urlset — that IS the truth, and the synthetic probe that fails on it is - /// then correctly failing. - /// - public bool AssertsWhatItDidNotCheck => Pages.Count == 0 && Undecided is not null; -} - -/// -/// There is no honest sitemap to render: the enumeration decided nothing AND admitted no page, so -/// the only document it could produce is the zero-root census of #4751. Carried as a fault rather -/// than an empty string so that no caller can mistake it for a result — the route turns it into -/// 503, and PublishedSettingsTab turns it into a sentence for a human. -/// -/// Why the surface could not be decided. -public sealed class SitemapUndecidedException(string reason) - : InvalidOperationException( - "the published surface could not be decided and no page was admitted, so a sitemap would " - + "declare zero public roots without having checked any: " + reason) -{ - /// Why the surface could not be decided, without the framing sentence. - public string Reason { get; } = reason; -} - -public static class SeoEndpoints -{ - /// - /// Late-fault sink for this surface's - /// bridges: a fault that lands AFTER the crawler response has already settled cannot change the - /// answer, but discarding it would hide a mesh read that failed on the way out. The logger is - /// captured eagerly, because a late fault arrives long after the request scope is gone. - /// - private static Action LateFault(IMessageHub hub, string route) - { - var logger = hub.ServiceProvider.GetService() - ?.CreateLogger(typeof(SeoEndpoints)); - return ex => logger?.LogWarning( - ex, "{Route}: faulted after its HTTP response had already settled", route); - } - - /// - /// Sink for an AUTHORED icon whose markup will not parse. The route answers 404 either way, so - /// without this line a broken mark and a node with no mark are indistinguishable from outside — - /// and the broken one is the only one anybody can fix. - /// - /// 🚨 GetRequiredService, unlike above, and the difference - /// is the point: this sink IS the diagnostic. A null-conditional logger would make the one - /// signal that a mark is broken silently optional — the exact failure the method exists to - /// prevent — so a host with no logger factory must say so loudly rather than serve 404s that - /// mean nothing. Resolved ONCE per request, before the reactive chain, so the cost is not paid - /// per emission and a missing registration cannot surface as a swallowed 404. - /// - private static Action UnrenderableIcon(IMessageHub hub, string nodePath) - { - var logger = hub.ServiceProvider.GetRequiredService() - .CreateLogger(typeof(SeoEndpoints)); - return ex => logger.LogWarning( - ex, "The icon of '{Path}' is inline svg that will not render; serving no raster icon " - + "for it", nodePath); - } - - /// - /// What /api/sitemap.xml actually answers, as ONE function the route calls and a test - /// can drive — the same shape as , and for the same reason: a test that - /// re-implements the mapping beside the route can agree with itself while the shipped answer is - /// wrong. The status code and the Retry-After header are part of the contract here, so - /// they are reached from the route's own decision or they are not tested at all. - /// - /// Both failure arms land in the same place: the deliberate - /// , and anything the mesh read faulted on. - /// - /// The hub the enumeration runs against. - /// The request — the 503 arm writes Retry-After on its response. - /// The canonical public host every <loc> is built on. - internal static IObservable SitemapResult(IMessageHub hub, HttpContext http, string baseUrl) => - BuildSitemap(hub, baseUrl) - .Select(xml => Results.Text(xml, "application/xml")) - .Catch(ex => Observable.Return(SitemapUnavailable(hub, http, ex))); - - /// - /// 🚨 THE ANSWER FOR "I COULD NOT CHECK" — 503 with a Retry-After, and a warning naming - /// the cause. - /// - /// Both halves are the fix for #4751. The status code, because 503 is the wire's word - /// for "ask again later" and a crawler acts on it correctly, whereas a 200 carrying zero - /// <loc> is indistinguishable from a portal that genuinely publishes nothing. And - /// the log line, because the old code discarded the exception into _ => — so nine - /// failures over 27 hours left NOTHING anywhere naming a cause, which is why the issue could be - /// measured precisely and diagnosed not at all. - /// - private static IResult SitemapUnavailable(IMessageHub hub, HttpContext http, Exception cause) - { - hub.ServiceProvider.GetService() - ?.CreateLogger(typeof(SeoEndpoints)) - .LogWarning( - cause, - "/sitemap.xml: the published surface could not be enumerated; answering 503 rather " - + "than a well-formed sitemap that declares zero public roots"); - http.Response.Headers.RetryAfter = "300"; - return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); - } - - /// Node types whose top-level mains are sitemap candidates (the partition roots). - private static readonly ImmutableArray CandidateNodeTypes = ["Store/Plugin", "Store/Catalog", "Space"]; - - /// - /// The root enumeration for one candidate type: every TOP-LEVEL main of that type, mesh-wide, - /// uncapped. - /// - /// 🚨 The root filter is pushed DOWN, not applied afterwards (#4080). This used to be - /// nodeType:{type} is:main limit:500 with !Path.Contains('/') on the client, so - /// once a type had more than 500 mains mesh-wide — every course installed into a user - /// partition is a Space main, every store plugin copy a Store/Plugin — which - /// roots landed inside the window was decided by storage order, and a public root silently - /// dropped out of the sitemap. Nothing errored: the endpoint is fail-open to fewer URLs, which - /// is exactly what made it invisible. namespace: with an EMPTY value is - /// namespace = '' on every backend (the home catalog's root leg is the other consumer), - /// so the storage returns only roots and there is nothing left to cap: the population is - /// bounded by the number of partitions, not by the number of mains. - /// - internal static string RootCandidateQuery(string nodeType) => - MeshWideQuery.Declare($"nodeType:{nodeType} is:main namespace: limit:all"); - - /// - /// 🚨 WHAT COUNTS AS A PAGE below a public root — the node types whose instances are documents - /// a person reads, as opposed to the data, code, releases and registrations a partition also - /// holds. A reinsurance plugin's partition carries hundreds of amount types, cashflows, source - /// files and release markers; none of those is a page, and listing them would bury the twenty - /// pages that are. Anonymous readability is decided separately, per node, by the gate — this - /// list only says which readable nodes are worth a search engine's visit. - /// - internal static readonly ImmutableArray PageNodeTypes = - ["Markdown", "Space", "Store/Plugin", "Store/Catalog", "Edu/Module", "Edu/Page"]; - - /// - /// Path segments that route to a partition's SATELLITE tables — code, tests, release markers — - /// never to a page. Underscore segments (_Thread, _Access, _GitSync, …) are - /// the satellite convention itself. - /// - private static readonly ImmutableHashSet SatelliteSegments = - ImmutableHashSet.Create(StringComparer.Ordinal, "Source", "Test", "Release"); - - /// Concurrent per-node gate checks while enumerating one root's pages. - private const int GateConcurrency = 8; - - /// - /// Whether a path below a root can be a page at all: no satellite segment anywhere in it. - /// Pure; the gate decides readability afterwards. - /// - internal static bool IsPagePath(string path) - => path.Split('/').All(segment => - segment.Length > 0 - && segment[0] != '_' - && !SatelliteSegments.Contains(segment)); - - public static IEndpointRouteBuilder MapSeo(this IEndpointRouteBuilder app) - { - app.MapGet("/robots.txt", (HttpContext http, IConfiguration configuration) => - { - var baseUrl = PublicSite.CanonicalBaseUrl(configuration, http.Request); - // The app host is the same site under a second name: nothing on it is for the index, - // and the sitemap it points at is the public host's. See PublicSite. - var rules = PublicSite.IsAppOnlyHost(configuration, http.Request) - ? "Disallow: /" - : """ - Disallow: /login - Disallow: /welcome - Disallow: /api/ - Disallow: /_blazor - Disallow: /dev/ - """.TrimEnd(); - return Results.Text( - $""" - User-agent: * - {rules} - Sitemap: {baseUrl}/sitemap.xml - """, "text/plain"); - }).AllowAnonymous(); - - // [FromServices] on the hub, explicitly: minimal-API parameter inference classifies an - // unregistered reference type as the request BODY, so a host that maps these routes - // without a mesh (a test of robots.txt alone) failed at map time with "Body was inferred". - app.MapGet("/sitemap.xml", ([FromServices] IMessageHub hub, HttpContext http, IConfiguration configuration, CancellationToken ct) => - { - var baseUrl = PublicSite.CanonicalBaseUrl(configuration, http.Request); - return SitemapResult(hub, http, baseUrl) - .FirstAsync() - .ObserveCompletion(LateFault(hub, "/sitemap.xml"), ct)!; - }).AllowAnonymous(); - - MapShareCard(app); - MapNodeIcon(app); - return app; - } - - /// - /// 🚨 THE FALLBACK SHARE CARD — /api/og/{node}.png. - /// - /// Generated on demand from the node's own name, description and category so that EVERY - /// public page has an Open Graph image without anyone authoring one. An authored image always - /// wins; this is what falls back to. - /// - /// Gated identically to the SEO head — literally the same call. The card is drawn - /// from , the ONE predicate the head's own card - /// block asks: a node the fail-closed admits, or one whose scope opted - /// in to . Anything else is 404 — the same - /// answer a missing node gets, so the route is still no existence oracle. There is no parallel - /// permission rule here to drift from the page's, which is the whole reason that predicate is one - /// function: a head declaring og:image for a page whose picture 404s ships a broken card, - /// and several unfurlers then drop the preview entirely. - /// - /// Shared-cacheable on purpose — the one image route where public is - /// correct. Everything drawn on it is already served to anonymous callers on the page itself, - /// and crawlers refetch cards aggressively; the strong ETag is the render's own hash, so a - /// renamed node produces a new card rather than a stale one. - /// - private static void MapShareCard(IEndpointRouteBuilder app) - { - // The instance's own card — what a page that is no public node shares with (the home - // page, a private node): the site name and host, nothing read from the mesh, so there is - // nothing here the anonymous gate would have to withhold. - app.MapGet("/api/og.png", ([FromServices] OgCardRenderer renderer, HttpContext http) => - PngResult(http, renderer.RenderSite(http.Request.Host.Host))).AllowAnonymous(); - - app.MapGet("/api/og/{**path}", ( - [FromServices] IMessageHub hub, [FromServices] OgCardRenderer renderer, HttpContext http, string path, - CancellationToken ct) => - { - var nodePath = (path ?? "").Trim('/'); - var authored = nodePath.EndsWith(SeoResolver.AuthoredCardSuffix, StringComparison.OrdinalIgnoreCase); - if (authored) - nodePath = nodePath[..^SeoResolver.AuthoredCardSuffix.Length]; - else if (nodePath.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) - nodePath = nodePath[..^4]; - if (nodePath.Length == 0) - return Task.FromResult(PngResult(http, renderer.RenderSite(http.Request.Host.Host))); - - return SeoResolver.ResolveShareableNode(hub, nodePath) - .SelectMany(shareable => shareable is not { } cleared - ? Observable.Return(Results.NotFound()) - : authored - ? AuthoredCardResult( - hub.ServiceProvider.GetRequiredService(), - hub.ServiceProvider.GetRequiredService().CreateLogger(typeof(SeoEndpoints)), - http, renderer, cleared.Node, cleared.AnonymousReadable) - : Observable.Return(CardResult(http, renderer, cleared.Node, cleared.AnonymousReadable))) - .Catch(_ => Observable.Return(Results.NotFound())) - .FirstAsync() - .ObserveCompletion(LateFault(hub, $"/api/og/{nodePath}"), ct)!; - }).AllowAnonymous(); - } - - /// - /// 🚨 THE RE-SERVED AUTHORED PICTURE — /api/og/{node}.jpg (). - /// - /// A node whose authored share image lives on ANOTHER host (a Social post's - /// mediaUrl) is declared in its head as this URL, not as the foreign one, with - /// og:image:type image/jpeg and 1200×630. This answers it: the authored picture, - /// fetched through the SSRF-guarded Http-pool fetcher and normalised by - /// — so what an unfurler receives is exactly what - /// the head promised, from an origin whose headers we set. Same gate as the drawn card (the - /// caller already cleared the node through ), same - /// cache directive. - /// - /// When the authored picture cannot be had — its host is down, refuses us, answers - /// something that is no picture, or the node no longer authors one — the node's DRAWN card is - /// served, as JPEG. That is not a cover-up: the head's promise is "a 1200×630 JPEG share picture - /// of this page", the drawn card is exactly that, and an image URL that 404s makes several - /// unfurlers drop the whole preview. The failure is logged, and nothing about it is cached in - /// the process, so the next request tries the authored picture again. - /// - /// The mesh's SSRF-guarded external fetcher. - /// Where a picture that could not be re-served is reported. - /// The request, for conditional-GET and response headers. - /// The card renderer, which also holds the normalised-picture cache. - /// The node, already cleared by the caller. - /// True when the anonymous gate admitted the node. - internal static IObservable AuthoredCardResult( - OpenGraphPreviewService fetcher, ILogger logger, HttpContext http, OgCardRenderer renderer, - MeshNode node, bool sharedCacheable) - { - IResult Drawn() => JpegResult(http, renderer.RenderJpeg(CardContent(node)), sharedCacheable); - - if (SeoResolver.ExtractImage(node) is not { } source || !SeoResolver.IsExternal(source)) - return Observable.Return(Drawn()); - - return renderer.AuthoredCard(SeoResolver.AbsoluteSource(source), fetcher) - .Select(jpeg => JpegResult(http, jpeg, sharedCacheable)) - .Catch(ex => - { - logger.LogWarning(ex, - "The authored share picture {Source} of {Path} could not be re-served; its drawn card is served instead", - source, node.Path); - return Observable.Return(Drawn()); - }); - } - - /// - /// 🚨 THE RASTER FAVICON — /api/icon/{node}.png?size=N. - /// - /// Why a portal serves its own favicon as PNG. A node page declares the node's own - /// icon in its head, and every store-package mark is authored inline <svg>. Safari - /// renders no SVG favicon at all, so on macOS and iOS the per-content favicon was invisible — - /// every tab wore the portal mark (issue #2075, item 3). This route renders the SAME svg - /// puts in the head, so the two are pictures of one - /// thing, and declares it beside the svg rather than - /// instead of it. - /// - /// Gated identically to the SEO head and the share card. It resolves through - /// — the same one predicate — so a mark reaches - /// this route only for a node the fail-closed admits or one whose - /// scope opted in to ; a missing node, a - /// withheld one that did not opt in, and a node with no mark all answer the same 404. There is no - /// parallel permission rule here to drift from the page's. - /// - /// 404 is the fallback, and nothing ever points at it. A node with no icon of its - /// own gets no icon link in its head either ( returns - /// empty), so the portal favicon stays — the same honest answer the head has always given. - /// Redirecting to the site favicon here would look like a fix while telling every consumer that - /// this node's mark IS the portal's. - /// - /// Sizes are an allow-list, not a range (): - /// the route is anonymous and shared-cacheable, so a free-form size parameter is an unbounded - /// number of distinct renders. An unsupported size is a 400 rather than a silent snap to 32 — - /// a caller asking for something this cannot serve should be told so. - /// - private static void MapNodeIcon(IEndpointRouteBuilder app) => - app.MapGet("/api/icon/{**path}", ( - [FromServices] IMessageHub hub, HttpContext http, string path, CancellationToken ct) => - { - var nodePath = (path ?? "").Trim('/'); - if (nodePath.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) - nodePath = nodePath[..^4]; - if (nodePath.Length == 0) - return Task.FromResult(Results.NotFound()); - - var size = IconRasterizer.FaviconSize; - var requested = http.Request.Query["size"].ToString(); - if (requested.Length > 0 - && (!int.TryParse(requested, System.Globalization.NumberStyles.Integer, - System.Globalization.CultureInfo.InvariantCulture, out size) - || !IconRasterizer.IsSupportedSize(size))) - return Task.FromResult(Results.BadRequest( - $"size must be one of {string.Join(", ", IconRasterizer.SupportedSizes)}")); - - var pixels = size; - var unrenderable = UnrenderableIcon(hub, nodePath); - return SeoResolver.ResolveShareableNode(hub, nodePath) - .Select(shareable => shareable is not { } cleared - ? Results.NotFound() - : IconResult(http, cleared.Node, pixels, unrenderable, cleared.AnonymousReadable)) - .Catch(_ => Observable.Return(Results.NotFound())) - .FirstAsync() - .ObserveCompletion(LateFault(hub, $"/api/icon/{nodePath}"), ct)!; - }).AllowAnonymous(); - - /// - /// One node's rasterized mark, or 404 when it carries none this can draw. Internal so the - /// endpoint's own decision — not a re-implementation of it — is what the tests exercise. - /// - /// The request, for conditional-GET and response headers. - /// The node, already cleared by the caller — either anonymous-readable or - /// preview-opted-in. - /// The square edge in pixels. - /// Sink for markup that is present but cannot be drawn — an - /// AUTHORED icon that fails to parse is a content defect worth a line in the log, not something - /// to swallow into an indistinguishable 404. - internal static IResult IconResult( - HttpContext http, MeshNode node, int size, Action? onUnrenderable = null, - bool sharedCacheable = true) - { - if (SeoResolver.ResolveIconSvg(node) is not { } svg) - return Results.NotFound(); - - byte[]? png; - try - { - png = IconRasterizer.Render(svg, size); - } - catch (Exception ex) - { - // Malformed authored markup is a 4xx-shaped fact about the CONTENT, not a fault in this - // route — but it is invisible from outside, so it is reported before the 404 is served. - onUnrenderable?.Invoke(ex); - return Results.NotFound(); - } - - if (png is null) - return Results.NotFound(); - - var etag = $"\"{Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(png))}\""; - if (string.Equals(http.Request.Headers.IfNoneMatch.ToString(), etag, StringComparison.Ordinal)) - return Results.StatusCode(StatusCodes.Status304NotModified); - - http.Response.Headers.ETag = etag; - // Cacheable exactly as the share card is, and for the same reasons on both legs — see - // CacheDirective: shared for a node the gate admitted, private/no-store for one cleared only - // by the revocable preview opt-in. - http.Response.Headers.CacheControl = CacheDirective(sharedCacheable); - return Results.File(png, "image/png"); - } - - private static IResult CardResult( - HttpContext http, OgCardRenderer renderer, MeshNode node, bool sharedCacheable) => - PngResult(http, renderer.Render(CardContent(node)), sharedCacheable); - - /// - /// Everything the card says about a node, read off the node the resolver already cleared: - /// name, description (with the catalog-copy fallbacks), category or type as the eyebrow, its - /// own mark through the SAME backplate policy the favicon route draws - /// (), the price when it sells something, and the - /// path. Internal so a test reads the endpoint's own mapping rather than re-deriving it. - /// - /// 🚨 Takes the NODE, not a . It never needed more than the node - /// — the description it drew was always of it — and - /// taking the node means the card path cannot reach even by - /// accident. That matters since the route now also serves a node the gate REFUSED, on a scope - /// that opted in to : the picture discloses the - /// same name, summary, eyebrow and mark the head does, and nothing else. - /// - internal static OgCardContent CardContent(MeshNode node) - { - var price = SeoResolver.ContentDecimal(node, "price"); - return new OgCardContent - { - Title = node.Name ?? node.Id, - Description = SeoResolver.ExtractDescription(node), - Eyebrow = string.IsNullOrWhiteSpace(node.Category) ? TypeLeaf(node.NodeType) : node.Category, - IconSvg = SeoResolver.ResolveIconSvg(node), - Price = price is > 0m - ? $"{SeoResolver.ContentString(node, "currency") ?? "CHF"} {price.Value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}" - : null, - Path = node.Path, - AccentSeed = node.Path, - }; - } - - /// The last segment of a node type — Store/Plugin reads as "Plugin" on the card. - private static string? TypeLeaf(string? nodeType) => - string.IsNullOrWhiteSpace(nodeType) ? null : nodeType[(nodeType.LastIndexOf('/') + 1)..]; - - private static IResult PngResult(HttpContext http, byte[] png, bool sharedCacheable = true) => - ImageResult(http, png, "image/png", sharedCacheable); - - private static IResult JpegResult(HttpContext http, byte[] jpeg, bool sharedCacheable) => - ImageResult(http, jpeg, OgCardRenderer.AuthoredMediaType, sharedCacheable); - - private static IResult ImageResult(HttpContext http, byte[] bytes, string mediaType, bool sharedCacheable) - { - var etag = $"\"{Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(bytes))}\""; - if (string.Equals(http.Request.Headers.IfNoneMatch.ToString(), etag, StringComparison.Ordinal)) - return Results.StatusCode(StatusCodes.Status304NotModified); - - http.Response.Headers.ETag = etag; - http.Response.Headers.CacheControl = CacheDirective(sharedCacheable); - return Results.File(bytes, mediaType); - } - - /// - /// 🚨 THE CACHE DIRECTIVE FOLLOWS WHICH DECISION CLEARED THE RESPONSE, and that is a correctness - /// property rather than a tuning one. - /// - /// Gate-admitted ⇒ shared-cacheable. Everything drawn is already served to anonymous - /// callers on the page itself, crawlers refetch cards aggressively, and the strong ETag is the - /// render's own hash — so a node that changes its mark produces a new picture rather than a stale - /// one. - /// - /// Preview-only ⇒ private, no-store. That response is reachable because a - /// POLICY says so, and a policy is revocable while a shared cache never re-asks the origin: a day - /// of public, max-age would leave a withdrawn disclosure publicly retrievable after the - /// owner withdrew it. The ETag still goes out, so a conditional GET works for whoever holds - /// one. - /// - /// 🚨 It does NOT reach the unfurler's own copy — Slack, Teams, iMessage and LinkedIn keep a - /// preview for hours to days and no response header controls that. Revocation is immediate at the - /// origin and eventually-consistent at the consumer; Doc/Architecture/LinkPreviews says so - /// where an owner reads about the flag. - /// - /// True when the anonymous gate admitted the node. - private static string CacheDirective(bool sharedCacheable) => - sharedCacheable ? "public, max-age=86400" : "private, no-store"; - - /// - /// The sitemap XML, built reactively: candidate roots from the (System-read) type queries, - /// each gated through the REAL anonymous permission check, then every page-shaped descendant - /// of an admitted root, each gated the same way. Cold. - /// - /// Fail-open to fewer URLs — never to ZERO of them. It faults with - /// rather than render a urlset that declares no public - /// root it never checked; the route maps that to 503. - /// - public static IObservable BuildSitemap(IMessageHub hub, string baseUrl) => - EnumeratePublished(hub) - .Select(surface => surface.AssertsWhatItDidNotCheck - ? throw new SitemapUndecidedException(surface.Undecided!) - : Render(baseUrl, surface.Pages.Select(p => (p.Node, p.Path)).ToList())); - - /// - /// 🚨 THE ONE DEFINITION OF "PUBLISHED TO THE WEB" — every page a logged-out visitor may open. - /// - /// There is no separate flag, and deliberately no Www/ namespace: a node is - /// published because it carries an explicit Anonymous Read grant, and that grant is what - /// already fails closed on. Moving public content under a path - /// prefix would rewrite every public URL — every shared link, every canonical, every - /// og:url — which is the opposite of what publishing well requires. - /// - /// So "which nodes are on the internet" is a QUERY, not a location, and this is it. The - /// sitemap renders it as XML for crawlers; PublishedSettingsTab renders the same list for - /// a human. Two views, one truth — they cannot drift. - /// - public static IObservable EnumeratePublished(IMessageHub hub) - { - var mesh = hub.ServiceProvider.GetService(); - var accessService = hub.ServiceProvider.GetService(); - // DECIDED, not undecided: a host with no mesh publishes nothing, permanently and by - // configuration — the same reasoning AnonymousGate applies to a mesh with no permission - // evaluator. Retrying changes nothing, so calling it "unavailable" would be its own lie. - if (mesh is null) - return Observable.Return(PublishedSurface.Decided([])); - - // Candidate enumeration runs as System (an anonymous HTTP entry has no query identity); - // ANONYMOUS readability is then decided per node by the fail-closed gate — the sitemap - // can never list more than a logged-out visitor can open. - // RunAsSystem, never `Observable.Using(() => ImpersonateAsSystem(), …)`: the scope's store - // and restore must land on the SAME thread (AGENTS.md; #1790), and Using disposes on - // whichever thread the inner stream terminates on. - var candidates = CandidateNodeTypes - .Select(type => accessService.RunAsSystem(() => - // Mesh-wide by nature: the sitemap enumerates the partition ROOTS of every - // partition, so there is no partition to anchor to (#3202 — fan-out is opt-in). - mesh.Query(MeshQueryRequest.FromQuery(RootCandidateQuery(type))) - .Take(1) - .Select(change => change.Items.ToList()))) - .ToObservable().Concat().ToList() - .Select(lists => lists.SelectMany(l => l).DistinctBy(n => n.Path).ToList()); - - return candidates - .SelectMany(roots => roots.Count == 0 - ? Observable.Return(new List()) - : roots.Select(Admit).ToObservable().Concat().ToList()) - // PagesOf yields UNNAMED (MeshNode, string) tuples, so address them positionally. - .Select(admissions => new PublishedSurface( - admissions - .SelectMany(a => a.Pages) - .DistinctBy(p => p.Item2) - .Select(p => new PublishedPage(p.Item1, p.Item2)) - .ToList(), - // FIRST reason, not all of them: one sentence is what a log line and a 503 need, - // and an undecided fold is a property of the deployment rather than of the root - // that happened to be asked first. - admissions.Select(a => a.Undecided).FirstOrDefault(reason => reason is not null))) - // 🚨 The bound STAYS and the swallow under it GOES. A 20 s cap at an HTTP edge is - // legitimate; what was not is that timing out and completing a census produced the - // same value. It now faults, and SitemapUnavailable names it. - .Timeout(TimeSpan.FromSeconds(20)); - - // 🚨 The TRI-state, not the bool. AnonymousGate.AllowAnonymous is documented as usable - // "only where 'unknown' and 'not public' lead to the SAME correct action and nothing is - // asserted" — and it even names omitting a page from the sitemap as such a place. That is - // right per PAGE (PagesOf still uses it, and always emits its root regardless) and wrong - // per ROOT, because the roots ARE the sitemap: omit every one of them and the document - // that comes out is an assertion. #4751. - IObservable Admit(MeshNode root) => - AnonymousGate.Evaluate(hub, root.Path) - .Take(1) - .SelectMany(outcome => outcome.IsGranted - ? PagesOf(mesh, accessService, hub, root) - .Select(pages => new RootAdmission(pages, null)) - : Observable.Return(new RootAdmission( - [], - // A DENIAL is decided — the root is simply not public, and leaving it out - // is the whole point of the gate. Only "no verdict" is carried out. - outcome.IsUndetermined - ? $"the anonymous gate on '{root.Path}' reached no verdict: " - + outcome.UndeterminedReason - : null))); - } - - /// - /// What one candidate root contributed: the pages it published, and — when its publicness - /// could not be decided — why. Both are needed: a root can contribute no page because it is - /// private (decided, ordinary) or because nothing could establish either way (#4751). - /// - /// The pages admitted under this root; empty when it is not public. - /// Null unless the gate reached no verdict on this root. - private sealed record RootAdmission(IReadOnlyList<(MeshNode, string)> Pages, string? Undecided); - - /// - /// The sitemap pages of one anonymous-readable root: the root itself plus every page-shaped - /// descendant (, ) the - /// admits — checked PER NODE, because a public course is a root - /// grant plus a deny on every chapter that is not free, and a commercial plugin's cover can be - /// public while its content is not. The descendant listing runs as System (a listing is a - /// valid query use: a stale negative here costs a URL, never a leak — every candidate still - /// passes the gate before it is listed). - /// - /// 🚨 This REPLACES the read of each store plugin's declared publicSegments - /// (#4056). That read went through the raw storage adapter from an anonymous HTTP entry and - /// came back empty on the partitioned Postgres deployment, so no course chapter was ever in the - /// sitemap; and it could only ever see one level of one node type, so the documentation tree - /// was never in it either. The gate is the one definition of "public" and it already encodes - /// the declared segments as grants and denies — asking it per node is both the fix and the - /// feature. - /// - private static IObservable> PagesOf( - IMeshService mesh, AccessService? accessService, IMessageHub hub, MeshNode root) - { - var self = (root, root.Path); - var types = string.Join("|", PageNodeTypes.Select(t => $"\"{t}\"")); - return accessService.RunAsSystem(() => mesh.Query(DescendantsOf(root, types))) - .Take(1) - .Select(change => change.Items - .Where(n => n.Path.Length > root.Path.Length - && n.Path.StartsWith(root.Path + "/", StringComparison.Ordinal) - && IsPagePath(n.Path[(root.Path.Length + 1)..])) - .DistinctBy(n => n.Path) - .ToList()) - .SelectMany(candidates => candidates.Count == 0 - ? Observable.Return>([self]) - : candidates - // Same boolean projection as the roots: "not public" and "the gate could not - // decide" both OMIT the page, which is the fail-closed action (#2901). - .Select(node => AnonymousGate.AllowAnonymous(hub, node.Path) - .Take(1) - .Select(allowed => allowed ? node : null)) - .MergeBounded(GateConcurrency) - .Where(node => node is not null) - .ToList() - .Select(admitted => (IReadOnlyList<(MeshNode, string)>) - new[] { self } - .Concat(admitted - .OrderBy(n => n!.Path, StringComparer.Ordinal) - .Select(n => (n!, n!.Path))) - .ToList())) - // Bounded degradation, unlike the sinks this change removed: the root itself is still - // published, so this can cost pages and can never produce the zero-root document of - // #4751. It was SILENT though, which is the other half of that issue — a sitemap that - // quietly lost a course's chapters left nothing to read. - .Catch, Exception>(ex => - { - hub.ServiceProvider.GetService() - ?.CreateLogger(typeof(SeoEndpoints)) - .LogWarning( - ex, - "sitemap: listing the pages below '{Root}' failed; publishing the root " - + "alone and none of its pages", - root.Path); - return Observable.Return>([self]); - }); - } - - // `limit:all` — this is an ENUMERATION, so every match comes back (MeshQueryRequest.NoLimit); - // a stated cap would silently truncate the one list that claims to be complete. The sitemap - // protocol's own bound is 50,000 URLs per file; that is a sitemap-index job for the day a - // deployment publishes that many pages, not a reason to clip the query. - private static MeshQueryRequest DescendantsOf(MeshNode root, string types) - => MeshQueryRequest.FromQuery( - $"namespace:\"{root.Path}\" scope:descendants is:main nodeType:{types} limit:all"); - - private static string Render(string baseUrl, IReadOnlyList<(MeshNode Node, string Url)> pages) - { - XNamespace ns = "http://www.sitemaps.org/schemas/sitemap/0.9"; - var urlset = new XElement(ns + "urlset", - pages.Select(p => new XElement(ns + "url", - new XElement(ns + "loc", $"{baseUrl}/{p.Url}"), - p.Node.LastModified == default - ? null - : new XElement(ns + "lastmod", p.Node.LastModified.UtcDateTime.ToString("yyyy-MM-dd"))))); - return new XDocument(new XDeclaration("1.0", "utf-8", null), urlset).ToString(); - } -} diff --git a/memex/Memex.Portal.Shared/Api/WebhookInboxEndpoints.cs b/memex/Memex.Portal.Shared/Api/WebhookInboxEndpoints.cs deleted file mode 100644 index 1d0be1753d..0000000000 --- a/memex/Memex.Portal.Shared/Api/WebhookInboxEndpoints.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Api; - -/// -/// The generic webhook inbox endpoint: POST /api/hooks/{**target} stores the raw delivery -/// as a WebhookEvent node at {target}/_Inbox/{id} (see -/// ). Anonymous by design — external services (Stripe, GitHub, …) -/// cannot authenticate — and fail-closed: only targets allowlisted under -/// WebhookInbox:Targets in configuration accept deliveries; everything else is 404. -/// -/// The endpoint speaks exactly ONE signature scheme, and only for targets that ask for it: -/// a target declaring Targets:N:SecretConfigKey has its X-Hub-Signature-256 verified -/// over the raw body before anything is stored, and gets 401 when it does not verify (#3312 — a -/// 2xx that meant "I received bytes" made a MISMATCHED secret look exactly like a correct one, so -/// a publisher could not fail on it). Everything else keeps the dumb contract: no integration- -/// specific (e.g. payment) code lives in the portal, and Stripe-shaped schemes stay the consuming -/// plugin's job over the verbatim stored body + headers. -/// -public static class WebhookInboxEndpoints -{ - /// Maps the anonymous /api/hooks/{**target} inbox endpoint. Call alongside - /// MapMeshApi. - public static IEndpointRouteBuilder MapWebhookInbox(this IEndpointRouteBuilder endpoints) - { - endpoints.MapPost("/api/hooks/{**target}", - (string target, HttpRequest request, IMessageHub rootHub, IConfiguration config, - CancellationToken ct) => - Deliver(target, request, rootHub, config, ct)) - .AllowAnonymous(); - return endpoints; - } - - /// - /// The body of a 200 answer — the contract a signing sender reads (#3312): - /// status is accepted (stored) or verified (a verify-only test, nothing - /// stored); signature is verified or not-required; sender names the - /// per-sender key that verified (a name, never a value) or is null for the shared secret. - /// Pure, and public so a test can answer exactly as the endpoint does. - /// - public static WebhookAnswer AcceptedAnswer(WebhookInbox.DeliveryResult result) => new( - result.VerifyOnly ? "verified" : "accepted", - result.SignatureVerified ? "verified" : "not-required", - result.SenderKey); - - /// The JSON body of a 200 answer (see ). - /// accepted or verified. - /// verified or not-required. - /// The per-sender key's name, or null. - public sealed record WebhookAnswer( - [property: System.Text.Json.Serialization.JsonPropertyName("status")] string Status, - [property: System.Text.Json.Serialization.JsonPropertyName("signature")] string Signature, - [property: System.Text.Json.Serialization.JsonPropertyName("sender")] string? Sender); - - // The sanctioned Task boundary (a minimal-API handler, like the MCP/registry adapters): - // the body is reactive — read, deliver, map to a status code. - private static async Task Deliver( - string target, HttpRequest request, IMessageHub hub, IConfiguration config, - CancellationToken ct) - { - var logger = hub.ServiceProvider.GetService() - ?.CreateLogger(typeof(WebhookInboxEndpoints)); - var allowed = WebhookInbox.ReadTargets(config); - - // Refuse oversized bodies BEFORE buffering them (Content-Length first; the capped reader - // below still guards chunked bodies that lie about their size). - if (request.ContentLength is > WebhookInbox.MaxBodyBytes) - return Results.StatusCode(StatusCodes.Status413PayloadTooLarge); - // 🚨 The cap the comment above always promised. Content-Length is advisory (absent on a - // chunked request, and a client may lie); the reader enforces the byte limit itself. - // 🚨 Same two outcomes, kept apart, as the GitHub endpoint (#4860): a client that drops - // mid-body raises Kestrel's "Unexpected end of request content" and is a 400, while `null` - // means over the cap and is a 413. Folding them would answer the wrong status AND report a - // cap breach that never happened. This is the OTHER BoundedBody call site — the fix is - // worth nothing if only the one the incident happened to name is corrected. - string? body; - try - { - body = await BoundedBody.ReadAsync(request.Body, WebhookInbox.MaxBodyBytes, ct); - } - catch (BadHttpRequestException ex) - { - // 🚨 The exception's own status, not a fixed 400 — Kestrel reuses this type with 413 for - // its MaxRequestBodySize breach, and this endpoint answers 413 for its OWN cap both - // above and below. See the same catch in GitHubWebhookEndpoints. - logger?.LogDebug(ex, - "Webhook body read for target '{Target}' failed with {Status}: {Reason}.", - target, ex.StatusCode, ex.Message); - return Results.StatusCode(ex.StatusCode); - } - - if (body is null) - return Results.StatusCode(StatusCodes.Status413PayloadTooLarge); - - var headers = request.Headers.Select(h => - new KeyValuePair(h.Key, h.Value.ToString())); - - // 🚨 A STALLED read is 503 + Retry-After, never an unhandled 500 (#6125). Before storing, - // Deliver confirms the target node exists with a `path:{target}` query, and when a query - // provider never delivers its Initial the fan-in terminates that read with - // QueryProviderStalledException (policy query-fanin-stall-terminal) — an availability - // failure that says, in its own text, that it is retryable. It used to escape this handler, - // so ASP.NET's exception middleware answered a bare 500 and logged the stall as a defect of - // the ENDPOINT. Same mapping as the plugin-bundle routes (#5345): only what - // AreaErrorClassifier.IsStorageUnavailable classifies becomes 503; every other fault still - // escapes, so a real defect keeps surfacing as one. Nothing was stored on that path, so a - // sender that delivers again loses nothing and duplicates nothing. - return (await WebhookInbox.Deliver( - hub, allowed, target, request.ContentType, headers, body) - .Select(result => Answer(result, target, logger)) - .UnavailableOnAStalledRead(request.HttpContext, logger, StalledReadError) - .FirstAsync() - .ObserveCompletion( - ex => logger?.LogWarning(ex, - "Webhook delivery for target '{Target}' faulted after the response had already been sent", - target), - ct))!; - } - - /// The 503 body's error when the delivery's own read could not be answered. - /// Nothing was stored, and the text says so: the sender's next move is to deliver again. - internal const string StalledReadError = - "The inbox could not confirm its target just now — nothing was stored; retry shortly."; - - /// Maps a delivery's verdict to its HTTP answer, with the log line that goes with it. - private static IResult Answer(WebhookInbox.DeliveryResult result, string target, ILogger? logger) - { - switch (result.Status) - { - case WebhookInbox.DeliveryStatus.Accepted when result.VerifyOnly: - // A sender TESTING its key (WebhookInbox.VerifyOnlyHeader): verified, nothing stored. - // `sender` names which per-sender key verified (a name, never a value), so the sender - // can show its operator "accepted as 'fabrikam'" rather than a bare yes. - logger?.LogInformation( - "Webhook verify-only test for target '{Target}' verified{Sender} — nothing stored", - target, result.SenderKey is { } testedAs ? $" with the per-sender key '{testedAs}'" : ""); - return Results.Json(AcceptedAnswer(result)); - case WebhookInbox.DeliveryStatus.Accepted: - // The sender key is a NAME (e.g. a deployment id), never a value: it says which - // per-sender key verified, so the consumer's attribution can be read from the log. - if (result.SenderKey is { } senderKey) - logger?.LogInformation( - "Webhook stored at {Path} — verified with the per-sender key '{SenderKey}'", - result.NodePath, senderKey); - else - logger?.LogInformation("Webhook stored at {Path}", result.NodePath); - // 🚨 The body, not the status, is what a signing sender must read. Both branches - // are 200: "verified" means this instance checked the HMAC, "not-required" means - // the target declares no SecretConfigKey here and the signature was never looked - // at. Answering a bare 200 to both is how a chart value going missing would take - // verification away again without a single red anything (#3312). - return Results.Json(AcceptedAnswer(result)); - case WebhookInbox.DeliveryStatus.TooLarge: - return Results.StatusCode(StatusCodes.Status413PayloadTooLarge); - case WebhookInbox.DeliveryStatus.SignatureInvalid: - // Warning, not Information: on a target that declares a secret this is either an - // attacker or — far more often — the two halves of a shared secret having drifted, - // which is invisible from the sending side except through this status. - logger?.LogWarning( - "Webhook for target '{Target}' REFUSED: {Header} absent or not verifying " - + "against the secret named by {Key}. Nothing was stored.", - target, WebhookInbox.SignatureHeader, WebhookInbox.SecretConfigKeyName); - return Results.StatusCode(StatusCodes.Status401Unauthorized); - case WebhookInbox.DeliveryStatus.SecretUnavailable: - // OUR misconfiguration, not the caller's — hence 500, and Error: this target - // refuses every delivery until the declared key resolves to something. - logger?.LogError( - "Webhook for target '{Target}' REFUSED: it declares {Key} but that " - + "configuration key is empty on this instance, so no delivery to it can be " - + "verified. Nothing was stored.", - target, WebhookInbox.SecretConfigKeyName); - return Results.StatusCode(StatusCodes.Status500InternalServerError); - default: - // Unknown target: no detail leaks about which paths exist or are allowlisted. - logger?.LogWarning("Webhook for unknown/refused target '{Target}' dropped", target); - return Results.NotFound(); - } - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/ApiTokenAuthenticationHandler.cs b/memex/Memex.Portal.Shared/Authentication/ApiTokenAuthenticationHandler.cs deleted file mode 100644 index a6261b1c57..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/ApiTokenAuthenticationHandler.cs +++ /dev/null @@ -1,272 +0,0 @@ -using System.Diagnostics; -using System.Globalization; -using System.Reactive.Linq; -using System.Security.Claims; -using System.Text.Encodings.Web; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// ASP.NET Core authentication handler that validates Bearer tokens -/// against the ApiTokenService. Builds a ClaimsPrincipal with claims -/// matching what UserContextMiddleware.ExtractUserContext() reads. -/// -/// Unavailable ≠ invalid (issue #637): when the token could not be -/// validated at all (storage read timeout during silo degradation), this handler -/// answers 503 Service Unavailable + Retry-After instead of the 401 -/// that a definitive invalid/expired token gets — a 401 here is indistinguishable -/// from a forged token and drives clients into discarding valid credentials. -/// always turns into 401 via the -/// default challenge, so the unavailable outcome is flagged on -/// and rewrites -/// the challenge to 503 when the flag is present. -/// -public class ApiTokenAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory loggerFactory, - UrlEncoder encoder, - IServiceProvider serviceProvider) - : AuthenticationHandler(options, loggerFactory, encoder) -{ - public const string SchemeName = MeshWeaver.Mesh.Security.ServiceIdentity.TokenAuthenticationType; - - /// - /// key flagging that authentication failed because token - /// validation was UNAVAILABLE (retryable), not because the token was invalid. Set by - /// , consumed by - /// to answer 503 + Retry-After instead of the default 401. - /// - internal const string ValidationUnavailableItemKey = "Memex.ApiToken.ValidationUnavailable"; - - /// Retry-After (seconds) advertised on the 503 unavailable challenge. - internal const int RetryAfterSeconds = 5; - - /// - /// The API token from an Authorization header — Bearer <token>, or - /// Basic base64(user:<token>) — or null when it carries neither. - /// - /// 🚨 Basic is accepted because a NuGet client cannot send Bearer: nuget.config's - /// packageSourceCredentials speaks Basic, and the only alternative is shipping a - /// credential-provider plugin. Accepting both lets `dotnet restore` read the access-controlled - /// content route with the SAME personal token every other API caller uses — one credential, - /// one validator, and the route's existing per-node Read check stays the authorization gate. - /// - /// The username half is ignored (the token is the whole secret); NuGet merely requires - /// one to be present. The token stays in the HEADER either way — the easier - /// ?apiKey= would leak it into every access log and proxy log along the route. - /// - /// Malformed Basic payloads yield null (→ the normal no-result path), never an - /// exception: an unauthenticated caller must not be able to make the handler throw. The length - /// cap bounds what such a caller can make us decode. - /// - internal static string? ExtractToken(string? authorizationHeader) - { - if (string.IsNullOrWhiteSpace(authorizationHeader)) - return null; - var header = authorizationHeader.Trim(); - - if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - return header["Bearer ".Length..].Trim() is { Length: > 0 } bearer ? bearer : null; - - if (!header.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) - return null; - - var encoded = header["Basic ".Length..].Trim(); - if (encoded.Length is 0 or > MaxBasicPayloadChars) - return null; - - Span buffer = stackalloc byte[MaxBasicPayloadChars / 4 * 3]; - if (!Convert.TryFromBase64String(encoded, buffer, out var written)) - return null; - - // UTF8.GetString uses replacement fallback, so invalid bytes become U+FFFD rather than - // throwing — and such a token simply fails validation downstream. - var decoded = System.Text.Encoding.UTF8.GetString(buffer[..written]); - var separator = decoded.IndexOf(':'); - if (separator < 0) - return null; - - return decoded[(separator + 1)..].Trim() is { Length: > 0 } password ? password : null; - } - - /// Largest Basic payload considered; see . - private const int MaxBasicPayloadChars = 1024; - - protected override async Task HandleAuthenticateAsync() - { - var rawToken = ExtractToken(Request.Headers.Authorization.ToString()); - if (string.IsNullOrEmpty(rawToken)) - return AuthenticateResult.NoResult(); - - var tokenService = serviceProvider.GetRequiredService(); - // HTTP boundary — bridge IObservable to Task once. The service exposes - // IObservable per the "no async in hub-reachable code" rule. - var validationStopwatch = Stopwatch.StartNew(); - // 🚨 Bridge under the REQUEST's abort token: a client that gives up mid-validation must - // release the wait at once rather than holding it to the validation read's own timeout. - // That matters most in exactly the degraded window this change is about — storage slow, - // clients retrying — where holding every abandoned request would compound the pressure. - var validation = (await tokenService.Validate(rawToken) - .FirstAsync() - .ObserveCompletion( - ex => Logger.LogWarning(ex, - "API token validation faulted after the authentication result had already settled"), - Context.RequestAborted))!; - if (validation.Status == TokenValidationStatus.Unavailable) - { - // Token validation UNAVAILABLE — retry; NOT an auth failure. The token was - // neither confirmed nor rejected, so the client must keep it and retry. - Logger.LogWarning( - "API token validation UNAVAILABLE for hash prefix {HashPrefix} after {ElapsedMs} ms ({Reason}) — retryable infrastructure fault, NOT an auth failure; answering 503 + Retry-After instead of 401", - ApiTokenService.HashToken(rawToken)[..12], validationStopwatch.ElapsedMilliseconds, - validation.Reason); - Context.Items[ValidationUnavailableItemKey] = validation.Reason ?? "token validation unavailable"; - return AuthenticateResult.Fail("API token validation is temporarily unavailable (retryable)"); - } - if (validation.Status == TokenValidationStatus.Invalid) - { - // 🚨 Never a silent 401: every rejected Bearer token gets a Warning naming - // the hash prefix (never the raw token) and how long validation took — - // ApiTokenService has already logged the exact failing stage - // (index/token not-found, hash mismatch, revoked, expired) under the - // same prefix. The silent Fail is what made the 2026-07-24 fresh-token - // 401 storm untraceable server-side. - Logger.LogWarning( - "API token authentication failed for hash prefix {HashPrefix} after {ElapsedMs} ms — see the ApiTokenService warning with the same prefix for the failing stage", - ApiTokenService.HashToken(rawToken)[..12], validationStopwatch.ElapsedMilliseconds); - return AuthenticateResult.Fail("Invalid or expired API token"); - } - - var apiToken = validation.Token!; - var claims = BuildClaims(apiToken).ToList(); - - // Enrich with DB-resolved AccessAssignment roles so Bearer requests - // see the same role set as cookie/OAuth sessions. Without this, the - // principal only carries roles that were stamped on the API token at - // creation time — any AccessAssignment granted to the user later - // (e.g. an admin promotion after the token was minted) would silently - // not apply for MCP requests, even though the same user logging in - // through the browser would see them. Live mesh query, bounded so a - // wedged data source can't slow auth. - // - // 🚨 This used to be wrapped in a bare `catch { }` "best-effort" (issue #637): a - // stalled role read authenticated the caller with a SILENTLY REDUCED role set, and - // every later request they made was refused with "Access denied" — an availability - // failure reported as an authorization failure, and one the caller cannot act on. - // The read now classifies itself, and an unavailable one takes the SAME retryable - // 503 path as an unavailable token validation. A resolved-but-empty set is - // unchanged: it means the user genuinely has no extra grants. - var dbRoles = await UserRoleResolver.LoadDbRolesAsync(serviceProvider, apiToken.UserId); - if (dbRoles.UnavailableReason is { } rolesReason) - { - Logger.LogWarning( - "API token role resolution UNAVAILABLE for user {UserId} ({Reason}) — retryable infrastructure fault; answering 503 + Retry-After rather than authenticating with a silently reduced role set", - apiToken.UserId, rolesReason); - Context.Items[ValidationUnavailableItemKey] = rolesReason; - return AuthenticateResult.Fail("Role resolution is temporarily unavailable (retryable)"); - } - - foreach (var role in dbRoles.Value ?? Array.Empty()) - { - if (string.IsNullOrEmpty(role)) continue; - if (claims.Any(c => c.Type == ClaimTypes.Role && c.Value == role)) - continue; - claims.Add(new Claim(ClaimTypes.Role, role)); - } - - var identity = new ClaimsIdentity(claims, SchemeName); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, SchemeName); - - // Login tracking lives in UserContextMiddleware so it fires for both - // Bearer and cookie authentication on the same code path — see - // UserContextMiddleware.TrackLogin. - return AuthenticateResult.Success(ticket); - } - - /// - /// Challenge override for the unavailable outcome. The base challenge answers 401 for - /// EVERY failed authentication — correct for a definitive invalid/expired token, wrong - /// for "validation could not run" (issue #637). When - /// flagged the request as validation-unavailable, - /// the challenge becomes 503 Service Unavailable with a Retry-After - /// header and a speaking body so API clients retry with the SAME token instead of - /// discarding it and re-authenticating. - /// - protected override async Task HandleChallengeAsync(AuthenticationProperties properties) - { - if (Context.Items.ContainsKey(ValidationUnavailableItemKey)) - { - Response.StatusCode = StatusCodes.Status503ServiceUnavailable; - Response.Headers.RetryAfter = RetryAfterSeconds.ToString(CultureInfo.InvariantCulture); - Response.ContentType = "text/plain; charset=utf-8"; - // Machine-facing API error text (wire surface, deliberately not localized — - // same as the 401 challenge body conventions). - await Response.WriteAsync( - "API token validation is temporarily unavailable — retry shortly. " - + "The token was NOT rejected; keep it and retry, do not re-authenticate."); - return; - } - - await base.HandleChallengeAsync(properties); - } - - /// - /// Builds the claim list for an authenticated API token. Public + static - /// so unit tests can assert the claim shape (in particular: that - /// become - /// claims) without needing an HTTP host. - /// Mirrors what UserContextMiddleware.ExtractUserContext() reads - /// back into . - /// - public static IReadOnlyList BuildClaims(MeshWeaver.Mesh.Security.ApiToken apiToken) - { - // Build claims matching UserContextMiddleware.ExtractUserContext(): - // ObjectId = preferred_username - // Name = ClaimTypes.Name or "name" - // Email = ClaimTypes.Email or "email" - // Roles = each ClaimTypes.Role claim → AccessContext.Roles - var claims = new List - { - new("preferred_username", apiToken.UserId), - new(ClaimTypes.Name, apiToken.UserName), - new("name", apiToken.UserName), - new(ClaimTypes.Email, apiToken.UserEmail), - new("email", apiToken.UserEmail), - new(ClaimTypes.NameIdentifier, apiToken.UserId), - new("token_label", apiToken.Label), - }; - - // A SERVICE token authenticates as its service principal. The middleware reads this claim - // only on an identity of THIS scheme (ServiceIdentity.TokenAuthenticationType), so no other - // sign-in can assert it. Validation already refused the token unless its identity record - // exists and is not revoked (ApiTokenService.ConfirmServicePrincipal). - if (!string.IsNullOrEmpty(apiToken.ServiceIdentityPath)) - claims.Add(new Claim( - MeshWeaver.Mesh.Security.ServiceIdentity.PrincipalKindClaim, - MeshWeaver.Mesh.Security.ServiceIdentity.ServicePrincipalKind)); - - // Stamp the token's Roles list as ClaimTypes.Role claims. Without - // this, UserContextMiddleware sets AccessContext.Roles to an empty - // list and SecurityService.GetEffectivePermissions can't resolve - // claim-based Admin — every API-token request that depended on a - // role grant rather than a static AccessAssignment got denied. - // The token's Roles surface is exactly the right vehicle: the - // creator chose them at token creation; the validator preserves - // them through ValidateTokenResponse.Roles; the auth handler - // mints them onto the principal here. - foreach (var role in apiToken.Roles) - { - if (!string.IsNullOrEmpty(role)) - claims.Add(new Claim(ClaimTypes.Role, role)); - } - - return claims; - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/ApiTokenController.cs b/memex/Memex.Portal.Shared/Authentication/ApiTokenController.cs deleted file mode 100644 index 1c0917fd1e..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/ApiTokenController.cs +++ /dev/null @@ -1,257 +0,0 @@ -using System.Reactive.Linq; -using System.Threading; -using MeshWeaver.Hosting.AspNetCore.Portal; // PortalApplication -using MeshWeaver.Mesh; // IsGlobalAdmin -using MeshWeaver.Mesh.Security; // OAuthTokenLifetime -using MeshWeaver.Messaging; // AccessService / AccessContext -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// REST API for managing API tokens. All endpoints require cookie authentication -/// (users must be logged into the web UI to manage tokens). -/// -[ApiController] -[Route("api/tokens")] -[Authorize] -public class ApiTokenController(IServiceProvider serviceProvider) : ControllerBase -{ - private ApiTokenService tokenService => serviceProvider.GetRequiredService(); - - /// - /// Where a fault that arrives AFTER the action's response has already settled goes. Every - /// bridge below needs one — a discarded - /// late fault is half of what that method exists to remove. - /// - private ILogger LateFaultLogger => serviceProvider.GetRequiredService() - .CreateLogger(typeof(ApiTokenController)); - - /// - /// The mesh-resolved identity for this request, as stamped on the portal - /// hub's by UserContextMiddleware. - /// - /// 🚨 We deliberately do NOT read preferred_username off the claims - /// principal here. Entra/OIDC fill that claim with the UPN, which is the - /// user's email (e.g. rbuergi@systemorph.com). The mesh - /// partition key is the User node's Id (e.g. rbuergi), and the - /// middleware already does the email→User resolution + normalisation (and - /// refuses email-shaped ids). Passing the raw email through as the token's - /// userId routed the token node AND its _Access self-scope into a - /// non-existent {email} partition — which 401'd every freshly-minted - /// token once the router stopped lazy-creating schemas. Reading the - /// already-resolved context guarantees the token lands in exactly the - /// partition the user's other data lives in. - /// - /// - /// - /// The hub whose services answer platform questions for this request — the portal hub when the - /// Blazor shell registered one, else the mesh root hub on a next-only portal. The same - /// resolution uses, named once so the admin verdict and the identity - /// cannot come from two different hubs. - /// - private IMessageHub PortalHub() => - serviceProvider.GetService()?.Hub - ?? serviceProvider.GetRequiredService(); - - private AccessContext? CurrentUser => - // Portal hub when the Blazor shell registered one; the mesh root hub on a next-only - // portal (Features:Gui:Blazor=false). AccessService is the mesh-wide singleton either - // way — see UserContextMiddleware, which stamps the identity this reads. - PortalHub().ServiceProvider.GetRequiredService().Context; - - /// - /// The mesh User.Id for the current request, or null if the request has no - /// resolved (non-email) mesh identity — in which case token operations must - /// be refused rather than routed to a parallel {email} partition. - /// - private static string? MeshUserId(AccessContext? user) - { - var id = user?.ObjectId; - return string.IsNullOrEmpty(id) || id.Contains('@') ? null : id; - } - - /// - /// Creates a new API token. Returns the raw token once — it cannot be retrieved again. - /// - [HttpPost] - public Task CreateToken([FromBody] CreateTokenRequest request, CancellationToken ct) - { - var user = CurrentUser; - var userId = MeshUserId(user); - if (userId is null) - return Task.FromResult(Unauthorized("No user identity found")); - - var userName = user!.Name ?? ""; - var userEmail = user.Email ?? ""; - - DateTimeOffset? expiresAt = request.ExpiresInDays > 0 - ? DateTimeOffset.UtcNow.AddDays(request.ExpiresInDays.Value) - : null; - - var label = request.Label ?? "API Token"; - // The OAuth label prefix is reserved for tokens the OAuth sign-in issues (OAuthTokenLifetime): - // refuse it here as a client error, before the mint refuses it as a fault. - if (OAuthTokenLifetime.IsReservedLabel(label)) - return Task.FromResult(BadRequest( - $"A token label may not start with '{OAuthTokenLifetime.LabelPrefix.Trim()}' — that prefix is " - + "reserved for tokens the OAuth sign-in issues.")); - - // No await: pull IObservable up to the controller's return type. The single bridge to Task - // happens at .ObserveCompletion(…, ct) — never Rx's .ToTask(), which resumes the awaiter - // INLINE on the signalling thread (a mesh hub's action block here) and is forbidden since - // 2026-08-30. The request's cancellation token is passed so a client disconnect stops the - // WAIT. 🚨 It does not dispose the subscription — ObserveCompletion deliberately keeps its - // error arm attached so a late fault is reported rather than lost, which means the mesh - // work continues to completion after an abort. That is the right trade for a token - // creation (the token is either created or not; abandoning it half-way would be worse), - // but it is NOT teardown, and saying so here would be a promise the code does not keep. - var lateFaultLogger = LateFaultLogger; - // 🚨 THE ADMIN VERDICT IS THE SERVER'S, and it rides with the mint because that is the one - // round-trip a browser client already makes to learn who it is (the response's NodePath is - // where `userId` comes from). A React client that has to ask separately either does not ask - // — the state today, where clients/portal-next omits the Admin notification leg entirely — - // or asks in a way a forged claim could answer (Plugins#1295). - // - // `hub.IsGlobalAdmin()` is the ONE sanctioned predicate: Permission.All at scope Admin, - // granted by an AccessAssignment in Admin/_Access. Never a role name off a token claim, and - // never a root-scope check — `Admin` is excluded from searchable_schemas, so only the - // path-anchored evaluation answers it at all. - // - // Fail CLOSED: any fault resolving it yields false. An admin who briefly sees no platform - // notifications is a nuisance; a non-admin who sees them is the failure this must not have. - // The verdict gates what the client ASKS for; RLS refuses the rows independently, so this is - // the second of two boundaries — but it is the one that decides what is even requested. - var adminVerdict = AdminVerdict.FailClosed( - PortalHub().IsGlobalAdmin(userId), userId, lateFaultLogger); - - return tokenService.CreateToken(userId, userName, userEmail, label, expiresAt) - .SelectMany(creation => adminVerdict.Select(isGlobalAdmin => (IActionResult)Ok( - new CreateTokenResponse - { - RawToken = creation.RawToken, - NodePath = creation.Node.Path, - Label = label, - CreatedAt = DateTimeOffset.UtcNow, - ExpiresAt = expiresAt, - IsGlobalAdmin = isGlobalAdmin, - }))) - .FirstAsync() - .ObserveCompletion( - ex => lateFaultLogger.LogWarning(ex, - "Minting an API token for {UserId} faulted after the response had already been sent", - userId), - ct)!; - } - - /// - /// Lists all tokens for the current user. Never returns raw tokens. - /// - [HttpGet] - public Task ListTokens(CancellationToken ct) - { - var userId = MeshUserId(CurrentUser); - if (userId is null) - return Task.FromResult(Unauthorized("No user identity found")); - - var lateFaultLogger = LateFaultLogger; - return tokenService.GetTokensForUser(userId) - .Select(tokens => (IActionResult)Ok(tokens)) - .FirstAsync() - .ObserveCompletion( - ex => lateFaultLogger.LogWarning(ex, - "Listing API tokens for {UserId} faulted after the response had already been sent", - userId), - ct)!; - } - - /// - /// Revokes a token by its node path. - /// - [HttpDelete("{*nodePath}")] - public Task RevokeToken(string nodePath, CancellationToken ct) - { - var userId = MeshUserId(CurrentUser); - if (userId is null) - return Task.FromResult(Unauthorized("No user identity found")); - - var lateFaultLogger = LateFaultLogger; - return tokenService.GetTokensForUser(userId) - .SelectMany(tokens => tokens.Any(t => t.NodePath == nodePath) - ? tokenService.RevokeToken(nodePath) - .Select(success => success ? (IActionResult)Ok() : NotFound()) - : Observable.Return(NotFound("Token not found or does not belong to you"))) - .FirstAsync() - .ObserveCompletion( - ex => lateFaultLogger.LogWarning(ex, - "Revoking API token '{NodePath}' faulted after the response had already been sent", - nodePath), - ct)!; - } -} - -public record CreateTokenRequest -{ - public string? Label { get; init; } - public int? ExpiresInDays { get; init; } -} - -/// -/// The fail-closed reduction of a global-admin lookup to ONE answer. -/// -/// 🚨 Any failure answers FALSE. An admin who briefly sees no platform notifications is -/// a nuisance; a non-admin who sees them is the failure this must not have. Extracted from the -/// controller so that asymmetry is decided by a test rather than by reading — with a control arm, -/// because a version that answered false unconditionally would pass a fault-only test -/// (Plugins#1295). -/// -internal static class AdminVerdict -{ - /// One answer from ; a fault or an empty source is false. - /// The hub.IsGlobalAdmin(userId) stream. - /// Whose verdict this is — for the log line only. - /// Where a resolution failure is reported. - public static IObservable FailClosed( - IObservable source, string userId, ILogger logger) - => source - .Take(1) - // A source that completes WITHOUT emitting is the same answer as one that faulted: we - // do not know, so we withhold. Take(1) alone would complete empty and SelectMany would - // then drop the response entirely — a mint that never returns. - .DefaultIfEmpty(false) - .Catch((Exception ex) => - { - logger.LogWarning(ex, - "Resolving the global-admin verdict for {UserId} failed — answering false " - + "(fail-closed: the platform view is withheld, never granted, on an error)", - userId); - return Observable.Return(false); - }); -} - -public record CreateTokenResponse -{ - public string RawToken { get; init; } = ""; - public string NodePath { get; init; } = ""; - public string Label { get; init; } = ""; - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? ExpiresAt { get; init; } - - /// - /// Whether the minting user is a PLATFORM admin — Permission.All at scope Admin, - /// the server's own hub.IsGlobalAdmin() answer. - /// - /// 🚨 It gates what a client ASKS for, never what it may read. A global admin is not a - /// superuser: the grant confers no Read on any other partition, and RLS refuses rows - /// independently. A client uses this to decide whether to issue the platform notification leg - /// at all — omitting it is why no React viewer has ever seen a platform notification - /// (Plugins#1295). - /// - /// Defaults to false, and any failure to resolve it answers false. Withholding the - /// platform view from an admin is a nuisance; granting it to a non-admin is not. - /// - public bool IsGlobalAdmin { get; init; } -} diff --git a/memex/Memex.Portal.Shared/Authentication/ApiTokenService.cs b/memex/Memex.Portal.Shared/Authentication/ApiTokenService.cs deleted file mode 100644 index 6de57f1a2e..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/ApiTokenService.cs +++ /dev/null @@ -1,1303 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Runtime.CompilerServices; -using System.Security.Cryptography; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -[assembly: InternalsVisibleTo("MeshWeaver.Auth.Test")] -[assembly: InternalsVisibleTo("Memex.Portal.Shared.Test")] - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Service for creating, validating, and revoking API tokens. -/// Tokens are stored as MeshNodes with nodeType "ApiToken". Raw tokens -/// are never persisted — only their SHA-256 hash. -/// -/// -/// 🚨 No async / Task / FromAsync / await anywhere in this file. Every -/// reachable method returns and the chain -/// stays observable end-to-end. VALIDATION-side reads go straight to the -/// authoritative shared store ( — Postgres on -/// multi-replica portals): auth is the front door and must not depend on -/// cross-silo per-node-hub activation being healthy (memex-cloud 2026-07-25: -/// the GetMeshNodeStream-based validation read timed out for the full 8 s on -/// every pod that didn't mint the token — 3 of 4 replicas 401ed every MCP -/// request). Issuance-side reads keep hub.GetMeshNode(path); listings -/// go through workspace.GetQuery(id, queries...) (synced + path-keyed -/// dedup). QueryAsync / iteration is -/// forbidden in this file per Doc/Architecture/AsynchronousCalls.md and -/// Doc/Architecture/SyncedMeshNodeQueries.md. -/// -/// -internal class ApiTokenService( - IMeshService nodeFactory, IMessageHub hub, IStorageAdapter storage, ILogger logger) -{ - private const string TokenPrefix = "mw_"; - private const int TokenByteLength = 32; - private const string NodeTypeApiToken = "ApiToken"; - private const string ApiTokenNamespace = "ApiToken"; - - /// - /// Fires the namespace of each expiry sweep as it FINISHES (see ). - /// - /// The sweep is deliberately off the mint's critical path — a user with a backlog must - /// not pay for it at sign-in — which means CreateToken's own emission says nothing about - /// whether the sweep has run. Without this signal there is no source to subscribe to, so an - /// observer can only SAMPLE and hope: that is what made - /// CreateToken_SweepsTheUsersAlreadyExpiredTokens fail under CI load (#1569) while - /// passing 11/11 locally — the assertion read the lagged query index before the sweep's deletes - /// had landed, and the test had no way to wait for the actual condition. - /// - /// Instance field on the mesh-scoped singleton — never static, so it dies with the mesh. - /// Synchronized because sweeps for different users complete on different threads. - /// - public IObservable ExpirySweepCompleted => expirySweepCompleted.AsObservable(); - - private readonly ISubject expirySweepCompleted = - Subject.Synchronize(new Subject()); - - /// - /// Single-flight recency memo for the LastUsedAt stamp, keyed by token hash: - /// the UTC time this service instance last DISPATCHED a stamp write for the - /// token. Recorded at dispatch (not completion), so a burst of validations - /// re-dispatches nothing while a stamp is still in flight — READ-SIDE state - /// (query snapshot, stream mirror) can lag arbitrarily and must never gate - /// the write (CI runs 28682878901/28684288201: the snapshot-only and - /// mirror-lambda gates both let lagged polls through, 5 then 3 duplicate - /// stamps on one token node). Instance field on the mesh-scoped singleton - /// (AddSingleton<ApiTokenService>) — dies with the mesh, never static. - /// - private readonly ConcurrentDictionary lastStampDispatchedAt = new(); - - private int stampDispatchCount; - - /// - /// Upper bound on each of the two validation-side node reads (index at - /// ApiToken/{hashPrefix}, then the user-scoped token node). The read is the - /// resilient GetMeshNodeStream poll (same shape as - /// OAuthCodeStore.ReadCodeNode), so a WARM token resolves on the first - /// attempt with no added latency — only a genuine miss (unknown token, or a fresh - /// token still inside the create→routable window on THIS replica) keeps polling - /// and burns the window. 8 s default: ample margin over the observed cross-replica - /// create→routable lag on memex-cloud (a fresh token 401ed for ~2 min under the - /// old one-shot read; the poll rides the lag out instead). Init-only so tests can - /// shorten it — never a static bound to "tune away" a failure. - /// - public TimeSpan ValidationReadTimeout { get; init; } = TimeSpan.FromSeconds(8); - - /// - /// Pause between validation read attempts — matches OAuthCodeStore.ReadCodeNode - /// and (self-paced Concat: the next attempt subscribes - /// only after the previous one completed, so exactly one owner-hub subscription is - /// live at a time). - /// - private static readonly TimeSpan ValidationRetryDelay = TimeSpan.FromMilliseconds(50); - - /// - /// Number of LastUsedAt stamp writes this instance has DISPATCHED — the - /// single-flight contract's observable. A burst of N validations against - /// a token must move this by exactly 1 per freshness window regardless of - /// read-side lag; ApiTokenServiceTests asserts on it (node-version - /// observation alone cannot distinguish "one write" from "several writes - /// coalesced by the change feed" deterministically). - /// - internal int StampDispatchCount => Volatile.Read(ref stampDispatchCount); - - /// - /// Reactive token creation — composes two observables - /// (hub.Post + RegisterCallback under the hood). No async/await anywhere. - /// Subscribe to observe the raw token + stored node once both writes commit. - /// - public IObservable CreateToken( - string userId, string userName, string userEmail, string label, DateTimeOffset? expiresAt = null) - { - // 🚨 The OAuth label namespace is RESERVED: it is what makes a token subject to the idle - // rule (OAuthTokenLifetime), so a hand-minted token may not wear it — only - // CreateOAuthToken, the exchange's own surface, mints into it. - if (OAuthTokenLifetime.IsReservedLabel(label)) - return Observable.Throw(new InvalidOperationException( - $"A token label may not start with '{OAuthTokenLifetime.LabelPrefix.Trim()}' — that prefix " - + "is reserved for tokens the OAuth sign-in issues. Choose another label.")); - return CreateTokenCore(userId, userName, userEmail, label, expiresAt); - } - - /// - /// Mints the token the OAuth exchange issues to , labelled - /// OAuth: {clientId} () — the one surface allowed - /// to mint into the reserved OAuth label namespace, and therefore the one whose tokens are - /// subject to the idle rule. - /// - internal IObservable CreateOAuthToken( - string userId, string userName, string userEmail, string clientId, DateTimeOffset? expiresAt) - => CreateTokenCore(userId, userName, userEmail, OAuthTokenLifetime.LabelFor(clientId), expiresAt); - - private IObservable CreateTokenCore( - string userId, string userName, string userEmail, string label, DateTimeOffset? expiresAt) - { - // 🚨 A person's surfaces never mint for a SERVICE principal. A service's tokens are issued - // only through CreateServiceToken, which reads the service's record in the Admin partition - // first — a token minted here for a `svc-…` id would carry no identity path, so revoking - // the service could not reach it (and validation refuses it anyway, see - // ConfirmServicePrincipal). Doc/Architecture/ServiceIdentities. - if (ServiceIdentity.IsServiceObjectId(userId)) - return Observable.Throw(new InvalidOperationException( - $"'{userId}' is a service principal — its tokens are issued by a global admin on the " - + "Service identities tab, never through a personal token surface.")); - - var rawBytes = RandomNumberGenerator.GetBytes(TokenByteLength); - var rawToken = TokenPrefix + Convert.ToBase64String(rawBytes) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - - var hash = HashToken(rawToken); - - // Per-user partition layout (Repair v10): tokens live at - // {userId}/ApiToken/{hashPrefix}, NOT under User/{userId}/ApiToken. - // The global ApiToken/{hashPrefix} index entry routes incoming - // bearer tokens to the right user-scoped node at validation time. - var userTokenNamespace = $"{userId}/{ApiTokenNamespace}"; - var assignmentPath = $"{userId}/_Access/{userId}_Access"; - - // Opportunistic, fire-and-forget sweep of THIS user's already-expired tokens. Short-lived - // tokens (a browser session's mw_ token is minted with expiresInDays: 1) would otherwise - // pile up in the user's partition forever — nothing ever removed a token whose ExpiresAt had - // passed (issue #1477). Same shape as OAuthCodeStore.CleanupExpired: no timer, no watchdog, - // no background service — it only ever runs on the reactive mint path, and only over the - // minting user's own namespace. - CleanupExpired(userTokenNamespace); - - var rolesObs = ResolveSelfScopeRoles(assignmentPath); - - return rolesObs.SelectMany(capturedRoles => Mint( - rawToken, userTokenNamespace, userId, new ApiToken - { - TokenHash = hash, - UserId = userId, - UserName = userName, - UserEmail = userEmail, - Label = label, - CreatedAt = DateTimeOffset.UtcNow, - ExpiresAt = expiresAt, - Roles = capturedRoles, - })); - } - - /// - /// Issues a token for the SERVICE principal — the one surface - /// that mints for a principal other than the caller, and it can name only a service. - /// - /// 🚨 The record is read FIRST, from the authoritative store: a name that is not a service - /// object id, a record that does not exist, or a revoked identity is refused before anything is - /// written. So this cannot be pointed at a person — a person's id has no record under - /// , and the prefix check refuses it before the read. - /// - /// The token row lives beneath the record (), - /// in the Admin partition, so the write itself runs under the CALLER's identity and only a global - /// admin can make it. It carries , which validation - /// reads on every use. The raw token is returned once; only its hash is stored. - /// - /// The service object id (svc-…). - /// What the token is for. - /// Optional end of term. - /// The raw token and the stored node, once both rows are readable. - public IObservable CreateServiceToken( - string serviceObjectId, string label, DateTimeOffset? expiresAt = null) - { - if (!ServiceIdentity.IsServiceObjectId(serviceObjectId)) - return Observable.Throw(new InvalidOperationException( - $"'{serviceObjectId}' is not a service principal. Service tokens are issued only for a " - + "service identity; a person mints their own tokens, signed in as themselves.")); - - var identityPath = ServiceIdentity.PathFor(serviceObjectId); - return storage.Read(identityPath, hub.JsonSerializerOptions) - .Take(1) - // An empty completion is "no record" and is refused below like a null one — never a - // mint that completes with neither a token nor an error. - .DefaultIfEmpty() - .SelectMany(record => - { - var identity = record?.ContentAs(hub.JsonSerializerOptions); - if (record is null || identity is null) - return Observable.Throw(new InvalidOperationException( - $"No service identity '{serviceObjectId}' exists at {identityPath}.")); - if (identity.IsRevoked) - return Observable.Throw(new InvalidOperationException( - $"The service identity '{serviceObjectId}' is revoked; it cannot be issued tokens.")); - - var rawBytes = RandomNumberGenerator.GetBytes(TokenByteLength); - var rawToken = TokenPrefix + Convert.ToBase64String(rawBytes) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - var tokenNamespace = ServiceIdentity.TokenNamespace(serviceObjectId); - CleanupExpired(tokenNamespace); - return Mint(rawToken, tokenNamespace, identityPath, new ApiToken - { - TokenHash = HashToken(rawToken), - UserId = serviceObjectId, - UserName = record.Name ?? serviceObjectId, - UserEmail = "", - Label = label, - CreatedAt = DateTimeOffset.UtcNow, - ExpiresAt = expiresAt, - ServiceIdentityPath = identityPath, - }); - }); - } - - /// - /// Writes one token: the row at {tokenNamespace}/{hashPrefix} under the caller's identity, - /// then the global ApiToken/{hashPrefix} index entry as System, then confirms both are - /// readable from the shared store before the raw token leaves the server. - /// - private IObservable Mint( - string rawToken, string tokenNamespace, string mainNode, ApiToken apiToken) - { - var hash = apiToken.TokenHash; - var hashPrefix = hash[..12]; - var label = apiToken.Label; - var userId = apiToken.UserId; - { - var userNode = new MeshNode(hashPrefix, tokenNamespace) - { - Name = $"API Token: {label}", - NodeType = NodeTypeApiToken, - State = MeshNodeState.Active, - MainNode = mainNode, - Content = apiToken, - }; - - var accessService = hub.ServiceProvider.GetService(); - - return nodeFactory.CreateNode(userNode) - .SelectMany(created => - { - var indexNode = new MeshNode(hashPrefix, ApiTokenNamespace) - { - Name = $"API Token: {label}", - NodeType = NodeTypeApiToken, - State = MeshNodeState.Active, - Content = new ApiTokenIndex - { - TokenHash = hash, - TokenPath = created.Path, - }, - }; - - // Index writes require System identity — the global ApiToken/ - // namespace is a separately-gated partition for security - // infrastructure that ordinary users don't have Create on. - // See git history on this file for the SwitchAccessContext- - // outside-Defer bug that this lambda layout fixes (System - // context must be active during CaptureContext at Subscribe - // time, not when the outer using-block returned). - IObservable indexObs; - if (accessService != null) - { - indexObs = Observable.Defer(() => - { - var disp = accessService.SwitchAccessContext( - new AccessContext { ObjectId = WellKnownUsers.System, Name = "system-security" }); - return nodeFactory.CreateNode(indexNode).Finally(() => disp.Dispose()); - }); - } - else - { - indexObs = nodeFactory.CreateNode(indexNode); - } - - logger.LogInformation("Creating API token {Label} for user {UserId} (hash prefix {HashPrefix})", - label, userId, hashPrefix); - - // 🚨 Confirm BOTH nodes are readable before returning the raw token. On a - // multi-silo portal a just-created node is not instantly routable: the /token - // exchange issues the token on one replica and the MCP client reconnects - // (validates) near-instantly, often on ANOTHER replica, where ValidateToken's - // one-shot hub.GetMeshNode hits [ROUTE] NotFound during the create→routable - // window → the token is rejected ("Got new credentials, but memex rejected them - // on reconnect"). Reading each node back through the resilient GetMeshNodeStream - // poll activates its owning per-node hub from Postgres, so by the time /token - // returns the token is validatable on any replica. Keeps the HOT validation path - // on the fast one-shot read (only issuance — a one-time login — pays this). - return indexObs.SelectMany(_ => - ConfirmReadable(indexNode.Path) - .SelectMany(__ => ConfirmReadable(created.Path)) - .Select(___ => new TokenCreationResult(rawToken, created, apiToken.CreatedAt))); - }); - } - } - - /// - /// Fire-and-forget sweep of the minting user's EXPIRED token rows, run on the mint path. - /// - /// - /// Enumerates {userId}/ApiToken straight off the AUTHORITATIVE shared store — the same - /// reason validation reads directly: the query index lags, and a - /// sweep must decide what to delete from what actually EXISTS, never from a stale snapshot. - /// Deletion goes back through (the canonical mesh delete), so the - /// user's live token list and every workspace mirror see the removal. - /// - /// - /// - /// 🚨 The predicate is deliberately narrow: a token is swept ONLY when - /// already refuses it — its is in the PAST, or it is an - /// OAuth-minted token past without use - /// (). A token a person minted by hand with no expiry is never - /// touched, however long it sits unused. Deleting a live credential is the one failure mode - /// this must not have. - /// - /// - /// - /// Self-paced (Concat, never Merge): one delete at a time, so a user carrying a - /// large backlog of expired tokens drains gently instead of bursting N node deletes onto the - /// mesh at once. Losing a race against a concurrent sweep or an explicit delete on another - /// replica is the expected outcome for the loser and only logged at Debug; an unexpected - /// sweep failure surfaces as a Warning. Each removal is recorded exactly once — by - /// , which every swept token goes through; this method adds no - /// per-token line of its own, so housekeeping costs no extra log volume. - /// - /// - private void CleanupExpired(string userTokenNamespace) - { - var now = DateTimeOffset.UtcNow; - storage.ListChildPaths(userTokenNamespace) - .Take(1) - .SelectMany(listing => Observable.Concat(listing.NodePaths - .Select(path => storage.Read(path, hub.JsonSerializerOptions) - .Where(n => IsExpired(n, now)) - // No per-token line here: DeleteToken already logs each removal at Information. - // A second line per swept token would double the volume of a purely housekeeping - // path — and the first mint after a large backlog accumulates would emit it in a - // burst — for nothing the delete's own line does not already say. - .SelectMany(_ => DeleteToken(path)) - .Catch(ex => - { - logger.LogDebug(ex, - "Expired API token cleanup skipped {Path} (already gone or delete rejected)", - path); - return Observable.Return(false); - })))) - .Subscribe( - _ => { }, - ex => - { - logger.LogWarning(ex, - "Expired API token cleanup sweep failed for {Namespace}", userTokenNamespace); - // Fire on the FAULT path too. An observer waiting for this sweep must be told it - // is over either way — a signal that only fires on success turns a failed sweep - // into an indefinite wait, which is the same "no source to subscribe to" defect - // one level down. - expirySweepCompleted.OnNext(userTokenNamespace); - }, - () => expirySweepCompleted.OnNext(userTokenNamespace)); - } - - /// - /// True when the stored node holds an whose expiry has already - /// passed. A row that carries no token content, or a token with no expiry at all, is - /// never expired — both are left alone. - /// - private bool IsExpired(MeshNode? node, DateTimeOffset now) - { - var token = node?.ContentAs(hub.JsonSerializerOptions); - return token is not null - && (token.ExpiresAt is { } expiresAt && expiresAt < now || IsIdleOAuthToken(token, now)); - } - - /// - /// True when was minted by the OAuth exchange and has gone unused past - /// . Validation refuses such a token and the expiry - /// sweep deletes it, exactly like one whose has passed. The rule - /// itself is , shared with so every - /// validator refuses the same token. - /// - internal static bool IsIdleOAuthToken(ApiToken token, DateTimeOffset now) => OAuthTokenLifetime.IsIdle(token, now); - - /// - /// Deletes the global ApiToken/{hashPrefix} index entry under the well-known System - /// identity — the SAME identity writes it with. - /// - /// - /// The global ApiToken/ namespace is a separately-gated partition for security - /// infrastructure that ordinary users do not hold rights on, so a user-identity delete of - /// their own token's index entry can be refused — leaving the index row behind while the - /// user-scoped row goes away. That orphan is half of the two-nodes-per-mint pair this - /// service creates, so removing the token without it just moves the accumulation (#1477). - /// keeps the System - /// scope alive across the cold delete's Subscribe, exactly as the create path does. - /// - /// - private IObservable DeleteIndexEntry(string indexPath) - { - var accessService = hub.ServiceProvider.GetService(); - var delete = accessService is null - ? nodeFactory.DeleteNode(indexPath) - : Observable.Defer(() => - { - var disp = accessService.SwitchAccessContext( - new AccessContext { ObjectId = WellKnownUsers.System, Name = "system-security" }); - return nodeFactory.DeleteNode(indexPath).Finally(() => disp.Dispose()); - }); - - return delete.Select(_ => true).Catch(ex => - { - logger.LogDebug(ex, "API token index entry {Path} not deleted (already gone or refused)", indexPath); - return Observable.Return(false); - }); - } - - /// - /// Reads the user's self-scope at - /// {userId}/_Access/{userId}_Access and emits the (non-denied) - /// role IDs assigned there. Pure observable composition — one-shot - /// under System - /// identity, then .Select. Emits an empty array on missing - /// assignment or read failure (the issued token still has identity - /// but no role grants — correct outcome). - /// ISSUANCE-ONLY — called exclusively from , - /// never on the validation path (Bearer-request role enrichment goes - /// through UserRoleResolver.LoadDbRolesAsync → the synced query), - /// so the one-shot read's timeout cannot 401 anybody; a miss only means - /// the new token carries no captured roles. Do not "harden" it with the - /// validation-side resilient poll — issuance is a one-time login that - /// must not stall on a genuinely absent assignment. - /// - private IObservable> ResolveSelfScopeRoles(string assignmentPath) - { - var accessService = hub.ServiceProvider.GetService(); - - // Observable.Using ties the AsyncLocal System scope's lifetime to - // the Subscribe of the inner observable, not to the lambda body's - // return — same shape used by ApiTokenNodeType.HandleValidateToken - // for the same reason (Defer-style subscribe-time capture). - var readUnderSystem = accessService != null - ? Observable.Using( - () => accessService.ImpersonateAsSystem(), - _ => hub.GetMeshNode(assignmentPath, TimeSpan.FromSeconds(5))) - : hub.GetMeshNode(assignmentPath, TimeSpan.FromSeconds(5)); - - return readUnderSystem - .Select(node => - { - var assignment = node?.Content as AccessAssignment ?? ExtractAccessAssignment(node); - if (assignment is null) - return (IReadOnlyCollection)Array.Empty(); - return assignment.Roles - .Where(r => !r.Denied && !string.IsNullOrEmpty(r.Role)) - .Select(r => r.Role) - .Distinct() - .ToArray(); - }) - .Catch, Exception>(ex => - { - logger.LogWarning(ex, - "Failed to resolve self-scope roles from {Path} for token creation; continuing with empty role set", - assignmentPath); - return Observable.Return>(Array.Empty()); - }); - } - - /// - /// Reactive token validation. Reads the index node at - /// ApiToken/{hashPrefix} — and, when it points at a user-scoped token, - /// the token node — DIRECTLY from the authoritative shared store - /// ( → ). - /// - /// Why: auth must be deterministic on every replica. Both prior shapes rode - /// the mesh routing path and failed multi-replica: the one-shot - /// hub.GetMeshNode hit [ROUTE] NotFound on non-minting pods - /// (memex-cloud 2026-07-24), and the resilient GetMeshNodeStream poll - /// that replaced it (#633) still timed out for the full 8 s on 3 of 4 pods - /// because the cross-silo per-node-hub subscribe itself wedged (memex-cloud - /// 2026-07-25). The storage read has no such dependency: the row either is - /// or isn't in Postgres, identically for every pod; the short poll only - /// rides out the mint→flush window for a seconds-old token. - /// - /// - /// Every failure is logged at Warning with the hash prefix (never the raw - /// token), the failing stage (index-read-timeout / index-hash-mismatch / - /// token-read-timeout / token-hash-mismatch / revoked / expired) and elapsed ms. - /// The chain is fully observable — no FromAsync, no await. - /// - /// - public IObservable ValidateToken(string rawToken) - // Compat projection: both definitive-Invalid and Unavailable collapse to null, - // matching the pre-tri-state surface. Callers that must tell "cannot validate - // right now (retryable)" apart from "invalid (401)" — the auth handler above - // all — use Validate(...) and branch on TokenValidationStatus (issue #637). - => Validate(rawToken).Select(result => result.Token); - - /// - /// Tri-state validation seam (issue #637): - /// carries the token record; - /// is a DEFINITIVE negative (row absent, - /// hash mismatch, revoked, expired) and may be answered 401; - /// means the authoritative store - /// could not be read within (storage fault / - /// timeout) — validation reached NO verdict, so the caller must answer retryable - /// (503 + Retry-After), NEVER 401. A timeout maps to Unavailable by construction; - /// definitive absence stays Invalid. - /// - public IObservable Validate(string rawToken) - { - if (string.IsNullOrEmpty(rawToken) || !rawToken.StartsWith(TokenPrefix)) - return Observable.Return(TokenValidationResult.Invalid("Invalid token format")); - - var hash = HashToken(rawToken); - var hashPrefix = hash[..12]; - var indexPath = $"{ApiTokenNamespace}/{hashPrefix}"; - - return Observable.Defer(() => - { - var elapsed = Stopwatch.StartNew(); - return ReadValidationNode(indexPath, node => IndexHashVerdict(node, hash), "index", hashPrefix, elapsed) - .SelectMany(indexOutcome => - { - if (indexOutcome.UnavailableReason is { } indexReason) - return Observable.Return(TokenValidationResult.Unavailable(indexReason)); - - var indexNode = indexOutcome.Node; - if (indexNode == null) - // Definitive absence/mismatch — already logged with its stage. - return Observable.Return(FinalizeToken(null, null, hash, hashPrefix, elapsed)); - - var index = indexNode.Content as ApiTokenIndex ?? ExtractApiTokenIndex(indexNode); - if (index != null) - return ReadValidationNode( - index.TokenPath, node => TokenHashVerdict(node, hash), "token", hashPrefix, elapsed) - .Select(tokenOutcome => tokenOutcome.UnavailableReason is { } tokenReason - ? TokenValidationResult.Unavailable(tokenReason) - : FinalizeToken( - tokenOutcome.Node, - (tokenOutcome.Node?.Content as ApiToken) ?? ExtractApiToken(tokenOutcome.Node), - hash, hashPrefix, elapsed)); - - // Legacy format: full ApiToken at the index path (its hash already - // matched in the verdict — a mismatch would have terminated above). - return Observable.Return(FinalizeToken( - indexNode, - (indexNode.Content as ApiToken) ?? ExtractApiToken(indexNode), - hash, hashPrefix, elapsed)); - }) - .SelectMany(result => ConfirmServicePrincipal(result, hashPrefix)); - }); - } - - /// - /// The service-principal half of validation (Doc/Architecture/ServiceIdentities). A valid token - /// that carries authenticates only while its - /// record exists and is not revoked — read from the SAME - /// authoritative store as the token itself, on every use, so revoking the identity revokes every - /// token it holds on the very next request, on every replica. A token that names a service - /// object id WITHOUT an identity path was not minted by and is - /// refused outright. A person's token passes through unchanged. - /// - private IObservable ConfirmServicePrincipal(TokenValidationResult result, string hashPrefix) - { - if (result.Status != TokenValidationStatus.Valid || result.Token is not { } token) - return Observable.Return(result); - - if (string.IsNullOrEmpty(token.ServiceIdentityPath)) - { - if (!ServiceIdentity.IsServiceObjectId(token.UserId)) - return Observable.Return(result); - logger.LogWarning( - "API token validation failed at {Stage} for hash prefix {HashPrefix}: the token names service principal {UserId} but carries no identity path", - "service-identity-missing", hashPrefix, token.UserId); - return Observable.Return(TokenValidationResult.Invalid("Service token without an identity record")); - } - - var path = token.ServiceIdentityPath; - return storage.Read(path, hub.JsonSerializerOptions) - .Take(1) - // An empty completion is "no record" (→ Invalid), never an absent verdict. - .DefaultIfEmpty() - .Timeout(ValidationReadTimeout) - .Select(record => - { - var refusal = ServiceIdentity.Refuse( - record?.ContentAs(hub.JsonSerializerOptions), token.UserId, path); - if (refusal is null) - return result; - logger.LogWarning( - "API token validation failed at {Stage} for hash prefix {HashPrefix}: {Reason}", - "service-identity-refused", hashPrefix, refusal); - return TokenValidationResult.Invalid(refusal); - }) - // No verdict about the identity is no verdict about the token (issue #637): Unavailable, - // never Invalid, and never Valid either — a revoked service must not slip through a - // storage blip. - .Catch(ex => Observable.Return(TokenValidationResult.Unavailable( - $"service identity read for token validation did not complete ({ex.GetType().Name})"))); - } - - /// - /// Verdict for one successfully-read candidate node during validation: - /// true = expected content present with the MATCHING hash (emit the node); - /// false = content present but a DIFFERENT hash — someone else's token - /// colliding on the 12-char prefix / tampering — a TERMINAL mismatch that must - /// fail fast, never spin the poll; null = the row is there but its content is not - /// readable as a token record — also TERMINAL (the store answered; nothing a re-read would - /// change), logged under its own -unreadable stage by - /// so it is never mistaken for an absent row. - /// - private bool? IndexHashVerdict(MeshNode node, string hash) - { - var index = node.Content as ApiTokenIndex ?? ExtractApiTokenIndex(node); - if (index != null) - return string.Equals(index.TokenHash, hash, StringComparison.OrdinalIgnoreCase); - var legacy = node.Content as ApiToken ?? ExtractApiToken(node); - if (legacy != null) - return string.Equals(legacy.TokenHash, hash, StringComparison.OrdinalIgnoreCase); - return null; - } - - /// - private bool? TokenHashVerdict(MeshNode node, string hash) - { - var token = node.Content as ApiToken ?? ExtractApiToken(node); - if (token != null) - return string.Equals(token.TokenHash, hash, StringComparison.OrdinalIgnoreCase); - return null; - } - - /// - /// Bounded resilient read for the validation path, with one correctness nuance: - /// a node that reads SUCCESSFULLY but carries a non-matching hash is a terminal - /// mismatch (emit null immediately, logged as {stage}-hash-mismatch) — - /// only absence re-polls. Each attempt reads the AUTHORITATIVE shared store - /// directly ( → the Postgres row on - /// multi-replica portals) — deterministic on EVERY replica, no grain - /// activation, no cross-silo stream subscribe. The previous - /// GetMeshNodeStream read rode the mesh's cross-silo per-node-hub - /// path, which wedged on non-minting pods (memex-cloud 2026-07-25: - /// index-read-timeout after the full 8 s on 3 of 4 replicas → every MCP - /// request 401ed); auth must not be hostage to that path. The short poll - /// (re-subscribe after via Concat — - /// never Merge) only rides out the mint→flush window for a token - /// created moments ago on another replica; a warm token resolves on the - /// first probe. Bounded by → Warning - /// ({stage}-read-timeout) → null. No impersonation needed: the - /// storage adapter enforces no ACL — the hash compare IS the - /// authentication step. - /// - /// - /// Outcome of one bounded validation-side read. Exactly one of three shapes: - /// Node != null — the row was read and its hash MATCHES; - /// Node == null && UnavailableReason == null — a DEFINITIVE negative - /// (row absent, or present with a different hash); - /// UnavailableReason != null — the store could not be read within - /// (storage fault / timeout): NO verdict was - /// reached, and the caller must map this to - /// , never to Invalid (issue #637). - /// - private sealed record ValidationReadOutcome(MeshNode? Node, string? UnavailableReason); - - private IObservable ReadValidationNode( - string path, Func hashVerdict, string stage, string hashPrefix, Stopwatch elapsed) - { - // 🚨 An ABSENT row is a TERMINAL negative, never a reason to poll. - // CreateToken confirms both rows are committed to the shared store BEFORE - // the raw token leaves the server (ConfirmReadable), so by the time any - // client can PRESENT a token, its rows exist — absence at validation means - // unknown/deleted, full stop. The previous shape re-polled absence until - // ValidationReadTimeout, making every bad-token 401 cost the full 8 s - // (measured 8.2 s per unauthenticated /mcp probe on memex-cloud after the - // storage-direct cutover) — a leftover from the cross-silo mesh read whose - // lag no longer exists. Only a THROWN storage error (connection blip) - // re-polls, bounded by ValidationReadTimeout. - // - // 🚨 Three negatives, three DIFFERENT log stages — never fold them. `{stage}-not-found` - // is the line a 401 investigation reads as "the row was deleted" (#5074 rested its whole - // supersession inference on it), so it must be emitted ONLY when the store answered that - // no row exists. A row that IS there but whose content cannot be read as the expected - // record is a different fact — the token was not deleted, this replica could not TYPE - // the row — and used to print as "no row at {Path}", which sent the reader hunting for a - // delete that never happened. It logs as `{stage}-unreadable`, naming the row's version - // and the content's runtime type, so the next occurrence is attributable inside the log - // window. The verdict stays a definitive negative either way: the store WAS read, and a - // row that carries no token record authenticates nobody — Unavailable is reserved for a - // store that could not be read at all. - IObservable Attempt() => - storage.Read(path, hub.JsonSerializerOptions) - .Select(node => - { - var verdict = node is null ? null : hashVerdict(node); - if (verdict == true) - return new ValidationReadOutcome(node, null); - if (verdict == false) - logger.LogWarning( - "API token validation failed at {Stage} for hash prefix {HashPrefix} after {ElapsedMs} ms: node at {Path} exists but carries a different token hash", - stage + "-hash-mismatch", hashPrefix, elapsed.ElapsedMilliseconds, path); - else if (node is null) - logger.LogWarning( - "API token validation failed at {Stage} for hash prefix {HashPrefix} after {ElapsedMs} ms: no row at {Path} (unknown or deleted token)", - stage + "-not-found", hashPrefix, elapsed.ElapsedMilliseconds, path); - else - logger.LogWarning( - "API token validation failed at {Stage} for hash prefix {HashPrefix} after {ElapsedMs} ms: row at {Path} exists (version {Version}) but its content is {ContentType}, not a readable token record — the token was NOT deleted; this replica could not type the row", - stage + "-unreadable", hashPrefix, elapsed.ElapsedMilliseconds, path, - node.Version, node.Content?.GetType().Name ?? "null"); - return new ValidationReadOutcome(null, null); - }) - // A transient storage ERROR (not absence) re-polls — the outer - // Timeout bounds the whole chain. - .Catch(_ => Observable.Empty()) - .Concat(Observable.Defer(Attempt).DelaySubscription(ValidationRetryDelay)); - - return Observable.Defer(Attempt) - .Take(1) - .Timeout(ValidationReadTimeout) - .Catch(ex => - { - // 🚨 UNAVAILABLE, not Invalid: the only way to get here is that every - // storage attempt within the window THREW (or a single read stalled) — - // validation reached no verdict about the token. Surfacing this as the - // same null as a definitive miss made a silo degradation answer 401 - // "Invalid or expired API token" for perfectly valid tokens (issue #637). - logger.LogWarning(ex, - "API token validation UNAVAILABLE at {Stage} for hash prefix {HashPrefix} after {ElapsedMs} ms: node at {Path} not readable within {Timeout} — retryable infrastructure fault, NOT an invalid token", - stage + "-read-timeout", hashPrefix, elapsed.ElapsedMilliseconds, path, ValidationReadTimeout); - return Observable.Return(new ValidationReadOutcome( - null, - $"{stage} read for token validation did not complete within {ValidationReadTimeout.TotalSeconds:0.#} s ({ex.GetType().Name})")); - }); - } - - /// - /// Best-effort confirm that a just-created node has reached the SHARED store - /// ( — the Postgres row on multi-replica - /// portals), tolerating the create→persist flush lag. This is the guarantee - /// that matters for issuance: once the row is committed, - /// (which reads the same store directly) succeeds on EVERY replica — the MCP - /// client's immediate reconnect can land anywhere. Polls one probe at a time - /// (Concat, never Merge) every 50 ms, bounded by a 10 s timeout. On timeout it - /// logs and returns null rather than failing issuance — the token IS created; - /// worst case validation resolves it a beat later. - /// - private IObservable ConfirmReadable(string path) - { - IObservable Attempt() => - storage.Read(path, hub.JsonSerializerOptions) - .Where(n => n is not null && n.Content is not null) - .Select(n => (MeshNode?)n) - .Catch(_ => Observable.Empty()) - .Concat(Observable.Defer(Attempt).DelaySubscription(TimeSpan.FromMilliseconds(50))); - - return Observable.Defer(Attempt) - .Take(1) - .Timeout(TimeSpan.FromSeconds(10)) - .Catch(ex => - { - logger.LogWarning(ex, - "API token node {Path} not confirmed readable within 10s of issuance; returning token anyway", - path); - return Observable.Return(null); - }); - } - - private TokenValidationResult FinalizeToken(MeshNode? tokenNode, ApiToken? apiToken, string hash, string hashPrefix, Stopwatch elapsed) - { - if (apiToken == null) - // Definitive negative: absence/mismatch — already logged by ReadValidationNode - // with its stage. (The unavailable branch never reaches FinalizeToken.) - return TokenValidationResult.Invalid("Unknown or unreadable token"); - if (!string.Equals(apiToken.TokenHash, hash, StringComparison.OrdinalIgnoreCase)) - { - // Defense in depth — the verdict in ReadValidationNode already terminates - // mismatches, so this branch should be unreachable; log it if it ever fires. - logger.LogWarning( - "API token validation failed at {Stage} for hash prefix {HashPrefix} after {ElapsedMs} ms", - "token-hash-mismatch", hashPrefix, elapsed.ElapsedMilliseconds); - return TokenValidationResult.Invalid("Token hash mismatch"); - } - // Warning, not Debug: a rejected token surfaces as a bare 401 to the client — - // without a server-side line naming the stage, prod triage is blind (the - // 2026-07-24 fresh-token 401s cost hours because every failure was silent). - if (apiToken.IsRevoked) - { - logger.LogWarning( - "API token validation failed at {Stage} for hash prefix {HashPrefix} after {ElapsedMs} ms", - "revoked", hashPrefix, elapsed.ElapsedMilliseconds); - return TokenValidationResult.Invalid("Token revoked"); - } - if (apiToken.ExpiresAt.HasValue && apiToken.ExpiresAt.Value < DateTimeOffset.UtcNow) - { - logger.LogWarning( - "API token validation failed at {Stage} for hash prefix {HashPrefix} after {ElapsedMs} ms (expired {ExpiresAt})", - "expired", hashPrefix, elapsed.ElapsedMilliseconds, apiToken.ExpiresAt.Value); - return TokenValidationResult.Invalid("Token expired"); - } - if (OAuthTokenLifetime.IsIdle(apiToken, DateTimeOffset.UtcNow)) - { - logger.LogWarning( - "API token validation failed at {Stage} for hash prefix {HashPrefix} after {ElapsedMs} ms (OAuth token unused since {LastUse})", - "oauth-idle-expired", hashPrefix, elapsed.ElapsedMilliseconds, apiToken.LastUsedAt ?? apiToken.CreatedAt); - return TokenValidationResult.Invalid("OAuth token expired after being unused"); - } - - // Update LastUsedAt via the canonical workspace remote stream — - // fire-and-forget (non-critical telemetry). Subscribe is mandatory - // because Update is cold; the empty error handler keeps the cold - // observable's GC-time fire-and-forget warning quiet on writes - // that hit a deleted node. - // - // Stamp at DISPLAY granularity, not per request. The UI renders "Last used" to the - // minute; a per-request write turned a busy integration's token into the hottest node - // on the mesh (prod 2026-07-02: version 8939 in one day, ~13 writes/min), and every - // write fans out through the change feed to all of the node's subscriber streams. - // Skip the write while the recorded LastUsedAt is fresh — the read above already - // carries the current value. - var now = DateTimeOffset.UtcNow; - if (tokenNode != null - && (apiToken.LastUsedAt is null || now - apiToken.LastUsedAt.Value >= LastUsedStampInterval) - // 🚨 Dispatch-time single-flight: the snapshot check above reads the - // EVENTUALLY-CONSISTENT query index, and the Update lambda below reads - // the stream MIRROR — both can lag the stamp that is already in flight, - // so neither can be the authoritative gate (each lagged validation - // would re-dispatch a distinct-timestamp patch the owner applies → - // per-request version bumps, the hot-token storm #210 exists to stop). - // The memo is dispatch-side state on THIS instance: once one stamp has - // been dispatched inside the freshness window, every later validation - // skips regardless of read-side lag. - && TryClaimStampDispatch(hash, now)) - { - Interlocked.Increment(ref stampDispatchCount); - // A service token's row lives in the Admin partition, which the service itself may not - // write (ServicePrincipalAdminGuard) — the stamp is platform telemetry, so it is written - // as System there. A person's row keeps the shape it always had. - IObservable Stamp() => hub.GetWorkspace() - .GetMeshNodeStream(tokenNode.Path) - .Update(node => - { - // Defense-in-depth for MULTI-INSTANCE racing (another silo's stamp - // already landed on the node this mirror has since synced): re-check - // freshness against the node handed to the lambda and return it - // unchanged when a stamp is already recorded — stream.Update - // short-circuits an unchanged node into a true no-op (no version - // bump, no change-feed fan-out). NOT the primary gate: the mirror - // can lag an in-flight stamp, which is what the dispatch-time memo - // above closes. - var current = node.ContentAs(hub.JsonSerializerOptions) ?? apiToken; - if (current.LastUsedAt is { } last && now - last < LastUsedStampInterval) - return node; - return node with { Content = current with { LastUsedAt = now } }; - }); - (string.IsNullOrEmpty(apiToken.ServiceIdentityPath) - ? Stamp() - : hub.ServiceProvider.GetService().RunAsSystem(Stamp)) - .Subscribe(_ => { }, _ => { }); - } - - return TokenValidationResult.Valid(apiToken); - } - - /// - /// Minimum age of the recorded before a token use writes a - /// fresh stamp. Keeps the "Last used" display accurate to a few minutes without making every - /// authenticated request a mesh-node write. - /// - private static readonly TimeSpan LastUsedStampInterval = TimeSpan.FromMinutes(5); - - /// - /// Atomically claims the right to dispatch a LastUsedAt stamp for - /// : returns true (and records - /// ) when no stamp has been dispatched by this - /// instance within ; false - /// when one already has. Lock-free CAS over the instance - /// memo — never blocks, never parks - /// (concurrent-collection compare-exchange, not an async gate). - /// - private bool TryClaimStampDispatch(string hash, DateTimeOffset now) - { - while (true) - { - if (lastStampDispatchedAt.TryGetValue(hash, out var previous)) - { - if (now - previous < LastUsedStampInterval) - return false; // a stamp for this token is already dispatched/fresh - if (lastStampDispatchedAt.TryUpdate(hash, now, previous)) - return true; - // Lost the CAS to a concurrent claimer — re-read its timestamp. - } - else if (lastStampDispatchedAt.TryAdd(hash, now)) - { - return true; - } - // Lost TryAdd to a concurrent claimer — re-read its timestamp. - } - } - - /// - /// Reactive token revocation. Writes the IsRevoked flag through - /// workspace.GetMeshNodeStream(path).Update(...) — the ONE mutation API — and nothing - /// else. The write routes to the token node's owning hub, is checked there against the - /// CALLER's permissions (the caller's AccessContext rides the update), and is durable - /// by the time the update emits: the owner flushes a patch-driven change post-commit - /// (PostCommitFlushRegistry, #1249). - /// - /// 🚨 No second write (MeshWeaver#6026). This used to follow the update with a raw - /// whole-node SaveMeshNodeRequest posted from whatever hub the service was handed — in - /// production the ROOT MESH HUB, so the router became the SENDER of a node write - /// (ROUTER_TRAFFIC ORIGIN) and that write's outcome had nowhere to go. It was a - /// workaround for a persistence gap the post-commit flush has since closed (the durability is - /// pinned by TokenRevocationIsIssuedOffTheRouterTest, which reads the revocation back - /// from the storage adapter), so it is deleted rather than moved onto another hub. - /// - /// 🚨 A refusal FAULTS, exactly as does (MeshWeaver#4707). - /// A revoke the mesh refused is a statement about a token that STILL AUTHENTICATES; folding it - /// into false let a caller that ignores the value (ServiceIdentities.Rotate) - /// report success with the old credential live. A token node whose content cannot be read as - /// an faults too (the typed Update<ApiToken>) — it used to be - /// returned unchanged and reported as revoked, or, read with default options, replaced by a - /// default token. true means the node now carries IsRevoked; false means the - /// path holds no node at all (nothing there authenticates). - /// - /// The global index entry is hard-deleted afterwards — the index miss is a - /// defense-in-depth gate on top of the authoritative IsRevoked flag, not a primary - /// requirement for the revoke to be effective. - /// - public IObservable RevokeToken(string tokenNodePath) - { - var workspace = hub.GetWorkspace(); - var indexPath = DeriveIndexPath(tokenNodePath); - - logger.LogInformation("Revoking API token at {Path}", tokenNodePath); - - var primary = workspace.GetMeshNodeStream(tokenNodePath) - // The TYPED write: the content is converted with the mesh's own serializer options, and - // content that is absent or not an ApiToken faults the write instead of being replaced - // by a default-constructed token. Flip IsRevoked on the live node — validation reads - // the node fresh (no cache), so the revoke takes effect immediately. - .Update(token => token with { IsRevoked = true }) - .Select(_ => true) - // An ABSENT path is the one non-fault: there is no token there to revoke, so nothing - // authenticates through it — `false`, as DeleteToken answers for the same case. Matched - // as the ROUTER's NotFound for THIS address (typed ErrorType.NotFound AND the routing - // banner naming the path), never on ErrorType alone: NotFound is also what a LIVE hub - // answers for a request it has no handler for, and a refusal arrives as a delivery - // failure too — neither may be read as "nothing was there". - .Catch(ex => - AreaErrorClassifier.IsRoutingNotFoundFailure(ex.Failure) - && string.Equals(AreaErrorClassifier.TryGetMissingNodePath(ex), tokenNodePath, - StringComparison.OrdinalIgnoreCase) - ? Observable.Return(false) - : Observable.Throw(ex)) - // .Do, never a swallowing .Catch: the warning is kept and the refusal reaches the caller. - .Do(_ => { }, ex => logger.LogWarning(ex, "RevokeToken failed for {Path}", tokenNodePath)); - - // Chain the global-index delete into the returned observable rather - // than firing a separate Subscribe — see the matching comment in - // DeleteToken. A missing index entry is fine: DeleteIndexEntry swallows - // it and the primary revoke result wins. - if (indexPath == null || indexPath == tokenNodePath) - return primary; - - return primary.SelectMany(result => DeleteIndexEntry(indexPath).Select(_ => result)); - } - - /// - /// Reactive hard-delete. Removes the user-scoped token node and the - /// global index entry (fire-and-forget). The user-scoped delete goes - /// through ; this is the - /// authoritative removal and the only outcome the caller observes. - /// - /// 🚨 The emitted bool is what this call removed, not whether it - /// completed. true means a token node was there and is now gone; false means - /// nothing was removed because the path was ALREADY ABSENT - /// ( answers false for that since - /// MeshWeaver#4668). Both are successes, and that is exactly why the value must not be - /// invented: a caller that can no longer tell "removed it" from "there was nothing there" - /// cannot detect a read that resolved the wrong path, which is what - /// DeleteToken_ImmediatelyAfterCreate_RemovesTheNewToken exists to catch. - /// - /// 🚨 A REFUSAL is the third outcome and it FAULTS — it is not folded into false - /// (MeshWeaver#4707). A permission denial, a validator verdict or an unestablished check is a - /// statement about a token that is STILL THERE and still authenticates; reporting it as - /// "removed nothing" made the settings tab tell the operator the token "was already gone" - /// while it kept working. Only the two success cases are values; the failure stays a failure, - /// so every caller has to decide what to do with it rather than inherit a quiet false. - /// The three consumers each carry their own arm: the settings tab renders - /// apiTokens.deleteFailed, the expiry sweep logs at Debug and continues its - /// Concat, and the OAuth supersede path warns that the previous token stays live. - /// - public IObservable DeleteToken(string tokenNodePath) - { - var indexPath = DeriveIndexPath(tokenNodePath); - - logger.LogInformation("Deleting API token at {Path}", tokenNodePath); - - // 🚨 PASS THE VALUE THROUGH — never `.Select(_ => true)`. Before MeshWeaver#4668 an - // already-absent path FAULTED, so the Catch below produced the `false` and the projection - // was harmless. Now the same case EMITS `false`, and overwriting it with `true` would - // report a removal that never happened — and, worse, make every assertion that this call - // returned `true` vacuous, including the stale-read regression test that is the only thing - // watching for DeleteToken resolving a path the token is not at. - // 🚨 AND DO NOT CATCH — `.Do`, so the warning is kept and the fault still propagates - // (MeshWeaver#4707). The Catch that used to sit here returned `false` for EVERY fault, - // which merged "refused" into "was already absent": two outcomes that differ in the one - // way an operator cares about, since after a refusal the token is still there and still - // authenticates. It also made the settings tab's Failed arm unreachable by construction. - var primary = nodeFactory.DeleteNode(tokenNodePath) - .Do(_ => { }, ex => logger.LogWarning(ex, "DeleteToken failed for {Path}", tokenNodePath)); - - // Chain the index-entry delete into the returned observable rather than - // firing a separate Subscribe. The previous shape leaked a pending - // hub.Observe callback past test dispose — the response arrives only - // after routing surfaces NotFound (~15ms+) but the test's await - // completes faster, so the dispose-time Quiescing watchdog flags the - // pending callback as a leaked subscription. Chaining here also makes - // a missing-index case (token already gone) a non-failure of the whole - // operation: DeleteIndexEntry swallows it and the primary result wins. - if (indexPath == null || indexPath == tokenNodePath) - return primary; - - return primary.SelectMany(result => DeleteIndexEntry(indexPath).Select(_ => result)); - } - - /// - /// Live list of the user's tokens via the canonical synced query - /// (workspace.GetQuery). The synced query gives us path-keyed - /// dedup across the user-scope and legacy global namespaces, - /// all-Initial gating, and provider fan-out — see - /// Doc/Architecture/SyncedMeshNodeQueries.md. The cache id is - /// per-user so re-mounts (settings tab re-render) reuse the upstream - /// subscription instead of cycling Initial waves. - /// - public IObservable> GetTokensForUser(string userId) - { - var workspace = hub.GetWorkspace(); - var userTokenNamespace = $"{userId}/{ApiTokenNamespace}"; - - return workspace.GetQuery( - $"api-tokens:{userId}", - $"namespace:{userTokenNamespace} nodeType:{NodeTypeApiToken}", - // Legacy fallback: tokens at the global ApiToken namespace - // that pre-date the per-user partition migration. Filtered - // by UserId in the projection below — the synced query - // can't express that predicate, so we over-fetch globally - // and prune. - $"namespace:{ApiTokenNamespace} nodeType:{NodeTypeApiToken}") - .Select(snapshot => - { - var tokens = new List(); - var seenPrefixes = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var node in snapshot) - { - if (node.Path is null) continue; - var apiToken = node.Content as ApiToken ?? ExtractApiToken(node); - if (apiToken == null) continue; - - // Legacy nodes in the global namespace must match the - // calling userId; per-user-partition nodes are scoped - // by namespace and don't need this filter, but the - // check is cheap and unifies the projection. - if (apiToken.UserId != userId) continue; - - var hashPrefix = apiToken.TokenHash.Length >= 8 - ? apiToken.TokenHash[..8] - : apiToken.TokenHash; - if (!seenPrefixes.Add(hashPrefix)) continue; - - tokens.Add(ToInfo(node, apiToken)); - } - return (IReadOnlyList)tokens; - }); - } - - /// - /// Live list of a SERVICE principal's tokens — the synced query over its token namespace beneath - /// the record (). Readable by whoever can read the - /// Admin partition, i.e. a global admin. - /// - /// The service object id. - /// Every emission is the complete list. - public IObservable> GetTokensForService(string serviceObjectId) - { - var tokenNamespace = ServiceIdentity.TokenNamespace(serviceObjectId); - return hub.GetWorkspace() - .GetQuery($"service-tokens:{serviceObjectId}", $"namespace:{tokenNamespace} nodeType:{NodeTypeApiToken}") - .Select(snapshot => (IReadOnlyList)snapshot - .Where(node => node.Path is not null) - .SelectMany(node => node.ContentAs(hub.JsonSerializerOptions) is { } token - ? [ToInfo(node, token)] - : Array.Empty()) - .OrderByDescending(info => info.CreatedAt) - .ToList()); - } - - /// - /// Derives the global ApiToken/{hashPrefix} index path from a - /// user-scoped token node path. sets the - /// node Id to the 12-char hash prefix, so the last path segment is - /// reliably the prefix used to build the index entry. Returns null - /// for malformed paths (no slash, trailing slash). - /// - private static string? DeriveIndexPath(string tokenNodePath) - { - if (string.IsNullOrEmpty(tokenNodePath)) return null; - var lastSlash = tokenNodePath.LastIndexOf('/'); - if (lastSlash < 0 || lastSlash >= tokenNodePath.Length - 1) return null; - var hashPrefix = tokenNodePath[(lastSlash + 1)..]; - return $"{ApiTokenNamespace}/{hashPrefix}"; - } - - private static ApiTokenInfo ToInfo(MeshNode node, ApiToken apiToken) => new() - { - NodePath = node.Path, - Label = apiToken.Label, - CreatedAt = apiToken.CreatedAt, - ExpiresAt = apiToken.ExpiresAt, - LastUsedAt = apiToken.LastUsedAt, - IsRevoked = apiToken.IsRevoked, - HashPrefix = apiToken.TokenHash.Length >= 8 ? apiToken.TokenHash[..8] : apiToken.TokenHash, - }; - - public static string HashToken(string rawToken) - { - var bytes = System.Text.Encoding.UTF8.GetBytes(rawToken); - var hashBytes = SHA256.HashData(bytes); - return Convert.ToHexString(hashBytes).ToLowerInvariant(); - } - - private static ApiToken? ExtractApiToken(MeshNode? node) - { - if (node?.Content is System.Text.Json.JsonElement jsonElement) - { - try - { - return System.Text.Json.JsonSerializer.Deserialize(jsonElement.GetRawText(), - new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - } - catch - { - return null; - } - } - return null; - } - - private static ApiTokenIndex? ExtractApiTokenIndex(MeshNode? node) - { - if (node?.Content is System.Text.Json.JsonElement jsonElement) - { - try - { - var index = System.Text.Json.JsonSerializer.Deserialize(jsonElement.GetRawText(), - new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - // Distinguish from legacy ApiToken: index has TokenPath, ApiToken does not - return !string.IsNullOrEmpty(index?.TokenPath) ? index : null; - } - catch - { - return null; - } - } - return null; - } - - private AccessAssignment? ExtractAccessAssignment(MeshNode? node) - { - if (node?.Content is AccessAssignment direct) return direct; - if (node?.Content is System.Text.Json.JsonElement jsonElement) - { - try - { - return System.Text.Json.JsonSerializer.Deserialize( - jsonElement.GetRawText(), hub.JsonSerializerOptions); - } - catch - { - return null; - } - } - return null; - } -} - -/// -/// Returned by the reactive method -/// once both the user-scoped token node and the index pointer have been created. -/// is the creation stamp of the minted record, carried from the value -/// the mint wrote — never re-read off , which can arrive untyped (#6636). -/// -public record TokenCreationResult(string RawToken, MeshNode Node, DateTimeOffset CreatedAt); - -/// -/// Tri-state outcome of an API-token validation (issue #637). The whole point of the -/// three-way split is that "cannot validate right now" is NOT "invalid": an HTTP -/// caller must see 503 + Retry-After for and 401 only for -/// — otherwise a platform hiccup is indistinguishable from a -/// forged token and clients discard perfectly valid credentials. -/// -public enum TokenValidationStatus -{ - /// The token is valid; carries the record. - Valid, - - /// - /// DEFINITIVE negative verdict: the row is absent, carries a different hash, - /// is revoked, or is expired. Answering 401 is correct. - /// - Invalid, - - /// - /// Validation could not be carried out (storage read timeout / fault): NO verdict - /// was reached. Retryable — answer 503 + Retry-After, never 401. - /// - Unavailable, -} - -/// -/// Result of : the verdict, -/// the validated (only for ), -/// and a speaking for the two negative shapes (log/diagnostics — -/// never echo it verbatim as an auth error to untrusted clients). -/// -public record TokenValidationResult -{ - /// The tri-state verdict. - public required TokenValidationStatus Status { get; init; } - - /// The validated token record; non-null exactly when is Valid. - public ApiToken? Token { get; init; } - - /// Why validation did not produce a valid token (Invalid/Unavailable shapes). - public string? Reason { get; init; } - - /// Creates the Valid outcome carrying the token record. - public static TokenValidationResult Valid(ApiToken token) - => new() { Status = TokenValidationStatus.Valid, Token = token }; - - /// Creates the definitive-negative outcome (may be answered 401). - public static TokenValidationResult Invalid(string reason) - => new() { Status = TokenValidationStatus.Invalid, Reason = reason }; - - /// Creates the retryable no-verdict outcome (must be answered 503, never 401). - public static TokenValidationResult Unavailable(string reason) - => new() { Status = TokenValidationStatus.Unavailable, Reason = reason }; -} - -/// -/// Safe DTO for listing tokens — never exposes the full hash or raw token. -/// -public record ApiTokenInfo -{ - public string NodePath { get; init; } = ""; - public string Label { get; init; } = ""; - public DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? ExpiresAt { get; init; } - public DateTimeOffset? LastUsedAt { get; init; } - public bool IsRevoked { get; init; } - public string HashPrefix { get; init; } = ""; -} diff --git a/memex/Memex.Portal.Shared/Authentication/BootstrapController.cs b/memex/Memex.Portal.Shared/Authentication/BootstrapController.cs deleted file mode 100644 index b73d1cfd80..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/BootstrapController.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// One-shot, secret-gated first-admin bootstrap. Exists because a fresh deployment with a -/// wedged onboarding path (or any environment where the interactive /onboarding flow can't be -/// driven) still needs a way to materialise the FIRST platform administrator. -/// -/// It reuses — the exact same write path the -/// interactive onboarding uses — so the produced User node + AccessAssignments are serialised -/// and schema-routed correctly (no hand-rolled SQL, no guessing the content JSON shape). -/// CreateUser writes to the user's own partition + the Admin/_Access scope; it never -/// touches the phantom onboarding hub, so it isn't affected by that deadlock. -/// -/// Gated by the Bootstrap:Secret config value: if unset, the endpoint is disabled -/// (returns 404-equivalent Unauthorized). Intended to be invoked once by an operator, then the -/// secret removed. Anonymous-reachable by design (there is no admin yet to authorise it). -/// -[ApiController] -[Route("bootstrap")] -public class BootstrapController( - UserOnboardingService onboarding, - IConfiguration config, - ILogger logger) : ControllerBase -{ - [HttpGet("first-admin")] - [HttpPost("first-admin")] - public async Task FirstAdmin( - [FromQuery] string? secret, - [FromQuery] string? email, - [FromQuery] string? name, - [FromQuery] string? username) - { - if (Gate(secret) is { } refusal) - return refusal; - if (string.IsNullOrWhiteSpace(email)) - return BadRequest("email query parameter is required"); - - var user = (string.IsNullOrWhiteSpace(username) ? email.Split('@')[0] : username) - .Trim().ToLowerInvariant(); - // The first global administrator: SEEDED with no learning path — they are setting the - // instance up, not learning it (UserOnboardingDefaults.PinnedPathsFor). The flag decides - // the seed only: this endpoint is re-runnable (see Step below), and on a user who already - // exists CreateUser keeps the pins as they are, so a re-run erases nothing they pinned since. - var request = new UserOnboardingRequest(user, email.Trim(), name ?? user) { IsPlatformBootstrap = true }; - - logger.LogInformation("Bootstrap: materialising first admin '{User}' ({Email})", user, email); - - // Idempotent step runner: "already exists" is success (a pre-existing static/seed node - // is fine — we still want the Admin grants). Any other error is a real failure. - // The request token rides along so a disconnected client does not leave the write running. - async Task Step(IObservable obs, string step) - { - try - { - await obs.FirstAsync() - .ObserveCompletion( - ex => logger.LogWarning(ex, - "Bootstrap: {Step} for '{User}' faulted after the step had already " - + "settled", step, user), - HttpContext.RequestAborted); - logger.LogInformation("Bootstrap: {Step} OK for '{User}'", step, user); - return true; - } - catch (Exception ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)) - { - logger.LogInformation("Bootstrap: {Step} already present for '{User}' — continuing", step, user); - return true; - } - catch (Exception ex) - { - logger.LogError(ex, "Bootstrap: {Step} FAILED for '{User}'", step, user); - return false; - } - } - - // Create the user node if missing (tolerate a pre-existing static/seed node), then grant - // self-Admin + platform-Admin so the resolved identity has Admin everywhere it needs it. - await Step(onboarding.CreateUser(request), "create-user"); - var selfOk = await Step(onboarding.GrantSelfAdmin(user), "self-admin"); - var platOk = await Step(onboarding.GrantPlatformAdmin(user), "platform-admin"); - - return selfOk && platOk - ? Ok($"OK: '{user}' ({email}) is platform admin. Sign in via Microsoft.") - : StatusCode(500, $"PARTIAL: self-admin={selfOk} platform-admin={platOk} — check portal logs."); - } - - /// - /// Secret-gated headless mint of a registration bootstrap key (mwr_…) — the same - /// operation the Instance grants tab performs, for environments with no drivable UI (scripted - /// scaffolds, the e2e stack). Same gate and lifecycle as : disabled - /// without Bootstrap:Secret, meant for one-shot operator use. The minted key's OWNER is - /// — instances registered with it land in that user's partition, - /// exactly as if they had minted it in the tab (never more access than that user's own - /// self-service registration would grant). - /// - [HttpGet("registration-key")] - [HttpPost("registration-key")] - public async Task RegistrationKey( - [FromQuery] string? secret, - [FromQuery] string? username, - [FromQuery] string? name, - [FromQuery] string? email, - [FromQuery] string? description, - [FromServices] IMessageHub hub) - { - if (Gate(secret) is { } refusal) - return refusal; - if (string.IsNullOrWhiteSpace(username)) - return BadRequest("username query parameter is required (the minted key's owner)"); - - var owner = username.Trim().ToLowerInvariant(); - var keys = hub.ServiceProvider.GetRequiredService(); - var accessService = hub.ServiceProvider.GetRequiredService(); - - // 🚨 The response body IS a one-time secret. Forbid caching anywhere on the path — a proxy - // or browser cache (or a prefetch of the GET form) would hand the raw key to whoever asks - // next. GET is kept because scripted scaffolds and `curl` in a k8s one-shot pod use it, so - // the no-store header is what makes that shape safe rather than the verb. - Response.Headers.CacheControl = "no-store, no-cache, must-revalidate, max-age=0"; - Response.Headers.Pragma = "no-cache"; - - logger.LogInformation("Bootstrap: minting registration key owned by '{Owner}'", owner); - - try - { - // Mint under the OWNER's identity — the key node lands in their partition, the same - // write the tab performs for a signed-in admin. The Bootstrap secret is the authority - // here, exactly as it is for first-admin. - // The request's cancellation token rides along: a disconnected client must not leave - // the mint running in the background. - var result = await Observable.Using( - () => accessService.SwitchAccessContext(new AccessContext - { - ObjectId = owner, - Name = string.IsNullOrWhiteSpace(name) ? owner : name.Trim(), - Email = email?.Trim() ?? "", - }), - _ => keys.Mint(owner, name?.Trim() ?? owner, email?.Trim() ?? "", - description?.Trim() ?? "bootstrap-minted")) - .FirstAsync() - .ObserveCompletion( - ex => logger.LogWarning(ex, - "Bootstrap: minting a registration key for '{Owner}' faulted after the " - + "response had already been sent", owner), - HttpContext.RequestAborted); - - // The raw key IS the response body — shown once, never stored, same contract as the tab. - return Ok(result!.RawKey); - } - catch (Exception ex) - { - logger.LogError(ex, "Bootstrap: minting registration key for '{Owner}' FAILED", owner); - return StatusCode(500, $"Minting failed: {ex.Message} — check portal logs."); - } - } - - /// - /// The Bootstrap:Secret door, applied identically at both endpoints — the refusal to - /// return, or null when the caller may proceed. - /// - /// The secret is taken from the X-Bootstrap-Secret HEADER first and from the query - /// string only as a fallback, because every proxy on the path logs a URL and this fleet ships - /// its ingress access logs to Loki. A caller using the query form is warned, by shape and never - /// by value: the secret itself is not logged here or anywhere else. - /// - /// The secret query parameter, or null. - private IActionResult? Gate(string? querySecret) - { - var header = Request.Headers[BootstrapSecretGate.HeaderName].ToString(); - var (presented, fromQuery) = BootstrapSecretGate.Present(header, querySecret); - switch (BootstrapSecretGate.Decide(config["Bootstrap:Secret"], presented)) - { - case BootstrapAuthResult.Disabled: - return NotFound(); // disabled unless a secret is configured - case BootstrapAuthResult.Accepted: - if (fromQuery) - logger.LogWarning( - "Bootstrap: the secret was presented in the QUERY STRING. Every proxy on " - + "the path logs the URL, and this fleet ships ingress access logs to Loki " - + "— present it in the {Header} header instead. Treat the value as exposed " - + "and rotate it.", BootstrapSecretGate.HeaderName); - return null; - default: - return Unauthorized("invalid or missing secret"); - } - } - -} diff --git a/memex/Memex.Portal.Shared/Authentication/BootstrapSecretGate.cs b/memex/Memex.Portal.Shared/Authentication/BootstrapSecretGate.cs deleted file mode 100644 index f52c189436..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/BootstrapSecretGate.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace Memex.Portal.Shared.Authentication; - -/// What the bootstrap gate decided about one caller. -public enum BootstrapAuthResult -{ - /// No Bootstrap:Secret is configured — the endpoints do not exist. - Disabled, - - /// A secret is configured and the caller presented none. - Missing, - - /// A secret is configured and the caller presented a different one. - Invalid, - - /// The caller presented the configured secret. - Accepted, -} - -/// -/// The Bootstrap:Secret check, as a PURE function — the one authority in front of -/// , which materialises the first platform administrator of an -/// instance that has none. -/// -/// Why it is extracted. The comparison was written twice in the controller, once per -/// endpoint, and both copies compared with string.Equals. Two copies of a security decision -/// is the shape that lets one of them get fixed alone — the reason -/// OnboardingGate.Decide exists as a pure function too — and this one now has properties -/// worth asserting without an HTTP context. -/// -/// 🚨 Constant time, deliberately. string.Equals returns at the first differing -/// character, so the time it takes is a function of how much of the secret the caller got right. -/// This endpoint is anonymous-reachable by design (there is no admin yet to authorise it), so that -/// oracle is offered to the whole internet, on the one door that grants platform admin. -/// compares the full length either way. -/// -/// 🚨 The secret belongs in a HEADER, not a query string. Every proxy on the path logs -/// a URL: this fleet's ingress ships its access logs to Loki, and a secret in a query parameter is -/// therefore a secret in a log store, readable by everyone who may read logs. The query form still -/// works — the e2e stack and scripted scaffolds use it — and the caller is warned. See -/// Doc/Architecture/FirstRunSetupOnAProvisionedInstance. -/// -public static class BootstrapSecretGate -{ - /// The header the secret should be presented in. - public const string HeaderName = "X-Bootstrap-Secret"; - - /// - /// Decides whether a caller may use the bootstrap endpoints. - /// - /// The Bootstrap:Secret value. Null, empty or whitespace ⇒ . - /// What the caller presented, from the header or the query. Null or empty ⇒ . - public static BootstrapAuthResult Decide(string? configured, string? presented) - { - if (string.IsNullOrWhiteSpace(configured)) - return BootstrapAuthResult.Disabled; - if (string.IsNullOrEmpty(presented)) - return BootstrapAuthResult.Missing; - return FixedTimeEquals(configured, presented) - ? BootstrapAuthResult.Accepted - : BootstrapAuthResult.Invalid; - } - - /// - /// Which of the two forms the caller used, for the warning. The header wins when both are - /// present, so a caller that has moved to the header is not scolded for a stale script that - /// still appends the query parameter. - /// - /// The header value, or null. - /// The query value, or null. - /// The value to check, and whether it came from the query string. - public static (string? Presented, bool FromQuery) Present(string? header, string? query) => - !string.IsNullOrEmpty(header) ? (header, false) : (query, !string.IsNullOrEmpty(query)); - - /// - /// Ordinal equality in time independent of WHERE the two differ. - /// - /// Length is not secret here — it leaks through the comparison of any implementation, and - /// a length mismatch is answered without a content comparison. What must not leak is the - /// position of the first differing byte, which is what a short-circuiting compare reveals. - /// - public static bool FixedTimeEquals(string a, string b) - { - var left = Encoding.UTF8.GetBytes(a); - var right = Encoding.UTF8.GetBytes(b); - return CryptographicOperations.FixedTimeEquals(left, right); - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/DevAuthController.cs b/memex/Memex.Portal.Shared/Authentication/DevAuthController.cs deleted file mode 100644 index 292797175f..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/DevAuthController.cs +++ /dev/null @@ -1,298 +0,0 @@ -using System.Reactive.Linq; -using System.Security.Claims; -using System.Text.Json; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; - -namespace Memex.Portal.Shared.Authentication; - -[ApiController] -[Route("dev")] -public class DevAuthController : ControllerBase -{ - private readonly IMeshService _meshQuery; - private readonly UserOnboardingService _onboarding; - private readonly IMessageHub _hub; - private readonly AccessService _accessService; - private readonly IConfiguration _configuration; - private readonly bool _devLoginEnabled; - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - - public DevAuthController(IMeshService meshQuery, UserOnboardingService onboarding, IMessageHub hub, AccessService accessService, IConfiguration configuration) - { - _meshQuery = meshQuery; - _onboarding = onboarding; - _hub = hub; - _accessService = accessService; - // DevLogin is forced OFF in prod (Memex.Portal.Distributed/Program.cs); this gate - // ensures the self-provisioning below can NEVER auto-create users in production. - // bool.TryParse, not == "true": a JSON boolean `true` binds as the string "True" - // (PascalCase), so the string comparison silently disabled self-provisioning for every - // appsettings that used the natural boolean form. - _devLoginEnabled = bool.TryParse(configuration["Authentication:EnableDevLogin"], out var enabled) && enabled; - _configuration = configuration; - } - - /// - /// Signs in as the specified person. - /// - [HttpPost("signin")] - public async Task Login([FromForm] string personId, [FromForm] string? returnUrl) - { - // 🚨 Resolve an EXISTING user AUTHORITATIVELY first — read the partition-root User node directly - // off its stream, NOT the eventually-consistent `nodeType:User` query. Under concurrent signin - // load the one-shot query's first emission is a stale/empty initial snapshot, so it MISSES a - // user who actually exists → DevLogin spuriously RE-provisions them → GrantSelfAdmin's _Access - // upsert races the owner hub and 500s the signin (CQRS violation; see CqrsAndContentAccess.md + - // /async). The single-node stream is write-consistent, so an existing user is found reliably and - // never re-provisioned. A genuine miss (timeout) falls through to the legacy query + provision. - // 🚨 Pre-auth read: there is NO caller identity on the signin request yet, so this read runs as - // Anonymous — and a user's partition-root node is private (NOT PublicRead). An EXISTING user - // (e.g. a reused dev DB) therefore throws UnauthorizedAccessException ("Anonymous lacks Read on - // '{personId}'"), which — uncaught — 500s the signin and blocks onboarding entirely. Resolving an - // existing user pre-auth is legitimate auth infrastructure, so impersonate System (same as - // ProvisionDevUser's grants). - // - // 🚨 RunAsSystem, NOT Observable.Using(ImpersonateAsSystem, …). Impersonation is an AsyncLocal - // store/restore pair, and Using splits the halves across threads: it opens the scope on the - // SUBSCRIBING thread and disposes it when the inner observable TERMINATES — for a cross-hub - // read, the owning hub's response thread. Nothing disposes it here, so this ASP.NET request - // thread goes back to the pool still holding system-security (#1790). RunAsSystem opens and - // closes inside one synchronous Subscribe and delivers notifications under the subscriber's - // own identity (#1444). - // Read the partition-root User node as System (pre-auth, private node). A brand-new user has - // none, so routing the read to '{personId}' surfaces "No node found" (a DeliveryFailureException) - // through the Rx OnError channel — NOT as a throw a try/catch around the await would see. Handle it - // REACTIVELY: a .Catch that maps the not-found (and the 5s timeout miss) to NO emission, so - // FirstOrDefaultAsync yields null and we fall through to the fallback query + DevLogin - // self-provisioning. Any OTHER error is rethrown (never silently swallowed). - var node = await _accessService.RunAsSystem( - () => _hub.GetMeshNodeStream(personId) - .Where(n => n is not null - && string.Equals(n.NodeType, "User", StringComparison.OrdinalIgnoreCase) - && n.Content is not null) - .Take(1) - .Timeout(TimeSpan.FromSeconds(5))) - .Catch((Exception ex) => - ex is TimeoutException || ex.GetType().Name == "DeliveryFailureException" - ? Observable.Empty() - : Observable.Throw(ex)) - .FirstOrDefaultAsync() - .Await(HttpContext.RequestAborted); - - // Fallback for the legacy `User/{id}` node shape (monolith / AddSampleUsers) — query nodeType:User - // and match by id (case-insensitive) or the legacy path. Only reached when the authoritative read - // above did not find a partition-root User node. - if (node is null) - { - // Same pre-auth rationale: under Anonymous the nodeType:User query is RLS-filtered to nothing - // (private user partitions), so an existing user is MISSED → spurious re-provision. Read as System. - var change = await _accessService.RunAsSystem( - () => _meshQuery.Query(MeshQueryRequest.FromQuery("nodeType:User"))) - .FirstAsync() - .Await(HttpContext.RequestAborted); - node = change.Items.FirstOrDefault(n => - string.Equals(n.Id, personId, StringComparison.OrdinalIgnoreCase) - || string.Equals(n.Path, $"User/{personId}", StringComparison.OrdinalIgnoreCase)); - } - - // DevLogin self-provisions (dev only — gated on EnableDevLogin, forced off in prod). - // A personId with no User node yet is onboarded on first signin via the SAME reactive - // dual-write the Entra onboarding flow runs (UserOnboardingService.CreateUser + - // GrantSelfAdmin), writing the partition-root `{id}` User node the lookup above - // matches. So a throwaway DevLogin portal (the e2e portal) works for ANY user with no - // separate seed step; without it DevLogin 400s on a fresh DB that seeds no users. - if ((node?.NodeType != "User" || node.Content == null) && _devLoginEnabled) - node = await ProvisionDevUser(personId); - - if (node?.NodeType != "User" || node.Content == null) - { - return BadRequest("Person not found"); - } - - // PLATFORM admin is opt-in and explicit, never a side effect of signing in: only the - // usernames named in `Authentication:DevAdminUsers` get it, and everyone else stays an - // ordinary user — which is what lets the e2e suite prove the purchase funnel at all. - // - // 🚨 Applied on EVERY signin, not inside ProvisionDevUser. That branch runs only when the - // User node is MISSING, so on any mesh whose database already holds the user — a re-boot - // reusing the volume, a seeded user, the second run of a suite — the grant would never be - // written and the configured admin would silently come back as an ordinary user. That is - // exactly how it failed the first time: a fresh identity got the grant, `e2e-admin` did - // not, and the bootstrap hung on an admin-gated Plugin Catalog it could no longer read. - // GrantPlatformAdmin is an idempotent create-or-update, so re-running it is free. - if (_devLoginEnabled && IsConfiguredDevAdmin(node.Id)) - await _onboarding.GrantPlatformAdmin(node.Id).FirstAsync().Await(HttpContext.RequestAborted); - - var person = ExtractPersonInfo(node.Id, node.Content); - if (person == null) - { - return BadRequest("Could not extract person info"); - } - - // Create claims: username is the node ID, email in content. - // 🚨 preferred_username MUST be the username (node Id), NOT the email. - // UserContextMiddleware.ExtractUserContext takes ObjectId from - // preferred_username first; if that's the email, every downstream - // route targets `` instead of the user's partition and the - // portal renders "No node found at 'rbuergi@systemorph.com'". - // ApiTokenAuthenticationHandler already puts the username here — keep - // the dev login consistent so the user's partition (path = node Id) - // is the resolved home. - var email = person.Email ?? ""; - var username = node.Id; - var claims = new List - { - new(ClaimTypes.NameIdentifier, username), - new(ClaimTypes.Name, username), - new("name", username), - new("preferred_username", username), - }; - - if (!string.IsNullOrEmpty(email)) - { - claims.Add(new Claim(ClaimTypes.Email, email)); - claims.Add(new Claim("email", email)); - } - - if (!string.IsNullOrEmpty(person.Role)) - { - claims.Add(new Claim(ClaimTypes.Role, person.Role)); - } - - // The step-up ladder reads the provider off the session (Doc/Architecture/ApprovalStepUp). - claims.AddRange(StepUpClaims.ForSession("Dev", Array.Empty(), DateTimeOffset.UtcNow)); - - var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); - var principal = new ClaimsPrincipal(identity); - - await HttpContext.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - principal, - new AuthenticationProperties - { - IsPersistent = true, - ExpiresUtc = DateTimeOffset.UtcNow.AddDays(7) - }); - - return Redirect(ReturnUrlPolicy.Sanitize(returnUrl)); - } - - /// - /// Onboards a brand-new DevLogin user on first signin (dev only): creates the per-user - /// partition-root User node and grants self-Admin, reusing the same reactive - /// dual-write as the Entra onboarding flow. Returns - /// the partition-root User node the sign-in claims are then built from. The grants run as - /// System (the brand-new partition has no caller identity yet — see UserOnboardingService). - /// - private async Task ProvisionDevUser(string personId) - { - // 🚨 LOWERCASE the node id — the SAME mapping BootstrapController applies. DevLogin used to - // provision the node id verbatim ('Roland') while bootstrap lowercased ('roland'): two - // id-derivations for the same person, and the circuit's email-local-part heuristic - // (CircuitAccessHandler.UsernameFromEmail) is aligned with the lowercase convention. - var username = personId.Trim().ToLowerInvariant(); - // 🚨 A dev-login user is an ORDINARY USER. This used to pass `Role: Role.Admin.Id`, which - // made EVERY throwaway dev identity a claim-Admin — and a claim role is added in - // PermissionEvaluator.ComputeRoleState AFTER the per-scope deny subtraction, so no gating - // deny could ever remove it. The visible effect was a paywall BYPASS that looked like a - // gating bug: a signed-in user saw the CHF 900 checkout (entitlement is a record, and they - // had none) yet still read the gated lessons (read came from the Admin claim). Anonymous - // was correctly blocked, which is exactly why it survived — the two are denied by - // different mechanisms. See AGENTS.md, "the access rule for a system-synced space". - var request = new UserOnboardingRequest( - Username: username, - Email: $"{username}@dev.local", - FullName: personId); - var userNode = await _onboarding.CreateUser(request).FirstAsync().Await(HttpContext.RequestAborted); - // Self-Admin on the user's OWN partition only — they must be able to write their own home - // (installed course copies, skills, agents). This is scoped to `{user}/_Access` with - // MainNode = {user}; it confers nothing anywhere else. - await _onboarding.GrantSelfAdmin(username).FirstAsync().Await(HttpContext.RequestAborted); - return userNode; - } - - /// - /// Whether this dev-login username is declared a PLATFORM admin in configuration - /// (Authentication:DevAdminUsers, a comma-separated list). Empty/unset → nobody, which - /// is the safe default: a dev portal that hands out admin by default cannot be used to test any - /// access rule, because every identity passes every gate. - /// - private bool IsConfiguredDevAdmin(string username) => - IsDevAdmin(_configuration["Authentication:DevAdminUsers"], username); - - /// - /// Pure form of the rule, so it is unit-testable without a controller, a mesh or a request. - /// Empty/unset list → FALSE for everyone; that default is the security property worth pinning. - /// - internal static bool IsDevAdmin(string? configuredList, string username) => - !string.IsNullOrWhiteSpace(username) - && (configuredList ?? string.Empty) - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Any(u => string.Equals(u, username, StringComparison.OrdinalIgnoreCase)); - - /// - /// Signs out the current user. - /// - [HttpPost("logout")] - [HttpGet("logout")] - public async Task Logout([FromQuery] string? returnUrl) - { - await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - return Redirect(ReturnUrlPolicy.Sanitize(returnUrl)); - } - - private static PersonInfo? ExtractPersonInfo(string id, object content) - { - try - { - // Content is typically a JsonElement or a dynamic object - if (content is JsonElement jsonElement) - { - return new PersonInfo - { - Id = id, - Name = jsonElement.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? id : id, - Email = jsonElement.TryGetProperty("email", out var emailProp) ? emailProp.GetString() : null, - Role = jsonElement.TryGetProperty("role", out var roleProp) ? roleProp.GetString() : null, - Avatar = jsonElement.TryGetProperty("avatar", out var avatarProp) ? avatarProp.GetString() : null - }; - } - - // Try to serialize and deserialize to handle other object types - var json = JsonSerializer.Serialize(content, JsonOptions); - var parsed = JsonSerializer.Deserialize(json); - - return new PersonInfo - { - Id = id, - Name = parsed.TryGetProperty("name", out var nameProp2) ? nameProp2.GetString() ?? id : id, - Email = parsed.TryGetProperty("email", out var emailProp2) ? emailProp2.GetString() : null, - Role = parsed.TryGetProperty("role", out var roleProp2) ? roleProp2.GetString() : null, - Avatar = parsed.TryGetProperty("avatar", out var avatarProp2) ? avatarProp2.GetString() : null - }; - } - catch - { - return new PersonInfo { Id = id, Name = id }; - } - } -} - -/// -/// Simplified person info for the login UI. -/// -public record PersonInfo -{ - public string Id { get; init; } = ""; - public string Name { get; init; } = ""; - public string? Email { get; init; } - public string? Role { get; init; } - public string? Avatar { get; init; } -} diff --git a/memex/Memex.Portal.Shared/Authentication/EaConsentController.cs b/memex/Memex.Portal.Shared/Authentication/EaConsentController.cs deleted file mode 100644 index 454373f61f..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/EaConsentController.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System.Collections.Immutable; -using System.Reactive.Linq; -using MeshWeaver.Mesh; // IEaGraphAuth — the SDK-free seam, now in the mesh contract -using MeshWeaver.Messaging; // AccessService, ObserveCompletion -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Drives the Executive Assistant's per-user, just-in-time Microsoft consent. The EA tool hands the -/// user a link to /auth/ea/connect the first time it needs their mailbox/calendar; that redirects to -/// Microsoft's consent screen for the EA's delegated Graph scopes, and the /auth/ea/callback -/// exchanges the code and stores the user's encrypted refresh token. The acting user is taken from the -/// authenticated principal at both steps — the OAuth state only carries the return URL. -/// -[Authorize] -[Route(BasePath)] -public sealed class EaConsentController( - IEaGraphAuth ea, AccessService access, ILogger logger) : ControllerBase -{ - /// Route prefix for this controller. Used by the itself. - public const string BasePath = "auth/ea"; - - /// Action segment that starts the consent flow. - public const string ConnectAction = "connect"; - - /// Action segment Microsoft redirects back to with the authorization code. - public const string CallbackAction = "callback"; - - /// - /// The absolute path that starts the delegated-Graph consent flow: /auth/ea/connect. - /// - /// This constant is the SINGLE definition of that route — the - /// and above are built from the same parts, so the endpoint and - /// everything that links to it cannot drift. Every consumer (the send-document dialog via - /// , the Executive Assistant's - /// chat consent link) reads it from here rather than re-typing the path; a rename now breaks - /// compilation instead of shipping a button that 404s. - /// - /// 🚨 Navigating here from in-app UI REQUIRES a full browser load - /// (ctx.NavigateTo(url, forceLoad: true)). This is a server-side MVC endpoint, so a - /// client-side Blazor navigation never reaches it — the router matches its own catch-all page - /// and reports "does not match any registered address pattern". - /// - public const string ConnectPath = "/" + BasePath + "/" + ConnectAction; - - /// Query-string parameter carrying where to return after consent. - public const string ReturnUrlParameter = "returnUrl"; - - /// - /// The prefix of an OAuth state that carries a mailbox account as well as the return URL: - /// acct:{account}:{escaped return URL}. An escaped URL contains no : and an - /// account id contains none either, so the split is unambiguous; a state WITHOUT the prefix is the - /// pre-existing shape (the escaped return URL alone) and means the primary mailbox, so a consent - /// in flight across a deploy still completes. - /// - internal const string AccountStatePrefix = "acct:"; - - /// Builds the OAuth state for and an already-sanitised return URL. - internal static string StateFor(string account, string safeReturnUrl) => - EaMailboxes.IsPrimary(account) - ? Uri.EscapeDataString(safeReturnUrl) - : AccountStatePrefix + account + ":" + Uri.EscapeDataString(safeReturnUrl); - - /// - /// Splits a state that came back from the IdP into its account and its (still UNTRUSTED) return - /// URL. An account that is not exactly or a well-formed added id - /// reads as the primary — the state left our process, so it is never trusted to name a path. - /// - internal static (string Account, string? ReturnUrl) ParseState(string? state) - { - if (string.IsNullOrEmpty(state)) return (EaMailboxes.Primary, null); - if (!state.StartsWith(AccountStatePrefix, StringComparison.Ordinal)) - return (EaMailboxes.Primary, Uri.UnescapeDataString(state)); - var rest = state[AccountStatePrefix.Length..]; - var colon = rest.IndexOf(':'); - var account = colon < 0 ? rest : rest[..colon]; - var url = colon < 0 ? null : Uri.UnescapeDataString(rest[(colon + 1)..]); - return (EaMailboxes.IsValidRequest(account) && !EaMailboxes.IsPrimary(account) ? account : EaMailboxes.Primary, url); - } - - private string CallbackUri => $"{Request.Scheme}://{Request.Host}/{BasePath}/{CallbackAction}"; - - /// - /// Only ever redirect to a local path. Delegates to . - /// - /// 🚨 This was a correct hand-written copy of the rule — it already rejected both - /// //host and /\host. It is delegated anyway, because being right was not the - /// problem: this assembly had FIVE implementations of one rule and only two were right - /// (#2302). Correct duplicates are what make an incorrect one look normal. - /// - internal static string SafeReturnUrl(string? candidate) => ReturnUrlPolicy.Sanitize(candidate); - - /// - /// Starts (or skips) the delegated-Graph consent for the signed-in user. - /// - /// 🚨 THE Task LIVES HERE AND NOWHERE ELSE (#3433). An MVC action is - /// genuinely Task-shaped — that signature is ASP.NET's, not ours — so this is where the ONE - /// bridge from the reactive seam belongs: - /// ObserveCompletion, which completes with RunContinuationsAsynchronously so the - /// request never resumes on the thread that signalled. What it must NOT do is push the Task - /// shape back onto the seam, whose OTHER consumer is the Executive Assistant's agent tools — - /// they run on a hub, where an await parks the turn that has to deliver the reply to the - /// credential read. - /// - /// Only skips the dialog. - /// — the read did not answer — goes THROUGH consent: - /// re-consenting a live grant is harmless (Microsoft re-issues it), whereas skipping it on a - /// guess would strand a user who genuinely never connected. The guess is logged, never - /// silent. - /// - /// Where to send the user afterwards; sanitised before either use. - /// Runs the full consent even for a connected user (scope additions, rotation). - /// Which mailbox: absent for the primary, new to add ANOTHER mailbox - /// (always runs the consent, with Microsoft's account picker), or an added mailbox's id to - /// reconnect it (with its address as the login hint). - /// Cancels the wait on the connection read, not the read itself. - [HttpGet(ConnectAction)] - public async Task Connect( - [FromQuery] string? returnUrl = null, [FromQuery] bool force = false, - [FromQuery(Name = EaMailboxes.AccountParameter)] string? account = null, CancellationToken ct = default) - { - if (!ea.IsConfigured) return BadRequest("The Executive Assistant Graph integration is not configured."); - var userId = access.Context?.ObjectId; - if (string.IsNullOrEmpty(userId)) return Unauthorized(); - if (!EaMailboxes.IsValidRequest(account)) return BadRequest("Unknown mailbox account."); - if (!EaMailboxes.IsPrimary(account)) - return await ConnectMailbox(account!, SafeReturnUrl(returnUrl), userId, ct); - // Already connected → nothing to consent: bounce straight back to the caller instead of - // sending a user whose grant is stored through Microsoft again — visiting the connect link - // twice used to re-prompt every time and read as "my consent is not saved". ?force=true - // still runs the authorize flow deliberately and stores a new grant (credential rotation, - // a revoked grant the stored token hides). A SCOPE ADDITION needs no - // force: EaGraphAuth classifies a grant consented for a smaller scope set than the build's - // as NotConnected, so this fast path is not taken for it — which is what ended the - // 2026-09-10 loop where the read scopes had landed, the reconnect link bounced a - // "connected" user straight back, and every Teams call kept failing on the refused refresh. - // Sanitised BEFORE either use: the fast path redirects to it directly, and the consent - // path round-trips it through the IdP as `state` and redirects to it on the way back — - // so an unsanitised value is an open redirect on both routes, not just the visible one. - var safeReturnUrl = SafeReturnUrl(returnUrl); - // The ONE Task bridge, at this action's own edge — see the remarks above for why it may - // not move onto the seam. - var connection = await ea.GetConnection(userId) - .ObserveCompletion( - ex => logger.LogWarning(ex, - "EA connect: the connection read for {User} faulted after the answer had already " - + "settled", userId), - ct); - if (!force && connection is { IsConnected: true }) - return Redirect(safeReturnUrl); - if (!force && connection is { Connection: EaConnection.Undetermined }) - logger.LogWarning( - "EA connect: could not determine whether {User} is already connected ({Reason}) — " - + "running the consent flow rather than guessing", userId, connection.Diagnostic); - return Redirect(ea.BuildConsentUrl(Uri.EscapeDataString(safeReturnUrl), CallbackUri)); - } - - /// - /// Starts the consent for a mailbox other than the primary. Adding one ALWAYS runs the dialog, - /// with prompt=select_account so the person can pick a different Microsoft account; a - /// reconnect of an added mailbox runs it with that mailbox's address as the login hint. - /// - private async Task ConnectMailbox(string account, string safeReturnUrl, string userId, CancellationToken ct) - { - var adding = string.Equals(account, EaMailboxes.New, StringComparison.OrdinalIgnoreCase); - string? hint = null; - if (!adding) - { - // The hint is a convenience: a list that cannot be read only means Microsoft asks which - // account, never that the reconnect is refused. - var mailboxes = await ea.GetMailboxes(userId) - .Catch((Exception ex) => - { - logger.LogWarning(ex, "EA connect: could not list {User}'s mailboxes for the login hint", userId); - return Observable.Return(ImmutableList.Empty); - }) - .ObserveCompletion( - ex => logger.LogWarning(ex, "EA connect: the mailbox list for {User} faulted after the answer had settled", userId), - ct); - hint = mailboxes?.FirstOrDefault(m => string.Equals(m.AccountId, account, StringComparison.Ordinal))?.Address; - } - // Sanitised again at the sink (idempotent): the policy is applied where the value is used, - // so this redirect is checkable on its own (RedirectSinksUseOnePolicyGuard). - var state = StateFor(adding ? EaMailboxes.New : account, SafeReturnUrl(safeReturnUrl)); - return Redirect(ea.BuildConsentUrl(state, CallbackUri, hint, selectAccount: adding)); - } - - /// - /// Completes the consent and tells the caller how it ended: the return URL carries - /// eaConnect=connected, or eaConnect=failed&reason=microsoft|exchange. - /// A silent redirect made a refused consent look exactly like success (#6082). - /// - [HttpGet(CallbackAction)] - public async Task Callback( - [FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, - [FromQuery(Name = "error_description")] string? errorDescription = null, CancellationToken ct = default) - { - // `state` is whatever came back from the IdP — treated as untrusted input, exactly like - // the query parameter it originated from. Re-sanitised here rather than trusted because - // it left our process in between. - var (account, untrustedReturn) = ParseState(state); - var returnUrl = SafeReturnUrl(untrustedReturn); - var userId = access.Context?.ObjectId; - if (string.IsNullOrEmpty(userId)) return Unauthorized(); - - if (!string.IsNullOrEmpty(error) || string.IsNullOrEmpty(code)) - { - // error_description carries the AADSTS code that names the cause; error alone does not. - logger.LogWarning("EA consent callback for {User} returned error '{Error}': {Description}", - userId, error, errorDescription); - return Redirect(WithOutcome(returnUrl, "eaConnect=failed&reason=microsoft")); - } - - // Same single bridge as Connect: the Task is the MVC action's, not the seam's. - var ok = await (EaMailboxes.IsPrimary(account) - ? ea.ExchangeAndStore(code, CallbackUri, userId) - : ea.ExchangeAndStore(code, CallbackUri, userId, account)) - .ObserveCompletion( - ex => logger.LogWarning(ex, - "EA consent callback: the code exchange for {User} faulted after the result had " - + "already settled", userId), - ct); - logger.LogInformation("EA consent for {User}: {Result}", userId, ok ? "connected" : "failed"); - return Redirect(WithOutcome(returnUrl, ok ? "eaConnect=connected" : "eaConnect=failed&reason=exchange")); - } - - /// Appends to the query of an already-sanitised local URL, before any fragment. - internal static string WithOutcome(string returnUrl, string outcome) - { - var hash = returnUrl.IndexOf('#'); - var path = hash < 0 ? returnUrl : returnUrl[..hash]; - var fragment = hash < 0 ? "" : returnUrl[hash..]; - return path + (path.Contains('?') ? "&" : "?") + outcome + fragment; - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/EaGraphAuth.cs b/memex/Memex.Portal.Shared/Authentication/EaGraphAuth.cs deleted file mode 100644 index 1851d6ac47..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/EaGraphAuth.cs +++ /dev/null @@ -1,874 +0,0 @@ -using MeshWeaver.AI; // IProviderKeyProtector & co keep their ORIGINAL namespace in MeshWeaver.Mesh.Contract (#2398 forwarders) -using MeshWeaver.Mesh.Security; -using System.Collections.Immutable; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using MeshWeaver.Hosting.AspNetCore.Portal; // PortalApplication -using MeshWeaver.Data; // IWorkspace.GetMeshNodeStream -using MeshWeaver.Graph.Configuration; // EaCredentialNodeType -using MeshWeaver.Mesh; // EaCredential, EaGraphAccess, MeshNode -using MeshWeaver.Mesh.Services; // IMeshService -using MeshWeaver.Mesh.Threading; // IIoPool, IoPoolNames, IoPoolRegistry -using MeshWeaver.Messaging; // AccessService, RunAsSystem -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Per-user, just-in-time delegated Microsoft Graph access for the Executive Assistant. The user -/// consents to the EA accessing their own mailbox/calendar only when they first use the tool; we -/// exchange the auth code for tokens, store the refresh token encrypted as an -/// node, and mint short-lived access tokens from it on demand. No standing -/// application-wide Graph permission is used. -/// -/// 🚨 Reactive end to end — #3433, and the reason is not style. An earlier revision of -/// this class was async/await/Task<T> throughout, and its own class -/// comment recorded the reason as a design note: "this sits at the OAuth/HTTP boundary (called -/// from the consent controller and the async EA tool), so async is appropriate here". The second -/// half of that sentence was the bug. The consent controller IS an HTTP boundary; the EA tool is -/// not — it runs inside an agent round, on a hub. Awaiting a mesh read from a hub turn parks -/// the single-threaded action block that has to process the reply to that very read, so the read -/// cannot complete, its Timeout fires, and a blanket catch returned the same value as -/// "this user never connected". A connected user was shown the re-consent link. -/// -/// So: every entry point returns and is COLD — nothing happens -/// until Subscribe. The Entra token POST, the one genuinely-async leaf, goes through -/// and never Observable.FromAsync. The consent controller keeps its -/// Task shape at its OWN edge (one ObserveCompletion bridge per action), which is what -/// it always should have done instead of forcing the shape onto the hub-facing path. -/// -/// 🚨 Every failure to FIND OUT is , never -/// . The three sites that used to collapse into "never -/// connected" — a blanket catch returning (null, null), a bare -/// catch { return null; } around the deserialization, and the _ => null arm of a -/// hand-rolled content shape test — are gone: the read is classified, the deserialization is -/// ContentAs<T>, and there is no shape switch left to have an arm. A token endpoint -/// that DID answer, and answered that the grant itself is dead (), is -/// the one refusal that is a finding rather than a failure to find out — it stamps the credential -/// and reads as NotConnected (MeshWeaver.Plugins#1615). -/// -/// Azure setup (one-time): on the sign-in app registration add the delegated scopes -/// Mail.ReadWrite Mail.Send Calendars.ReadWrite Team.ReadBasic.All Channel.ReadBasic.All -/// ChannelMessage.Read.All Chat.Read offline_access and the redirect URI -/// {BaseUrl}/auth/ea/callback. The user's first use triggers the consent screen; -/// ChannelMessage.Read.All needs a tenant admin's consent once. -/// -public sealed class EaGraphAuth( - IServiceProvider rootServices, - IConfiguration configuration, - IProviderKeyProtector protector, - HttpClient http, - ILogger? logger = null) : IEaGraphAuth -{ - /// - /// Delegated scopes the EA needs (space-separated, Graph v2 form). - /// - /// Teams (read 2026-09-09, send 2026-09-10). Team.ReadBasic.All lists the - /// user's teams, Channel.ReadBasic.All a team's channels, ChannelMessage.Read.All - /// a channel's messages, Chat.Read the user's chats — the surface the Executive - /// Assistant's ListTeams / ListChannels / ReadChannelMessages / - /// ListChats / ReadChat tools (MeshWeaver.Plugins) call. - /// ChannelMessage.Send and ChatMessage.Send let the same grant POST to a channel - /// or a chat as the user — Teams has no draft state, so those posts are immediate and - /// irreversible, which is why the plugin hands the posting tools to the model ONLY where the - /// deployment says so (Teams:AgentSend=Send, mirroring Email:AgentSend). The - /// scope is consented here regardless: consent is per grant, and a user should not have to - /// reconnect a second time on the day the deployment turns posting on. - /// - /// Consent. The v2 authorize endpoint consents to whatever scope asks for - /// (dynamic consent), so these need not be pre-listed on the app registration — but - /// ChannelMessage.Read.All is admin-restricted: a non-admin user sees "Need admin - /// approval" until a tenant admin has consented once (Entra → Enterprise applications → the - /// sign-in app → Permissions → Grant admin consent). 🚨 Widening this string invalidates - /// every stored grant: Entra refuses to redeem a refresh token for scopes the user never - /// consented to (400 invalid_grant), so a grant minted under the previous string answers - /// nothing until the user reconnects. That is why - /// compares the stored with THIS string and reports a mismatch - /// as — the one state that hands the user the consent - /// link AND makes /auth/ea/connect run the dialog instead of bouncing a "connected" user - /// straight back (measured 2026-09-10: the read scopes landed, the user clicked the link, the - /// controller saw a stored credential and skipped consent, and every Teams call kept failing on - /// the refused refresh). - /// - /// Tenant boundary. A grant is minted by the user's HOME tenant. A team the user - /// reaches as a guest of another company's tenant is not visible on it — /me/joinedTeams - /// omits guest teams — and no scope here changes that; see the /teams skill. - /// - public const string Scopes = - "https://graph.microsoft.com/Mail.ReadWrite https://graph.microsoft.com/Mail.Send " + - "https://graph.microsoft.com/Calendars.ReadWrite " + - "https://graph.microsoft.com/Team.ReadBasic.All https://graph.microsoft.com/Channel.ReadBasic.All " + - "https://graph.microsoft.com/ChannelMessage.Read.All https://graph.microsoft.com/Chat.Read " + - "https://graph.microsoft.com/ChannelMessage.Send https://graph.microsoft.com/ChatMessage.Send " + - "offline_access"; - - /// - /// The OpenID scopes asked for ON TOP of at consent and code exchange only, - /// so the token response carries an id token naming the address the person signed in as — the - /// mailbox's label when several are connected. 🚨 Deliberately NOT part of : - /// that string is what a stored grant is compared with, and widening it would read every grant - /// stored so far as "consented for an earlier scope set". The refresh redemption keeps asking - /// for alone, a subset of what was consented. - /// - internal const string IdentityScopes = "openid profile email"; - - /// - /// How long a single credential-node read may take before the answer becomes - /// . - /// - /// 🚨 This is a BOUND, not a knob to widen (#3433 says so explicitly). It exists as a - /// settable property for exactly one reason: a test needs to reach the undetermined branch - /// deterministically, the same way ApiTokenService.ValidationReadTimeout and - /// UserRoleResolver's budget parameter are reachable. Production never sets it. - /// If this fires in production the read is WEDGED — find what is not completing. - /// - internal TimeSpan CredentialReadTimeout { get; init; } = TimeSpan.FromSeconds(10); - - // The tenant and the authority are composed by MicrosoftTenant, which treats blank as UNSET and - // refuses a value that cannot form a single authority segment — a configMap / env var cannot - // carry null, only "", and "" is not a tenant (it yields `login.microsoftonline.com//oauth2/ - // v2.0`, the URL every memex-cloud sign-in 500-ed on — #2621). The same value is validated at - // boot by MemexConfiguration, so a malformed one is named at startup rather than here. - private string? ClientId => configuration["Authentication:Microsoft:ClientId"]; - private string? ClientSecret => configuration["Authentication:Microsoft:ClientSecret"]; - private string Authority => - MicrosoftTenant.Authority(configuration[MicrosoftTenant.ConfigurationKey], "oauth2/v2.0"); - - /// True when the sign-in app credentials needed for the delegated flow are configured. - /// - // The route stays defined ONCE, by the controller that registers it; this only - // surfaces it through the seam so the module-side EA tools can link to it. - public string ConnectPath => EaConsentController.ConnectPath; - - public bool IsConfigured => !string.IsNullOrEmpty(ClientId) && !string.IsNullOrEmpty(ClientSecret); - - /// - /// The Microsoft consent/authorize URL to send the user to (incremental consent). - /// - /// 🚨 No prompt=consent. It forced the consent dialog on every request, and in a - /// tenant where users may not consent to these scopes that dialog is always "request admin - /// approval" — also after an admin granted tenant-wide consent, so the user looped (#6082). - /// Microsoft prompts by itself when consent is actually missing. - /// - public string BuildConsentUrl(string state, string redirectUri) => - BuildConsentUrl(state, redirectUri, loginHint: null, selectAccount: false); - - /// - /// - /// prompt=select_account is the ONE prompt sent, and only to add another mailbox: it shows - /// the account picker without forcing the consent dialog, which is what prompt=consent did - /// and why it was removed (#6082). - /// - public string BuildConsentUrl(string state, string redirectUri, string? loginHint, bool selectAccount) => - $"{Authority}/authorize?client_id={Uri.EscapeDataString(ClientId ?? "")}" + - "&response_type=code&response_mode=query" + - $"&redirect_uri={Uri.EscapeDataString(redirectUri)}" + - $"&scope={Uri.EscapeDataString(Scopes + " " + IdentityScopes)}" + - (selectAccount ? "&prompt=select_account" : "") + - (string.IsNullOrWhiteSpace(loginHint) ? "" : $"&login_hint={Uri.EscapeDataString(loginHint.Trim())}") + - $"&state={Uri.EscapeDataString(state)}"; - - /// - public IObservable ExchangeAndStore(string code, string redirectUri, string userObjectId) => - ExchangeAndStore(code, redirectUri, userObjectId, EaMailboxes.Primary); - - /// - public IObservable ExchangeAndStore(string code, string redirectUri, string userObjectId, string? account) - { - if (!IsConfigured) return Observable.Return(false); - if (!EaMailboxes.IsValidRequest(account)) - { - logger?.LogWarning("EaGraphAuth: refused a code exchange for a malformed account id"); - return Observable.Return(false); - } - - return PostToken(new Dictionary - { - ["client_id"] = ClientId!, - ["client_secret"] = ClientSecret!, - ["grant_type"] = "authorization_code", - ["code"] = code, - ["redirect_uri"] = redirectUri, - ["scope"] = Scopes + " " + IdentityScopes - }) - .SelectMany(answer => - { - if (!answer.IsSuccess) - { - logger?.LogWarning("EaGraphAuth: token endpoint returned {Status} to the code exchange", answer.StatusCode); - return Observable.Return(false); - } - string? refresh; - string? address; - try - { - // Synchronous parse of a value already in hand — the try covers this statement - // and nothing in the stream around it (/async Rule 1b). - refresh = RefreshTokenIn(answer.Body); - address = SignedInAddress(answer.Body); - } - catch (JsonException ex) - { - logger?.LogWarning(ex, "EaGraphAuth: the code-exchange response was not JSON"); - return Observable.Return(false); - } - if (string.IsNullOrEmpty(refresh)) - { - logger?.LogWarning("EaGraphAuth: no refresh_token in code exchange"); - return Observable.Return(false); - } - return TargetAccount(userObjectId, account, address) - .SelectMany(target => Store(userObjectId, target, refresh!, address)) - .Select(_ => true); - }); - } - - /// - /// Which mailbox a consent writes. The primary and an added id write themselves. A NEW consent - /// writes the mailbox already connected under the same address (a reconnect, never a duplicate), - /// else the primary when the person has none yet, else a fresh id — so no id is ever reused. - /// A list that cannot be read falls back to a fresh id: a duplicate the person can remove beats - /// a consent that was redeemed and then thrown away. - /// - private IObservable TargetAccount(string userObjectId, string? account, string? address) - { - if (EaMailboxes.IsPrimary(account)) return Observable.Return(EaMailboxes.Primary); - if (EaMailboxes.IsAddedAccountId(account)) return Observable.Return(account!); - return GetMailboxes(userObjectId) - .Select(list => ChooseNewTarget(list, address)) - .Catch((Exception ex) => - { - logger?.LogWarning(ex, "EaGraphAuth: could not list {User}'s mailboxes while adding one; storing it as a new mailbox", userObjectId); - return Observable.Return(EaMailboxes.NewAccountId()); - }); - } - - /// The pure half of , for a NEW consent. - internal static string ChooseNewTarget(IReadOnlyList existing, string? address) - { - if (!string.IsNullOrWhiteSpace(address) - && existing.FirstOrDefault(m => string.Equals(m.Address, address, StringComparison.OrdinalIgnoreCase)) is { } same) - return same.AccountId; - return existing.Any(m => m.IsPrimary) ? EaMailboxes.NewAccountId() : EaMailboxes.Primary; - } - - /// - public IObservable GetAccessToken(string userObjectId) => - GetAccessToken(userObjectId, EaMailboxes.Primary); - - /// - public IObservable GetAccessToken(string userObjectId, string? account) - { - if (!IsConfigured) return Observable.Return(NotConfigured); - if (!EaMailboxes.IsPrimary(account) && !EaMailboxes.IsAddedAccountId(account)) - return Observable.Return(EaGraphAccess.NotConnected($"'{account}' is not a mailbox account id")); - var accountId = EaMailboxes.IsPrimary(account) ? EaMailboxes.Primary : account!; - - return Load(PathFor(userObjectId, accountId)) - .SelectMany(read => - { - // A read that did not answer stays unanswered. It is NOT "no token": minting is - // impossible either way, but the two are different things to tell a user, and - // conflating them is the whole of #3433. - if (read.Access.Connection != EaConnection.Connected) - return Observable.Return(read.Access); - - var refresh = protector.Unprotect(read.Credential?.RefreshTokenEncrypted); - if (string.IsNullOrEmpty(refresh)) - return Observable.Return(EaGraphAccess.NotConnected( - "the stored credential carries no refresh token")); - - return PostToken(new Dictionary - { - ["client_id"] = ClientId!, - ["client_secret"] = ClientSecret!, - ["grant_type"] = "refresh_token", - ["refresh_token"] = refresh!, - ["scope"] = Scopes - }) - .SelectMany(answer => answer.IsSuccess - ? MintFrom(answer.Body, userObjectId, accountId, read.Credential?.Address) - : Refused(userObjectId, accountId, read.Credential!, TokenRefusal.Of(answer.StatusCode, answer.Body))) - // No HTTP answer at all — DNS, a reset, the pool's timeout — is a failure to - // find out, the same as a 503: Undetermined, never a fault out of the tool. - .Catch((Exception ex) => - { - logger?.LogWarning(ex, "EaGraphAuth: the token endpoint could not be reached for {User}", userObjectId); - return Observable.Return(EaGraphAccess.Unknown( - "the Microsoft token endpoint could not be reached: " + ex.Message)); - }); - }); - } - - /// - /// What a non-2xx token-endpoint answer means for the caller — two different things, and the - /// difference is the whole of MeshWeaver.Plugins#1615. - /// - /// The grant was refused ( — a 400 whose OAuth - /// error names the grant: invalid_grant, interaction_required, - /// consent_required): Entra DID answer, and the answer is that this refresh token cannot - /// be redeemed — revoked, expired, the password changed, consent withdrawn, or minted for a - /// narrower scope set. The remedy is consent, so the credential is STAMPED - /// () and the answer is . - /// The stamp is what makes the two readers agree: the assistant's next call reads NotConnected - /// before asking the token endpoint again, and the consent controller — whose read never asks - /// the token endpoint — runs the dialog instead of bouncing a "connected" user straight back. - /// Without it the user was stranded both ways: the tool said "retry in a moment" and the link - /// said "already connected". - /// - /// Anything else — a 401 invalid_client (OUR app credential is wrong), a 429, - /// a 5xx, a body that is not the OAuth error shape — says nothing about the grant, so it stays - /// (#3433): never the consent link for a grant that may - /// be perfectly good. - /// - private IObservable Refused(string userObjectId, string accountId, EaCredential credential, TokenRefusal refusal) - { - logger?.LogWarning("EaGraphAuth: token endpoint refused the redemption for {User}: {Status} {Error}", - userObjectId, refusal.StatusCode, refusal.Error ?? "(no OAuth error in the body)"); - if (!refusal.GrantRefused) - return Observable.Return(EaGraphAccess.Unknown( - $"the Microsoft token endpoint answered {refusal.StatusCode}" - + (refusal.Error is null ? "" : $" ({refusal.Error})") - + " to the refresh-token redemption; this says nothing about the stored grant")); - - var verdict = EaGraphAccess.NotConnected( - $"Microsoft refused the stored grant ({refusal.Summary}); it must be consented again — " - + "a reconnect, never a first connection"); - return Stamp(PathFor(userObjectId, accountId), credential, refusal.Summary) - .Select(_ => verdict) - // 🚨 A stamp that did NOT land answers Undetermined, not the verdict. Handing out the - // consent link while the controller still reads the credential as Connected is the - // two-reader disagreement this stamp exists to end; "ask again in a moment" is true - // here — the next call re-attempts the stamp — and the diagnostic says what happened. - .Catch((Exception ex) => - { - logger?.LogWarning(ex, "EaGraphAuth: could not stamp the refused grant for {User}", userObjectId); - return Observable.Return(EaGraphAccess.Unknown( - $"Microsoft refused the stored grant ({refusal.Summary}) but the refusal could not be " - + "recorded on the credential; the next call records it")); - }); - } - - /// - /// Records a definitive refusal on the credential node — on the owning hub, through the one - /// mutation API — so every later read of it classifies NotConnected until a new consent - /// rewrites the node. Cold. - /// - private IObservable Stamp(string path, EaCredential credential, string reason) => - Observable.Defer(() => - { - var scope = rootServices.CreateScope(); - var hub = HubFrom(scope.ServiceProvider); - if (hub is null) - { - scope.Dispose(); - return Observable.Throw(new InvalidOperationException( - "EaGraphAuth: no message hub is available to stamp the credential.")); - } - var ws = hub.GetWorkspace(); - var access = hub.ServiceProvider.GetService(); - var refusedToken = credential.RefreshTokenEncrypted; - var options = hub.JsonSerializerOptions; - return access.RunAsSystem(() => ws.GetMeshNodeStream(path) - // The lambda runs on the OWNER over the node's CURRENT content: a re-consent or a - // rotation that landed while the refusal was in flight holds a different token, - // and stamping THAT would mark a live grant dead. Stamp only the grant that was - // refused; otherwise leave the node exactly as it is. - .Update(node => - { - var current = node.ContentAs(options, logger); - return current is null - || !string.Equals(current.RefreshTokenEncrypted, refusedToken, StringComparison.Ordinal) - ? node - : node with { Content = current with { RefusedAt = DateTimeOffset.UtcNow, RefusalReason = reason } }; - })) - .Finally(scope.Dispose); - }); - - /// - public IObservable GetConnection(string userObjectId) => - !IsConfigured - ? Observable.Return(NotConfigured) - : Load(PathFor(userObjectId)).Select(read => read.Access); - - /// - /// - /// The primary record is read at its own path; the added ones are found by a query of the - /// children of that path (run as System — the records live outside the person's partition) and - /// each is then read through the same CLASSIFIED read as the primary, so a record whose read - /// does not answer is listed as Undetermined, never dropped. A primary that does not exist, or - /// was emptied by , is not listed. The query's merged Initial is - /// used, never the first frame of the progressive fan-in (seeded empty per provider). - /// - public IObservable> GetMailboxes(string userObjectId) - { - if (!IsConfigured) return Observable.Return(ImmutableList.Empty); - var primaryPath = PathFor(userObjectId); - var primary = Load(primaryPath) - // Only a COMPLETED read that found no grant leaves the primary out; an undetermined - // read is listed as such — dropping it would read as "not connected" (#3433). - .Select(read => read.Access.Connection == EaConnection.NotConnected - && (read.Node is null || string.IsNullOrEmpty(read.Credential?.RefreshTokenEncrypted)) - ? ImmutableList.Empty - : ImmutableList.Create(new EaMailbox(EaMailboxes.Primary, read.Credential?.Address, read.Access))); - var added = AddedAccountIds(primaryPath) - .SelectMany(ids => ids.IsEmpty - ? Observable.Return(ImmutableList.Empty) - : ids.Select(id => Load(PathFor(userObjectId, id)) - .Select(read => new EaMailbox(id, read.Credential?.Address, read.Access))) - .Concat() - .ToList() - .Select(list => list.ToImmutableList())); - return primary.Zip(added, (p, a) => EaMailboxes.Ordered(p.AddRange(a))); - } - - /// The added mailboxes' account ids: the well-formed ids among the primary path's children. Cold, single emission. - private IObservable> AddedAccountIds(string primaryPath) => - Observable.Defer(() => - { - var scope = rootServices.CreateScope(); - var hub = HubFrom(scope.ServiceProvider); - if (hub is null) - { - scope.Dispose(); - return Observable.Throw>(new InvalidOperationException( - "EaGraphAuth: no message hub is available to list the mailboxes.")); - } - var mesh = hub.ServiceProvider.GetRequiredService(); - var access = hub.ServiceProvider.GetService(); - var request = MeshQueryRequest.FromQuery( - $"namespace:{primaryPath} nodeType:{EaCredentialNodeType.NodeType}"); - return access.RunAsSystem(() => mesh.Query(request)) - .Where(change => change.ChangeType == QueryChangeType.Initial) - .Take(1) - .Select(change => change.Items - .Where(n => n.Namespace == primaryPath && EaMailboxes.IsAddedAccountId(n.Id)) - .Select(n => n.Id) - .Distinct(StringComparer.Ordinal) - .ToImmutableList()) - .Finally(scope.Dispose); - }); - - /// - /// - /// 🚨 The primary is EMPTIED, never deleted: DeleteNode removes descendants, and the added - /// mailboxes are the primary's children — deleting it would disconnect every mailbox. An emptied - /// primary reads as "no refresh token" (NotConnected) and is not listed. For the primary the - /// answer is true once its record holds no grant, false when there is no record. - /// - public IObservable RemoveMailbox(string userObjectId, string account) - { - if (EaMailboxes.IsAddedAccountId(account)) - return WithMesh((hub, mesh, access) => - access.RunAsSystem(() => mesh.DeleteNode(PathFor(userObjectId, account)))); - if (!EaMailboxes.IsPrimary(account)) - return Observable.Return(false); - return WithMesh((hub, _, access) => - { - var options = hub.JsonSerializerOptions; - return access.RunAsSystem(() => hub.GetWorkspace().GetMeshNodeStream(PathFor(userObjectId)) - .Update(node => - { - var current = node.ContentAs(options, logger); - if (current is null || string.IsNullOrEmpty(current.RefreshTokenEncrypted)) - return node; - return node with - { - Content = current with - { - RefreshTokenEncrypted = null, Address = null, RefusedAt = null, RefusalReason = null - } - }; - })) - .Select(_ => true) - // No primary record at all: nothing to remove, which is the postcondition. - .Catch((Exception ex) => IsNodeNotFound(ex) ? Observable.Return(false) : Observable.Throw(ex)); - }); - } - - /// Runs against the portal hub's mesh services, disposing the scope after. Cold. - private IObservable WithMesh(Func> body) => - Observable.Defer(() => - { - var scope = rootServices.CreateScope(); - var hub = HubFrom(scope.ServiceProvider); - if (hub is null) - { - scope.Dispose(); - return Observable.Throw(new InvalidOperationException( - "EaGraphAuth: no message hub is available.")); - } - return body(hub, hub.ServiceProvider.GetRequiredService(), hub.ServiceProvider.GetService()) - .Finally(scope.Dispose); - }); - - /// - /// A deployment with no sign-in app credentials has no delegated flow at all. That is a static - /// configuration FACT, not a failed read — is the honest - /// answer, and is the property callers gate the connect link on. - /// - private static EaGraphAccess NotConfigured => - EaGraphAccess.NotConnected("the delegated Microsoft Graph integration is not configured"); - - /// - /// Turns the token response into the caller's answer, rotating the stored refresh token first - /// when Entra issued a new one. The rotation write is composed INTO the chain rather than fired - /// beside it: a cold write nobody subscribes to silently does nothing, and a rotation that is - /// dropped leaves the next round redeeming a token Entra may already have retired. - /// - private IObservable MintFrom(string json, string userObjectId, string accountId, string? address) - { - string? access; - string? rotated; - try - { - using var doc = JsonDocument.Parse(json); - access = doc.RootElement.TryGetProperty("access_token", out var at) ? at.GetString() : null; - rotated = doc.RootElement.TryGetProperty("refresh_token", out var r) ? r.GetString() : null; - } - catch (JsonException ex) - { - // Synchronous parse of a value we already hold — a try/catch is correct here and covers - // exactly this statement, not the stream around it (/async Rule 1b). - logger?.LogWarning(ex, "EaGraphAuth: the token endpoint response for {User} was not JSON", userObjectId); - return Observable.Return(EaGraphAccess.Unknown( - "the Microsoft token endpoint returned a response that could not be read")); - } - - var answer = string.IsNullOrEmpty(access) - ? EaGraphAccess.Unknown("the Microsoft token endpoint returned no access token") - : EaGraphAccess.Connected(access); - - return string.IsNullOrEmpty(rotated) - ? Observable.Return(answer) - // The rotation keeps the address: it rewrites the whole record, and a dropped label - // would leave the mailbox unaddressable by the assistant's `mailbox` argument. - : Store(userObjectId, accountId, rotated!, address).Select(_ => answer); - } - - /// The rotated refresh token in a token-endpoint response, or null when there is none. - private static string? RefreshTokenIn(string json) - { - using var doc = JsonDocument.Parse(json); - return doc.RootElement.TryGetProperty("refresh_token", out var r) ? r.GetString() : null; - } - - /// - /// The address the person signed in as, read from the token response's id_token - /// (preferred_username, then email, then upn). Null when there is no id - /// token or it names none. - /// - /// The id token is decoded, not validated: it came straight from the token endpoint over - /// TLS in exchange for our own code and client secret, and it is used only as a LABEL — the - /// grant's authority is the refresh token, which the token endpoint itself checks on every - /// redemption. - /// - internal static string? SignedInAddress(string json) - { - using var doc = JsonDocument.Parse(json); - if (!doc.RootElement.TryGetProperty("id_token", out var idt) || idt.GetString() is not { } jwt) - return null; - var parts = jwt.Split('.'); - if (parts.Length < 2) return null; - var payload = parts[1].Replace('-', '+').Replace('_', '/'); - payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='); - byte[] bytes; - try { bytes = Convert.FromBase64String(payload); } - catch (FormatException) { return null; } - using var claims = JsonDocument.Parse(Encoding.UTF8.GetString(bytes)); - foreach (var name in new[] { "preferred_username", "email", "upn" }) - if (claims.RootElement.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String - && v.GetString() is { Length: > 0 } value && value.Contains('@')) - return value.Trim(); - return null; - } - - /// - /// The Entra token POST — the one genuinely-async leaf in this class, and therefore the one - /// thing that goes through . - /// - /// 🚨 Never Observable.FromAsync: that runs the prologue on the SUBSCRIBING thread - /// (a hub action block, when the EA tool subscribes mid-round) with no concurrency bound. The - /// pool is the single sealed boundary between the turn-based schedulers and real I/O — it hops - /// off-hub, bounds concurrency per resource class, and hands the result back as an observable. - /// Resolved from the mesh-scoped , per subscribe, so a host without - /// a mesh still works. - /// - private IObservable PostToken(Dictionary form) => - Observable.Defer(() => HttpPool().Invoke(async ct => - { - using var resp = await http - .PostAsync($"{Authority}/token", new FormUrlEncodedContent(form), ct) - .ConfigureAwait(false); - var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - // The status travels with the body: a 400 whose body names the grant and a 503 are - // two different answers, and collapsing both to null is what read every refusal as - // "retry in a moment" (MeshWeaver.Plugins#1615). - return new TokenEndpointAnswer((int)resp.StatusCode, body); - })); - - /// One token-endpoint answer, status and body together. - private readonly record struct TokenEndpointAnswer(int StatusCode, string Body) - { - public bool IsSuccess => StatusCode is >= 200 and < 300; - } - - private IIoPool HttpPool() - { - using var scope = rootServices.CreateScope(); - var hub = HubFrom(scope.ServiceProvider); - return hub?.ServiceProvider.GetService()?.Get(IoPoolNames.Http) - ?? IoPool.Unbounded; - } - - // The single canonical PATH of a user's EA-credential node — the ONE place read and write must - // agree. Load subscribes here; NewCredentialNode below builds a node whose Path resolves to - // exactly this. A mismatch here silently reports "mailbox not connected" (the token is stored but - // never found on load) — see the regression test. - internal static string PathFor(string userObjectId) => - $"Auth/{EaCredentialNodeType.UserSegment}/{userObjectId}"; - - /// - /// The path of one mailbox's record: the primary at , unchanged, so - /// every grant stored before several mailboxes existed IS the primary without a migration; an - /// added mailbox as a child of it, Auth/_EaCredential/{user}/ea-credential--{10 hex}. - /// - internal static string PathFor(string userObjectId, string accountId) => - EaMailboxes.IsPrimary(accountId) ? PathFor(userObjectId) : $"{PathFor(userObjectId)}/{accountId}"; - - // Builds the credential node AT PathFor(userObjectId): Id = userObjectId, Namespace = - // Auth/_EaCredential ⇒ Path = Auth/_EaCredential/{user}, and NodeType set so the node's hub - // activates with the EaCredential data source (WithContentType<EaCredential>). Store - // fills in Content. The previous form — `new MeshNode(NodeType, PathFor(...))` — misused the - // MeshNode(Id, Namespace) ctor: it created Auth/_EaCredential/{user}/EaCredential (Id="EaCredential", - // NodeType unset), one level deeper than Load reads, so the stored token was never loaded. - internal static MeshNode NewCredentialNode(string userObjectId) => - new(userObjectId, $"Auth/{EaCredentialNodeType.UserSegment}") - { - NodeType = EaCredentialNodeType.NodeType, - Name = "EA Credential", - }; - - /// The node of one mailbox's record, AT . - internal static MeshNode NewCredentialNode(string userObjectId, string accountId) => - EaMailboxes.IsPrimary(accountId) - ? NewCredentialNode(userObjectId) - : new(accountId, PathFor(userObjectId)) - { - NodeType = EaCredentialNodeType.NodeType, - Name = "EA Credential", - }; - - /// - /// Writes (or rewrites) the user's encrypted refresh token. Cold — subscribe or nothing happens. - /// - /// 🚨 No read-before-write. This used to load the node, then branch - /// existing is null ? CreateNode : UpdateNode — a client-side split that decides - /// create-vs-update from a read that can be wrong. With the read now classified, the wrongness - /// is explicit: a read that came back carries no node, - /// so the branch would pick Create over a node that exists and lose the rotation. The - /// mesh has one atomic verb for exactly this — CreateOrUpdateNode, where the OWNING hub - /// decides by existence and serialises concurrent writers — and a token rotation is the textbook - /// idempotent, concurrently-re-run write. The node's other fields are re-derived from - /// rather than carried over from a read, because they are - /// constants (path, NodeType, Name) and the read that supplied them was the unreliable part. - /// - /// - private IObservable Store(string userObjectId, string accountId, string refreshToken, string? address) => - Observable.Defer(() => - { - var scope = rootServices.CreateScope(); - var hub = HubFrom(scope.ServiceProvider); - if (hub is null) - { - // Dispose before faulting: a throw out of the Defer factory becomes an OnError, and - // the Finally below is never attached because there is no chain to attach it to. - scope.Dispose(); - return Observable.Throw(new InvalidOperationException( - "EaGraphAuth: no message hub is available to store the credential.")); - } - var meshService = hub.ServiceProvider.GetRequiredService(); - var access = hub.ServiceProvider.GetService(); - - var node = NewCredentialNode(userObjectId, accountId) with - { - Content = new EaCredential - { - UserObjectId = userObjectId, - Account = EaMailboxes.IsPrimary(accountId) ? null : accountId, - Address = address, - RefreshTokenEncrypted = protector.Protect(refreshToken), - Scopes = Scopes, - AcquiredAt = DateTimeOffset.UtcNow - } - }; - - // 🚨 RunAsSystem, never a `using` around a Subscribe that happens elsewhere and never - // Observable.Using (#1790): impersonation is an AsyncLocal store/restore pair, and this - // write's Subscribe may land on any thread. RunAsSystem opens the scope AT Subscribe and - // closes it on the way out of that same Subscribe, so the cold write is issued as System - // and no caller is left latched. System because the consent callback and the rotation - // both write a node the acting user does not own. - return access.RunAsSystem(() => meshService.CreateOrUpdateNode(node)) - .Finally(scope.Dispose); - }); - - /// What one credential read found: the node, its content, and the modelled answer. - private readonly record struct CredentialRead(MeshNode? Node, EaCredential? Credential, EaGraphAccess Access); - - private IObservable Load(string path) => Load(path, hub: null); - - /// - /// Reads the user's credential node and CLASSIFIES the outcome. Cold, single-emission, and it - /// never faults: every terminal — a value, an absent node, a timeout, a transport error — comes - /// out as one , because a caller that has to catch is a caller that - /// can swallow. - /// - /// 🚨 The classification is the fix, and it turns on ONE distinction: did the read tell us - /// the node is not there, or did it fail to tell us anything? Absence arrives by two transports - /// depending on how the path routes — a null emission through - /// IMeshNodeStreamCache (measured: this is what the monolith does), or a routing - /// not-found on the Rx ERROR channel when the read goes remote, the shape - /// DevAuthController documents. Both are positive evidence and both produce - /// . Everything else — the - /// , any other transport fault, content that will not - /// deserialize — is . - /// - private IObservable Load(string path, IMessageHub? hub) => - Observable.Defer(() => - { - var owned = hub is null ? rootServices.CreateScope() : null; - var resolved = hub ?? HubFrom(owned!.ServiceProvider); - if (resolved is null) - { - owned?.Dispose(); - return Observable.Return(new CredentialRead(null, null, - EaGraphAccess.Unknown("no message hub is available to read the credential"))); - } - - var ws = resolved.GetWorkspace(); - var access = resolved.ServiceProvider.GetService(); - - // System because the credential node lives outside the acting user's partition and the - // consent controller reads it PRE-token; RunAsSystem seals both ends of the scope - // (see Store above for why the `using`/Observable.Using shapes are wrong here). - return access.RunAsSystem(() => ws.GetMeshNodeStream(path).Take(1)) - .Timeout(CredentialReadTimeout) - .Select(node => Classify(node, resolved)) - .Catch((Exception ex) => Observable.Return(Classify(ex, path))) - // A node stream that COMPLETES without emitting is the same information as a - // not-found: nothing was there to read. Never an empty completion to the caller — - // SelectMany over an empty source silently drops the whole chain. - .DefaultIfEmpty(new CredentialRead(null, null, - EaGraphAccess.NotConnected("the credential node stream completed with no node"))) - .Finally(() => owned?.Dispose()); - }); - - private CredentialRead Classify(MeshNode? node, IMessageHub hub) - { - if (node is null) - return new CredentialRead(null, null, - EaGraphAccess.NotConnected("the credential node does not exist")); - - // 🚨 ContentAs, never `node.Content is EaCredential` plus a JsonElement arm plus a - // `_ => null` fallthrough. That hand-rolled shape test had THREE outcomes and only two of - // them were answers: the untyped-JSON case went through a bare `catch { return null; }` - // with no exception variable and no log, and the "neither shape" arm — the as-written - // JsonObject DOM, or a same-named type from another collectible assembly — produced a - // silent null that read as "never connected". ContentAs handles every shape and logs the - // one it cannot. - var cred = node.ContentAs(hub.JsonSerializerOptions, logger); - if (cred is null) - // The node EXISTS. Whatever is wrong with its content, "you never connected" is a - // statement we have positive evidence against. - return new CredentialRead(node, null, EaGraphAccess.Unknown( - $"the credential node at {node.Path} exists but its content could not be read as an " - + $"{nameof(EaCredential)}")); - - if (string.IsNullOrEmpty(cred.RefreshTokenEncrypted)) - return new CredentialRead(node, cred, - EaGraphAccess.NotConnected("the stored credential carries no refresh token")); - - // 🚨 A grant is only as wide as the consent that minted it. When THIS build asks for more - // than the stored grant covers, Entra refuses to redeem the refresh token (400 - // invalid_grant) — so "connected" would be a lie every Teams call disproves, and the - // consent controller, trusting it, would bounce the user's reconnect straight back - // without ever showing the dialog. NotConnected is the truthful answer: the read - // COMPLETED and found a credential that cannot serve this build. The mailbox side of that - // grant still works, and the diagnostic says so, so the message is "reconnect once", - // never "you never connected". Compared as the exact string the consent stored: the - // constant is the only writer, so any difference IS a scope-set change. - if (!string.Equals(cred.Scopes, Scopes, StringComparison.Ordinal)) - return new CredentialRead(node, cred, EaGraphAccess.NotConnected( - "the stored grant was consented for an earlier scope set; this build needs more " - + "(Teams), so a reconnect is required — the mailbox connection itself is intact")); - - // 🚨 A grant Microsoft has already refused (see Refused): the read COMPLETED and found a - // credential the token endpoint will not redeem. NotConnected hands the assistant the - // consent link and makes the controller run the dialog; the token endpoint is not asked - // again for a dead refresh token. A new consent rewrites the node without the stamp. - if (cred.RefusedAt is { } refusedAt) - return new CredentialRead(node, cred, EaGraphAccess.NotConnected( - $"Microsoft refused the stored grant on {refusedAt:u} ({cred.RefusalReason ?? "no reason recorded"}); " - + "it must be consented again — a reconnect, never a first connection")); - - return new CredentialRead(node, cred, EaGraphAccess.Connected()); - } - - /// - /// Classifies a read FAULT. Exactly one fault shape means "absent": the owning hub answering - /// that there is no node at the path. Everything else is - /// — and it is logged at Warning naming the user, so an undetermined answer is always - /// greppable rather than merely inferred. - /// - private CredentialRead Classify(Exception ex, string path) - { - if (IsNodeNotFound(ex)) - return new CredentialRead(null, null, - EaGraphAccess.NotConnected("the credential node does not exist")); - - logger?.LogWarning(ex, - "EaGraphAuth: the credential read at {Path} did not complete — reporting the connection " - + "state as undetermined, NOT as 'not connected'", path); - return new CredentialRead(null, null, EaGraphAccess.Unknown( - ex is TimeoutException - ? $"the credential read did not complete within {CredentialReadTimeout.TotalSeconds:0.##}s" - : $"the credential read faulted: {ex.GetType().Name}")); - } - - /// - /// "There is no node at this path", as it actually arrives. The owning per-node hub never - /// activates for a path with no node, so routing NACKs the read and the failure surfaces as a - /// DeliveryFailureException whose message says so. - /// - /// Matched by TYPE NAME rather than by a reference: DeliveryFailureException lives - /// in the messaging assembly this host does reference, but the same shape reaches here wrapped - /// by the stream cache, and the wrapper is not part of any contract. The message check is what - /// keeps a genuinely different delivery failure — a dead route, a refused post — out of the - /// "absent" bucket, where it would become the very lie this method exists to stop. - /// - private static bool IsNodeNotFound(Exception ex) - { - for (var e = ex; e is not null; e = e.InnerException) - { - if (e is AggregateException agg) - return agg.InnerExceptions.Any(IsNodeNotFound); - if (e.GetType().Name == "DeliveryFailureException" - && e.Message.Contains("no node", StringComparison.OrdinalIgnoreCase)) - return true; - } - return false; - } - - /// - /// The portal hub when the Blazor shell registered one; the mesh root hub otherwise; null on a - /// host that has neither (a unit-test service provider). Null is an answer here, not a throw: - /// the credential read turns it into rather than a fault - /// the caller has to catch. - /// - private static IMessageHub? HubFrom(IServiceProvider services) => - services.GetService()?.Hub ?? services.GetService(); -} diff --git a/memex/Memex.Portal.Shared/Authentication/EntraMetadataCache.cs b/memex/Memex.Portal.Shared/Authentication/EntraMetadataCache.cs deleted file mode 100644 index 9a8c76c497..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/EntraMetadataCache.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.IdentityModel.Protocols; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// The Entra discovery documents and signing keys the step-up rung validates against — ONE -/// per metadata address for the life of the mesh, so the keys -/// are fetched once and refreshed on the library's own schedule instead of on every approval. -/// A mesh-scoped singleton (never a static): registered by MemexConfiguration, resolved from -/// the mesh hub. -/// -/// No latched failure. caches only a SUCCESSFUL -/// retrieval; a fetch that fails throws to that caller and the next call fetches again — so a -/// transient outage of the metadata endpoint fails the step-ups it overlaps (fail closed) and -/// never poisons the ones after it. -/// -/// The client the managers fetch with; null ⇒ a pooled client of this cache's own. -internal sealed class EntraMetadataCache(HttpClient? http = null) -{ - private readonly HttpClient client = http ?? new HttpClient(); - - private readonly ConcurrentDictionary> managers = - new(StringComparer.Ordinal); - - /// The manager for one metadata address. - /// The .well-known/openid-configuration URL. - /// The shared manager. - public ConfigurationManager For(string metadataAddress) => - managers.GetOrAdd(metadataAddress, address => new ConfigurationManager( - address, new OpenIdConnectConfigurationRetriever(), new HttpDocumentRetriever(client) { RequireHttps = true })); -} diff --git a/memex/Memex.Portal.Shared/Authentication/EntraStepUp.cs b/memex/Memex.Portal.Shared/Authentication/EntraStepUp.cs deleted file mode 100644 index 03a6a6734b..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/EntraStepUp.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Collections.Immutable; -using System.Text.Json; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Threading; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using Microsoft.IdentityModel.Tokens; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// The Entra ID rung of the approval step-up (Doc/Architecture/ApprovalStepUp): composes the -/// authorize request (prompt=login, login_hint, a nonce, and the claims -/// parameter requesting the declared Conditional Access authentication context in the ID token), -/// redeems the code, validates the returned id_token's signature/audience/lifetime against -/// the tenant's published keys, and hands its claims to . -/// -/// Reuses the portal's Microsoft sign-in app (Authentication:Microsoft:ClientId / -/// ClientSecret) — the same registration uses for delegated -/// consent; nothing new to register beyond the redirect URI /auth/step-up/callback. -/// -/// All network I/O runs on the HTTP ; the surface is reactive, and the -/// one Task bridge lives in the MVC action. -/// -/// Host configuration (read live). -/// An HTTP client. -/// The HTTP I/O pool. -/// The mesh's shared discovery/JWKS managers. -/// Logger. -internal sealed class EntraStepUp(IConfiguration configuration, HttpClient http, IIoPool pool, EntraMetadataCache metadata, ILogger logger) -{ - private static readonly ImmutableHashSet MultiTenantAliases = - ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, "common", "organizations", "consumers"); - - private string? ClientId => configuration["Authentication:Microsoft:ClientId"]; - private string? ClientSecret => configuration["Authentication:Microsoft:ClientSecret"]; - - /// The tenant step-up tokens must come from: the declared step-up tenant, else the sign-in tenant. - public string Tenant => StepUpOptions.From(configuration).EntraTenantId - ?? MicrosoftTenant.Resolve(configuration[MicrosoftTenant.ConfigurationKey]); - - /// True when the Microsoft sign-in app can run the flow. - public bool IsConfigured => !string.IsNullOrEmpty(ClientId) && !string.IsNullOrEmpty(ClientSecret); - - /// True when names ONE tenant — a multi-tenant alias has no single issuer to pin. - public bool TenantIsSpecific => !MultiTenantAliases.Contains(Tenant); - - /// The claims request parameter asking for in the ID token. - /// The authentication context id. - /// The JSON. - public static string ClaimsParameter(string context) => - JsonSerializer.Serialize(new Dictionary - { - ["id_token"] = new Dictionary - { - ["acrs"] = new Dictionary { ["essential"] = true, ["value"] = context }, - }, - }); - - /// The authorize URL for one step-up. - /// Opaque state, echoed back. - /// The nonce the ID token must carry. - /// The callback. - /// The signed-in account, so Entra re-authenticates THAT account. - /// The authentication context id. - /// The URL. - public string AuthorizeUrl(string state, string nonce, string redirectUri, string? loginHint, string context) => - $"{MicrosoftTenant.Authority(Tenant, "oauth2/v2.0")}/authorize?client_id={Uri.EscapeDataString(ClientId ?? "")}" - + "&response_type=code&response_mode=query" - + $"&redirect_uri={Uri.EscapeDataString(redirectUri)}" - + "&scope=" + Uri.EscapeDataString("openid profile email") - + $"&state={Uri.EscapeDataString(state)}" - + $"&nonce={Uri.EscapeDataString(nonce)}" - + "&prompt=login" - + (string.IsNullOrEmpty(loginHint) ? "" : $"&login_hint={Uri.EscapeDataString(loginHint)}") - + $"&claims={Uri.EscapeDataString(ClaimsParameter(context))}"; - - /// - /// Redeems and checks the ID token. Cold, single emission, never faults: - /// every failure is an naming the step that refused. - /// - /// The authorization code. - /// The callback the code was issued to. - /// Builds the expectation once the tenant's issuer is known. - /// The verdict. - public IObservable Redeem(string code, string redirectUri, Func expectation) => - pool.Invoke(async ct => - { - using var response = await http.PostAsync( - $"{MicrosoftTenant.Authority(Tenant, "oauth2/v2.0")}/token", - new FormUrlEncodedContent(new Dictionary - { - ["client_id"] = ClientId ?? "", - ["client_secret"] = ClientSecret ?? "", - ["grant_type"] = "authorization_code", - ["code"] = code, - ["redirect_uri"] = redirectUri, - ["scope"] = "openid profile email", - }), ct).ConfigureAwait(false); - var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) - { - logger.LogWarning("Entra step-up: the token endpoint answered {Status}: {Body}", - (int)response.StatusCode, body.Length > 400 ? body[..400] : body); - return EntraStepUpCheck.Fail("exchange"); - } - - string? idToken; - using (var doc = JsonDocument.Parse(body)) - idToken = doc.RootElement.TryGetProperty("id_token", out var t) ? t.GetString() : null; - if (string.IsNullOrEmpty(idToken)) - return EntraStepUpCheck.Fail("exchange"); - - var config = await metadata.For($"{MicrosoftTenant.Authority(Tenant, "v2.0")}/.well-known/openid-configuration") - .GetConfigurationAsync(ct).ConfigureAwait(false); - - var validated = await new JsonWebTokenHandler().ValidateTokenAsync(idToken, new TokenValidationParameters - { - ValidateIssuer = false, // pinned exactly by EntraStepUpTokenCheck against config.Issuer - ValidateAudience = true, - ValidAudience = ClientId, - ValidateLifetime = true, - IssuerSigningKeys = config.SigningKeys, - ValidateIssuerSigningKey = true, - RequireSignedTokens = true, - }).ConfigureAwait(false); - if (!validated.IsValid || validated.SecurityToken is not JsonWebToken jwt) - { - logger.LogWarning(validated.Exception, "Entra step-up: the ID token did not validate"); - return EntraStepUpCheck.Fail("signature"); - } - - return EntraStepUpTokenCheck.Evaluate(jwt.Claims.ToList(), expectation(config.Issuer)); - }); -} diff --git a/memex/Memex.Portal.Shared/Authentication/EntraStepUpTokenCheck.cs b/memex/Memex.Portal.Shared/Authentication/EntraStepUpTokenCheck.cs deleted file mode 100644 index 2570782dca..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/EntraStepUpTokenCheck.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Globalization; -using System.Security.Claims; -using MeshWeaver.Mesh.Security; - -namespace Memex.Portal.Shared.Authentication; - -/// What one Entra step-up id_token must satisfy beyond its signature, audience and lifetime. -/// The tenant-specific issuer (https://login.microsoftonline.com/{tid}/v2.0). -/// The nonce of THIS pending step-up. -/// The session's Entra object id, when the session carries one. -/// The session's account (email) — the subject check for a session without oid. -/// The instance's step-up options (context, amr policy, max auth age). -/// The instant of the check. -public sealed record EntraStepUpExpectation( - string Issuer, string Nonce, string? SessionOid, string? SessionAccount, StepUpOptions Options, DateTimeOffset Now); - -/// The verdict on one step-up token. -/// True when every check passed. -/// The first failed check (issuer, nonce, subject, auth_time, acrs, amr, context), or null. -/// The token's auth_time. -/// What was verified, for the receipt — never a secret. -public sealed record EntraStepUpCheck(bool Ok, string? Reason, DateTimeOffset AuthenticatedAt, string? Evidence) -{ - internal static EntraStepUpCheck Fail(string reason) => new(false, reason, default, null); -} - -/// -/// The pure half of the Entra step-up rung: given the claims of an id_token whose signature, -/// audience and lifetime the handler has ALREADY validated, decide whether it proves a fresh, -/// phishing-resistant authentication of THIS session's account for THIS step-up. Every rule is in -/// the table on Doc/Architecture/ApprovalStepUp → "The Entra rung". -/// -public static class EntraStepUpTokenCheck -{ - /// Evaluates the token claims against the expectation. - /// The validated token's claims (a multi-valued claim appears once per value). - /// What this step-up requires. - /// The verdict. - public static EntraStepUpCheck Evaluate(IReadOnlyCollection claims, EntraStepUpExpectation expected) - { - var context = expected.Options.EntraAuthenticationContext; - if (string.IsNullOrEmpty(context)) - return EntraStepUpCheck.Fail("context"); - - string? One(string type) => claims.FirstOrDefault(c => c.Type == type)?.Value; - IEnumerable Many(string type) => claims.Where(c => c.Type == type) - .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); - - var iss = One("iss"); - if (!string.Equals(iss, expected.Issuer, StringComparison.Ordinal)) - return EntraStepUpCheck.Fail("issuer"); - - if (!string.Equals(One("nonce"), expected.Nonce, StringComparison.Ordinal)) - return EntraStepUpCheck.Fail("nonce"); - - var oid = One("oid"); - if (!string.IsNullOrEmpty(expected.SessionOid)) - { - if (!string.Equals(oid, expected.SessionOid, StringComparison.OrdinalIgnoreCase)) - return EntraStepUpCheck.Fail("subject"); - } - else - { - var account = One("preferred_username") ?? One("email"); - if (string.IsNullOrEmpty(expected.SessionAccount) - || !string.Equals(account, expected.SessionAccount, StringComparison.OrdinalIgnoreCase)) - return EntraStepUpCheck.Fail("subject"); - } - - if (!long.TryParse(One("auth_time"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var authSeconds)) - return EntraStepUpCheck.Fail("auth_time"); - var authenticatedAt = DateTimeOffset.FromUnixTimeSeconds(authSeconds); - var age = expected.Now - authenticatedAt; - // A small negative age is clock skew between Entra and this host; a large one is a token - // from the future and as wrong as a stale one. - if (age > expected.Options.MaxAuthAge || age < -TimeSpan.FromMinutes(2)) - return EntraStepUpCheck.Fail("auth_time"); - - var acrs = Many("acrs").ToList(); - if (!acrs.Contains(context, StringComparer.OrdinalIgnoreCase)) - return EntraStepUpCheck.Fail("acrs"); - - var amr = Many("amr").ToList(); - if (amr.Count > 0 && !amr.Any(expected.Options.EntraPhishingResistantAmr.Contains)) - return EntraStepUpCheck.Fail("amr"); - if (amr.Count == 0 && expected.Options.EntraRequireAmr) - return EntraStepUpCheck.Fail("amr"); - - var evidence = $"acrs={string.Join(',', acrs)} amr={(amr.Count == 0 ? "-" : string.Join(',', amr))} tid={One("tid")} oid={oid}"; - return new EntraStepUpCheck(true, null, authenticatedAt, evidence); - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/ExternalAuthController.cs b/memex/Memex.Portal.Shared/Authentication/ExternalAuthController.cs deleted file mode 100644 index 5b1c6aff76..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/ExternalAuthController.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Security.Claims; -using MeshWeaver.Hosting.AspNetCore.Portal.Authentication; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using PortalAuthOptions = MeshWeaver.Hosting.AspNetCore.Portal.Authentication.AuthenticationOptions; - -namespace Memex.Portal.Shared.Authentication; - -[ApiController] -[Route("auth")] -public class ExternalAuthController : ControllerBase -{ - private readonly PortalAuthOptions _authOptions; - private readonly ILogger _logger; - - public ExternalAuthController(IOptions authOptions, ILogger logger) - { - _authOptions = authOptions.Value; - _logger = logger; - } - - /// - /// Initiates OAuth challenge for the specified provider. - /// - [HttpGet("login")] - public IActionResult Login([FromQuery] string provider, [FromQuery] string? returnUrl) - { - var config = _authOptions.Providers.FirstOrDefault( - p => string.Equals(p.Name, provider, StringComparison.OrdinalIgnoreCase)); - - if (config == null) - return BadRequest($"Unknown provider: {provider}"); - - var properties = new AuthenticationProperties - { - RedirectUri = Url.Action(nameof(Callback), new { provider }) + - (returnUrl != null ? $"?returnUrl={Uri.EscapeDataString(returnUrl)}" : ""), - Items = { ["provider"] = provider } - }; - - return Challenge(properties, provider); - } - - /// - /// Handles the OAuth callback, normalizes claims, and issues a cookie. - /// - [HttpGet("callback/{provider}")] - public async Task Callback(string provider, [FromQuery] string? returnUrl) - { - // The OIDC middleware already processed the auth code at CallbackPath (/signin-microsoft) - // and signed in via the cookie scheme. Read the authenticated user from cookies. - var result = await HttpContext.AuthenticateAsync(); - if (!result.Succeeded || result.Principal == null) - { - // 🚨 Named, never silent. This is the SECOND producer of `/login?error=auth_failed` (the - // first is the OIDC handler's remote-failure hook, which logs on its own): the provider - // answered, the handler signed the cookie in at /signin-{provider}, and this very next - // request cannot read it back — a cookie the browser did not return, a Data Protection - // key ring this replica does not share, a sign-in that never happened on this host. A - // person reporting "auth_failed" used to leave no trace here at all: a 12-hour log - // query over one such report returned zero lines. - _logger.LogWarning( - "{Provider} sign-in reached /auth/callback on {Host} without an authenticated " - + "principal — {Failure}; cookies on the request: {CookieCount}; None: {None}", - provider, Request.Host.Value, - result.Failure?.Message ?? (result.None ? "no authentication ticket at all" : "ticket rejected"), - Request.Cookies.Count, result.None); - return Redirect("/login?error=auth_failed"); - } - - var externalClaims = result.Principal.Claims.ToList(); - var (objectId, name, email) = NormalizeClaims(provider, externalClaims); - - var claims = new List - { - new(ClaimTypes.NameIdentifier, objectId), - new(ClaimTypes.Name, name), - new("name", name), - new("preferred_username", objectId), - }; - - if (!string.IsNullOrEmpty(email)) - { - claims.Add(new Claim(ClaimTypes.Email, email)); - claims.Add(new Claim("email", email)); - } - - // Preserve any role claims - foreach (var roleClaim in externalClaims.Where(c => c.Type == ClaimTypes.Role)) - claims.Add(roleClaim); - - // Which provider, and (for Entra) which object/tenant — the approval step-up decides its - // ladder rung and pins the step-up token's subject from these (Doc/Architecture/ApprovalStepUp). - // 🚨 The provider comes from the TICKET the challenged scheme produced, never from the route: - // the route is the caller's to choose. A ticket that carries the provider item is a sign-in - // that just happened, so it is also the only thing that may stamp a new auth_time; a - // re-entry with an existing session keeps the provider and the auth_time it already had. - string? ticketProvider = null; - result.Properties?.Items.TryGetValue("provider", out ticketProvider); - var signedInWith = StepUpClaims.ResolveProvider(ticketProvider, externalClaims); - if (signedInWith is not null && !string.Equals(signedInWith, provider, StringComparison.OrdinalIgnoreCase)) - _logger.LogWarning( - "Sign-in callback for '{Route}' carried a session signed in with '{Actual}' — the route is ignored", - provider, signedInWith); - DateTimeOffset? authTime = !string.IsNullOrEmpty(ticketProvider) ? DateTimeOffset.UtcNow - : long.TryParse(externalClaims.FirstOrDefault(c => c.Type == StepUpClaims.AuthTime)?.Value, - System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var previous) - ? DateTimeOffset.FromUnixTimeSeconds(previous) : null; - claims.AddRange(StepUpClaims.ForSession(signedInWith, externalClaims, authTime)); - - var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); - var principal = new ClaimsPrincipal(identity); - - await HttpContext.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - principal, - new AuthenticationProperties - { - IsPersistent = true, - ExpiresUtc = DateTimeOffset.UtcNow.AddDays(14) - }); - - return Redirect(ReturnUrlPolicy.Sanitize(returnUrl)); - } - - /// - /// Signs out the current user. - /// - [HttpPost("logout")] - [HttpGet("logout")] - public async Task Logout([FromQuery] string? returnUrl) - { - await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - return Redirect(ReturnUrlPolicy.Sanitize(returnUrl)); - } - - /// - /// Normalizes claims from external providers. - /// ObjectId is always the primary email address (UPN). - /// - private static (string objectId, string name, string email) NormalizeClaims( - string provider, List claims) - { - var name = claims.FirstOrDefault(c => c.Type == "name")?.Value - ?? claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value - ?? "Unknown"; - var email = claims.FirstOrDefault(c => c.Type == "email")?.Value - ?? claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value - ?? claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value - ?? ""; - - // ObjectId = email address, always - var objectId = email; - - if (string.IsNullOrEmpty(objectId)) - { - // Fallback to sub/NameIdentifier only if no email available - objectId = claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value - ?? claims.FirstOrDefault(c => c.Type == "sub")?.Value - ?? "unknown"; - } - - return (objectId, name, email); - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/GlobalAdminSeed.cs b/memex/Memex.Portal.Shared/Authentication/GlobalAdminSeed.cs deleted file mode 100644 index be423408cf..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/GlobalAdminSeed.cs +++ /dev/null @@ -1,107 +0,0 @@ -using MeshWeaver.Graph; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using Microsoft.Extensions.Configuration; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Seeds root-scope nodes that grant the Admin -/// role to each user listed under Auth:GlobalAdmins in configuration. -/// -/// Background: SecurityService.GetEffectiveRoles walks scopes from root -/// down and accumulates role assignments. Without a root-scope AccessAssignment -/// granting Admin, a configured Microsoft Entra ID user has zero roles on the -/// root scope, which surfaces as "lacks Read permission on 'Space'" -/// when navigating to the NodeType detail page (and equivalent denials on -/// cross-partition operations like creating a new Space). -/// -/// The test base ships an equivalent seed via -/// TestUsers.PublicAdminAccess() — production needs the same shape, -/// driven by config instead of hardcoded so each deployment can declare its own -/// admin list. See Doc/Architecture/AccessControl.md for the role -/// accumulation rules and src/MeshWeaver.Mesh.Contract/Security/AccessAssignment.cs -/// for the schema. -/// -public static class GlobalAdminSeed -{ - private const string ConfigSection = "Auth:GlobalAdmins"; - - /// - /// Builds AccessAssignment MeshNodes for every user id in - /// Auth:GlobalAdmins. Returns an empty array when the section is - /// missing or empty — safe to chain via builder.AddMeshNodes(...) - /// in environments that have no admins configured. - /// - public static MeshNode[] Build(IConfiguration configuration) - { - // 🚨 Blank ids are REFUSED, not trimmed into a user (MeshWeaver#5217). A configured-but-empty - // entry — an env var rendered as "", a hole left between indices — used to become an - // `Admin/_Access/_Access` grant for the empty username: an Admin assignment on the Admin - // partition whose AccessObject matches nobody today and whatever identity ever resolves to - // "" tomorrow. A duplicate is dropped too, case-insensitively as mesh paths are, so two - // spellings of one id cannot mint two nodes at one path — and the LAST spelling is kept, - // because that is what AddMeshNodes' case-insensitive last-wins already made effective - // before this filter existed. The permission fold matches AccessObject ORDINALLY, so - // keeping the first spelling instead would silently move a platform admin to a - // different login (review on #5462). - var ids = (configuration.GetSection(ConfigSection).Get() ?? []) - .Where(id => !string.IsNullOrWhiteSpace(id)) - .Select(id => id.Trim()) - .Reverse() - .DistinctBy(id => id, StringComparer.OrdinalIgnoreCase) - .Reverse() - .ToArray(); - if (ids.Length == 0) - return []; - - // Two grants per configured admin: - // (1) Admin/_Access — the canonical "global admin = admin on the Admin partition" grant - // (scope "Admin"); this is what hub.IsGlobalAdmin() keys off. - // (2) Provider/_Access — standing write on the top-level "Provider" model-catalog partition - // so platform admins can manage the shared providers/models (endpoints, keys, enabled - // models) through the standard mesh catalog UI. A global admin is NOT a data-superuser - // (the Admin-scope grant does not cover other partitions), so the Provider catalog — - // which moved from Admin/Provider to the top-level Provider partition — needs this - // explicit grant; its _Policy lifts the write caps but caps are ceilings, not grants. - // Non-admins hold no write role anywhere in the Provider hierarchy → they stay - // read-only (the _Policy is PublicRead). See Doc/Architecture/NodeTypeCatalogs.md - // and AccessControl.md. - var nodes = new MeshNode[ids.Length * 2]; - for (var i = 0; i < ids.Length; i++) - { - var userId = ids[i]; - - nodes[i * 2] = new MeshNode(userId + "_Access", "Admin/_Access") - { - NodeType = "AccessAssignment", - Name = $"{userId} — Admin", - Content = new AccessAssignment - { - AccessObject = userId, - DisplayName = userId, - Roles = [new RoleAssignment { Role = "Admin" }], - }, - // Scoped to the Admin partition — MainNode MUST equal the scope the path - // encodes (AccessAssignmentGuard refuses the write otherwise). An empty - // MainNode here is NOT "the Admin folder": it is a ROOT grant — a data - // superuser over every partition (see AccessControl.md → the scope invariant). - MainNode = "Admin", - }; - - nodes[i * 2 + 1] = new MeshNode(userId + "_Access", "Provider/_Access") - { - NodeType = "AccessAssignment", - Name = $"{userId} — Provider Admin", - Content = new AccessAssignment - { - AccessObject = userId, - DisplayName = userId, - Roles = [new RoleAssignment { Role = "Admin" }], - }, - MainNode = "Provider", - }; - } - return nodes; - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/IdentityReadOutcome.cs b/memex/Memex.Portal.Shared/Authentication/IdentityReadOutcome.cs deleted file mode 100644 index 6cb491fa9d..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/IdentityReadOutcome.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Reactive.Linq; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// The outcome of ONE bounded identity-side read — the seam that keeps -/// "we could not find out" apart from "we found out, and the answer is no" (issue #637). -/// -/// Exactly two shapes: -/// -/// Resolved ( null) — the read -/// completed and IS the answer. A null/empty -/// here is a DEFINITIVE negative: this user genuinely has no account / -/// no role grants. -/// Unavailable ( non-null) — the read -/// did NOT complete within its budget, or faulted. NO answer was reached, so the caller must -/// report unavailability (503 + Retry-After), NEVER a negative identity verdict. -/// -/// -/// Why the distinction has to live HERE and not further up: only the read knows whether it -/// timed out. Collapsing a timeout into the same null an absent row produces is what made a -/// degraded silo tell a correctly-signed-in user "you have no account — sign up" (and, on the API -/// side, "401 invalid token"). Once collapsed, no caller can tell them apart — a message-string -/// sniff further up the stack is a guess, not a fact. -/// -/// The value the read produces (a mesh node, a role set, …). -public sealed record IdentityReadOutcome where T : class -{ - /// The resolved value. null on a DEFINITIVE negative — and always null - /// when , where it carries no meaning at all. - public T? Value { get; init; } - - /// Why no verdict was reached, or null when the read produced one. - public string? UnavailableReason { get; init; } - - /// True when the read reached NO verdict — retryable, never a denial. - public bool IsUnavailable => UnavailableReason is not null; - - /// The read completed; is the answer (possibly a definitive null). - public static IdentityReadOutcome Resolved(T? value) => new() { Value = value }; - - /// The read reached no verdict — the caller must answer retryable, never a denial. - public static IdentityReadOutcome Unavailable(string reason) => new() { UnavailableReason = reason }; -} - -/// -/// Bounds an identity-side read and CLASSIFIES its outcome (issue #637). -/// -/// This is the one place a budget is applied to an identity lookup, so it is also the one -/// place that knows a lookup gave up. Every consumer branches on -/// instead of re-deriving the distinction from a -/// null value or an exception message. -/// -public static class IdentityRead -{ - /// - /// Runs under and maps it to an - /// : a value (or a definitive null) becomes - /// Resolved; a budget overrun or a fault becomes Unavailable. - /// - /// 🚨 The fault leg is a CLASSIFICATION, not a swallow: the reason is carried out to the - /// caller (which turns it into 503 + Retry-After) and logged at Warning with the - /// operation name. Nothing is defaulted to a negative verdict. - /// - /// The read. Composed lazily — a synchronous throw while building it is - /// classified too (callers wrap their composition in ). - /// Wall-clock budget for reaching a verdict. - /// Human-readable operation name for the log line (e.g. FindUserByEmail(a@b.c)). - /// Optional logger; the Warning naming the operation is the operator's signal. - public static IObservable> Bounded( - IObservable source, TimeSpan budget, string operation, ILogger? logger) - where T : class - => source - .Select(IdentityReadOutcome.Resolved) - .Timeout(budget, Observable.Defer(() => - { - logger?.LogWarning( - "{Operation}: no verdict within {Timeout} — identity read UNAVAILABLE (retryable infrastructure fault), NOT a negative verdict about this user", - operation, budget); - return Observable.Return(IdentityReadOutcome.Unavailable( - $"{operation} reached no verdict within {budget.TotalSeconds:0.##}s")); - })) - .Catch, Exception>(ex => - { - logger?.LogWarning(ex, - "{Operation}: faulted — identity read UNAVAILABLE (retryable infrastructure fault), NOT a negative verdict about this user", - operation); - return Observable.Return(IdentityReadOutcome.Unavailable( - $"{operation} faulted: {ex.GetType().Name}")); - }); -} diff --git a/memex/Memex.Portal.Shared/Authentication/InvitationService.cs b/memex/Memex.Portal.Shared/Authentication/InvitationService.cs deleted file mode 100644 index 06aed1ff9f..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/InvitationService.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Reads and writes nodes for invitation-only onboarding. Invitations -/// live in the always-present Admin partition at Admin/Invitation/{slug} and are -/// made globally queryable by the path-less nodeType:Invitation → Admin routing rule -/// registered in . -/// -/// 100% reactive — IObservable<T> end-to-end (like ). -/// Every method returns a cold observable; callers Subscribe. All writes wrap in -/// Observable.Using(() => accessService.ImpersonateAsSystem(), …) because the caller — the -/// onboarding user (no identity yet) or an admin who lacks rights on the Admin partition — cannot -/// write there directly. This is the same infrastructure-write pattern as -/// . -/// -public sealed class InvitationService( - IMeshService meshService, - AccessService accessService, - ILogger? logger = null) -{ - /// Hard cap on the invitation lookup; matches OnboardingMiddleware's user lookup. - private static readonly TimeSpan LookupTimeout = TimeSpan.FromSeconds(20); - - /// - /// Reactive lookup of an outstanding () invitation for - /// via the canonical synced query (workspace.GetQuery, runs as - /// System and routes to the Admin partition). Emits the matching node, or null when no - /// pending invitation exists (or on timeout). Shape mirrors - /// OnboardingMiddleware.FindUserByEmail. - /// - public IObservable FindPendingInvitation(IWorkspace workspace, string email) - { - var jsonOptions = workspace.Hub.JsonSerializerOptions; - var accessService = workspace.Hub.ServiceProvider.GetService(); - // As System — this ran under the caller's identity while the summary above already said it - // "runs as System", so the prose was describing a guarantee the code did not make. It is a - // pre-identity read (the invitee has no mesh User node yet, which is the point) against the - // Admin partition, so per-user RLS has no established identity to be correct about, and an - // invitation filtered out is indistinguishable from one that was never issued. - return accessService.RunAsSystem(() => workspace.GetQuery( - $"invite:byEmail:{email}", - // PATH-scoped to Admin/Invitation so it routes to the admin schema (routing is by - // the path's first segment). Invitations live in the Admin partition, which is - // excluded from cross-schema global search, so a `namespace:Admin`-only query - // (no path) fans out cross-schema and never finds them — onboarding would then - // treat every invited email as NOT invited and block it. - $"path:{InvitationNodeType.Namespace} scope:children nodeType:{InvitationNodeType.NodeType} content.email:{email}") - .Take(1) - .Select(items => (MeshNode?)items - .FirstOrDefault(n => TryGetInvitation(n, jsonOptions) is { Status: InvitationStatus.Pending })) - .Timeout(LookupTimeout, Observable.Defer(() => - { - logger?.LogWarning( - "FindPendingInvitation({Email}): no snapshot within {Timeout} — treating as not invited", - email, LookupTimeout); - return Observable.Return(null); - }))); - } - - /// - /// Creates (or overwrites, since the slug is derived from the email) a Pending invitation for - /// . Returns a cold observable emitting the created node; subscribe to drive. - /// - public IObservable CreateInvitation(string email, string? invitedBy, string? note) - { - var trimmed = email.Trim(); - var node = new MeshNode(Slugify(trimmed), InvitationNodeType.Namespace) - { - Name = trimmed, - NodeType = InvitationNodeType.NodeType, - State = MeshNodeState.Active, - Content = new Invitation - { - Email = trimmed, - InvitedBy = invitedBy, - Note = string.IsNullOrWhiteSpace(note) ? null : note!.Trim(), - Status = InvitationStatus.Pending, - }, - }; - - return Observable.Using( - () => accessService.ImpersonateAsSystem(), - _ => meshService.CreateNode(node) - .Do(__ => logger?.LogInformation( - "Invitation: created for {Email} at {Path} (by {InvitedBy})", - trimmed, node.Path, invitedBy ?? "(unknown)"))); - } - - /// - /// Flips an invitation to (called on successful - /// onboarding). The caller passes the content it already extracted - /// so all other fields (Id, InvitedBy, InvitedAt, Note) are preserved. - /// - public IObservable MarkAccepted(MeshNode node, Invitation current) => - WriteStatus(node, current with - { - Status = InvitationStatus.Accepted, - AcceptedAt = DateTimeOffset.UtcNow, - }); - - /// Flips an invitation to (admin withdraws it). - public IObservable Revoke(MeshNode node, Invitation current) => - WriteStatus(node, current with { Status = InvitationStatus.Revoked }); - - private IObservable WriteStatus(MeshNode node, Invitation updated) => - Observable.Using( - () => accessService.ImpersonateAsSystem(), - _ => meshService.UpdateNode(node with { Content = updated }) - .Do(__ => logger?.LogInformation("Invitation: {Path} → {Status}", node.Path, updated.Status))); - - /// - /// Extracts the from a node's Content, deserializing from - /// when the synced query returned the raw JSON shape. Returns - /// null when the content is absent or not an invitation. - /// - public static Invitation? TryGetInvitation(MeshNode node, JsonSerializerOptions? options) => - node.Content switch - { - Invitation inv => inv, - JsonElement je => Deserialize(je, options), - _ => null, - }; - - private static Invitation? Deserialize(JsonElement je, JsonSerializerOptions? options) - { - try { return JsonSerializer.Deserialize(je.GetRawText(), options); } - catch { return null; } - } - - private static string Slugify(string email) => - new(email.ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '_').ToArray()); -} diff --git a/memex/Memex.Portal.Shared/Authentication/McpAuthenticationExtensions.cs b/memex/Memex.Portal.Shared/Authentication/McpAuthenticationExtensions.cs deleted file mode 100644 index ecaf00ab14..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/McpAuthenticationExtensions.cs +++ /dev/null @@ -1,191 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.AspNetCore.Authentication; -using ModelContextProtocol.Authentication; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Separate auth wiring for the MCP endpoint. -/// -/// The Blazor portal uses cookie-based auth with a redirect-to-login challenge, which is -/// correct for browser users but fatal for MCP clients: Claude Desktop / Claude.ai follow -/// a 302 to an HTML login page and fail with "couldn't reach the server" instead of doing -/// OAuth discovery. -/// -/// MCP auth must be strictly Bearer-only: -/// * token validation goes to -/// * unauthed requests get 401 + WWW-Authenticate: Bearer resource_metadata="..." -/// emitted by the MCP SDK's own scheme, so clients can run OAuth discovery -/// * no leakage to cookie — no redirects, ever -/// -public static class McpAuthenticationExtensions -{ - public const string PolicyName = "McpAuth"; - - /// - /// Authorization policy for the READ-ONLY /api/mesh verbs: an - /// Authorization: Bearer mw_… token OR the portal's own session cookie. - /// - /// - /// 🚨 Why a browser session must be enough here: a server-side renderer that already - /// holds the user's session cookie (portal-next SSR) otherwise has to MINT an API token - /// per page render just to read a snapshot back — and every mint writes TWO permanent - /// mesh nodes ({userId}/ApiToken/{hash} + the global ApiToken/{hash} index), - /// so ordinary page traffic grew a user's partition without bound (issue #1477). The - /// credential the caller already has must be sufficient to READ. - /// - /// - /// - /// 🚨 READ-ONLY on purpose. Every MUTATING verb stays on - /// (Bearer-only), so a cookie can never drive a write. That keeps the CSRF surface at - /// zero by construction rather than by argument: the worst a forged cross-site request - /// could do against these endpoints is cause a read whose response the attacker cannot - /// see (no CORS grant), and the session cookie is SameSite=Lax so it is not even - /// sent on a cross-site POST. Adding a verb here is a security decision — only pure - /// reads belong. - /// - /// - public const string ReadPolicyName = "MeshApiRead"; - - /// - /// The composite authentication scheme behind : a policy - /// scheme that forwards AUTHENTICATION to the Bearer/ApiToken scheme when the request - /// carries an Authorization: Bearer header and to the cookie scheme otherwise — - /// but forwards every CHALLENGE to the MCP scheme unconditionally. - /// - /// - /// The challenge split is the load-bearing part. Letting the cookie scheme challenge - /// would answer an unauthenticated API call with 302 → /login and an HTML page, - /// which is exactly the failure this file exists to prevent (see the type remarks): a - /// REST/MCP client sees a 200 full of markup instead of a 401 it can act on. Forwarding - /// the challenge keeps the API-shaped 401 + WWW-Authenticate: Bearer for every - /// anonymous caller, cookie-capable or not. - /// - /// - public const string CookieOrBearerScheme = "MeshApiCookieOrBearer"; - - /// - /// Registers the ApiToken + MCP authentication schemes and the McpAuth - /// authorization policy. Call after the primary (cookie / OIDC) auth has been - /// registered — this adds to the existing authentication builder without - /// touching its defaults. - /// - public static IServiceCollection AddMcpAuthentication(this IServiceCollection services) - { - // The /api/mcp → /mcp endpoint alias (see McpEndpointRoutes): self-wired at the front of - // the pipeline so the PRIMARY path is served by every host that has MCP auth, without the - // composition needing an extra call. - services.AddTransient(); - - services.AddAuthentication() - .AddScheme( - ApiTokenAuthenticationHandler.SchemeName, _ => { }) - .AddMcp(ConfigureMcpAuth) - .AddPolicyScheme(CookieOrBearerScheme, CookieOrBearerScheme, ConfigureCookieOrBearer); - - services.AddAuthorization(options => - { - options.AddPolicy(PolicyName, policy => - { - policy.AddAuthenticationSchemes(McpAuthenticationDefaults.AuthenticationScheme); - policy.RequireAuthenticatedUser(); - }); - - options.AddPolicy(ReadPolicyName, policy => - { - policy.AddAuthenticationSchemes(CookieOrBearerScheme); - policy.RequireAuthenticatedUser(); - }); - }); - - return services; - } - - /// - /// Wires : pick the scheme by what the request - /// actually presents, and never let the cookie scheme write the challenge. - /// - private static void ConfigureCookieOrBearer(PolicySchemeOptions options) - { - options.ForwardDefaultSelector = context => - context.Request.Headers.Authorization.ToString() - .StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) - ? McpAuthenticationDefaults.AuthenticationScheme - : CookieAuthenticationDefaults.AuthenticationScheme; - - // 🚨 NEVER the cookie challenge — see CookieOrBearerScheme's remarks. Both auth - // modes the portal composes (unified cookie, and MicrosoftIdentity's OIDC + cookie - // sign-in) register CookieAuthenticationDefaults.AuthenticationScheme, so the - // selector above always names a scheme that exists; the challenge, however, has to - // stay API-shaped regardless of which one authenticated. - options.ForwardChallenge = McpAuthenticationDefaults.AuthenticationScheme; - options.ForwardForbid = McpAuthenticationDefaults.AuthenticationScheme; - } - - private static void ConfigureMcpAuth(McpAuthenticationOptions options) - { - // Bearer token validation → ApiToken handler. The MCP SDK constructor hardcodes - // ForwardAuthenticate = "Bearer" (a scheme that doesn't exist here); point it at - // the real scheme so token validation actually runs. - options.ForwardAuthenticate = ApiTokenAuthenticationHandler.SchemeName; - - // Leave Challenge on the MCP scheme itself so it emits - // 401 + WWW-Authenticate: Bearer resource_metadata="..." — that's what lets - // MCP clients discover the auth server. NEVER forward to cookie: that would - // produce a 302 to /login which MCP clients can't follow. - options.ForwardChallenge = null; - options.ForwardForbid = null; - // 🚨 …EXCEPT when validation could not RUN (issue #637). The ApiToken handler flags that - // on the request and its own challenge answers 503 + Retry-After — but the MCP scheme - // writes the challenge itself, so that override never ran on /mcp: a token the mesh - // simply could not look up for a moment was answered 401 + WWW-Authenticate: Bearer, and - // an MCP client reads exactly that as "sign in again". Measured on memex, 2026-09-29/30: - // every validation stall signed the maintainer's MCP connections out - // (Doc/Architecture/TokenValidationHotPath). With no explicit ForwardChallenge the handler - // resolves its target through this selector, so a flagged request's challenge goes to the - // ApiToken scheme (503, token kept) and every other one stays here (401 + discovery). - options.ForwardDefaultSelector = context => - context.Items.ContainsKey(ApiTokenAuthenticationHandler.ValidationUnavailableItemKey) - ? ApiTokenAuthenticationHandler.SchemeName - : null; - - options.ResourceMetadata = new ProtectedResourceMetadata - { - BearerMethodsSupported = { "header" }, - ScopesSupported = { "mcp" }, - }; - - options.Events = new McpAuthenticationEvents - { - OnResourceMetadataRequest = ctx => - { - var req = ctx.HttpContext.Request; - var origin = $"{req.Scheme}://{req.Host}"; - // RFC 9728 path-inserted discovery: McpEndpointRoutes rewrites - // /.well-known/oauth-protected-resource/{api/mcp|mcp} onto this bare document - // and stashes which resource the client asked about — answer with THAT resource - // so a strict client's "resource matches the server URL" validation passes for - // both the primary (/api/mcp) and the compatibility (/mcp) endpoint. A fetch of - // the bare document (the URL the WWW-Authenticate challenge names) keeps - // answering /mcp — the value every already-connected client validated against. - var resourcePath = - ctx.HttpContext.Items.TryGetValue(McpEndpointRoutes.ResourcePathItem, out var stashed) - && stashed is string s - ? s - : McpEndpointRoutes.CompatibilityEndpoint; - ctx.ResourceMetadata = new ProtectedResourceMetadata - { - Resource = $"{origin}{resourcePath}", - BearerMethodsSupported = { "header" }, - ScopesSupported = { "mcp" }, - AuthorizationServers = { origin }, - }; - return Task.CompletedTask; - }, - }; - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/McpBackConnectionService.cs b/memex/Memex.Portal.Shared/Authentication/McpBackConnectionService.cs deleted file mode 100644 index 6a9d4db204..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/McpBackConnectionService.cs +++ /dev/null @@ -1,151 +0,0 @@ -using MeshWeaver.AI.Connect; // IMcpBackConnection/McpConnectionInfo keep their ORIGINAL namespace in MeshWeaver.Mesh.Contract (#2398 forwarders) -using System.Collections.Concurrent; -using System.Reactive.Linq; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Hosting.AspNetCore; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Automatic, token-based MCP back-connection provisioning — the portal-side implementation of -/// . The co-hosted Claude Code / GitHub Copilot CLIs call -/// at spawn time (every execution); on a cache miss this mints a -/// long-lived per-user MeshWeaver ApiToken via — with NO manual -/// step — and returns the composed {baseUrl}/api/mcp URL (the PRIMARY endpoint path — see -/// ; safe to use here because the back-connection targets THIS -/// portal, whose image carries the alias) plus the raw mw_… token to present -/// as Authorization: Bearer. Internal portal↔CLI↔MCP comms are therefore token-based and -/// scoped to the user's own permissions (the ApiToken carries the user's roles). -/// -/// The per-user token is cached on this singleton's instance dictionary (NEVER static — it -/// dies with the host) for the process lifetime; a fresh replica mints its own (the prior token -/// stays valid). -/// -/// 🚨 A cached token is VALIDATED before it is reused, and that is the only thing that -/// makes a revocation take effect. This paragraph used to claim "a revoked token surfaces as a 401 -/// on the next MCP call, which the auth-on-exception path turns into a re-mint" — there was no such -/// path (#2302 finding 6). Neither CLI client reports a subsequent /mcp 401 back to this -/// service, so nothing evicted the entry and mesh access stayed broken until the process restarted. -/// An Invalidate(userId) contract would not have helped for the same reason: there is no -/// caller to invoke it. Validation reads the token node fresh, so asking here is meaningful. -/// -/// Only an Invalid verdict evicts. Unavailable deliberately does not: "we could -/// not find out" is not "it was revoked" (#637), and minting on a storage blip would fill the -/// user's token list with orphans exactly when the mesh is already unwell. -/// -internal sealed class McpBackConnectionService : IMcpBackConnection -{ - private readonly ApiTokenService tokenService; - private readonly IOptions mcpConfig; - private readonly ILogger logger; - - /// - /// How long a cache-hit validation may take before the cached token is reused anyway. Short by - /// design: this is the hot path of every CLI spawn, and the check is an optimisation on - /// revocation latency, not a security boundary — a revoked token is still refused at /mcp. - /// - private static readonly TimeSpan ValidationBudget = TimeSpan.FromSeconds(2); - - // Instance (not static) — lifetime == the portal host. userId → raw mw_ token. - private readonly ConcurrentDictionary tokensByUser = new(StringComparer.Ordinal); - - public McpBackConnectionService( - ApiTokenService tokenService, - IOptions mcpConfig, - ILogger logger) - { - this.tokenService = tokenService; - this.mcpConfig = mcpConfig; - this.logger = logger; - } - - /// - /// Drops a cached token the store has judged INVALID and mints a replacement, so a revocation - /// takes effect without a portal restart. - /// - private IObservable EvictAndMint( - string userId, string? userName, string? userEmail, string mcpUrl, string? reason) - { - tokensByUser.TryRemove(userId, out _); - logger.LogInformation( - "Cached MCP back-connection token for user {UserId} is no longer valid ({Reason}) — " - + "evicted and re-minting.", userId, reason ?? "no reason given"); - return Mint(userId, userName, userEmail, mcpUrl); - } - - public IObservable EnsureForUser(string userId, string? userName = null, string? userEmail = null) - { - var baseUrl = mcpConfig.Value?.BaseUrl; - if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(userId)) - return Observable.Return(null); - - var mcpUrl = $"{baseUrl!.TrimEnd('/')}{McpEndpointRoutes.PrimaryEndpoint}"; - - // Reuse the cached token — but VALIDATE it first (#2302 finding 6). The cache had no - // eviction path at all: once a token was cached it was returned for the life of the - // process, so REVOKING it did not take effect. Neither CLI client reports a subsequent - // /mcp 401 back here, so an `Invalidate(userId)` contract would have been a method nobody - // ever calls — the revocation would still never land. Validation reads the token node - // fresh (no cache), which is what makes checking it here meaningful rather than circular. - if (tokensByUser.TryGetValue(userId, out var cached)) - return tokenService.Validate(cached) - // 🚨 Bounded well below ApiTokenService.ValidationReadTimeout (8 s by default). - // This runs on EVERY CLI spawn, so an unbounded check would let a brief store blip - // stall every spawn for the full validation window — trading the stale-token bug - // for a latency one. Expiring here lands in the Catch below and REUSES the cached - // token, which is the Unavailable semantics this class already applies: "we could - // not find out" is never "it was revoked". - .Timeout(ValidationBudget) - .SelectMany(verdict => verdict.Status switch - { - // 🚨 Only INVALID evicts. Unavailable must NOT: "we could not find out" is not - // "it was revoked" (#637), and treating a storage blip as revocation would mint - // a fresh token on every transient failure — filling the user's token list with - // orphans and doing it precisely when the mesh is already unwell. A cached token - // that is actually revoked simply fails at /mcp on that attempt, which is the - // same outcome as today and is recoverable on the next call. - TokenValidationStatus.Invalid => EvictAndMint(userId, userName, userEmail, mcpUrl, verdict.Reason), - _ => Observable.Return(new McpConnectionInfo(mcpUrl, cached)), - }) - .Catch((Exception ex) => - { - // Validation itself faulting is an availability failure, not a verdict: keep - // serving the cached token rather than minting on every fault. - logger.LogWarning(ex, - "Could not validate the cached MCP back-connection token for user {UserId}; " - + "reusing it. If it has been revoked the /mcp call will refuse it.", userId); - return Observable.Return(new McpConnectionInfo(mcpUrl, cached)); - }); - - // Cache miss → mint automatically. - return Mint(userId, userName, userEmail, mcpUrl); - } - - /// - /// Mints a back-connection token and caches it. CreateToken self-elevates for the global index - /// write; the user-scoped node is created under the calling user's AccessContext (active at - /// spawn). - /// - private IObservable Mint( - string userId, string? userName, string? userEmail, string mcpUrl) - { - return tokenService - .CreateToken(userId, userName ?? userId, userEmail ?? string.Empty, - label: "MCP back-connection (auto)", expiresAt: null) - .Select(result => - { - tokensByUser[userId] = result.RawToken; - logger.LogInformation("Auto-minted MCP back-connection token for user {UserId}", userId); - return (McpConnectionInfo?)new McpConnectionInfo(mcpUrl, result.RawToken); - }) - .Catch((Exception ex) => - { - // Fail soft: the CLI runs without mesh access rather than failing the chat. - logger.LogWarning(ex, - "Could not provision MCP back-connection for user {UserId}; co-hosted CLI will run without mesh access.", userId); - return Observable.Return(null); - }); - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/McpEndpointRoutes.cs b/memex/Memex.Portal.Shared/Authentication/McpEndpointRoutes.cs deleted file mode 100644 index f0dd06e217..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/McpEndpointRoutes.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// The portal's MCP endpoint path contract: /api/mcp is the PRIMARY published -/// URL, /mcp is the permanent compatibility alias every existing client config points at -/// (Claude Code harnesses, plugin MCP servers, IMcpBackConnection consumers, -/// McpRemoteMeshClient mirrors). Both serve the identical endpoint. -/// -/// How the alias works. The MeshWeaver.Mcp module maps its streamable-HTTP -/// transport at /mcp (via MapMeshModuleEndpoints). — -/// self-wired through an registered by -/// , so it runs at the very FRONT -/// of the pipeline — rewrites /api/mcp[/…] to /mcp[/…] before routing. Everything -/// downstream (the MCP auth policy, the middleware exclusion lists, the module's route) then -/// treats /api/mcp traffic byte-for-byte like /mcp traffic. This deliberately -/// does NOT depend on the module's own mapping moving: the alias works against every module -/// generation, and there is no cross-repo ordering window where /api/mcp maps to -/// nothing. If the module's pattern ever moves to /api/mcp natively, INVERT this rewrite -/// in the same change (/mcp → /api/mcp) — never map both without removing it, or -/// the module-endpoint collision check refuses startup. -/// -/// OAuth discovery (RFC 9728). A strict MCP client derives the path-inserted -/// protected-resource-metadata URL from the endpoint it connects to: -/// /.well-known/oauth-protected-resource/api/mcp for the primary, -/// …/mcp for the alias. The MCP SDK serves ONE metadata document at the bare -/// /.well-known/oauth-protected-resource, so the middleware rewrites both path-inserted -/// forms onto the bare path and stashes the resource path in -/// ; OnResourceMetadataRequest (which runs AFTER -/// UseForwardedHeaders, so its origin is the public one) reads the stash and answers -/// with the matching resource value. The bare document keeps answering -/// {origin}/mcp — the value every already-connected client validated against. -/// -public static class McpEndpointRoutes -{ - /// The primary MCP endpoint path. New client configs point here. - public const string PrimaryEndpoint = "/api/mcp"; - - /// The compatibility alias — the path the MCP module actually maps, and the one - /// every pre-#2378 client config uses. Permanent: breaking it breaks every existing - /// harness/plugin/back-connection config in the field. - public const string CompatibilityEndpoint = "/mcp"; - - /// The bare RFC 9728 protected-resource-metadata path the MCP SDK serves. - public const string ResourceMetadataPath = "/.well-known/oauth-protected-resource"; - - /// key carrying the resource path - /// ( or ) a path-inserted - /// metadata request was addressed to. - public const string ResourcePathItem = "Memex.Mcp.ResourcePath"; - - /// - /// The front-of-pipeline rewrite described in the type remarks. Pure path logic — no - /// response is written and no origin is composed here, precisely because this runs before - /// UseForwardedHeaders. - /// - public static IApplicationBuilder UseMcpEndpointAlias(this IApplicationBuilder app) - => app.Use(Middleware); - - private static Task Middleware(HttpContext context, RequestDelegate next) - { - var path = context.Request.Path; - - // /api/mcp[/…] → /mcp[/…] — segment-exact (never /api/mcpx), case-insensitive - // (route matching is; a client typing /API/MCP gets the same endpoint). - if (path.StartsWithSegments(PrimaryEndpoint, StringComparison.OrdinalIgnoreCase, out var remaining)) - { - context.Request.Path = new PathString(CompatibilityEndpoint).Add(remaining); - return next(context); - } - - // Path-inserted RFC 9728 discovery for both resource names → the SDK's bare document, - // with the requested resource path stashed for OnResourceMetadataRequest. - if (path.StartsWithSegments(ResourceMetadataPath, StringComparison.OrdinalIgnoreCase, out var resource) - && resource.HasValue) - { - if (resource.Equals(new PathString(PrimaryEndpoint), StringComparison.OrdinalIgnoreCase)) - { - context.Items[ResourcePathItem] = PrimaryEndpoint; - context.Request.Path = ResourceMetadataPath; - } - else if (resource.Equals(new PathString(CompatibilityEndpoint), StringComparison.OrdinalIgnoreCase)) - { - context.Items[ResourcePathItem] = CompatibilityEndpoint; - context.Request.Path = ResourceMetadataPath; - } - } - - return next(context); - } - - /// Self-wires at the front of every host that registers - /// MCP authentication — the composition (which lives in the plugins repo) needs no extra - /// call, so there is no cross-repo window where the primary path is unserved. - internal sealed class StartupFilter : IStartupFilter - { - public Action Configure(Action next) - => app => - { - app.UseMcpEndpointAlias(); - next(app); - }; - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/MeshWeaverInstanceService.cs b/memex/Memex.Portal.Shared/Authentication/MeshWeaverInstanceService.cs deleted file mode 100644 index 583e018660..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/MeshWeaverInstanceService.cs +++ /dev/null @@ -1,796 +0,0 @@ -using System.Reactive; -using System.Reactive.Linq; -using System.Text.RegularExpressions; -using MeshWeaver.Data; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Registers MeshWeaver installations and issues their keys — the instance-facing counterpart of -/// ApiTokenService, built on the same mechanism (mint → hash → write the node → write a -/// routing index under System identity) and deliberately NOT on the same node type. -/// -/// Registering grants nothing — unless the registry operator has opted specific sources into -/// every new registration via PluginCatalog:DefaultGrants (a list of Source/Package -/// entries, e.g. Plugins/*). Registration then seeds those entries into the instance's -/// node in the Admin partition — the grant node stays the single -/// authority, so a platform admin can still revoke or extend per instance, and anything NOT in the -/// default list (private/paid sources) remains admin-granted as before. The registering user still -/// cannot reach the grant node either way. -/// -public sealed class MeshWeaverInstanceService( - IMeshService nodeFactory, IMessageHub hub, ILogger logger, - IConfiguration? configuration = null) : IInstanceKeyRegistry -{ - /// Config key listing the Source/Package entries every new registration is - /// seeded with (PluginCatalog:DefaultGrants:0, …). Absent/empty → registration grants - /// nothing, the strict default. - public const string DefaultGrantsConfigKey = "PluginCatalog:DefaultGrants"; - - /// - /// Config key holding the registration key an OPEN registration — a - /// POST /api/instances/register with NO bootstrap key — is treated as presenting. - /// Normally a key the operator minted for the free plan, so a local install that names - /// only its instance id lands on the free tier by itself; raising it is an admin's edit of its - /// grant on the registry. Absent = open registrations are refused (401), the strict default. - /// Revoking the key closes the lane without touching the instances it registered. - /// - public const string OpenRegistrationKeyConfigKey = "PluginCatalog:OpenRegistration:Key"; - - /// Namespace holding a user's registered instances, inside their own partition. - public const string InstanceNamespace = "MeshWeaverInstance"; - - /// - /// A logical App ID: lowercase letters, digits and single hyphens, 3–48 chars, not - /// hyphen-terminated. Constrained because the id becomes a node id AND the key of the grant node - /// in the Admin partition — a loose id would let a registration collide with, or visually - /// impersonate, another instance's grant. - /// - private static readonly Regex InstanceIdPattern = - new("^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,47}$", RegexOptions.Compiled); - - /// Whether is a well-formed logical App ID. - public static bool IsValidInstanceId(string? instanceId) => - !string.IsNullOrWhiteSpace(instanceId) && InstanceIdPattern.IsMatch(instanceId); - - /// - /// Registers an installation and mints its key. Cold — the writes happen on Subscribe. - /// - /// 🚨 The instance id is claimed GLOBALLY, not per user: grants are keyed on it in the - /// Admin partition, so two users must never hold the same id, and an id must not be silently - /// re-registered after deletion or the newcomer would inherit the old grant. The index write - /// is what makes the claim visible; is checked first so a - /// collision is a clean error rather than a half-written registration. - /// - /// The subscription plan the instance enrols into (a registration key's - /// ), or null. Seeds one plan-scoped whole-source entry per - /// configured source on top of the seed. - public IObservable Register( - string userId, string userName, string userEmail, - string instanceId, string displayName, string description = "", string homeUrl = "", - string? tier = null, InstanceOwnership? ownership = null) - { - if (!IsValidInstanceId(instanceId)) - return Observable.Throw(new ArgumentException( - $"'{instanceId}' is not a valid instance id: 3–48 chars, lowercase letters, digits " - + "and single hyphens, not starting or ending with a hyphen.", nameof(instanceId))); - - var rawKey = InstanceKeys.Generate(); - var hash = InstanceKeys.Hash(rawKey); - - return IsIdAvailable(instanceId).SelectMany(available => - { - if (!available) - return Observable.Throw( - new InstanceIdTakenException(instanceId)); - - var instance = new MeshWeaverInstance - { - InstanceId = instanceId, - DisplayName = string.IsNullOrWhiteSpace(displayName) ? instanceId : displayName, - Description = description, - HomeUrl = homeUrl, - OwnerUserId = userId, - // 🚨 A STATED ownership record wins, per field. On the bootstrap and open lanes the - // userName/userEmail arriving here belong to whoever MINTED the registration key — a - // platform admin at the registry, who for an open registration is a stranger to the - // person actually standing the instance up. Where the registrant said who they are, - // that is the owner; where they said nothing, the previous fallback is unchanged. - OwnerUserName = InstanceOwnership.Prefer(ownership?.Name, userName), - OwnerUserEmail = InstanceOwnership.Prefer(ownership?.Email, userEmail), - Company = InstanceOwnership.Prefer(ownership?.Company, ""), - KeyHash = hash, - KeyIssuedAt = DateTimeOffset.UtcNow, - CreatedAt = DateTimeOffset.UtcNow, - // The LICENCE lives here, on the record (#2804): the plan the registration key was - // minted for, else the baseline. Never blank — a blank would still read as the - // baseline, but a record that names its plan is one an admin can read at a glance. - Plan = PlanTierRanks.Canonical(tier) is { Length: > 0 } plan ? plan : PlanTierRanks.BaselinePlan, - }; - - var instanceNode = new MeshNode(instanceId, $"{userId}/{InstanceNamespace}") - { - Name = instance.DisplayName, - NodeType = MeshWeaverInstanceNodeType.NodeType, - State = MeshNodeState.Active, - MainNode = userId, - Content = instance, - }; - - // The IsIdAvailable pre-check reads the eventually-consistent query index, so a - // just-registered id can still look available — the CREATE is the real serialization - // point. When it fails and an authoritative read shows a node at the claimed path, - // surface the same typed conflict the pre-check would have raised, not a raw storage - // error (the endpoint's 409 depends on the type). - return nodeFactory.CreateNode(instanceNode) - .Catch((Exception ex) => Observable.Using( - () => hub.ServiceProvider.GetRequiredService().ImpersonateAsSystem(), - _ => hub.GetMeshNode(instanceNode.Path, TimeSpan.FromSeconds(10))) - .Take(1) - .SelectMany(existing => Observable.Throw( - existing is not null ? new InstanceIdTakenException(instanceId) : ex))) - .SelectMany(created => WriteIndex(hash, created.Path, instanceId) - .SelectMany(_ => SeedDefaultGrants(instanceId, tier)) - .Select(_ => - { - logger.LogInformation( - "Registered MeshWeaver instance {InstanceId} for {UserId} (key hash prefix {Prefix})", - instanceId, userId, InstanceKeys.HashPrefix(hash)); - return new InstanceRegistrationResult(rawKey, created, instance); - })); - }); - } - - /// - /// Registers an installation presenting a registration bootstrap key (mwr_…) — the - /// first-startup auto-registration flow behind POST /api/instances/register. The - /// bootstrap key IS the authentication: it resolves to its minting admin - /// (, revocation + expiry enforced) and the - /// registration runs under THAT identity, so the instance lands in the minter's partition - /// exactly like a hand registration — including the seed. - /// Errors are typed: (→ 401) and - /// (→ 409). - /// - public IObservable RegisterWithBootstrapKey( - string rawBootstrapKey, string instanceId, - string displayName = "", string description = "", string homeUrl = "", - InstanceOwnership? ownership = null) - { - var keys = hub.ServiceProvider.GetRequiredService(); - var accessService = hub.ServiceProvider.GetRequiredService(); - - return keys.Resolve(rawBootstrapKey) - .SelectMany(resolved => - { - if (resolved is null) - return Observable.Throw(new InvalidBootstrapKeyException()); - - return Observable.Defer(() => - { - var disposable = accessService.SwitchAccessContext(new AccessContext - { - ObjectId = resolved.Key.OwnerUserId, - Name = resolved.Key.OwnerUserName, - Email = resolved.Key.OwnerUserEmail, - }); - return Register( - resolved.Key.OwnerUserId, resolved.Key.OwnerUserName, - resolved.Key.OwnerUserEmail, instanceId, - displayName, description, homeUrl, - // The key's PLAN is what the instance enrols into — a key minted - // "for Pro customers" seeds Plugins/*@pro on every install that - // presents it; a key without a plan seeds the DefaultGrants alone. - tier: resolved.Key.Tier, - // The registrant's own statement of who owns this instance — the - // key owner above is who minted the key, not necessarily them. - ownership: ownership) - .Finally(() => disposable.Dispose()); - }) - .SelectMany(registration => keys.StampUse(resolved.KeyPath) - .Select(_ => - { - logger.LogInformation( - "Instance '{InstanceId}' auto-registered via bootstrap key of {Owner} (plan {Plan})", - registration.Instance.InstanceId, resolved.Key.OwnerUserId, - resolved.Key.Tier ?? "none"); - return registration; - })); - }); - } - - /// - /// Registers an installation that presented NO bootstrap key — open registration. Runs the - /// ordinary with the key the registry's operator - /// configured for un-keyed callers (), so the - /// instance is owned by that key's minting admin and enrols into that key's plan exactly as a - /// keyed registration would; the key's revocation and expiry apply unchanged. - /// Refuses — as , the same 401 an invalid key gets — - /// when no open key is configured, or when the configured value is not a registration key. - /// - public IObservable RegisterOpen( - string instanceId, string displayName = "", string description = "", string homeUrl = "", - InstanceOwnership? ownership = null) - { - var openKey = configuration?[OpenRegistrationKeyConfigKey]?.Trim() ?? ""; - if (openKey.Length == 0) - { - logger.LogInformation( - "Open registration for '{InstanceId}' refused — no {Key} is configured on this registry", - instanceId, OpenRegistrationKeyConfigKey); - return Observable.Throw(new InvalidBootstrapKeyException()); - } - if (!RegistrationKeys.HasKeyShape(openKey)) - { - // Misconfiguration, not a caller's fault — and it must not be fixed by accepting - // whatever string is there: the value decides whose instances these become. - logger.LogError( - "{Key} is set but is not a registration key (mwr_…) — open registration is " - + "misconfigured and refused", OpenRegistrationKeyConfigKey); - return Observable.Throw(new InvalidBootstrapKeyException()); - } - logger.LogInformation( - "Open registration for '{InstanceId}' — presenting the registry's open registration key", - instanceId); - return RegisterWithBootstrapKey(openKey, instanceId, displayName, description, homeUrl, ownership); - } - - /// The default grant entries the operator configured, parsed and de-blanked. - /// Empty when no list is set — the strict default. - private IReadOnlyList DefaultGrantEntries() => - configuration is null - ? [] - : configuration.GetSection(DefaultGrantsConfigKey).GetChildren() - .Select(c => PluginGrantEntry.TryParse(c.Value)) - .Where(e => e is not null) - .Select(e => e!) - .ToList(); - - /// - /// The seed a registration with plan adds: one <source>/* - /// entry per configured source (PluginCatalog:Sources:N:Name) — WHICH sources the - /// instance may see. At what level is the instance's own - /// (#2804), so the entries carry no @plan suffix: a suffix here would be a cap equal to - /// the plan, which is no cap, and a plan written in two places is a plan that drifts. - /// Empty for no plan — and, loudly, for a plan on a registry that configures no sources: a - /// key "for Pro customers" that seeds nothing is a registration that installs nothing, and - /// that must be legible in the log rather than discovered on the first boot. - /// - private IReadOnlyList PlanGrantEntries(string? tier) - { - var plan = PlanTierRanks.Canonical(tier); - if (plan.Length == 0) - return []; - var sources = configuration?.GetSection("PluginCatalog:Sources").GetChildren() - .Select(s => s["Name"]) - .Where(n => !string.IsNullOrWhiteSpace(n)) - .Select(n => n!) - .ToList() ?? []; - if (sources.Count == 0) - { - logger.LogWarning( - "Registration with plan '{Plan}': this registry configures no PluginCatalog:Sources, " - + "so the plan seeds no grant entry — the instance will be able to pull nothing", plan); - return []; - } - var now = DateTimeOffset.UtcNow; - return sources.Select(source => new PluginGrantEntry - { - Source = source, - PackageId = PluginGrantEntry.AllPackages, - IssuedVia = $"registration (plan {plan})", - IssuedAt = now, - }).ToList(); - } - - /// - /// Seeds the operator-configured default grant entries — plus the plan-scoped entries a - /// registration with earns () — into - /// Admin/_PluginGrant/{instanceId} as part of registration. No defaults and no plan → - /// no write at all, preserving "registering is identity, not entitlement" exactly. - /// - /// Runs as System on BOTH the read and the write: the grant lives in the Admin partition, - /// which the registering user cannot reach — that unreachability is the security model, and the - /// seed is registry policy, not a user action. Read-then-CreateOrUpdateNode, never - /// stream.Update: the node does not exist yet for a fresh id, and Update aborts on a - /// missing node (the first-grant failure observed live 2026-08-06). Existing entries — e.g. an - /// admin re-seeding after an orphan cleanup — are preserved; defaults merge in idempotently, and - /// an existing entry for a pair is never NARROWED by a plan-scoped seed for the same pair. - /// - private IObservable SeedDefaultGrants(string instanceId, string? tier = null) - { - var defaults = DefaultGrantEntries().Concat(PlanGrantEntries(tier)).ToList(); - if (defaults.Count == 0) - return Observable.Return(Unit.Default); - - var accessService = hub.ServiceProvider.GetRequiredService(); - var path = MeshWeaverInstanceNodeType.GrantPath(instanceId); - - // RunAsSystem for the read, never Observable.Using over ImpersonateAsSystem: the scope is - // AsyncLocal and Rx disposes a Using resource on whatever thread terminates the sequence, - // which leaked System into the caller's continuation (#1790). RunAsSystem enters at - // Subscribe and leaves on the way out of it; the write below opens its own scope. - return accessService.RunAsSystem(() => hub.GetMeshNode(path, TimeSpan.FromSeconds(10)).Take(1)) - .SelectMany(existing => Observable.Defer(() => - { - var grant = existing?.ContentAs(hub.JsonSerializerOptions) - ?? new PluginGrant { InstanceId = instanceId }; - var missing = defaults - .Where(d => !grant.Entries.Any(e => - string.Equals(e.Source, d.Source, StringComparison.OrdinalIgnoreCase) - && string.Equals(e.PackageId, d.PackageId, StringComparison.Ordinal))) - .ToList(); - // Nothing to add → no write at all. Writing a no-op would still re-stamp - // GrantedByUserId/UpdatedAt to System/now, silently erasing WHO last decided an - // existing admin-written grant — and a grant is an access decision, so the - // attribution is part of the record. - if (missing.Count == 0) - return Observable.Return(Unit.Default); - var entries = grant.Entries.Concat(missing).ToList(); - - var node = new MeshNode(instanceId, MeshWeaverInstanceNodeType.GrantNamespace) - { - Name = $"Plugin grant: {instanceId}", - NodeType = MeshWeaverInstanceNodeType.GrantNodeType, - State = MeshNodeState.Active, - Content = grant with - { - InstanceId = instanceId, - Entries = entries, - GrantedByUserId = WellKnownUsers.System, - UpdatedAt = DateTimeOffset.UtcNow, - }, - }; - - // Same Defer/Finally discipline as WriteIndex: the System context must be active - // when CreateOrUpdateNode captures it, and the SelectMany continuation may land on - // a thread the earlier Using scope never touched. - var disposable = accessService.ImpersonateAsSystem(); - return nodeFactory.CreateOrUpdateNode(node) - .Select(_ => - { - logger.LogInformation( - "Seeded default plugin grants [{Entries}] for instance {InstanceId}", - string.Join(", ", missing), instanceId); - return Unit.Default; - }) - .Finally(() => disposable.Dispose()); - })); - } - - /// - /// Mints a replacement key for an already-registered instance, writing the new index entry - /// before the instance node so there is no window where a valid key resolves to nothing. - /// The OLD index entry is left to be cleaned up separately — the instance node's hash is the - /// authority, so the stale entry stops authenticating the moment this completes. - /// - public IObservable ReissueKey(string instancePath) - { - var rawKey = InstanceKeys.Generate(); - var hash = InstanceKeys.Hash(rawKey); - var workspace = hub.GetWorkspace(); - - return workspace.GetMeshNodeStream(instancePath) - .Where(node => node is not null) - .Take(1) - .Timeout(TimeSpan.FromSeconds(10)) - .SelectMany(node => - { - var instance = node!.ContentAs(hub.JsonSerializerOptions) - ?? throw new InvalidOperationException($"Node {instancePath} is not a MeshWeaverInstance."); - return WriteIndex(hash, instancePath, instance.InstanceId) - .SelectMany(_ => workspace.GetMeshNodeStream(instancePath) - .Update(current => current with - { - Content = (current.ContentAs(hub.JsonSerializerOptions) - ?? instance) with - { - KeyHash = hash, - KeyIssuedAt = DateTimeOffset.UtcNow, - }, - })) - .Select(_ => - { - logger.LogInformation("Re-issued key for instance {InstanceId}", instance.InstanceId); - return rawKey; - }); - }); - } - - /// - /// Adopts a key that was minted ELSEWHERE — the Hosting operator's hosting-kv-rotate - /// job generates the raw mwi_ key, puts it straight into Key Vault, and reports only its - /// SHA-256 through the ::hosting:: key_hash= marker line. This is the registry-side half - /// of that rotation: the instance node takes the new hash, a fresh index entry points at it, - /// and the PREVIOUS index entry is deleted so the old key stops authenticating the moment this - /// completes ( left that entry "to be cleaned up separately"; a rotation - /// driven by a lifecycle verb must not). - /// - /// 🚨 The raw key never reaches this process. That is the point of the split: the - /// registry stores hashes, the operator holds the vault write, and nothing in between logs, - /// displays or persists the secret. A caller that already has the raw key and wants a hash for - /// it uses — never a log line. - /// - /// The MeshWeaverInstance node to re-key. - /// The lowercase SHA-256 hex of the new raw key (64 chars). - public IObservable AdoptKeyHash(string instancePath, string keyHash) - { - if (keyHash is not { Length: 64 } || !keyHash.All(Uri.IsHexDigit) || keyHash != keyHash.ToLowerInvariant()) - return Observable.Throw(new ArgumentException( - "keyHash must be the lowercase SHA-256 hex of the raw key (64 hex chars) — the registry " - + "stores hashes only, and this one arrived in the wrong shape", nameof(keyHash))); - - // 🚨 The same transition machinery as stage/commit/revoke (InstanceKeyRotation.Adopt): an - // immediate adoption supersedes a staged rotation, so a staged key is retired even when the - // adopted hash is ALREADY current — the idempotent short-cut used to return first and leave a - // staged key nobody installed authenticating (Copilot review on #4055). - return ApplyKeyTransition(instancePath, - (instance, now) => InstanceKeyRotation.Adopt(instance, keyHash, now), "Adopted") - .Select(_ => Unit.Default); - } - - /// - /// Resolves the instance NODE by id first — the operator knows the deployment's - /// instance id, never the owner's node path — then adopts on that path. - IObservable IInstanceKeyRegistry.AdoptKeyHash(string instanceId, string keyHash) => - ResolveInstancePath(instanceId).SelectMany(path => AdoptKeyHash(path, keyHash)); - - /// - /// - /// 🚨 A GLOBAL ADMINISTRATOR's act (hub.IsGlobalAdmin(), read off the caller's own - /// identity): it cuts an instance off by id, with no key presented, so - /// nothing short of the platform-admin grant may authorise it. The record's writes then run as - /// System, because the instance lives in its owner's partition, which a platform admin — not a - /// data superuser — cannot write. - /// - IObservable IInstanceKeyRegistry.RevokeKey(string instanceId) => - hub.IsGlobalAdmin() - .Take(1) - .SelectMany(isAdmin => isAdmin - ? ResolveInstancePath(instanceId) - .SelectMany(path => ApplyKeyTransition(path, - (instance, now) => InstanceKeyRotation.RevokeAll(instance, now), "Revoked every")) - .Select(_ => Unit.Default) - : Observable.Throw(new UnauthorizedAccessException( - $"revoking the keys of instance '{instanceId}' is a global administrator's act on the registry"))); - - /// - /// STAGES as the instance's next key — the first phase of a two-phase - /// rotation (, MeshWeaver#2802). Authorised by the hash of - /// the key the caller PRESENTED, which must be one of this instance's keys; afterwards both that - /// key and the new one authenticate, so nothing the instance holds stops working until the new - /// key has been proven to reach it (). Only hashes cross this - /// method. A refusal is an and writes nothing. - /// - /// The MeshWeaverInstance node the presented key resolved to. - /// Hash of the key the caller authenticated with. - /// Lowercase SHA-256 hex of the new raw key. - public IObservable StageKeyHash(string instancePath, string presentedHash, string newHash) => - ApplyKeyTransition(instancePath, - (instance, now) => InstanceKeyRotation.Stage(instance, presentedHash, newHash, now), "Staged"); - - /// - /// COMMITS the staged key — the second phase (). The - /// caller presents the STAGED key, which is the proof that the key the instance reads is the new - /// one; the old current key's index entry is deleted and it stops authenticating. - /// - /// The MeshWeaverInstance node the presented key resolved to. - /// Hash of the key the caller authenticated with. - public IObservable CommitStagedKey(string instancePath, string presentedHash) => - ApplyKeyTransition(instancePath, - (instance, now) => InstanceKeyRotation.Commit(instance, presentedHash, now), "Committed"); - - /// - /// REVOKES the presented key without a successor () - /// — for a key found somewhere it should not be, revoked by whoever can read it there, without - /// anybody having to know its value. - /// - /// The MeshWeaverInstance node the presented key resolved to. - /// Hash of the key the caller authenticated with. - public IObservable RevokePresentedKey(string instancePath, string presentedHash) => - ApplyKeyTransition(instancePath, - (instance, now) => InstanceKeyRotation.RevokePresented(instance, presentedHash, now), "Revoked"); - - /// - /// Applies one key transition: the NEW index entry first (so there is no window where a valid key - /// resolves to nothing), then the instance record, then the deletion of every retired hash's - /// index entry (the instance record is the authority, so a retired hash stops authenticating the - /// moment the record is written; the deletion removes the routing hint as well). The record - /// write sets only the key fields, onto the CURRENT content, so a concurrent - /// stamp is never reverted. - /// - private IObservable ApplyKeyTransition( - string instancePath, Func transition, string verb) - { - var workspace = hub.GetWorkspace(); - var accessService = hub.ServiceProvider.GetRequiredService(); - // 🚨 A FRESH point read, not the node stream's first frame. The stream replays the last - // value it holds, and the frame that arrives first can predate the write the PREVIOUS call - // in this rotation just made — measured on CI (#4055): an adoption read an instance whose - // staged key was not in its snapshot yet, decided the hash was new, and tried to write an - // index entry the stage had already created ("Node already exists"). The same staleness - // would make a COMMIT refuse a perfectly good staged key as "not a key of this instance", - // which is the worse half. GetMeshNode re-probes rather than replaying. - return accessService.RunAsSystem(() => hub.GetMeshNode(instancePath, TimeSpan.FromSeconds(10)).Take(1)) - .SelectMany(node => - { - if (node is null) - throw new InvalidOperationException( - $"no MeshWeaverInstance node at '{instancePath}' — a key transition acts on a registered instance"); - var instance = node.ContentAs(hub.JsonSerializerOptions) - ?? throw new InvalidOperationException($"Node {instancePath} is not a MeshWeaverInstance."); - var step = transition(instance, DateTimeOffset.UtcNow); - if (step.Refusal is { } refusal) - return Observable.Throw(new InstanceKeyRefusedException(instance.InstanceId, refusal)); - if (!step.Changed) - return Observable.Return(instance); - - var indexed = step.Indexed is { } hash - ? WriteIndex(hash, instancePath, instance.InstanceId).Select(_ => Unit.Default) - : Observable.Return(Unit.Default); - var next = step.Next; - return indexed - .SelectMany(_ => Observable.Defer(() => - { - // The record is the owner's node, in the owner's partition: the registry - // writes it as System — it already decided the caller holds a key of THIS - // instance (or is a global admin), which is the only authority a - // key-lifecycle call carries. Same Defer/Finally discipline as WriteIndex. - var disposable = accessService.ImpersonateAsSystem(); - return workspace.GetMeshNodeStream(instancePath) - .Update(current => current with - { - Content = (current.ContentAs(hub.JsonSerializerOptions) ?? instance) with - { - KeyHash = next.KeyHash, - KeyIssuedAt = next.KeyIssuedAt, - PendingKeyHash = next.PendingKeyHash, - PendingKeyIssuedAt = next.PendingKeyIssuedAt, - KeyRevokedAt = next.KeyRevokedAt, - }, - }) - .Finally(() => disposable.Dispose()); - })) - .SelectMany(_ => step.Retired.Select(DeleteIndex).Concat().DefaultIfEmpty(Unit.Default).LastAsync()) - .Select(_ => - { - logger.LogInformation( - "{Verb} key for instance {InstanceId}: current {Current}, staged {Staged}, retired [{Retired}]", - verb, instance.InstanceId, - string.IsNullOrEmpty(next.KeyHash) ? "none" : InstanceKeys.HashPrefix(next.KeyHash) + "…", - string.IsNullOrEmpty(next.PendingKeyHash) ? "none" : InstanceKeys.HashPrefix(next.PendingKeyHash) + "…", - string.Join(", ", step.Retired.Select(h => InstanceKeys.HashPrefix(h) + "…"))); - return next; - }); - }); - } - - /// - /// The path of the instance registered as in THIS registry's store, - /// or a failure naming the id. 🚨 Never a silent no-op: a lookup on a store that does not hold the - /// instance (a portal that is not the registry) must say so, or a rotation would believe it had - /// adopted a key that the real registry never saw. - /// - private IObservable ResolveInstancePath(string instanceId) - { - if (string.IsNullOrWhiteSpace(instanceId)) - return Observable.Throw(new ArgumentException("instanceId is required", nameof(instanceId))); - var meshService = hub.ServiceProvider.GetRequiredService(); - var accessService = hub.ServiceProvider.GetRequiredService(); - // An instance is keyed by its id but LIVES under whichever user registered it - // ({user}/MeshWeaverInstance/{id}), so a lookup by id has no partition to anchor to and - // must say so (#3202 — fan-out is opt-in). The durable fix is an id → owner index in a - // pinned partition, the way RegistrationKeyIndex already indexes keys. - var request = new MeshQueryRequest - { - Query = MeshWideQuery.Declare( - $"nodeType:{MeshWeaverInstanceNodeType.NodeType} id:{instanceId}"), - }; - // RunAsSystem, never `Observable.Using(() => ImpersonateAsSystem(), …)` — store and restore - // of the identity must land on the same thread (AGENTS.md; #1790). - // 🚨 The MERGED Initial, never the first frame of the progressive fan-in (seeded empty per - // provider, so it misses an instance held by a provider that answers asynchronously). - return accessService.RunAsSystem(() => meshService.Query(request)) - .Where(change => change.ChangeType == QueryChangeType.Initial) - .Take(1) - // No timer of its own: the fan-in's Initial is bounded by its own stall terminal - // (MeshOperationOptions.QueryInitialBudget), and a shorter local bound gave up inside - // it — the inverted ladder of #1198 (#5894). - .SelectMany(change => - { - // The index nodes share the NodeType and use the hash prefix as id, so an instance - // record is the hit whose id IS the instance id and whose namespace is not the index. - var node = change.Items.FirstOrDefault(n => - string.Equals(n.Id, instanceId, StringComparison.Ordinal) - && !string.Equals(n.Namespace, MeshWeaverInstanceNodeType.IndexNamespace, StringComparison.Ordinal)); - return node?.Path is null - ? Observable.Throw(new InstanceNotRegisteredException(instanceId)) - : Observable.Return(node.Path); - }); - } - - /// Removes the index entry for a superseded hash. Absent is fine — a first-time - /// adoption or an already-cleaned entry both leave nothing to delete. - private IObservable DeleteIndex(string? previousHash) - { - if (string.IsNullOrWhiteSpace(previousHash) || previousHash.Length < InstanceKeys.HashPrefixLength) - return Observable.Return(Unit.Default); - var accessService = hub.ServiceProvider.GetRequiredService(); - var indexPath = $"{MeshWeaverInstanceNodeType.IndexNamespace}/{InstanceKeys.HashPrefix(previousHash)}"; - return Observable.Defer(() => - { - var disposable = accessService.SwitchAccessContext( - new AccessContext { ObjectId = WellKnownUsers.System, Name = "system-security" }); - return nodeFactory.DeleteNode(indexPath) - .Select(_ => Unit.Default) - .Catch(ex => - { - // Absent already: not a failure. Anything else IS one — a stale entry that - // still authenticates is the hole this method exists to close. - logger.LogDebug(ex, "No index entry to remove at {Path}", indexPath); - return Observable.Return(Unit.Default); - }) - .Finally(() => disposable.Dispose()); - }); - } - - /// Enables or disables an instance. A disabled instance fails authentication while its - /// grants stay intact, so access can be cut without losing the grant history. - public IObservable SetDisabled(string instancePath, bool disabled) => - hub.GetWorkspace().GetMeshNodeStream(instancePath) - .Update(current => current with - { - Content = current.ContentAs(hub.JsonSerializerOptions) is { } instance - ? instance with { IsDisabled = disabled } - : current.Content, - }); - - /// - /// Whether no instance currently claims . An existence check is one - /// of the valid uses of the (eventually consistent) query index — reading the grant path would - /// not do, since the claim lives on the instance nodes. - /// - /// Reads the MERGED Initial of — never - /// the first frame of the progressive fan-in, which is seeded empty per provider and therefore - /// answered "available" for an id a slower provider holds. Running as System: the claim is - /// global, and a caller must not be told "available" merely because someone else's instance is - /// invisible to them. - /// - public IObservable IsIdAvailable(string instanceId) - { - var meshService = hub.ServiceProvider.GetRequiredService(); - var accessService = hub.ServiceProvider.GetRequiredService(); - // An instance is keyed by its id but LIVES under whichever user registered it - // ({user}/MeshWeaverInstance/{id}), so a lookup by id has no partition to anchor to and - // must say so (#3202 — fan-out is opt-in). The durable fix is an id → owner index in a - // pinned partition, the way RegistrationKeyIndex already indexes keys. - var request = new MeshQueryRequest - { - Query = MeshWideQuery.Declare( - $"nodeType:{MeshWeaverInstanceNodeType.NodeType} id:{instanceId}"), - }; - // RunAsSystem, never `Observable.Using(() => ImpersonateAsSystem(), …)` — store and restore - // of the identity must land on the same thread (AGENTS.md; #1790). - return accessService.RunAsSystem(() => meshService.Query(request)) - .Where(change => change.ChangeType == QueryChangeType.Initial) - .Take(1) - // No timer of its own: the fan-in's Initial is bounded by its own stall terminal - // (MeshOperationOptions.QueryInitialBudget), and a shorter local bound gave up inside - // it — the inverted ladder of #1198 (#5894). - .Select(change => change.Items.Count == 0); - } - - // The global index namespace is not user-writable — the same System-scoped write ApiTokenService - // does for ApiTokenIndex. The Defer/Finally layout matters: the System context must be active - // during CaptureContext at Subscribe time, not merely when the using-block was constructed. - // - // 🚨 The index lives at the TOP-LEVEL path MeshWeaverInstance/{keyHashPrefix}, and a top-level - // segment is its own Postgres schema. The router does NOT lazy-create schemas, so without the - // provisioning step the FIRST registration dies with - // 42P01: relation "meshweaverinstance.mesh_nodes" does not exist - // (observed on memex-cloud 2026-08-06). Provisioned HERE rather than declared in - // DefaultPartitionProvider on purpose: an eager declaration provisions the schema at EVERY - // mesh boot — including every test mesh — for a partition that is written only when someone - // registers an installation. ApiToken/VUser are declared eagerly because they sit on a - // per-request path and cannot afford a first-request provision; registration is rare and - // deliberate, so it takes the same lazy route PackageInstaller uses for its target partition. - // EnsurePartitionProvisioned is idempotent and promise-cached; non-Postgres providers no-op. - private IObservable WriteIndex(string hash, string instancePath, string instanceId) - { - var accessService = hub.ServiceProvider.GetRequiredService(); - var indexNode = new MeshNode( - InstanceKeys.HashPrefix(hash), MeshWeaverInstanceNodeType.IndexNamespace) - { - Name = $"Instance key: {instanceId}", - NodeType = MeshWeaverInstanceNodeType.NodeType, - State = MeshNodeState.Active, - Content = new MeshWeaverInstanceIndex - { - KeyHash = hash, - InstancePath = instancePath, - InstanceId = instanceId, - }, - }; - - var providers = hub.ServiceProvider.GetServices().ToArray(); - var provisioned = providers.Length == 0 - ? Observable.Return(Unit.Default) - : Observable.Merge(providers.Select(p => - p.EnsurePartitionProvisioned(MeshWeaverInstanceNodeType.IndexNamespace))) - .DefaultIfEmpty(Unit.Default) - .LastAsync(); - - return provisioned.SelectMany(_ => Observable.Defer(() => - { - var disposable = accessService.SwitchAccessContext( - new AccessContext { ObjectId = WellKnownUsers.System, Name = "system-security" }); - return nodeFactory.CreateNode(indexNode) - // 🚨 Writing the SAME entry twice is not a failure — a rotation can legitimately - // re-derive an index it already wrote (a re-run, or a read that lagged). But a - // DIFFERENT instance behind the same 12-hex prefix is a real collision and must - // stay loud: the older key's routing would be silently repointed, and it would stop - // authenticating with nothing said (#4055). - .Catch((Exception ex) => Observable - .Defer(() => hub.GetMeshNode(indexNode.Path, TimeSpan.FromSeconds(10)).Take(1)) - .SelectMany(existing => - { - var content = existing?.ContentAs(hub.JsonSerializerOptions); - return content is not null - && InstanceKeys.HashEquals(hash, content.KeyHash) - && string.Equals(content.InstancePath, instancePath, StringComparison.Ordinal) - ? Observable.Return(existing!) - : Observable.Throw(ex); - })) - .Finally(() => disposable.Dispose()); - })); - } -} - -/// -/// The outcome of registering an instance. is the ONLY time the key is -/// available in the clear — only its hash is stored, so it must be shown to the user now or lost. -/// -public sealed record InstanceRegistrationResult(string RawKey, MeshNode Node, MeshWeaverInstance Instance); - -/// The requested instance id is already claimed. Typed so callers (the registration -/// endpoint's 409) can branch on it without matching message text. -public sealed class InstanceIdTakenException(string instanceId) - : InvalidOperationException($"Instance id '{instanceId}' is already registered.") -{ - /// The id that was requested. - public string InstanceId { get; } = instanceId; -} - -/// This registry's store holds no instance with the id — the named, hard failure a key -/// lifecycle call on the WRONG portal gets (MeshWeaver#2802), never a silent no-op. -public sealed class InstanceNotRegisteredException(string instanceId) - : InvalidOperationException($"no MeshWeaverInstance is registered as '{instanceId}' in this registry — nothing to rotate or revoke") -{ - /// The id that was looked up. - public string InstanceId { get; } = instanceId; -} - -/// A key transition the rules refuse () — the key -/// endpoints' 409. Nothing was written. -public sealed class InstanceKeyRefusedException(string instanceId, string reason) - : InvalidOperationException($"instance '{instanceId}': {reason}") -{ - /// The instance the transition was asked of. - public string InstanceId { get; } = instanceId; - - /// Why it was refused. - public string Reason { get; } = reason; -} - -/// The presented registration bootstrap key is unknown, revoked, expired or used up — the -/// registration endpoint's 401. The message deliberately does not say which. -public sealed class InvalidBootstrapKeyException() - : InvalidOperationException("A valid registration bootstrap key is required (mwr_…)."); diff --git a/memex/Memex.Portal.Shared/Authentication/MicrosoftTenant.cs b/memex/Memex.Portal.Shared/Authentication/MicrosoftTenant.cs deleted file mode 100644 index 5fb48c08ea..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/MicrosoftTenant.cs +++ /dev/null @@ -1,133 +0,0 @@ -namespace Memex.Portal.Shared.Authentication; - -/// -/// The one place a Microsoft (Entra) authority is composed from the configured tenant — -/// https://login.microsoftonline.com/{tenant}/{path} — and the guard that refuses a tenant -/// which cannot form one. -/// -/// 🚨 Why this exists (MeshWeaver#2621). An environment variable or configMap entry -/// cannot be null, only EMPTY, so "" is the deployed shape of "unset". The portal chart -/// rendered Authentication__Microsoft__TenantId with an empty default and the composition -/// read that empty string AS the tenant: the authority became -/// login.microsoftonline.com//v2.0, whose discovery document Entra will never serve -/// (IDX20803). Every Microsoft sign-in on memex-cloud answered 500, per request, -/// deterministically — and the 500 named an unreachable URL rather than the configuration key an -/// operator had to set. -/// -/// Two rules, and they are deliberately different. -/// -/// Blank is UNSET, never a tenant. Null, empty and whitespace all resolve -/// to — the same as no key at all. This must NOT refuse: an install that never -/// wired Microsoft sign-in leaves the key blank, and aborting a portal because an optional -/// integration is unconfigured is the #2510 failure (a guard that fails worse than the thing it -/// guards). -/// A present-but-malformed tenant is REFUSED, at startup. A value that is -/// not a single URL path segment cannot compose an authority at all, so no install is serving -/// sign-in with one today — refusing it at boot cannot take a working portal down, and it converts -/// the outage's per-request 500 into a named configuration error that says which key is -/// wrong. -/// -/// -/// Pure decision, no I/O and no container, so the boot-time guard is unit-testable without -/// spinning a host — the same shape as MemexConfiguration.ValidateContentStorageDurability. -/// The sign-in handler that actually 500-ed lives in the portal package -/// (MeshWeaver.Blazor.Portal.Authentication.AuthenticationBuilderExtensions, plugins repo) -/// and carries the same two rules at its registration; this covers every authority composed -/// platform-side, starting with the Executive Assistant's delegated Graph flow. -/// -public static class MicrosoftTenant -{ - /// The configuration key the tenant is read from, in binder form. - public const string ConfigurationKey = "Authentication:Microsoft:TenantId"; - - /// - /// The same key as an environment variable / configMap entry — the form an operator actually - /// sets, and the form that must appear in a refusal so the message is actionable in a pod. - /// - public const string EnvironmentKey = "Authentication__Microsoft__TenantId"; - - /// The Entra login host every authority is composed against. - public const string Host = "login.microsoftonline.com"; - - /// - /// The tenant an UNSET key resolves to: the multi-tenant common authority, which accepts - /// both work/school and personal accounts. An environment that needs a single tenant, or the - /// work/school-only organizations authority the onboarding guide prescribes, sets its - /// value explicitly. - /// - public const string Unset = "common"; - - /// - /// The tenant to compose an authority from: the configured value, trimmed, or - /// when the key is absent or blank. - /// - /// The raw configured value (null when the key is absent). - /// A tenant that is guaranteed to form a single, non-empty authority segment. - /// - /// The value is present but is not a single URL path segment, so no authority can be composed - /// from it. The message names and the accepted values. - /// - public static string Resolve(string? configured) - { - // Blank is the deployed shape of "unset" — see rule 1 on the type. - if (string.IsNullOrWhiteSpace(configured)) - return Unset; - - var tenant = configured.Trim(); - // A tenant id is a GUID, a verified domain (contoso.onmicrosoft.com), or one of the - // well-known multi-tenant aliases — all of which are a single path segment of letters, - // digits, '-', '.' and '_'. Anything else ('/', whitespace, '?', '#', ':') would either - // split the path or need escaping, and both compose a URL Entra never serves. - foreach (var c in tenant) - if (!char.IsAsciiLetterOrDigit(c) && c != '-' && c != '.' && c != '_') - throw new InvalidOperationException(Refusal(configured)); - - return tenant; - } - - /// - /// Composes https://login.microsoftonline.com/{tenant}/{path} from the configured tenant. - /// - /// The raw configured value (null when the key is absent). - /// The authority path after the tenant, e.g. v2.0 or oauth2/v2.0. - /// An absolute authority with no empty segment. - /// - /// The configured tenant cannot form an authority — see . - /// - public static string Authority(string? configured, string path) - { - var authority = $"https://{Host}/{Resolve(configured)}/{path}"; - // Belt and braces: the composed URL is re-read, so an EMPTY SEGMENT can never leave this - // method however the tenant rule above evolves. `//` in the path is the outage's exact - // signature, and it is cheaper to assert here than to recognise in a log at 13:50Z. - if (!Uri.TryCreate(authority, UriKind.Absolute, out var uri) - || uri.Host != Host - || uri.AbsolutePath.Contains("//", StringComparison.Ordinal) - || uri.AbsolutePath.EndsWith('/')) - throw new InvalidOperationException(Refusal(configured)); - return authority; - } - - /// - /// The BOOT-TIME guard: throws when the configured tenant cannot form an authority, so the - /// misconfiguration is named at startup instead of surfacing as a 500 on the first sign-in. - /// A blank or absent key is legitimate (it means "unset") and passes. - /// - /// The raw configured value (null when the key is absent). - /// - /// The value is present but malformed; the message names the configuration key. - /// - public static void Validate(string? configured) => Authority(configured, "v2.0"); - - private static string Refusal(string? configured) => - $"Microsoft sign-in misconfiguration (issue #2621): {EnvironmentKey} ('{configured}') is not a " - + "tenant. It has to be a single authority segment — an Entra tenant GUID, a verified domain " - + $"such as 'contoso.onmicrosoft.com', or one of '{Unset}' (work/school and personal), " - + "'organizations' (work/school only — what the onboarding guide prescribes for a public " - + $"portal) and 'consumers'. Composing https://{Host}//... from this value yields a " - + "URL Entra will never serve a discovery document for, so EVERY Microsoft sign-in would " - + $"answer 500 at the first request that touched the handler. Set {EnvironmentKey} to a valid " - + "tenant, or leave it unset (an unset key means the multi-tenant " - + $"'{Unset}' authority). Refusing to start so the misconfiguration surfaces now rather than " - + "on the first user who tries to sign in."; -} diff --git a/memex/Memex.Portal.Shared/Authentication/OAuthCodeNodeType.cs b/memex/Memex.Portal.Shared/Authentication/OAuthCodeNodeType.cs deleted file mode 100644 index 3566a34888..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/OAuthCodeNodeType.cs +++ /dev/null @@ -1,53 +0,0 @@ -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// NodeType definition for nodes — the pending OAuth -/// authorization codes persists at -/// Admin/OAuthCode/{hashPrefix} so a /token exchange on ANY portal replica can -/// redeem a code minted by any other (the in-memory predecessor broke under KEDA -/// scale-out). Short-lived infrastructure rows: single-use (consumed by delete on -/// exchange), 5-minute lifetime, System-identity managed — excluded from search, -/// create menus, and autocomplete. -/// -public static class OAuthCodeNodeType -{ - /// The node-type identifier string for OAuth authorization-code nodes. - public const string NodeType = "OAuthCode"; - - /// - /// Registers the OAuthCode node type on the mesh builder: adds the MeshNode type - /// definition, excludes it from autocomplete, and registers the - /// content type so code nodes (de)serialize across - /// silos and persistence round-trips. - /// - public static TBuilder AddOAuthCodeType(this TBuilder builder) where TBuilder : MeshBuilder - { - builder.AddMeshNodes(CreateMeshNode()); - builder.AddAutocompleteExcludedTypes(NodeType); - builder.WithMeshType(); - return builder; - } - - /// Builds the MeshNode type definition for OAuth authorization codes. - public static MeshNode CreateMeshNode() => new(NodeType) - { - Name = "OAuth Authorization Code", - NodeType = "NodeType", - Icon = "/static/NodeTypeIcons/key.svg", - IsSatelliteType = false, - ExcludeFromContext = System.Collections.Immutable.ImmutableHashSet.Create("search", "create"), - Content = new NodeTypeDefinition - { - Description = "Pending OAuth authorization code (PKCE) awaiting its /token exchange. " - + "Stored under Admin/OAuthCode/{hashPrefix}; single-use and short-lived.", - }, - HubConfiguration = config => config - .AddMeshDataSource(source => source - .WithContentType()) - }; -} diff --git a/memex/Memex.Portal.Shared/Authentication/OAuthCodeStore.cs b/memex/Memex.Portal.Shared/Authentication/OAuthCodeStore.cs deleted file mode 100644 index b3d295f9fa..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/OAuthCodeStore.cs +++ /dev/null @@ -1,304 +0,0 @@ -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Storage-backed store for OAuth authorization codes with PKCE support. -/// -/// -/// Each pending code is a row at Admin/OAuthCode/{hashPrefix} written and read -/// DIRECTLY through — the shared Postgres store on -/// multi-replica portals. Auth is the front door: it must be deterministic on every -/// replica and must not depend on the mesh's cross-silo routing/subscribe machinery -/// being healthy. Both prior shapes failed multi-replica: the in-memory dictionary -/// broke the moment KEDA scaled past one pod (prod 2026-07-23), and the mesh-node -/// version that replaced it (#620) rode GetMeshNodeStream/DeleteNode -/// cross-silo — /token on a non-minting pod hit "[ROUTE] No node found at -/// Admin/OAuthCode/…" with 40 s stale SubscribeRequests while the row sat in -/// Postgres all along (memex-cloud 2026-07-25). -/// -/// -/// -/// The storage-direct shape closes both races by construction: -/// emits only after -/// committed the row — the client cannot present a code any replica can't see — -/// and consumption is , whose single -/// DELETE row count is the atomic cross-replica "first delete wins" gate (a lost -/// race — duplicate callback, replay against another replica — surfaces as a -/// failure, never a second success). -/// -/// -/// -/// The raw code is never persisted — the node id is the first 12 chars of the code's -/// SHA-256 hash and the content carries the full hash (same scheme as -/// ). Codes expire after . -/// 🚨 No async/await/Task in this file — the surface is -/// end-to-end; bridges at the HTTP boundary only. -/// -/// -internal class OAuthCodeStore(IStorageAdapter storage, IMessageHub hub, ILogger logger) -{ - private const string NodeTypeOAuthCode = "OAuthCode"; - private const string CodeNamespace = "Admin/OAuthCode"; - - /// - /// Codes expire 5 minutes after issuance (RFC 6749 §4.1.2 recommends ≤10). - /// Init-settable so tests can pin the expiry branch deterministically - /// (a zero lifetime makes every issued code already expired) instead of sleeping. - /// - internal TimeSpan CodeLifetime { get; init; } = TimeSpan.FromMinutes(5); - - /// - /// Generates a new authorization code and persists it straight to the shared - /// store. Cold observable — the write happens on Subscribe and the raw code is - /// emitted only once the row is committed, so a following /token exchange on - /// ANY replica reads it deterministically. Errors propagate: a code that could - /// not be persisted must not be handed to the client. - /// - public IObservable GenerateCode( - string userId, - string userName, - string userEmail, - string clientId, - string redirectUri, - string? codeChallenge, - string? codeChallengeMethod) - { - // Opportunistic, fire-and-forget sweep of expired sibling codes (abandoned - // authorize flows would otherwise accumulate forever). No timer/watchdog — - // it only ever runs on the reactive generate path. - CleanupExpired(); - - var code = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - var hash = HashRawCode(code); - var hashPrefix = hash[..HashPrefixLength]; - - var entry = new AuthorizationCode - { - CodeHash = hash, - UserId = userId, - UserName = userName, - UserEmail = userEmail, - ClientId = clientId, - RedirectUri = redirectUri, - CodeChallenge = codeChallenge, - CodeChallengeMethod = codeChallengeMethod, - CreatedAt = DateTimeOffset.UtcNow, - }; - - var node = new MeshNode(hashPrefix, CodeNamespace) - { - Name = $"OAuth code {hashPrefix}", - NodeType = NodeTypeOAuthCode, - State = MeshNodeState.Active, - Content = entry, - }; - - return storage.Write(node, hub.JsonSerializerOptions).Select(_ => code); - } - - /// - /// Exchanges an authorization code for the stored entry. - /// Emits a whose - /// is null on failure — with the exact failing check in - /// so the caller can log a diagnosable - /// warning (a bare "invalid_grant" made real-world flow failures unattributable). - /// Validates PKCE code_verifier if a code_challenge was stored. - /// - /// - /// Consume-first is intentional: a code is single-use on ANY exchange attempt (standard - /// OAuth hardening — prevents retry brute-force). The consume is the atomic - /// row delete, and the exchange is only - /// honored when THIS caller's delete removed the row — a lost race (duplicate callback, - /// replay against another replica) surfaces as a failure, never as a second success. - /// No read-retry is needed: completes only after the row - /// committed, so by the time the client presents the code, every replica sees it. - /// - /// - public IObservable ExchangeCode( - string code, string clientId, string redirectUri, string? codeVerifier) - { - if (string.IsNullOrEmpty(code)) - return Observable.Return(CodeExchangeResult.Failure(UnknownCodeReason)); - - var hash = HashRawCode(code); - var path = $"{CodeNamespace}/{hash[..HashPrefixLength]}"; - - return storage.Read(path, hub.JsonSerializerOptions) - .SelectMany(node => - { - var entry = ExtractEntry(node); - if (entry is null - || !string.Equals(entry.CodeHash, hash, StringComparison.OrdinalIgnoreCase)) - return Observable.Return(CodeExchangeResult.Failure(UnknownCodeReason)); - - // Consume — the atomic cross-replica single-use gate. DeleteIfExists - // emits false when another exchange already consumed the code between - // our read and this delete; that loser maps to invalid_grant. Any - // infrastructure failure propagates so it surfaces as a server error, - // not a silent invalid_grant. - return storage.DeleteIfExists(path) - .Select(consumed => consumed - ? Validate(entry, clientId, redirectUri, codeVerifier) - : CodeExchangeResult.Failure( - "already consumed: lost the single-use consume race (first delete wins) " - + "— e.g. a duplicate callback or the same code replayed against another replica")); - }); - } - - /// - /// Post-consume validation — runs only for the caller whose delete won. - /// Same checks and failure-reason strings as always: expiry, client_id, - /// redirect_uri, then PKCE. The row is already deleted at this point, so an - /// expired code is rejected AND gone (no separate cleanup needed for it). - /// - private CodeExchangeResult Validate( - AuthorizationCode entry, string clientId, string redirectUri, string? codeVerifier) - { - var age = DateTimeOffset.UtcNow - entry.CreatedAt; - if (age > CodeLifetime) - return CodeExchangeResult.Failure( - $"expired: age {(int)age.TotalSeconds}s > lifetime {(int)CodeLifetime.TotalSeconds}s"); - - if (!string.Equals(entry.ClientId, clientId, StringComparison.Ordinal)) - return CodeExchangeResult.Failure("client_id mismatch between /authorize and /token"); - if (!string.Equals(entry.RedirectUri, redirectUri, StringComparison.Ordinal)) - return CodeExchangeResult.Failure("redirect_uri mismatch between /authorize and /token"); - - if (!string.IsNullOrEmpty(entry.CodeChallenge)) - { - if (string.IsNullOrEmpty(codeVerifier)) - return CodeExchangeResult.Failure( - "PKCE code_verifier missing (a code_challenge was supplied at /authorize)"); - - if (!VerifyPkce(codeVerifier, entry.CodeChallenge, entry.CodeChallengeMethod)) - return CodeExchangeResult.Failure( - "PKCE verification failed (code_verifier does not match code_challenge)"); - } - - return new CodeExchangeResult(entry, null); - } - - private static bool VerifyPkce(string codeVerifier, string codeChallenge, string? method) - { - if (string.Equals(method, "S256", StringComparison.OrdinalIgnoreCase)) - { - var hash = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier)); - var computed = Convert.ToBase64String(hash) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - return string.Equals(computed, codeChallenge, StringComparison.Ordinal); - } - - // plain method (or no method specified) - return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal); - } - - /// - /// Fire-and-forget sweep of expired code rows, run on the generate path. - /// Lists the namespace's children straight off the storage adapter, reads each - /// row, and deletes the expired ones best-effort — losing a delete race against - /// a concurrent sweep or exchange on another replica is the expected outcome for - /// a loser and only logged, while an unexpected sweep failure surfaces as a warning. - /// - private void CleanupExpired() - { - var cutoff = DateTimeOffset.UtcNow - CodeLifetime; - storage.ListChildPaths(CodeNamespace) - .Take(1) - .SelectMany(listing => listing.NodePaths - .Select(path => storage.Read(path, hub.JsonSerializerOptions) - .Where(n => ExtractEntry(n) is { } e && e.CreatedAt < cutoff) - .SelectMany(_ => storage.DeleteIfExists(path)) - .Catch(ex => - { - logger.LogDebug(ex, - "Expired OAuth code cleanup skipped {Path} (already gone or delete rejected)", - path); - return Observable.Return(false); - })) - .Merge()) - .Subscribe( - _ => { }, - ex => logger.LogWarning(ex, "Expired OAuth code cleanup sweep failed")); - } - - private const int HashPrefixLength = 12; - - private static string HashRawCode(string code) - => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code))).ToLowerInvariant(); - - /// - /// The storage path a raw code's row lives at — exposed for tests - /// (visibility waits, expiry manipulation) via InternalsVisibleTo. - /// - internal static string PathForCode(string code) - => $"{CodeNamespace}/{HashRawCode(code)[..HashPrefixLength]}"; - - /// - /// Node content → . Rows read back through the - /// storage adapter usually carry a JsonElement payload; the direct-CLR case covers - /// in-memory adapters that round-trip the typed record — same fallback shape as - /// ApiTokenService.ExtractApiToken. - /// - private static AuthorizationCode? ExtractEntry(MeshNode? node) - { - switch (node?.Content) - { - case AuthorizationCode direct: - return direct; - case System.Text.Json.JsonElement jsonElement: - try - { - return System.Text.Json.JsonSerializer.Deserialize( - jsonElement.GetRawText(), - new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - } - catch - { - return null; - } - default: - return null; - } - } - - private const string UnknownCodeReason = - "unknown or already consumed code (never issued, expired-and-cleaned, or burnt by an " - + "earlier exchange attempt on any replica — e.g. a duplicate callback)"; -} - -/// -/// Persisted content of an Admin/OAuthCode/{hashPrefix} row. Carries the full -/// SHA-256 hash of the code (never the raw code) plus everything the /token exchange -/// validates and the token issuance needs. -/// -internal record AuthorizationCode -{ - public required string CodeHash { get; init; } - public required string UserId { get; init; } - public required string UserName { get; init; } - public required string UserEmail { get; init; } - public required string ClientId { get; init; } - public required string RedirectUri { get; init; } - public string? CodeChallenge { get; init; } - public string? CodeChallengeMethod { get; init; } - public DateTimeOffset CreatedAt { get; init; } -} - -/// -/// Result of : exactly one of -/// (success) or (the exact failing -/// check, logged by the /token endpoint — the wire response stays a generic -/// invalid_grant per RFC 6749). -/// -internal record CodeExchangeResult(AuthorizationCode? Entry, string? FailureReason) -{ - public static CodeExchangeResult Failure(string reason) => new(null, reason); -} diff --git a/memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs b/memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs deleted file mode 100644 index e0da75f1f1..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs +++ /dev/null @@ -1,672 +0,0 @@ -using System.Linq; -using System.Reactive.Linq; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text.Json.Serialization; -using MeshWeaver.Hosting.AspNetCore.Portal; // PortalApplication -using MeshWeaver.Mesh.Security; // ApiToken (the minted token content) -using MeshWeaver.Messaging; // AccessService / AccessContext -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Minimal OAuth 2.0 authorization server for MCP clients (claude.ai Connectors, Claude Desktop). -/// Implements authorization code flow with PKCE + RFC 7591 Dynamic Client Registration. -/// Issues mw_ API tokens as access tokens, reusing the existing ApiTokenService infrastructure. -/// -[ApiController] -public class OAuthConnectController( - IServiceProvider serviceProvider, - ILogger logger) : ControllerBase -{ - private OAuthCodeStore CodeStore => serviceProvider.GetRequiredService(); - private ApiTokenService TokenService => serviceProvider.GetRequiredService(); - - /// - /// How many live OAuth credentials one (user, client_id) keeps — see - /// . Optional configuration: a provider without one (a test's - /// bare service collection) gets the default. - /// - private int MaxLiveCredentialsPerClient => - OAuthCredentialBound.From(serviceProvider.GetService()); - - /// - /// Resolves the mesh User.Id for the issued token. 🚨 It MUST be the mesh - /// User.Id (e.g. rbuergi), NEVER the raw preferred_username claim — Entra - /// fills that with the email/UPN, and an email userId routes the token node + its - /// _Access self-scope into a non-existent {email} partition (401 on every - /// freshly-minted token once the router stopped lazy-creating schemas). - /// Prefers the authoritative identity UserContextMiddleware stamped on the - /// portal hub's (email→User.Id); falls back to normalising the - /// claim to the username (email local-part) when no resolved context is present — e.g. - /// controller unit tests, or any call before the middleware ran. - /// - private string? ResolveMeshUserId() - { - var ctx = serviceProvider.GetService()? - .Hub.ServiceProvider.GetService()?.Context; - var resolved = ctx?.ObjectId; - if (!string.IsNullOrEmpty(resolved) && !resolved.Contains('@')) - return resolved; - var claim = User.FindFirstValue("preferred_username") ?? User.FindFirstValue(ClaimTypes.Email); - return UsernameFromEmail(claim); - } - - /// Email-shaped identifier → its local part (the post-v10 username / mesh - /// partition key, e.g. rbuergi@systemorph.com → rbuergi); unchanged when there's no @. - private static string? UsernameFromEmail(string? value) - { - if (string.IsNullOrEmpty(value)) return null; - var at = value.IndexOf('@'); - return at > 0 ? value[..at] : value; - } - - /// - /// RFC 8414 — OAuth Authorization Server Metadata. - /// MCP clients discover this via the authorization_servers URL from the protected resource metadata. - /// - [HttpGet("/.well-known/oauth-authorization-server")] - [AllowAnonymous] - public IActionResult GetServerMetadata() - { - var origin = $"{Request.Scheme}://{Request.Host}"; - logger.LogInformation("OAuth metadata requested from {Origin}", origin); - return Ok(new - { - issuer = origin, - authorization_endpoint = $"{origin}/authorize", - token_endpoint = $"{origin}/token", - registration_endpoint = $"{origin}/register", - response_types_supported = new[] { "code" }, - grant_types_supported = new[] { "authorization_code" }, - code_challenge_methods_supported = new[] { "S256" }, - token_endpoint_auth_methods_supported = new[] { "none" }, - }); - } - - /// - /// RFC 7591 — Dynamic Client Registration. - /// MCP clients (Claude Desktop, claude.ai Connectors) self-register here with their redirect URIs - /// before running the authorization flow. The client_id is DERIVED DETERMINISTICALLY from the - /// client's own metadata (name + redirect URIs) and this deployment's origin, so a client that - /// re-registers — which MCP clients do on every reconnect — gets the SAME id back and is recognised - /// as the same application. - /// - /// 🚨 It used to be RandomNumberGenerator, and that is what made users re-consent on - /// every single connection: a fresh random id is, to the authorization server and to any consent - /// record keyed on it, a brand-new application. Nothing else changes — the id is still opaque to the - /// caller and echoed back in /authorize and /token, where the code store validates - /// client_id+redirect_uri consistency between the two calls. - /// - /// A client_id is public by construction in OAuth (these are PUBLIC clients: - /// token_endpoint_auth_method=none), so deriving rather than randomising it gives away - /// nothing — the flow's security rests on exact redirect_uri matching plus PKCE, both unchanged. - /// The origin is folded into the hash so ids are not shared across deployments. - /// - [HttpPost("/register")] - [AllowAnonymous] - public IActionResult RegisterClient([FromBody] ClientRegistrationRequest? request) - { - if (request is null) - { - logger.LogWarning("OAuth /register called with empty or invalid body"); - return BadRequest(new { error = "invalid_client_metadata", error_description = "Request body is required" }); - } - - logger.LogInformation( - "OAuth client registration: client_name={ClientName}, redirect_uris={RedirectUris}, grant_types={GrantTypes}, auth_method={AuthMethod}", - request.ClientName ?? "(unset)", - request.RedirectUris is null ? "(none)" : string.Join(",", request.RedirectUris), - request.GrantTypes is null ? "(unset)" : string.Join(",", request.GrantTypes), - request.TokenEndpointAuthMethod ?? "(unset)"); - - if (request.RedirectUris is null || request.RedirectUris.Length == 0) - { - logger.LogWarning("OAuth /register rejected: redirect_uris missing for client {ClientName}", request.ClientName); - return BadRequest(new { error = "invalid_redirect_uri", error_description = "redirect_uris is required" }); - } - - var clientId = DeriveClientId( - $"{Request.Scheme}://{Request.Host}", request.ClientName, request.RedirectUris); - - var response = new ClientRegistrationResponse - { - ClientId = clientId, - ClientIdIssuedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - ClientName = request.ClientName, - RedirectUris = request.RedirectUris, - GrantTypes = request.GrantTypes ?? new[] { "authorization_code" }, - ResponseTypes = request.ResponseTypes ?? new[] { "code" }, - TokenEndpointAuthMethod = request.TokenEndpointAuthMethod ?? "none", - }; - - logger.LogInformation( - "Issued OAuth client_id {ClientId} for {ClientName} (derived — a re-registering client receives the same id)", - clientId, request.ClientName); - return StatusCode(StatusCodes.Status201Created, response); - } - - /// - /// Derives a stable client_id from the registering client's own metadata. - /// Redirect URIs are ordered before hashing so the id does not depend on the order the client - /// happened to list them in, and the deployment origin is included so the same client registering - /// against two portals receives two distinct ids. - /// - internal static string DeriveClientId(string origin, string? clientName, string[] redirectUris) - { - var canonical = string.Join( - "\n", - new[] { origin, clientName?.Trim() ?? string.Empty } - .Concat(redirectUris - .Select(u => u?.Trim() ?? string.Empty) - .OrderBy(u => u, StringComparer.Ordinal))); - - var digest = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(canonical)); - return Convert.ToBase64String(digest, 0, 24) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - } - - /// - /// OAuth Authorization Endpoint — redirects authenticated users to the client's redirect_uri - /// with an authorization code. Unauthenticated users are sent to /login first. - /// The code is persisted as a mesh node (replica-safe: the /token exchange may land on a - /// different pod), so this action bridges the store's IObservable to Task at the HTTP - /// boundary — same single-bridge shape as . - /// - [HttpGet("/authorize")] - public Task Authorize( - [FromQuery] string response_type, - [FromQuery] string client_id, - [FromQuery] string redirect_uri, - [FromQuery] string? state, - [FromQuery] string? scope, - [FromQuery] string? code_challenge, - [FromQuery] string? code_challenge_method, - CancellationToken ct = default) - { - logger.LogInformation( - "OAuth /authorize: response_type={ResponseType}, client_id={ClientId}, redirect_uri={RedirectUri}, has_state={HasState}, has_pkce={HasPkce}, authenticated={Authenticated}", - response_type, client_id, redirect_uri, - !string.IsNullOrEmpty(state), !string.IsNullOrEmpty(code_challenge), - User?.Identity?.IsAuthenticated == true); - - if (response_type != "code") - { - logger.LogWarning("OAuth /authorize rejected: unsupported response_type={ResponseType}", response_type); - return Task.FromResult(BadRequest(new { error = "unsupported_response_type" })); - } - - if (string.IsNullOrEmpty(client_id) || string.IsNullOrEmpty(redirect_uri)) - { - logger.LogWarning("OAuth /authorize rejected: missing client_id or redirect_uri"); - return Task.FromResult(BadRequest(new { error = "invalid_request", error_description = "client_id and redirect_uri are required" })); - } - - // If user is not authenticated, redirect to login carrying THIS request as the return URL. - // - // 🚨 The returnUrl is a LOCAL path — `{Path}{QueryString}`, never `{Scheme}://{Host}{Path}…`. - // Every returnUrl SINK in the portal validates local-only (ReturnUrlPolicy.Sanitize here, - // LocalUrl.IsLocal in the GUI), because an unvalidated one is an open redirect. Those rules - // are correct and must not be relaxed; what was wrong was this SOURCE minting an absolute - // URL that no sink can accept. The login page's LocalOrNull then dropped it silently, the - // provider link was built with no returnUrl at all, and the user landed on "/" after signing - // in with the /authorize request gone — so no authorization code was ever issued and the MCP - // client waited forever for an "authentication successful" that could not arrive (#5074). - // The absolute form was also needless: the login page and this endpoint are the same origin. - // OnboardingMiddleware carries the same request the same way, for the same reason. - if (User?.Identity?.IsAuthenticated != true) - { - var authorizePath = $"{Request.Path}{Request.QueryString}"; - var loginUrl = $"/login?returnUrl={Uri.EscapeDataString(ReturnUrlPolicy.Sanitize(authorizePath))}"; - logger.LogInformation("OAuth /authorize: redirecting unauthenticated caller to {LoginUrl}", loginUrl); - return Task.FromResult(Redirect(loginUrl)); - } - - // Extract user identity from cookie claims (email/name are display - // fields). The token's userId is the MESH User.Id, resolved by - // UserContextMiddleware onto AccessService.Context for this cookie - // request — NOT preferred_username, which Entra fills with the email. - var email = User.FindFirstValue(ClaimTypes.Email) - ?? User.FindFirstValue("email") - ?? User.FindFirstValue("preferred_username") - ?? ""; - var name = User.FindFirstValue(ClaimTypes.Name) - ?? User.FindFirstValue("name") - ?? email; - var userId = ResolveMeshUserId(); - - if (string.IsNullOrEmpty(email)) - { - logger.LogWarning("OAuth /authorize rejected: authenticated principal has no email/preferred_username claim"); - return Task.FromResult(BadRequest(new { error = "invalid_request", error_description = "Unable to determine user identity" })); - } - - // Refuse to issue a code with an unresolved or email-shaped userId — it - // would mint the token into a parallel {email} partition that owns none - // of the user's data (the original prod 401). A missing mesh identity - // means the User node isn't provisioned yet; the user should retry after - // a normal browser login populates the identity cache. - if (string.IsNullOrEmpty(userId) || userId.Contains('@')) - { - logger.LogWarning( - "OAuth /authorize rejected: no resolved mesh identity for {Email} (userId='{UserId}'). " - + "Retry after a browser login provisions/loads the User node.", - email, userId ?? "(null)"); - return Task.FromResult(BadRequest(new { error = "invalid_request", error_description = "Unable to determine user identity" })); - } - - // Generate + persist the authorization code (mesh node — visible to every - // replica). The redirect only fires once the node write committed; a code - // whose persistence failed must never reach the client (it could not be - // exchanged anywhere), so a store error surfaces as 500 server_error. - return CodeStore.GenerateCode( - userId: userId, - userName: name, - userEmail: email, - clientId: client_id, - redirectUri: redirect_uri, - codeChallenge: code_challenge, - codeChallengeMethod: code_challenge_method) - .Select(code => - { - logger.LogInformation("Issued OAuth authorization code for user {Email}, client {ClientId}", email, client_id); - - // Redirect to client with code (and state if provided) - var callbackUrl = string.IsNullOrEmpty(state) - ? $"{redirect_uri}?code={Uri.EscapeDataString(code)}" - : $"{redirect_uri}?code={Uri.EscapeDataString(code)}&state={Uri.EscapeDataString(state)}"; - - return (IActionResult)Redirect(callbackUrl); - }) - .Catch(ex => - { - logger.LogError(ex, - "OAuth /authorize failed to persist the authorization code for user {Email}, client {ClientId}", - email, client_id); - return Observable.Return(StatusCode( - StatusCodes.Status500InternalServerError, - new { error = "server_error", error_description = "Failed to persist the authorization code" })); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger.LogWarning(ex, - "OAuth /authorize for client {ClientId} faulted after the response had already been sent", - client_id), - ct)!; - } - - /// - /// OAuth Token Endpoint — exchanges an authorization code for an API token. - /// The issued token is a standard mw_ API token, indistinguishable from manually created ones. - /// - [HttpPost("/token")] - [AllowAnonymous] - public Task ExchangeToken([FromForm] TokenRequest request, CancellationToken ct) - { - logger.LogInformation( - "OAuth /token: grant_type={GrantType}, client_id={ClientId}, redirect_uri={RedirectUri}, has_code={HasCode}, has_verifier={HasVerifier}", - request.grant_type, request.client_id, request.redirect_uri, - !string.IsNullOrEmpty(request.code), !string.IsNullOrEmpty(request.code_verifier)); - - if (request.grant_type != "authorization_code") - { - logger.LogWarning("OAuth /token rejected: unsupported grant_type={GrantType}", request.grant_type); - return Task.FromResult(BadRequest(new { error = "unsupported_grant_type" })); - } - - if (string.IsNullOrEmpty(request.code) || string.IsNullOrEmpty(request.client_id) || string.IsNullOrEmpty(request.redirect_uri)) - { - logger.LogWarning("OAuth /token rejected: missing code/client_id/redirect_uri"); - return Task.FromResult(BadRequest(new { error = "invalid_request" })); - } - - // 🚨 Resolved HERE, while the request's scope is provably alive (MeshWeaver.Feedback#13). - // The chain below outlives the request whenever the client aborts: `.ObserveCompletion(…, ct)` - // cancels the WAIT, not the source, so the exchange keeps running after the response is gone. - // Read lazily inside it, these properties resolved from a disposed request scope — the - // "faulted after the response had already been sent" warning measured on memex. Both are - // root singletons, so the captured instances stay valid for the whole chain. - var codeStore = CodeStore; - var tokens = TokenService; - var maxLive = MaxLiveCredentialsPerClient; - - // Exchange the code against the mesh-backed store (replica-safe: the code may - // have been minted by any pod, and the single-use consume is atomic across - // replicas — first delete wins), then mint the mw_ API token. One reactive - // chain, single bridge to Task at .ObserveCompletion(…, ct) — never Rx's .ToTask(), which - // resumes the awaiter INLINE on the signalling thread (forbidden since 2026-08-30). - return codeStore.ExchangeCode( - request.code, - request.client_id, - request.redirect_uri, - request.code_verifier) - .SelectMany(exchange => - { - if (exchange.Entry is null) - { - // The reason names the exact failing check (unknown/consumed, lost consume - // race, expired, client_id, redirect_uri, PKCE) — the wire response stays - // a generic invalid_grant per RFC 6749. - logger.LogWarning( - "OAuth token exchange failed for client {ClientId}: {FailureReason}", - request.client_id, exchange.FailureReason); - return Observable.Return(BadRequest(new { error = "invalid_grant" })); - } - - var entry = exchange.Entry; - - // 🚨 A client that has already given up receives nothing we mint — so mint nothing. - // The token would be a year-long credential no client holds, removed only by a - // later supersede for the same client_id, which for a client whose id changes per - // registration never comes (MeshWeaver.Feedback#13 / #12). The code is consumed - // either way: it is single-use, and the client re-runs the whole flow. - if (ct.IsCancellationRequested) - { - logger.LogInformation( - "OAuth /token for client {ClientId}: the client abandoned the exchange before a " - + "token was minted — no token issued, nothing to revoke", - request.client_id); - return Observable.Return(BadRequest(new { error = "invalid_request" })); - } - - // Create an mw_ API token via the existing token service. Lifetime - // is long-lived because OAuth clients (MCP, CLI tools) typically - // can't run interactive re-auth flows — a token that expires in 30 - // days surprises users who connect once and come back months later. - // Refresh-token flow isn't implemented yet; until it is, default to - // 1 year. Bump if needed via TokenLifetime below. - // An unused token still expires after 30 days (policy oauth-idle-expiry, - // OAuthTokenLifetime): the one-year term bounds a token in use, the idle rule one - // nobody is using any more. - var label = OAuthTokenLifetime.LabelFor(request.client_id); - - return tokens.CreateOAuthToken( - userId: entry.UserId, - userName: entry.UserName, - userEmail: entry.UserEmail, - clientId: request.client_id, - expiresAt: DateTimeOffset.UtcNow.Add(TokenLifetime)) - // 🚨 A BOUNDED number of live credentials per client (#1493, #5074). Every authorization used to - // mint a FRESH year-long token and leave the previous one live and listed, so a - // reinstall, a new device, or a revoked-and-reconnected integration each left - // another usable credential behind — for a year. That is what dominated the 86 - // ApiToken rows found on the live portal, and each of them is a key that still - // opens the door. The bound is not ONE: a client_id is shared by every process of - // one installation (Claude Code keeps one registration per machine), and a - // one-per-client rule made each new sign-in revoke every sibling session (#5074). - // - // Ordered mint-THEN-supersede, never the reverse: if the mint fails the client - // must keep the credential it already has, so revoking first could strand it - // with none at all. - // - // 🚨 And the abandonment check sits BETWEEN the two: a client that gave up while - // the mint was in flight never receives the new token, so it is REVOKED and the - // supersede is SKIPPED — superseding first would delete the credential the client - // still holds and leave it with none (MeshWeaver.Feedback#13). - // - // The check is not one snapshot: SupersedePreviousTokens re-reads it after its - // listing and before EACH delete, and answers false when it stopped because the - // client left — the new token is then revoked just the same. - .SelectMany(creation => (ct.IsCancellationRequested - ? Observable.Return(false) - : SupersedePreviousTokens( - tokens, entry.UserId, label, creation.Node.Path, MintedAt(creation), maxLive, ct)) - .SelectMany(completed => completed - ? Observable.Return((Creation: creation, Abandoned: (IActionResult?)null)) - : RevokeUndeliveredToken(tokens, creation, request.client_id))) - .Select(step => - { - if (step.Abandoned is { } refused) - return refused; - var creation = step.Creation; - logger.LogInformation("Issued OAuth access token for user {Email}, client {ClientId}", entry.UserEmail, request.client_id); - return (IActionResult)Ok(new - { - access_token = creation.RawToken, - token_type = "Bearer", - expires_in = (int)TokenLifetime.TotalSeconds, - }); - }); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger.LogWarning(ex, - "OAuth /token for client {ClientId} faulted after the response had already been sent", - request.client_id), - ct)!; - } - - /// - /// Evicts this client's OLDEST tokens once a fresh authorization has minted a new one, so a - /// (user, client_id) pair holds at most live credentials — the - /// newest ones, the one just minted included (#1493 bounded the count; #5074 raised the bound - /// from one, because every process of one installation shares the client_id). - /// - /// Identity is the label — OAuth: {client_id} — and client_id is DERIVED - /// from the client's own metadata (see ), so a shared label means - /// the same client. When ids were still random per registration this filter never matched - /// anything, which is exactly why the token rows piled up; a re-registering client keeping its - /// id is what makes superseding fire at all. The token just minted is excluded by PATH, which - /// also makes the read's lag harmless: a listing that has not caught up yet simply leaves an - /// older row for the next authorization to collect, and can never take the new one. - /// - /// Deleted, not merely marked revoked: this is BOTH the live-credential bound and the - /// unbounded accumulation behind it, and a revoked row keeps accumulating. It matches what - /// #1477's expiry sweep already does with a dead credential. - /// - /// Evictions are logged at three points: the candidate set before any delete (intent), one - /// line per credential a delete actually removed, and the tally once every delete has answered - /// (outcome). A removed path's last segment is the hash prefix the evicted holder's 401 is logged - /// under. - /// - /// Never fails the exchange. The client has a valid token by this point; housekeeping - /// that could not complete is a Warning and the next authorization tries again. - /// - private IObservable SupersedePreviousTokens( - ApiTokenService tokens, string userId, string label, string keepPath, DateTimeOffset mintedAt, - int maxLive, CancellationToken abandoned) - { - return tokens.GetTokensForUser(userId) - .Take(1) - .SelectMany(all => - { - // 🚨 Supersede only what is strictly OLDER, in a TOTAL order — never "everything - // that is not mine". Two concurrent exchanges for the same (user, client_id) each - // see the other's freshly-minted token in this listing, and a not-mine rule would - // have them delete each other's: both clients then walk away holding a credential - // that was removed moments later. Ordering by (CreatedAt, path) makes the outcome - // convergent instead — every participant evicts only below itself, so the NEWEST - // tokens survive no matter which exchange evaluates last, and at most maxLive live - // credentials remain at the end. - // - // The path tiebreak matters: CreatedAt is a UTC timestamp and two exchanges can - // land on the same tick, where "strictly older by time" would let both survive. - // - // Of the strictly-older tokens, the newest (maxLive - 1) are KEPT beside the one just - // minted and only the rest are evicted. Still convergent: a token among the maxLive - // newest overall has at most (maxLive - 2) tokens between it and any exchange newer - // than it, so NO exchange evicts it — and a listing that lags (misses a row) only - // ranks the older rows higher, i.e. evicts less, never a newer token. - var superseded = OAuthCredentialEviction.Evict(all, label, keepPath, mintedAt, maxLive) - .ToArray(); - if (superseded.Length == 0) - return Observable.Return(true); - // Re-checked AFTER the listing (it is a live read, and the client may have left while - // it ran) and again before each delete below: an abandoned exchange must not retire - // the credential the client still holds (MeshWeaver.Feedback#13). - if (abandoned.IsCancellationRequested) - return Observable.Return(false); - - // 🚨 Name the rows — twice, and the two lines say different things. The 401 a - // superseded holder gets is logged by the validator under the token's HASH - // PREFIX, which is the last segment of these paths, so a path here and that - // warning correlate on one string. Without the paths, "superseding 1 previous - // token(s)" cannot be tied to the 401 it caused, and the alternation #5074 reports - // (two processes of one installation sharing a client_id and evicting each - // other's credential) is unattributable inside the log window. - // - // This first line is INTENT: the candidate set, before any delete has run. - // DeleteToken answers false for a path that was already gone (a concurrent - // exchange got there first) and faults on a refusal (the per-path catch below - // keeps that token live), so what was actually removed is known only after the - // Concat completes — the second line, below, reports that. A reader tying a 401 - // to a removal uses the second line; the first says what this exchange set out - // to do, which is what a refusal is measured against. - logger.LogInformation( - "OAuth: superseding {Count} previous token(s) for user {UserId}, client label {Label} — " - + "candidates {SupersededPaths}, keeping {KeptPath}; " - + "a client keeps at most {MaxLive} live credential(s), the oldest are evicted", - superseded.Length, userId, label, string.Join(", ", superseded), keepPath, maxLive); - - // Self-paced (Concat, never Merge): one delete at a time, the same shape the expiry - // sweep uses, so a client that re-authorized many times drains gently. Each - // delete's own bool is what it REMOVED (true) or found already absent (false); - // a fault is caught per path and counts as not removed. - return Observable.Concat(superseded.Select(path => Observable.Defer(() => abandoned.IsCancellationRequested - ? Observable.Return((Path: path, Removed: false, Skipped: true)) - : tokens.DeleteToken(path) - .Do(removed => - { - if (removed) - logger.LogInformation( - "OAuth: evicted credential {Path} for user {UserId}, client label {Label} — " - + "older than the {MaxLive} newest live credential(s) of this client; " - + "its holder's next call answers 401 and re-authorizes", - path, userId, label, maxLive); - }) - .Select(removed => (Path: path, Removed: removed, Skipped: false)) - .Catch<(string Path, bool Removed, bool Skipped), Exception>(ex => - { - logger.LogWarning(ex, - "OAuth: could not supersede previous token {Path} — it stays live until " - + "the next authorization or its expiry", path); - return Observable.Return((Path: path, Removed: false, Skipped: false)); - })))) - .ToList() - .Do(outcomes => - { - var removed = outcomes.Where(o => o.Removed).Select(o => o.Path).ToArray(); - logger.LogInformation( - "OAuth: superseded {RemovedCount} of {Count} previous token(s) for user {UserId}, " - + "client label {Label} — removed {RemovedPaths}; kept {KeptPath}", - removed.Length, outcomes.Count, userId, label, - removed.Length == 0 ? "(none)" : string.Join(", ", removed), keepPath); - }) - .Select(outcomes => !outcomes.Any(o => o.Skipped)); - }) - .Catch(ex => - { - logger.LogWarning(ex, - "OAuth: could not list previous tokens for user {UserId} to supersede them", userId); - return Observable.Return(!abandoned.IsCancellationRequested); - }); - } - - /// - /// Removes a token this exchange minted for a client that abandoned the exchange before the - /// response could carry it (MeshWeaver.Feedback#13). Nobody holds it, so leaving it would be a - /// year-long live credential with no holder. A refusal is logged with the path — the token then - /// stays live and the line is what lets an operator remove it — and never faults the exchange, - /// whose caller is already gone. - /// - private IObservable<(TokenCreationResult Creation, IActionResult? Abandoned)> RevokeUndeliveredToken( - ApiTokenService tokens, TokenCreationResult creation, string clientId) - => tokens.DeleteToken(creation.Node.Path) - .Do( - removed => logger.LogInformation( - "OAuth /token for client {ClientId}: the client abandoned the exchange while the token " - + "was being minted — {Outcome} the undelivered token {Path}; the client's previous " - + "credential was NOT superseded", - clientId, removed ? "revoked" : "found already absent", creation.Node.Path), - ex => logger.LogWarning(ex, - "OAuth /token for client {ClientId}: could not revoke the undelivered token {Path} — " - + "it stays live, held by no client, until revoked or expired", - clientId, creation.Node.Path)) - .Catch(_ => Observable.Return(false)) - .Select(_ => (creation, (IActionResult?)BadRequest(new { error = "invalid_request" }))); - - /// - /// The creation stamp of the token this exchange just minted — the ordering key - /// compares against. It is the stamp the mint WROTE, - /// carried on : reading it back off the node's - /// content needed a cast that yields nothing for an untyped payload, and its "now" fallback was - /// taken after the read-back, so it was LATER than the real stamp and ranked every token minted - /// in between as older — more eviction, not less (#6636). - /// - private static DateTimeOffset MintedAt(TokenCreationResult creation) => creation.CreatedAt; - - /// - /// Lifetime for OAuth-issued API tokens. Single source of truth — the - /// expiresAt timestamp on the token row and the expires_in OAuth response - /// field both read from this so they can't drift apart. - /// - private static readonly TimeSpan TokenLifetime = TimeSpan.FromDays(365); -} - -/// -/// Binds the form-encoded token request body. -/// -public class TokenRequest -{ - public string grant_type { get; set; } = ""; - public string? code { get; set; } - public string? client_id { get; set; } - public string? redirect_uri { get; set; } - public string? code_verifier { get; set; } -} - -/// -/// RFC 7591 Dynamic Client Registration request. Fields use snake_case JSON names per the spec. -/// -public class ClientRegistrationRequest -{ - [JsonPropertyName("client_name")] - public string? ClientName { get; set; } - - [JsonPropertyName("redirect_uris")] - public string[]? RedirectUris { get; set; } - - [JsonPropertyName("grant_types")] - public string[]? GrantTypes { get; set; } - - [JsonPropertyName("response_types")] - public string[]? ResponseTypes { get; set; } - - [JsonPropertyName("token_endpoint_auth_method")] - public string? TokenEndpointAuthMethod { get; set; } - - [JsonPropertyName("scope")] - public string? Scope { get; set; } -} - -/// -/// RFC 7591 Dynamic Client Registration response. -/// -public class ClientRegistrationResponse -{ - [JsonPropertyName("client_id")] - public string ClientId { get; set; } = ""; - - [JsonPropertyName("client_id_issued_at")] - public long ClientIdIssuedAt { get; set; } - - [JsonPropertyName("client_name")] - public string? ClientName { get; set; } - - [JsonPropertyName("redirect_uris")] - public string[]? RedirectUris { get; set; } - - [JsonPropertyName("grant_types")] - public string[]? GrantTypes { get; set; } - - [JsonPropertyName("response_types")] - public string[]? ResponseTypes { get; set; } - - [JsonPropertyName("token_endpoint_auth_method")] - public string? TokenEndpointAuthMethod { get; set; } -} diff --git a/memex/Memex.Portal.Shared/Authentication/OAuthCredentialBound.cs b/memex/Memex.Portal.Shared/Authentication/OAuthCredentialBound.cs deleted file mode 100644 index b9f8aa5f07..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/OAuthCredentialBound.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.Extensions.Configuration; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// How many OAuth-issued tokens one (user, client_id) pair may hold live at once -/// (policy oauth-bounded-live-credentials). A fresh authorization keeps the newest this-many — -/// itself included — and evicts the older ones, logging every eviction. -/// -/// Not one, because one client_id is routinely shared by several processes: Claude Code -/// stores its registration once per machine, so every Claude Code session on that machine presents the -/// same client_id, and a one-per-client rule made each new sign-in revoke every sibling -/// session's credential (#5074). Still bounded, because an unbounded count is the accumulation of -/// year-long live keys #1493 removed. -/// -/// Read from under (environment form -/// Mcp__OAuth__MaxLiveCredentialsPerClient). Absent, malformed or no configuration at all means -/// ; a value below 1 is treated as 1, so an exchange never evicts the credential -/// it just handed out. -/// -public static class OAuthCredentialBound -{ - /// The configuration key. - public const string ConfigKey = "Mcp:OAuth:MaxLiveCredentialsPerClient"; - - /// The bound when nothing is configured. - public const int Default = 5; - - /// The effective bound for (null means the default). - public static int From(IConfiguration? configuration) => - int.TryParse(configuration?[ConfigKey], out var configured) - ? Math.Max(1, configured) - : Default; -} diff --git a/memex/Memex.Portal.Shared/Authentication/OAuthCredentialEviction.cs b/memex/Memex.Portal.Shared/Authentication/OAuthCredentialEviction.cs deleted file mode 100644 index 0ee65ab7bd..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/OAuthCredentialEviction.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace Memex.Portal.Shared.Authentication; - -/// -/// The eviction rule of the OAuth token exchange, kept pure so it can be pinned without a mesh: -/// which of a client's credentials a fresh authorization removes. -/// -internal static class OAuthCredentialEviction -{ - /// - /// The paths a fresh authorization evicts, oldest first. Candidates are the tokens carrying - /// that are strictly OLDER than the one just minted, in a total order over - /// (CreatedAt, path). Of those, the newest - 1 LIVE ones - /// are kept — so, with the token just minted, at most stay live — and - /// every other older candidate is evicted. A revoked or expired row is not live: it never takes one - /// of the kept slots and is always removed, even when it is NEWER than the token just minted. - /// - /// Only strictly-older LIVE rows are ever evicted, never "everything that is not mine": two - /// concurrent exchanges each see the other's fresh token, and a not-mine rule would have them delete - /// each other's. Ranking relative to the evaluating exchange keeps the outcome convergent: a token - /// among the newest has at most maxLive - 2 tokens between it and - /// any newer exchange, so no exchange evicts it, and a listing that lags (misses a row) only ranks - /// older rows higher — it evicts less, never a newer token. - /// - internal static IReadOnlyList Evict( - IEnumerable tokens, string label, string keepPath, DateTimeOffset mintedAt, int maxLive) - { - var keepOlder = Math.Max(1, maxLive) - 1; - var candidates = tokens - .Where(t => t.Label == label && t.NodePath != keepPath) - .ToArray(); - - // The kept slots go ONLY to live rows strictly older than the token just minted — a newer - // live row belongs to a concurrent exchange and is never this exchange's to judge. - var kept = candidates - .Where(t => IsLive(t, mintedAt) && IsOlder(t, keepPath, mintedAt)) - .OrderByDescending(t => t.CreatedAt) - .ThenByDescending(t => t.NodePath, StringComparer.Ordinal) - .Take(keepOlder) - .Select(t => t.NodePath) - .ToHashSet(StringComparer.Ordinal); - - // Evicted: every older row outside the kept slots, and every DEAD row whatever its age — a - // revoked or expired credential opens nothing, so removing one is never a race with its - // holder, even when a concurrent exchange minted it after this one. - return candidates - .Where(t => !kept.Contains(t.NodePath) - && (IsOlder(t, keepPath, mintedAt) || !IsLive(t, mintedAt))) - .OrderBy(t => t.CreatedAt) - .ThenBy(t => t.NodePath, StringComparer.Ordinal) - .Select(t => t.NodePath) - .ToArray(); - } - - private static bool IsOlder(ApiTokenInfo t, string keepPath, DateTimeOffset mintedAt) => - t.CreatedAt < mintedAt - || (t.CreatedAt == mintedAt && string.CompareOrdinal(t.NodePath, keepPath) < 0); - - private static bool IsLive(ApiTokenInfo t, DateTimeOffset mintedAt) => - !t.IsRevoked && !(t.ExpiresAt <= mintedAt); -} diff --git a/memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs b/memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs deleted file mode 100644 index 0d91356117..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs +++ /dev/null @@ -1,723 +0,0 @@ -using System.Globalization; -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Middleware that redirects authenticated users without an Active user node -/// to the onboarding page. Runs after UserContextMiddleware. -/// -/// Flow: -/// -/// No user node (or Transient) → redirect /onboarding -/// Active node → update AccessContext with username, pass through -/// Active node but sitting ON /onboarding (bookmark, or a cold-start -/// race that bounced them here before the User catalog hydrated) → redirect home; -/// the profile form is not for an already-onboarded user -/// -/// -/// -/// The user lookup uses workspace.GetQuery (the canonical synced-query -/// API from SyncedMeshNodeQueries.md). The synced layer bypasses RLS internally -/// (System identity), dedupes by path, gates on Initial, and includes static-node -/// providers — same guarantees as ApiTokenService.GetTokensForUser and -/// AgentChatClient.Initialize. Direct IMeshQueryCore.Query calls -/// from application code are pedestrian queries and were forbidden in 2026-05. -/// -/// Internally the lookup is a reactive observable chain -/// (workspace.GetQuery → Where → Take(1) → Timeout); -/// the single await at the middleware boundary is unavoidable because -/// ASP.NET Core's RequestDelegate is Task-based. -/// -public class OnboardingMiddleware(RequestDelegate next, ILogger logger) -{ - /// - /// Hard cap on the user-node lookup. Sized for cold start: the User - /// catalog partition can take 5–10s to hydrate on a fresh portal - /// process, and the previous 5s budget routinely bounced legitimate - /// users to /onboarding right after a restart. Bumped to 20s - /// so the timeout is reserved for genuinely-pathological cases (mesh - /// down, query layer wedged) rather than cold-start hydration race. - /// - private static readonly TimeSpan LookupTimeout = TimeSpan.FromSeconds(20); - - /// - /// If the FIRST snapshot is empty, - /// resubscribe once after this delay before giving up. Covers the case - /// where the catalog grain replied to the subscription with an empty - /// pre-hydration snapshot but never fires a follow-up Added once - /// hydration completes (we've seen this with the InMemory catalog when - /// the partition is loaded synchronously by a different request that - /// holds the grain lock). - /// - private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(750); - - // NOTE: "/onboarding" is deliberately NOT here. It still must never redirect a - // not-yet-onboarded user back TO onboarding (that would loop), but an ALREADY- - // onboarded user who lands there must be redirected home — so the page is resolved - // (not blanket-excluded) and handled explicitly in BuildPipeline via onOnboardingPage. - private static readonly HashSet ExcludedPrefixes = new(StringComparer.OrdinalIgnoreCase) - { - "/welcome", - "/login", - "/auth/", - "/dev/", - "/admin/", - "/_framework", - "/_content", - "/_blazor", - "/static/", - // Asset fetches must never be bounced to onboarding — a redirect renders as a broken - // image, not a page. /static covers build assets; /api/content is the access-controlled - // content route mesh images/PDFs/downloads use (issue #587). - "/api/content", - "/favicon.ico", - // Both MCP endpoint paths. The McpEndpointRoutes front-of-pipeline rewrite normally - // turns /api/mcp into /mcp before this middleware runs; the explicit /api/mcp entry is - // defense-in-depth against pipeline reordering — an MCP client bounced to /onboarding - // cannot follow the redirect. - "/mcp", - "/api/mcp", - "/signin-", - "/bootstrap", - "/api/email", - }; - - public Task InvokeAsync(HttpContext context) => Continue(context, BuildPipeline(context)); - - /// - /// Everything after the identity decision is REQUESTED: wait for it, then act on it. Split from - /// only so a test can supply a decision that is still IN FLIGHT when - /// the connection aborts — the race #4859 is about, which the synchronous pass-through of an - /// unauthenticated request can never reach. - /// - internal async Task Continue(HttpContext context, IObservable pendingDecision) - { - // Pull the reactive composition all the way up: the user-resolution - // pipeline (FindUserByEmail → conditional LoadUserRoles → SetContext) - // is a single observable chain. The only Task bridge is on the line - // below — ASP.NET's RequestDelegate signature forces Task at this - // boundary, but everything else stays observable so a slow query - // layer can't deadlock by awaiting a result the awaiting thread is - // supposed to publish. The bridge is ObserveCompletion, NOT Rx's - // .ToTask(): the latter resumes this method INLINE on the thread that - // signalled — a mesh hub's action block — and then the whole rest of - // the request pipeline runs there (forbidden since 2026-08-30). - // - // No cancellation token: RequestAborted must NOT cut this wait short. - // The decision below is what tells the pipeline whether to redirect, - // pass through or answer 503, and abandoning it mid-flight would leave - // the request with no outcome at all — the LookupTimeout inside - // BuildPipeline is the bound that belongs here. - // - // Outcome semantics: - // • Result = "Redirect" — middleware bounces to /onboarding, doesn't - // call next. - // • Result = "PassThrough" — context updated (or skipped because - // unauthenticated / virtual / excluded path); fall through to next. - // • Result = "Unavailable" — identity could not be RESOLVED (not: resolved - // to "no account"); answers 503 + Retry-After, doesn't call next. - var decision = (await pendingDecision.FirstAsync() - .ObserveCompletion(ex => logger.LogWarning(ex, - "Onboarding: the identity pipeline for {Path} faulted after the request had " - + "already been answered", context.Request.Path)))!; - var outcome = decision.Outcome; - - // 🚨 The wait above is deliberately deaf to RequestAborted — so the request it hands on - // may already be DEAD by the time the decision arrives, and nothing downstream knows (#4859). - // Every endpoint behind this middleware that bridges through ObserveCompletion(…, ct) is - // cancelled the instant its connection is aborted; this wait is not, so it resumes AFTER an - // abort and must look before it goes on. Handing a dead request - // to `next` runs the endpoint for nobody, and when the abort was Kestrel's shutdown giving up - // on the connection, the endpoint's response write rents from the transport's memory pool - // after the transport has disposed it: `ObjectDisposedException: 'MemoryPool'` at - // `Http1OutputProducer.GetFakeMemory`, five of them in 31 ms on memex-cloud, each attributed - // to THIS frame because it is the frame that resumed them. - if (context.RequestAborted.IsCancellationRequested) - { - ReportAbortedBeforeDecision(context, outcome); - return; - } - - if (outcome == OnboardingOutcome.Unavailable) - { - // 🚨 UNAVAILABLE ≠ "you have no account" (issue #637). The lookup that decides - // whether this user is onboarded reached NO verdict — a storage/query fault, not a - // fact about the user. Bouncing them to /onboarding would tell a correctly - // signed-in person that their account does not exist and invite them to create a - // second one; it is the browser-side twin of answering 401 for a read timeout. - // Answer retryable instead, and say plainly that signing in again will not help. - await WriteIdentityUnavailable(context, decision.UnavailableReason); - return; - } - - if (outcome == OnboardingOutcome.RedirectHome) - { - // Already-onboarded user hit /onboarding — the profile form is not for them. - // Server-side 302 home, BEFORE the page renders: no circuit dependency, no - // "Loading…/Redirecting…" spinner, no chance of the form flashing up. - context.Response.Redirect("/"); - return; - } - - if (outcome == OnboardingOutcome.Redirect) - { - // Carry the page the user was trying to reach into the onboarding URL so - // the form can send them back there on completion — instead of dumping - // everyone on "/". The target is always THIS request's own path+query on - // this host, so it is inherently local (no open-redirect surface), and - // excluded paths (/login, assets, …) never reach here. A bare - // "/" carries no returnUrl — onboarding falls back to "/" anyway. - var target = $"{context.Request.Path}{context.Request.QueryString}"; - var location = string.IsNullOrEmpty(target) || target == "/" - ? "/onboarding" - : $"/onboarding?returnUrl={Uri.EscapeDataString(target)}"; - context.Response.Redirect(location); - return; - } - - await next(context); - } - - /// - /// Says what happened to a request whose connection was aborted before its identity decision - /// arrived — and keeps the one diagnostic that matters from disappearing with the crash it - /// replaces. - /// - /// 🚨 Not answering a dead request is not the whole story, and this line is why. - /// A client that simply left is routine (Debug). But an abort while the host is STOPPING is how - /// Kestrel ends a shutdown drain that ran out of budget: it aborts every connection still in - /// flight and then disposes its transport. In #4859 the trace of that which reached the incident - /// pipeline was the ObjectDisposedException burst this method prevents, so silencing the - /// crash without saying this at Warning would hide a drain that was cut short — which is the - /// question #4859 leaves open. - /// - private void ReportAbortedBeforeDecision(HttpContext context, OnboardingOutcome outcome) - { - var stopping = context.RequestServices?.GetService() - ?.ApplicationStopping.IsCancellationRequested == true; - if (stopping) - logger.LogWarning( - "Onboarding: the connection for {Method} {Path} was ABORTED while the host is " - + "stopping, before its identity decision ({Outcome}) arrived — not handing it to the " - + "endpoint, whose response would be written into a transport Kestrel has already " - + "torn down (#4859). An abort during shutdown means either the client left or " - + "Kestrel's drain gave up on in-flight requests; a burst of these lines at one " - + "instant is the second, and says the shutdown budget ran out before HTTP drained.", - context.Request.Method, context.Request.Path, outcome); - else - logger.LogDebug( - "Onboarding: the client aborted {Method} {Path} before its identity decision " - + "({Outcome}) arrived — nothing to answer", - context.Request.Method, context.Request.Path, outcome); - } - - internal enum OnboardingOutcome { PassThrough, Redirect, RedirectHome, Unavailable } - - /// - /// What the middleware should do next, plus — for - /// — WHY no identity verdict was reached. - /// The reason is logged (never shown verbatim: it is engineering detail, and the page a - /// user sees is localized). - /// - internal sealed record OnboardingDecision(OnboardingOutcome Outcome, string? UnavailableReason = null) - { - public static readonly OnboardingDecision PassThrough = new(OnboardingOutcome.PassThrough); - public static readonly OnboardingDecision Redirect = new(OnboardingOutcome.Redirect); - public static readonly OnboardingDecision RedirectHome = new(OnboardingOutcome.RedirectHome); - public static OnboardingDecision Unavailable(string reason) - => new(OnboardingOutcome.Unavailable, reason); - } - - /// - /// Answers a request whose identity could not be RESOLVED with - /// 503 Service Unavailable + Retry-After and a localized, plain-text - /// explanation (issue #637). - /// - /// The wording matters as much as the status: the failure mode this replaces sent a - /// signed-in user to a sign-up form, so the body says explicitly that the sign-in is fine and - /// that signing in again will not help. Localized through the one seam - /// (AccessService.Localize → ) — - /// never ambient CultureInfo. Plain text, not hand-built HTML. - /// - /// Internal + static so the response SHAPE is testable over a real - /// without an HTTP host: asserting the classification alone would - /// pass while production still redirected. - /// - internal static async Task WriteIdentityUnavailable( - HttpContext context, string? reason, AccessService? accessService = null) - { - var logger = context.RequestServices?.GetService>(); - accessService ??= TryResolveViewer(context, logger); - - context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; - // Shared with the API-token challenge so the two retry hints cannot drift apart. - context.Response.Headers.RetryAfter = - ApiTokenAuthenticationHandler.RetryAfterSeconds.ToString(CultureInfo.InvariantCulture); - context.Response.ContentType = "text/plain; charset=utf-8"; - await context.Response.WriteAsync( - accessService.Localize("error.identityUnavailable") - + "\n\n" - + accessService.Localize("error.identityUnavailableHint"), - context.RequestAborted); - - // The reason is engineering detail — logged, never rendered. - logger?.LogWarning( - "Identity resolution UNAVAILABLE for {Path} ({Reason}) — answering 503 + Retry-After " - + "instead of bouncing a signed-in user to /onboarding (issue #637)", - context.Request.Path, reason ?? "(no reason given)"); - } - - /// - /// The viewer's for language resolution, or null when this - /// scope has no portal application (an excluded path, teardown, a bare test context). - /// - /// is genuinely OPTIONAL per scope, so it is resolved with - /// GetService. is a REQUIRED hub service, so it is resolved - /// with GetRequiredService — per the repo rule, never GetService plus implicit - /// trust. - /// - /// 🚨 The lookup is nonetheless guarded, and the guard is the point: - /// is the code that REPORTS an availability failure, so - /// it must never BECOME one. A throw here would be turned into a 500 by ASP.NET — - /// re-creating, inside the fix, exactly the collapse-into-the-wrong-status this fix exists to - /// prevent. Nothing is hidden: the failure is logged at Warning, and the 503 plus its reason is - /// still reported. Only the viewer's LANGUAGE degrades, to the English catalog. - /// - private static AccessService? TryResolveViewer(HttpContext context, ILogger? logger) - { - try - { - // Hub is non-nullable on a constructed PortalApplication. - return context.RequestServices?.GetService() - ?.Hub.ServiceProvider.GetRequiredService(); - } - catch (Exception ex) - { - logger?.LogWarning(ex, - "Could not resolve the viewer's AccessService while reporting identity-unavailable — " - + "falling back to the default language. The 503 itself is unaffected."); - return null; - } - } - - /// - /// Builds the reactive onboarding pipeline. Returns an observable that - /// emits exactly one describing what the - /// middleware should do next. Composition is end-to-end reactive — no - /// intermediate await, no fire-and-forget Subscribe, no - /// TaskCompletionSource. The single Task bridge lives in - /// . - /// - private IObservable BuildPipeline(HttpContext context) - { - if (context.User?.Identity?.IsAuthenticated != true) - return Observable.Return(OnboardingDecision.PassThrough); - - // /onboarding is resolved (not blanket-excluded): a not-yet-onboarded user - // stays on the form, but an already-onboarded one is redirected home. - var onOnboardingPage = context.Request.Path.StartsWithSegments("/onboarding"); - if (!onOnboardingPage && IsExcludedPath(context.Request.Path)) - return Observable.Return(OnboardingDecision.PassThrough); - - var portalApp = context.RequestServices.GetService(); - if (portalApp == null) - return Observable.Return(OnboardingDecision.PassThrough); - - var accessService = portalApp.Hub.ServiceProvider.GetRequiredService(); - var userContext = accessService.Context ?? accessService.CircuitContext; - - // Skip virtual users — they don't need onboarding. Nor does a SERVICE principal: it has no - // User node by design and never will (Doc/Architecture/ServiceIdentities), so looking one up - // would bounce every service request to /onboarding. - if (userContext is not { IsVirtual: false, IsService: false } || string.IsNullOrEmpty(userContext.ObjectId)) - return Observable.Return(OnboardingDecision.PassThrough); - - var email = userContext.Email ?? userContext.ObjectId; - var workspace = portalApp.Hub.GetWorkspace(); - - // Correctness fix + diagnostic (2026-06): we previously short-circuited to - // PassThrough whenever ObjectId != email, ASSUMING such a session was already - // onboarded. That stranded any session carrying a username-shaped identity with - // NO backing User node (a leftover DevLogin cookie, a deleted user, …): the - // middleware never redirected to /onboarding and Index.razor rendered an empty - // Activity area (the "blank screen, never onboards" bug). We now ALWAYS resolve - // the user by email; a missing node ⇒ redirect to onboarding. A genuinely - // onboarded external-auth session carries ObjectId == email here (the cookie's - // NameIdentifier is the email), so its FindUserByEmail lookup finds the node and - // passes through — the only sessions this newly redirects are the stale/unknown - // ones that SHOULD onboard. - logger.LogDebug( - "OnboardingMiddleware: resolving session - ObjectId='{ObjectId}' email='{Email}' isVirtual={IsVirtual} path={Path}", - userContext.ObjectId, email, userContext.IsVirtual, context.Request.Path); - - // Reactive composition: FindUser → SelectMany → either Redirect (no - // node / Transient) or LoadRoles → set context → PassThrough. - return FindUserByEmail(workspace, email, logger) - .SelectMany(userRead => - { - // 🚨 UNAVAILABLE ≠ "no account" (issue #637). The lookup reached NO verdict — - // it says nothing about whether this user is onboarded. Bouncing them to - // /onboarding would report an availability failure as an identity failure and - // invite a signed-in user to create a second account. - if (userRead.UnavailableReason is { } userReason) - return Observable.Return(OnboardingDecision.Unavailable(userReason)); - - var node = userRead.Value; - - // Not onboarded (no node, or only a Transient shell) — a DEFINITIVE verdict. - if (node == null || node.State == MeshNodeState.Transient) - { - // On /onboarding: stay on the form — NEVER redirect back to - // /onboarding (that would loop). Everywhere else: bounce there. - if (onOnboardingPage) - return Observable.Return(OnboardingDecision.PassThrough); - - logger.LogInformation( - "OnboardingMiddleware: Redirecting to onboarding for {Email} (node={NodeState})", - email, node?.State.ToString() ?? "(null — lookup returned no match)"); - return Observable.Return(OnboardingDecision.Redirect); - } - - // Onboarded but sitting on /onboarding → the form isn't for them; go home. - if (onOnboardingPage) - { - logger.LogInformation( - "OnboardingMiddleware: {Email} is already onboarded — redirecting off /onboarding to home", - email); - return Observable.Return(OnboardingDecision.RedirectHome); - } - - var username = node.Id; - return LoadUserRoles(workspace, username, logger) - .Select(rolesRead => - { - // 🚨 Same distinction on the role side (issue #637). Stamping an EMPTY - // role set because the grant read timed out silently strips the user's - // privileges, and every screen they open then says "Access denied" — an - // availability failure reported as an authorization failure. An empty - // set is only ever stamped when the read RESOLVED to "no grants". - if (rolesRead.UnavailableReason is { } rolesReason) - return OnboardingDecision.Unavailable(rolesReason); - - var updatedContext = userContext with - { - ObjectId = username, - Name = node.Name ?? username, - Roles = rolesRead.Value ?? Array.Empty() - }; - // Set per-request context. CircuitAccessHandler handles - // per-circuit persistence via CreateInboundActivityHandler. - accessService.SetContext(updatedContext); - return OnboardingDecision.PassThrough; - }); - }) - // Both lookups classify their OWN faults (IdentityRead.Bounded), so this only - // covers a fault in the surrounding composition — not an identity read. - .Catch(ex => - { - // Non-critical — don't block the request on onboarding check failure. - logger.LogWarning(ex, - "OnboardingMiddleware: Failed to check user node for {UserId}", - userContext.ObjectId); - return Observable.Return(OnboardingDecision.PassThrough); - }); - } - - /// - /// Reactive lookup of the User node by email via the canonical synced query - /// (workspace.GetQuery). The synced layer dedupes by path, gates on - /// Initial, includes static providers, and runs queries with System identity - /// internally — so this RLS-bypassing lookup uses exactly the same machinery - /// as every other "live mesh node set" consumer in the codebase - /// (ApiTokenService.GetTokensForUser, AgentChatClient, etc.). - /// Direct IMeshQueryCore.Query here was a pedestrian-query - /// antipattern — replaced 2026-05 per SyncedMeshNodeQueries.md. - /// - /// Returns rather than - /// so the caller composes the chain; the middleware is the single allowed - /// bridge point (ASP.NET's RequestDelegate is Task-based). - /// - /// Robustness: the synced layer's Initial-gating means the first - /// emission is already the authoritative snapshot — no per-emission Where - /// filter needed. We Take(1) and bound the read (cold start can take seconds while - /// the partition hydrates). An empty snapshot is a DEFINITIVE - /// Resolved(null) → "redirect to /onboarding". - /// - /// 🚨 A read that does NOT complete within the budget is - /// , never Resolved(null) (issue - /// #637). It used to fall back to null, so a storage/query stall told a correctly - /// signed-in user "you have no account" and bounced them to the sign-up form — an - /// availability failure reported as an identity failure, and one that re-signing-in - /// cannot fix. The two facts are kept apart HERE, where the timeout is known; no caller - /// has to infer it from a null or an exception message. - /// - internal static IObservable> FindUserByEmail( - IWorkspace workspace, string email, ILogger? logger) - { - var query = UserByEmailQuery(email); - var accessService = workspace.Hub.ServiceProvider.GetService(); - - // As System, for the reason given on LoadUserRoles — and this read needs it MORE, being - // the FIRST in the pipeline: it answers "who is this?" when no identity exists yet, so - // there is nothing for the per-user filter to be correct about. `limit:1` stays; that is a - // unique-key point lookup, not a set being truncated. - // - // The failure it forecloses is the one this whole file exists to prevent (#637): an - // RLS-stripped User node is indistinguishable from "no such account", and the caller's - // response to that is to bounce a signed-in user to /onboarding — inviting them to create - // a SECOND account. - // - // The Defer this replaces is not lost — but that was NOT free, and as first written this - // comment was wrong. Review caught it: SubscribeScopedObservable invoked the factory with - // no catch, so a synchronous composition throw escaped Subscribe and bypassed - // IdentityRead.Bounded's classification, turning "we could not find out" back into an - // unclassified failure — the #637 collapse. RunAsSystem now mirrors Observable.Defer and - // reports OnError, which is what makes dropping the explicit Defer safe here. - return IdentityRead.Bounded( - accessService.RunAsSystem(() => - // Fast path: the shared, cross-request synced-query snapshot. For a user this - // process has ALREADY seen, this replays the cached hit with no DB round-trip. - workspace.GetQuery($"auth:userByEmail:{email}", query) - .Do(items => logger?.LogDebug( - "FindUserByEmail({Email}): synced query emit, items={Count}", - email, items.Count())) - .Take(1) - .Select(items => (MeshNode?)items.FirstOrDefault()) - .SelectMany(cached => cached is not null - ? Observable.Return(cached) - // A cached HIT is authoritative; a cached MISS is NOT. This lookup is a pathless, - // auth-routed one-shot fan-out query, and workspace.GetQuery caches its result - // PERMANENTLY (Replay(1).AutoConnect(1)) with no live-delta source in the partitioned - // portal (the pg_notify listener is disabled). The middleware itself seeds an EMPTY - // snapshot while rendering the pre-onboarding / /onboarding requests — so a user - // onboarded afterwards would replay that empty snapshot and get bounced to - // /onboarding forever ("cannot advance", until a process restart clears the cache). - // On a miss, re-read the source of truth (auth.mesh_nodes) before concluding "no - // account". The DB row exists synchronously (the auth-mirror trigger fires inside the - // onboarding write), so the authoritative re-read finds the just-onboarded user. - : QueryUserByEmailAuthoritative(workspace, query, email, logger))), - LookupTimeout, $"FindUserByEmail({email})", logger); - } - - /// - /// Authoritative one-shot re-read of the User-by-email lookup that BYPASSES the permanent - /// synced-query cache. 's first emission is a fresh - /// Initial fan-out snapshot straight off auth.mesh_nodes, so a user materialised - /// after the cache seeded empty is found. Invoked ONLY on a cached miss — the common - /// already-known-user path keeps its zero-DB cache hit. This is the read-after-write authoritative - /// path CQRS mandates for a gate that decides whether to bounce a user to onboarding. - /// - /// Runs as System (Observable.Using holding an - /// ImpersonateAsSystem scope for the subscription) so the re-read has the SAME - /// RLS-bypassing visibility the synced layer uses internally — a not-yet-onboarded caller must - /// not be denied reading the infrastructure auth schema, or the recovery would never - /// fire. - /// - private static IObservable QueryUserByEmailAuthoritative( - IWorkspace workspace, string query, string email, ILogger? logger) - { - var sp = workspace.Hub.ServiceProvider; - var meshService = sp.GetRequiredService(); - var accessService = sp.GetRequiredService(); - return Observable.Using( - () => accessService.ImpersonateAsSystem(), - _ => meshService.Query(MeshQueryRequest.FromQuery(query))) - .Take(1) - .Select(change => (MeshNode?)change.Items.FirstOrDefault()) - // Information ONLY on actual recovery (the stale-cache condition healed) — for a - // genuinely-not-onboarded user this path runs on every request, so the still-missing - // case is Debug to keep production (Loki) log volume down. - .Do(node => - { - if (node is not null) - logger?.LogInformation( - "FindUserByEmail({Email}): recovered stale-empty cache via authoritative re-read → {User}", - email, node.Id); - else - logger?.LogDebug( - "FindUserByEmail({Email}): authoritative re-read still finds no user (not onboarded)", - email); - }); - } - - /// - /// Reactive load of the user's PLATFORM role names from the three anchored - /// legs (root, Admin, own partition — #3202) via the - /// canonical synced query (workspace.GetQuery). Same machinery as - /// FindUserByEmail — bypasses RLS, dedupes, gates on Initial, - /// includes static providers. Bearer auth uses this via - /// to enrich principals with - /// DB-resolved roles rather than only the roles stamped on the API token at - /// creation time. - /// - /// 🚨 A stalled or faulted read is , - /// never an empty role set (issue #637). It used to default to no roles, which is - /// indistinguishable from "this user genuinely has no grants" — so a transient storage - /// fault silently stripped a user's privileges and every screen they opened answered - /// "Access denied": an availability failure reported as an authorization failure. An - /// empty set now means exactly one thing: the read RESOLVED and found no grants. - /// - /// Workspace to run the synced grant query on. - /// The mesh user id whose grants to read. - /// Optional logger for the emit/timeout diagnostics. - /// - /// Optional override of the read budget. Production passes nothing and gets the cold-start-sized - /// ; tests pass a short window to reach the unavailable branch - /// deterministically, mirroring how ApiTokenService exposes ValidationReadTimeout. - /// - internal static IObservable>> LoadUserRoles( - IWorkspace workspace, string username, ILogger? logger, TimeSpan? budget = null) - { - var jsonOptions = workspace.Hub.JsonSerializerOptions; - var accessService = workspace.Hub.ServiceProvider.GetService(); - - // 🚨 As System — the same escape hatch, for the same reason, as - // SecurityService.ObserveScopeAssignments, which reads these very nodes and is described in - // WrapWithPerUserRls as doing so "by design". - // - // This read DECIDES the viewer's roles, so it is a security-fold read wearing ordinary - // clothes. `workspace.GetQuery` re-applies per-user RLS at the consumer, and that filter - // resolves permissions through PermissionEvaluator — which reads AccessAssignment nodes. - // Running it under the caller's identity therefore makes identity establishment depend on - // the identity being established, and re-enters the permission fold from the request path. - // Seeing System, WrapWithPerUserRls short-circuits to the raw upstream: "no per-user - // filter, no service resolution, NO RECURSION". That edge is what this removes. - // - // It grants the viewer nothing. The filter can only ever REMOVE grants they legitimately - // hold, and a stripped grant is indistinguishable from "no grants" — the empty-role outcome - // whose consequence the caller above documents as every screen answering "Access denied". - // - // RunAsSystem, never `Observable.Using(() => ImpersonateAsSystem(), …)`, whose store and - // restore land on different threads and leave the subscriber latched (AGENTS.md; the - // ratchet guard fails the build at any new site). - return IdentityRead.Bounded( - accessService.RunAsSystem(() => - // The (id, query set) pair is the cache key, so the three anchored legs below get - // their own process-wide snapshot; the synced layer UNIONs them, deduped by path. - workspace.GetQuery($"auth:userRoles:{username}", RoleQueries(username)) - .Do(items => logger?.LogDebug( - "LoadUserRoles({User}): synced query emit, items={Count}", - username, items.Count())) - .Take(1) - .Select(items => (IReadOnlyCollection?)FoldRoles(items, jsonOptions))), - budget ?? LookupTimeout, $"LoadUserRoles({username})", logger); - } - - /// - /// The partition that holds the platform-admin grants — "global admin" has exactly one meaning, - /// Permission.All at scope Admin, i.e. an AccessAssignment in - /// Admin/_Access (AGENTS.md → "Global admin"; hub.IsGlobalAdmin() keys off it). - /// - internal const string AdminPartition = "Admin"; - - /// - /// The account lookup: nodeType:User with no path. That is deliberate and it is PINNED, - /// not unanchored — UserNodeType registers a QueryRoutingRule that routes a - /// path-less nodeType:User query to the Auth partition (the auth-mirror of every - /// User node in the mesh, excluded from cross-schema search), and the Postgres planner consults - /// that rule BEFORE refusing a query as unanchored. SignInReadsAreAnchoredTest pins that - /// the rule resolves this exact text on the real mesh configuration. - /// - /// The sign-in email. - /// The query string. - internal static string UserByEmailQuery(string email) => - $"nodeType:User content.email:{email} limit:1"; - - /// - /// The three ANCHORED reads whose union is the user's platform role set (#3202) — each pinned to - /// ONE schema, none a mesh-wide fan-out: - /// - /// the ROOT scope — _Access/{id}, the registered global satellite - /// (system_access): platform-wide viewer/admin grants; - /// the — Admin/_Access/{id}: the platform-admin - /// grant, the ONE meaning of "global admin"; - /// the user's OWN partition — {user}/_Access/{id}: the Admin-of-my-own-home - /// grant onboarding writes. - /// - /// - /// Why not every partition. The read this replaces, - /// nodeType:AccessAssignment content.accessObject:"{user}" scope:subtree, named no - /// partition: on Postgres that was a UNION over every partition schema on EVERY request (199 - /// schemas on memex-cloud, ~4 s each under load — #2194), and since MeshWeaver.Plugins #1231 - /// made fan-out opt-in it was REFUSED outright, so every signed-in user got a 503. The obvious - /// remedy — partitions:all — would restore the fan-out, not remove it. - /// - /// Why three homes are the COMPLETE answer. AccessContext.Roles is read by - /// no permission decision: PermissionEvaluator deliberately ignores claim roles (a claim - /// role used to be a global, undeniable grant — the 2026-08-05 paywall bypass), and its only - /// consumer in core or MeshWeaver.Plugins is the per-viewer access-cache KEY. What the set - /// means is "this user's PLATFORM roles", and a platform role is granted in exactly these three - /// places by contract (Doc/Architecture/AccessControl → "Where to look"). A grant in an - /// arbitrary space (Editor on acme) is a DATA permission the fold evaluates from the - /// space's own _Access at check time; folding it into the platform roles was incidental - /// to the mesh-wide query, not a requirement of any caller. - /// - /// Why the #2011 deny hazard does not apply. Narrowing a fold read is dangerous - /// where a DENY may live outside the anchor — a group deny that loses its membership rows fails - /// OPEN. honours no deny at all (it collects non-denied role names and - /// subtracts nothing), so no scope's deny can be lost by not reading that scope. - /// - /// The mesh user id whose grants to read. - /// The three anchored query strings, each stamped complete. - internal static string[] RoleQueries(string username) => - [ - SecurityQueries.RootAssignmentsFor(username), - SecurityQueries.PartitionAssignmentsFor(AdminPartition, username), - SecurityQueries.PartitionAssignmentsFor(username, username), - ]; - - /// Back-compat overload used by callers that don't yet pass a logger. - internal static IObservable>> LoadUserRoles( - IWorkspace workspace, string username) - => LoadUserRoles(workspace, username, logger: null); - - private static IReadOnlyCollection FoldRoles( - IEnumerable items, JsonSerializerOptions options) - { - var roles = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var accessNode in items) - { - if (accessNode.Content == null) - continue; - - AccessAssignment? assignment = accessNode.Content switch - { - AccessAssignment aa => aa, - JsonElement je => JsonSerializer.Deserialize( - je.GetRawText(), options), - _ => null - }; - - if (assignment == null) - continue; - - foreach (var r in assignment.Roles.Where(r => !r.Denied && !string.IsNullOrEmpty(r.Role))) - roles.Add(r.Role); - } - return roles.ToList(); - } - - private static bool IsExcludedPath(PathString path) - { - var pathValue = path.Value ?? ""; - foreach (var prefix in ExcludedPrefixes) - { - if (pathValue.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - return true; - } - return false; - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/PasskeyStepUp.cs b/memex/Memex.Portal.Shared/Authentication/PasskeyStepUp.cs deleted file mode 100644 index 0a1315e65d..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/PasskeyStepUp.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System.Collections.Immutable; -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using Fido2NetLib; -using Fido2NetLib.Exceptions; -using Fido2NetLib.Objects; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Threading; - -namespace Memex.Portal.Shared.Authentication; - -/// What a passkey ceremony produced. -/// True when the server verified it. -/// Why not (passkey, unknownCredential, counter), or null. -/// The credential as it must be stored now (new, or with its counter moved). -public sealed record PasskeyResult(bool Ok, string? Reason, PasskeyCredential? Credential) -{ - internal static PasskeyResult Fail(string reason) => new(false, reason, null); -} - -/// -/// The passkey rung of the approval step-up (Doc/Architecture/ApprovalStepUp): WebAuthn -/// registration and assertion, verified by the maintained FIDO2 library (Fido2NetLib) — -/// origin, RP id, challenge, signature, user verification and the signature counter. The -/// assertion challenge is DERIVED from the pending step-up (user, targets, nonce), so an assertion -/// made for one approval can never confirm another. -/// -/// Reactive end to end: the library's verification is a Task-returning edge, so it -/// runs through the it is given and every caller receives an -/// . A refused ceremony is a with a reason, -/// never a fault; anything else the library throws stays a fault, so the caller fails closed. -/// -/// The relying-party id — the portal's host name. -/// The portal's origin (https://host). -/// The instance name shown by the authenticator. -/// The pool the library's asynchronous verification runs through. -internal sealed class PasskeyStepUp(string rpId, string origin, string rpName, IIoPool pool) -{ - private Fido2Configuration Config => new() - { - RPID = rpId, - RPName = rpName, - Origins = new HashSet(StringComparer.Ordinal) { origin }, - }; - - private Fido2 Fido => new(Config, null!); - - /// The challenge bound to one pending step-up: SHA-256 over the user, every target and the nonce. - /// The approver. - /// The targets. - /// The pending step-up's nonce. - /// 32 bytes. - public static byte[] ChallengeFor(string userId, IEnumerable targets, string nonce) - { - var sb = new StringBuilder("MeshWeaver.StepUp.Passkey.v1\n").Append(userId).Append('\n').Append(nonce).Append('\n'); - foreach (var t in targets) sb.Append(t.ActionPath).Append('\u001f').Append(t.Binding).Append('\n'); - return SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())); - } - - /// The user handle a user's passkeys are created for: SHA-256 of the mesh id (no personal data on the authenticator's handle). - /// The user. - /// 32 bytes. - public static byte[] UserHandle(string userId) => SHA256.HashData(Encoding.UTF8.GetBytes("MeshWeaver.User\n" + userId)); - - /// Registration options for a new passkey (user verification required, existing ones excluded). - /// The user. - /// How the authenticator labels the account. - /// Already enrolled credentials. - /// The options (JSON via ToJson()). - public CredentialCreateOptions RegistrationOptions(string userId, string displayName, IEnumerable existing) => - Fido.RequestNewCredential(new RequestNewCredentialParams - { - User = new Fido2User { Id = UserHandle(userId), Name = displayName, DisplayName = displayName }, - ExcludeCredentials = existing.Select(c => new PublicKeyCredentialDescriptor(Base64Url.Decode(c.CredentialId))).ToList(), - AuthenticatorSelection = new AuthenticatorSelection - { - ResidentKey = ResidentKeyRequirement.Preferred, - UserVerification = UserVerificationRequirement.Required, - }, - AttestationPreference = AttestationConveyancePreference.None, - }); - - /// Verifies a registration response against the options it answers. - /// The browser's attestation response. - /// The options issued for it. - /// Already enrolled credentials (an id already present is refused). - /// The clock. - /// Cold, single emission: the credential to store, or the refusal. - public IObservable Register(string responseJson, CredentialCreateOptions options, - IEnumerable existing, DateTimeOffset now) => - Observable.Defer(() => - { - if (Parse(responseJson) is not { } response) - return Observable.Return(PasskeyResult.Fail("passkey")); - var known = existing.Select(c => c.CredentialId).ToImmutableHashSet(StringComparer.Ordinal); - return pool.Invoke(ct => Fido.MakeNewCredentialAsync(new MakeNewCredentialParams - { - AttestationResponse = response, - OriginalOptions = options, - IsCredentialIdUniqueToUserCallback = (p, _) => Task.FromResult(!known.Contains(Base64Url.Encode(p.CredentialId))), - }, ct)) - .Select(registered => new PasskeyResult(true, null, new PasskeyCredential - { - CredentialId = Base64Url.Encode(registered.Id), - PublicKey = Convert.ToBase64String(registered.PublicKey), - UserHandle = Base64Url.Encode(registered.User.Id), - SignCount = registered.SignCount, - AaGuid = registered.AaGuid, - CreatedAt = now, - })) - .Catch((Fido2VerificationException _) => Observable.Return(PasskeyResult.Fail("passkey"))); - }); - - /// Assertion options for a pending step-up — the challenge is . - /// The bound challenge. - /// The user's passkeys. - /// The options. - public AssertionOptions AssertionOptions(byte[] challenge, IEnumerable credentials) => - Fido2NetLib.AssertionOptions.Create(Config, challenge, - credentials.Select(c => new PublicKeyCredentialDescriptor(Base64Url.Decode(c.CredentialId))).ToList(), - UserVerificationRequirement.Required, null); - - /// Verifies an assertion for the pending step-up. - /// The browser's assertion response. - /// The options (with the bound challenge) it answers. - /// The user's passkeys. - /// The clock. - /// Cold, single emission: the used credential with its counter moved, or the refusal. - public IObservable Assert(string responseJson, AssertionOptions options, - IEnumerable credentials, DateTimeOffset now) => - Observable.Defer(() => - { - if (Parse(responseJson) is not { } response) - return Observable.Return(PasskeyResult.Fail("passkey")); - var id = Base64Url.Encode(response.RawId); - var stored = credentials.FirstOrDefault(c => c.CredentialId == id); - if (stored is null) return Observable.Return(PasskeyResult.Fail("unknownCredential")); - return pool.Invoke(ct => Fido.MakeAssertionAsync(new MakeAssertionParams - { - AssertionResponse = response, - OriginalOptions = options, - StoredPublicKey = Convert.FromBase64String(stored.PublicKey), - StoredSignatureCounter = stored.SignCount, - IsUserHandleOwnerOfCredentialIdCallback = (p, _) => Task.FromResult( - p.UserHandle is null || Base64Url.Encode(p.UserHandle) == stored.UserHandle), - }, ct)) - .Select(verified => new PasskeyResult(true, null, stored with { SignCount = verified.SignCount, LastUsedAt = now })) - .Catch((Fido2VerificationException ex) => Observable.Return( - PasskeyResult.Fail(ex.Code == Fido2ErrorCode.InvalidSignCount ? "counter" : "passkey"))); - }); - - /// The browser's response, or null when it is not one (a malformed body is a refusal, not a fault). - private static T? Parse(string json) where T : class - { - try { return System.Text.Json.JsonSerializer.Deserialize(json); } - catch (System.Text.Json.JsonException) { return null; } - } -} - -/// RFC 4648 §5 base64url without padding. -internal static class Base64Url -{ - public static string Encode(byte[] bytes) => - Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - - public static byte[] Decode(string text) - { - var s = text.Replace('-', '+').Replace('_', '/'); - return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '=')); - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/ReturnUrlPolicy.cs b/memex/Memex.Portal.Shared/Authentication/ReturnUrlPolicy.cs deleted file mode 100644 index 5ba8864c06..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/ReturnUrlPolicy.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Memex.Portal.Shared.Authentication; - -/// -/// The ONE rule for a returnUrl that arrives from the outside world: it is honoured only if -/// it is LOCAL — a path on this host. Anything else falls back to the root. -/// -/// 🚨 Before this existed, ExternalAuthController redirected to -/// returnUrl ?? "/" unvalidated on both the sign-in callback and logout — a classic open -/// redirect (#2302): a crafted link to our own /auth/external/callback?returnUrl=https://evil -/// signs the victim in HERE and then sends them THERE, on a page that looks like the tail of our -/// own login flow. Pure and static so it is unit-tested without constructing a controller. -/// -public static class ReturnUrlPolicy -{ - /// A safe target: the given URL if it is local, otherwise "/". - public static string Sanitize(string? returnUrl) - { - if (string.IsNullOrWhiteSpace(returnUrl)) return "/"; - var u = returnUrl.Trim(); - // Local = a single leading slash, and NOT protocol-relative ("//evil.com") or a - // backslash variant browsers normalise to one ("/\evil.com"). - if (u.Length == 0 || u[0] != '/') return "/"; - if (u.Length > 1 && (u[1] == '/' || u[1] == '\\')) return "/"; - return u; - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/ServiceIdentities.cs b/memex/Memex.Portal.Shared/Authentication/ServiceIdentities.cs deleted file mode 100644 index febe117f83..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/ServiceIdentities.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Data; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// The verbs on SERVICE identities () — create, revoke, grant — shared -/// by the Admin app's Service identities tab and the tests, so both drive the SAME composition. -/// Token issuance, rotation and revocation are 's -/// (, ). -/// -/// 🚨 Every write runs as the CALLER. The records live in the Admin partition, so only a -/// global admin can create or revoke one; a grant is an ordinary at -/// the target scope, so it succeeds exactly where the caller may grant — a global admin is not a data -/// superuser, and this surface does not make them one. See Doc/Architecture/ServiceIdentities. -/// -internal static class ServiceIdentities -{ - /// - /// Creates the service identity called . Emits the created record. - /// Refused (OnError) when the name yields no usable object id. - /// - /// The hub to write through. - /// The display name — its object id is . - /// What the service is and who owns it. - /// The created record node. - public static IObservable Create(IMessageHub hub, string name, string? description) - { - var objectId = ServiceIdentity.ObjectIdFor(name); - if (objectId is null) - return Observable.Throw(new ArgumentException( - $"'{name}' does not yield a service object id: use letters, digits and dashes, at most " - + $"{64 - ServiceIdentity.ObjectIdPrefix.Length} characters.")); - - var issuedBy = hub.ServiceProvider.GetRequiredService().Context?.ObjectId ?? ""; - var node = new MeshNode(objectId, ServiceIdentity.Namespace) - { - NodeType = ServiceIdentity.NodeType, - Name = name.Trim(), - State = MeshNodeState.Active, - Content = new ServiceIdentity - { - Description = description?.Trim() ?? "", - IssuedBy = issuedBy, - IssuedAt = DateTimeOffset.UtcNow, - }, - }; - return hub.ServiceProvider.GetRequiredService().CreateNode(node); - } - - /// - /// Revokes the service identity . Every token it holds stops - /// authenticating on the next request — validation reads the record on every use — without - /// touching the tokens themselves. - /// - /// The hub to write through. - /// The service object id. - /// The updated record. - public static IObservable Revoke(IMessageHub hub, string objectId) - { - if (!ServiceIdentity.IsServiceObjectId(objectId)) - return Observable.Throw(new ArgumentException($"'{objectId}' is not a service object id.")); - var revokedBy = hub.ServiceProvider.GetRequiredService().Context?.ObjectId; - var path = ServiceIdentity.PathFor(objectId); - // Existence first, from the authoritative store (a point read of an absent node is a routing - // NotFound, not an answer): a typo'd or deleted id is refused by name, never "revoked". - return hub.ServiceProvider.GetRequiredService() - .Read(path, hub.JsonSerializerOptions) - .Take(1) - .DefaultIfEmpty() - .SelectMany(record => record?.ContentAs(hub.JsonSerializerOptions) is null - ? Observable.Throw(new ArgumentException( - $"No service identity '{objectId}' exists at {path}.")) - : RevokeExisting(hub, path, revokedBy)); - } - - private static IObservable RevokeExisting(IMessageHub hub, string path, string? revokedBy) - => hub.GetWorkspace() - .GetMeshNodeStream(path) - .Update(node => - { - var identity = node.ContentAs(hub.JsonSerializerOptions); - if (identity is null || identity.IsRevoked) - return node; - return node with - { - Content = identity with - { - IsRevoked = true, - RevokedAt = DateTimeOffset.UtcNow, - RevokedBy = revokedBy, - }, - }; - }); - - /// - /// Sets the service 's role at to - /// — the {scope}/_Access/{objectId}_Access - /// every principal's grant is (create-or-update, idempotent). - /// 🚨 It SETS, it does not add: a service holds one role per scope through this verb, so granting - /// Viewer where it held Editor replaces the Editor grant. That is the intent - /// for a machine principal (least privilege, one answer per scope); compose several roles on one - /// subject through the node's Access Control tab instead. - /// Refused for a non-service id, and — by ServicePrincipalAdminGuard — anywhere in the - /// Admin partition. - /// - /// The hub to write through. - /// The service object id. - /// The node path the grant applies at (and below). - /// The role id — Viewer, Editor, … - /// The assignment node. - public static IObservable Grant(IMessageHub hub, string objectId, string scopePath, string role) - { - if (!ServiceIdentity.IsServiceObjectId(objectId)) - return Observable.Throw(new ArgumentException($"'{objectId}' is not a service object id.")); - var scope = scopePath.Trim().Trim('/'); - if (scope.Length == 0) - return Observable.Throw(new ArgumentException( - "A service is granted at a named scope, never at the mesh root.")); - if (string.IsNullOrWhiteSpace(role)) - return Observable.Throw(new ArgumentException("A grant names a role.")); - var node = new MeshNode($"{objectId}_Access", $"{scope}/_Access") - { - NodeType = AccessAssignmentNodeType.NodeType, - Name = $"{objectId} Access", - MainNode = scope, - Content = new AccessAssignment - { - AccessObject = objectId, - DisplayName = objectId, - Roles = [new RoleAssignment { Role = role.Trim(), Denied = false }], - }, - }; - return hub.ServiceProvider.GetRequiredService().CreateOrUpdateNode(node); - } - - /// - /// Rotates a service token: issues a fresh token with the same label and end of term, THEN - /// revokes the old one — mint-then-revoke, so a failed mint leaves the service its working - /// credential. Emits the new token (shown once). - /// - /// The token service. - /// The service object id. - /// The path of the token being replaced. - /// The label to carry over. - /// The end of term to carry over. - /// The new token. - public static IObservable Rotate( - ApiTokenService tokens, string objectId, string oldTokenPath, string label, DateTimeOffset? expiresAt) - => tokens.CreateServiceToken(objectId, label, expiresAt) - .SelectMany(created => tokens.RevokeToken(oldTokenPath).Select(_ => created)); -} diff --git a/memex/Memex.Portal.Shared/Authentication/SetupHandOffController.cs b/memex/Memex.Portal.Shared/Authentication/SetupHandOffController.cs deleted file mode 100644 index 71dd280958..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/SetupHandOffController.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// One value on its way to a vault. -/// The configuration key it answers. -/// The secret. Never logged, never echoed, never stored outside the vault. -/// The object it becomes, derived by the sender from the record's prefix. -public sealed record SetupHandOffSecret(string ConfigKey, string Value, string VaultObject) -{ - /// - public override string ToString() => - $"SetupHandOffSecret {{ ConfigKey = {ConfigKey}, VaultObject = {VaultObject}, Value = }}"; -} - -/// What a provisioned instance asks the control instance to do at the end of its setup. -/// The deployment id, e.g. fabrikam. -/// The vault its record names. -/// The values that become vault objects. -/// The plain values, which belong on the record. -/// Who is to administer the instance. -/// Whether that person must also be invited. -/// What the wizard selected. -public sealed record SetupHandOffBody( - string Deployment, - string Vault, - IReadOnlyList Secrets, - IReadOnlyDictionary RecordValues, - string AdministratorEmail, - bool InviteAdministrator, - IReadOnlyList Plugins); - -/// -/// The control instance's setup hand-off: the values a freshly provisioned instance collected, -/// written into the vault ITS record names, by the one identity entitled to write there. -/// -/// 🚨 Its own path, deliberately NOT the control inbox. The inbox persists every event -/// it receives — that is what makes it an inbox, and what lets a lost callback be re-read. A body -/// carrying a client's sign-in secret and model keys must not be persisted anywhere, so bending the -/// inbox to make an exception for one event type would put the exception one refactor away from -/// being forgotten. This endpoint keeps the inbox's SIGNATURE scheme and none of its storage. -/// -/// What is durable when this returns: the vault objects, and nothing else. No node, no -/// activity log, no inbox row, no request log, no response echo. The values exist in this process's -/// memory for the length of the call. is overridden so an -/// interpolated log line cannot leak one, and every log statement here names the KEY and never the -/// value. -/// -/// 🚨 What it does not yet do. The plain half — the record values, the selected plugins -/// and the administrator grant — is reported back to the caller and is NOT applied here: those are -/// writes to a deployment record and to another instance's mesh, which belong to the operator path -/// the fleet already steers from. Doing half the job loudly is better than doing it quietly in a -/// place nobody audits. -/// -[ApiController] -[Route("api/hosting/setup-handoff")] -public class SetupHandOffController( - IConfiguration configuration, - ISetupSecretWriter writer, - ILogger logger) : ControllerBase -{ - /// The configuration key holding the shared secret — the inbox's own. - public const string SecretKey = "Hosting:PlatformWebhookSecret"; - - private static readonly JsonSerializerOptions Json = new() - { - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - /// - /// Receives one instance's collected setup values and writes the secret half to its vault. - /// - /// Cancellation. - [HttpPost] - public async Task Receive(CancellationToken cancellationToken) - { - // The RAW bytes: the signature is over exactly what arrived, so re-serialising a parsed - // object before verifying would make a property-order difference look like an attack. - using var buffer = new MemoryStream(); - await Request.Body.CopyToAsync(buffer, cancellationToken); - var body = buffer.ToArray(); - - var refusal = SetupHandOffVerifier.Verify( - configuration[SecretKey], - body, - Request.Headers[SetupHandOffVerifier.SignatureHeader], - Request.Headers[SetupHandOffVerifier.TimestampHeader], - Request.Headers[SetupHandOffVerifier.NonceHeader], - DateTimeOffset.UtcNow); - - if (refusal is not HandOffRefusal.None) - return Refuse(refusal); - - SetupHandOffBody? request; - try - { - request = JsonSerializer.Deserialize(body, Json); - } - catch (JsonException) - { - // Never the exception's message: a JSON error quotes the document it failed on. - return BadRequest("the body is not a setup hand-off."); - } - - if (request is null || string.IsNullOrWhiteSpace(request.Deployment)) - return BadRequest("the body names no deployment."); - if (string.IsNullOrWhiteSpace(request.Vault)) - return Refuse(HandOffRefusal.UnknownInstance); - - var written = new List(); - var failures = new List(); - foreach (var secret in request.Secrets ?? []) - { - if (string.IsNullOrWhiteSpace(secret.VaultObject) || string.IsNullOrEmpty(secret.Value)) - { - // The sender derives object names; one that arrives empty means the record had no - // prefix to derive from. Writing it under a guessed name would put a secret where - // the instance will never read it. - failures.Add($"'{secret.ConfigKey}' carries no vault object name — the instance's record has no keyVaultSecretPrefix."); - continue; - } - - var failure = await writer.WriteAsync(request.Vault, secret.VaultObject, secret.Value, cancellationToken); - if (failure is null) - written.Add(secret.VaultObject); - else - failures.Add(failure); - } - - // KEYS and object names only. This is the log line that would otherwise leak everything. - logger.LogInformation( - "Setup hand-off for {Deployment}: wrote {Written} object(s) to vault {Vault}, {Failed} refused. " - + "Administrator {Administrator}, invite={Invite}, {Plugins} plugin(s) selected.", - request.Deployment, written.Count, request.Vault, failures.Count, - request.AdministratorEmail, request.InviteAdministrator, request.Plugins?.Count ?? 0); - - if (failures.Count > 0) - return StatusCode(502, new - { - written, - failures, - message = "some values could not be written; nothing was stored anywhere else.", - }); - - return Ok(new - { - written, - // The plain half is reported, not applied — see the type's remarks. - pending = new - { - recordValues = request.RecordValues?.Keys ?? (IEnumerable)[], - plugins = request.Plugins ?? [], - administrator = request.AdministratorEmail, - invite = request.InviteAdministrator, - }, - }); - } - - /// - /// The answer for a refused hand-off. A refusal names WHICH check failed, except that an - /// unconfigured endpoint answers 404 — it must be indistinguishable from "no such route" so a - /// control instance that does not offer this does not advertise it. - /// - /// Why it was refused. - private IActionResult Refuse(HandOffRefusal refusal) - { - logger.LogWarning("Setup hand-off refused: {Refusal}", refusal); - return refusal switch - { - HandOffRefusal.NotConfigured => NotFound(), - HandOffRefusal.UnknownInstance => NotFound("no deployment record names that instance."), - HandOffRefusal.Replayed => Conflict("this hand-off has already been received."), - HandOffRefusal.Stale => BadRequest("the request is outside the accepted time window."), - _ => Unauthorized("the request is not signed by this fleet."), - }; - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/SetupHandOffVerifier.cs b/memex/Memex.Portal.Shared/Authentication/SetupHandOffVerifier.cs deleted file mode 100644 index 3131dc2c16..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/SetupHandOffVerifier.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Collections.Concurrent; -using System.Security.Cryptography; -using System.Text; - -namespace Memex.Portal.Shared.Authentication; - -/// Why the control instance refused a setup hand-off. -public enum HandOffRefusal -{ - /// Not a refusal. - None, - - /// No shared secret is configured here, so the endpoint does not exist. - NotConfigured, - - /// The request carried no signature. - Unsigned, - - /// The signature does not match the body. - BadSignature, - - /// The timestamp is outside the accepted window — too old, or from the future. - Stale, - - /// This exact request has been seen before. - Replayed, - - /// No deployment record names this instance, so there is no vault to write to. - UnknownInstance, -} - -/// -/// The door in front of the setup hand-off — the one endpoint that carries a client's secrets, from -/// a freshly provisioned instance to the vault its record names. -/// -/// 🚨 A signature alone is not enough, and this is the reason. The body of this request -/// is a set of secrets. Anyone who can capture one signed request can send it again — the signature -/// stays valid forever, because it signs the body and nothing about WHEN. So the door needs three -/// checks, not one: the signature proves the sender holds the shared secret, the TIMESTAMP bounds -/// how long a captured request stays useful, and the NONCE refuses the same request twice inside -/// that window. -/// -/// The nonce cache is per-process, and that is stated rather than hidden. A control -/// instance running two replicas can have a replay land on the other one inside the window. The -/// honest mitigations are the short window and the fact that a hand-off is accepted only while the -/// instance still has no administrator — a replay after setup completes changes nothing. A durable -/// nonce store would close it fully and can be added without touching this decision: a nonce is not -/// a secret, so unlike the body it may be persisted. -/// -public static class SetupHandOffVerifier -{ - /// How far from now a timestamp may be. Covers ordinary clock skew and no more. - public static readonly TimeSpan Window = TimeSpan.FromMinutes(5); - - /// The header carrying the signature — the pairing the control inbox already verifies. - public const string SignatureHeader = "X-Hub-Signature-256"; - - /// The header carrying the instant the request was signed, as Unix seconds. - public const string TimestampHeader = "X-Setup-Timestamp"; - - /// The header carrying the request's unique id. - public const string NonceHeader = "X-Setup-Nonce"; - - private static readonly ConcurrentDictionary Seen = new(); - - /// - /// Whether this request may be acted on. - /// - /// The shared webhook secret. Blank ⇒ the endpoint is off. - /// The exact bytes received. - /// The sha256=… header value. - /// The timestamp header value, Unix seconds. - /// The nonce header value. - /// The instant to judge against. - public static HandOffRefusal Verify( - string? configuredSecret, - byte[] body, - string? signature, - string? timestamp, - string? nonce, - DateTimeOffset now) - { - ArgumentNullException.ThrowIfNull(body); - - if (string.IsNullOrWhiteSpace(configuredSecret)) - return HandOffRefusal.NotConfigured; - if (string.IsNullOrWhiteSpace(signature) || string.IsNullOrWhiteSpace(nonce)) - return HandOffRefusal.Unsigned; - - if (!long.TryParse(timestamp, out var unixSeconds)) - return HandOffRefusal.Stale; - var signedAt = DateTimeOffset.FromUnixTimeSeconds(unixSeconds); - // Both directions: a future timestamp would otherwise extend a captured request's life. - if (now - signedAt > Window || signedAt - now > Window) - return HandOffRefusal.Stale; - - var expected = Sign(body, configuredSecret!); - if (!BootstrapSecretGate.FixedTimeEquals(expected, signature!.Trim())) - return HandOffRefusal.BadSignature; - - // Last, so a replay check is never spent on a request that was not authentic in the first - // place — otherwise an attacker could burn nonces they do not own. - Forget(now); - return Seen.TryAdd(nonce!.Trim(), now) ? HandOffRefusal.None : HandOffRefusal.Replayed; - } - - /// The signature for a body, in the form the sender produces. - /// The exact bytes. - /// The shared secret. - public static string Sign(byte[] body, string secret) => - "sha256=" + Convert.ToHexStringLower(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), body)); - - /// Drops nonces older than the window — they can no longer be replayed anyway. - /// The current instant. - public static void Forget(DateTimeOffset now) - { - foreach (var (nonce, seenAt) in Seen) - if (now - seenAt > Window) - Seen.TryRemove(nonce, out _); - } - - /// Clears the nonce cache. For tests, which must not inherit each other's state. - public static void Reset() => Seen.Clear(); -} diff --git a/memex/Memex.Portal.Shared/Authentication/SetupSecretWriter.cs b/memex/Memex.Portal.Shared/Authentication/SetupSecretWriter.cs deleted file mode 100644 index cbe6fe30de..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/SetupSecretWriter.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Net.Http.Json; -using Azure.Core; - -namespace Memex.Portal.Shared.Authentication; - -/// Writes one collected value into an instance's Key Vault. -public interface ISetupSecretWriter -{ - /// - /// Writes as in . - /// - /// The vault name, as the deployment record states it. - /// The object name, derived by the fleet's rule. - /// The secret. Never logged, never returned, never stored anywhere else. - /// Cancellation. - /// Null on success; the reason on failure — never containing the value. - Task WriteAsync(string vault, string objectName, string value, CancellationToken cancellationToken); -} - -/// -/// The Key Vault writer, over the vault's REST API with a token from this instance's own managed -/// identity. -/// -/// REST rather than the SDK because Azure.Identity is already a dependency here -/// and Azure.Security.KeyVault.Secrets is not: one PUT is not worth a package, an audit -/// entry and a version to keep. -/// -/// 🚨 This needs a write grant the control instance does not have — and the obvious way to -/// give it is wrong. Measured 2026-09-16 on memexaks-portal-mi (subscription 7ecc5974, -/// resource group memex-aks-rg): that ONE managed identity carries five federated credentials — -/// system:serviceaccount:{initech,memex,memex-cloud,build,fabrikam}:memex-portal-sa. It is not the -/// control instance's identity; it is every portal's identity on that cluster. Granting it -/// Secrets Officer on the Systemorph vault would give WRITE over every object in that -/// vault to every instance on the cluster, a CLIENT instance included — the exact opposite of why -/// the hand-off goes through the control instance at all. -/// -/// What to grant instead. A DEDICATED identity for the control instance, federated only -/// to system:serviceaccount:memex:memex-portal-sa, holding Secrets Officer, while the shared -/// identity keeps read. That is the smaller change and the recommended one. Scoping the grant per -/// SECRET OBJECT instead is supported by Key Vault RBAC, but it does not scale past a handful of -/// names and still lands on the shared identity. -/// -/// Until one of those exists, every call here refuses with the vault's own 403 — correct -/// behaviour, reported as a refusal the maintainer can act on rather than as a mysterious failure. -/// The alternative designs are worse: handing the values to the operator lane would put them in -/// workflow inputs, and writing them from the instance would give every client portal write access -/// to its own secrets. -/// -/// The client used for the PUT. -/// -/// The token source, supplied by the host — normally the instance's own managed identity. Required -/// rather than defaulted: this assembly is also built for the browser, where a managed-identity -/// credential does not exist, and a type that constructs one unconditionally cannot compile there. -/// The registration is where the server's credential belongs anyway. -/// -public sealed class KeyVaultSetupSecretWriter(HttpClient httpClient, TokenCredential credential) - : ISetupSecretWriter -{ - - /// The scope a vault token is requested for. - public const string Scope = "https://vault.azure.net/.default"; - - /// - public async Task WriteAsync( - string vault, string objectName, string value, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(vault); - ArgumentException.ThrowIfNullOrWhiteSpace(objectName); - ArgumentException.ThrowIfNullOrWhiteSpace(value); - - try - { - var token = await credential.GetTokenAsync( - new TokenRequestContext([Scope]), cancellationToken); - - using var request = new HttpRequestMessage( - HttpMethod.Put, - $"https://{vault}.vault.azure.net/secrets/{Uri.EscapeDataString(objectName)}?api-version=7.4") - { - Content = JsonContent.Create(new { value }), - }; - request.Headers.Authorization = new("Bearer", token.Token); - - using var response = await httpClient.SendAsync(request, cancellationToken); - if (response.IsSuccessStatusCode) - return null; - - // 🚨 The vault's body is echoed because it names the missing role — and it cannot - // contain the value, which travelled in the REQUEST. A refusal nobody can read is a - // refusal nobody can fix. - var detail = await response.Content.ReadAsStringAsync(cancellationToken); - return $"the vault refused '{objectName}' with {(int)response.StatusCode}: " - + Truncate(detail, 300); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // The message, never the exception's full detail: a stack trace from a JSON serializer - // can carry the payload it was serializing. - return $"the vault could not be reached for '{objectName}': {ex.GetType().Name}"; - } - } - - private static string Truncate(string text, int max) => - string.IsNullOrEmpty(text) ? "" : text.Length <= max ? text : text[..max] + "…"; -} diff --git a/memex/Memex.Portal.Shared/Authentication/StepUpClaims.cs b/memex/Memex.Portal.Shared/Authentication/StepUpClaims.cs deleted file mode 100644 index 57083203db..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/StepUpClaims.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Globalization; -using System.Security.Claims; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// The claims the session cookie carries so an approval step-up knows WHICH ladder rung applies and -/// WHOSE token to accept (Doc/Architecture/ApprovalStepUp). Before these existed the cookie -/// recorded nothing about the provider — every sign-in was re-issued with name and email only — so a -/// Microsoft account and a Google account were indistinguishable after sign-in. -/// -public static class StepUpClaims -{ - /// The sign-in provider's scheme name (Microsoft, Google, LinkedIn, Apple, GitHub, Dev). - public const string Idp = "mw_idp"; - - /// The Entra object id of a Microsoft account. - public const string Oid = "mw_oid"; - - /// The Entra tenant id of a Microsoft account. - public const string Tid = "mw_tid"; - - /// When this session signed in (Unix seconds) — kept through sliding renewal. - public const string AuthTime = "mw_auth_time"; - - /// The scheme name of the Microsoft (Entra ID) sign-in. - public const string MicrosoftProvider = "Microsoft"; - - // The provider's own spellings: Entra's short names, and the long URIs the OIDC handler maps - // them to by default. Our session claims carry an `mw_` prefix so a provider claim of the same - // short name (Entra sends `idp` for guests, `auth_time` on request) can never pass for ours. - private const string EntraOid = "oid"; - private const string EntraTid = "tid"; - private const string MappedOid = "http://schemas.microsoft.com/identity/claims/objectidentifier"; - private const string MappedTid = "http://schemas.microsoft.com/identity/claims/tenantid"; - - /// - /// The step-up claims to add to a session being issued: the provider (when known), the sign-in - /// time (when this IS a sign-in), and the Entra object/tenant ids taken from the provider's own - /// claims (the OIDC handler maps oid/tid to their long URI forms by default; both - /// spellings are read). - /// - /// The scheme the user signed in with (), or null when unknown. - /// The provider's (or the existing session's) claims. - /// When the user authenticated, or null to stamp none. - /// The claims to add. - public static IEnumerable ForSession(string? provider, IReadOnlyCollection external, DateTimeOffset? authTime) - { - if (!string.IsNullOrEmpty(provider)) yield return new Claim(Idp, provider); - if (authTime is { } at) yield return new Claim(AuthTime, at.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); - var oid = external.FirstOrDefault(c => c.Type is Oid or EntraOid or MappedOid)?.Value; - if (!string.IsNullOrEmpty(oid)) yield return new Claim(Oid, oid); - var tid = external.FirstOrDefault(c => c.Type is Tid or EntraTid or MappedTid)?.Value; - if (!string.IsNullOrEmpty(tid)) yield return new Claim(Tid, tid); - } - - /// - /// The provider a session REALLY signed in with — from the authentication ticket the challenged - /// scheme produced (the provider item the sign-in challenge set), else from the provider - /// the existing session already records. NEVER from the callback route: a signed-in Google user - /// who opens /auth/callback/Microsoft must not become a Microsoft session. - /// - /// The ticket's provider item, present right after a remote sign-in. - /// The claims of the principal being re-issued. - /// The provider, or null when nothing trustworthy names one. - public static string? ResolveProvider(string? ticketProvider, IEnumerable sessionClaims) => - !string.IsNullOrEmpty(ticketProvider) ? ticketProvider - : sessionClaims.FirstOrDefault(c => c.Type == Idp)?.Value; - - /// The provider the session signed in with, or null for a session issued before the claim existed. - /// The session principal. - /// The scheme name or null. - public static string? ProviderOf(ClaimsPrincipal user) => user.FindFirst(Idp)?.Value; -} diff --git a/memex/Memex.Portal.Shared/Authentication/StepUpController.Portal.cs b/memex/Memex.Portal.Shared/Authentication/StepUpController.Portal.cs deleted file mode 100644 index 084125de96..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/StepUpController.Portal.cs +++ /dev/null @@ -1,663 +0,0 @@ -using System.Collections.Immutable; -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text.Json; -using System.Text.Json.Serialization; -using Fido2NetLib; -using MeshWeaver.AI; // IProviderKeyProtector keeps its original namespace (#2398 forwarders) -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Net.Codecrete.QrCodeGenerator; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// The portal-held rungs of the approval step-up — for every account that is NOT a Microsoft -/// account (Doc/Architecture/ApprovalStepUp → "The passkey rung", "The TOTP rung"): the -/// passkey assertion, the TOTP code, and enrolling either. Every endpoint re-decides on the server -/// what the page offered; the page decides nothing. -/// -/// What every completing endpoint checks, in this order: the pending step-up is TAKEN -/// atomically (one ceremony, one proof); the rung the server recorded on it is this endpoint's -/// method AND the ladder decided again now still lands on it ( -/// — so a Microsoft account can never finish an Entra step-up with a portal factor); and a TOTP -/// step or recovery code is CLAIMED in the store before a receipt is minted, so two concurrent -/// confirmations of one code yield one receipt, not two. -/// -public sealed partial class StepUpController -{ - /// The enrolment page. - public const string EnrollPath = "/" + BasePath + "/enroll"; - - /// Where the TOTP form posts. - public const string TotpVerifyAction = "totp/verify"; - - /// How recent a sign-in must be to enrol the FIRST factor (no factor exists to step up with). - internal static readonly TimeSpan FirstFactorSignInAge = TimeSpan.FromMinutes(10); - - private const string RegistrationCookie = "mw_stepup_reg"; - private const string TotpEnrollCookie = "mw_stepup_totp"; - - // ───────────────────────────── the passkey rung ───────────────────────────── - - /// Assertion options for the pending step-up; the challenge is bound to it. - /// Cancellation. - /// The options JSON, or {error}. - [HttpPost("passkey/options")] - public Task PasskeyOptions(CancellationToken ct = default) - { - var t = Texts(); - var userId = access.Context?.ObjectId; - if (string.IsNullOrEmpty(userId)) return Done(Unauthorized()); - var rung = RungCheck(); - var rp = Passkeys(); - var flow = TakePending(userId, state: null, complete: false).SelectMany(pending => pending is null - ? Observable.Return(Json(Error(t, "state"))) - : ReadFactors(userId).Select(read => - { - if (!read.Answered) return Json(Error(t, "unavailable")); - if (!rung(pending, StepUpRung.Passkey, read.Factors)) return Json(Error(t, "wrongRung")); - if (read.Factors is not { Passkeys.Count: > 0 } factors) return Json(Error(t, "notEnrolled")); - var options = rp.AssertionOptions(PasskeyStepUp.ChallengeFor(userId, pending.Targets, pending.Nonce), factors.Passkeys.Values); - return (IActionResult)Content(options.ToJson(), "application/json"); - })); - return Edge(flow, () => Json(Error(t, "unavailable")), "passkey options", userId, ct); - } - - /// Verifies the assertion, mints the receipt, stamps the targets. - /// The browser's assertion response (the request body). - /// Cancellation. - /// {redirect} or {error}. - [HttpPost("passkey/verify")] - public Task PasskeyVerify([FromBody] JsonElement assertion, CancellationToken ct = default) - { - var t = Texts(); - var caller = access.Context; - var userId = caller?.ObjectId; - if (caller is null || string.IsNullOrEmpty(userId)) return Done(Unauthorized()); - var hub = Hub(); - if (hub is null) return Done(Json(Error(t, "mint"))); - var body = assertion.GetRawText(); - var rung = RungCheck(); - var rp = Passkeys(hub); - - var flow = TakePending(userId, state: null, complete: true).SelectMany(pending => - { - if (pending is null) return Observable.Return(Json(Error(t, "state"))); - return ReadFactors(userId).SelectMany(read => - { - if (!read.Answered) return Observable.Return(Json(Error(t, "unavailable"))); - // Server-decided, re-checked: only a step-up STARTED as a passkey one, for an - // account whose rung is still the passkey (never a Microsoft account). - if (!rung(pending, StepUpRung.Passkey, read.Factors)) - { - logger.LogWarning("Step-up passkey for {User} refused: the step-up was started as '{Rung}'", userId, pending.Rung); - return Observable.Return(Json(Error(t, "wrongRung"))); - } - if (read.Factors is not { Passkeys.Count: > 0 } factors) - return Observable.Return(Json(Error(t, "notEnrolled"))); - var options = rp.AssertionOptions(PasskeyStepUp.ChallengeFor(userId, pending.Targets, pending.Nonce), factors.Passkeys.Values); - var now = DateTimeOffset.UtcNow; - return rp.Assert(body, options, factors.Passkeys.Values, now).SelectMany(result => - { - if (!result.Ok || result.Credential is not { } used) - { - logger.LogWarning("Step-up passkey for {User} refused: {Reason}", userId, result.Reason); - return Observable.Return(Json(Error(t, result.Reason ?? "passkey"))); - } - // The snapshot only says the counter COULD be accepted. A non-zero counter value is - // CLAIMED in the store first, so two assertions carrying the same counter (a cloned - // authenticator, run against two ceremonies at once) yield one receipt, not two. - // Zero means "no counter" (WebAuthn): nothing to claim, the single-use pending bounds it. - var claim = used.SignCount > 0 - ? new StepUpSingleUse(hub).ClaimPasskeyCounter(userId, used.CredentialId, used.SignCount) - : Observable.Return(true); - return claim.SelectMany(won => - { - if (!won) - { - logger.LogWarning("Step-up passkey for {User} refused: counter {Count} of the credential was already spent", userId, used.SignCount); - return Observable.Return(Json(Error(t, "counter"))); - } - return new StepUpFactorStore(hub) - // Keyed by credential id: the patch touches this credential's fields alone. - .Update(userId, f => f with - { - Passkeys = f.Passkeys.TryGetValue(used.CredentialId, out var current) - ? f.Passkeys.SetItem(used.CredentialId, used with { SignCount = Math.Max(used.SignCount, current.SignCount) }) - : f.Passkeys, - }) - .SelectMany(_ => MintAndStamp(hub, caller, StepUpMethod.Passkey, now, - $"credential={used.CredentialId[..Math.Min(12, used.CredentialId.Length)]} aaguid={used.AaGuid}", pending.Targets)) - .Select(receipt => (IActionResult)Json(new { redirect = SuccessUrl(pending, receipt) })) - .Catch((Exception ex) => - { - logger.LogWarning(ex, "Step-up passkey for {User}: recording the receipt failed", userId); - return Observable.Return(Json(Error(t, "mint"))); - }); - }); - }); - }); - }); - return Edge(flow, () => Json(Error(t, "mint")), "passkey verify", userId, ct); - } - - // ───────────────────────────── the TOTP rung ───────────────────────────── - - /// Verifies a TOTP or recovery code for the pending step-up — only for an account with no passkey. - /// What the user typed. - /// Cancellation. - /// A redirect back, or a page explaining the failure. - [HttpPost(TotpVerifyAction)] - public Task TotpVerify([FromForm] string? code, CancellationToken ct = default) - { - var t = Texts(); - var caller = access.Context; - var userId = caller?.ObjectId; - if (caller is null || string.IsNullOrEmpty(userId)) return Done(Unauthorized()); - var hub = Hub(); - var rung = RungCheck(); - - var flow = TakePending(userId, state: null, complete: true).SelectMany(pending => - { - if (pending is null) return Observable.Return(Failure(t, "/", "state")); - if (hub is null) return Observable.Return(Failure(t, pending.ReturnUrl, "mint")); - return ReadFactors(userId).SelectMany(read => - { - if (!read.Answered) return Observable.Return(Failure(t, pending.ReturnUrl, "unavailable")); - // Re-decided here, never trusted from the page: the step-up was started as a TOTP one, - // and the account's rung is STILL TOTP — not Entra (a Microsoft account), not a passkey. - if (!rung(pending, StepUpRung.Totp, read.Factors)) - { - logger.LogWarning("Step-up TOTP for {User} refused: the step-up was started as '{Rung}'", userId, pending.Rung); - return Observable.Return(Failure(t, pending.ReturnUrl, - read.Factors is { Passkeys.Count: > 0 } ? "downgrade" : "wrongRung")); - } - // A durable, per-user attempt budget ACROSS ceremonies, spent before the code is - // even looked at: consuming the pending record allows one guess per ceremony, but - // ceremonies are free to start, so without this a stolen session could keep guessing - // six digits. Each attempt claims one of TotpAttemptsPerWindow slots of the current - // window in the store (atomic, survives restarts and replicas); none left = refused. - return new StepUpSingleUse(hub).ClaimTotpAttempt(userId, DateTimeOffset.UtcNow).SelectMany(admitted => - { - if (!admitted) - { - logger.LogWarning("Step-up TOTP for {User} refused: the attempt budget of the current window is spent", userId); - return Observable.Return(Failure(t, pending.ReturnUrl, "locked")); - } - var factors = read.Factors!; - var protector = hub.ServiceProvider.GetRequiredService(); - var secretText = protector.Unprotect(factors.TotpSecretProtected); - if (string.IsNullOrEmpty(secretText)) return Observable.Return(Failure(t, pending.ReturnUrl, "totp")); - var secret = Convert.FromBase64String(secretText); - var now = DateTimeOffset.UtcNow; - - var step = Totp.Verify(secret, code, now, factors.LastTotpStep); - var recoveryHash = step is null ? Totp.HashRecoveryCode(code ?? "") : null; - if (step is null && (recoveryHash is null || !factors.RecoveryCodeHashes.Contains(recoveryHash))) - { - logger.LogWarning("Step-up TOTP for {User} refused: no valid code", userId); - return Observable.Return(Failure(t, pending.ReturnUrl, "totp")); - } - - // The snapshot above only says the code COULD be accepted. The claim says it IS — - // once: of two concurrent confirmations of the same step or recovery code, only the - // store's winner goes on to mint. - var singleUse = new StepUpSingleUse(hub); - var claim = step is { } s ? singleUse.ClaimTotpStep(userId, s) : singleUse.ClaimRecoveryCode(userId, recoveryHash!); - return claim.SelectMany(won => - { - if (!won) - { - logger.LogWarning("Step-up TOTP for {User} refused: the code was spent by a concurrent confirmation", userId); - return Observable.Return(Failure(t, pending.ReturnUrl, "totp")); - } - return new StepUpFactorStore(hub) - .Update(userId, f => step is { } s1 - ? f with { LastTotpStep = Math.Max(f.LastTotpStep, s1) } - : f with { RecoveryCodeHashes = f.RecoveryCodeHashes.Remove(recoveryHash!) }) - .SelectMany(_ => MintAndStamp(hub, caller, StepUpMethod.Totp, now, - step is { } s2 ? $"totp step={s2}" : "totp recovery-code", pending.Targets)) - .Select(receipt => (IActionResult)Redirect(SuccessUrl(pending, receipt))) - .Catch((Exception ex) => - { - logger.LogWarning(ex, "Step-up TOTP for {User}: recording the receipt failed", userId); - return Observable.Return(Failure(t, pending.ReturnUrl, "mint")); - }); - }); - }); - }); - }); - return Edge(flow, () => Failure(t, "/", "mint"), "totp verify", userId, ct); - } - - // ───────────────────────────── enrolment ───────────────────────────── - - /// The enrolment page. - /// Where "back" goes. - /// The receipt of a step-up that authorized adding another factor. - /// Cancellation. - /// The page. - [HttpGet("enroll")] - public Task Enroll([FromQuery] string? returnUrl, [FromQuery] string? stepUpReceipt, CancellationToken ct = default) - { - var t = Texts(); - var safeReturn = ReturnUrlPolicy.Sanitize(returnUrl); - var userId = access.Context?.ObjectId; - if (string.IsNullOrEmpty(userId)) return Done(Unauthorized()); - // A Microsoft account confirms through Entra: there is nothing to enrol, and no button to press. - if (!StepUpLadder.MayEnrollPortalFactors(StepUpClaims.ProviderOf(User))) - return Done(Page(StepUpPages.Message(t, t.L(StepUpLadder.IsMicrosoft(StepUpClaims.ProviderOf(User)) - ? "stepUp.enroll.microsoft" : "stepUp.refused.unknownSession"), safeReturn))); - var options = StepUpOptions.From(configuration); - var gate = EnrollGateFor(userId); - var flow = ReadFactors(userId).SelectMany(read => !read.Answered - ? Observable.Return(Failure(t, safeReturn, "unavailable")) - : gate(read.Factors, stepUpReceipt, false).Select(authorization => - { - var confirmFirst = authorization == EnrollAuthorization.NeedsStepUp - ? StepUpPaths.StartUrl([new StepUpTarget { ActionPath = StepUpPaths.Factors(userId), Binding = StepUpPaths.EnrollBinding }], - EnrollPath + "?returnUrl=" + Uri.EscapeDataString(safeReturn)) - : null; - return (IActionResult)Page(StepUpPages.Enroll(t, read.Factors, StepUpLadder.MayEnrollTotp(options, read.Factors), - authorization == EnrollAuthorization.AllowedByReceipt ? stepUpReceipt : null, confirmFirst, - authorization == EnrollAuthorization.NeedsFreshSignIn, safeReturn)); - })); - return Edge(flow, () => Failure(t, safeReturn, "unavailable"), "enroll page", userId, ct); - } - - /// Body of the enrolment calls. - public sealed record EnrollRequest - { - /// The authorizing receipt, when another factor exists. - [JsonPropertyName("receipt")] public string? Receipt { get; init; } - - /// The browser's attestation response, as JSON text. - [JsonPropertyName("credential")] public string? Credential { get; init; } - - /// A TOTP code. - [JsonPropertyName("code")] public string? Code { get; init; } - } - - /// Registration options for a new passkey. - /// The authorizing receipt. - /// Cancellation. - /// The options JSON, or {error}. - [HttpPost("passkey/register/options")] - public Task PasskeyRegisterOptions([FromBody] EnrollRequest request, CancellationToken ct = default) - { - var t = Texts(); - var userId = access.Context?.ObjectId; - if (string.IsNullOrEmpty(userId)) return Done(Unauthorized()); - var gate = EnrollGateFor(userId); - var display = User.FindFirst("email")?.Value ?? userId; - var rp = Passkeys(); - var protector = Protector(); - var flow = ReadFactors(userId).SelectMany(read => !read.Answered - ? Observable.Return(Json(Error(t, "unavailable"))) - : gate(read.Factors, request.Receipt, false).Select(authorization => - { - if (!IsAllowed(authorization)) return Json(Error(t, ReasonFor(authorization))); - var options = rp.RegistrationOptions(userId, display, read.Factors?.Passkeys.Values ?? []); - Response.Cookies.Append(RegistrationCookie, protector.Protect(BindToUser(userId, options.ToJson()), DateTimeOffset.UtcNow + PendingLifetime), SealedCookie()); - return (IActionResult)Content(options.ToJson(), "application/json"); - })); - return Edge(flow, () => Json(Error(t, "unavailable")), "passkey register options", userId, ct); - } - - /// Verifies the attestation and stores the new passkey. - /// The receipt and the attestation. - /// Cancellation. - /// {ok} or {error}. - [HttpPost("passkey/register")] - public Task PasskeyRegister([FromBody] EnrollRequest request, CancellationToken ct = default) - { - var t = Texts(); - var userId = access.Context?.ObjectId; - if (string.IsNullOrEmpty(userId)) return Done(Unauthorized()); - var hub = Hub(); - if (hub is null) return Done(Json(Error(t, "mint"))); - var optionsJson = ReadSealedFor(RegistrationCookie, userId); - Response.Cookies.Delete(RegistrationCookie, new CookieOptions { Path = "/" + BasePath }); - if (optionsJson is null) return Done(Json(Error(t, "state"))); - var gate = EnrollGateFor(userId); - var rp = Passkeys(hub); - var store = new StepUpFactorStore(hub); - - var flow = ReadFactors(userId).SelectMany(read => !read.Answered - ? Observable.Return(Json(Error(t, "unavailable"))) - // The authorization is CONSUMED here, at the write — options alone grant nothing. - : gate(read.Factors, request.Receipt, true).SelectMany(authorization => !IsAllowed(authorization) - ? Observable.Return(Json(Error(t, ReasonFor(authorization)))) - : rp.Register(request.Credential ?? "", CredentialCreateOptions.FromJson(optionsJson), read.Factors?.Passkeys.Values ?? [], DateTimeOffset.UtcNow) - .SelectMany(result => - { - if (!result.Ok || result.Credential is not { } credential) - return Observable.Return(Json(Error(t, result.Reason ?? "passkey"))); - return StoreFactor(store, userId, authorization, - first: new StepUpFactors { Passkeys = ImmutableDictionary.Empty.Add(credential.CredentialId, credential) }, - fold: f => f.Passkeys.ContainsKey(credential.CredentialId) ? f : f with { Passkeys = f.Passkeys.Add(credential.CredentialId, credential) }, - landed: f => f.Passkeys.ContainsKey(credential.CredentialId), - report: ex => logger.LogWarning(ex, "Passkey enrolment for {User} could not be stored", userId)) - .Select(outcome => - { - logger.LogInformation("Passkey enrolment for {User}: {Outcome}", userId, outcome); - return outcome == FactorWrite.Stored ? Json(new { ok = true }) : Json(Error(t, ReasonFor(outcome))); - }); - }))); - return Edge(flow, () => Json(Error(t, "mint")), "passkey register", userId, ct); - } - - /// Starts an authenticator-app enrolment — only for an account with no passkey. - /// The authorizing receipt. - /// Cancellation. - /// {svg, secret} or {error}. - [HttpPost("totp/enroll/start")] - public Task TotpEnrollStart([FromBody] EnrollRequest request, CancellationToken ct = default) - { - var t = Texts(); - var userId = access.Context?.ObjectId; - if (string.IsNullOrEmpty(userId)) return Done(Unauthorized()); - var gate = EnrollGateFor(userId); - var options = StepUpOptions.From(configuration); - var account = User.FindFirst("email")?.Value ?? userId; - var host = Request.Host.Host; - var protector = Protector(); - var flow = ReadFactors(userId).SelectMany(read => - { - if (!read.Answered) return Observable.Return(Json(Error(t, "unavailable"))); - if (!StepUpLadder.MayEnrollTotp(options, read.Factors)) return Observable.Return(Json(Error(t, "downgrade"))); - return gate(read.Factors, request.Receipt, false).Select(authorization => - { - if (!IsAllowed(authorization)) return Json(Error(t, ReasonFor(authorization))); - var secret = Totp.NewSecret(); - Response.Cookies.Append(TotpEnrollCookie, protector.Protect(BindToUser(userId, Convert.ToBase64String(secret)), DateTimeOffset.UtcNow + PendingLifetime), SealedCookie()); - var uri = Totp.OtpAuthUri(host, account, secret); - return (IActionResult)Json(new { svg = QrCode.EncodeText(uri, QrCode.Ecc.Medium).ToSvgString(4), secret = Totp.Base32(secret) }); - }); - }); - return Edge(flow, () => Json(Error(t, "unavailable")), "totp enroll start", userId, ct); - } - - /// Confirms the authenticator app with its first code; returns the recovery codes ONCE. - /// The receipt and the code. - /// Cancellation. - /// {recoveryCodes} or {error}. - [HttpPost("totp/enroll/confirm")] - public Task TotpEnrollConfirm([FromBody] EnrollRequest request, CancellationToken ct = default) - { - var t = Texts(); - var userId = access.Context?.ObjectId; - if (string.IsNullOrEmpty(userId)) return Done(Unauthorized()); - var hub = Hub(); - if (hub is null) return Done(Json(Error(t, "mint"))); - var secretText = ReadSealedFor(TotpEnrollCookie, userId); - if (secretText is null) return Done(Json(Error(t, "state"))); - var gate = EnrollGateFor(userId); - var options = StepUpOptions.From(configuration); - var store = new StepUpFactorStore(hub); - var keyProtector = hub.ServiceProvider.GetRequiredService(); - - var flow = ReadFactors(userId).SelectMany(read => - { - if (!read.Answered) return Observable.Return(Json(Error(t, "unavailable"))); - if (!StepUpLadder.MayEnrollTotp(options, read.Factors)) return Observable.Return(Json(Error(t, "downgrade"))); - var step = Totp.Verify(Convert.FromBase64String(secretText), request.Code, DateTimeOffset.UtcNow, lastAcceptedStep: 0); - if (step is null) return Observable.Return(Json(Error(t, "totp"))); - return gate(read.Factors, request.Receipt, true).SelectMany(authorization => - { - if (!IsAllowed(authorization)) return Observable.Return(Json(Error(t, ReasonFor(authorization)))); - Response.Cookies.Delete(TotpEnrollCookie, new CookieOptions { Path = "/" + BasePath }); - var codes = Totp.NewRecoveryCodes(); - var protectedSecret = keyProtector.Protect(secretText); - StepUpFactors Apply(StepUpFactors f) => f with - { - TotpSecretProtected = protectedSecret, - TotpConfirmedAt = DateTimeOffset.UtcNow, - LastTotpStep = step.Value, - RecoveryCodeHashes = codes.Select(Totp.HashRecoveryCode).ToImmutableList(), - }; - return StoreFactor(store, userId, authorization, - first: Apply(new StepUpFactors()), - fold: Apply, - landed: f => f.TotpSecretProtected == protectedSecret, - report: ex => logger.LogWarning(ex, "TOTP enrolment for {User} could not be stored", userId)) - .Select(outcome => - { - logger.LogInformation("TOTP enrolment for {User}: {Outcome}", userId, outcome); - return outcome == FactorWrite.Stored ? Json(new { recoveryCodes = codes }) : Json(Error(t, ReasonFor(outcome))); - }); - }); - }); - return Edge(flow, () => Json(Error(t, "mint")), "totp enroll confirm", userId, ct); - } - - // ───────────────────────────── shared ───────────────────────────── - - /// Whether adding a factor is authorized right now — and WHICH write it authorizes. - internal enum EnrollAuthorization - { - /// No factor exists and the sign-in is fresh: the FIRST factor may be CREATED — never added to an existing node. - AllowedFirst, - - /// A factor exists and a step-up (or, with step-up off, a fresh sign-in) authorized adding another. - AllowedByReceipt, - - /// A factor exists — confirm with it first. - NeedsStepUp, - - /// The sign-in is not recent enough: to enrol the first factor, or — with step-up off — any further one. - NeedsFreshSignIn, - - /// A Microsoft account (or a session that does not name its provider): portal factors are not for it. - NotForThisAccount, - } - - /// What a factor write came to. - internal enum FactorWrite - { - /// Stored, and the stored node carries this write. - Stored, - - /// A first-factor create found a node already there (a stale listing or a concurrent enrolment) — refused. - AlreadyEnrolled, - - /// The store did not take it. - Failed, - } - - private static bool IsAllowed(EnrollAuthorization a) => a is EnrollAuthorization.AllowedFirst or EnrollAuthorization.AllowedByReceipt; - - private static string ReasonFor(EnrollAuthorization a) => - a == EnrollAuthorization.NotForThisAccount ? "microsoftAccount" : "enrollAuth"; - - private static string ReasonFor(FactorWrite w) => w == FactorWrite.AlreadyEnrolled ? "enrollAuth" : "mint"; - - /// - /// Writes a factor the way allowed — and no other way. A FIRST - /// factor is a CREATE that refuses an existing node () and - /// then checks the node it reads back carries THIS write (); a further - /// factor is a fold onto the existing node. A stale "no factors" listing therefore can never add - /// a factor beside an existing one without the step-up that adding one requires. - /// - internal static IObservable StoreFactor(StepUpFactorStore store, string userId, EnrollAuthorization authorization, - StepUpFactors first, Func fold, Func landed, Action report) => - (authorization switch - { - EnrollAuthorization.AllowedFirst => store.Create(userId, first) - .Select(stored => stored is null ? FactorWrite.AlreadyEnrolled - : landed(stored) ? FactorWrite.Stored : FactorWrite.AlreadyEnrolled), - EnrollAuthorization.AllowedByReceipt => store.Update(userId, fold).Select(_ => FactorWrite.Stored), - _ => Observable.Return(FactorWrite.Failed), - }) - .Catch((Exception ex) => - { - report(ex); - return Observable.Return(FactorWrite.Failed); - }); - - /// - /// The enrolment gate, with everything it needs from the request captured NOW (at the action's - /// edge): a Microsoft account — or a session that does not name its provider — enrols nothing; - /// the FIRST factor needs a sign-in within ; every further one - /// needs a step-up with an existing factor — so a stolen session cannot add its own - /// authenticator. With step-up off on the instance the fresh sign-in is the bar for both. - /// - private Func> EnrollGateFor(string userId) - { - var provider = StepUpClaims.ProviderOf(User); - var signedInRecently = SignedInRecently(); - var enabled = StepUpOptions.From(configuration).Enabled; - var service = Hub()?.ServiceProvider.GetRequiredService(); - return (factors, receiptId, consume) => - { - if (!StepUpLadder.MayEnrollPortalFactors(provider)) - return Observable.Return(EnrollAuthorization.NotForThisAccount); - var hasFactor = factors is { Passkeys.Count: > 0 } || factors is { TotpConfirmedAt: not null }; - if (!hasFactor) - return Observable.Return(signedInRecently ? EnrollAuthorization.AllowedFirst : EnrollAuthorization.NeedsFreshSignIn); - if (string.IsNullOrEmpty(receiptId)) - return Observable.Return(FurtherFactorWithoutReceipt(enabled, signedInRecently)); - if (service is null) return Observable.Return(EnrollAuthorization.NeedsStepUp); - return (consume - ? service.Consume(receiptId, userId, StepUpPaths.Factors(userId), StepUpPaths.EnrollBinding) - : service.Check(receiptId, userId, StepUpPaths.Factors(userId), StepUpPaths.EnrollBinding)) - .Select(verdict => FurtherFactorFromVerdict(verdict.Outcome, signedInRecently)); - }; - } - - /// - /// A further factor with no receipt presented. With step-up on, the existing factor confirms it - /// first. With step-up off there is no step-up to send the user to (Start answers - /// and mints nothing), so the bar is the fresh sign-in - /// alone — a stale session is told to sign in again rather than looped through a confirmation - /// that can never produce a receipt. - /// - /// Whether step-up is on for this instance. - /// Whether the sign-in is within . - /// The authorization. - internal static EnrollAuthorization FurtherFactorWithoutReceipt(bool enabled, bool signedInRecently) => - enabled ? EnrollAuthorization.NeedsStepUp - : signedInRecently ? EnrollAuthorization.AllowedByReceipt - : EnrollAuthorization.NeedsFreshSignIn; - - /// - /// A further factor with a receipt presented: an accepted receipt authorizes it; a - /// verdict (step-up off) falls back to the fresh-sign-in - /// bar exactly as does; anything else needs the step-up. - /// - /// The receipt verdict. - /// Whether the sign-in is within . - /// The authorization. - internal static EnrollAuthorization FurtherFactorFromVerdict(string? outcome, bool signedInRecently) => - outcome == StepUpOutcome.Accepted ? EnrollAuthorization.AllowedByReceipt - : outcome == StepUpOutcome.NotRequired - ? signedInRecently ? EnrollAuthorization.AllowedByReceipt : EnrollAuthorization.NeedsFreshSignIn - : EnrollAuthorization.NeedsStepUp; - - /// - /// The completion check of , with the session's provider, - /// the instance options and the Entra configuration captured now. - /// - private Func RungCheck() - { - var provider = StepUpClaims.ProviderOf(User); - var options = StepUpOptions.From(configuration); - var entra = Entra(); - var entraUsable = entra.IsConfigured && entra.TenantIsSpecific; - return (pending, method, factors) => StepUpLadder.MayComplete(pending.Rung, method, provider, options, entraUsable, factors); - } - - /// The session's sign-in is within (its auth_time claim). - private bool SignedInRecently() => - long.TryParse(User.FindFirst(StepUpClaims.AuthTime)?.Value, System.Globalization.NumberStyles.Integer, - System.Globalization.CultureInfo.InvariantCulture, out var at) - && DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(at) <= FirstFactorSignInAge; - - /// One factor read: answered (with or without factors) or not. - internal readonly record struct FactorRead(bool Answered, StepUpFactors? Factors); - - private IObservable ReadFactors(string userId) - { - var hub = Hub(); - if (hub is null) return Observable.Return(new FactorRead(false, null)); - return new StepUpFactorStore(hub).Load(userId) - .Select(f => new FactorRead(true, f)) - .Catch((Exception ex) => - { - logger.LogWarning(ex, "Step-up: the factor read for {User} did not answer", userId); - return Observable.Return(new FactorRead(false, null)); - }) - .DefaultIfEmpty(new FactorRead(false, null)) - .Take(1); - } - - private PasskeyStepUp Passkeys(IMessageHub? hub = null) - { - var resolved = hub ?? Hub(); - // The FIDO library's verification is a Task-returning edge; it runs through the mesh's pool, - // and a hub without the pool registry FAILS (GetRequiredService) rather than verifying - // unbounded. Only the no-hub path, which builds options and never verifies, has no pool. - var pool = resolved is null - ? IoPool.Unbounded - : resolved.ServiceProvider.GetRequiredService().Get(IoPoolNames.Http); - return new(Request.Host.Host, $"{Request.Scheme}://{Request.Host}", Request.Host.Host, pool); - } - - /// - /// Binds a sealed enrolment payload to the account that requested it. The cookie outlives a - /// sign-out (only the authentication cookie is removed), so without the binding a payload - /// started as account A could be confirmed after a fresh sign-in as account B and stored as B's - /// factor — A's WebAuthn user handle, or a TOTP secret A was shown. - /// - internal static string BindToUser(string userId, string payload) => userId + "\n" + payload; - - /// The payload of a text when it names ; otherwise null. - internal static string? UnbindFromUser(string boundText, string userId) - { - var cut = boundText.IndexOf('\n'); - return cut > 0 && string.Equals(boundText[..cut], userId, StringComparison.Ordinal) - ? boundText[(cut + 1)..] - : null; - } - - /// The sealed payload of when it was bound to ; otherwise null. - private string? ReadSealedFor(string cookie, string userId) - { - var sealedText = ReadSealed(cookie); - if (sealedText is null) return null; - if (UnbindFromUser(sealedText, userId) is { } payload) return payload; - logger.LogWarning("Step-up: cookie {Cookie} was sealed for another account; refused", cookie); - return null; - } - - private string? ReadSealed(string cookie) - { - if (!Request.Cookies.TryGetValue(cookie, out var raw) || string.IsNullOrEmpty(raw)) return null; - try { return Protector().Unprotect(raw); } - catch (CryptographicException ex) - { - logger.LogWarning(ex, "Step-up: cookie {Cookie} did not unprotect (expired or foreign)", cookie); - return null; - } - } - - private static CookieOptions SealedCookie() => new() - { - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Strict, - MaxAge = PendingLifetime, - Path = "/" + BasePath, - }; - - private static object Error(StepUpPageTexts t, string reason) => new { error = t.L("stepUp.failed", t.L("stepUp.reason." + reason)) }; - - private static JsonResult Json(object value) => new(value); -} diff --git a/memex/Memex.Portal.Shared/Authentication/StepUpController.cs b/memex/Memex.Portal.Shared/Authentication/StepUpController.cs deleted file mode 100644 index b99af0378e..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/StepUpController.cs +++ /dev/null @@ -1,433 +0,0 @@ -using System.Reactive; -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using MeshWeaver.Data; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// The approval step-up endpoints (Doc/Architecture/ApprovalStepUp). A page that needs an -/// approval confirmed sends the approver to ; this controller -/// decides the ladder rung, runs it, mints ONE receipt covering every requested target, stamps the -/// receipt id onto each target (as the approver, through the node stream — which is what makes the -/// target's watcher re-evaluate), and returns to the page. The server-side consumer — never this -/// controller, never the page — decides whether the approval then counts. -/// -/// Reactive end to end. Every action is ONE pipeline, and -/// the only is the one MVC requires, made at the action's own edge by -/// (ObserveCompletion). Everything the pipeline needs from the request — -/// the caller's access context, the viewer's language, the provider claim — is captured at that -/// edge, because the pipeline continues downstream of store and pool emissions where the request's -/// ambient context is no longer reliable. -/// -/// 🚨 Navigating here from in-app UI needs a FULL page load: this is an MVC endpoint, a -/// client-side Blazor navigation never reaches it. -/// -[Authorize] -[Route(BasePath)] -public sealed partial class StepUpController( - IConfiguration configuration, - IHttpClientFactory httpFactory, - IDataProtectionProvider dataProtection, - AccessService access, - IServiceProvider services, - ILogger logger) : ControllerBase -{ - /// Route prefix — the same value as without its leading slash. - public const string BasePath = "auth/step-up"; - - /// The Entra callback segment. - public const string CallbackAction = "callback"; - - /// The segment the step-up pages' script is served from (). - internal const string PageScriptAction = "page.js"; - - /// The cookie carrying the handle and state of the pending step-up — never its content. - internal const string PendingCookie = "mw_stepup"; - - /// How long a started step-up may take to come back. - internal static readonly TimeSpan PendingLifetime = TimeSpan.FromMinutes(10); - - private const string ProtectorPurpose = "MeshWeaver.StepUp.Pending.v1"; - - /// The most targets one step-up may cover (a bulk approval of a full inbox page). - internal const int MaxTargets = 50; - - private string CallbackUri => $"{Request.Scheme}://{Request.Host}/{BasePath}/{CallbackAction}"; - - /// What the browser carries between the redirect and the proof: a handle and the state, sealed. - internal sealed record PendingHandle(string Handle, string State); - - /// Starts a step-up for the signed-in approver. - /// The action paths (repeatable, paired with ). - /// The hashes approved, one per target. - /// Where to come back to; sanitised to a local URL. - /// Cancels the wait on the store, not the store. - /// A redirect, or a page explaining the refusal. - [HttpGet("")] - public Task Start( - [FromQuery(Name = "target")] string[]? targets, - [FromQuery(Name = "binding")] string[]? bindings, - [FromQuery] string? returnUrl, - CancellationToken ct = default) - { - var t = Texts(); - var safeReturn = ReturnUrlPolicy.Sanitize(returnUrl); - var userId = access.Context?.ObjectId; - if (string.IsNullOrEmpty(userId)) return Done(Unauthorized()); - - var pairs = Pair(targets, bindings); - if (pairs is null) - return Done(Refusal(t, safeReturn, t.L("stepUp.refused.targets"))); - - var options = StepUpOptions.From(configuration); - var provider = StepUpClaims.ProviderOf(User); - var entra = Entra(); - var entraUsable = entra.IsConfigured && entra.TenantIsSpecific; - var loginHint = User.FindFirst("email")?.Value ?? User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; - var restart = Request.Path + Request.QueryString; - var callbackUri = CallbackUri; - // A Microsoft account's rung never depends on portal factors — they are not even read. - var factors = options.Enabled && StepUpLadder.MayEnrollPortalFactors(provider) - ? ReadFactors(userId) - : Observable.Return(new FactorRead(true, null)); - - var flow = factors.SelectMany(read => - { - if (!read.Answered) return Observable.Return(Failure(t, safeReturn, "unavailable")); - var rung = StepUpLadder.Decide(provider, options, entraUsable, read.Factors); - logger.LogInformation("Step-up start for {User}: rung {Rung}, {Count} target(s)", userId, rung, pairs.Count); - switch (rung) - { - case StepUpRung.NotRequired: - return Observable.Return(Redirect(EaConsentController.WithOutcome(safeReturn, "stepUp=notRequired"))); - case StepUpRung.RefuseUnknownSession: - return Observable.Return(Refusal(t, safeReturn, t.L("stepUp.refused.unknownSession"))); - case StepUpRung.RefuseNotConfigured: - return Observable.Return(Refusal(t, safeReturn, t.L("stepUp.refused.notConfigured"))); - case StepUpRung.Enroll: - return Observable.Return(Page(StepUpPages.EnrollNeeded(t, - EnrollPath + "?returnUrl=" + Uri.EscapeDataString(restart)))); - case StepUpRung.Entra: - case StepUpRung.Passkey: - case StepUpRung.Totp: - break; - default: - return Observable.Return(Refusal(t, safeReturn, t.L("stepUp.refused.provider", provider))); - } - - // The rung is recorded ON the pending step-up: it is the only rung that may complete it. - return BeginPending(userId, pairs, safeReturn, rung).Select(pending => - { - if (pending is null) return (IActionResult)Failure(t, safeReturn, "unavailable"); - var manage = EnrollPath + "?returnUrl=" + Uri.EscapeDataString(safeReturn); - return rung switch - { - StepUpRung.Passkey => (IActionResult)Page(StepUpPages.Passkey(t, manage)), - StepUpRung.Totp => Page(StepUpPages.Totp(t, "/" + BasePath + "/" + TotpVerifyAction, manage)), - _ => Redirect(entra.AuthorizeUrl(pending.State, pending.Nonce, callbackUri, loginHint, options.EntraAuthenticationContext!)), - }; - }); - }); - return Edge(flow, () => Failure(t, safeReturn, "unavailable"), "start", userId, ct); - } - - /// Completes the Entra rung: redeems the code, checks the token, mints and stamps the receipt. - /// The authorization code. - /// The state echoed by Entra. - /// An OAuth error, when Entra refused. - /// Entra's description (carries the AADSTS code). - /// Cancels the wait, not the work. - /// A redirect back, or a page explaining the failure. - [HttpGet(CallbackAction)] - public Task Callback( - [FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, - [FromQuery(Name = "error_description")] string? errorDescription = null, CancellationToken ct = default) - { - var t = Texts(); - var caller = access.Context; - var userId = caller?.ObjectId; - if (caller is null || string.IsNullOrEmpty(userId)) return Done(Unauthorized()); - - var options = StepUpOptions.From(configuration); - var provider = StepUpClaims.ProviderOf(User); - var sessionOid = User.FindFirst(StepUpClaims.Oid)?.Value; - var sessionAccount = User.FindFirst("email")?.Value ?? User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; - var callbackUri = CallbackUri; - var hub = Hub(); - var entra = Entra(hub); - var entraUsable = entra.IsConfigured && entra.TenantIsSpecific; - - var flow = TakePending(userId, state, complete: true).SelectMany(pending => - { - if (pending is null) return Observable.Return(Failure(t, "/", "state")); - // Only a step-up STARTED for Entra completes here, and only for a session whose rung is Entra now. - if (!StepUpLadder.MayComplete(pending.Rung, StepUpRung.Entra, provider, options, entraUsable, factors: null)) - { - logger.LogWarning("Step-up for {User}: an Entra callback for a step-up started as '{Rung}' refused", userId, pending.Rung); - return Observable.Return(Failure(t, pending.ReturnUrl, "wrongRung")); - } - if (!string.IsNullOrEmpty(error) || string.IsNullOrEmpty(code)) - { - logger.LogWarning("Step-up for {User}: Entra answered '{Error}': {Description}", userId, error, errorDescription); - return Observable.Return(Failure(t, pending.ReturnUrl, "microsoft")); - } - if (hub is null) return Observable.Return(Failure(t, pending.ReturnUrl, "mint")); - - return entra - .Redeem(code, callbackUri, issuer => new EntraStepUpExpectation( - issuer, pending.Nonce, sessionOid, sessionAccount, options, DateTimeOffset.UtcNow)) - .Catch((Exception ex) => - { - logger.LogWarning(ex, "Step-up for {User}: the Entra exchange faulted", userId); - return Observable.Return(EntraStepUpCheck.Fail("exchange")); - }) - .SelectMany(check => !check.Ok - ? Observable.Return((Check: check, Receipt: (StepUpReceipt?)null)) - : MintAndStamp(hub, caller, StepUpMethod.Entra, check.AuthenticatedAt, check.Evidence, pending.Targets) - .Select(r => (Check: check, Receipt: (StepUpReceipt?)r))) - .Catch((Exception ex) => - { - logger.LogWarning(ex, "Step-up for {User}: minting or stamping the receipt failed", userId); - return Observable.Return((Check: EntraStepUpCheck.Fail("mint"), Receipt: (StepUpReceipt?)null)); - }) - .Select(outcome => - { - if (outcome.Receipt is null) - { - logger.LogWarning("Step-up for {User} refused at '{Reason}'", userId, outcome.Check.Reason); - return Failure(t, pending.ReturnUrl, outcome.Check.Reason ?? "exchange"); - } - return (IActionResult)Redirect(SuccessUrl(pending, outcome.Receipt)); - }); - }); - return Edge(flow, () => Failure(t, "/", "mint"), "callback", userId, ct); - } - - /// - /// Mints the receipt (as System, inside the service) and stamps it onto every target AS THE - /// APPROVER. The approver is passed in, captured at the action's edge: this runs downstream of an - /// I/O-pool emission, where the request's AsyncLocal access context is no longer reliable, - /// and a stamp written with no identity is refused. A factor-enrolment target - /// (Auth/_StepUpFactors/…) is never stamped — the enrolment endpoint consumes that receipt - /// itself, from the return URL. - /// - private IObservable MintAndStamp(IMessageHub hub, AccessContext caller, string method, - DateTimeOffset authenticatedAt, string? evidence, IReadOnlyList targets) - { - var issuer = hub.ServiceProvider.GetRequiredService(); - var workspace = hub.GetWorkspace(); - return issuer.Mint(caller.ObjectId, method, targets, authenticatedAt, evidence) - .SelectMany(receipt => targets - .Where(t => !IsEnrollmentTarget(t)) - .Select(t => t.ActionPath).Distinct(StringComparer.Ordinal) - .Select(path => access.RunAs(caller, () => workspace.GetMeshNodeStream(path) - .Update(node => node with { Content = StepUpPaths.Stamp(node.Content, caller.ObjectId, receipt.Id, hub.JsonSerializerOptions) }) - .Take(1) - .Select(_ => Unit.Default))) - .Merge() - .DefaultIfEmpty(Unit.Default) - .LastAsync() - .Select(_ => receipt)); - } - - /// A target that authorizes enrolling another step-up factor rather than an approval. - internal static bool IsEnrollmentTarget(StepUpTarget target) => - target.ActionPath.StartsWith(StepUpPaths.FactorsNamespace + "/", StringComparison.Ordinal); - - /// - /// Where a successful step-up returns: the page with stepUp=done — and, when it covered a - /// factor enrolment, the receipt id the enrolment endpoint consumes. - /// - private static string SuccessUrl(StepUpPending pending, StepUpReceipt receipt) => - EaConsentController.WithOutcome(ReturnUrlPolicy.Sanitize(pending.ReturnUrl), - pending.Targets.Any(IsEnrollmentTarget) ? "stepUp=done&stepUpReceipt=" + receipt.Id : "stepUp=done"); - - /// Pairs the repeated target/binding parameters; null when they do not pair. - internal static IReadOnlyList? Pair(string[]? targets, string[]? bindings) - { - if (targets is null || bindings is null || targets.Length == 0 || targets.Length != bindings.Length || targets.Length > MaxTargets) - return null; - var list = new List(targets.Length); - for (var i = 0; i < targets.Length; i++) - { - if (string.IsNullOrWhiteSpace(targets[i]) || string.IsNullOrWhiteSpace(bindings[i])) - return null; - list.Add(new StepUpTarget { ActionPath = targets[i].Trim().TrimStart('/'), Binding = bindings[i].Trim() }); - } - return list; - } - - /// - /// Stores the pending step-up SERVER-side (Auth/_StepUpPending/{handle}, as System) — - /// recording the the server decided — and gives the browser only the - /// sealed handle and state: a bulk approval's targets would overflow a cookie. Emits null when the - /// store did not answer. - /// - private IObservable BeginPending(string userId, IReadOnlyList targets, string returnUrl, string rung) - { - var hub = Hub(); - if (hub is null) return Observable.Return(null); - var pending = new StepUpPending - { - Id = StepUpSeal.NewId(), - State = StepUpSeal.NewId(), - Nonce = StepUpSeal.NewId(), - UserId = userId, - Rung = rung, - Targets = [.. targets], - ReturnUrl = returnUrl, - ExpiresAt = DateTimeOffset.UtcNow + PendingLifetime, - }; - var sealedHandle = Protector().Protect(JsonSerializer.Serialize(new PendingHandle(pending.Id, pending.State)), pending.ExpiresAt); - return new StepUpPendingStore(hub).Begin(pending) - .Select(_ => - { - Response.Cookies.Append(PendingCookie, sealedHandle, new CookieOptions - { - HttpOnly = true, - Secure = true, - // Lax: the IdP's redirect back is a top-level GET, which Lax admits. - SameSite = SameSiteMode.Lax, - MaxAge = PendingLifetime, - Path = "/" + BasePath, - }); - return (StepUpPending?)pending; - }) - .Catch((Exception ex) => - { - logger.LogWarning(ex, "Step-up for {User}: the pending step-up could not be stored", userId); - return Observable.Return(null); - }); - } - - /// - /// The pending step-up named by the sealed cookie, checked to belong to this request — same user, - /// same state (constant-time), not expired. When , it is TAKEN - /// (): claimed atomically in the store, so of two concurrent - /// requests carrying the same cookie only one ever receives it — one ceremony, one proof. The - /// cookie is dropped and the node deleted afterwards as tidying; neither is the guarantee. - /// - private IObservable TakePending(string userId, string? state, bool complete) - { - var handle = ReadHandle(); - if (complete) Response.Cookies.Delete(PendingCookie, new CookieOptions { Path = "/" + BasePath }); - var hub = Hub(); - if (handle is null || hub is null || !StepUpPaths.IsWellFormedId(handle.Handle)) return Observable.Return(null); - if (state is not null && !CryptographicOperations.FixedTimeEquals( - Encoding.UTF8.GetBytes(handle.State), Encoding.UTF8.GetBytes(state))) - return Observable.Return(null); - - bool Admits(StepUpPending p) => - p.UserId == userId && p.State == handle.State && DateTimeOffset.UtcNow <= p.ExpiresAt; - - var store = new StepUpPendingStore(hub); - var read = complete - ? store.Take(handle.Handle, Admits) - .Do(taken => - { - if (taken is not null) - store.Complete(handle.Handle).Subscribe(_ => { }, - ex => logger.LogWarning(ex, "Step-up: the taken pending step-up {Handle} could not be deleted", handle.Handle)); - }) - : store.Read(handle.Handle).Select(p => p is not null && Admits(p) ? p : null); - return read.Catch((Exception ex) => - { - logger.LogWarning(ex, "Step-up for {User}: the pending step-up could not be read or claimed", userId); - return Observable.Return(null); - }); - } - - private PendingHandle? ReadHandle() - { - if (!Request.Cookies.TryGetValue(PendingCookie, out var raw) || string.IsNullOrEmpty(raw)) return null; - try - { - return JsonSerializer.Deserialize(Protector().Unprotect(raw)); - } - catch (CryptographicException ex) - { - logger.LogWarning(ex, "Step-up: the pending cookie did not unprotect (expired or foreign)"); - return null; - } - catch (JsonException ex) - { - logger.LogWarning(ex, "Step-up: the pending cookie did not parse"); - return null; - } - } - - /// - /// The action's edge — the ONE bridge MVC needs. A pipeline that faults or - /// completes empty answers , so a request never ends in an unhandled - /// exception or a blank response; a fault after it settled is logged, never dropped. - /// - private Task Edge(IObservable flow, Func onFault, string action, string userId, CancellationToken ct) => - flow - .Catch((Exception ex) => - { - logger.LogWarning(ex, "Step-up {Action} for {User} faulted", action, userId); - return Observable.Return(onFault()); - }) - .DefaultIfEmpty(onFault()) - .Take(1) - .ObserveCompletion(ex => logger.LogWarning(ex, "Step-up {Action} for {User} faulted after settling", action, userId), ct)!; - - /// An answer decided before any store or pool call — no wait at all. - private static Task Done(IActionResult result) => Task.FromResult(result); - - private ITimeLimitedDataProtector Protector() => - dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector(); - - private IMessageHub? Hub() => - services.GetService()?.Hub ?? services.GetService(); - - private EntraStepUp Entra(IMessageHub? hub = null) - { - var resolved = hub ?? Hub(); - var pool = resolved?.ServiceProvider.GetService()?.Get(IoPoolNames.Http) ?? IoPool.Unbounded; - var metadata = resolved?.ServiceProvider.GetService() ?? new EntraMetadataCache(); - return new EntraStepUp(configuration, httpFactory.CreateClient(nameof(EntraStepUp)), pool, metadata, logger); - } - - private ContentResult Failure(StepUpPageTexts t, string returnUrl, string reason) => - Refusal(t, returnUrl, t.L("stepUp.failed", t.L("stepUp.reason." + reason))); - - /// A refusal or failure — localized, with a way back (). - private static ContentResult Refusal(StepUpPageTexts t, string returnUrl, string message) => - Page(StepUpPages.Message(t, message, ReturnUrlPolicy.Sanitize(returnUrl)), StatusCodes.Status403Forbidden); - - /// - /// The step-up pages' script () as a same-origin file, so the - /// pages run under a script-src without 'unsafe-inline'. Anonymous: it is a constant - /// that carries no per-user value — every value it uses comes from the page's own data block, and - /// every endpoint it calls is authorized on its own. - /// - /// The script. - [AllowAnonymous] - [HttpGet(PageScriptAction)] - public ContentResult PageScript() => - new() { Content = StepUpPages.ScriptSource, ContentType = "text/javascript; charset=utf-8", StatusCode = StatusCodes.Status200OK }; - - /// One of the step-up pages as the response. - private static ContentResult Page(string html, int status = StatusCodes.Status200OK) => - new() { Content = html, ContentType = "text/html; charset=utf-8", StatusCode = status }; - - /// The viewer's language, captured at the action's edge. - private StepUpPageTexts Texts() => new(access.ViewerLocale()); -} diff --git a/memex/Memex.Portal.Shared/Authentication/StepUpFactorStore.cs b/memex/Memex.Portal.Shared/Authentication/StepUpFactorStore.cs deleted file mode 100644 index 73d5c0c0d6..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/StepUpFactorStore.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Data; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Reads and writes a user's node (Auth/_StepUpFactors/{user}/factors), -/// always as System — the node type admits System alone. -/// -/// Both halves, never a guessed point read. Whether the node EXISTS is learned from a -/// scope:children listing of the user's factor namespace; only a node the listing names is -/// then read by path. A point read of an absent path is a routing not-found that also opens the -/// storm-breaker on that path — which would then fast-fail the very enrolment write that creates it. -/// The listing trails the store, so a factor enrolled a moment ago can read as absent once; the -/// ladder then offers enrolment again, never a pass. -/// -/// Creating the FIRST factor and adding a further one are different writes, and never -/// fall back into each other. Which one is allowed is an authorization decision (the first needs -/// a fresh sign-in, every further one a step-up), so only ever creates — a node -/// that already exists is a REFUSAL, never a silent update — and reads the stored node back so the -/// caller can see whether its own write is the one that landed (a concurrent create response cannot -/// say). folds onto a node known to exist, through -/// GetMeshNodeStream(path).Update(fold). -/// -/// The mesh hub. -internal sealed class StepUpFactorStore(IMessageHub hub) -{ - /// The bound on one listing or read; a read that does not answer is a fault, never "no factors". - internal static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(10); - - private AccessService? Access => hub.ServiceProvider.GetService(); - - /// The user's factors, or null when none are stored. Faults when the read does not answer. - /// The user. - /// Cold, single emission. - public IObservable Load(string userId) => - Access.RunAsSystem(() => hub.ServiceProvider.GetRequiredService() - .Query(MeshQueryRequest.FromQuery($"namespace:\"{StepUpPaths.FactorsNamespaceOf(userId)}\" scope:children")) - .Where(c => c.ChangeType == QueryChangeType.Initial) - .Take(1) - .Select(c => c.Items.Any(n => n.Id == StepUpPaths.FactorsId))) - .Timeout(ReadTimeout) - .SelectMany(exists => exists - ? Access.RunAsSystem(() => hub.GetMeshNode(StepUpPaths.Factors(userId), ReadTimeout).Take(1)) - .Select(node => node?.ContentAs(hub.JsonSerializerOptions)) - : Observable.Return(null)); - - /// - /// Creates the user's factors node with — the first factor's write. A node - /// that already exists is refused (null), never turned into an update: the caller was authorized - /// for a FIRST factor only, on a listing that may have been stale. Cold. - /// - /// The user. - /// The factors to store. - /// The STORED factors read back after the create, or null when the node already existed. - public IObservable Create(string userId, StepUpFactors first) => - Observable.Defer(() => - { - var node = new MeshNode(StepUpPaths.FactorsId, StepUpPaths.FactorsNamespaceOf(userId)) - { - Name = "Step-up factors", - NodeType = StepUpPaths.FactorsNodeType, - State = MeshNodeState.Active, - Content = first with { UserId = userId }, - }; - var mesh = hub.ServiceProvider.GetRequiredService(); - return Access.RunAsSystem(() => mesh.CreateNode(node).Take(1) - .Select(_ => true) - .Catch((Exception ex) => StepUpSingleUse.IsAlreadyExists(ex) ? Observable.Return(false) : Observable.Throw(ex)) - .SelectMany(created => !created - ? Observable.Return(null) - : hub.GetMeshNode(StepUpPaths.Factors(userId), ReadTimeout).Take(1) - .Select(stored => stored?.ContentAs(hub.JsonSerializerOptions)))) - .Timeout(ReadTimeout); - }); - - /// - /// Applies to the user's EXISTING factors node, through the node stream — - /// the owning hub serialises it. Only for a node a listing has named; never a create. - /// - /// The user. - /// The change, applied to the current value. - /// Cold; emits once the write is acknowledged. - public IObservable Update(string userId, Func fold) => - Observable.Defer(() => Access.RunAsSystem(() => hub.GetWorkspace().GetMeshNodeStream(StepUpPaths.Factors(userId)) - // Typed: null means ABSENT only; present-but-unreadable content faults instead of being - // folded over as an empty record. - .Update((node, content) => node with { Content = fold(content ?? new StepUpFactors { UserId = userId }) }) - .Take(1))); -} diff --git a/memex/Memex.Portal.Shared/Authentication/StepUpLadder.cs b/memex/Memex.Portal.Shared/Authentication/StepUpLadder.cs deleted file mode 100644 index 8ddf674e32..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/StepUpLadder.cs +++ /dev/null @@ -1,130 +0,0 @@ -using MeshWeaver.Mesh.Security; - -namespace Memex.Portal.Shared.Authentication; - -/// Which step-up a session gets — the outcome of . -public static class StepUpRung -{ - /// Step-up is off on this instance — go straight back. - public const string NotRequired = "notRequired"; - - /// Entra ID step-up (Microsoft accounts). - public const string Entra = StepUpMethod.Entra; - - /// The portal's own passkey (every other account, once one is enrolled). - public const string Passkey = StepUpMethod.Passkey; - - /// The portal's own TOTP — only for an account with NO passkey and an authenticator app enrolled. - public const string Totp = StepUpMethod.Totp; - - /// No factor enrolled yet — enrol one (a passkey preferred) before approving. - public const string Enroll = "enroll"; - - /// The session predates the idp claim — sign in again; the provider is never guessed. - public const string RefuseUnknownSession = "unknownSession"; - - /// A Microsoft account, but no authentication context (or no usable sign-in app / single tenant) is declared. - public const string RefuseNotConfigured = "notConfigured"; - - /// An account no rung can step up. - public const string RefuseProvider = "provider"; -} - -/// -/// The pure ladder decision of Doc/Architecture/ApprovalStepUp → "The ladder", policy -/// approval-step-up: always prefer a passkey, use a second factor only where no passkey is -/// available. Microsoft accounts step up through Entra (whose authentication strength requires a -/// phishing-resistant passkey) and never through the portal's own factors as well — one prompt, not -/// two. Every other account steps up with its portal passkey; TOTP is the rung ONLY for an account -/// that has no passkey at all — so a TOTP code can never be used to downgrade an account that has one. -/// No rung ever waves an approval through. -/// -public static class StepUpLadder -{ - /// Decides the rung for a session. - /// The session's idp claim, or null for an older session. - /// The instance's step-up options. - /// The Microsoft sign-in app is configured and its step-up tenant is a single tenant. - /// The account's portal-held factors, or null when it has none. - /// A value. - public static string Decide(string? provider, StepUpOptions options, bool entraUsable, StepUpFactors? factors) - { - if (!options.Enabled) - return StepUpRung.NotRequired; - if (string.IsNullOrEmpty(provider)) - return StepUpRung.RefuseUnknownSession; - if (IsMicrosoft(provider)) - return string.IsNullOrEmpty(options.EntraAuthenticationContext) || !entraUsable - ? StepUpRung.RefuseNotConfigured - : StepUpRung.Entra; - return PortalRung(options, factors); - } - - /// The rung among the portal's own factors — also what enrolling another factor steps up with. - /// The instance's step-up options. - /// The account's factors, or null. - /// , or . - public static string PortalRung(StepUpOptions options, StepUpFactors? factors) - { - if (factors is { Passkeys.Count: > 0 }) - return StepUpRung.Passkey; - if (factors is { TotpConfirmedAt: not null } && options.AllowTotpFallback) - return StepUpRung.Totp; - return StepUpRung.Enroll; - } - - /// - /// Whether a TOTP code may confirm a step-up for this account — re-asked at verification, never - /// trusted from the page: only with no passkey, the rung allowed, and an authenticator enrolled. - /// - /// The instance's step-up options. - /// The account's factors. - /// True when TOTP is this account's rung. - public static bool AcceptsTotp(StepUpOptions options, StepUpFactors? factors) => - PortalRung(options, factors) == StepUpRung.Totp; - - /// - /// Whether TOTP may be ENROLLED: the rung exists and the account has no passkey. (Whether the - /// device can do a passkey is the browser's to report; the page offers TOTP only when it cannot.) - /// - /// The instance's step-up options. - /// The account's factors. - /// True when an authenticator app may be set up. - public static bool MayEnrollTotp(StepUpOptions options, StepUpFactors? factors) => - options.AllowTotpFallback && factors is not { Passkeys.Count: > 0 }; - - /// - /// Whether this session may enrol a PORTAL factor (passkey or TOTP) at all. A Microsoft account - /// steps up through Entra and nothing else, so a portal factor would be dead weight at best and, - /// at worst, a second way in that bypasses Entra's phishing-resistant prompt. A session that - /// predates the provider claim is refused too: the provider is never guessed. - /// - /// The session's idp claim, or null for an older session. - /// True only for a known, non-Microsoft provider. - public static bool MayEnrollPortalFactors(string? provider) => - !string.IsNullOrEmpty(provider) && !IsMicrosoft(provider); - - /// - /// Whether may COMPLETE a pending step-up: the rung the server decided - /// when the step-up started must be this method, AND the ladder decided again now — on the - /// session's provider and the account's factors as they are now — must still land on it. Every - /// completing endpoint asks this; none trusts that the page it served is the page that called. - /// - /// The rung recorded on the pending step-up. - /// The method the calling endpoint verifies (a value). - /// The session's idp claim. - /// The instance's step-up options. - /// The Microsoft sign-in app is configured for a single tenant. - /// The account's portal factors as read now. - /// True only when both agree on . - public static bool MayComplete(string? pendingRung, string method, string? provider, StepUpOptions options, - bool entraUsable, StepUpFactors? factors) => - string.Equals(pendingRung, method, StringComparison.Ordinal) - && string.Equals(Decide(provider, options, entraUsable, factors), method, StringComparison.Ordinal); - - /// True when is the Microsoft (Entra ID) sign-in. - /// The session's idp claim. - /// True for a Microsoft account. - public static bool IsMicrosoft(string? provider) => - string.Equals(provider, StepUpClaims.MicrosoftProvider, StringComparison.OrdinalIgnoreCase); -} diff --git a/memex/Memex.Portal.Shared/Authentication/StepUpPages.cs b/memex/Memex.Portal.Shared/Authentication/StepUpPages.cs deleted file mode 100644 index 8b938b2d3a..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/StepUpPages.cs +++ /dev/null @@ -1,274 +0,0 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using MeshWeaver.Mesh.Security; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// The viewer's language, captured ONCE at the action's edge, and the catalog lookup every step-up -/// text renders with. Explicit on purpose: the actions continue downstream of store and pool -/// emissions, where the request's ambient access context — and with it the viewer's locale — is no -/// longer reliable. -/// -/// The resolved language tag. -internal sealed record StepUpPageTexts(string Locale) -{ - /// The catalog text for in , with positional arguments. - /// The catalog key. - /// Positional arguments. - /// The text. - public string L(string key, params object?[] args) => MeshWeaver.Messaging.LocalizationCatalog.Get(key, Locale, args); - - /// - /// The culture of , for dates and numbers on the page — resolved explicitly - /// from the viewer's locale, never from the process culture. A tag .NET does not know formats - /// invariant (the text itself still follows the catalog's own fallback). - /// - public System.Globalization.CultureInfo Culture - { - get - { - try { return System.Globalization.CultureInfo.GetCultureInfo(Locale); } - catch (System.Globalization.CultureNotFoundException) { return System.Globalization.CultureInfo.InvariantCulture; } - } - } -} - -/// -/// The few pages the passkey and TOTP rungs render themselves. They live OUTSIDE the Blazor shell by -/// necessity: a WebAuthn ceremony must run in the page that asked for it, and the flow is reached by -/// a full-page navigation (like the IdP round trip of the Entra rung). So they are deliberately -/// minimal — a heading, a sentence, one button or field — every word from the localization catalog, -/// and the only script is the WebAuthn call itself (navigator.credentials.get/create, no -/// framework), served as a same-origin file () so the pages carry no -/// inline script. Nothing is decided here: every endpoint the script calls re-checks on the server. -/// -internal static class StepUpPages -{ - /// - /// The path the page script is served from — a same-origin FILE, never an inline block. The - /// portal's Content-Security-Policy is moving to a script-src without - /// 'unsafe-inline' (Doc/Architecture/ContentSecurityPolicy), and under it an inline - /// script would not run: these pages carry their per-page values in a JSON data block (which - /// the browser never executes) and their behaviour in . - /// - internal const string ScriptPath = "/" + StepUpController.BasePath + "/" + StepUpController.PageScriptAction; - - /// The id of the JSON data block the page script reads its values from. - internal const string DataElementId = "mw-step-up-data"; - - /// - /// The page shell. names the behaviour the shared page script runs - /// (passkey or enroll); null renders a page with no script at all. - /// are the per-page values (the receipt id), serialized into the data - /// block next to the localized status words. - /// - internal static string Shell(StepUpPageTexts t, string body, string? page = null, - IReadOnlyDictionary? values = null) - { - var html = new StringBuilder() - .Append("") - .Append("") - .Append(Enc(t.L("stepUp.title"))).Append("") - .Append("") - .Append("

").Append(Enc(t.L("stepUp.title"))).Append("

") - .Append(body); - if (page is not null) - html.Append(""); - return html.Append("").ToString(); - } - - /// - /// The data block's JSON. 's default encoder escapes <, - /// > and &, so no value can close the <script> element it sits in. - /// - private static string Data(StepUpPageTexts t, string page, IReadOnlyDictionary? values) => - JsonSerializer.Serialize(new Dictionary - { - ["page"] = page, - ["values"] = values ?? new Dictionary(), - ["texts"] = new Dictionary - { - ["working"] = t.L("stepUp.working"), - ["failed"] = t.L("stepUp.failed.generic"), - ["unsupported"] = t.L("stepUp.passkey.unsupported"), - ["done"] = t.L("stepUp.enroll.done"), - }, - }); - - /// A refusal or failure — one sentence and a way back. - internal static string Message(StepUpPageTexts t, string message, string backUrl) => - Shell(t, "

" + Enc(message) + "

" + Enc(t.L("stepUp.back")) + "

"); - - /// No factor yet: enrol one before approving. - internal static string EnrollNeeded(StepUpPageTexts t, string enrollUrl) => - Shell(t, P(t.L("stepUp.enrollNeeded")) + "

" + Enc(t.L("stepUp.enrollLink")) + "

"); - - /// The passkey rung: one button that runs the assertion. - internal static string Passkey(StepUpPageTexts t, string enrollUrl) => - Shell(t, - P(t.L("stepUp.passkey.intro")) - + "

" - + "

" - + "

" + Enc(t.L("stepUp.enroll.manage")) + "

", - "passkey"); - - /// The TOTP rung: a code (or a recovery code) and a nudge towards a passkey. - internal static string Totp(StepUpPageTexts t, string formAction, string enrollUrl) => - Shell(t, - P(t.L("stepUp.totp.intro")) - + "
" - + "

" - + "

" - + "

" + Enc(t.L("stepUp.nudgePasskey")) + "

"); - - /// - /// The enrolment page — the user's passkeys, "add a passkey", and the authenticator-app fallback - /// offered ONLY when this device reports no passkey support and the account has no passkey. - /// - internal static string Enroll(StepUpPageTexts t, StepUpFactors? factors, bool mayEnrollTotp, - string? receiptId, string? confirmFirstUrl, bool signInAgain, string returnUrl) - { - var body = new StringBuilder(P(t.L("stepUp.enroll.intro"))); - body.Append("

").Append(Enc(t.L("stepUp.enroll.passkeys"))).Append("

"); - var passkeys = factors?.Passkeys ?? []; - if (passkeys.Count == 0) - body.Append(P(t.L("stepUp.enroll.none"))); - else - { - body.Append("
    "); - // Formatted for the VIEWER's locale (never the process culture), oldest first. - var culture = t.Culture; - foreach (var p in passkeys.Values.OrderBy(p => p.CreatedAt)) - body.Append("
  • ").Append(Enc(string.Format(culture, - t.L("stepUp.enroll.created"), p.CreatedAt.UtcDateTime.ToString("d", culture)))) - .Append("
  • "); - body.Append("
"); - } - if (factors is { TotpConfirmedAt: not null }) - body.Append(P(t.L("stepUp.enroll.totpOn"))); - - if (signInAgain) - return Shell(t, body + P(t.L("stepUp.enroll.signInAgain")) + Back(t, returnUrl)); - if (confirmFirstUrl is not null) - return Shell(t, body + P(t.L("stepUp.enroll.confirmFirst")) - + "

" + Enc(t.L("stepUp.confirm")) + "

" + Back(t, returnUrl)); - - body.Append("

"); - if (mayEnrollTotp) - body.Append(""); - body.Append("

").Append(Back(t, returnUrl)); - - return Shell(t, body.ToString(), "enroll", - new Dictionary { ["receipt"] = receiptId }); - } - - /// - /// The page script, served as a same-origin file at : the base64url - /// helpers, a JSON POST to a sibling endpoint, the two WebAuthn response serializers, and the - /// behaviour of the two pages that need one — chosen by the data block's page. Every - /// endpoint it calls re-checks on the server; nothing here decides anything. - /// - internal const string ScriptSource = """ - (() => { - const D = JSON.parse(document.getElementById('mw-step-up-data').textContent); - const T = D.texts; - const RECEIPT = D.values.receipt ?? null; - const enc = b => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - const dec = s => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - s.length % 4) % 4)), c => c.charCodeAt(0)); - const post = async (path, body) => { - const r = await fetch('/auth/step-up/' + path, { method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - return r.json(); - }; - const assertion = c => ({ id: c.id, rawId: enc(c.rawId), type: c.type, - response: { authenticatorData: enc(c.response.authenticatorData), clientDataJSON: enc(c.response.clientDataJSON), - signature: enc(c.response.signature), userHandle: c.response.userHandle ? enc(c.response.userHandle) : null }, - clientExtensionResults: c.getClientExtensionResults() }); - const attestation = c => ({ id: c.id, rawId: enc(c.rawId), type: c.type, - response: { attestationObject: enc(c.response.attestationObject), clientDataJSON: enc(c.response.clientDataJSON), - transports: c.response.getTransports ? c.response.getTransports() : [] }, - clientExtensionResults: c.getClientExtensionResults() }); - - if (D.page === 'passkey') { - document.getElementById('go').addEventListener('click', async () => { - const status = document.getElementById('status'); - if (!window.PublicKeyCredential) { status.textContent = T.unsupported; return; } - status.textContent = T.working; - try { - const options = await post('passkey/options', {}); - if (options.error) { status.textContent = options.error; return; } - options.challenge = dec(options.challenge); - (options.allowCredentials || []).forEach(c => c.id = dec(c.id)); - const cred = await navigator.credentials.get({ publicKey: options }); - const answer = await post('passkey/verify', assertion(cred)); - if (answer.redirect) { location.href = answer.redirect; return; } - status.textContent = answer.error || T.failed; - } catch (e) { status.textContent = T.failed; } - }); - } - - if (D.page === 'enroll') { - const status = document.getElementById('status'); - const totp = document.getElementById('totp'); - (async () => { - // TOTP is offered only where this device can do no passkey at all. - const webauthn = !!window.PublicKeyCredential; - const platform = webauthn && await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().catch(() => false); - if (totp && !platform) totp.hidden = false; - if (!webauthn) document.getElementById('add').disabled = true; - })(); - document.getElementById('add').addEventListener('click', async () => { - status.textContent = T.working; - try { - const options = await post('passkey/register/options', { receipt: RECEIPT }); - if (options.error) { status.textContent = options.error; return; } - options.challenge = dec(options.challenge); - options.user.id = dec(options.user.id); - (options.excludeCredentials || []).forEach(c => c.id = dec(c.id)); - const cred = await navigator.credentials.create({ publicKey: options }); - const answer = await post('passkey/register', { receipt: RECEIPT, credential: JSON.stringify(attestation(cred)) }); - if (answer.ok) { status.textContent = T.done; location.reload(); return; } - status.textContent = answer.error || T.failed; - } catch (e) { status.textContent = T.failed; } - }); - const start = document.getElementById('totpStart'); - if (start) start.addEventListener('click', async () => { - try { - const setup = await post('totp/enroll/start', { receipt: RECEIPT }); - if (setup.error) { status.textContent = setup.error; return; } - document.getElementById('qr').innerHTML = setup.svg; - document.getElementById('secret').textContent = setup.secret; - document.getElementById('totpSetup').hidden = false; - } catch (e) { status.textContent = T.failed; } - }); - const confirm = document.getElementById('totpConfirm'); - if (confirm) confirm.addEventListener('click', async () => { - try { - const answer = await post('totp/enroll/confirm', { receipt: RECEIPT, code: document.getElementById('totpCode').value }); - if (answer.error) { status.textContent = answer.error; return; } - document.getElementById('totpSetup').hidden = true; - document.getElementById('codes').textContent = answer.recoveryCodes.join('\n'); - document.getElementById('recovery').hidden = false; - } catch (e) { status.textContent = T.failed; } - }); - } - })(); - """; - - private static string Back(StepUpPageTexts t, string url) => - "

" + Enc(t.L("stepUp.back")) + "

"; - - private static string P(string text) => "

" + Enc(text) + "

"; - - private static string Enc(string? text) => WebUtility.HtmlEncode(text ?? ""); -} diff --git a/memex/Memex.Portal.Shared/Authentication/StepUpPendingStore.cs b/memex/Memex.Portal.Shared/Authentication/StepUpPendingStore.cs deleted file mode 100644 index 6bec0ea6a0..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/StepUpPendingStore.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// The server-side half of a step-up in progress (, at -/// Auth/_StepUpPending/{handle}), always as System — the node type admits System alone. -/// Reactive end to end; the one Task bridge is the MVC action that calls it. -/// -/// The mesh hub. -internal sealed class StepUpPendingStore(IMessageHub hub) -{ - /// The bound on one write or read; one that does not answer fails the step-up (closed). - internal static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); - - private AccessService? Access => hub.ServiceProvider.GetService(); - - private IMeshService Mesh => hub.ServiceProvider.GetRequiredService(); - - /// Stores a pending step-up. Cold; emits once it is stored. - /// The pending step-up. - /// The stored node. - public IObservable Begin(StepUpPending pending) => - Access.RunAsSystem(() => Mesh.CreateNode(new MeshNode(pending.Id, StepUpPaths.PendingNamespace) - { - Name = "Step-up in progress", - NodeType = StepUpPaths.PendingNodeType, - State = MeshNodeState.Active, - Content = pending, - }).Take(1)) - .Timeout(Timeout); - - /// - /// Reads the pending step-up a handle names. Only ever called with a handle this host minted and - /// sealed into the browser's cookie, so the node exists unless it expired and was removed. Cold, - /// single emission; null when absent. - /// - /// The handle. - /// The pending step-up, or null. - public IObservable Read(string handle) => - Access.RunAsSystem(() => hub.GetMeshNode(StepUpPaths.Pending(handle), Timeout).Take(1)) - .Select(n => n?.ContentAs(hub.JsonSerializerOptions)) - .DefaultIfEmpty(null); - - /// - /// TAKES a pending step-up — at most one caller ever gets it. Reads it, lets - /// check it belongs to the request (user, state, expiry), then claims it - /// through : of two concurrent requests carrying the - /// same cookie, only the store's winner receives the record; the other gets null. The node is - /// deleted afterwards as tidying — the claim, not the delete, is what makes it single use. - /// Cold, single emission. - /// - /// The handle. - /// The request's own checks; a record it refuses is never claimed. - /// The pending step-up for the winner; null otherwise. - public IObservable Take(string handle, Func admits) => - Read(handle).SelectMany(pending => pending is null || !admits(pending) - ? Observable.Return(null) - : new StepUpSingleUse(hub).ClaimPending(handle, pending.UserId) - .Select(won => won ? pending : null)); - - /// Deletes a pending step-up once it produced its proof. Cold. - /// The handle. - /// Whether it was deleted. - public IObservable Complete(string handle) => - Access.RunAsSystem(() => Mesh.DeleteNode(StepUpPaths.Pending(handle))); -} diff --git a/memex/Memex.Portal.Shared/Authentication/StepUpSingleUse.cs b/memex/Memex.Portal.Shared/Authentication/StepUpSingleUse.cs deleted file mode 100644 index 2c3242e81b..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/StepUpSingleUse.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Single use, decided by the STORE — for what the portal rungs must spend exactly once: a pending -/// step-up (one ceremony, one proof), a TOTP time step, a recovery code. The same primitive the -/// receipt consumption uses (StepUpService.Claim): creating a marker node is atomic at the -/// owning hub, and because a concurrent create response cannot be trusted to say who won, the -/// claimant reads the marker BACK and wins only when the stored nonce is its own. -/// -/// A read-then-write (read the pending node, delete it later; validate a code against a -/// snapshot, fold the counter later) is NOT single use: two concurrent requests both read before -/// either write lands, and both proceed. Every completing endpoint therefore claims here first and -/// goes on only as the winner. -/// -/// The mesh hub. -internal sealed class StepUpSingleUse(IMessageHub hub) -{ - /// The bound on the create and the read-back; one that does not answer faults (fail closed). - internal static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); - - private AccessService? Access => hub.ServiceProvider.GetService(); - - /// Claims a pending step-up — the one proof it may yield. Cold; true for the winner only. - /// The pending step-up's handle (32 lowercase hex). - /// The approver (recorded on the marker). - /// Whether this call won. - public IObservable ClaimPending(string handle, string userId) => - Claim("pending-" + handle, StepUpPaths.Pending(handle), userId); - - /// Claims one TOTP time step of one user. Cold; true for the winner only. - /// The user. - /// The RFC 6238 time step the code verified for. - /// Whether this call won. - public IObservable ClaimTotpStep(string userId, long step) => - Claim("totp-" + UserKey(userId) + "-" + step.ToString(System.Globalization.CultureInfo.InvariantCulture), - StepUpPaths.Factors(userId), userId); - - /// Claims one recovery code of one user. Cold; true for the winner only. - /// The user. - /// The code's hash (hex) as stored in . - /// Whether this call won. - public IObservable ClaimRecoveryCode(string userId, string recoveryHash) => - Claim("rc-" + UserKey(userId) + "-" + recoveryHash.ToLowerInvariant()[..Math.Min(32, recoveryHash.Length)], - StepUpPaths.Factors(userId), userId); - - /// Claims one non-zero signature counter value of one passkey. Cold; true for the winner only. - /// The user. - /// The credential id (base64url). - /// The counter the verified assertion carried (non-zero: zero means "no counter"). - /// Whether this call won. - public IObservable ClaimPasskeyCounter(string userId, string credentialId, uint signCount) => - Claim("pk-" + UserKey(userId) + "-" + UserKey(credentialId)[..16] + "-" - + signCount.ToString(System.Globalization.CultureInfo.InvariantCulture), - StepUpPaths.Factors(userId), userId); - - /// How many TOTP/recovery-code attempts one user gets per . - internal const int TotpAttemptsPerWindow = 5; - - /// The window the TOTP attempt budget is counted in. - internal static readonly TimeSpan TotpAttemptWindow = TimeSpan.FromMinutes(15); - - /// - /// Spends one TOTP/recovery-code attempt of in the window that holds - /// : claims the first free one of slots, - /// in order, and stops at the first it wins. Atomic per slot (the store decides), durable across - /// restarts and replicas, and counted across ceremonies — so a stolen session cannot brute-force - /// a six-digit code by starting one ceremony per guess. Cold; true while the budget lasts. - /// - /// The user. - /// The clock. - /// Whether an attempt was admitted. - public IObservable ClaimTotpAttempt(string userId, DateTimeOffset now) - { - var window = (now.ToUnixTimeSeconds() / (long)TotpAttemptWindow.TotalSeconds) - .ToString(System.Globalization.CultureInfo.InvariantCulture); - var prefix = "totp-try-" + UserKey(userId) + "-" + window + "-"; - return Observable.Range(0, TotpAttemptsPerWindow) - .Select(slot => Claim(prefix + slot.ToString(System.Globalization.CultureInfo.InvariantCulture), - StepUpPaths.Factors(userId), userId)) - // Concat subscribes one claim at a time, and Take(1) stops at the first win: a slot - // already taken costs one create + read-back, a free one ends the walk. - .Concat() - .Where(won => won) - .Take(1) - .DefaultIfEmpty(false); - } - - /// A path-safe key for a user id (user ids may carry characters a node id may not). - internal static string UserKey(string userId) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId)))[..32].ToLowerInvariant(); - - private IObservable Claim(string markerId, string claimedPath, string userId) => - Observable.Defer(() => - { - var nonce = StepUpSeal.NewId(); - var marker = new MeshNode(markerId, StepUpPaths.ConsumptionNamespace) - { - Name = "Step-up single use", - NodeType = StepUpPaths.ConsumptionNodeType, - State = MeshNodeState.Active, - Content = new StepUpConsumption - { - Id = markerId, - ReceiptId = markerId, - ActionPath = claimedPath, - UserId = userId, - ConsumedAt = DateTimeOffset.UtcNow, - Nonce = nonce, - }, - }; - var mesh = hub.ServiceProvider.GetRequiredService(); - // A create refused as "already exists" is the loser's normal path, not a fault — the - // read-back decides either way. Any OTHER fault surfaces: the caller fails closed. - return Access.RunAsSystem(() => mesh.CreateNode(marker) - .Take(1) - .Select(_ => true) - .Catch((Exception ex) => IsAlreadyExists(ex) ? Observable.Return(false) : Observable.Throw(ex)) - .SelectMany(_ => hub.GetMeshNode(StepUpPaths.SingleUse(markerId), Timeout).Take(1))) - .Timeout(Timeout) - .Select(stored => stored?.ContentAs(hub.JsonSerializerOptions) is { } c - ? string.Equals(c.Nonce, nonce, StringComparison.Ordinal) - : throw new InvalidOperationException($"The single-use marker {markerId} was created but could not be read back.")); - }); - - /// A create refused because the path is taken — typed first, message as the fallback. - internal static bool IsAlreadyExists(Exception ex) - { - for (var e = ex; e is not null; e = e.InnerException) - { - if (e.Data[NodeCreationFailure.RejectionReasonKey] is NodeCreationRejectionReason.NodeAlreadyExists) - return true; - if (e.Message?.StartsWith("Node already exists", StringComparison.Ordinal) == true) - return true; - } - return false; - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/TokenRefusal.cs b/memex/Memex.Portal.Shared/Authentication/TokenRefusal.cs deleted file mode 100644 index ff49472811..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/TokenRefusal.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Text.Json; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// A non-2xx answer from Microsoft's token endpoint, read for the ONE thing that matters to the -/// Executive Assistant: did Entra say the stored grant is dead, or did it say nothing about it? -/// -/// The OAuth 2.0 error shape (RFC 6749 §5.2, and Entra's extension of it) is a 400 with a -/// JSON body { "error": "…", "error_description": "AADSTS…: …" }. Three error values -/// name the GRANT: invalid_grant (revoked, expired, password changed, consent withdrawn, or a -/// refresh token minted for a narrower scope set than the one requested), interaction_required -/// and consent_required (the user must go through the dialog again). Those are -/// : the remedy is consent, and saying "retry in a moment" to them is the -/// defect in MeshWeaver.Plugins#1615 — the user retries forever. Everything else — 401 -/// invalid_client (OUR secret), 429, 5xx, a body that is not that shape — says nothing about -/// the grant and stays undetermined (#3433). -/// -/// Pure: a status and a body in, a verdict out, so it is pinned without a network -/// (EaCredentialReadTest). -/// -public sealed record TokenRefusal(int StatusCode, string? Error, string? Description) -{ - /// - /// True when Entra answered that the stored grant cannot be redeemed — consent is the remedy. - /// The three OAuth error values that name the grant itself rather than us or the service. - /// - public bool GrantRefused => - StatusCode == 400 && Error is "invalid_grant" or "interaction_required" or "consent_required"; - - /// The endpoint's own words, for the diagnostic and the credential's stamp. - public string Summary => - Error is null - ? $"HTTP {StatusCode}" - : Description is null ? Error : $"{Error}: {FirstLine(Description)}"; - - /// Reads one token-endpoint answer. Never throws: a body that is not the OAuth error shape is a refusal with no error. - public static TokenRefusal Of(int statusCode, string? body) - { - if (string.IsNullOrWhiteSpace(body)) - return new TokenRefusal(statusCode, null, null); - try - { - using var doc = JsonDocument.Parse(body); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - return new TokenRefusal(statusCode, null, null); - var error = doc.RootElement.TryGetProperty("error", out var e) && e.ValueKind == JsonValueKind.String - ? e.GetString() - : null; - var description = doc.RootElement.TryGetProperty("error_description", out var d) && d.ValueKind == JsonValueKind.String - ? d.GetString() - : null; - return new TokenRefusal(statusCode, error, description); - } - catch (JsonException) - { - return new TokenRefusal(statusCode, null, null); - } - } - - // Entra's error_description is several lines (the AADSTS sentence, a trace id, a correlation id, - // a timestamp); the first line is the sentence a person can act on. - private static string FirstLine(string text) - { - var end = text.IndexOfAny(['\r', '\n']); - return end < 0 ? text.Trim() : text[..end].Trim(); - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/Totp.cs b/memex/Memex.Portal.Shared/Authentication/Totp.cs deleted file mode 100644 index e35a122a82..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/Totp.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Collections.Immutable; -using System.Globalization; -using System.Security.Cryptography; -using System.Text; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// RFC 6238 time-based one-time passwords (HMAC-SHA1, 30-second steps, 6 digits) — the last rung of -/// the approval step-up, used only where no passkey is possible (Doc/Architecture/ApprovalStepUp). -/// Pure: the clock and the secret are arguments. Each accepted step is returned so the caller can -/// refuse it the second time (a code is accepted once). -/// -public static class Totp -{ - /// Step length. - public const int StepSeconds = 30; - - /// Code length. - public const int Digits = 6; - - /// How many steps either side of now a code may come from (clock drift). - public const int Window = 1; - - private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - - /// A fresh 160-bit secret. - /// The secret bytes. - public static byte[] NewSecret() => RandomNumberGenerator.GetBytes(20); - - /// The time step of . - /// The instant. - /// The step number. - public static long StepOf(DateTimeOffset at) => at.ToUnixTimeSeconds() / StepSeconds; - - /// The code for one step (RFC 4226 dynamic truncation). - /// The shared secret. - /// The time step. - /// The code length. - /// The zero-padded code. - public static string Code(byte[] secret, long step, int digits = Digits) - { - var counter = new byte[8]; - for (var i = 7; i >= 0; i--) { counter[i] = (byte)(step & 0xff); step >>= 8; } - var hash = HMACSHA1.HashData(secret, counter); - var offset = hash[^1] & 0x0f; - var binary = ((hash[offset] & 0x7f) << 24) | (hash[offset + 1] << 16) | (hash[offset + 2] << 8) | hash[offset + 3]; - var modulo = (int)Math.Pow(10, digits); - return (binary % modulo).ToString(CultureInfo.InvariantCulture).PadLeft(digits, '0'); - } - - /// - /// The newest step a submitted code matches within the drift window and AFTER - /// , or null. Compared in constant time. - /// - /// The shared secret. - /// What the user typed (spaces ignored). - /// The clock. - /// The last step already accepted for this secret. - /// The matched step, or null. - public static long? Verify(byte[] secret, string? code, DateTimeOffset now, long lastAcceptedStep) - { - var typed = (code ?? "").Replace(" ", "", StringComparison.Ordinal); - if (typed.Length != Digits || !typed.All(char.IsAsciiDigit)) return null; - var current = StepOf(now); - long? matched = null; - for (var step = current - Window; step <= current + Window; step++) - { - if (step <= lastAcceptedStep) continue; - if (CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(Code(secret, step)), Encoding.ASCII.GetBytes(typed))) - // The NEWEST matching step: adjacent steps can share a code, and keeping the older - // one would let the same code be accepted again for the newer step (a replay). - matched = step; - } - return matched; - } - - /// RFC 4648 base32 (no padding) — how authenticator apps take a secret. - /// The bytes. - /// The base32 text. - public static string Base32(byte[] bytes) - { - var sb = new StringBuilder(); - int buffer = 0, bits = 0; - foreach (var b in bytes) - { - buffer = (buffer << 8) | b; bits += 8; - while (bits >= 5) { sb.Append(Base32Alphabet[(buffer >> (bits - 5)) & 31]); bits -= 5; } - } - if (bits > 0) sb.Append(Base32Alphabet[(buffer << (5 - bits)) & 31]); - return sb.ToString(); - } - - /// The otpauth:// URI an authenticator app scans. - /// The instance name. - /// The account label. - /// The secret. - /// The URI. - public static string OtpAuthUri(string issuer, string account, byte[] secret) => - $"otpauth://totp/{Uri.EscapeDataString(issuer)}:{Uri.EscapeDataString(account)}" - + $"?secret={Base32(secret)}&issuer={Uri.EscapeDataString(issuer)}&algorithm=SHA1&digits={Digits}&period={StepSeconds}"; - - /// Ten fresh one-time recovery codes (xxxxx-xxxxx, lowercase base32). - /// The codes. - public static ImmutableList NewRecoveryCodes() => - Enumerable.Range(0, 10).Select(_ => - { - var raw = Base32(RandomNumberGenerator.GetBytes(7))[..10].ToLowerInvariant(); - return raw[..5] + "-" + raw[5..]; - }).ToImmutableList(); - - /// The stored form of a recovery code: SHA-256 hex of its normalized text. - /// The code. - /// The hash. - public static string HashRecoveryCode(string code) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( - (code ?? "").Trim().Replace("-", "", StringComparison.Ordinal).ToLowerInvariant()))).ToLowerInvariant(); -} diff --git a/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs b/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs deleted file mode 100644 index 23dbff6ecf..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs +++ /dev/null @@ -1,381 +0,0 @@ -using System.Collections.Immutable; -using System; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Materialises a new user's identity in all the places login + routing + partition -/// activation need to find them. Extracted from Onboarding.razor so the dual-write -/// shape is unit-testable end-to-end (see UserOnboardingServiceTests). -/// -/// 100% reactive — IObservable<T> end-to-end. Every method returns a -/// cold observable. Callers Subscribe (and may chain). No await, no -/// FirstAsync().ToTask() — those would block hub message processing on the -/// publishing thread and deadlock under load. -/// See Doc/Architecture/AsynchronousCalls.md. -/// -/// One row, one write: the per-user partition root — -/// {username}.mesh_nodes at (namespace='', id={username}). This is what -/// /{username} resolves to via the standard partition router; it renders the User -/// layout (Activity area) from 's -/// HubConfiguration. The per-user Postgres schema is created lazily on this first write by -/// the path-routing adapter (public.ensure_partition_schema); no explicit -/// Admin/Partition catalog entry is needed. -/// -/// Login finds the user via the Auth mirror, not a second write. The login flow -/// runs nodeType:User (routed to the Auth partition by -/// UserNodeType.AddQueryRoutingRule); the V27 trigger -/// mirror_access_object_to_auth_schema copies this partition-root User row into -/// auth.mesh_nodes automatically. There is therefore NO separate -/// (namespace='User', id={username}) catalog-mirror write — that legacy write routed -/// to an unregistered User first-segment and lazily provisioned a stray user -/// schema distinct from auth (cleaned up + dropped by migration V31). Non-system writes -/// into the User/Auth mirror are blocked by PartitionWriteGuardValidator. -/// -public sealed class UserOnboardingService( - IMeshService meshService, - AccessService accessService, - IMessageHub hub, - ILogger? logger = null, - IIconGenerator? iconGenerator = null) -{ - /// - /// Drives the full dual-write. Returns a cold observable that, on Subscribe, - /// creates the three rows in order and emits the per-user-partition-root - /// (path = {username}) as its single value - /// before completing. Errors surface via OnError — callers wrap in a UI - /// Catch block. Subscribe to drive. - /// - public IObservable CreateUser(UserOnboardingRequest request) - { - var username = request.Username; - // 🚨 The service-principal prefix is not a username. A person holding `svc-…` would share - // an object id space with the services (Doc/Architecture/ServiceIdentities), and the request - // middleware refuses such a session as anonymous — so the account would be unusable anyway. - if (ServiceIdentity.IsServiceObjectId(username)) - return Observable.Throw(new InvalidOperationException( - $"'{username}' is reserved: usernames may not start with " - + $"'{ServiceIdentity.ObjectIdPrefix}', which names service principals.")); - var fullDisplayName = string.IsNullOrWhiteSpace(request.FullName) ? username : request.FullName!; - var avatarIcon = string.IsNullOrWhiteSpace(request.AvatarUrl) ? null : request.AvatarUrl!.Trim(); - - var userContent = new User - { - FullName = string.IsNullOrWhiteSpace(request.FullName) ? null : request.FullName!.Trim(), - Email = request.Email.Trim(), - Bio = string.IsNullOrWhiteSpace(request.Bio) ? null : request.Bio!.Trim(), - Role = string.IsNullOrWhiteSpace(request.Role) ? null : request.Role!.Trim(), - // The UI language, chosen on the onboarding form and defaulted there from the visitor's - // own computer language (Accept-Language → AccessContext.Locale). Set HERE, at user - // creation, rather than left to the browser detector: the detector only runs inside the - // authenticated portal shell, i.e. AFTER onboarding, so a German-speaking user filled in - // this form — and saw the first screens — in English. Resolved through Locales.TryMatch - // so an unshipped tag stores nothing rather than pinning the profile to a language we - // would render in English anyway. - Locale = Locales.TryMatch(request.Locale), - // The SEED — what a profile starts with. It reaches the store on the create leg only: - // UpsertProfile declares PinnedPaths as create-once, so on a user who already exists - // this value is discarded and the owner keeps the pins it holds. - PinnedPaths = UserOnboardingDefaults.PinnedPathsFor(request.IsPlatformBootstrap), - }; - - var partitionRootNode = new MeshNode(username) - { - Name = fullDisplayName, - NodeType = "User", - State = MeshNodeState.Active, - Icon = avatarIcon, - Content = userContent, - }; - - // Single write: the partition-root User node at (namespace='', id={username}). - // The per-user Postgres schema is auto-created on this first write - // (ensure_partition_schema), and the V27 auth-mirror trigger copies this User row - // into auth.mesh_nodes automatically — so login's `nodeType:User` lookup (routed to - // the Auth partition) finds it WITHOUT a separate catalog-mirror write. - // - // The old `new MeshNode(username, "User")` catalog-mirror write is GONE: it routed - // to the unregistered `User` first-segment and lazily provisioned a stray `user` - // schema separate from `auth` (migration V31 unifies that back into `auth` and drops - // it). Writes into the User/Auth mirror by non-system callers are now blocked by - // PartitionWriteGuardValidator; onboarding stays clean by simply not writing there. - // - // Wrap in Observable.Using + ImpersonateAsSystem so onboarding runs as the System - // identity (Permission.All): the new user / their brand-new partition root don't - // exist yet, so neither the signed-in admin nor the user-being-onboarded can hold - // Create on it — the canonical infrastructure-write case (see AccessService.cs). - // ATOMIC create-or-update — the owning hub decides create-vs-update by existence, so a re-run or - // a CONCURRENT onboarding (e.g. a lagged user-existence check that re-triggers provisioning) is - // race-free + idempotent. The old CreateNode().Catch(already-exists → UpdateNode()) split raced: - // under concurrency the create's exists-check lagged the in-flight create, the catch fell through - // to UpdateNode, which patched a not-yet-materialized node → "NotFound … for patch apply" → - // unhandled → error storm → the portal climbed to OutOfMemory and wedged. See /async, CreateOrUpdateNode. - return Observable.Using( - () => accessService.ImpersonateAsSystem(), - _ => UpsertProfile(partitionRootNode) - .Do(__ => logger?.LogInformation( - "Onboarding: upserted partition-root User '{Username}' to {Schema}.mesh_nodes", - username, username.ToLowerInvariant()))) - // Best-effort: once the user node exists, generate an inline-SVG avatar in the - // background (the configurable utility model, via IIconGenerator → NodeInitializer) - // and stamp it onto the node's Icon — exactly like thread auto-naming runs AFTER a - // thread is created. Skipped when the user supplied an avatar or no generator is wired. - .Do(rootNode => MaybeGenerateAvatar(rootNode, fullDisplayName, avatarIcon)); - } - - /// - /// The partition-root upsert, carrying the ONE fold onboarding needs: - /// is seeded on the create leg and kept as the owner holds it on the update leg - /// ( — the create-once member, #4928). - /// - /// 🚨 Why a fold and not the plain . The - /// full-instance upsert's update leg takes Content WHOLESALE - /// (UpdateAccordingToSourceNode: Content = sourceNode.Content ?? state.Content), so a - /// second for a user who exists replaced their pins with the seed — - /// [] for the first administrator — and silently erased everything they had pinned since. - /// The recovery endpoint () is documented as - /// re-runnable, and the onboarding page re-submits after a lagged existence check, so that leg - /// is not hypothetical. A client-side "exists → stream.Update, else create" split is not - /// the answer either: that is the exact shape 's own comment records as - /// removed (the create's exists-check lagged a concurrent create → a patch onto a not-yet- - /// materialised node → error storm → OOM wedge). The fold is the owner-side form of that same - /// Update: the caller states the RULE, the owner applies it against the node as it holds it, - /// inside the one serialised merge — no read, no decision, no race. - /// - /// Every other member still lands wholesale, deliberately: the interactive form's - /// re-submission carries values the person just typed, and a re-run of the recovery endpoint - /// repairing a display name is what the existing-user leg is FOR. Only the pins are the user's - /// own curation after onboarding. - /// - /// Issued off the router () exactly as - /// MeshService.CreateOrUpdateNode does, with the caller's identity stamped on the request - /// (RequestedBy) and on the delivery, because the response's Subscribe lands on an - /// emission thread where the impersonation scope's AsyncLocal is gone. Caller-read-free is not - /// cluster-atomic (see ) — which is exactly enough here: nothing is - /// counted, a value is merely preserved. - /// - /// The User node carrying the SEED content. - /// The node as the owner stored it — created, or merged onto the existing profile. - private IObservable UpsertProfile(MeshNode partitionRootNode) - { - // Captured at the call, which CreateUser makes INSIDE its Using scope — so this is the - // System identity the write runs under, pinned by value before any scheduler hop. - var captured = accessService.Context; - var request = new CreateOrUpdateNodeRequest(partitionRootNode) - .WithFolds(hub.JsonSerializerOptions, f => f - .KeepExisting(u => u.PinnedPaths) - // The home's path manifests — the logon action's, never the form's. - .KeepExisting(u => u.SpacePaths) - .KeepExisting(u => u.SharedPaths)) - with { RequestedBy = captured?.ObjectId }; - var target = hub.NodeOperationTarget(); - // Defer keeps the post cold — it fires on Subscribe, never on construction. - return Observable.Defer(() => hub.NodeOperationIssuingHub() - .Observe(request, o => captured is null - ? o.WithTarget(target) - : o.WithTarget(target).WithAccessContext(captured)) - .SelectMany(d => - { - var r = d.Message; - if (r.Success && r.Node is not null) - return Observable.Return(r.Node); - return Observable.Throw(r.RejectionReason switch - { - NodeUpsertRejectionReason.Unauthorized or NodeUpsertRejectionReason.ValidationFailed => - new UnauthorizedAccessException(r.Error ?? "Access denied"), - _ => new InvalidOperationException(r.Error ?? "Node upsert failed"), - }); - })) - // The SAME identity around every emission that the post was stamped with, so the - // subscriber's callbacks do not inherit whatever is ambient on the emission thread. - .CarryAccessContext(hub.ServiceProvider, captured); - } - - /// - /// Fire-and-forget avatar generation for a freshly-created User node. Reuses the existing - /// (the NodeInitializer agent on the configurable utility - /// model) to produce an inline SVG, then writes it to the node's - /// as System (the brand-new partition root has no usable caller identity on this background - /// callback). Best-effort: bounded by a timeout and swallows failures so a missing/un-configured - /// utility model never blocks onboarding — the user simply keeps the initials fallback avatar. - /// - private void MaybeGenerateAvatar(MeshNode userNode, string displayName, string? providedIcon) - { - if (iconGenerator is null || !string.IsNullOrWhiteSpace(providedIcon)) - return; - - iconGenerator - .GenerateSvgAsync(displayName, $"A friendly, minimal circular profile avatar for {displayName}") - .Timeout(TimeSpan.FromSeconds(45)) - .Subscribe( - svg => - { - using (accessService.ImpersonateAsSystem()) - meshService.UpdateNode(userNode with { Icon = svg }) - .Subscribe( - _ => logger?.LogInformation("Onboarding: generated avatar for '{User}'", userNode.Id), - ex => logger?.LogWarning(ex, "Onboarding: avatar Icon write failed for '{User}'", userNode.Id)); - }, - ex => logger?.LogInformation( - ex, "Onboarding: avatar generation skipped for '{User}' (no utility model or error)", userNode.Id)); - } - - /// - /// Self-AccessAssignment write — the new user gets Admin on their own scope. - /// Lives in the per-user partition's access satellite. Without this, - /// the user can read their own partition root (public read on User nodes) - /// but every subsequent write ("Create permission required") fails. - /// Returns a cold observable that emits the created AccessAssignment node; - /// subscribe to drive. - /// - public IObservable GrantSelfAdmin(string username) - { - var assignment = new MeshNode($"{username}_Access", $"{username}/_Access") - { - NodeType = "AccessAssignment", - Name = $"{username} Access", - MainNode = username, - Content = new AccessAssignment - { - AccessObject = username, - DisplayName = username, - Roles = [new RoleAssignment { Role = Role.Admin.Id, Denied = false }] - } - }; - // Self-impersonate as System: granting a brand-new user access to their own (possibly - // just-created) partition is an infrastructure write — the caller may have no usable - // identity (server-side bootstrap) or only hub identity (interactive onboarding). Same - // justification + pattern as CreateUser above. PostPipeline fails closed without a - // context, so this MUST set one explicitly rather than rely on the caller. - // ATOMIC create-or-update (see CreateUser): race-free + idempotent. A retried or concurrent - // onboarding must not fall into the CreateNode→already-exists→UpdateNode split that patched a - // not-yet-materialized _Access node ("NotFound … for patch apply" → storm → OOM wedge). - return Observable.Using( - () => accessService.ImpersonateAsSystem(), - _ => meshService.CreateOrUpdateNode(assignment) - .Do(__ => logger?.LogInformation( - "Onboarding: granted self-Admin to '{Username}' at {Path}", username, assignment.Path))); - } - - /// - /// First-user-only: grants the user global admin by making them an admin on the - /// Admin partition — an at namespace - /// Admin/_Access with MainNode="Admin": the scope the path encodes, - /// which AccessAssignmentGuard enforces at the write boundary. The same shape - /// writes for config-driven admins. This confers the - /// platform gates (hub.IsGlobalAdmin() = - /// at scope Admin) — it is deliberately NOT a data superuser; an empty - /// MainNode would scope the grant to ROOT (every partition) and is refused. - /// See Doc/Architecture/AccessControl.md → "The scope invariant". - /// - /// Caller gates this on the platform-bootstrap check (no admin grant exists yet). - /// Subscribe to drive — a silent failure would leave the platform with no admins, so - /// callers must surface OnError. - /// - public IObservable GrantPlatformAdmin(string username) - { - // Global admin = admin on the ADMIN PARTITION: namespace "Admin/_Access", MainNode - // "Admin". MainNode MUST equal the scope the path encodes — AccessAssignmentGuard - // refuses the write otherwise (an empty MainNode is a ROOT grant, the 43-superuser - // incident shape). hub.IsGlobalAdmin() reads Permission.All at scope "Admin"; the - // platform gates come from this grant, cross-partition data access deliberately - // does NOT. Mirrors GlobalAdminSeed's config-admin shape. See AccessControl.md. - var assignment = new MeshNode($"{username}_Access", "Admin/_Access") - { - NodeType = "AccessAssignment", - Name = $"{username} — Admin", - MainNode = "Admin", - Content = new AccessAssignment - { - AccessObject = username, - DisplayName = username, - Roles = [new RoleAssignment { Role = Role.Admin.Id, Denied = false }] - } - }; - // Self-impersonate as System (see GrantSelfAdmin) — the first-user platform-Admin grant - // is an infrastructure write that must succeed even when no user identity is on the - // caller (server-side bootstrap) or only a hub identity (interactive onboarding). - // ATOMIC create-or-update (see GrantSelfAdmin): race-free + idempotent first-user grant. - return Observable.Using( - () => accessService.ImpersonateAsSystem(), - _ => meshService.CreateOrUpdateNode(assignment) - .Do(__ => logger?.LogInformation( - "Onboarding: granted platform Admin (first user) to '{Username}' on the Admin partition (Admin/_Access, MainNode=\"Admin\")", username))); - } -} - -/// -/// The learning path a new user is SEEDED with — and who is not. A seed, never a value re-imposed: -/// applies it on the create leg only. -/// -public static class UserOnboardingDefaults -{ - /// - /// The four documentation sections an ordinary new user's Pinned tab opens onto: a clean grid - /// of doc landing pages, each with its own TOC. - /// - public static readonly ImmutableList LearningPath = - ["Doc/Architecture", "Doc/DataMesh", "Doc/GUI", "Doc/AI"]; - - /// - /// What to seed for this user. - /// - /// 🚨 The first global administrator gets NO learning path. That person is setting - /// the instance up, not learning it — a Pinned tab full of doc landing pages in front of the - /// setup work is noise at the one moment it costs most. Ordinary users keep it. Policy - /// first-admin-no-learning-path (Doc/Architecture/FirstRunSetupOnAProvisionedInstance, - /// "The first administrator learns nothing here"). - /// - /// Whether this user is the instance's first global administrator. - public static ImmutableList PinnedPathsFor(bool isPlatformBootstrap) => - isPlatformBootstrap ? [] : LearningPath; -} - -/// -/// Input shape for . Mirrors the form -/// model in Onboarding.razor; kept in this assembly so unit tests can -/// construct it without taking a dependency on the Blazor page. -/// -public sealed record UserOnboardingRequest( - string Username, - string Email, - string? FullName = null, - string? Bio = null, - string? Role = null, - string? AvatarUrl = null, - // The BCP-47 UI language for the new user ("de"), from the onboarding form's language picker — - // which itself defaults to the visitor's own computer language, negotiated from the request's - // Accept-Language header onto AccessContext.Locale. Resolved through Locales.TryMatch at the - // point of use, so a tag this deployment does not ship stores nothing rather than pinning the - // profile to a language we would only ever render in English anyway. - string? Locale = null) -{ - /// - /// True when this user is the instance's FIRST global administrator — the bootstrap path - /// (, and the first-user promotion the onboarding - /// page decides). Such a user is setting the instance up, not learning it, and is - /// seeded with no learning path (). - /// - /// 🚨 A CALLER's decision, not a form field — which is why it is an init-only property - /// and not a primary-constructor parameter. The primary constructor mirrors what the person - /// typed on the onboarding form; this flag is what the code orchestrating the onboarding - /// knows about the instance. Keeping it out of the constructor also keeps the record's - /// constructor signature — the one a module compiled earlier calls — exactly as it was - /// (check-record-signatures.py: adding a parameter, even with a default, replaces - /// that signature). - /// - /// It decides the SEED only. On a user who already exists the flag changes nothing: - /// keeps an existing profile's pins as they are - /// (the KeepExisting fold), so a re-run of the recovery endpoint — which is documented - /// as re-runnable — cannot erase what the administrator pinned after the original - /// onboarding. - /// - public bool IsPlatformBootstrap { get; init; } -} diff --git a/memex/Memex.Portal.Shared/Authentication/UserRoleResolver.cs b/memex/Memex.Portal.Shared/Authentication/UserRoleResolver.cs deleted file mode 100644 index 7fe4f94513..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/UserRoleResolver.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Data; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Thin façade over OnboardingMiddleware.LoadUserRoles so callers -/// outside this assembly can resolve a user's AccessAssignment-derived roles in -/// one call. -/// -/// Used by to enrich Bearer -/// principals with DB-resolved roles, so MCP / API-token sessions see the -/// same role set as cookie / OAuth sessions. Without this layer, roles would -/// be limited to whatever was stamped on the API token at creation time — -/// any later AccessAssignment grant would silently not apply for Bearer -/// requests, even though the same user logging in through a browser would -/// see them. -/// -/// Resolution goes through the canonical synced-query API -/// (workspace.GetQuery) — same path-keyed dedup + Initial gating + static -/// provider fan-out as every other live mesh-node collection consumer in the -/// codebase. Direct IMeshQueryCore.Query calls from auth code -/// were a pedestrian-query antipattern (replaced 2026-05). -/// -internal static class UserRoleResolver -{ - /// - /// Resolves the user's AccessAssignment-derived role names, keeping "there are no extra - /// roles" apart from "the role store could not be read" (issue #637). - /// - /// - /// Resolved(roles) — the read completed; an empty set means the - /// user genuinely has no AccessAssignment grants. - /// Unavailable(reason) — the read stalled or faulted. The caller - /// must answer retryable rather than authenticate a silently under-privileged principal, - /// whose every later request would be denied with a misleading "Access denied". - /// - /// - /// No role SOURCE at all (no hub / no workspace / no user id) is Resolved(empty), - /// not Unavailable: that is a static configuration fact — there is nothing to enrich from — - /// not a transient outage, and it must not turn every request into a 503. - /// - /// The single Task bridge here lives at the ASP.NET - /// AuthenticationHandler.HandleAuthenticateAsync boundary — - /// callers expect a Task-returning helper, but everything below - /// stays observable. It is - /// , never Rx's - /// .ToTask(), which would resume the caller inline on whichever hub - /// thread published the roles (forbidden since 2026-08-30). - /// - /// Scope to resolve the hub (and through it the workspace) from. - /// The mesh user id whose grants to read. - /// - /// Optional override of the role-read budget. Production passes nothing and gets - /// OnboardingMiddleware's cold-start-sized default; tests pass a short window to reach - /// the unavailable branch deterministically, the same way ApiTokenService exposes - /// ValidationReadTimeout. - /// - public static async Task>> LoadDbRolesAsync( - IServiceProvider services, string userId, TimeSpan? budget = null) - { - if (string.IsNullOrEmpty(userId)) - return IdentityReadOutcome>.Resolved(Array.Empty()); - - var lateFaultLogger = services.GetService() - ?.CreateLogger(typeof(UserRoleResolver)); - - // 🚨 Resolving the hub and its workspace happens INSIDE the chain, not before it. A hub - // mid-disposal (portal restart) throws from GetWorkspace(), and that throw used to be - // absorbed by the caller's bare `catch { }`. With the swallow gone it would escape as a - // 500 — an availability failure reported as a server error, i.e. this issue's own defect - // reappearing inside its fix. Deferring puts it on the same classified path as any other - // read fault: Unavailable, retryable, 503. - return (await Observable.Defer(() => - { - var workspace = services.GetService()?.GetWorkspace(); - // No role SOURCE at all is a static configuration fact — there is nothing to - // enrich from — NOT a transient outage. Resolved(empty), never Unavailable: - // otherwise a deployment without a mesh would 503 every request forever. - return workspace is null - ? Observable.Return( - IdentityReadOutcome>.Resolved(Array.Empty())) - : OnboardingMiddleware.LoadUserRoles(workspace, userId, logger: null, budget); - }) - .Catch>, Exception>(ex => - Observable.Return(IdentityReadOutcome>.Unavailable( - $"LoadDbRoles({userId}) faulted resolving the role source: {ex.GetType().Name}"))) - // The trailing Catch turns every fault into a value, so this source emits EXACTLY - // ONCE; FirstAsync is kept so an empty completion still surfaces as an error rather - // than silently authenticating an under-privileged principal. - .FirstAsync() - .ObserveCompletion(ex => lateFaultLogger?.LogWarning(ex, - "LoadDbRoles({UserId}) faulted after the role read had already settled", userId)))!; - } -} diff --git a/memex/Memex.Portal.Shared/Authentication/VirtualUserMiddleware.cs b/memex/Memex.Portal.Shared/Authentication/VirtualUserMiddleware.cs deleted file mode 100644 index 1beedf6b41..0000000000 --- a/memex/Memex.Portal.Shared/Authentication/VirtualUserMiddleware.cs +++ /dev/null @@ -1,177 +0,0 @@ -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Authentication; - -/// -/// Middleware that assigns a virtual user identity to unauthenticated visitors. -/// Runs after authentication. If the user is NOT authenticated: -/// 1. Checks for a "meshweaver_virtual_user" cookie. -/// 2. If no cookie, generates a new GUID, sets a long-lived cookie. -/// 3. Checks if VUser node exists (PingRequest with ImpersonateAsHub), creates if not. -/// 4. Sets AccessContext with IsVirtual = true. -/// -/// The VUser check runs once per circuit (page load). -/// Subsequent requests reuse the cached circuit context (same pattern as UserContextMiddleware). -/// -public class VirtualUserMiddleware(RequestDelegate next, ILogger logger) -{ - private const string CookieName = "meshweaver_virtual_user"; - - // "/api/mcp" beside "/mcp": the McpEndpointRoutes rewrite normally folds the primary path - // onto /mcp before this runs; the explicit entry is defense-in-depth against reordering — - // Bearer-authed MCP traffic must never provision guest VUsers. - private static readonly string[] ExcludedPrefixes = - ["/_framework", "/_content", "/_blazor", "/static/", "/favicon.ico", "/mcp", "/api/mcp", "/bootstrap", "/healthz"]; - - // 🚨 Anonymous, high-frequency infrastructure routes. They are polled by machines that keep no - // cookie, so the VUser flow can only ever churn: it re-derives an id and stamps a guest context - // on EVERY request, and it is the path that produced the 2026-06-12 outage (~15 VUsers/minute - // from kube-probe alone, 10 000+ leaked hubs). /api/version is exactly that shape — an - // unauthenticated build-identity poll target that must stay answerable, and cheap, when the mesh - // is unhealthy. - // - // Matched EXACTLY, not as a prefix: these are leaf routes with no children, and a prefix match - // would silently exempt anything that merely starts with the same text (/api/versioning) from - // the VUser flow. An exclusion list that is broader than it reads is how routes end up skipping - // machinery nobody meant to skip. (The prefix list above is genuinely prefix-shaped — subtrees - // like /_content/… and /static/… — so it keeps StartsWith.) - // - // /api/csp-report is the same shape: browsers POST Content-Security-Policy violation reports - // there on their own, possibly without the visitor's cookie, at the rate pages violate the - // report-only policy (Doc/Architecture/ContentSecurityPolicy). A report needs no identity. - private static readonly string[] ExcludedExactPaths = ["/api/version", "/api/csp-report"]; - - public async Task InvokeAsync(HttpContext context) - { - var path = context.Request.Path.Value ?? ""; - if (ExcludedPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)) - || ExcludedExactPaths.Any(p => string.Equals(path, p, StringComparison.OrdinalIgnoreCase))) - { - await next(context); - return; - } - - if (context.User?.Identity?.IsAuthenticated != true) - { - var portalApp = context.RequestServices.GetService(); - if (portalApp != null) - { - var accessService = portalApp.Hub.ServiceProvider.GetRequiredService(); - - // 🚨 Skip the entire VUser flow when a real-user identity is - // already on AccessService — `UserContextMiddleware` runs - // BEFORE us in the pipeline (see MemexConfiguration.cs) and - // resolves OAuth / Bearer / mesh-User-by-email into - // AccessService.Context. Real users sometimes have - // `context.User.Identity.IsAuthenticated == false` (e.g., - // the ASP.NET cookie expired but a Bearer token in the - // request still resolves a valid user via the cache); without - // this guard we'd wastefully provision a guest VUser node - // for them AND post the CreateNodeRequest into the portal - // hub, which has no handler — the crash the user just hit - // on the sub-thread URL. - var preExisting = accessService.Context ?? accessService.CircuitContext; - if (preExisting is not null && !preExisting.IsVirtual - && !string.IsNullOrEmpty(preExisting.ObjectId)) - { - await next(context); - return; - } - - var (virtualUserId, isNew) = GetOrCreateVirtualUserId(context); - - // Fast path: if the circuit already has a context for this virtual user, reuse it - // (avoids mesh call on every request — only needed once per circuit). - if (preExisting is not null && preExisting.ObjectId == virtualUserId && preExisting.IsVirtual) - { - accessService.SetContext(preExisting); - await next(context); - return; - } - - // 🚨 Mint the VUser NODE only on cookie ROUND-TRIP (isNew == false): - // the node (a mesh write + a per-node hub graph) is created the - // first time the cookie COMES BACK, proving a cookie-keeping - // browser session. First-contact requests get the cookie + an - // in-memory guest context only. Without this gate, every - // cookie-less client minted a fresh node PER REQUEST — kube-probe - // alone created ~15 VUsers/minute (and any crawler does the same), - // leaking 10,000+ hubs until the portal wedged at 100% CPU - // (2026-06-12 prod outage). A real visitor's node exists by - // their second request — before the Blazor circuit needs it. - // - // 100% reactive, NO await: EnsureVUserNode composes - // hub.Observe(CreateNodeRequest).Subscribe(...) internally — the - // request thread never waits on mesh work (AsynchronousCalls.md; - // an await here parks the request on the hub pump = deadlock). - if (!isNew) - EnsureVirtualUserNode(portalApp, virtualUserId); - - var portalIdentity = portalApp.Hub.Address.ToFullString(); - var virtualContext = new AccessContext - { - ObjectId = virtualUserId, - Name = "Guest", - IsVirtual = true, - ImpersonatedBy = portalIdentity - }; - // Set per-request context only. CircuitAccessHandler handles - // per-circuit persistence via CreateInboundActivityHandler. - accessService.SetContext(virtualContext); - - var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); - var userAgent = context.Request.Headers.UserAgent.ToString(); - var acceptLanguage = context.Request.Headers.AcceptLanguage.ToString(); - var referer = context.Request.Headers.Referer.ToString(); - logger.LogInformation( - "VirtualUser: {VirtualUserId} | New={IsNew} | IP={IP} | ForwardedFor={ForwardedFor} | Language={Language} | Referer={Referer} | UserAgent={UserAgent}", - virtualUserId, isNew, ip, forwardedFor, acceptLanguage, referer, userAgent); - } - } - - await next(context); - } - - /// - /// Returns the virtual user ID from the cookie, or generates a new one. - /// The bool indicates whether the ID was newly created. - /// - private static (string id, bool isNew) GetOrCreateVirtualUserId(HttpContext context) - { - if (context.Request.Cookies.TryGetValue(CookieName, out var existingId) && - !string.IsNullOrEmpty(existingId)) - { - return (existingId, false); - } - - var newId = Guid.NewGuid().ToString("N")[..12]; // Short ID for cookie - context.Response.Cookies.Append(CookieName, newId, new CookieOptions - { - HttpOnly = true, - IsEssential = true, - SameSite = SameSiteMode.Lax, - Expires = DateTimeOffset.UtcNow.AddDays(365) - }); - - return (newId, true); - } - - private void EnsureVirtualUserNode(PortalApplication portalApp, string virtualUserId) - { - try - { - VUserHelper.EnsureVUserNode(portalApp, virtualUserId, logger); - } - catch (Exception ex) - { - // Non-critical — don't block the request on VUser node creation failure - logger.LogWarning(ex, "VirtualUser: Failed to ensure VUser node for {VirtualUserId}", virtualUserId); - } - } -} diff --git a/memex/Memex.Portal.Shared/Email/EmailConfigurationGuard.cs b/memex/Memex.Portal.Shared/Email/EmailConfigurationGuard.cs deleted file mode 100644 index bda553fa0e..0000000000 --- a/memex/Memex.Portal.Shared/Email/EmailConfigurationGuard.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System.Collections.Immutable; -using MeshWeaver.Mesh; -using Microsoft.Extensions.Configuration; - -namespace Memex.Portal.Shared.Email; - -/// -/// The BOOT-TIME guard for the Email section: an install that CLAIMS mail -/// (Email:Enabled=true) and cannot possibly deliver it is refused at startup, by key -/// (MeshWeaver#2636, #2637). -/// -/// 🚨 The defect this closes. On memex the section was half-set — enabled, with -/// Email:TenantId and Email:ClientId unset — and the portal came up perfectly -/// healthy with mail DARK. did its job (the watchers refused, so -/// nothing was ever falsely stamped Sent), but a single Error line in a pod log is -/// not a signal anyone reads: /health was 200, the site served, and every invitation, -/// notification and document share queued as New and stayed there. The install said mail -/// was on and dropped every message. That is the failure — not the missing key, which is -/// ordinary, but the fact that nothing surfaced it. -/// -/// Two rules, and they are deliberately different — the same distinction -/// encodes for the OIDC tenant (#2621), and it is the -/// whole of this guard: -/// -/// Absent or Email:Enabled=false = the integration was NEVER -/// configured, and it must NOT refuse. A blank section is exactly what "no mail on this -/// install" looks like — it is every local dev run, every test host, every deployment that never -/// wanted mail — and aborting a portal because an OPTIONAL integration is unconfigured is the -/// #2510 failure verbatim (a guard that fails worse than the thing it guards). Nothing here is -/// even looked at in that case. -/// Email:Enabled=true with the section INCOMPLETE = a real -/// misconfiguration, refused at startup, naming the exact keys. Someone MEANT to enable mail. -/// An install in this state is not sending anything today, so refusing it cannot take a working -/// portal's mail down — it converts a silent drop into a named configuration error at boot, which -/// is the one thing an operator can act on. -/// -/// -/// 🚨 Why this does NOT re-open #2510. #2510 was not "email configuration stopped the -/// host" in the abstract; it was EmailDeliveryGuard ACTIVATING the Graph sender from -/// IHostedService.StartAsync, whose ClientSecretCredential constructor threw -/// ArgumentException: Invalid tenant id provided → Hosting failed to start — an -/// unactionable message, from a container resolution, that named Azure's validator instead of the -/// key to set. Two properties keep that fixed and are not negotiable here: -/// -/// this verdict is reached from inert configuration data only — no -/// container, no credential object, no I/O — so it cannot throw for any reason other than the one -/// it is reporting; and -/// the unconfigured install (blank/disabled) is never refused. -/// -/// What changes is only the SEVERITY of the enabled-but-incomplete case: from a log line nobody -/// reads to a named startup refusal. stays exactly as it is — it -/// still refuses the watchers on hosts that never call this guard (LocalMesh, test hosts, any host -/// not going through ConfigureMemexMesh), and it is still what guarantees queued mail is -/// never stamped Sent. -/// -/// Pure decision, no I/O and no container, so it is unit-testable without spinning a host — -/// the same shape as MemexConfiguration.ValidateContentStorageDurability and -/// . -/// -/// Scope is OUTBOUND, deliberately. The inbound channel (Email:InboundEnabled, -/// Email:WebhookBaseUrl, Email:SubscriptionClientState) has the same shape but no -/// incident behind it, and widening a boot-time refusal past the failure it is fixing is how a -/// guard acquires a blast radius nobody signed off on. -/// -public static class EmailConfigurationGuard -{ - /// The configuration section this guard reads, in binder form. - public const string SectionName = EmailOptions.SectionName; - - /// The switch that turns a blank section into a CLAIM, in binder form. - public const string EnabledKey = $"{SectionName}:{nameof(EmailOptions.Enabled)}"; - - /// The mailbox the system path sends AS — required by both credential flows. - public const string MailboxAddressKey = $"{SectionName}:{nameof(EmailOptions.MailboxAddress)}"; - - /// The alternative to the client-secret flow, named in every refusal as a way out. - public const string UseManagedIdentityKey = $"{SectionName}:{nameof(EmailOptions.UseManagedIdentity)}"; - - /// - /// A binder key (Email:TenantId) as the environment-variable / configMap entry an - /// operator actually sets (Email__TenantId). - /// - /// Both forms appear in every refusal on purpose. The binder form is what the code, the - /// docs and speak; the environment form is - /// what is typed into a Helm value, a configMap or a kubectl set env, and an operator - /// reading a pod log has no reason to know the two are the same key. Naming only one of them - /// is how a message that looks actionable still costs a search. - /// - public static string EnvironmentKey(string binderKey) - => binderKey.Replace(":", "__", StringComparison.Ordinal); - - /// - /// The keys this install CLAIMS mail with and has not set — empty when there is nothing to - /// refuse, which includes every disabled or absent section. - /// - /// The credential half is delegated to - /// rather than re-derived, so "which keys does the selected flow need" has exactly ONE - /// definition; a second copy here would be free to drift from the one the watchers and the - /// no-op sender already report. Managed identity carries no credential keys at all. - /// - /// is added on top because it is required by - /// BOTH flows and by no credential: the system send path is - /// /users/{MailboxAddress}/sendMail, so a blank value composes a Graph request for no - /// mailbox. It is not a credential, which is why it is not in - /// — but it is just as certainly the - /// difference between an install that sends and one that silently does not. - /// - /// The bound Email section (null when absent). - public static ImmutableArray MissingRequiredKeys(EmailOptions? options) - { - // Rule 1: absent or disabled is a COMPLETE, supported configuration. Nothing is missing - // from a section that never claimed anything. - if (options is not { Enabled: true }) - return ImmutableArray.Empty; - - var missing = ImmutableArray.CreateBuilder(4); - if (string.IsNullOrWhiteSpace(options.MailboxAddress)) - missing.Add(MailboxAddressKey); - missing.AddRange(options.MissingCredentialKeys()); - return missing.ToImmutable(); - } - - /// - /// The boot-time guard: throws when this install claims mail it cannot send, so the - /// misconfiguration is named at startup instead of surfacing as mail that never arrives. An - /// absent or disabled section passes. - /// - /// The bound Email section (null when absent). - /// - /// Email:Enabled=true and a required key is unset; the message names every missing key - /// in both the binder and the environment-variable form. - /// - public static void Validate(EmailOptions? options) - { - var missing = MissingRequiredKeys(options); - if (!missing.IsEmpty) - throw new InvalidOperationException(Refusal(missing)); - } - - /// - /// The call-site form: binds the Email section exactly as the portal composition does - /// (GetSection(...).Get<EmailOptions>()) and validates it. An absent section binds - /// to null and passes. - /// - /// The host configuration. - public static void Validate(IConfiguration configuration) - => Validate(configuration.GetSection(SectionName).Get()); - - /// - /// The refusal wording. Names every missing key in both forms, and every way OUT of the - /// refusal — complete the client-secret flow, switch to managed identity, or turn the - /// integration off — because "your configuration is incomplete" without a supported way to - /// disable it is an instruction to guess. - /// - /// The keys from ; never empty. - internal static string Refusal(ImmutableArray missing) - { - var named = string.Join(", ", missing.Select(k => $"{k} ({EnvironmentKey(k)})")); - return $"Email misconfiguration (issues #2636, #2637): {EnabledKey}=true " - + $"({EnvironmentKey(EnabledKey)}=true), but the {SectionName} section is INCOMPLETE — " - + $"{named} {(missing.Length == 1 ? "is" : "are")} not set, so this install cannot send " - + "mail at all. It would accept every invitation, notification and document share and " - + "then DROP it: the mail queues as New and stays there, the portal serves normally, " - + "/health stays 200, and nothing on any screen says mail is dark. Set the keys named " - + $"above, or set {UseManagedIdentityKey}=true " - + $"({EnvironmentKey(UseManagedIdentityKey)}=true) and grant the managed identity the " - + $"Mail.Send app role, or turn the integration off with {EnabledKey}=false " - + $"({EnvironmentKey(EnabledKey)}=false) — a disabled section is a complete, supported " - + "configuration and starts normally. Refusing to start so the misconfiguration " - + "surfaces now rather than as mail that never arrives."; - } -} diff --git a/memex/Memex.Portal.Shared/Email/EmailDeliveryGuard.cs b/memex/Memex.Portal.Shared/Email/EmailDeliveryGuard.cs deleted file mode 100644 index 57766a93f0..0000000000 --- a/memex/Memex.Portal.Shared/Email/EmailDeliveryGuard.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Collections.Immutable; -using MeshWeaver.Mesh; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Email; - -/// -/// 🚨 Email:Enabled=true on an install that cannot actually deliver is a REFUSED -/// configuration, not a working one (#2023) — and reaching that verdict must never cost more -/// than the mail it guards (#2510). -/// -/// The defect this closes. While the Graph sender was compiled into the portal, -/// "a sender is registered" and "mail can be delivered" were the same question, and -/// 's report-success-without-sending contract was safe: it was only -/// ever reached with Email:Enabled=false, where no watcher calls it. The move into the -/// MeshWeaver.Mail.MicrosoftGraph module (#1737) added a SECOND path to the no-op — the -/// TryAddSingleton fallback when the module is not on the install — and on that path -/// Enabled IS true and the watchers DO run. then flipped -/// every queued mail New → Sending → Sent while nothing left the process: node data saying -/// delivered, no error, no log (the send lines are Information under a Warning default). Hours -/// were spent hunting inboxes, junk folders and message traces because Sent was trusted. -/// -/// Why refusing beats succeeding quietly. Mail that stays New is recoverable: -/// complete the configuration, restart the portal, and it goes out — with no data repair and -/// nothing to re-queue by hand. (The restart is required, not incidental: the Email section -/// is bound once at host start and these watchers start with it, so neither re-reads it at -/// runtime.) Mail stamped Sent that was never sent is unrecoverable, because nothing -/// distinguishes it from mail that WAS sent. So -/// the watchers do not start at all on a refused configuration, and the no-op sender fails loudly -/// for every other caller instead of returning true. -/// -/// The refusal is logged at from StartAsync — inside the -/// startup window — so StartupErrorNotifier folds it into the Admin bell notification that -/// reports a degraded boot, rather than it sitting at Information under a Warning default where -/// nobody would ever see it. -/// -/// 🚨 Two refused configurations, asked in a fixed order. See -/// : the CONFIGURATION verdict is reached first, from inert data, and -/// only a completely-configured install goes on to ask the CONTAINER. That order is the whole of -/// #2510 and it is load-bearing — not a micro-optimisation. -/// -internal static class EmailDeliveryGuard -{ - /// The module that supplies a real sender. Named in every refusal so the operator is - /// told what to provision rather than that "email is broken". - internal const string SenderModule = "MeshWeaver.Mail.MicrosoftGraph"; - - /// - /// The CONFIGURATION verdict: the Email:* keys this install is enabled-but-missing, or - /// empty when there is nothing to refuse on this ground. A disabled section is never refused — - /// blank keys are exactly what Email:Enabled=false means. - /// - /// 🚨 Reached without touching the service provider, and that is the point: see - /// . Everything a container could tell us here - /// costs a construction that can throw; everything this tells us costs a string comparison. - /// - internal static ImmutableArray MissingConfiguration(EmailOptions options) - => options.Enabled ? options.MissingCredentialKeys() : ImmutableArray.Empty; - - /// - /// The CONTAINER verdict: this install is configured to send mail, but the sender it resolved - /// cannot deliver it. A null sender counts: no sender at all certainly does not deliver, - /// and treating "could not resolve" as "probably fine" would be the skip-trapdoor this guard - /// exists to remove. - /// - internal static bool RefusesDelivery(EmailOptions options, IEmailSender? sender) - => options.Enabled && sender?.DeliversMail is not true; - - /// The one wording for the module-absent refusal, so the sender, the watchers and the - /// tests all say the same thing. - /// What is being refused (a component that will not start, or the send itself). - internal static string Explain(string what) - => $"Email:Enabled=true but the resolved {nameof(IEmailSender)} does NOT deliver mail — this " - + $"install has no mail sender. {what} Land the {SenderModule} module on this deployment " - + "(Modules:Assemblies / the plugin bundle) and RESTART the portal to restore delivery — " - + "the Email section is read once at host start and the mail watchers start with it. " - + "Refusing rather than reporting success: mail left queued is recoverable on that " - + "restart, mail stamped Sent that was never sent is not."; - - /// - /// The one wording for the INCOMPLETE-configuration refusal. Deliberately does not blame the - /// module: on this path the module may well be present and perfectly healthy — what is missing - /// is the credential the operator never set, so the message names the keys instead. Telling an - /// operator to "land the module" when the module is already landed is the kind of confidently - /// wrong diagnosis that costs an afternoon. - /// - /// The keys from ; never empty. - /// What is being refused (a component that will not start, or the send itself). - internal static string ExplainIncomplete(ImmutableArray missingKeys, string what) - => $"Email:Enabled=true but the Email section is INCOMPLETE — {string.Join(", ", missingKeys)} " - + $"{(missingKeys.Length == 1 ? "is" : "are")} not set, so no {nameof(IEmailSender)} on this " - + $"install can authenticate. {what} Set {string.Join(", ", missingKeys)}, or set " - + "Email:UseManagedIdentity=true and grant the managed identity the Mail.Send app role, " - + "then RESTART the portal to restore delivery — the Email section is read once at host " - + "start and the mail watchers start with it. Refusing rather than reporting success: mail " - + "left queued is recoverable on that restart, mail stamped Sent that was never sent is not."; - - /// - /// The refusal wording that fits THIS install — incomplete configuration when that is the - /// cause, the module-absent wording otherwise. One entry point so a caller holding only the - /// options (the no-op sender) reports the same cause the watchers logged, instead of the two - /// disagreeing about why the same install refused. - /// - internal static string ExplainRefusal(EmailOptions options, string what) - => MissingConfiguration(options) is { IsEmpty: false } missing - ? ExplainIncomplete(missing, what) - : Explain(what); - - /// - /// The watcher-side gate: answers true when this install cannot deliver mail, having - /// logged the refusal at Error, so the caller returns WITHOUT starting its watch. Queued mail - /// then visibly stays New. - /// - /// 🚨 The order of the two questions is the fix for #2510. This runs inside - /// IHostedService.StartAsync, where a throw does not degrade a feature — it aborts the - /// HOST. Asking the container first meant ACTIVATING the module's sender there, and the - /// reference sender validates its Azure credential in its constructor: an unset - /// Email:TenantId became ArgumentException: Invalid tenant id provided → - /// Hosting failed to start → a pod that never becomes ready. A half-configured optional - /// integration took down the entire portal, twice, on two rollouts. - /// - /// So the configuration is asked FIRST, from data that cannot throw. An install missing - /// its credentials is refused here and the sender is never resolved at all — which is the - /// difference between a portal that serves without mail and a portal that does not serve. Only - /// a COMPLETELY configured install goes on to ask the container, which is the question #2023 - /// needs (is the module actually here?) and the one that has no configuration-shaped answer. - /// - /// This is not a catch around the resolution, deliberately. Swallowing an - /// activation failure would also swallow the ones that mean something is genuinely broken; not - /// asking a question whose answer we already hold leaves every other failure exactly as loud as - /// it was. - /// - /// The provider the host's sender singleton lives in. - /// The bound Email section. - /// Where the refusal is reported. - /// The watcher refusing to start, named in the log line. - internal static bool RefuseToStart( - IServiceProvider services, EmailOptions options, ILogger? logger, string component) - { - var what = $"{component} will not claim or send anything, so outbound mail stays visibly queued."; - - // 1. CONFIGURATION — inert, cannot throw, cannot activate anything. - var missing = MissingConfiguration(options); - if (!missing.IsEmpty) - { - logger?.LogError( - "{Component} is NOT starting. {Explanation}", component, ExplainIncomplete(missing, what)); - return true; - } - - // 2. CONTAINER — only now, and only for an install whose configuration is complete. - if (!RefusesDelivery(options, services.GetService())) - return false; - - logger?.LogError("{Component} is NOT starting. {Explanation}", component, Explain(what)); - return true; - } -} diff --git a/memex/Memex.Portal.Shared/Email/InvitationEmailSender.cs b/memex/Memex.Portal.Shared/Email/InvitationEmailSender.cs deleted file mode 100644 index 144b97fd03..0000000000 --- a/memex/Memex.Portal.Shared/Email/InvitationEmailSender.cs +++ /dev/null @@ -1,278 +0,0 @@ -using System.Collections.Concurrent; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Data; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Memex.Portal.Shared.Authentication; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Email; - -/// -/// Mesh-driven invitation emailer. Watches Pending nodes (Admin -/// partition) that have not been emailed yet ( == null) via -/// , sends the "You've been invited" email through -/// , and stamps EmailSentAt so it never re-sends. -/// -/// Two-layer de-dup. Node-state (EmailSentAt) is the durable, cross-restart -/// guard. But the live query's snapshot LAGS the claim write — after we stamp EmailSentAt -/// the query keeps re-emitting the stale (null) node, and because each node's claim re-emits the -/// WHOLE set, a single batch would otherwise re-claim+re-send each invitation many times before -/// propagation. So an in-process single-claim guard (, an INSTANCE field — -/// mesh/process-scoped, never static) makes the first claim per path win; later stale emissions -/// short-circuit. Released only on send failure so a later tick retries. -/// -/// Decouples the invite email from the creation entry point: an invitation created from the -/// Invitations settings tab, from MCP (raw create), or from a REST call all get emailed -/// exactly once. Mirrors ; the only Task boundary is the -/// contract. Self-skips unless Email:Enabled. -/// -public sealed class InvitationEmailSender( - IServiceProvider rootServices, - IHostApplicationLifetime lifetime, - EmailOptions options, - IConfiguration configuration, - ILogger? logger = null) : IHostedService, IDisposable -{ - private const string GenericSubject = "You've been invited to Memex"; - // Cap on resolving the invited Space's display name for the email — a nice-to-have, so we fall - // back to the raw space path (never block the send) if the cross-partition read is slow. - private static readonly TimeSpan SpaceLookupTimeout = TimeSpan.FromSeconds(10); - private readonly CompositeDisposable subscriptions = new(); - // In-process single-claim guard: path → claimed. Instance (mesh/process-scoped), never static. - // Prevents the live-query snapshot lag from re-claiming+re-sending the same invitation while - // its EmailSentAt write propagates. See class summary; released on send failure to allow retry. - private readonly ConcurrentDictionary claiming = new(); - private IServiceScope? scope; - - public Task StartAsync(CancellationToken cancellationToken) - { - if (!options.Enabled) - { - logger?.LogInformation("Email disabled — InvitationEmailSender idle"); - return Task.CompletedTask; - } - - // 🚨 Same refusal as OutboundEmailSender, and it matters more here: this watcher's claim is - // Invitation.EmailSentAt, which is PERMANENT — an invitation stamped "emailed" by a sender - // that delivered nothing can never be re-sent by any code path. Not starting leaves it - // un-stamped, so delivery resumes by itself once the module lands (#2023). - if (EmailDeliveryGuard.RefuseToStart(rootServices, options, logger, nameof(InvitationEmailSender))) - return Task.CompletedTask; - - // Defer mesh access until the host is fully started (Orleans + mesh hub come up as - // hosted services too) — same rationale as OutboundEmailSender. - lifetime.ApplicationStarted.Register(BeginWatching); - return Task.CompletedTask; - } - - private void BeginWatching() - { - try - { - scope = rootServices.CreateScope(); - // Portal hub when the Blazor shell registered one; the mesh root hub otherwise. - var hub = scope.ServiceProvider.GetService()?.Hub - ?? scope.ServiceProvider.GetRequiredService(); - var sp = hub.ServiceProvider; - var query = sp.GetRequiredService(); - var meshService = sp.GetRequiredService(); - var accessService = sp.GetRequiredService(); - var emailSender = sp.GetRequiredService(); - var workspace = hub.GetWorkspace(); - var jsonOptions = hub.JsonSerializerOptions; - var baseUrl = configuration["Portal:BaseUrl"] - ?? configuration["PublicBaseUrl"] - ?? configuration["Email:WebhookBaseUrl"]; - - // Live query: ALL invitations — filter Pending + not-yet-emailed in Send. We do NOT - // filter `content.status:Pending` in the query: a Pending invitation's status is the - // enum default (0) and is OMITTED from the stored JSON, so that filter would never - // match (same reason OnboardingMiddleware/InvitationService filter status in code). - // IMeshQueryCore = the no-access-control core path (infra read). - logger?.LogInformation("InvitationEmailSender: watching invitations (baseUrl={BaseUrl})", baseUrl ?? "(none)"); - // A STANDING watch — see SubscribeInvitationWatch for why it re-opens after a fault. - subscriptions.Add(SubscribeInvitationWatch( - hub, - () => query - // PATH-scoped to Admin/Invitation. Routing is by the path's FIRST SEGMENT - // (PostgreSqlPartitionedMeshQuery.FirstSegment → schema), so `path:Admin/…` - // routes to the admin schema. A `namespace:Admin`-only query has NO path, so it - // fans out cross-schema — and the admin schema is intentionally EXCLUDED from - // that fan-out (PostgreSqlSchemaInitializer.searchable_schemas) — so it would - // never see invitations. (`namespace:Admin` is also exact-match and would miss - // the `Admin/Invitation` namespace regardless.) scope:children = the invitation - // slugs directly under Admin/Invitation. Runs as System (IMeshQueryCore + - // MeshQuery's System stamp) — no access-control filtering. See AccessControl.md. - .Query(MeshQueryRequest.FromQuery( - $"path:{InvitationNodeType.Namespace} scope:children nodeType:{InvitationNodeType.NodeType}"), jsonOptions) - .Select(change => change.Items), - items => - { - foreach (var node in items) - Send(node, meshService, accessService, emailSender, workspace, jsonOptions, baseUrl); - }, - logger)); - } - catch (Exception ex) - { - logger?.LogWarning(ex, "InvitationEmailSender: failed to start watching invitations"); - } - } - - /// - /// The invitation watch: (the live query) handed to the hub watcher, - /// every listing to . - /// - /// 🚨 A STANDING watch, so a fault must not end it. It used to be a bare - /// .Subscribe whose error arm only logged, so ONE fault of the query — a stalled provider - /// (QueryProviderStalledException), a dropped database connection — stopped every - /// invitation e-mail until the process restarted, with one Warning line as the only trace. The - /// hub watcher re-opens it on a paced cadence and ends it with the hub. A re-open's first - /// listing re-offers every un-emailed invitation; that is safe because the in-process claim - /// () and the durable EmailSentAt stamp both still decide the send. - /// - /// The hub the watch belongs to; its teardown ends the watch. - /// Opens the live query, FRESH on every call. - /// Handles one listing. - /// Records each re-open. - /// Test seam for how a re-open is scheduled; null in production. - /// The watch; disposing it ends it. - internal static IDisposable SubscribeInvitationWatch( - IMessageHub hub, - Func>> open, - Action> onItems, - ILogger? logger, - Func? scheduleReopen = null) => - ActivityControlPlaneExtensions.SubscribeHubWatcher( - hub, open, onItems, logger, "InvitationEmailSender invitation watch", - scheduleReEstablish: scheduleReopen); - - private void Send( - MeshNode node, IMeshService meshService, AccessService accessService, - IEmailSender emailSender, IWorkspace workspace, JsonSerializerOptions jsonOptions, string? baseUrl) - { - var invitation = InvitationService.TryGetInvitation(node, jsonOptions); - if (invitation is null - || invitation.Status != InvitationStatus.Pending - || invitation.EmailSentAt is not null - || string.IsNullOrWhiteSpace(invitation.Email)) - return; - - // In-process single-claim: the live query re-emits the STALE (EmailSentAt=null) node many - // times before our claim write propagates back into its snapshot (and every node's claim - // re-emits the whole set), so without this guard one batch re-sends each invitation many - // times. TryAdd makes the first claim per path win; we release only on send failure. - if (!claiming.TryAdd(node.Path, 0)) - return; - - // Claim FIRST: stamp EmailSentAt before sending so a duplicate query emission (or a - // second replica) doesn't re-send. On send failure we clear the stamp so a later tick - // retries. (Single-writer per node on the owning hub; last-write-wins is acceptable for - // the rare multi-replica race — worst case one duplicate email, never a lost one.) - var claimedAt = DateTimeOffset.UtcNow; - SetEmailSentAt(node, invitation, claimedAt, meshService, accessService) - .SelectMany(_ => ResolveSpaceName(invitation, workspace, accessService)) - .SelectMany(spaceName => - { - var (subject, html) = BuildInviteEmail(invitation, spaceName, baseUrl); - return emailSender.SendEmail(invitation.Email!, subject, html); - }) - .Subscribe( - ok => logger?.LogInformation( - "InvitationEmailSender: {Email} emailed (sent={Sent})", invitation.Email, ok), - ex => - { - logger?.LogWarning(ex, "InvitationEmailSender: send failed for {Email}", invitation.Email); - // Release the in-process claim + roll back the stamp so a later tick retries. - claiming.TryRemove(node.Path, out _); - SetEmailSentAt(node, invitation, null, meshService, accessService) - .Subscribe(_ => { }, _ => { }); - }); - } - - private static IObservable SetEmailSentAt( - MeshNode node, Invitation current, DateTimeOffset? to, - IMeshService meshService, AccessService accessService) => - // System identity: invitations live in the Admin partition that application identities - // can't write to directly (same infrastructure-write pattern as InvitationService). - Observable.Using( - () => accessService.ImpersonateAsSystem(), - _ => meshService.UpdateNode(node with { Content = current with { EmailSentAt = to } })); - - /// - /// Resolves the invited Space's display name for a space-scoped invitation (reads the Space - /// node's as System, since this hosted service has no user context). - /// Emits null for a deployment-wide invitation (no ), - /// and falls back to the raw space path if the read is slow or the node isn't visible — the - /// name is cosmetic, so we never block or fail the send on it. - /// - private static IObservable ResolveSpaceName( - Invitation invitation, IWorkspace workspace, AccessService accessService) - { - if (string.IsNullOrWhiteSpace(invitation.SpacePath)) - return Observable.Return(null); - - var spacePath = invitation.SpacePath!.Trim(); - return Observable.Using( - accessService.ImpersonateAsSystem, - _ => workspace.GetMeshNodeStream(spacePath) - .Where(n => n is not null) - .Select(n => string.IsNullOrWhiteSpace(n!.Name) ? spacePath : n.Name) - .Take(1) - .Timeout(SpaceLookupTimeout, Observable.Return(spacePath)) - .Catch(Observable.Return(spacePath))); - } - - /// - /// Builds the invitation email. When the invitation targets a Space - /// ( set) it addresses the Space by - /// (falling back to the path) and links straight to it ({baseUrl}/{SpacePath}); otherwise - /// it renders the generic deployment-wide invite. Pure + static so it is directly unit-testable. - /// - internal static (string Subject, string Html) BuildInviteEmail( - Invitation invitation, string? spaceName, string? baseUrl) - { - if (string.IsNullOrWhiteSpace(invitation.SpacePath)) - return (GenericSubject, BuildGenericHtml(baseUrl)); - - var spacePath = invitation.SpacePath!.Trim(); - var display = string.IsNullOrWhiteSpace(spaceName) ? spacePath : spaceName!.Trim(); - var spaceUrl = string.IsNullOrEmpty(baseUrl) - ? null - : $"{baseUrl!.TrimEnd('/')}/{spacePath.TrimStart('/')}"; - return ($"You've been invited to {display}", BuildSpaceHtml(display, spaceUrl)); - } - - private static string BuildGenericHtml(string? baseUrl) - => MeshWeaver.Graph.EmailTemplate.Build( - heading: "You've been invited to Memex", - paragraphs: ["You've been invited to Memex — a shared workspace for your team's content and agents."], - ctaLabel: string.IsNullOrEmpty(baseUrl) ? null : "Open Memex", - ctaUrl: string.IsNullOrEmpty(baseUrl) ? null : baseUrl, - footerNote: "Sign in with this email address to get started."); - - private static string BuildSpaceHtml(string spaceName, string? spaceUrl) - => MeshWeaver.Graph.EmailTemplate.Build( - heading: $"You've been invited to {spaceName}", - paragraphs: [$"You've been invited to \"{spaceName}\" on Memex."], - ctaLabel: string.IsNullOrEmpty(spaceUrl) ? null : $"Open {spaceName}", - ctaUrl: string.IsNullOrEmpty(spaceUrl) ? null : spaceUrl, - footerNote: "Sign in with this email address to get started."); - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public void Dispose() - { - subscriptions.Dispose(); - scope?.Dispose(); - } -} diff --git a/memex/Memex.Portal.Shared/Email/NoOpEmailSender.cs b/memex/Memex.Portal.Shared/Email/NoOpEmailSender.cs deleted file mode 100644 index bc14806838..0000000000 --- a/memex/Memex.Portal.Shared/Email/NoOpEmailSender.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Mesh; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Email; - -/// -/// The an install resolves when no real sender is registered — the -/// TryAddSingleton fallback in MemexConfiguration, taken whenever the -/// MeshWeaver.Mail.MicrosoftGraph module is not on this deployment. -/// -/// It reports false, and what it does on a send depends -/// entirely on which of TWO configurations reached it: -/// -/// -/// Email:Enabled=false — the intended case (local dev, tests, a -/// deployment with no mailbox). Nothing queues mail here, no watcher runs, and callers' reactive -/// chains complete normally: logs the would-be send and emits true. -/// 🚨 Email:Enabled=true — a MISCONFIGURED install: it is set up to send -/// mail and has nothing to send it with. Emitting true here is a lie that -/// converts into a durable one, stamping every queued mail -/// Sent while nothing leaves the process (#2023). So this path REFUSES: it logs the cause -/// at Error and surfaces the failure through OnError, which marks the mail -/// Failed — visible and re-queueable — instead of falsely -/// Sent. -/// -/// -/// The two are told apart by , which is why this type takes -/// the options rather than defaulting them: a no-op that cannot tell which case it is in cannot -/// report the right thing, and its old log line ("Email disabled (Email:Enabled=false)") stated a -/// falsehood on the second path. -/// -public sealed class NoOpEmailSender(EmailOptions options, ILogger? logger = null) : IEmailSender -{ - /// Always false — this sender's entire contract is to not deliver. See - /// for why the flag exists at all. - public bool DeliversMail => false; - - public IObservable SendEmail(string toAddress, string subject, string htmlBody) - => SendEmail(toAddress, subject, htmlBody, []); - - public IObservable SendEmail( - string toAddress, string subject, string htmlBody, IReadOnlyCollection attachments) - => SendEmail(new EmailMessage - { - To = [toAddress], - Subject = subject, - HtmlBody = htmlBody, - Attachments = attachments, - }); - - /// - /// 🚨 Overridden so a message with Cc/Bcc is handled by this sender rather than refused - /// by the interface default (#3473). - /// - /// The default refusal exists to stop a single-address transport from quietly delivering - /// a DIFFERENT message than the one it was handed. That reasoning does not apply here: this - /// sender delivers nothing at all, to anybody, and says so. Refusing copied recipients would - /// break every local-dev and test send that carries one, while protecting a delivery that is - /// not happening either way. - /// - /// What it does still do is name the FULL envelope in its log line — To, Cc and Bcc - /// counts — so a developer reading the log sees the message that would have gone out, copies - /// included, instead of a line that silently describes fewer recipients than were asked for. - /// The Email:Enabled=true refusal (#2023) is unchanged and applies to the whole - /// envelope. - /// - /// The message, its recipients, its attachments and its sender identity. - public IObservable SendEmail(EmailMessage message) - { - var recipients = string.Join(", ", message.AllRecipients); - - if (options.Enabled) - { - // ExplainRefusal, not Explain: this install may have the module and be missing only a - // credential key (#2510). Naming the module in that case sends the operator to fix - // something that is not broken. - var explanation = EmailDeliveryGuard.ExplainRefusal( - options, "This send is REFUSED rather than reported as delivered."); - logger?.LogError( - "Refusing to send to {To} (subject: {Subject}, to: {ToCount}, cc: {CcCount}, " - + "bcc: {BccCount}, attachments: {Attachments}). {Explanation}", - recipients, message.Subject, message.To.Length, message.Cc.Length, - message.Bcc.Length, message.Attachments.Count, explanation); - return Observable.Throw(new InvalidOperationException(explanation)); - } - - logger?.LogInformation( - "Email disabled (Email:Enabled=false) — skipping send to {To} (subject: {Subject}, " - + "to: {ToCount}, cc: {CcCount}, bcc: {BccCount}, attachments: {Attachments})", - recipients, message.Subject, message.To.Length, message.Cc.Length, - message.Bcc.Length, message.Attachments.Count); - return Observable.Return(true); - } -} diff --git a/memex/Memex.Portal.Shared/Email/OutboundEmailSender.cs b/memex/Memex.Portal.Shared/Email/OutboundEmailSender.cs deleted file mode 100644 index c14ea15a0b..0000000000 --- a/memex/Memex.Portal.Shared/Email/OutboundEmailSender.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Text.RegularExpressions; -using System.Text.Json; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Email; - -/// -/// Mesh-driven outbound sender — no in-memory state. The Email Router agent emits its -/// reply as an outbound node (Direction=Outbound, Status=New) -/// in the parent email's namespace; this single hosted service watches for those via -/// and hands each to its serialized , -/// which re-reads the mail's authoritative state, claims it (New → Sending), sends it through -/// , and flips it to (or -/// ). Restart-safety lives in the node's status; DEDUP lives in -/// the queue's serialize-then-re-read gate — the watch legitimately emits the same New snapshot -/// more than once, and a plain status write is not a claim (the double-delivery this fixed is -/// documented on the queue). -/// -/// Reactive; the only Task boundary is the contract. Self-skips -/// unless Email:Enabled. -/// -public sealed class OutboundEmailSender( - IServiceProvider rootServices, - IHostApplicationLifetime lifetime, - EmailOptions options, - ILogger? logger = null) : IHostedService, IDisposable -{ - /// - /// The live watch query. 🚨 It must NOT match content.status:New POSITIVELY: - /// is the enum DEFAULT (0) and the serializer OMITS it from the - /// stored JSON, so that filter never matches a freshly queued email — the exact trap - /// documents for content.status:Pending, and the - /// reason the Store contact form's notification sat queued forever on memex (2026-08-12: the - /// query with the positive status clause returned 0 rows, without it 1). - /// - /// NEGATIONS are the shape that both avoids the trap and keeps the live set BOUNDED: - /// a negation on an omitted field never excludes (verified live — -content.status:New - /// still returned the status-omitted email), so New-queued mail always matches, while - /// explicitly stamped Sending/Sent/Failed mail drops out of the set as it - /// is processed instead of accumulating forever (the Copilot review's growth concern on the - /// unfiltered form). Status is additionally re-checked IN CODE — serialized and against the - /// AUTHORITATIVE node stream — by , which is what actually - /// guards double-send (duplicate emissions of the same New snapshot are a documented property - /// of the change feed). content.direction:Outbound is a safe - /// positive match: Outbound is not the default, so it always serializes — see - /// OutboundEmailWatchQueryTest. - /// - public const string WatchQuery = - $"nodeType:{EmailNodeType.NodeType} content.direction:Outbound " - + "-content.status:Sending -content.status:Sent -content.status:Failed " - // A process-wide watch: an outbound mail is filed under whoever sent it, in any partition, - // so the ONE live subscription spans them all and says so (#3202 — fan-out is opt-in). - + ParsedQuery.CrossPartitionQualifier; - - private readonly CompositeDisposable subscriptions = new(); - private IServiceScope? scope; - - public Task StartAsync(CancellationToken cancellationToken) - { - if (!options.Enabled) - { - logger?.LogInformation("Email disabled — OutboundEmailSender idle"); - return Task.CompletedTask; - } - - // 🚨 Enabled, but is there anything that can actually DELIVER? Asked HERE, in StartAsync, - // for two reasons. It is inside the startup window, so StartupErrorNotifier folds the - // refusal into the Admin bell that reports a degraded boot — whereas ApplicationStarted - // callbacks run after that buffer has already drained. And refusing before the watch is - // even armed is what keeps queued mail at New: a watcher that starts and then declines - // per-item has already claimed (New → Sending) the first one. See EmailDeliveryGuard. - if (EmailDeliveryGuard.RefuseToStart(rootServices, options, logger, nameof(OutboundEmailSender))) - return Task.CompletedTask; - - // Defer ALL mesh access until the host is fully started. The Orleans client and the mesh - // hub come up as hosted services too; touching the hub here (or constructing - // PortalApplication, whose ctor registers an Orleans stream) races that startup and NREs - // in OrleansRoutingService.RegisterStream / PersistentStreamProvider. ApplicationStarted - // fires once every hosted service (Orleans included) has started, so the mesh is ready. - lifetime.ApplicationStarted.Register(BeginWatching); - return Task.CompletedTask; - } - - private void BeginWatching() - { - try - { - // Resolve a fresh PortalApplication in its own scope now that the mesh is up — the - // instance DI built at host-construction time may have captured a not-yet-ready hub. - scope = rootServices.CreateScope(); - // Portal hub when the Blazor shell registered one; the mesh root hub otherwise. - var hub = scope.ServiceProvider.GetService()?.Hub - ?? scope.ServiceProvider.GetRequiredService(); - var sp = hub.ServiceProvider; - var query = sp.GetRequiredService(); - var meshService = sp.GetRequiredService(); - var accessService = sp.GetRequiredService(); - var emailSender = sp.GetRequiredService(); - var jsonOptions = hub.JsonSerializerOptions; - - // The serialized send queue: re-reads each mail's CURRENT state through the shared - // per-node stream handle (authoritative — never the eventually-consistent query) before - // claiming and sending, so duplicate emissions of the same New snapshot send ONCE. - var sendQueue = new OutboundSendQueue( - readCurrent: path => hub.GetMeshNodeStream(path) - .Where(n => n is not null) - .Select(n => EmailOf(n, jsonOptions)), - writeStatus: (node, current, status) => - SetStatus(node, current, status, meshService, accessService), - send: email => emailSender.SendEmail(email.To!, OutboundSubject(email.Subject), email.Body), - logger: logger); - subscriptions.Add(sendQueue); - - // Live query: any outbound mail. Emits the current set on change; the pre-screen keeps - // the queue to plausible candidates, the queue's own re-read gate decides the send. - // A STANDING watch — see SubscribeOutboundWatch for why it re-opens after a fault. - subscriptions.Add(SubscribeOutboundWatch( - hub, - () => query - .Query(MeshQueryRequest.FromQuery(WatchQuery), jsonOptions) - .Select(change => change.Items), - items => - { - foreach (var node in items) - { - var email = EmailOf(node, jsonOptions); - if (email is null - || email.Direction != EmailDirection.Outbound - || email.Status != EmailStatus.New) - continue; - if (string.IsNullOrEmpty(email.To)) - { - logger?.LogWarning( - "OutboundEmailSender: outbound {Path} has no recipient — marking Failed", - node.Path); - SetStatus(node, email, EmailStatus.Failed, meshService, accessService) - .Subscribe(_ => { }, _ => { }); - continue; - } - sendQueue.Enqueue(node, email); - } - }, - logger)); - } - catch (Exception ex) - { - logger?.LogWarning(ex, "OutboundEmailSender: failed to start watching outbound mail"); - } - } - - /// - /// The outbound-mail watch: (the live query) handed to the hub watcher, - /// every listing to . - /// - /// 🚨 A STANDING watch, so a fault must not end it. It is the one subscription that - /// sends this process's outbound mail, held for the process's life. It used to be a bare - /// .Subscribe whose error arm only logged, so ONE fault of the query — a stalled provider - /// (QueryProviderStalledException), a dropped database connection — stopped all outbound - /// mail until the process restarted, with one Warning line as the only trace. The hub watcher - /// re-opens it on a paced cadence and ends it with the hub. A re-open's first listing re-offers - /// every still-New mail; that is safe because re-reads each mail's - /// authoritative state before claiming it, the same gate that absorbs the change feed's - /// duplicate emissions. - /// - /// The hub the watch belongs to; its teardown ends the watch. - /// Opens the live query, FRESH on every call. - /// Handles one listing. - /// Records each re-open. - /// Test seam for how a re-open is scheduled; null in production. - /// The watch; disposing it ends it. - internal static IDisposable SubscribeOutboundWatch( - IMessageHub hub, - Func>> open, - Action> onItems, - ILogger? logger, - Func? scheduleReopen = null) => - ActivityControlPlaneExtensions.SubscribeHubWatcher( - hub, open, onItems, logger, "OutboundEmailSender outbound-mail watch", - scheduleReEstablish: scheduleReopen); - - /// - /// The subject an outbound mail is sent with: a reply marker is added only when the subject - /// does not already carry one. - /// - /// 🚨 "Already carries one" is not just Re:. The check used to be - /// StartsWith("Re:"), which is right for the case this lane was built for — the Email - /// Router's reply, whose subject the agent writes as Re: <original> anyway — and - /// wrong for every outbound mail that is not a reply. A FORWARD is the one that made it visible - /// (Systemorph/MeshWeaver#2656): the inbox lane queues Fwd: Quote for 200 seats and it - /// left the building as Re: Fwd: Quote for 200 seats, which tells the recipient the - /// opposite of what happened. - /// - /// Only the LEADING marker matters: this decides whether to ADD one, not what the subject - /// means. "Question about the RE: header" is not a reply. - /// - /// 🚨 The marker set is a deliberate SECOND COPY of the one - /// EmailInboundProcessor.ThreadKey strips, and the duplication cannot be removed from - /// here today. That processor is in the MeshWeaver.Mail.MicrosoftGraph MODULE, in the - /// plugins repository, which depends on this one — the reference cannot run the other way, so a - /// shared constant would have to live in the mesh CONTRACT. Putting it there is a new framework - /// API, and a module may not call an API newer than the newest RELEASED framework without - /// raising its minMeshVersion floor; so "de-duplicate it" today means shipping a floor - /// bump for a list of natural-language prefixes that changes about never. The copies are - /// therefore left standing and each NAMES the other, so whoever edits one is told where the twin - /// is. Fold them into the contract when a floor bump is being paid for anyway. - /// - /// The subject as queued; null is treated as empty. - /// The subject to send. - internal static string OutboundSubject(string? subject) - { - // One normalisation, not two: the pattern's own `^\s*` handles the leading whitespace, so - // trimming here as well would be a second answer to the same question — and returning the - // TRIMMED string would silently rewrite a subject this method was only asked to classify. - var queued = subject ?? ""; - return ReplyOrForwardPrefix.IsMatch(queued) ? queued : $"Re: {queued}"; - } - - /// - /// Leading reply/forward markers, several languages. 🚨 Twin of the pattern in - /// EmailInboundProcessor.ThreadKey (plugins repo) — change both; see - /// for why they cannot be one. - /// - private static readonly Regex ReplyOrForwardPrefix = new( - @"^\s*(re|fwd|fw|aw|wg|tr|rv|sv|vs|antw|antwort|rif)(\s*\[\d+\])?\s*:", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - private static IObservable SetStatus( - MeshNode node, MeshWeaver.Mesh.Email current, EmailStatus to, - IMeshService meshService, AccessService accessService) => - Observable.Using( - () => accessService.ImpersonateAsSystem(), - _ => meshService.UpdateNode(node with { Content = current with { Status = to } })); - - private static MeshWeaver.Mesh.Email? EmailOf(MeshNode n, JsonSerializerOptions? options) => n.Content switch - { - MeshWeaver.Mesh.Email e => e, - JsonElement je => Safe(je, options), - _ => null - }; - - private static MeshWeaver.Mesh.Email? Safe(JsonElement je, JsonSerializerOptions? options) - { - try { return JsonSerializer.Deserialize(je.GetRawText(), options); } - catch { return null; } - } - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public void Dispose() => subscriptions.Dispose(); -} diff --git a/memex/Memex.Portal.Shared/Email/OutboundSendQueue.cs b/memex/Memex.Portal.Shared/Email/OutboundSendQueue.cs deleted file mode 100644 index 44086caee7..0000000000 --- a/memex/Memex.Portal.Shared/Email/OutboundSendQueue.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using MeshWeaver.Mesh; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Email; - -/// -/// Serializes outbound sends and re-reads each mail's CURRENT state immediately before sending — -/// the fix for the duplicate-delivery defect observed live on 2026-08-21 (every queued mail -/// arrived twice). -/// -/// Why the old claim could not work. The watch query emits the live SET on every -/// change, and the change feed legitimately delivers the same New snapshot more than once -/// (initial snapshot + change echo — the same reason tests must never assert "exactly N change -/// events"). The old New → Sending "claim" was a plain UpdateNode: last-write-wins, no -/// compare — and the node it returned always echoed Sending, so the "claim failed" guard -/// was unreachable. Two emissions of the same queued mail therefore meant two full sends. -/// -/// The shape of the fix. One instance per sender; Subject + Concat is the -/// serialization (the canonical queue idiom — order without a lock, no semaphore), and each item -/// re-reads the mail's authoritative state through the seam before touching it: a duplicate -/// emission, a stale echo, or a re-emitted snapshot finds the mail already -/// Sending/Sent/Failed and is skipped. A read that does not answer within -/// its budget SKIPS with a warning — not sending is the safe direction, the mail stays -/// New, and the next emission retries. -/// -/// What deliberately remains. Two PODS overlapping during a rollout can both read -/// New and both send — cross-process at-least-once is the accepted trade (same as the -/// retry-after-partial-failure note on ContactInquiry.SubmissionId); this class removes -/// the in-process duplicates, which are the ones users actually observed. -/// -/// Seams are delegates so the whole state machine is testable without a hub — see -/// OutboundSendQueueTest. -/// -internal sealed class OutboundSendQueue : IDisposable -{ - /// How long the authoritative pre-send read may take. It answers from the node - /// stream's current state, so anything but a prompt answer means the read path is unhealthy — - /// waiting longer cannot make sending safer. - public static readonly TimeSpan DefaultReadBudget = TimeSpan.FromSeconds(10); - - private readonly Func> readCurrent; - private readonly Func> writeStatus; - private readonly Func> send; - private readonly TimeSpan readBudget; - private readonly ILogger? logger; - - private readonly Subject<(MeshNode Node, MeshWeaver.Mesh.Email Snapshot)> queue = new(); - private readonly Subject processed = new(); - private readonly IDisposable subscription; - - /// Creates the queue. - /// Authoritative read of the mail node's CURRENT content by path. - /// When it answers, it emits the content (null = a node without readable e-mail content, which - /// is skipped). An absent or unreachable node may emit NOTHING at all — the production seam is - /// the per-node stream, which stays silent rather than emitting an absence — and the queue's - /// read budget converts that silence into a fail-closed skip. - /// Writes the given status onto the node (the System-identity write - /// lives behind this seam). - /// Performs the actual send; false or an error marks the mail Failed. - /// Budget for ; defaults to - /// . - /// Diagnostics. - public OutboundSendQueue( - Func> readCurrent, - Func> writeStatus, - Func> send, - TimeSpan? readBudget = null, - ILogger? logger = null) - { - this.readCurrent = readCurrent; - this.writeStatus = writeStatus; - this.send = send; - this.readBudget = readBudget ?? DefaultReadBudget; - this.logger = logger; - - // Concat subscribes the next item only after the previous completed — the serialization. - // Every inner observable is hardened to COMPLETE (never error), so one poisoned mail can - // never kill the queue for the ones behind it. - subscription = queue - .Select(item => Observable.Defer(() => ProcessOne(item.Node, item.Snapshot)) - .Catch((Exception ex) => - { - logger?.LogWarning(ex, - "OutboundSendQueue: processing {Path} failed unexpectedly", item.Node.Path); - return Observable.Empty(); - }) - .Concat(Observable.Defer(() => - { - processed.OnNext(item.Node.Path); - return Observable.Empty(); - }))) - .Concat() - .Subscribe( - _ => { }, - ex => logger?.LogError(ex, "OutboundSendQueue: the queue itself faulted")); - } - - /// Emits each item's node path when the queue has finished with it (sent, skipped, - /// or failed) — the deterministic completion signal the tests await. - public IObservable Processed => processed; - - /// Queues one emitted mail snapshot for a serialized, re-checked send. - public void Enqueue(MeshNode node, MeshWeaver.Mesh.Email snapshot) => - queue.OnNext((node, snapshot)); - - private IObservable ProcessOne(MeshNode node, MeshWeaver.Mesh.Email snapshot) => - readCurrent(node.Path) - .Take(1) - .Timeout(readBudget, Observable.Throw(new TimeoutException( - $"the pre-send state read for {node.Path} did not answer within {readBudget}"))) - .SelectMany(current => - { - // The gate: only a mail that CURRENTLY reads New is ours to send. Anything else — - // a duplicate emission of an already-claimed mail, a stale echo of a finished one, - // a node that vanished — is skipped, not an error. - if (current is null - || current.Direction != EmailDirection.Outbound - || current.Status != EmailStatus.New) - { - logger?.LogDebug( - "OutboundSendQueue: {Path} no longer reads as queued (status {Status}) — skipped", - node.Path, current?.Status.ToString() ?? ""); - return Observable.Empty(); - } - - return writeStatus(node, current, EmailStatus.Sending) - .SelectMany(_ => send(current) - .SelectMany(ok => - { - logger?.LogInformation( - "OutboundSendQueue: {Path} → {To} sent={Sent}", - node.Path, current.To, ok); - return writeStatus(node, current, - ok ? EmailStatus.Sent : EmailStatus.Failed); - })) - .Select(_ => Unit.Default) - .Catch((Exception ex) => - { - logger?.LogWarning(ex, - "OutboundSendQueue: send failed for {Path} — marking Failed", node.Path); - return writeStatus(node, current, EmailStatus.Failed) - .Select(_ => Unit.Default) - .Catch(Observable.Empty()); - }); - }) - .Catch((TimeoutException ex) => - { - // Fail CLOSED: without a trustworthy current state, not sending is the safe - // direction — the mail stays New and the next emission retries. - logger?.LogWarning(ex, - "OutboundSendQueue: state read for {Path} timed out — send skipped, will retry " - + "on the next emission", node.Path); - return Observable.Empty(); - }); - - public void Dispose() - { - subscription.Dispose(); - queue.Dispose(); - processed.Dispose(); - } -} diff --git a/memex/Memex.Portal.Shared/GuiShellSwitch.cs b/memex/Memex.Portal.Shared/GuiShellSwitch.cs deleted file mode 100644 index 8d577120f3..0000000000 --- a/memex/Memex.Portal.Shared/GuiShellSwitch.cs +++ /dev/null @@ -1,112 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; - -namespace Memex.Portal.Shared; - -/// -/// The per-browser GUI-shell switch for a deployment serving BOTH shells (): -/// Blazor owns /, Next lives under /next on the same host. A plain browser navigation -/// lands on ; ?gui=next / ?gui=blazor on any -/// page switches — the choice is remembered in a cookie and applied by a REDIRECT (never a proxy: -/// the ingress owns the actual routing to the Next service, exactly as today). -/// -/// Only NAVIGATIONS switch: GET, an Accept that wants text/html, and a path -/// outside every non-page surface (APIs, the circuit, static assets, auth). Everything else — the -/// mesh surfaces both shells consume — is untouched, so a Next viewer's same-host REST/gRPC-web -/// calls flow exactly as before. -/// -public static class GuiShellSwitch -{ - /// The preference cookie. Values: "next" | "blazor". - public const string CookieName = "memex-gui"; - - /// The query parameter that sets (and re-sets) the preference. - public const string QueryParam = "gui"; - - /// The Next shell's base path on the shared host (its build bakes basePath: "/next"). - public const string NextBasePath = "/next"; - - // Non-page surfaces a navigation redirect must never touch. Prefix match on the raw path. - private static readonly string[] ExcludedPrefixes = - [ - NextBasePath, - "/api", "/mcp", "/signalr", "/meshweaver.v1.Mesh", - "/_blazor", "/_framework", "/_content", - "/static", "/content", "/images", "/favicon", "/robots.txt", "/manifest", - "/login", "/logout", "/signin-oidc", "/signout", "/authorize", "/token", "/register", - "/.well-known", "/healthz", "/alive", "/ready", "/webhooks", - ]; - - /// - /// The pure decision: where should this request land, and should the cookie be (re)written? - /// Exposed for unit tests — the middleware below is just this plus HttpContext plumbing. - /// - /// redirect null = pass through; otherwise the location to 302 to. - /// setCookie null = leave the cookie alone; otherwise write this value. - public static (string? Redirect, string? SetCookie) Decide( - string method, PathString path, string? acceptHeader, string? queryGui, string? cookieGui, - string defaultShell) - { - // An explicit ?gui= always records the choice, even on excluded paths (the redirect below - // still only applies to navigations). - var setCookie = queryGui?.ToLowerInvariant() switch - { - "next" => "next", - "blazor" => "blazor", - _ => null, - }; - - if (!HttpMethods.IsGet(method)) - return (null, setCookie); - if (acceptHeader is null || !acceptHeader.Contains("text/html", StringComparison.OrdinalIgnoreCase)) - return (null, setCookie); - var raw = path.HasValue ? path.Value! : "/"; - foreach (var prefix in ExcludedPrefixes) - if (raw.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - return (null, setCookie); - - var effective = setCookie - ?? cookieGui?.ToLowerInvariant() - ?? (string.Equals(defaultShell, "Next", StringComparison.OrdinalIgnoreCase) ? "next" : "blazor"); - if (effective != "next") - return (null, setCookie); - - // Send the navigation to the same path under /next (Next is built with that basePath). - var target = NextBasePath + (raw == "/" ? "" : raw); - return (target, setCookie); - } - - /// Mounts the switch. Call only when BOTH shells are enabled. - public static IApplicationBuilder UseGuiShellSwitch(this IApplicationBuilder app, GuiFeatureOptions gui) - => app.Use(async (context, nextMiddleware) => - { - var (redirect, setCookie) = Decide( - context.Request.Method, - context.Request.Path, - context.Request.Headers.Accept, - context.Request.Query[QueryParam], - context.Request.Cookies[CookieName], - gui.Default); - if (setCookie is not null) - context.Response.Cookies.Append(CookieName, setCookie, new CookieOptions - { - Path = "/", - MaxAge = TimeSpan.FromDays(365), - HttpOnly = false, // the shells may read it to render their switch affordance - SameSite = SameSiteMode.Lax, - // Same policy as the frontend-selection cookie: Secure whenever the request - // itself is HTTPS, so the preference never rides plain HTTP on a TLS site. - Secure = context.Request.IsHttps, - }); - if (redirect is not null) - { - // Preserve the query, minus the gui parameter itself (it has done its job). - var query = QueryString.Create( - context.Request.Query.Where(kv => !string.Equals(kv.Key, QueryParam, StringComparison.OrdinalIgnoreCase)) - .SelectMany(kv => kv.Value, (kv, v) => new KeyValuePair(kv.Key, v))); - context.Response.Redirect(redirect + query, permanent: false); - return; - } - await nextMiddleware(); - }); -} diff --git a/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj b/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj deleted file mode 100644 index 8afd059ba7..0000000000 --- a/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj +++ /dev/null @@ -1,173 +0,0 @@ - - - {a1b2c3d4-e5f6-7890-abcd-ef1234567890} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/memex/Memex.Portal.Shared/MemexConfiguration.cs b/memex/Memex.Portal.Shared/MemexConfiguration.cs deleted file mode 100644 index 4e6ef94f85..0000000000 --- a/memex/Memex.Portal.Shared/MemexConfiguration.cs +++ /dev/null @@ -1,1123 +0,0 @@ -using System.Collections.Immutable; -using System.IdentityModel.Tokens.Jwt; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using Memex.Portal.Shared.Email; -using Memex.Portal.Shared.SelfUpdate; -using Memex.Portal.Shared.Settings; -using Memex.Portal.Shared.Social; -using Microsoft.Extensions.DependencyInjection.Extensions; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.ContentCollections; -using MeshWeaver.Documentation; -using MeshWeaver.Data; -using MeshWeaver.GitSync; -using MeshWeaver.Graph; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Graph.ControlLane; -using MeshWeaver.Graph.ImageClosures; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.AspNetCore; -using MeshWeaver.Hosting.Persistence; -using MeshWeaver.Hosting.Security; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Mesh.Threading; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Identity.Web; -using Microsoft.Identity.Web.UI; - -namespace Memex.Portal.Shared; - -public static class MemexConfiguration -{ - /// - /// Conditional fluent step: applies only when - /// holds — keeps feature-flagged registrations readable inside - /// long builder chains. - /// - public static T If(this T value, bool condition, Func apply) - => condition ? apply(value) : value; - - - /// - /// Says out loud, once at startup, which ACTIVATED modules did not host-load here (#2093). - /// - /// 🚨 Why this cannot be left to the module's own code. A module that does not load - /// runs nothing — including anything that would have complained. MapMeshModuleEndpoints - /// scans only LOADED assemblies, so an activated endpoint provider that never made it into the - /// process contributes no routes and its whole HTTP surface answers 404 for the pod's lifetime, - /// with no exception, no warning and nothing to grep. On memex.systemorph that was /mcp, - /// dead through two clean rolling restarts while /health and /readyz were 200 and - /// the activation record cheerfully listed the module as installed. Absence of evidence read as - /// evidence of absence, again. - /// - /// The two cases are reported differently because the remedies are: a module whose bytes - /// are on the volume is one restart from working, while a module whose landed assembly is GONE - /// is a half-completed landing that no restart repairs — that one is an ERROR naming the - /// re-install. Same reader, same wording, as the pending_module_activation health check, - /// so the pod log and the probe can never disagree. - /// - private static void ReportUnloadedActivatedModules(WebApplication app) - { - var logger = app.Services.GetRequiredService() - .CreateLogger("MeshWeaver.PluginCatalog.ModuleActivation"); - // 🚨 CONSTRUCTED, not resolved — deliberately. PendingModuleActivations is registered in - // the MESH container (AddPluginCatalog), which is why every other caller reaches it through - // `hub.ServiceProvider` and guards with GetService. Asking `app.Services` for it would - // throw at startup on any host where the two containers differ — a diagnostic that CRASHES - // the portal it exists to inform is worse than the silence it replaces. Constructing costs - // nothing and cannot differ: the reader is a stateless file reader that starts nothing and - // writes nothing, and the registration is this same one-liner over the same resolved - // module root. - var report = new PendingModuleActivations(app.Configuration) - { - // 🚨 #3538 — the modules InstallAssemblies REFUSED (link probe declined, or the - // registration threw). GetServices, never GetRequiredService: an absent registration - // means "nothing was refused", and a diagnostic that throws is worse than the silence - // it replaces — the same rule the construction above follows. - QuarantinedModules = app.Services.GetServices() - .Select(m => m.Name) - .Where(n => !string.IsNullOrWhiteSpace(n)) - .ToImmutableHashSet(StringComparer.OrdinalIgnoreCase), - // #3649 — the modules it loaded from their PREVIOUS generation: present, one version - // behind, and never "restart required". - FallbackModules = [.. app.Services.GetServices()], - // 🚨 #4550 — the two inputs that tell the boot's #4161 DECLINE from a pending update. - // Both are the SAME values the boot decided on a few seconds earlier (the - // Modules:Assemblies names, and the two readings this platform states about itself), so - // this log line and that decision cannot tell different stories. Without them a - // declined generation reads as "landed but not loaded … until a restart" — which is the - // line this pod printed, every boot, for eight modules no restart could activate. - ImageShippedModules = MeshBuilderModuleActivation.BaselineModuleNames(app.Configuration), - PlatformIdentities = - [ - PrebuiltAssemblySeeder.LiveFrameworkMvid, - MeshWeaver.Compiler.FrameworkBuildIdentity.ProducerStatedIdentity, - MeshWeaver.Compiler.FrameworkBuildIdentity.ProducerStatedProvenance, - ], - }.Read(); - - if (report.IsUndetermined) - { - logger.LogError( - "Module endpoint contributions mapped, but this pod cannot say whether an " - + "activated module failed to load: {Reason}", report.Describe()); - return; - } - - if (report.HasUnresolvable) - logger.LogError( - "🚨 {Count} ACTIVATED module(s) are not loaded in this process and NO RESTART will " - + "load them — any HTTP endpoint, view or provider they contribute is silently " - + "absent (a 404 with no error) until the package is re-installed: {Detail}", - report.Unresolvable.Count, - ModuleActivationStatus.DescribeUnresolvable(report.Unresolvable)); - - if (report.HasQuarantined) - logger.LogError( - "🚨 {Count} activated module(s) were REFUSED against this platform build and are " - + "contributing nothing — their bytes are linked against a platform this " - + "deployment is not running, so no restart loads them; the platform update that " - + "satisfies them is itself that restart (#3538): {Detail}", - report.Quarantined.Count, - ModuleActivationReport.DescribeQuarantined(report.Quarantined)); - - if (report.HasPending) - logger.LogWarning( - "{Count} activated module(s) are landed but not loaded in this process — whatever " - + "they contribute (endpoints included) is absent until a restart: {Detail}", - report.Pending.Count, ModuleActivationStatus.Describe(report.Pending)); - - // 🚨 #4550 — the landed generations this boot DECLINED in favour of the copy the image - // ships. A Warning, not an Error, and deliberately not the line above: the module IS - // running — from the image's own copy — so nothing it contributes is absent, and no - // restart and no re-install changes which generation runs. The line an operator's log - // query finds when /health names the same modules. - if (report.HasDeclined) - logger.LogWarning( - "{Count} landed module generation(s) are DECLINED in favour of the copy this image " - + "ships — the modules run, one generation is not in effect, and neither a restart " - + "nor a re-install changes that: {Detail}", - report.Declined.Count, ModuleActivationReport.DescribeDeclined(report.Declined)); - - // 🚨 #3649 — once per boot, on the logger: the loader already said it on stderr before the - // pipeline was up, and this is the line an operator's log query finds. A Warning, not an - // Error: the module is running, one version behind, and nothing here is broken. - foreach (var fallback in app.Services.GetServices()) - logger.LogWarning("[MeshWeaver.Mesh.FallbackModule] {Report}", fallback.Report()); - } - - /// - /// Fails fast on the content-storage configuration that GUARANTEES silent data loss (issue #435): - /// a DEPLOYED (non-development) FileSystem content store whose BasePath is empty or - /// relative. Such a path resolves against the container's ephemeral working directory (/app), - /// so every uploaded collection file is written to disk that vanishes on the next pod restart or - /// grain teardown — reads succeed for minutes, then the files are gone, with no signal to the user. - /// We cannot verify from code that an absolute path is a durable mount, but we CAN reject - /// the empty/relative footgun outright. A local Development run keeps the relative-to-working- - /// tree convenience (the Monolith's Storage:BasePath = "../../samples/Graph"). - /// Pure decision (no I/O) so it is unit-testable without spinning a mesh. - /// - /// The parsed Storage section, or null when unconfigured. - /// true for a local Development run (relative BasePath allowed). - /// - /// Thrown when a non-development FileSystem content store has an empty or relative BasePath. - /// - - public static void ValidateContentStorageDurability( - ContentCollectionConfig? contentStorageConfig, bool isDevelopment) - { - // Development resolves a relative BasePath against a stable working tree — that's the intended - // local convenience, not the ephemeral-container footgun. Nothing configured → nothing to guard. - if (isDevelopment || contentStorageConfig is null) - return; - // Only the FileSystem store roots files on a local path. AzureBlob/other stores don't use - // BasePath as a filesystem root (it's a blob prefix), so the durability concern doesn't apply. - if (!string.Equals(contentStorageConfig.SourceType, "FileSystem", StringComparison.OrdinalIgnoreCase)) - return; - - var basePath = contentStorageConfig.BasePath; - var isEmpty = string.IsNullOrWhiteSpace(basePath); - // An absolute path is the operator's chosen mount; code can't verify it's durable, so allow it. - if (!isEmpty && Path.IsPathRooted(basePath)) - return; - - throw new InvalidOperationException( - "Content storage misconfiguration (issue #435): Storage:SourceType is 'FileSystem' but " - + (isEmpty ? "Storage:BasePath is empty." : $"Storage:BasePath ('{basePath}') is relative.") - + " A FileSystem content store with an empty or relative BasePath resolves against the " - + "container's ephemeral working directory, so uploaded collection files are written to " - + "storage that is SILENTLY LOST on the next pod restart or grain teardown. Set " - + "Storage:BasePath to an ABSOLUTE path backed by a durable volume (e.g. '/mnt/content' on " - + "a PersistentVolumeClaim), or use Storage:SourceType 'AzureBlob'. Refusing to start so the " - + "misconfiguration surfaces now rather than after users have uploaded and lost files."); - } - - - extension(TBuilder builder) where TBuilder : MeshBuilder - { - /// - /// Configures the mesh with Graph domain only. - /// - /// Configuration is read from appsettings: - /// - Graph:Storage:Type - Storage type: "FileSystem", "AzureBlob", "PostgreSql", "Cosmos" - /// or "Snowflake". Cosmos and Snowflake are BOOT PACKS: their factories register only - /// when the matching DLL (MeshWeaver.Hosting.Cosmos / .Snowflake) is listed under - /// Modules:Assemblies — installation runs before this selection, so ordering is safe. - /// - Graph:Storage:BasePath - Base path for FileSystem storage - /// - Graph:Storage:ConnectionString - Connection string for AzureBlob/Cosmos - /// - storage - Content collection configuration (Name, SourceType, BasePath) - /// - public TBuilder ConfigureMemexMesh(IConfiguration configuration, bool isDevelopment = false) - { - // The shared artifact store keeps one bundle store per runtime identifier, and a record that - // takes part in it renders PreWarm:PrebuiltBundleRoot with a {rid} placeholder - // (Doc/Architecture/SharedArtifactStore). Expanded FIRST, before anything reads the key, to - // this process's own identifier; a root without the placeholder is untouched. - MeshWeaver.Hosting.BundleRootRuntimeIdentifier.ExpandConfigured(configuration); - // …and the shared MODULE tier beside it (increment 3), expanded to this runtime's store. - MeshWeaver.Hosting.BundleRootRuntimeIdentifier.ExpandConfiguredKey( - configuration, SharedModuleTier.ConfigKey); - // Boot-time module packs: DLL paths listed under Modules:Assemblies are loaded into - // the default ALC BEFORE the container builds, and their MeshNodeProviderAttribute - // registrations (services + nodes + hub configuration) fold into this mesh — the - // per-deployment "which packs does this instance run" knob (Doc/Architecture/ - // UiExtensibility). Empty/absent = no-op; a listed path that fails to load should - // fail loudly at startup, never silently run without the pack. - // - // #1664 step 9 — the effective set is the appsettings baseline ∪ the ENABLED entries - // of the modules/activation.json sidecar (store-installed modules landed by - // ModuleLandingService), deduped by name. Sidecar entries are guarded by ONE rule: a - // missing DLL SKIPS the entry with a loud stderr line — never a crash, the deployment - // must boot. 🚨 A declared minMeshVersion FLOOR above the running platform is NOT a - // skip since #3648 — it is announced on stdout as an advisory ("declares platform ≥ - // X; running Y") and the entry is handed to the loader, whose link probe - // (MeshBuilder.InstallAssemblies) measures whether the bytes load. The floor skip is - // the line that held every production portal on the morning build for all of - // 2026-09-07 (ci < rc in the comparator) while every module would have linked. A - // landed module's built-against framework identity is not a LOAD gate here either — - // modules bind by simple name across platform builds, and the strict identity gate is - // the NodeType bake lane's. (It is not merely diagnostic, since #3154: - // ModuleUpdateDecision compares it to tell a rebuild from a no-op, and #3211 makes a - // bundle that states none unpublishable. That is the UPDATE question, not this one.) - // Pre-DI, so diagnostics go to stderr (pod stdout/stderr ship to Loki regardless). - var moduleAssemblies = configuration.GetSection("Modules:Assemblies").Get(); - // 🚨 The SAME root ModuleLandingService writes (ModuleRoot) — never - // AppContext.BaseDirectory directly. They must name one directory: a landed module - // read from somewhere else is simply invisible, and on a deployment whose /app is - // read-only the writer cannot use AppContext.BaseDirectory at all. - var moduleRoot = ModuleRoot.Resolve(configuration); - // The shared module tier of THIS module root (increment 3), set BEFORE the activation - // sidecar below resolves a single generation directory — per root, so another mesh in the - // same process keeps its own. Unset → no tier, and every resolution is the local one it was. - if (SharedModuleTier.Configure(moduleRoot, configuration) is { Root: { } sharedModules } tier) - Console.WriteLine( - $"[ModuleActivation] shared module tier: {sharedModules} " - + (tier.Publishes - ? "(this instance OWNS the store: landings publish there)" - : "(read-only: sealed generations there are used, nothing is written)")); - - // The instance manifest (#2550) — read BEFORE anything else needs it, on the same - // writable root the module activation sidecar lives on, and for the same reason it is - // a file: at this point there is no storage provider, hub or connection string, and - // WHICH STORAGE TO USE is precisely what it answers. - // - // A corrupt manifest resolves to InstanceManifest.Unreadable rather than null: it - // EXISTS, so the instance is not treated as never-configured, and it answers nothing, - // so it cannot leave setup. Offering a fresh setup over a database that already holds - // data is the failure that distinction prevents. - var setupManifest = InstanceManifest.Read(moduleRoot, - msg => Console.Error.WriteLine($"[InstanceSetup] {msg}")); - // Generations GC — delete only what NO activation entry references and nothing holds - // open (skip-on-locked). Landing never deletes anything on a shared volume; this is - // the one reclaim point. See ModuleLandingService.CollectGarbage. - // - // 🚨 Registered, NEVER run here (#2684). It used to be a synchronous call at this exact - // spot — before the host listened — and on an Azure Files (CIFS) /data the - // rename-then-recursive-delete of orphaned generations is one SMB round-trip per file: - // minutes of uninterruptible IO (PID 1 in `Dsl` at wchan=wait_for_response), no - // listener on :8080, so the 300 s startup probe killed a pod the kernel could not even - // deliver the kill to, and the roll looped (memex-cloud, ci.6559). Housekeeping must - // not gate readiness: the hosted service runs the SAME pass, with the SAME semantics, - // after ApplicationStarted, through the file-system IIoPool. It stays ahead of the - // awaiting-setup early return below on purpose — an instance parked in setup still - // reclaims its garbage, exactly as the synchronous call did. - builder.ConfigureServices(services => services.AddModuleGenerationsGc(moduleRoot)); - // The sibling collector for the CI-published prebuilt-bundle store on the same volume - // (2026-09-08: 482 identity directories / 13.4 GiB filled the 16 GiB share). Same - // shape — registered here, run after ApplicationStarted behind the bake, on the - // file-system IIoPool — then recurring daily. Inert without PreWarm:PrebuiltBundleRoot. - // The rule is PrebuiltBundleStore's: prune what nothing references, never by age alone. - builder.ConfigureServices(services => services.AddPrebuiltBundleRetention(configuration)); - // On a control instance the Deployment records' pins and the registered instances' - // reports are references too: a remote instance pulls exactly its own identity's seal - // over the HTTP prebuilt surface, however old that build is (fabrikam on 3.0.0-ci.8080). - builder.ConfigureServices(services => services.AddSingleton( - sp => DeploymentPinnedReferences.SourceFor(sp))); - var persistedActivation = ModuleActivationSidecar.Read(moduleRoot, - msg => Console.Error.WriteLine($"[ModuleActivation] {msg}")); - - // 🚨 #3395 — CONVERGE ON THE MESH'S MODULE SET, never on this process's own snapshot of - // the activation record. The record moves per module, the instant each landing finishes, - // so reading it directly makes the module set a function of WHEN this pod happened to - // boot: three pods of one ReplicaSet differed in 39 of 40 generations on memex-cloud - // (2026-09-06), and a pod booting mid-wave pinned a TORN mix no wave ever intended. - // The set is proposed ONCE per completed landing wave (ModuleSetStore.Propose), so - // every boot between two waves loads identical bytes. A deployment that has never - // completed a wave has no set, and the projection is then the identity — pre-#3395 - // behaviour, which is what makes this inert until the first wave. - var meshModuleSets = ModuleSetStore.Read(moduleRoot, - msg => Console.Error.WriteLine($"[ModuleSet] {msg}")); - Console.WriteLine($"[ModuleSet] {ModuleSetStore.Describe(meshModuleSets)}"); - var activationOnMeshSet = ModuleActivationBoot.ProjectOntoMeshSet( - persistedActivation, - meshModuleSets.Proposed, - (module, reason) => Console.Error.WriteLine( - $"[ModuleSet] DEFERRED store-installed module '{module}': {reason}"), - // The SAME existence check boot's own gate applies below — so the projection can - // never hand the gate a generation the gate would then skip, leaving the module - // absent when an older one is sitting right there. - entry => ModuleActivationBoot.LandedModuleDllExists(moduleRoot, entry), - (module, reason) => Console.Error.WriteLine( - $"[ModuleSet] DEGRADED module '{module}': {reason}")); - var effectiveModules = ModuleActivationBoot.ComputeEffectiveModuleEntriesAgainstImage( - moduleAssemblies, - activationOnMeshSet, - // The ONE wording of the declared floor (ModulePlatformFloor) — ADVISORY since - // #3648: it names both versions for the line below and skips nothing. - ModulePlatformFloor.DeclineReason, - // 🚨 The entry's OWN landed directory SPECIFICALLY — modules// - // .dll — never ResolveModulePath, whose BaseDirectory fallback would let a - // sidecar entry with a lost modules/ folder silently bind a same-named app-closure - // DLL instead of being skipped. Baseline entries below keep ResolveModulePath (both - // locations are legitimate for them). Passing the ENTRY rather than the name is - // what makes this gate agree with the resolver below: landing writes generations - // (modules/@/) and moves the entry pointer, so a name-only check found - // nothing for ANY generation-landed module and boot skipped every store module on - // the deployment while its bytes sat correctly on disk (#1949). - entry => ModuleActivationBoot.LandedModuleDllExists(moduleRoot, entry), - (module, reason) => Console.Error.WriteLine( - $"[ModuleActivation] SKIPPED store-installed module '{module}': {reason}"), - // 🚨 #3648 — stdout, not stderr: an advisory is not a fault. It says what the - // module's author claimed; the link probe in InstallAssemblies says whether it - // loads, and THAT line is the one to read when it does not. - (module, advisory) => Console.WriteLine( - $"[ModuleActivation] store-installed module '{module}' {advisory}"), - // 🚨 #4161 — the framework identity THIS image was built with, which is what - // decides between the image's copy of a module and a store copy of the same name. - // Declines nothing on its own: only a store copy that STATES a different identity - // AND is shadowing a module this image ships, in which case the image's copy runs - // and the line above says so. - // - // 🚨 #4550 — BOTH readings, because a platform states its build in two schemes and - // a bundle can only ever state ONE. LiveFrameworkMvid is the API-SURFACE identity - // (s) this image compiles content against — the reading every other consumer - // shares (the bundle client, the bake, the deployment report). Every module-pack - // lane instead states the PRODUCER reading of the platform it packed against - // (g, read off that platform's MeshWeaver.Compiler.dll), which is - // FrameworkBuildIdentity.ProducerStatedIdentity here. Handing this gate the surface - // identity ALONE made the comparison unequal for every pair that exists in the - // fleet: measured on memex.systemorph.com 2026-09-16, eight modules declined - // against s4b2836… while stating a core commit, re-landed by every reconcile, - // re-declined by every boot, and reported as "a restart activates them" across a - // restart that could not clear one of them. With both stated, a bundle packed by - // the build that produced this image MATCHES and is adopted. - // - // 🚨 Since the compatibility key (policy platform-backwards-compatibility) both of - // those readings ARE the key (c003e001), so a module packed against ANY build of - // this epoch matches. The third reading is the TRANSITION: a bundle packed before - // the key states this image's own g provenance, and still matches its build. - [ - PrebuiltAssemblySeeder.LiveFrameworkMvid, - MeshWeaver.Compiler.FrameworkBuildIdentity.ProducerStatedIdentity, - MeshWeaver.Compiler.FrameworkBuildIdentity.ProducerStatedProvenance, - ], - // 🚨 MeshWeaver#6044 — what the image's OWN copy of each module was built as (the - // module.seed.json stamp the closure lane writes beside it). Under the compatibility - // key the identity above is one value for a whole epoch, so it no longer tells an - // older store copy from the image's newer one; the version does. A store copy of an - // image-shipped module overrides only as a strictly newer release. No stamp (an - // image from before it) decides nothing. - ImageModuleSeed.OfImageCopy); - // 🚨 A LISTED-BUT-ABSENT module must never crash boot. `InstallAssemblies` does - // `Assembly.LoadFrom`, which throws FileNotFoundException, so one stale line in - // `Modules:Assemblies` takes the whole portal down before anything is serving — - // observed on 3.0.0-rc5, whose image no longer ships the fourteen extracted modules - // while appsettings still listed them: every boot died on - // `Could not load file or assembly '/app/MeshWeaver.AI.OpenAI.dll'`. - // - // The sidecar half already skips a missing DLL loudly (LandedModuleDllExists above); - // the appsettings BASELINE did not, and a baseline entry is exactly the one a platform - // change can invalidate without touching the deployment. So the same rule applies to - // both: skip, say so on stderr, and boot. A module that is genuinely required makes - // itself known as a missing FEATURE, which is diagnosable — a portal that will not - // start is not. - void WarnPin(string msg) => Console.Error.WriteLine($"[ModuleActivation] {msg}"); - var loadableModules = effectiveModules - // 🚨 ONE resolution, shared with the existence gate above - // (ModuleActivationBoot.ResolveLoadPath): a store-landed module resolves to the - // directory ITS activation entry points at — the generation the landing wrote — - // and a baseline entry keeps ResolveModulePath's probes. The provenance comes from - // the union itself rather than being re-derived here; the gate and the resolver - // each deciding for themselves where a module's bytes live is exactly #1949. - // - // 🚨 #2509: a store-landed generation is then PINNED — copied to process-local - // storage and loaded from there. The shared modules/ tree has reference-set - // lifetime: an auto-update that lands a newer generation makes THIS pod's loaded - // one unreferenced, and a sibling pod's boot GC reclaims it while this process - // still lazily loads dependency DLLs from it (first chat after that was - // FileNotFoundException 'OpenAI' — the 2026-08-27 outage). Baseline entries stay - // un-pinned: they resolve into the image's own immutable closure. - // - // 🚨 #3649: a store-landed entry that still holds its PREVIOUS generation hands - // the loader a way back — resolved (and pinned) LAZILY, only when the head - // generation is refused before loading or fails to load. Pinning every previous - // generation up front would double the per-boot copy for a path taken only when - // something is wrong. The loader then runs the previous generation, records a - // FallbackModule, and says so on stderr; the module is present, one version - // behind, instead of absent. - .Select(module => ( - Module: module, - Path: module.Landed is not null - ? ModuleGenerationPin.PinnedLoadPath(moduleRoot, module.Landed, onWarn: WarnPin) - : ModuleActivationBoot.ResolveLoadPath(moduleRoot, module), - Previous: module.Landed is { } landed - && ModuleActivationBoot.PreviousGeneration(landed) is { } previous - && ModuleActivationBoot.LandedModuleDllExists(moduleRoot, previous) - ? previous - : null)) - .Where(candidate => - { - if (File.Exists(candidate.Path)) - return true; - Console.Error.WriteLine( - $"[ModuleActivation] SKIPPED module '{candidate.Module.Entry}': no assembly at " - + $"'{candidate.Path}'. It is listed in Modules:Assemblies but this image " - + "does not ship it — delist it, or install it as a module. Booting without " - + "it; whatever it provided is absent."); - return false; - }) - .ToArray(); - // 🚨 #3735: and the IMAGE-SHIPPED copy the landed entry displaced, as the LAST step of - // the fallback order. The union above substitutes a landed store entry in place of - // the same-named Modules:Assemblies entry on the DLL's existence alone; whether it - // loads is measured by the link probe inside InstallModules, and until this the - // image's copy was simply gone from the list by then — so a refused store generation - // SHADOWED the baseline that loads by construction (memex.systemorph.com, - // 2026-09-08: MeshWeaver.Blazor.Views, every skinned control on FallbackHtml). - // Resolved through MeshBuilder.ResolveModulePath WITHOUT the module root — the image's - // own modules/ folder and app closure, never the landed tree — because the baseline is - // by definition what the image ships. No pin: the image closure is immutable. - var resolvedModules = loadableModules - .Select(candidate => new ModuleInstallCandidate(candidate.Path) - { - Version = candidate.Module.Landed?.Version, - PreviousVersion = candidate.Previous?.Version, - Previous = candidate.Previous is { } previous - ? () => ModuleGenerationPin.PinnedLoadPath(moduleRoot, previous, onWarn: WarnPin) - : null, - ImageBaseline = candidate.Module.BaselineEntry is { } baseline - && MeshBuilder.ResolveModulePath(baseline) is { } imageCopy - && File.Exists(imageCopy) - ? imageCopy - : null, - }) - .ToArray(); - - // 🚨 #2223 — SAY WHICH COPY IS BEING LOADED. A view-pack fix can merge, build, land in - // the module store and still not run, because a baseline Modules:Assemblies entry - // resolves to the IMAGE copy and dedupes the store entry away by name. Every lane - // reported green; the only evidence lived in /proc/1/maps on a prod pod. This reports - // the paths that are about to be loaded — the SAME array, so the line and the load - // cannot disagree — and warns when the store holds a newer, different copy. It warns - // and boots: a pod that refuses to start cannot be given the fix for what is wrong - // with it. - ModuleLoadReport.Write( - ModuleLoadReport.Describe(moduleRoot, - loadableModules.Select(candidate => (candidate.Module, candidate.Path))), - Console.WriteLine, - Console.Error.WriteLine); - - // 🚨 #2507 — HAND THE CONFIGURATION TO THE BUILDER BEFORE ANYTHING INSTALLS. A module - // attribute's BuilderConfigurations run inside InstallAssemblies and read - // builder.Configuration for the deployment's answers; this method had every answer in - // its `configuration` parameter and never passed it on, so every module folding on a - // PORTAL saw null — AiMeshModuleAttribute.ServeFromPartitions(null) put the whole AI - // catalog on the in-memory path and skipped the AI content sources on both prods, - // while the deployed config plainly listed the partitions. InstallConfiguredModules - // (the tester/LocalMesh path) already hands it over, which is exactly why the defect - // was portal-specific. Unconditional: modules are not the only readers, and a null - // Configuration must mean "the deployment supplied nothing", never "the host forgot". - builder.WithConfiguration(configuration); - if (resolvedModules.Length > 0) - builder.InstallModules(resolvedModules); - - // 🚨 #3395 — say, once and durably, that a replica came up on the mesh's set. Deferred - // to a hosted service rather than written here, because the claim is "this set is - // RUNNING", not "this set was read" — a boot that dies before InstallAssemblies must - // not close the convergence window it never entered, and #3478 is the same rule one - // step further: a process its OWN validation refuses never serves anything either, so - // it must not make the claim. ModuleSetAdoptionService offers the write to the - // mesh-admission gate, which holds it while the bake is still forming a verdict, writes - // it when the bake passes and never writes it when the bake refuses the image. On every - // deployment that has not armed the bake readiness gate — the chart default — the gate - // is Unarmed and the write happens inline on that service's StartAsync, milliseconds - // later in boot than it did here. Create-if-absent, so the second and every later - // replica writes nothing, and best-effort — a read-only volume costs the mesh-level - // signal and nothing else. - // 🚨 #3649 — the adoption records what this process ACTUALLY LOADED per module: the - // set's generation where it loaded, the previous one where the loader fell back. Read - // off the container, because the FallbackModule records exist only after - // InstallModules ran; so the mesh's records say what runs, and the GC references it. - if (meshModuleSets.Proposed is { } adoptedSet) - builder.ConfigureServices(services => services.AddModuleSetAdoption( - $"module set {adoptedSet.Sequence} ('{adoptedSet.Id}')", - sp => ModuleSetStore.RecordAdoption(moduleRoot, adoptedSet, - ModuleSetStore.RunningGenerationsOf(adoptedSet, sp.GetServices()), - adoptedBy: Environment.MachineName, - onWarn: msg => Console.Error.WriteLine($"[ModuleSet] {msg}")))); - // 🚨 #3650 — the boot that falls back WRITES THE MARKER the update reconcile reads: - // for every store entry the loader was handed (the union above, so its Directory is - // the generation actually tried), the FallbackModule / IncompatibleModule records say - // whether that generation loaded here, and the verdict lands on that module's own - // sidecar marker (activation.d/.unloadable). Without it the reconcile's - // fallback branch (ModuleUpdateDecision, an entry in fallback re-examining every new - // build of its version) could never fire — #3665 records what RUNS on the module set's - // adoption, once per set, which survives a platform roll unchanged; this is re-measured - // by every boot. Unconditional, unlike the adoption: a deployment with no proposed set - // yet still measures its heads. - var triedEntries = loadableModules - .Select(candidate => candidate.Module.Landed) - .Where(landed => landed is not null) - .Select(landed => landed!) - .ToArray(); - if (triedEntries.Length > 0) - builder.ConfigureServices(services => - services.AddModuleLoadabilityRecord(moduleRoot, triedEntries)); - // Restart-as-activation: this boot IS the restart the sidecar was waiting for — - // consume the pending flag so the step-10 signal reads current. Best-effort: on a - // read-only app filesystem the flag simply stays set (cosmetic), and boot proceeds. - // 🚨 Clears the MARKER, never rewrites the activation record (#2090). Rewriting the - // whole list here meant every replica's boot read-modify-wrote a file the other - // replicas were reading — on a rolling restart that is several writers and several - // readers on one SMB file at once, which is how a boot read came back - // FileNotFoundException and the pod started with NONE of its store modules (#2189). - if (persistedActivation.PendingRestart) - try - { - ModuleActivationSidecar.SetPendingRestart(moduleRoot, false); - } - catch (Exception ex) - { - Console.Error.WriteLine( - $"[ModuleActivation] could not reset PendingRestart ({ex.GetType().Name}: " - + $"{ex.Message}) — the flag stays set; activation itself is unaffected."); - } - - // The first-run setup surface's three inputs, registered HERE — after - // InstallAssemblies (so a module's assembly attribute has already registered its keyed - // storage factory) and BEFORE the storage decision below, whose "awaiting setup" branch - // returns early. A registration after that return would exist on a configured instance - // and be missing on precisely the instance that needs it. - builder = (TBuilder)builder.ConfigureServices(services => - { - // Read off the collection NOW: an IServiceProvider can resolve a keyed service you - // name but cannot enumerate the keys, so this set is unrecoverable later. - services.TryAddSingleton(Setup.StorageBackendCatalog.Discover(services)); - // A factory, so IsAwaitingSetup is read after the whole builder has run — the two - // hosts call this method on opposite sides of their portal configuration. - services.TryAddSingleton(_ => Setup.InstanceSetupStatusAccessor.For(builder)); - services.TryAddSingleton(); - // The approval step-up's Entra discovery/JWKS managers — one per tenant for the - // life of the mesh (Doc/Architecture/ApprovalStepUp). - services.TryAddSingleton(_ => new Authentication.EntraMetadataCache()); - return services; - }); - - // Read graph storage config — from this host's configuration, or from the INSTANCE - // MANIFEST an earlier setup wrote (#2550). - // - // 🚨 Configuration WINS. The manifest answers what configuration has not already said; - // it never overrides a host that stated its own storage. Every deployment that exists - // today is appsettings-configured and has no manifest, and this path must leave all of - // them byte-identical — an additive mechanism that changed a configured instance's - // storage would be a data-loss bug, not a feature. - // 🚨 The section is bound ONLY when the raw Graph:Storage:Type key actually names a - // backend. `GraphStorageConfig.Type` carries the initializer "FileSystem", so binding a - // section that exists but states no type hands back a working-looking file-system - // configuration — and the section legitimately exists for other reasons: the deployed - // image states Graph:Storage:UnanchoredQueryPolicy there so no chart or environment can - // forget it (the ci.7658 503 incident). Without this test, that one query-policy line - // would silently mean "storage is configured": MarkAwaitingSetup is never reached, the - // first-run wizard becomes unreachable, and a real instance is pointed at - // container-ephemeral disk — issue #435's shape, arriving from a file nobody edited. - var graphStorageConfig = - string.IsNullOrWhiteSpace(configuration["Graph:Storage:Type"]) - ? null - : configuration.GetSection("Graph:Storage").Get(); - // 🚨 COMPLETE, and with a real backend named (Copilot review). A manifest that exists - // is not a manifest that ANSWERS: the wizard's own starting point is - // State=AwaitingStorage with a pre-filled type, and an Unreadable one answers nothing - // at all. Treating either as configured storage would boot past setup and fail later, - // deeper, on an unknown backend or a missing connection string — a worse failure than - // the setup surface, and further from its cause. - if (graphStorageConfig is null - && setupManifest is { State: InstanceSetupState.Complete } complete - && complete.HasStorage - && complete.Storage is { } chosen) - { - graphStorageConfig = new GraphStorageConfig - { - Type = chosen.Type, - BasePath = chosen.BasePath, - ConnectionString = chosen.ConnectionString, - }; - Console.WriteLine( - $"[InstanceSetup] storage '{chosen.Type}' comes from the instance manifest at " - + $"{InstanceManifest.PathFor(moduleRoot)} (set up " - + $"{setupManifest.SetUpAt?.ToString("u") ?? "at an unrecorded time"}" - + $"{(setupManifest.SetUpBy is { } who ? $" by {who}" : "")})."); - } - - if (graphStorageConfig == null) - { - // 🚨 NOT a throw any more (#2550). An image with no storage configured is an - // instance AWAITING SETUP, and the whole point of the setup wizard is that such an - // image comes up far enough to ask which database to use. Throwing here is what - // made "install the empty image, then configure it" impossible: the process died - // before anything could serve, so there was nowhere to ask the question. - // - // The caller decides what to do with this state; it is reported, never guessed at. - // A SETUP mesh serves the wizard and nothing else — it must never quietly invent a - // storage backend, because a wrong guess writes real data somewhere nobody chose - // (an ephemeral container path, silently lost on the next roll — issue #435's - // shape). - // Say WHICH of the three states this is — "no manifest" would be a lie for an - // unreadable or half-answered one, and setup/boot diagnostics are exactly where a - // misleading message costs the most (Copilot review). - var manifestState = setupManifest switch - { - null => "no instance manifest exists there", - { State: InstanceSetupState.Unreadable } => - "the instance manifest there could NOT BE READ (see the error above) — " - + "repair or delete it to re-run setup", - { State: var state } => - $"the instance manifest there is INCOMPLETE (state {state}" - + $"{(setupManifest.HasStorage ? "" : ", no storage chosen")}) — finish the " - + "setup wizard", - }; - Console.Error.WriteLine( - "[InstanceSetup] No Graph:Storage configuration, and " - + $"{manifestState} ({InstanceManifest.PathFor(moduleRoot)}). This instance is " - + "AWAITING SETUP. Configure Graph:Storage in appsettings, or complete the " - + "setup wizard, which writes the manifest and restarts into a configured mesh."); - builder.MarkAwaitingSetup(); - return builder; - } - - // Resolve relative BasePath to absolute - var basePath = graphStorageConfig.BasePath; - if (!string.IsNullOrEmpty(basePath) && !Path.IsPathRooted(basePath)) - { - basePath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), basePath)); - graphStorageConfig = graphStorageConfig with { BasePath = basePath }; - } - - // In development, format JSON for readability - if (isDevelopment) - { - var settings = graphStorageConfig.Settings != null - ? new Dictionary(graphStorageConfig.Settings) - : new Dictionary(); - settings["FormatJson"] = "true"; - graphStorageConfig = graphStorageConfig with { Settings = settings }; - } - - // Read content collection storage config from appsettings - var contentStorageConfig = configuration.GetSection("Storage").Get(); - // 🚨 Fail fast on the guaranteed-silent-data-loss footgun (issue #435) BEFORE the - // relative→absolute resolution below would MASK it: a deployed FileSystem content store - // with an empty/relative BasePath resolves against the ephemeral container CWD (/app), - // so uploaded collection files vanish on the next pod restart / grain teardown. - ValidateContentStorageDurability(contentStorageConfig, isDevelopment); - // 🚨 Fail fast on a Microsoft tenant that cannot form an OIDC authority (#2621). An - // env var cannot be null, only empty, so a blank key legitimately means "unset" and - // passes; a value that is not a single authority segment composes - // login.microsoftonline.com//... — a URL Entra never serves — and used to surface as an - // unhandled 500 on the FIRST sign-in, naming the URL instead of the key to fix. Named - // here at boot instead. - MicrosoftTenant.Validate(configuration[MicrosoftTenant.ConfigurationKey]); - // 🚨 Fail fast on an install that CLAIMS mail it cannot send (#2636, #2637): - // Email:Enabled=true with the section incomplete. Same two rules as the tenant guard - // above — a blank/disabled section is "never configured" and passes (aborting on an - // unconfigured optional integration is the #2510 failure), while enabled-but-incomplete - // is a real misconfiguration and is named here. On memex it produced a portal that - // served perfectly, reported mail as ON, and dropped every invitation and notification - // into a queue nobody watches. Inert configuration data only — nothing is resolved and - // no credential is constructed, which is the property #2510 turned on. - EmailConfigurationGuard.Validate(configuration); - if (contentStorageConfig != null) - { - // Resolve relative path to absolute - if (!string.IsNullOrEmpty(contentStorageConfig.BasePath) && !Path.IsPathRooted(contentStorageConfig.BasePath)) - { - contentStorageConfig = contentStorageConfig with - { - BasePath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), contentStorageConfig.BasePath)) - }; - } - - // Ensure Settings are populated for AzureBlob source type - if (contentStorageConfig.SourceType == "AzureBlob") - { - var settings = contentStorageConfig.Settings is { } existing - ? new Dictionary(existing) - : new Dictionary(); - if (!settings.ContainsKey("ContainerName")) - settings["ContainerName"] = "content"; - if (!settings.ContainsKey("ClientName")) - settings["ClientName"] = contentStorageConfig.Name; - contentStorageConfig = contentStorageConfig with { Settings = settings }; - } - } - - // The content storage as a pre-configured store for storage bindings - // (Doc/Architecture/StorageBindings): on a FileSystem content store every Space's - // collection lives under {BasePath}/content/{Space} (below), so the directories there are - // the store's containers and a partition's default is its own. A blob content store is - // registered by the host that owns the blob client. - if (contentStorageConfig is { BasePath: { Length: > 0 } contentBase } - && string.Equals(contentStorageConfig.SourceType, "FileSystem", StringComparison.OrdinalIgnoreCase)) - { - var contentRoot = Path.Combine(contentBase, "content"); - builder.ConfigureServices(services => services.AddSingleton(sp => - new MeshWeaver.Graph.Storage.DirectoryInstanceStore( - "content", "Content storage (files)", contentRoot, - [MeshWeaver.Mesh.Storage.StoragePurpose.Originals], - sp.GetRequiredService()))); - } - - // Use partitioned persistence for FileSystem to support per-org partitions - var usePartitioned = string.Equals(graphStorageConfig.Type, "FileSystem", StringComparison.OrdinalIgnoreCase) - && !string.IsNullOrEmpty(graphStorageConfig.BasePath); - - // Deploy-time feature flags (symmetric with ConfigureMemexServices). - var features = configuration - .GetSection(MemexFeatureOptions.SectionName) - .Get() ?? new MemexFeatureOptions(); - - // Static-repo → DB sync: partitions to materialize into + serve from the DB. For a - // synced partition the read-only in-memory static provider is skipped (PG serves it) - // and the import runs on boot. Empty (default) = in-memory serving everywhere, no - // import — no regression. Default Helm sets ["Doc","Agent","Provider","Harness","Skill"]. - var syncPartitions = features.StaticRepoSync.Partitions - .ToHashSet(StringComparer.OrdinalIgnoreCase); - // NB: the AI partitions are expanded to the whole bundle by the AI module itself - // (AiMeshModuleAttribute.ServeFromPartitions), next to the sources that rule gates. - IReadOnlySet serveFromPartition = syncPartitions; - - MeshBuilder mb = builder - // Configure persistence from Graph:Storage section. - // Skip if any IPartitionStorageProvider was already registered upstream - // (e.g., AddPartitionedPostgreSqlPersistence in Memex.Portal.Distributed/Program.cs). - .ConfigureServices(services => - { - if (services.Any(sd => sd.ServiceType == typeof(IPartitionStorageProvider))) - return services; - - return usePartitioned - ? services.AddPartitionedFileSystemPersistence(graphStorageConfig.BasePath!) - : services.AddPersistence(graphStorageConfig); - }) - // Enable Row-Level Security for access control - .AddRowLevelSecurity() - // Configure graph from the same base path - .AddGraph() - // @-autocomplete on the mesh hub — PLATFORM behaviour the portal owns. It used to ride - // the Blazor.Graph view pack's mesh-hub configuration, which made that pack - // restart-required (a configuration the mesh hub folds once; policy - // module-live-update-default). Idempotent: both registrations dedupe. - .ConfigureHub(hub => hub.AddMeshNavigation()) - // Plugin catalog: registers the Package/PluginCatalog content types + (below) the - // platform-admin "Plugin Catalog" settings tab — NOT a browsable Plugins Space. This - // instance ALSO acts as the registry: /api/plugins serves its configured source - // (PluginCatalog:SourceRepoPath — the plugins repo) to other installations, and the - // admin tab installs from PluginCatalog:RegistryUrl. The options carry the consumer's - // registry URL/ref (empty RegistryUrl -> the tab shows a "not configured" note). - .AddPluginCatalog() - // Red-log ticketing rides the MeshWeaver.Observability MODULE - // (ObservabilityProviderAttribute → AddLogWatch(); LogWatchOptions binds through - // the options pipeline). The DETECTOR is not here either way — it is a separate - // service in the cluster's monitoring namespace that polls Loki and POSTs to - // /api/log-incidents (Doc/Architecture/LogWatchTriage.md); the compiled endpoint - // resolves the ILogIncidentIngest Contract seam optionally. - // Bind the whole section so the multi-registry list (PluginCatalog:Registries:N:*) - // binds alongside the legacy single RegistryUrl/RegistryRef pair. - .ConfigureServices(pcs => pcs.AddSingleton( - configuration.GetSection(PluginCatalogOptions.SectionName).Get() - ?? new PluginCatalogOptions())) - // Register GitHub-sync content types (GitHubCredential / GitHubSyncConfig) - // on the mesh + per-node hubs so their config nodes (de)serialize. - .AddGitHubSyncTypes() - // The instance-sync content type ({space}/_Sync/{sourceId}) and the SignalR - // mesh-transport services are registered by the portal composition in - // MeshWeaver.Plugins (ConfigureMemexPortal) — host business, composed where the - // hosts live. - // Register the OAuthCode NodeType + AuthorizationCode content type so the - // MCP OAuth server (OAuthCodeStore) can persist pending authorization codes - // as Admin/OAuthCode/{hashPrefix} mesh nodes — the replica-safe store every - // pod shares (the /token exchange may land on a different replica than the - // /authorize that minted the code). Without this the create fails with - // "NodeType 'OAuthCode' is not registered" and no MCP client can connect. - .AddOAuthCodeType() - // Seed root-scope Admin AccessAssignments for users listed under - // `Auth:GlobalAdmins` so configured admins bypass per-partition - // RLS for cross-partition operations (list Spaces, create - // a new Space, etc.). Empty / missing section = no-op. - .AddMeshNodes(Authentication.GlobalAdminSeed.Build(configuration)) - // The platform's settings-tab menu entries (What's New / About / Privacy and the - // admin tabs Invitations / Inbox / Updates / Published / Token Usage) as seeded - // UiContribution nodes — the WS7 lane a plugin's own settings tab arrives through. - .AddPlatformSettingsTabContributions() - // The AI menu's navigation entries (Threads/Models/Tiers/Providers/Agents/Skills) - // as seeded UiContribution nodes — same lane a plugin's AI-menu entry (or a whole - // TopBar-declared menu) arrives through. Only the imperative "New thread" stays - // compiled (AiMenuItems). - .AddSpaceType() - // Generic webhook inbox: the WebhookEvent node type behind - // POST /api/hooks/{target} (allowlisted via WebhookInbox:Targets). - .AddWebhookInbox() - // An image's closure as mesh data (#4066, policy image-closure-as-mesh-data): the - // ImageClosure node type, and the ingest that turns the CD build's signed record into - // Admin/ImageClosures/{repository}-{digest}. Registered everywhere, armed only where - // WebhookInbox:Targets lists Admin/ImageClosures WITH a SecretConfigKey. - .AddImageClosures() - // Secrets a global admin may ENTER in the portal instead of an operator minting - // them in a vault (Doc/Architecture/InstanceSecrets). Each slot is a key the code - // below READS: the self-updater's signing key (GENERATED at /Admin/Settings/ControlLane, - // on a target instance) and a deployment's own announcement key as the control inbox - // verifies it (REGISTERED at /Hosting/Integrations/Deployment/{id}, on the control - // instance, from the value the deployment generated). The fleet-wide - // inbox secret itself is NOT a slot, so the portal can never replace it. - .AddInstanceSecretSlot(SelfUpdate.SelfUpdateHandover.SecretKey) - .AddInstanceSecretSlot(SelfUpdate.SelfUpdateHandover.LocalSecretKey + ":*") - // A package update held on its declared minMeshVersion floor is ticketed to the - // control instance as ONE blocking event, on the self-update announcement's own - // channel (policy package-min-mesh-version) — never a silent hold. - .ConfigureServices(services => services - .AddSingleton()) - // The control→instance lane (Doc/Architecture/ControlLane): POST /api/control-lane - // on a TARGET, and the signing half on the control instance. Registered on every - // portal and armed on none until ControlLane:Key + Hosting:Deployment are set. - .AddControlLane(); - // Courses are fully node-native: the Edu pack owns the types (Edu/Lesson, - // Edu/Module, Edu/Exercise, Edu/Quiz, Edu/CourseInvite, Edu/CourseCatalog) AND - // the whole-course navigation (EduCourseNavigationProvider, registered per-hub - // by the type configuration lambdas). The compiled MeshWeaver.Courses types had - // zero instances in any repo or reachable mesh and are deleted. - - // The gRPC mesh transport is a MODULE (MeshWeaver.Hosting.Grpc.dll under - // Modules:Assemblies — GrpcMeshModuleAttribute folds AddGrpcHub over this builder: - // the transport services + the py/node stream-routed participant address types; its - // GrpcModuleAttribute maps the meshweaver.v1.Mesh endpoint via - // MapMeshModuleEndpoints). 🚨 DEFAULT-ON in every deployment: the endpoint is the - // React GUI's browser data plane (grpc-web Connect+Deliver at the origin root), not - // just the foreign-participant (py/*, node/*) transport — delist only where there is - // no React GUI and no foreign participant. The former Features:Grpc flag is gone; - // the module listing IS the switch. Only the pipeline-order-bound gRPC-web - // middleware stays compiled (UseMeshWeaverGrpcWebWhenInstalled, below). - - // Each AI provider self-registers everything (catalog source + - // IOptions binding + IChatClientFactory) via one builder extension. - // The Models settings tab + the ModelProviderService read these out - // of the live LanguageModelCatalogOptions — no central registry. - // Gated by deploy-time feature flags (symmetric with the services-tier - // AddCopilot/AddClaudeCode in ConfigureMemexServices). A disabled flag - // drops the catalog source → the provider vanishes from the model - // picker and its Model/ nodes never seed. - // Language-model providers + CLI harnesses register via boot-loaded module packs - // (Modules:Assemblies -> each pack's MeshNodeProviderAttribute). The composition root - // carries NO provider type references any more; a deployment picks providers by - // editing its module list. Features:Ai flags remain only for the portal-side blocks - // that co-host CLI processes (Connect, skills sync). - - // Content → vector index is a MODULE now (MeshWeaver.ContentCollections.Indexing. - // PostgreSql in Modules:Assemblies — PostgresContentIndexingModuleAttribute). Its - // activation is decided at RESOLVE time on the same conditions this block used to - // check at compose time (mesh Postgres connection + Embedding:Endpoint/ApiKey + a - // registered IEmbeddingProvider); unconfigured deployments stay inert exactly as - // before. The image describer rides the AI package (AddAgentChatServices TryAdds the - // optional IImageDescriber off the default multimodal model). - - return (TBuilder)mb - .AddSelfRegistry() - .AddDocumentation(serveFromPartition) - .AddStaticRepoSync(serveFromPartition, features.StaticRepoSync.Modes) - // Ship compiled releases WHEREVER we ship code NodeTypes — Doc AND the sample - // partitions (ACME, FutuRe, Northwind, Cornerstone, MeshWeaver). Pre-build every - // shipped code NodeType's release at boot, as System, so the runtime path is a - // cache hit and no user navigation ever triggers an on-demand compile (the prod - // 2026-06-18 phantom _Activity/compile-* storm). Idempotent (skips already-built - // types); off the thread pool so it never blocks startup. - .ConfigureServices(services => - services.AddHostedService()) - // The registry's module-published broadcast (#3650): the publish route tells every - // registered consumer the moment a bundle lands on the shelf. Registered on every - // portal — it is inert where no instance is registered — because whether THIS - // deployment is a registry is a runtime fact (a publish token configured), not a - // composition-time one. - .ConfigureServices(services => - { - Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions - .TryAddSingleton(services); - return services; - }) - // Markdown export (PDF/DOCX/HTML + share-by-email) rides the - // MeshWeaver.Markdown.Export MODULE (MarkdownExportProviderAttribute → - // AddMarkdownExport(); node seeding is IfAbsent so the lane switch is idempotent). - // Azure Blob support (the stream-provider factory, the blob assembly cache, the - // blob NuGet cache) RELOCATED to the MeshWeaver.Azure.Blob MODULE — its assembly - // attribute registers the stream-provider factory when landed, and the - // Azure-backend branches reach the store types by probe-and-delegate. Nothing to - // register here: a filesystem deployment carries no Azure SDK at all now. - // Shared NodeType assembly cache (versioned, cross-replica consistent): the - // TryAdd below yields to the Distributed app's filesystem store on the - // self-host branch, exactly as the compiled-in registration always did. - .ConfigureServices(services => - { - Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions - .TryAddSingleton(services, sp => - { - var type = Type.GetType( - "MeshWeaver.Azure.Blob.BlobAssemblyStore, MeshWeaver.Azure.Blob", - throwOnError: false) - ?? throw new InvalidOperationException( - "No IAssemblyStore is registered and the MeshWeaver.Azure.Blob " - + "module is not landed — register AddFileSystemAssemblyStore " - + "(self-host) or land the AzureBlob package (Azure backend)."); - var cacheDir = System.IO.Path.Combine( - System.IO.Path.GetTempPath(), "meshweaver-assembly-cache"); - // The client type reflects too — this project no longer references the - // Azure SDK; the module's assembly (already probed above) carries it. - var clientType = Type.GetType( - "Azure.Storage.Blobs.BlobServiceClient, Azure.Storage.Blobs", - throwOnError: true)!; - return (MeshWeaver.Mesh.Services.IAssemblyStore)Activator.CreateInstance( - type, - Microsoft.Extensions.DependencyInjection.ServiceProviderKeyedServiceExtensions - .GetRequiredKeyedService(sp, clientType, "nodetype-cache"), - "nodetype-cache", - cacheDir, - sp.GetRequiredService( - typeof(Microsoft.Extensions.Logging.ILogger<>).MakeGenericType(type)))!; - }); - return services; - }) - // Register the mesh catalog and its public interfaces - .ConfigureServices(services => services.AddMeshCatalog()) - // Configure default views and content collections for each node hub - // Each hub gets its own "content" collection pointing to a subdirectory - .ConfigureDefaultNodeHub(config => - { - // Declared before the if-block so it's available for both the "content" - // collection mapping below and the "attachments" mapping further down. - var nodePath = config.Address.ToString(); - - // Content lives ONCE per Space (partition root), NOT on every node. A child-node - // path (e.g. "AgenticPension/Dokument") must not get its own content collection — - // it inherits the Space's via ExposeInChildren below. Mounting per-child created - // overlapping/orphaned collections (content/{space}/{child}/…) and node-level content - // refs; indexing is likewise per-Space (one content_chunks table per partition schema). - // Gate on the partition root: a single-segment node path (no '/'). - if (contentStorageConfig != null && !nodePath.Contains('/')) - { - // Scope static media (SVG, PNG, JPG) to the Space's content subdirectory. - var contentSubdir = $"content/{nodePath}"; - // Combine with original BasePath for FileSystem; for AzureBlob, subdirectory is the blob prefix - var basePath = string.IsNullOrEmpty(contentStorageConfig.BasePath) - ? contentSubdir - : Path.Combine(contentStorageConfig.BasePath, contentSubdir); - var nodeContentConfig = contentStorageConfig with - { - Name = "content", - IsEditable = true, - ExposeInChildren = true, - // isStatic: PUBLISHED on the access-controlled content route — a Space's - // images, thumbnails, PDFs and videos are fetched as - // /api/content/{node}/{file} and /api/content/{Space}/content/{file}. - // Publishing decides REACHABLE, never READABLE: every request is still - // gated on Read of the owning node (issue #587). - IsStatic = true, - BasePath = basePath, - Settings = contentStorageConfig.Settings is { } src - ? new Dictionary(src) { ["BasePath"] = basePath } - : new Dictionary { ["BasePath"] = basePath } - }; - config = config.AddContentCollection(_ => nodeContentConfig); - } - - // Map "attachments" to "storage" with per-node subdirectory - // (needed by FutuRe and other samples that store datacube.csv, etc.). - // isStatic: the file browser's download links are - // /api/content/{node}/attachments/… — access-controlled, gated on Read of the node. - config = config.MapContentCollection( - "attachments", "storage", $"attachments/{nodePath}", isStatic: true); - - // Shared large static assets (e.g. the on-device Whisper models the native client - // downloads) live in a FileSystem content collection on the MeshWeaver space, backed - // by a read-only AKS file-share mount (StaticAssets:Path). This is the framework-native - // way — it gives the upload UI + get/list + content serving for free, and the native - // VoiceModelCatalog downloads from the content URL (…/MeshWeaver/static/Speech/…). It's - // a no-op when the mount isn't configured (local dev, tests). - var staticAssetsMount = configuration["StaticAssets:Path"]; - if (!string.IsNullOrWhiteSpace(staticAssetsMount) && nodePath == "MeshWeaver") - config = config.AddContentCollection(_ => new ContentCollectionConfig - { - Name = "static", - SourceType = "FileSystem", - BasePath = staticAssetsMount, - Address = config.Address, - IsEditable = true, - ExposeInChildren = true, - // isStatic: the native client downloads these over - // /api/content/MeshWeaver/static/Speech/… — a real publication, still - // gated on Read of the MeshWeaver node. - IsStatic = true, - Settings = new Dictionary { ["BasePath"] = staticAssetsMount }, - }); - - return config - .WithHeartBeatHandler() // silently ack heartbeats on every per-node hub - .AddDefaultLayoutAreas() - // The course-shell areas (StartExercise / GoToMyCopy / CourseNav / Learn) - // The three PERSONAL tabs — API tokens, Connected instances (register your - // own MeshWeaver installation, get it an instance key) and Notifications - // (bell/email per category) — ride every node hub but are tabs of the PERSON - // APP (PersonApp.AddPersonAppTab): they show on the viewer's own - // /{user}/Settings only, and an old link on any other page redirects there. - .AddApiTokensSettingsTab() - // Security: how this person confirms approvals (passkey / authenticator app). - .AddStepUpSettingsTab() - .AddInstancesSettingsTab() - .AddNotificationsSettingsTab() - // The AI top-bar menu entry ("New thread") is GUI: its action is a click-time - // sentinel resolved in the circuit, so the portal GUI module registers it via - // its MeshNodeProviderAttribute rather than core naming a Blazor type. - // (The platform-admin Instances overview — live cluster query, Grafana links, - // create-instance plan generator — rides the MeshWeaver.SelfUpdate.Aks module, - // which registers its own settings tab on the per-node hub.) - // The platform's global settings tabs ride the UiContribution lane (WS7): - // What's New / About / Privacy (slice 2) plus the Administration tabs — - // Invitations + Inbox (invitation-only onboarding, non-user mail), Updates - // (the Admin/UpdatePolicy auto-update strategy), Published to the web (the - // /sitemap.xml enumeration, rendered) and Token Usage (per-model _Usage - // analytics) — all in slice 4. Content stays compiled, exposed here as - // layout areas; the menu entries are seeded UiContribution nodes - // (AddPlatformSettingsTabContributions on the mesh builder above). - .AddPlatformSettingsTabAreas() - // The Admin app's landing tab (/Admin): this instance's facts + About. - // Inert on every hub but the Admin app's (AdminAppNodeType.AddAdminAppTab). - .AddAdminAppOverviewTab() - // GitHub Sync tab — the Space ROOT's settings only (self-filtered on Space, - // RestrictSettingsTabsToPartitionRoot for the descendants). - .AddGitHubSyncSettingsTab() - // GitHub Issues & PRs tab — browse/act on the repo's issues + pull requests. - .AddGitHubIssuesTab() - // NO Plugin Catalog tab. Browsing and provisioning packages is the STORE's - // job (/Store → the package card → Provision), and it is the only surface - // that runs the install under the System identity via SystemInstall. A - // second admin-only page onto the same registry duplicated that flow while - // bypassing the funnel it exists to enforce. - // Coupons tab (platform admins only) — the Store's typed coupon codes at - // Admin/Coupons: live list, redemption tallies, create/open. - .AddCouponAdminSettingsTab() - // Instance grants (platform admins only) — which plugins each registered - // MeshWeaver installation may pull. Registration is self-service; granting - // is not, and the grants live in the Admin partition out of the owner's reach. - .AddInstanceGrantAdminSettingsTab() - // Composition (platform admins only) — WHY this environment carries what it - // carries: the deployment's feature flags (Features:Flags:*, which also - // decide what it pre-installs) and the parameters its installed packages - // declare, with the exact env var to provision for any this environment does - // not supply. Read-only: composition arrives through the values file, and a - // browser edit would be reverted by the next helm upgrade. - .AddCompositionAdminSettingsTab() - // Instance Sync lives in the "Synchronizations" NODE-menu item (not a - // settings tab) — composed by the portal composition in MeshWeaver.Plugins (ConfigureMemexPortal → AddInstanceSyncTypes). - // Code workspace tab — on-disk working-tree editor (checkout/edit/commit/push). - .AddWorkingTreeTab() - // Git history tab — read-only git browser (commit log + changes + diffs) over the same working tree. - .AddGitHistoryTab(); - // The Content Indexing tab rides the indexing MODULE - // (PostgresContentIndexingModuleAttribute's default-node-hub hook) — it - // appears exactly when the deployment lists the pipeline. - }) - // MemexClient node type — per-installation client config under {user}/Client/{id}. - .AddMemexClientType() - // Platform self-update: the Admin/UpdatePolicy node + the poller that watches ACR and - // (on Kubernetes) patches the portal+migration deployments to the newest version per - // policy. On a non-k8s host it degrades to detect-and-notify. See ReleaseStrategy.md. - .AddSelfUpdate() - ; - // (The platform-admin Instances feature — InstancesOptions plus the live cluster-query - // service — rides the MeshWeaver.SelfUpdate.Aks module: it is AKS-specific, while the - // self-update POLLER above stays here because self-update is how a deployment receives - // new bits, modules included.) - } - - } - - -} diff --git a/memex/Memex.Portal.Shared/MemexFeatureOptions.cs b/memex/Memex.Portal.Shared/MemexFeatureOptions.cs deleted file mode 100644 index 5d8c9ad448..0000000000 --- a/memex/Memex.Portal.Shared/MemexFeatureOptions.cs +++ /dev/null @@ -1,215 +0,0 @@ -using MeshWeaver.Mesh; - -namespace Memex.Portal.Shared; - -/// -/// Deploy-time capability toggles for a Memex deployment, bound from the -/// Features configuration section. These declare which capabilities a -/// deployment ships — independent of whether a given key happens to be present. -/// A disabled flag is the operator's intent and wins even if a key is configured. -/// -/// All flags default to true so an absent Features section preserves -/// the current behaviour (no regression for existing deployments). Operators turn -/// capabilities OFF explicitly. The env-var form -/// (Features__Ai__Providers__OpenAI=false) flows identically through ACA env, -/// compose .env, and ARM createUiDefinition → container env. -/// -public sealed record MemexFeatureOptions -{ - public const string SectionName = "Features"; - - public AiFeatureOptions Ai { get; init; } = new(); - - /// Which GUI shells this deployment serves — see . - public GuiFeatureOptions Gui { get; init; } = new(); - - /// Static-repo → DB sync: which partitions are materialized into and served from the DB. - public StaticRepoSyncFeatureOptions StaticRepoSync { get; init; } = new(); - - /// User self-provisioning (open vs closed registration). - public OnboardingFeatureOptions Onboarding { get; init; } = new(); - - /// Orleans clustering provider selection (membership store). - public OrleansFeatureOptions Orleans { get; init; } = new(); - - /// - /// SignalR mesh transport (/signalr) for external participants — native clients, and a - /// future native iOS portal. On by default (like the other flags); an operator can close the - /// connection surface with Features:SignalR=false. See Doc/Architecture/SignalRMeshParticipant. - /// - public bool SignalR { get; init; } = true; - - // The gRPC mesh transport (meshweaver.v1.Mesh + gRPC-web — foreign-language participants - // AND the React GUI's browser Connect+Deliver split) is a MODULE now: the former - // Features:Grpc flag is gone, and listing/delisting MeshWeaver.Hosting.Grpc.dll under - // Modules:Assemblies is the switch (GrpcMeshModuleAttribute + GrpcModuleAttribute; - // no org deployment ever set the flag). See Doc/Architecture/Modules. - - /// - /// True when the deployment ships at least one in-process API provider OR one - /// co-hosted CLI. When false, the portal has no built-in chat capability via - /// catalog sources (users may still bring their own keys via ModelProviders) — - /// surfaced as a startup warning, not a hard failure. - /// - public bool HasAnyChatCapability => Ai.Providers.HasAny || Ai.Clis.HasAny; -} - -/// -/// Static-repo → DB synchronization. Selects which partitions' build-time static content -/// (embedded docs, built-in agents, the model catalog) is materialized into and served from -/// the database partition via the static-repo import on boot — instead of the in-memory -/// read-only static provider. For a listed partition the import registers an -/// IStaticRepoSource, the read-only StaticNodePartitionStorageProvider is NOT -/// registered (so Postgres serves + accepts the import's writes), and ImportAll runs after -/// schema provisioning. See Doc/Architecture/StaticRepoImport.md. -/// -/// Empty (default) = no sync: every partition keeps the in-memory static provider, no DB -/// import — i.e. current behaviour, no regression. The default Helm deployment sets -/// ["Doc","Agent","Provider","Harness","Skill"]. Gated, not global — the monolith (no -/// Postgres) leaves this empty and keeps in-memory serving. -/// -public sealed record StaticRepoSyncFeatureOptions -{ - /// - /// Partition names to materialize into + serve from the DB (e.g. "Doc", "Agent", - /// "Provider"). Matching is case-insensitive. "Provider" is the model catalog - /// (providers + models + policy); the legacy "Model" name is still honoured as an alias. - /// Empty = no sync. - /// - public string[] Partitions { get; init; } = []; - - /// - /// Optional per-partition override, keyed by partition name - /// (matched case-insensitively at read). Selects what a partition's import PRUNES after upserting - /// the source: FullReplace (mirror — prune all extras), Additive (prune only - /// previously-shipped nodes, so user-added nodes survive), or UpsertOnly (never prune). A - /// partition NOT listed uses its source's own default — FullReplace for most, but the built-in - /// AI catalogs (Skill/Agent/Provider/Harness) default to Additive. Bind by name, e.g. - /// Features__StaticRepoSync__Modes__Skill=UpsertOnly. Distinct from the per-NODE - /// SyncBehavior, which still claims individual nodes in any mode. - /// - public Dictionary Modes { get; init; } = new(); - - /// True when is configured for DB sync. - public bool Includes(string partition) => - Partitions.Any(p => string.Equals(p, partition, StringComparison.OrdinalIgnoreCase)); -} - -/// -/// Controls whether a brand-new authenticated user may self-provision their own -/// account + per-user partition through the /onboarding flow (open vs -/// closed registration). -/// -public sealed record OnboardingFeatureOptions -{ - /// - /// When true (default — current behaviour, no regression), any newly - /// authenticated user without an Active User node may self-onboard. When - /// false, registration is closed: self-onboarding is refused with a - /// "contact your administrator" message instead of materialising the user. - /// - /// First-user bootstrap exception: a brand-new deployment with - /// ZERO existing User nodes always lets the very first user onboard (and - /// become platform admin) even when this flag is false — otherwise the - /// platform would lock out with no administrator. The exception reuses the - /// existing "no existing User nodes" detection in the onboarding flow. - /// - public bool AllowSelfOnboarding { get; init; } = true; - - /// - /// When true, onboarding is allowed ONLY for an email that has an outstanding - /// () . - /// An admin issues invitations from the "Invitations" settings tab; the invited person is - /// emailed and, when they sign in via the IdP, the verified email is matched against an - /// outstanding invitation. Any non-invited email is refused at the onboarding gate. - /// - /// Independent of : when invitation-only is on it is the - /// binding gate (an invited email onboards even if self-onboarding is also disabled). The - /// first-user bootstrap exception still applies — a brand-new deployment with zero - /// existing User nodes always lets the very first user onboard so the platform never locks out. - /// Default false preserves current behaviour. - /// - public bool InvitationOnly { get; init; } = false; -} - -/// -/// Selects the Orleans cluster-membership provider. Bound from -/// Features:Orleans:Clustering. Values: -/// -/// AzureTables (default) — Aspire-injected Azure Table Storage membership -/// (the ACA / Marketplace path; the silo relies on the Aspire Orleans integration). -/// AdoNet — PostgreSQL-backed membership on the separate orleans database -/// (real clustering for self-host / HA). The silo calls UseAdoNetClustering against the -/// Aspire-injected ConnectionStrings:orleans; the migration creates the membership tables. -/// Localhost — single in-process silo (local dev only; never production). -/// -/// -public sealed record OrleansFeatureOptions -{ - public string Clustering { get; init; } = "AzureTables"; -} - -public sealed record AiFeatureOptions -{ - /// In-process API providers (one flag each). - public AiProviderFeatureOptions Providers { get; init; } = new(); - - /// Co-hosted CLI providers (Claude Code, GitHub Copilot). - public AiCliFeatureOptions Clis { get; init; } = new(); -} - -public sealed record AiProviderFeatureOptions -{ - public bool Anthropic { get; init; } = true; - public bool AzureFoundry { get; init; } = true; - public bool AzureOpenAI { get; init; } = true; - public bool OpenAI { get; init; } = true; - - /// - /// The generic OpenAI-compatible custom-URL provider — the "type" a user picks in - /// Settings → Language Models to bring any OpenAI-wire endpoint (OpenRouter, Groq, - /// Together, a local vLLM, …) by base URL + key. No system default; always user-supplied. - /// - public bool OpenAICompatible { get; init; } = true; - - /// - /// OpenRouter — the OpenAI-wire model gateway at https://openrouter.ai/api/v1. - /// Ships a system-default endpoint but no model ids (auto-listed/added later as mesh - /// data). Requires an API key. Rides the OpenAI-compatible factory. - /// - public bool OpenRouter { get; init; } = true; - - public bool HasAny => Anthropic || AzureFoundry || AzureOpenAI || OpenAI || OpenAICompatible || OpenRouter; -} - -public sealed record AiCliFeatureOptions -{ - public bool ClaudeCode { get; init; } = true; - public bool Copilot { get; init; } = true; - - public bool HasAny => ClaudeCode || Copilot; -} - -/// -/// The GUI SHELLS a deployment serves, and which one owns the front door. Both default ON so an -/// absent section preserves today's behaviour; a deployment composes its portal by turning shells -/// off (Features__Gui__Blazor=false → a NEXT-ONLY portal: the host serves the mesh surfaces -/// — REST, gRPC-web, SignalR, MCP, auth, static assets — and NO Razor-components pipeline, no -/// circuit, no Blazor view registry; the Next.js app is the whole GUI). -/// When BOTH are on, picks the shell a plain browser navigation lands -/// on, and the viewer can switch per-browser with ?gui=next / ?gui=blazor (a cookie -/// remembers the choice — see GuiShellSwitch). Next is served under /next on the -/// same host (ingress-routed in k8s; PORTAL_ORIGIN rewrites in local dev), so the switch is -/// a redirect, never a proxy. -/// -public sealed record GuiFeatureOptions -{ - /// Serve the Blazor shell (Razor components + circuit + Blazor view registry). - public bool Blazor { get; init; } = true; - - /// The Next.js shell participates (under /next); enables the shell switch. - public bool Next { get; init; } = true; - - /// The shell a plain navigation lands on when both are enabled: "Blazor" or "Next". - public string Default { get; init; } = "Blazor"; -} diff --git a/memex/Memex.Portal.Shared/Pages/ErrorRoutes.cs b/memex/Memex.Portal.Shared/Pages/ErrorRoutes.cs deleted file mode 100644 index eb3ba1fc46..0000000000 --- a/memex/Memex.Portal.Shared/Pages/ErrorRoutes.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Memex.Portal.Shared.Pages; - -/// -/// The single definition of the production error route. -/// -/// Why this constant exists. The portal wires -/// app.UseExceptionHandler(ErrorRoutes.Path) in production only, and for a long time the -/// path it named — "/Error", written as a string literal — was served by nothing: -/// no page, no controller, no endpoint anywhere in the solution. So every unhandled server-side -/// exception in production was re-executed onto a route the Blazor router could not match, and the -/// user got a BLACK SCREEN with no message, no status and no request id. Nothing failed to compile -/// and nothing went red, because a route expressed as a literal in one file and as a page in -/// another has no link between the two. It never showed up in development either — the handler is -/// registered only when !IsDevelopment(), so locally you get the developer exception page. -/// -/// Both sides now read this constant ( in the pipeline, -/// @attribute [Route(ErrorRoutes.Path)] on the page), so a rename breaks compilation rather -/// than silently shipping a blank screen. ErrorPageRouteTest pins that a component actually -/// claims it. -/// -public static class ErrorRoutes -{ - /// The path the production exception handler re-executes onto. - public const string Path = "/Error"; -} diff --git a/memex/Memex.Portal.Shared/README.md b/memex/Memex.Portal.Shared/README.md deleted file mode 100644 index b09e5346e8..0000000000 --- a/memex/Memex.Portal.Shared/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Memex.Portal.Shared - -## Overview -Memex.Portal.Shared is a Razor class library containing the shared configuration, authentication, UI pages, and domain types used by both the monolith and distributed Memex portal deployments. It centralizes all portal-level concerns so that deployment topology is the only difference between hosting modes. - -## Features -- **Portal configuration** (`ConfigureMemexServices`, `ConfigureMemexMesh`, `ConfigureMemexPortal`) — wires up Blazor, AI providers, graph, documentation, persistence, and content collections -- **Authentication** — supports dev login, Microsoft Identity (Entra ID), Google, LinkedIn, Apple, and API token auth for MCP -- **Space integration** — composes around the `Space` NodeType shipped by `MeshWeaver.Blazor.Portal` (custom layout areas, access rules, post-creation handlers); per-tenant partition schemas are auto-created lazily on first write via `public.ensure_partition_schema` -- **UI pages** — Login, DevLogin, Onboarding, Welcome, Search, and the root `App.razor` / `Routes.razor` -- **AI integration** — Azure Foundry Claude, Azure OpenAI, Copilot, Claude Code, and web search plugin registration - -## Integration -- Referenced by [Memex.Portal.Monolith](../Memex.Portal.Monolith/) and [Memex.Portal.Distributed](../aspire/Memex.Portal.Distributed/) -- Depends on core MeshWeaver libraries: Blazor, Graph, AI, Documentation, Hosting, and Kernel -- Uses [Microsoft.FluentUI](https://github.com/microsoft/fluentui-blazor) for the component framework - -## See Also -Refer to the [main MeshWeaver documentation](../../Readme.md) for the overall architecture. diff --git a/memex/Memex.Portal.Shared/SelfUpdate/ComboClearance.cs b/memex/Memex.Portal.Shared/SelfUpdate/ComboClearance.cs deleted file mode 100644 index 8894c4ed9e..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/ComboClearance.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System.Collections.Immutable; -using MeshWeaver.PluginCatalog; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// -/// What the combo gate says about ONE candidate for THIS instance — the roll-side reading of a -/// . -/// -/// 🚨 The three verdicts are never conflated, and neither are the two ways there can be no -/// answer. is the ONLY thing that produces -/// — an absent gate, an absent verdict, a faulted producer and a -/// verdict all land on a state that grants nothing. -/// That is the anti-trapdoor property of this type: no configuration, no missing service and no -/// exception can manufacture a clearance. -/// -public enum ComboClearanceKind -{ - /// - /// 🚨 Nobody has asked. No verdict exists for this candidate on this instance — the - /// combo gate is not registered on this host, or no producer has ever run - /// (mw-combo-verify, per Doc/Architecture/CandidateReleaseProtocol). - /// - /// Neither a pass nor a refusal. It grants NO clearance, so it can never read as Green; - /// it refuses nothing, because refusing on it would freeze every instance in the fleet on the - /// day this gate shipped — the exact "fail-closed rule drawn one state too wide" that - /// ReleaseGateApplicabilityTest exists to prevent. The roll then rests on the other - /// gates, and the fact is recorded and logged so it is visible rather than inferred. - /// - NotVerified, - - /// - /// 🚨 The gate ran and could not answer (): - /// a module could not be materialised at its recorded ref, the gate could not run, or it - /// produced no structured evidence. - /// - /// Also neither a pass nor a refusal, and deliberately DISTINCT from - /// in its reason — "we tried and could not find out" and "nothing has - /// tried" are different incidents with different fixes, and an operator has to be able to tell - /// them apart from the recorded text alone. - /// - Unverifiable, - - /// - /// The gate ran and at least one module of this instance's combo FAILS against the candidate - /// (). This is the combo answer's refusal, and every failing - /// module is named. It does not stop the platform roll: a roll path reports it as a package - /// advisory (policy platform-rolls-independently-of-packages). - /// - Refused, - - /// - /// Every module of the combo compiled, rendered and tested green against the candidate - /// (). The ONLY state that grants clearance — and it still - /// carries the verdict's caveats, because a Green over a moving pin is not an unqualified pass. - /// - Cleared, -} - -/// -/// The combo gate's answer for one candidate tag: the state, the sentence an operator reads on -/// Admin/UpdatePolicy, and the verdict it was folded from (null when there was none). -/// -/// Pure — no hub, no logger, no Rx — so the four states and their wording are pinned by unit -/// tests rather than re-derived from an integration run. The reasons are GATE DIAGNOSTICS and are -/// deliberately not localized, the same call InstanceComboReader.ProvenanceCaveat makes: -/// module ids, tags, digests and failure text are machine text, and the surfaces that render them -/// localize the labels around them. -/// -/// The state. -/// The candidate this answer is about. -/// One sentence, naming what was found and (for a refusal) every module that -/// blocks it. -/// The recorded verdict this was folded from; null for -/// . -public sealed record ComboClearance( - ComboClearanceKind Kind, - string CandidateTag, - string Reason, - ComboVerification? Verdict) -{ - /// The roll is REFUSED — a Red verdict, and the only state that blocks. - public bool Refuses => Kind == ComboClearanceKind.Refused; - - /// The candidate is CLEARED — a Green verdict, and the only state that grants. - public bool IsCleared => Kind == ComboClearanceKind.Cleared; - - /// - /// Folds a recorded verdict (or its absence) into the roll-side answer. - /// - /// The candidate. - /// The verdict recorded for it, or null when none exists. - /// Why there is no verdict — names whether the gate is registered at - /// all. Only read when is null. - public static ComboClearance For( - string candidateTag, ComboVerification? verdict, string? absence = null) - { - if (verdict is null) - return new( - ComboClearanceKind.NotVerified, - candidateTag, - $"no combo verification has been recorded for '{candidateTag}' on this instance" - + (string.IsNullOrWhiteSpace(absence) ? "" : $" ({absence})") - + " — nothing has checked whether that image can serve the modules this instance " - + "runs. Produce one with mw-combo-verify (Doc/Architecture/CandidateReleaseProtocol) " - + "and it will be honoured on the next check.", - null); - - var ran = $"verified {verdict.VerifiedAt:u}" - + (verdict.VerifiedPlatform is null ? "" : $" for {verdict.VerifiedPlatform}") - + (verdict.ImageDigest is null ? "" : $" at {verdict.ImageDigest}"); - - return verdict.Verdict switch - { - ComboVerdictKind.Green => new( - ComboClearanceKind.Cleared, - candidateTag, - $"combo verification is GREEN for '{candidateTag}': all {verdict.Modules.Count} " - + $"module(s) compiled, rendered and tested against the candidate ({ran})." - + Caveats(verdict), - verdict), - - ComboVerdictKind.Red => new( - ComboClearanceKind.Refused, - candidateTag, - $"combo verification is RED for '{candidateTag}': " - + $"{verdict.FailedModules.Count} module(s) this instance runs fail against that " - + $"image — {FailingModules(verdict)} ({ran})." - + Caveats(verdict), - verdict), - - // 🚨 The default arm is NotVerifiable, and it must stay the default: a ComboVerdictKind - // member added later must land here, never on Cleared. An `is not Red` test would have - // cleared it instead. - _ => new( - ComboClearanceKind.Unverifiable, - candidateTag, - $"combo verification for '{candidateTag}' could NOT answer ({ran}) — " - + $"{NotVerifiedModules(verdict)}." - + Caveats(verdict), - verdict), - }; - } - - /// Every failing module with its first failure line — breadth-complete, never - /// truncated: one broken module must not hide another. - private static string FailingModules(ComboVerification verdict) => - verdict.FailedModules.Count == 0 - ? "(the verdict names no module — read Admin/UpdatePolicy for the full record)" - : string.Join("; ", verdict.FailedModules.Select(m => - $"'{m.ModuleId}': {First(m.Failures)}")); - - /// The modules a NotVerifiable verdict could not evaluate, with the reason each - /// carries. Modules that merely rode along carry none and are counted rather than listed. - private static string NotVerifiedModules(ComboVerification verdict) - { - var named = verdict.Modules - .Where(m => m.Outcome == ModuleVerificationOutcome.NotVerified && m.Failures.Count > 0) - .Select(m => $"'{m.ModuleId}': {First(m.Failures)}") - .ToImmutableList(); - return named.Count > 0 - ? string.Join("; ", named) - : $"{verdict.Modules.Count} module(s) were left unverified"; - } - - /// - /// 🚨 The caveats, on EVERY verdict including a Green. - /// documents them as mandatory-to-surface: a Green resolved over a moving pin, or one whose - /// input diverged from the install record, is not an unqualified pass and must never read as - /// one. - /// - private static string Caveats(ComboVerification verdict) => - (verdict.Caveats.Count == 0 - ? "" - : " Caveats: " + string.Join(" | ", verdict.Caveats)) - // Retired modules (#3848) neither fail nor hold the verdict, and are still named on it. - + (verdict.RetiredModules is not { Count: > 0 } retired - ? "" - : " Retired, not verifiable: " - + string.Join(", ", retired.Select(r => $"'{r.ModuleId}'")) + "."); - - private static string First(ImmutableList failures) => - failures.Count == 0 ? "(no detail recorded)" : failures[0]; -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/ComboVerificationGate.cs b/memex/Memex.Portal.Shared/SelfUpdate/ComboVerificationGate.cs deleted file mode 100644 index e7622a8c2a..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/ComboVerificationGate.cs +++ /dev/null @@ -1,221 +0,0 @@ -using System.Reactive; -using System.Reactive.Linq; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// -/// 🚨 The combo gate (#2274): may THIS instance be rolled to that image, given the modules it -/// actually runs? -/// -/// The release-availability gate (#1754) asks whether an ARTIFACT exists for the target. This -/// asks the harder question the artifact cannot answer: whether the candidate's assemblies can -/// still serve the module content this instance has landed. A framework-identity change invalidates -/// the whole assembly cache by design, and an optional parameter added to a record's primary -/// constructor REPLACES the signature — so "newer" and "baked" can both be true while the roll -/// trades a working portal for a MissingMethodException at boot. That is not hypothetical: -/// memex.systemorph.com sat trapped between two failing states for exactly this reason, and the -/// verdict that would have said so was produced by nothing and read by nothing. -/// -/// Produce where it is possible, CONSULT everywhere. Producing a verdict needs docker, -/// a materialisation root and repo credentials a portal pod does not have (see -/// ), so the producer is off-cluster by design. This gate therefore -/// runs when a runner IS registered — recording the verdict -/// through , where the Updates settings tab -/// already reads it — and otherwise consults the verdict an operator or CD landed on -/// Admin/UpdatePolicy. -/// -/// 🚨 Only a Green clears. Every other path — no runner, no verdict, a producer that -/// faulted, a verdict — resolves to a -/// that grants nothing. There is deliberately no configuration key, no -/// missing-service branch and no catch that can manufacture a clearance; the fold lives in -/// and is pinned by unit tests for all four states. -/// -/// Registered unconditionally by , for the -/// same reason is: the same verdict has to be readable by -/// every path that rolls a version, and a gate wired for only one of them is not a gate. -/// -/// Mesh-scoped singleton (its lifetime is the mesh's — Doc/Architecture/NoStaticState), -/// reactive end to end, no async/await. Not sealed and its members are virtual: they -/// are the documented injection seams, exactly as -/// is. -/// -/// See Doc/Architecture/ComboGateWiring. -/// -/// The hub whose instance this gates. -/// Diagnostics. -public class ComboVerificationGate( - IMessageHub hub, - ILogger? logger = null) -{ - /// - /// How long ONE production run may take before it is abandoned and the recorded verdict is - /// consulted instead. Generous by design — assembling a combo means one shallow pack per module - /// repository and the gate run compiles, renders and TESTS every NodeType inside the candidate - /// image — so this bounds a WEDGE, not a slow answer. - /// - /// Abandoning is safe precisely because it is not a decision: the fallback is the recorded - /// verdict, so a producer that hangs cannot turn a recorded Red into a pass. - /// - protected virtual TimeSpan ProduceBudget => TimeSpan.FromMinutes(30); - - /// - /// The producer seam, resolved from the mesh's services — null on every host that cannot run a - /// gate, which is every portal pod. Virtual so a test can present one without registering - /// docker. - /// - protected virtual IComboGateRunner? ResolveRunner() => - hub.ServiceProvider.GetService(); - - /// - /// 🚨 The pure consult: the clearance that follows from what is already RECORDED. Used - /// to walk the candidate list, where asking the producer once per candidate would cost a full - /// docker run per tag. Cheap, synchronous, and total — it always answers. - /// - /// The policy content the poller has already read; the verdicts live on - /// it, so this costs no additional mesh touch (the wedges-to-zero invariant is untouched). - /// The candidate. - public virtual ComboClearance Recorded(UpdatePolicyContent policy, string candidateTag) => - ComboClearance.For(candidateTag, policy.VerificationFor(candidateTag), AbsenceReason()); - - /// - /// The clearance for the candidate this instance is about to roll to: PRODUCE a verdict when - /// this host can, else consult the recorded one. Cold — subscribe to run — and total: it - /// answers on every path, so a caller never needs a Catch that would turn an incident - /// into a pass. - /// - /// The policy content already read by the caller. - /// The candidate tag. - /// The full image reference the gate would run - /// (registry/repo:tag). - public virtual IObservable Clearance( - UpdatePolicyContent policy, string candidateTag, string imageRef) => - Produce(candidateTag, imageRef) - .Select(produced => ComboClearance.For( - candidateTag, - // 🚨 The freshly produced verdict wins, but its ABSENCE falls back to the record — - // never to a clearance. A producer that could not run leaves a recorded Red - // refusing, which is the whole point of consulting the record at all. - produced ?? policy.VerificationFor(candidateTag), - AbsenceReason())); - - /// - /// Runs for this instance's combo against the candidate and - /// RECORDS the verdict on Admin/UpdatePolicy. Emits the verdict, or null when this - /// host produces none (no runner registered) or the production could not complete. - /// - /// 🚨 The record is BOOKKEEPING, never a gate (#1020, the shape - /// SelfUpdateHostedService.RecordAvailable already uses): a failed write is surfaced as a - /// warning naming the tag and the node, and the verdict is still returned — the DECISION comes - /// from the verdict itself, never from whether the mesh accepted the write. Concat, not - /// Merge, so the record still lands FIRST when it succeeds. - /// - /// Virtual: the documented seam for a test that must pin what the poller DOES with a - /// produced verdict without staging docker and a module repository. - /// - protected virtual IObservable Produce(string candidateTag, string imageRef) - { - var runner = ResolveRunner(); - if (runner is null) - // Not a skip: nothing has been decided here. The caller folds the ABSENCE of a verdict - // into NotVerified, which grants no clearance. - return Observable.Return(null); - - var combo = ReadCombo(); - if (combo is null) - { - logger?.LogWarning( - "[ComboGate] a gate runner is registered but {Reader} is not — this host cannot " - + "state its own combo, so no verdict can be produced for {Tag}. The recorded " - + "verdict (if any) is consulted instead.", - nameof(InstanceComboReader), candidateTag); - return Observable.Return(null); - } - - var filePool = hub.ServiceProvider.GetService()?.Get(IoPoolNames.FileSystem) - ?? IoPool.Unbounded; - var assembler = new InstanceComboAssembler( - runner.Fetch, filePool, runner.Options, logger); - var verifier = new InstanceComboVerifier(assembler, runner.Run, logger); - - return Observable.Defer(() => - { - logger?.LogInformation( - "[ComboGate] verifying candidate {Image} against this instance's combo.", - imageRef); - return combo; - }) - .SelectMany(combo => verifier.Verify(combo, imageRef, runner.WorkRoot, candidateTag)) - .SelectMany(run => Record(run.Verdict) - .IgnoreElements() - .Select(_ => (ComboVerification?)null) - .Concat(Observable.Return(run.Verdict))) - .Take(1) - .Timeout(ProduceBudget) - .Catch((Exception exception) => - { - // 🚨 Surfaced, never swallowed — and it does not decide anything: the caller falls - // back to the RECORDED verdict, so a producer that faults cannot clear a candidate - // and cannot un-refuse one. - logger?.LogWarning(exception, - "[ComboGate] could not produce a combo verdict for {Tag}; the verdict recorded " - + "on {Node} (if any) decides instead.", - candidateTag, UpdatePolicyNodeType.NodePath); - return Observable.Return(null); - }); - } - - /// - /// What this instance runs, as states it — the INPUT the - /// verifier materialises and gates. Null when the reader is not registered on this host, in - /// which case nothing can be produced (and nothing is thereby cleared). - /// - /// Virtual: the seam that lets a test present a combo without provisioning module - /// partitions, so the assembler, the verifier and the verdict record all still run FOR REAL — - /// which is the only way a test can pin that they are called at all. - /// - protected virtual IObservable? ReadCombo() => - hub.ServiceProvider.GetService()?.Read(); - - /// The verdict write, isolated so a failed record can never abort the run that - /// produced it. - private IObservable Record(ComboVerification verdict) => - UpdatePolicyNodeType.RecordVerification(hub, verdict) - .Catch((Exception exception) => - { - logger?.LogWarning(exception, - "[ComboGate] could not record the {Verdict} verdict for {Tag} on {Node}; the " - + "verdict itself still decides this check.", - verdict.Verdict, verdict.CandidateTag, UpdatePolicyNodeType.NodePath); - return Observable.Return(Unit.Default); - }); - - /// Why this host has no verdict of its own to offer — carried into the - /// reason so "nobody ran the gate" reads - /// differently from "the gate ran and could not answer". - private string AbsenceReason() => - ResolveRunner() is null - ? "no combo-gate runner is registered on this host, so it produces no verdicts of its " - + "own — they are landed here by mw-combo-verify" - : "the gate runner on this host produced none"; - - /// - /// 🚨 The clearance for a host on which the gate itself is NOT registered — the answer the - /// poller uses when it can resolve no gate at all. - /// - /// It is , never a clearance: an unregistered - /// gate has not answered the question, it has failed to ask it. It does not REFUSE either, - /// because refusing on the absence of a producer nothing has wired yet would freeze every - /// instance in the fleet — the fail-closed rule drawn one state too wide that - /// ReleaseGateApplicabilityTest already records the cost of. - /// - public static ComboClearance NotRegistered(string candidateTag) => - ComboClearance.For( - candidateTag, null, - $"no {nameof(ComboVerificationGate)} is registered on this host (it is wired by " - + $"{nameof(SelfUpdateConfiguration.AddSelfUpdate)})"); -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/IComboGateRunner.cs b/memex/Memex.Portal.Shared/SelfUpdate/IComboGateRunner.cs deleted file mode 100644 index 8bd8209e9b..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/IComboGateRunner.cs +++ /dev/null @@ -1,54 +0,0 @@ -using MeshWeaver.GitSync; -using MeshWeaver.PluginCatalog; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// -/// 🚨 The off-pod half of the combo gate: everything -/// needs that a portal POD does not have. -/// -/// Producing a combo verdict means materialising every module of this instance's combo at its -/// recorded ref and executing mw-plugin-test INSIDE the candidate image. That needs docker, a -/// writable work root, and read access to the module repositories — none of which a portal pod has, -/// which is why mw-combo-verify lives in tools/ and the Candidate Release Protocol runs -/// it off-cluster. This interface is that host's contribution, expressed as ONE optional service so -/// the gate stays the same code whether it produces its own verdict or reads the one an operator (or -/// CD) landed. -/// -/// Its absence is never a pass. A host with no runner registered simply produces no -/// verdict — then CONSULTS what is recorded on -/// Admin/UpdatePolicy, and a candidate with no verdict resolves to -/// , which grants nothing. Only a -/// verdict can ever clear a candidate. -/// -/// Reactive: the fetch and the gate run are cold observables that do their work on Subscribe, -/// never Task-shaped — a hub-reachable caller must not await anything. -/// -public interface IComboGateRunner -{ - /// The directory the combo is materialised into and the gate runs over. Must be - /// writable by this process. - string WorkRoot { get; } - - /// - /// Executes mw-plugin-test <root> --report … inside the candidate image: - /// (imageRef, workRoot) → one . Cold. Expected failures (docker - /// missing, pull denied, timeout) are reported through - /// rather than as an OnError — the verifier folds either into NotVerifiable. - /// - IObservable Run(string imageRef, string workRoot); - - /// - /// The snapshot fetch the assembler materialises modules through: - /// (repositoryUrl, gitRef, subdirectory, accessToken) → one — the - /// same shape IGitHubRepoClient.Fetch has, so a host wires its existing client here - /// instead of hand-rolling git. - /// - IObservable Fetch( - string repositoryUrl, string gitRef, string? subdirectory, string accessToken); - - /// Assembly policy for the run — the repo access token, the source→repository map, - /// and whether moving refs may be materialised. Defaults REFUSE moving refs and incomplete - /// combos, because a gate run on either is not evidence. - ComboAssemblyOptions Options { get; } -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/NewerPlatformFromUpdatePolicy.cs b/memex/Memex.Portal.Shared/SelfUpdate/NewerPlatformFromUpdatePolicy.cs deleted file mode 100644 index f21523e770..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/NewerPlatformFromUpdatePolicy.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.GitSync; -using MeshWeaver.Messaging; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// -/// The platform self-update's answer to GitSync's "does this instance know of a newer platform?" -/// (): the newest tag the poller recorded on -/// Admin/UpdatePolicy, when it is newer than the running build — whether the roll onto it is -/// available or HELD by a gate (a held instance is exactly the one that must not take sources nobody -/// stamped a floor for). Read through , which reads as System, -/// bounds its own reads and degrades every failure to — -/// which answers null here, "not lagging". -/// -public sealed class NewerPlatformFromUpdatePolicy : INewerPlatformReading -{ - /// - public IObservable NewerPlatform(IMessageHub hub) - => PlatformUpdateStatus.Observe(hub) - .Select(status => status.Availability is PlatformUpdateAvailability.UpdateAvailable - or PlatformUpdateAvailability.UpdateHeld - ? status.LatestVersion - : null); -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/OciTagLister.cs b/memex/Memex.Portal.Shared/SelfUpdate/OciTagLister.cs deleted file mode 100644 index ba0fe08df6..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/OciTagLister.cs +++ /dev/null @@ -1,380 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// -/// Lists a repository's tags on an OCI Distribution registry — the fleet's own registry, or -/// the read-through mirror another installation serves at {portal}/v2 (#3353, -/// Doc/Architecture/ContainerRegistryInMemex) — for a self-updater whose -/// is NOT an Azure Container Registry. -/// -/// The credential is the one this installation already holds. The registry authenticates -/// a caller with its plugin-registry instance key, and a consuming installation already carries -/// exactly that key for the registry it installs modules from -/// (PluginCatalog:Registries:N:Token / legacy PluginCatalog:RegistryToken, or the -/// stored auto-registration credential). So the credential is RESOLVED, never configured a second -/// time, through the same the Store uses. No new secret, -/// nothing to rotate twice. -/// -/// 🚨 WHICH plugin registry's key is a disclosure decision, and -/// is the control: the container registry's OWN host when a plugin registry is configured there, -/// or the host this installation DECLARES as that registry's validator -/// () when one is configured there — and -/// nothing else. No declaration ⇒ refuse. The fleet needs the second case because -/// cr.meshweaver.cloud decides a pull by forwarding the key to -/// memex.meshweaver.cloud, so the two hosts differ BY DESIGN (#4093, -/// Doc/Architecture/SelfUpdateRegistryCredential). -/// -/// The wire conversation — the bearer handshake, the paginated listing, every page -/// followed — is , the same client -/// pulls bundles with; this class contributes the credential -/// resolution and the self-update-specific configuration errors. -/// -/// Failure is an error, never an empty list. A refused credential, an unreachable -/// host or a malformed answer FAULT the observable and the poller records the fault — an empty -/// listing would be read as "nothing to roll to", which is the one silent state #2553 exists to -/// forbid. Compare UnavailableUpdateMechanics.NoRegistry, which answers empty by design -/// because it stands for "no registry configured at all". -/// -/// Selected by the poller from ; -/// the ACR path is untouched. -/// -public sealed class OciTagLister( - IMessageHub hub, - SelfUpdateOptions options, - ILogger? logger = null) -{ - /// The page size asked for. The registry may answer fewer; every page is followed. - public const int PageSize = OciRegistryClient.TagPageSize; - - /// The named HttpClient a host may configure; the shared fallback is used otherwise. - public const string HttpClientName = "MeshWeaver.SelfUpdate.Oci"; - - /// - /// Every tag on at . - /// Cold; emits once; FAULTS on any answer that is not a complete listing. - /// - public IObservable> ListTags(string repository) - { - var configured = (options.Registry ?? string.Empty).Trim().TrimEnd('/'); - if (configured.Length == 0) - return Observable.Throw>(new InvalidOperationException( - "SelfUpdate:Registry is empty — there is no registry to list tags on.")); - - // 🚨 THE TARGET IS NORMALIZED AND VALIDATED BEFORE ANY CREDENTIAL IS RESOLVED, and the - // check is that the configured value ALREADY IS a bare `host[:port]`. - // - // Two defects live behind a raw value here, and the second is a key disclosure: - // * ASYMMETRY — `ResolveCredential` compares this against plugin-registry hosts parsed by - // `HostOf`, so a `https://host` form would miss a registry that is in fact the same host. - // * DISCLOSURE — `OciRegistryClient` builds `https://{registry}/` for a value with no - // scheme, so `instance:mwi_…@evil.example` is a VALID URI whose host is `evil.example`; - // it would receive the Basic credential, and the raw value is interpolated into that - // client's error messages and into this class's audit line. Host equality alone could - // never match such a value, so the declared-validator branch below is what would make it - // reachable — this refusal is the reason that branch cannot open a disclosure path. - // - // Requiring a bare host (not merely "parses to a host") also keeps this in step with - // SelfUpdateOptions.PortalImage/MigrationImage, which interpolate the SAME value into an - // image reference and need a bare host for it to be well-formed. - var registry = SelfUpdateOptions.HostOf(configured); - if (registry is null) - // 🚨 The value is NOT echoed: this is exactly the branch a userinfo-bearing value lands - // in, and the userinfo is where a key would be. - return Observable.Throw>(new InvalidOperationException( - "SelfUpdate:Registry does not name an http(s) registry host. It must be a bare " - + "'host' or 'host:port' (for example cr.meshweaver.cloud) — never a value carrying " - + "credentials, and never one this platform cannot also use as the registry half of " - + "an image reference. The configured value is not repeated here because a value of " - + "this shape can carry a credential.")); - // 🚨 "Bare" is a TEXTUAL test on the configured value, NOT equality with the host HostOf - // read from it: HostOf drops a scheme-default port, so `cr.example.test:443` — a legal - // `host:port` the documentation promises — would fail an equality test and be refused with - // a message claiming it carries a scheme or a path when it carries neither (#4094 review). - // A value that parsed (so: no userinfo) and carries no scheme and no path IS a bare host, - // whatever port it names; the client and the messages then use the normalized host. - if (!IsBareHost(configured)) - // Safe to name: HostOf refuses userinfo, so a value that parsed cannot have carried one. - return Observable.Throw>(new InvalidOperationException( - $"SelfUpdate:Registry must be a bare registry host, but it carries a scheme or a path; " - + $"it names the host '{registry}'. Set SelfUpdate:Registry to '{registry}' — the same " - + "value is interpolated into the portal and migration image references, which are " - + "malformed with anything else.")); - - return new OciRegistryClient( - hub, registry, ResolveCredential(registry), options.RegistryUsername, HttpClientName, logger) - .ListTags(repository); - } - - /// - /// Whether a value that could read is ALSO nothing but - /// host[:port]: no scheme, no path, no query, no fragment. Userinfo is not tested here - /// because HostOf already refused it. Pure. - /// - private static bool IsBareHost(string configured) => - !configured.Contains("://", StringComparison.Ordinal) - && configured.IndexOfAny(['/', '\\', '?', '#']) < 0; - - /// - /// 🚨 The plugin-registry credential this installation may present to the container registry — - /// and the DISCLOSURE CONTROL that decides which one, because the answer is an instance key and - /// the wrong answer hands it to whatever host SelfUpdate:Registry happens to name. - /// - /// Exactly two hosts qualify, both by an explicit statement in this installation's own - /// configuration, tried in this order: - /// - /// the container registry's OWN host, when a plugin registry is configured there — a - /// portal serving its images from the /v2 mirror it also serves its catalog from; - /// the host this installation DECLARES as that registry's validator - /// (), when a plugin registry is - /// configured THERE — the fleet's shape, where cr.meshweaver.cloud forwards the key to - /// memex.meshweaver.cloud to decide the pull (#4093, - /// Doc/Architecture/SelfUpdateRegistryCredential). - /// - /// - /// Nothing else, ever. The two hosts are compared WHOLE and case-insensitively — never by - /// suffix, registrable domain or any other resemblance, which is a coincidence of naming rather - /// than a grant — and an ABSENT declaration is refused, never read as permission. Both steps - /// need the operator to have configured a plugin registry on the host in question, so the key - /// only ever goes where this installation was already given one for. - /// - /// The SHAPE of the credential follows the host: the registry's own host gets what the - /// Store presents ( — a short-lived token when - /// the stored key was exchanged for one); a declared validator gets the DURABLE key - /// (), because on the fleet's shape the - /// validator IS the key-to-token exchange and refuses a token by design. - /// - /// Faults, naming the hosts (never the key), when the declaration is set but unreadable, - /// when no host qualifies, when the only registry on a qualifying host carries its credential - /// inside its URL, or when the resolved credential is empty: a listing without a credential - /// could only ever be a 401, so the misconfiguration is said once here instead of on every - /// check as a refused handshake. Cold: the fault is raised at subscription, before the client - /// sends a byte. - /// - private IObservable ResolveCredential(string registry) => - Observable.Defer(() => - { - // 🚨 A DECLARED-BUT-UNREADABLE validator is refused as MALFORMED, first and on its own — - // never folded into "nothing declared". `RegistryValidatorHost` is null for both, and - // treating the two alike diagnosed a typo'd scheme or a userinfo-bearing declaration - // as an ABSENT one: the boot line said "NO validator declared" and the refusal told the - // operator to set the key they had already set (#4094 review). The value is not echoed — - // a userinfo-bearing value lands exactly here, and the userinfo is where a key would be. - if (options.RegistryValidatorDeclared && options.RegistryValidatorHost is null) - return Observable.Throw(new InvalidOperationException( - "SelfUpdate:RegistryValidationUrl is set but does not name an http(s) host, so it " - + "declares no validator and the listing refuses. Set it to the registry record's " - + "validationUrl verbatim (for example https://memex.meshweaver.cloud/api/instances/token) " - + "or to a bare host — never a value carrying credentials. The configured value is " - + "not repeated here because a value of this shape can carry a credential.")); - - var catalog = hub.ServiceProvider.GetService() ?? new PluginCatalogOptions(); - var registries = RegistryTokenResolver.WithLegacyTokens(catalog, catalog.EffectiveRegistries); - var declaredValidator = options.RegistryValidatorHost; - - var match = On(registries, registry); - // The HOST the match was made on — the parsed host, never `match.Url`, which may carry - // userinfo; every message and log line below names this and nothing else. - var matchedHost = registry; - var paired = false; - if (match is null && declaredValidator is not null && On(registries, declaredValidator) is { } viaValidator) - { - // 🚨 The new, security-relevant decision, said out loud once per listing — and this - // is an Information line, so it LEAVES THE POD for Loki. It therefore carries TWO - // HOSTS and the NAME of a configuration key, and NOTHING derived from the secret: - // not the key, not a prefix or suffix of it, not its length, not a hash of it. - // Do not add one "for diagnostics" — a length is a fact about a credential. - // - // 🚨 And the hosts are the PARSED hosts, never `viaValidator.Url`. A configured URL - // may carry userinfo (`https://instance:mwi_…@memex.meshweaver.cloud`), which would - // put the key in Loki in a field nobody thinks of as a credential — the #3201 - // shape, where the registry key sat in a pod spec `kubectl describe` printed and is - // still owed a rotation. `declaredValidator` IS this registry's host by - // construction: it is what `On(...)` just matched it by. - logger?.LogInformation( - "[SelfUpdate] presenting the instance key configured under PluginCatalog:Registries " - + "for {PluginRegistryHost} to the container registry {Registry}: " - + "SelfUpdate:RegistryValidationUrl declares that host as the portal which validates " - + "this installation's key there. The durable key is presented, unexchanged — the " - + "validator IS the key-to-token exchange and refuses a token.", - declaredValidator, registry); - match = viaValidator; - matchedHost = declaredValidator; - paired = true; - } - - if (match is null) - { - // 🚨 A plugin registry that EXISTS on the host but whose URL carries credentials is - // named as such, never diagnosed as "no plugin registry is configured on that host". - // HostOf refuses userinfo (so `https://memex.meshweaver.cloud@evil.example` can never - // be read as memex), which also means a registry configured as - // `https://instance:mwi_…@host` stops matching on BOTH branches — the pre-#4094 - // reader tolerated it. The registry is real and the catalog is already talking to - // it; what is wrong is WHERE the credential sits, and the message says so. The URL - // is not echoed: the credential is in it. - var credentialInUrl = registries.FirstOrDefault(r => - CarriesCredentialsFor(r.Url, registry) - || (declaredValidator is not null && CarriesCredentialsFor(r.Url, declaredValidator))); - if (credentialInUrl is not null) - return Observable.Throw(new InvalidOperationException( - $"SelfUpdate:Registry is '{registry}', an OCI registry the self-updater authenticates " - + "with this installation's plugin-registry instance key. A plugin registry IS configured " - + "for that host or for its declared validator, but its PluginCatalog:Registries URL " - + "carries credentials (user:secret@host), and a URL of that shape is never read as " - + "naming a host — the credential is where a human misreads the host. Move the key " - + "to PluginCatalog:Registries:N:Token and set the URL to the bare " - + "https://host; the registry then qualifies. The URL is not repeated here because " - + "the credential is in it.")); - - return Observable.Throw(new InvalidOperationException( - $"SelfUpdate:Registry is '{registry}', an OCI registry the self-updater authenticates " - + "with this installation's plugin-registry instance key — but no plugin registry " - + "(PluginCatalog:Registries / PluginCatalog:RegistryUrl) is configured on that host, " - + (declaredValidator is null - ? "and no validator is declared for it, so there is no key to present. Either " - + "configure the plugin registry on the same host, or — when the registry " - + "validates this installation's key at ANOTHER portal, as cr.meshweaver.cloud " - + "does at memex.meshweaver.cloud — declare that portal in " - + "SelfUpdate:RegistryValidationUrl (the registry record's own validationUrl) " - + "and configure it as a plugin registry. " - : $"and the validator it declares, '{declaredValidator}' " - + "(SelfUpdate:RegistryValidationUrl), is not configured as one either, so there " - + "is no key to present. Add that host to PluginCatalog:Registries with the " - + "instance key issued for it. ") - + WhatThisInstallationHolds(ConfiguredHosts(registries), registry, declaredValidator) - + "Or set SelfUpdate:Registry back to the upstream Azure Container Registry.")); - } - - // 🚨 WHICH SHAPE of the credential depends on WHO validates it, and the two branches - // differ here on purpose: - // * the registry's OWN host is a portal serving its /v2 mirror, whose token endpoint - // runs the same authenticator the plugin registry does and accepts BOTH the durable - // key and the short-lived token — so it is handed what the Store presents - // (`ResolveToken`: the configured token, or the stored key EXCHANGED for a JWT); - // * a DECLARED validator is, on the fleet's shape, the key-to-token exchange itself - // (`/api/instances/token`), and that endpoint refuses a token by design — a token may - // never mint its successor. An auto-registered installation (PluginCatalog:BootstrapKey, - // no configured Token) resolving through `ResolveToken` would therefore present an - // `mwa_` JWT and be refused on EVERY check, while only an installation with a raw - // Token configured would ever work (#4094 review). So this branch presents the - // DURABLE key with no exchange — the one shape every validator that accepts the - // instance key accepts. - var resolver = hub.ServiceProvider.GetService(); - var credential = resolver is null - ? Observable.Return(match.Token?.Trim() ?? string.Empty) - : paired - ? resolver.ResolveDurableKey(match) - : resolver.ResolveToken(match); - - // 🚨 `matchedHost`, never `match.Url` — same reason as the log line above: a configured - // URL may carry userinfo, and this message is logged by the poller on every failed check. - return credential.Select(token => token.Length > 0 - ? token - : throw new InvalidOperationException( - $"No instance key could be resolved for the plugin registry at {matchedHost}, so the " - + $"container registry {registry} cannot be listed. The key arrives as " - + "PluginCatalog:Registries:N:Token (or the legacy PluginCatalog:RegistryToken), or " - + "through auto-registration with PluginCatalog:BootstrapKey.")); - }); - - /// - /// How many plugin-registry hosts a refusal will name before it summarises the rest. A refusal - /// is read in a log line and on Admin/UpdatePolicy, so a pathological configuration must - /// not be able to grow it without bound. - /// - private const int MaxNamedHosts = 8; - - /// - /// 🚨 The sentence that turns "declare the pairing" into a value the operator can WRITE: the - /// plugin-registry hosts this installation is configured for, named in the refusal. - /// - /// It names, it never grants. Every branch above has already decided to refuse and - /// this only describes what is configured — so an installation with exactly one mount is told - /// which host a declaration would name and still refuses until one is declared. That is the - /// whole point: the rule the maintainer rejected for #4093 was "one mount carries a key, so - /// present it", and naming the host while still requiring the declaration is what a refusal owes - /// an operator without becoming that rule. OciTagListerTest pins both halves — the - /// host is named AND the key does not leave. - /// - /// For a declaration that resolved to a host holding no registry this is the - /// spot-the-typo line: the operator sees what they declared beside what they actually hold, which - /// is the difference between "add a registry" and "fix one character". #4093's own shape — a - /// validator declared as https://memex.meshweaver.cloud/api/instances/token against a - /// mount at https://memex.meshweaver.cloud — resolves to the same host, so a mismatch - /// here is always a real one. - /// - private static string WhatThisInstallationHolds( - IReadOnlyList hosts, string registry, string? declaredValidator) => - hosts.Count switch - { - 0 => "This installation is configured for no plugin registry whose URL names a host, so " - + "nothing could be declared yet: configure PluginCatalog:Registries with the instance " - + "key issued for the validating portal first. ", - _ when declaredValidator is not null => - $"The plugin registr{(hosts.Count == 1 ? "y this installation is" : "ies this installation is")} " - + $"configured for {(hosts.Count == 1 ? "is" : "are")} {Name(hosts)} — if the declaration was " - + $"meant to name {(hosts.Count == 1 ? "that host" : "one of those")}, correct " - + "SelfUpdate:RegistryValidationUrl instead. ", - 1 => $"This installation is configured for exactly one plugin registry, at {hosts[0]}. If that " - + $"is the portal '{registry}' validates this installation's key at, {hosts[0]} is the host " - + "to declare — holding the key is not itself the declaration, and the check stays refused " - + "until SelfUpdate:RegistryValidationUrl names it. ", - _ => $"This installation is configured for plugin registries at {Name(hosts)} — the declaration " - + $"must name whichever of them '{registry}' validates this installation's key at. ", - }; - - /// The named hosts, with the overflow counted rather than dropped, so the list can never - /// read as complete when it is not. Pure. - private static string Name(IReadOnlyList hosts) => - hosts.Count <= MaxNamedHosts - ? string.Join(", ", hosts) - : string.Join(", ", hosts.Take(MaxNamedHosts)) + $" (and {hosts.Count - MaxNamedHosts} more)"; - - /// - /// The plugin-registry HOSTS this installation is configured for, as a refusal may name them: - /// read through , deduplicated case-insensitively and - /// ordered so the message is deterministic. - /// - /// 🚨 HostOf is what makes this safe to print: it refuses userinfo, so a registry - /// configured as https://instance:mwi_…@host contributes NOTHING here rather than being - /// echoed — the credential is in that URL, and this string reaches Loki and the policy node. The - /// credentialInUrl branch above is what diagnoses such a registry, by name and without the - /// URL. Serves the DIAGNOSIS only: nothing is matched, selected or presented through this reader. - /// Pure. - /// - private static IReadOnlyList ConfiguredHosts(IEnumerable registries) => - registries - .Select(r => SelfUpdateOptions.HostOf(r.Url)) - .OfType() - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(h => h, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - /// - /// Whether a configured plugin-registry URL names BUT carries - /// credentials (userinfo) — the one shape refuses for a - /// registry that nevertheless exists and is in use. Serves the DIAGNOSIS only: nothing is ever - /// matched, selected or logged through this reader. Pure. - /// - private static bool CarriesCredentialsFor(string? url, string host) => - Uri.TryCreate((url ?? string.Empty).Trim(), UriKind.Absolute, out var uri) - && uri.UserInfo.Length > 0 - && uri.Scheme is "http" or "https" - && string.Equals(uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}", host, - StringComparison.OrdinalIgnoreCase); - - /// - /// The configured plugin registry living on — whole-host, case - /// insensitive, through the SAME host reader the declaration is parsed with, so the two sides - /// of the comparison can never drift. Null when none does. Pure. - /// - private static PluginRegistryReference? On(IEnumerable registries, string host) => - registries.FirstOrDefault(r => SelfUpdateOptions.HostOf(r.Url) is { } configured - && string.Equals(configured, host, StringComparison.OrdinalIgnoreCase)); -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/PackageHoldHandover.cs b/memex/Memex.Portal.Shared/SelfUpdate/PackageHoldHandover.cs deleted file mode 100644 index 0b0de15076..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/PackageHoldHandover.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// -/// 🚨 A held package update becomes ONE blocking ticket on the control instance — the -/// portal's (policy package-min-mesh-version: "when we -/// cannot advance we file a blocking ticket, through dispatch"). -/// -/// No new channel. The ticket rides — the -/// signed POST into the control inbox (Hosting:ControlInbox:Url + -/// Hosting:ControlInbox:Secret, or the in-process delivery on the control instance itself) -/// that every self-update announcement already takes — as event -/// , with severity: blocking. The instance -/// never opens a GitHub issue; the control plane's triage decides what the ticket becomes. -/// -/// No route is an answer, not a crash. When the instance declares no control inbox -/// (or no record id), errors with 's -/// sentence, which the catalog records on the install record as "NOT dispatched: …" and logs at -/// Warning. -/// -/// 🚨 The control-plane half is MeshWeaver.Plugins', and until it lands an accepted -/// ticket is stored and then dropped by the inbox watcher: PlatformBuildInboxWatcher admits -/// only a self-update announcement under a deployment's own key, and TriageIntake.IsTriageKind -/// does not list . See -/// Doc/Architecture/ModuleAdoptionPolicy → "Blocking tickets". -/// -public sealed class PackageHoldHandover : IPackageHoldDispatch -{ - /// The severity every held-update ticket carries. - public const string Blocking = "blocking"; - - /// The reporter field of a held-update ticket. - public const string Reporter = "package-floor"; - - /// The announcement a ticket is sent as (pure). - /// The ticket. - /// Now. - public static SelfUpdateHandover.Announcement AnnouncementOf(PackageHoldTicket ticket, DateTimeOffset now) => - new() - { - Event = SelfUpdateHandover.PackageHeldEvent, - CurrentVersion = ticket.Running, - Package = ticket.Package, - HeldVersion = ticket.HeldVersion, - Floor = ticket.Floor, - InstalledVersion = ticket.InstalledVersion, - Reason = ticket.Summary, - Unblocks = ticket.Unblocks, - Severity = Blocking, - DetectedAt = SelfUpdateHandover.Stamp(now), - Reporter = Reporter, - }; - - /// - public IObservable Dispatch(IMessageHub hub, PackageHoldTicket ticket) - { - var logger = hub.ServiceProvider.GetService()?.CreateLogger(); - return Handover(hub, logger) - .Announce(AnnouncementOf(ticket, DateTimeOffset.UtcNow)) - .Select(outcome => $"{outcome.Detail} ({outcome.Destination})"); - } - - /// The hand-over the ticket rides. Virtual seam for tests that present a configured - /// control inbox without a second mesh. - internal Func Handover { get; init; } = - (hub, logger) => new SelfUpdateHandover(hub, logger); -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/PlatformUpdateChip.cs b/memex/Memex.Portal.Shared/SelfUpdate/PlatformUpdateChip.cs deleted file mode 100644 index 1261c102bc..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/PlatformUpdateChip.cs +++ /dev/null @@ -1,163 +0,0 @@ -using MeshWeaver.Messaging; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// What clicking the header build chip does. -public enum PlatformUpdateChipAction -{ - /// Open the About page, which carries the full build identity the tooltip summarises. - OpenAbout, - - /// - /// Hard-reload the browser, moving this session onto whatever instance is serving now. Offered - /// ONLY for — see - /// for why a held update deliberately does not get it. - /// - Refresh, -} - -/// -/// Everything the header build chip renders, as a plain record — so the wording, the version it -/// names and the action it offers are unit-testable without a hub or a rendered circuit (the same -/// reason takes its localizer as a -/// function). The component holds only markup and wiring. -/// -/// -/// The full sentence on hover — build identity in every state, and the deployment time. -/// -/// 🚨 The chip renders a GLYPH ONLY. It sits in a strip of icon buttons, and a chip that also -/// rendered text was wider than its siblings and pushed its own glyph off their shared centre line. -/// So everything the chip has to say is here, and this string is the whole surface: if a fact is not -/// in it, the reader cannot get at it without opening the About page. -/// -/// Whether an update is pending, which is what the chip styles on. -/// What a click does. -public record PlatformUpdateChip( - string Tooltip, - bool IsUpdate, - PlatformUpdateChipAction Action) -{ - /// How many characters of the commit sha survive into the header. - private const int ShaDisplayLength = 7; - - /// - /// The build id as the HEADER shows it: the version, with SemVer build metadata (everything - /// after + — the commit sha) abbreviated. - /// - /// A local build's full version is 3.0.0-rc4.ci.0+8278244204d7e3d0cc95b1461c825383cf0875a9: - /// 48 characters, 40 of them hash, in a top bar between two icon buttons. The sha is kept - /// rather than dropped because on a local build every version is ci.0 — the sha is the - /// only part that tells one build from the next. The full string stays on the tooltip and on - /// the About page, so nothing is lost, it is one hover away. - /// - public static string ShortVersion(string version) - { - if (string.IsNullOrEmpty(version)) return version; - var plus = version.IndexOf('+'); - if (plus < 0) return version; // no build metadata — already short - - var sha = version[(plus + 1)..]; - return sha.Length <= ShaDisplayLength - ? version - : $"{version[..(plus + 1)]}{sha[..ShaDisplayLength]}"; - } - - /// - /// WHEN this build started serving, compact enough for the header, in the VIEWER's zone. - /// - /// The version says WHICH build; this says SINCE WHEN, and a re-deploy of the same image - /// changes only the second. Month-day-and-time without a year because the header is not an - /// audit log — it answers "did this roll just now, or has it been up for days?" at a glance, - /// and the full stamp is on the About page. - /// - /// 🚨 The month is NUMERIC, never MMM. A month abbreviation formatted invariantly - /// renders "Aug" to a German viewer exactly as it does to an English one — English text - /// hard-coded into a localized UI, in the one place it is easiest to miss because it looks like - /// formatting rather than copy. Numeric needs no catalog key and no culture at all, and it - /// keeps the yyyy-MM-dd HH:mm field order the About page prints, minus the year the - /// header has no room for. - /// - /// Returns null when the start time is unknown, so the caller renders nothing - /// rather than an epoch date. - /// - public static string? ShortStartedAt(DateTimeOffset startedAtUtc, string? viewerZoneId) - => startedAtUtc == default - ? null - : DisplayTimeExtensions.ToDisplayTime(startedAtUtc, viewerZoneId) - .ToString("MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture); - - /// - /// Derives the chip from the update verdict and this process's identity. - /// - /// The chip renders in EVERY state, including - /// and . That is what makes it a confirmation - /// rather than an announcement: because the running build is always on screen, a refresh that - /// lands on a new instance is visible as the value changing. An indicator that appeared only - /// while an update was pending could say something was available but never that you had - /// arrived — which is the question ahead of starting a thread round. - /// - /// The tooltip names the INSTANCE as well as the build, because two replicas can run the - /// same build — the version alone cannot settle "did my session move?". - /// - /// 🚨 The visible text is the deployment TIME, in every state — never a version. A build - /// id is an identifier an ordinary reader cannot act on, and the header is the busiest strip in - /// the portal; "Last deployed 08-18 15:35" answers the question people actually bring to it. - /// The exact build did not disappear: it is on the tooltip, and in full on the About page. That - /// holds even with an update pending — the glyph is what says one is waiting, and swapping the - /// text for a newer unreadable identifier would not tell anyone more. - /// - /// 🚨 A held update reads differently from an available one and offers NO refresh. Per - /// a hold must never be silent; but - /// refreshing cannot clear one, and offering the button anyway would teach the user to click at - /// a problem they cannot fix from the header. - /// - /// The verdict from . - /// The build serving this session. - /// The serving process — the pod name under Kubernetes. - /// When this build started serving, UTC; default if unknown. - /// The viewer's IANA zone — the time is never shown as bare UTC. - /// Key → text; taken as a function so the wording is testable. - public static PlatformUpdateChip Describe( - PlatformUpdateStatus status, - string runningVersion, - string instanceName, - DateTimeOffset deployedAtUtc, - string? viewerZoneId, - Func localize) - { - // Build identity, in every state — the half of the tooltip that answers "where am I?". - var running = $"{localize("about.version")}: {runningVersion} · {localize("about.instance")}: {instanceName}"; - - // The one visible string, shared by every state: WHEN, not WHICH. - var deployed = ShortStartedAt(deployedAtUtc, viewerZoneId) is { } when - ? $"{localize("about.lastDeployed")} {when}" - : null; - - // 🚨 The deployment time is TOOLTIP-ONLY, and it must appear in every state. The header - // strip is icon buttons; a chip that also rendered text was wider than its siblings and - // pushed its own glyph off their shared centre line — visible as one misaligned icon in a - // row of eight. Dropping the text fixes the row, so the time has to land here or it is - // simply lost, and "when was this deployed?" is the question the chip exists to answer. - var withDeployed = (string sentence) => deployed is null ? sentence : $"{sentence} {deployed}."; - - return status.Availability switch - { - PlatformUpdateAvailability.UpdateAvailable => new( - withDeployed( - $"{localize("about.updateAvailable")} — {status.LatestVersion}. {running}. " - + localize("ui.updateRefreshHint")), - IsUpdate: true, - PlatformUpdateChipAction.Refresh), - - PlatformUpdateAvailability.UpdateHeld => new( - withDeployed($"{localize("about.updateHeld")} — {status.LatestVersion}. {running}."), - IsUpdate: true, - PlatformUpdateChipAction.OpenAbout), - - // UpToDate and Unknown are the same chip on purpose. Unknown means nothing is polling, - // so claiming "up to date" would be an unfounded verdict (PlatformUpdateAvailability - // .Unknown) — but the running build is a fact either way, and it is the whole point. - _ => new(withDeployed(running), IsUpdate: false, PlatformUpdateChipAction.OpenAbout), - }; - } -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/PlatformUpdateStatus.cs b/memex/Memex.Portal.Shared/SelfUpdate/PlatformUpdateStatus.cs deleted file mode 100644 index 3532508fe1..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/PlatformUpdateStatus.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using MeshWeaver.Hosting.SelfUpdate; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// Whether the running build is the newest one the platform knows about. -public enum PlatformUpdateAvailability -{ - /// Nothing can be said: the policy node does not exist (this install never wired - /// self-update), the read failed, or automatic checks are switched off and no newer build was - /// ever recorded. Renders as NO update line at all — silence beats an unfounded "up to date". - Unknown, - - /// No build newer than the running one has been detected. - UpToDate, - - /// A newer build exists; names it. - UpdateAvailable, - - /// - /// A newer build exists and an OPERATIVE hold record names it - /// (). Under policy - /// platform-rolls-independently-of-packages no package answer produces this state — a - /// package that has no usable artifact for the build and a - /// combo verdict are package advisories ( with - /// ) — so it is reached only through a hold an - /// older build recorded, until the next check clears it. - /// still names the build. - /// - /// 🚨 This state exists so a hold cannot be silent. Rendering a held update as - /// "update available" would leave an install looking normal while it never moves; rendering it - /// as "up to date" would be a lie. An environment frozen for weeks because one stale package - /// blocks it is its own outage, and the only defence is that it SAYS so. - /// - UpdateHeld, -} - -/// -/// The one fact an ORDINARY user needs to answer "is this deployment up to date?" — a two-state -/// verdict plus, when an update exists, the version that supersedes the running build. Deliberately -/// the whole projection: the update , the CI-green flag and -/// the poller's bookkeeping stay behind the admin gate on -/// . -/// -/// Why a projection and not a direct read: both halves of the comparison live on -/// Admin/UpdatePolicy, and the Admin partition is platform-scoped — an ordinary user -/// cannot read it (pinned by NonAdminUpdateStatusTest), and widening that grant would hand -/// every user the whole update policy. So reads the node as System and lets -/// only this record out, exactly as does for -/// the anonymous /privacy page. -/// -/// The verdict. -/// The newer version's tag — non-null ONLY for -/// . -public record PlatformUpdateStatus(PlatformUpdateAvailability Availability, string? LatestVersion) -{ - /// Nothing is known — the render-nothing state. - public static readonly PlatformUpdateStatus Unknown = new(PlatformUpdateAvailability.Unknown, null); - - /// - /// The pure verdict: is superseded by the newest tag the poller - /// has recorded? - /// - /// - /// A recorded tag that is strictly newer ⇒ - /// — regardless of policy, because an available update is a fact about the registry, not about - /// whether this install intends to take it. - /// Otherwise, with automatic checks OFF () ⇒ - /// . Nothing is polling, so "up to date" would be a - /// claim we have no evidence for. - /// Otherwise ⇒ : the poller is running and has - /// recorded no build newer than this one. (It records only when it finds something newer, so an - /// empty tag — or one this install has since rolled to — IS the up-to-date signal.) - /// - /// - public static PlatformUpdateStatus Derive(UpdatePolicyContent policy, string runningVersion) - { - var latest = policy.LatestAvailableTag; - if (!string.IsNullOrEmpty(latest) && VersionSelect.IsNewer(latest, runningVersion)) - { - // A held update is still an available one — but saying only "available" about a build - // this install has refused would make an indefinitely-frozen deployment look healthy. - // - // 🚨 BOTH refusals count. The availability hold is a poller BOOKKEEPING field, so a - // combo Red that blocked the roll before the hold write landed — or on a tick whose - // hold write failed — would otherwise render as an unqualified "update available" - // forever. The recorded verdict is the fact; IsHeld is the poller's note about it, and - // reading only the note is how a control ends up covering the empty state alone. - // - // 🚨 Red ONLY. A NotVerifiable verdict is not a hold: nothing refused that build, so - // rendering it as held would tell an operator to go fix an incompatibility that was - // never diagnosed. The Updates settings tab renders that state on its own terms. - // - // 🚨 OPERATIVE, not merely recorded (#3812). The line above already draws this - // distinction for the other half — "the recorded verdict is the fact; IsHeld is the - // poller's note about it" — and a note nothing can refresh is the case it did not - // anticipate. With updates switched off the poller evaluates nothing, so the note is - // frozen at whatever the last evaluation said and would render "update held" forever, - // naming an incompatibility that may have been fixed days ago. A Red verification is - // unaffected: that is a recorded FACT about the build, not a note about the poller. - // - // 🚨 SUPERSEDED for the combo half by policy platform-rolls-independently-of-packages: - // a Red verification names modules that do not - // survive the build — a statement about PACKAGES — and the poller rolls over it, so - // rendering it as "held" would be the lie the paragraph above warns about, inverted. - // It reaches this surface as a package ADVISORY instead (HasAdvisories below). - return new( - policy.IsHoldOperative(latest) - ? PlatformUpdateAvailability.UpdateHeld - : PlatformUpdateAvailability.UpdateAvailable, - latest) - { - HasAdvisories = HasAdvisoriesFor(policy, latest), - }; - } - - return policy.Policy == UpdatePolicyKind.None - ? Unknown - // 🚨 The platform ROLLED (the running build is the one the advisories describe): the - // packages that did not follow it are still worth saying until they catch up. - : new(PlatformUpdateAvailability.UpToDate, null) - { - HasAdvisories = HasAdvisoriesFor(policy, runningVersion.Split('+')[0]), - }; - } - - /// - /// Whether package advisories are recorded for — what the release check - /// said about it without holding it (the boot-compile cost, the packages that do not follow - /// the platform roll), or a Red combo verification of it. 🚨 A BOOLEAN on purpose: this record - /// is projected to every user ( reads the policy node as System), and - /// the advisories themselves are package names and diagnostic reasons — admin-scoped, like a - /// hold's reason. Only the fact that they exist escapes; the text renders on the Updates tab. - /// - private static bool HasAdvisoriesFor(UpdatePolicyContent policy, string? tag) => - policy.HasAdvisoriesFor(tag) - || policy.VerificationFor(tag) is { Verdict: ComboVerdictKind.Red }; - - /// - /// 🚨 Whether the release check said something about the build this status is about WITHOUT - /// holding it — packages that do not follow the platform roll and catch up on their own (policy - /// platform-rolls-independently-of-packages). The About tab announces it in one - /// translated sentence; what was said stays on the admin-scoped Updates tab. Init-only, so the - /// positional shape stays what it was. - /// - public bool HasAdvisories { get; init; } - - /// - /// The status for the CURRENT build, read off Admin/UpdatePolicy as System and projected to - /// this record. Safe to call from any user-facing surface: nothing but the verdict (and the newer - /// tag) escapes the Admin partition. - /// - /// Absence is detected with the storm-safe existence GetQuery (empty-on-absent) — - /// NEVER a point GetMeshNodeStream probe of a maybe-absent path, which NotFound-resubscribe- - /// storms on an install that never wired self-update. Every failure mode (missing node, query - /// stall, unparseable content) degrades to , which renders nothing: the About - /// page must never hang and never error over a nice-to-have line. - /// - /// 🚨 The System scope is opened and closed SYNCHRONOUSLY around each subscribe, which is the - /// only moment identity is read (the cache snapshots the caller before the pipeline runs). It is - /// deliberately NOT Observable.Using: impersonation is an AsyncLocal, and - /// Using disposes its resource on whichever thread the sequence terminates on — so the - /// restore never reaches the thread that opened it, leaving the CALLER (here: a user-facing layout - /// render) running as System afterwards. Two scopes for the same reason: the second subscribe - /// happens on the thread delivering the query's emission, where an outer scope was never in force. - /// - /// One-shot (Take(1)) by design: the poller refreshes this at most a few times a day, - /// and a live subscription would hold a System-impersonated read open for the lifetime of the view. - /// Re-opening About re-reads it. - /// - /// Any hub — the read routes to the Admin partition's owner. - /// The build to compare against; defaults to - /// . Tests pass an explicit version because - /// under a test host the entry assembly's informational version is not a platform version at all. - public static IObservable Observe(IMessageHub hub, string? runningVersion = null) - { - var accessService = hub.ServiceProvider.GetService(); - var workspace = hub.GetWorkspace(); - var jsonOptions = hub.JsonSerializerOptions; - var running = runningVersion ?? ShippedReleaseSeed.InstalledPlatformVersion; - - // Build AND subscribe inside the scope; close it on the same thread the moment Subscribe - // returns. See the remarks above for why this is not Observable.Using. - IObservable AsSystem(Func> source) => - Observable.Create(observer => - { - using (AccessContextScope.AsSystem(accessService)) - return source().Subscribe(observer); - }); - - return AsSystem(() => workspace - // Same query KEY the seeding path registers (UpdatePolicyNodeType.EnsureExists), so - // this shares that registration instead of adding a second synced query. - .GetQuery( - $"{UpdatePolicyNodeType.NodeType}|{UpdatePolicyNodeType.NodePath}", - $"path:{UpdatePolicyNodeType.NodePath} nodeType:{UpdatePolicyNodeType.NodeType}") - .Take(1) - .Timeout(TimeSpan.FromSeconds(10))) - .SelectMany(nodes => nodes.Any() - ? AsSystem(() => workspace.GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Take(1) - .Timeout(TimeSpan.FromSeconds(10)) - .Select(node => Derive(UpdatePolicyNodeType.Parse(node, jsonOptions), running))) - : Observable.Return(Unknown)) - .Catch(_ => Observable.Return(Unknown)); - } -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/ReleaseAvailabilityService.cs b/memex/Memex.Portal.Shared/SelfUpdate/ReleaseAvailabilityService.cs deleted file mode 100644 index 0c2583e9c5..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/ReleaseAvailabilityService.cs +++ /dev/null @@ -1,681 +0,0 @@ -using System.Collections.Immutable; -using System.Reactive.Linq; -using MeshWeaver.Data; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// -/// 🚨 The deployment gate (#1754): may this environment be rolled to that release? -/// -/// 🚨 Its answer is a PACKAGE answer and never holds the platform (policy -/// platform-rolls-independently-of-packages). answers -/// truthfully — "cannot determine" is still not "available" — and every roll path (the poller, -/// the reboot, the manual button, /api/plugins/is-updatable, ) -/// passes a refusal through , which turns it -/// into advisories, and rolls to the newest admitted tag. Wherever this type says the package -/// verdict REFUSES, that is the truthful package answer; no roll stops on it. -/// -/// What the package verdict refuses, since #3651 (maintainer rule of 2026-09-07, -/// Doc/Architecture/ModuleAdoptionPolicy), is MEASURED: a module this environment has -/// landed, for which the target publishes no build, whose bytes reference a type the target's -/// platform surface does not carry. A missing content bake is reported ("would recompile at -/// boot: …") and is no refusal — the compile is what every PR of that content already proved -/// green; Modules:RequirePrebuilt keeps it a refusal for the instances that opt in. A declared MinMeshVersion floor is worded onto the -/// advisories and decides nothing. This service is what adds the environment's LANDED generations -/// (the activation sidecar) to the gate's inputs and runs the link measurement against the surface -/// the publication carries. -/// -/// Why the service lives here, in the registry, and not in each portal's poller. An -/// instance only knows itself; it cannot answer "is this release safe for environment X". Memex -/// holds the environment → instance → installed-package mapping and mounts the same published -/// bundle root every bake lane writes to, so it can answer for any environment. The verdict is -/// then readable by all three paths that roll a version — the self-update poll, CD's own -/// post-promote assertion, and a manual kubectl set image (through -/// /api/plugins/is-updatable) — because a gate only one path honours is not a gate. -/// -/// 🚨 The consequence, designed for deliberately: this makes the registry a dependency -/// of the environment's ability to update. When the catalogue cannot be read the answer is -/// — a HOLD, never a pass — and it is reported -/// as an availability failure with its own reason, never dressed up as a compatibility verdict. -/// The one applicability exemption is stated out loud by -/// : a deployment that consumes no CI bakes at all is -/// already compiling at every boot, so holding it could only freeze it forever. -/// -/// What "deployed" means today is the install records in the Plugins partition — -/// the same records the catalog and the bundle index read. Once #1735 lands, the per-environment -/// composition declares what an environment is SUPPOSED to have, which is strictly better to gate -/// on; this service's is the one place that would change. -/// -/// -/// Not sealed, and is virtual: it is the documented injection seam for the -/// poller's gate, exactly as SelfUpdateHostedService.ReadPolicyStream/RecordAvailable -/// are for its two mesh touches. A test can then pin what the POLLER does with a verdict without -/// also staging an artifact store — the verdict itself is pinned separately, against a real one. -/// -public class ReleaseAvailabilityService( - IMessageHub hub, - IConfiguration configuration, - ILogger? logger = null) -{ - private readonly IIoPool pool = - hub.ServiceProvider.GetService()?.Get(IoPoolNames.FileSystem) - ?? IoPool.Unbounded; - - /// Where a release's framework identity is read when this root has no marker for it. - private readonly RegistryReleaseIdentities registryIdentities = - hub.ServiceProvider.GetService() - ?? new RegistryReleaseIdentities(hub, logger); - - /// Where each source's publication is read LIVE when the registry named the identity - /// (policy registry-fed-gate-reads-live-publication). - private readonly RegistryLivePublications livePublications = - hub.ServiceProvider.GetService() - ?? new RegistryLivePublications(hub, logger); - - /// - /// 🚨 The gate's DENOMINATOR, read incrementally (#4742). The mesh-scoped singleton - /// AddSelfUpdate registers; the fall-back is an instance of this service's own, which is - /// itself a mesh-scoped singleton — never a static, so nothing survives mesh disposal. See - /// for why re-walking it every tick froze the fleet. - /// - private readonly SealedBundleFloorCache floorCache = - hub.ServiceProvider.GetService() ?? new SealedBundleFloorCache(); - - /// - /// The self-update knobs, for . Resolved rather than injected so a host - /// that constructs this gate without the poller (the tests, /api/plugins/is-updatable on a - /// minimal host) still gets the shipped defaults. - /// - private readonly SelfUpdateOptions options = - hub.ServiceProvider.GetService() ?? new SelfUpdateOptions(); - - /// The published bundle root this deployment mounts, or null when it consumes no CI - /// bakes. - public string? PublishedRoot => configuration[ShippedPrebuiltBundles.PublishedRootConfigKey]; - - /// - /// The instance's gate policy (#3651): Modules:RequirePrebuilt — off by default — is the - /// one knob that turns a missing content bake from a reported cost back into a hold, because on - /// such an instance the seeder refuses the boot compile and the type would park. Read from the - /// SAME configuration the published root comes from — the host's — through the seeder's own - /// parse rule, so the gate and the seeder cannot disagree about what the key says. - /// - public ReleaseGatePolicy Policy => - new(PrebuiltAssemblySeeder.RequirePrebuiltFromValue( - configuration[PrebuiltAssemblySeeder.RequirePrebuiltConfigKey])); - - /// The module root every landed generation lives under — the same resolution the - /// landing service and the boot loader share, so the gate measures the bytes boot would load. - private string ModuleRootPath => ModuleRoot.Resolve(configuration); - - /// - /// 🚨 Does this gate APPLY to this deployment at all? Returns the reason it does not, or - /// null when it does. - /// - /// Decided from CONFIGURATION alone, which is what makes it answerable by a caller that - /// has no instance of this service — and that is the whole point. "Cannot verify" and "verified - /// as nothing to verify" are different states, and only the first may hold. A deployment that - /// consumes no CI bakes already compiles its content at every boot, so a registered gate would - /// answer for it; the gate being ABSENT on such a - /// deployment tells you nothing new, and holding on it would freeze an environment the gate was - /// never going to protect. - /// - /// Static and shared on purpose: the poller's unwired path (#1754) and - /// must reach the same applicability answer, and a rule that only one - /// caller honours is not a rule. - /// - /// The host's configuration; null reads as "nothing configured". - public static string? NotApplicableReason(IConfiguration? configuration) => - string.IsNullOrWhiteSpace(configuration?[ShippedPrebuiltBundles.PublishedRootConfigKey]) - ? $"this deployment consumes no CI bakes ({ShippedPrebuiltBundles.PublishedRootConfigKey} " - + "is not configured), so it already compiles its content at every boot — the " - + "release-availability gate has nothing to enforce here" - : null; - - /// - /// Is a release this environment may be rolled to? Cold — - /// the file-system leaves run on the pool, never on a hub - /// action block — and total: every failure resolves to a HOLD carrying its reason, so a - /// caller can subscribe without a Catch that would turn an incident into a pass. - /// - public virtual IObservable IsUpdatable(string? targetVersion) => - Observable.Defer(() => - { - // ONE applicability rule, shared with the poller's unwired path — see NotApplicableReason. - if (NotApplicableReason(configuration) is { } notApplicable) - return Observable.Return(UpdatabilityVerdict.NotEnforced(notApplicable)); - - var publishedRoot = PublishedRoot!; - - // 🚨 The DENOMINATOR is read FIRST, and from every identity the root holds except the - // one under judgement — never from the target's own publication (#3441). Read - // incrementally (#4742): the identities this process has already read are not walked - // again, because the store is append-only and a full walk outgrew this budget. - return floorCache - .Observe(pool, publishedRoot, logger) - .SelectMany(f => Verdict(f, publishedRoot, targetVersion)); - }) - // 🚨 The gate must ANSWER, always. Its two inputs can each stall indefinitely — a mesh - // query that never emits its initial snapshot, an I/O pool slot that never frees — and a - // gate that hangs is strictly worse than one that refuses: the poller's tick never - // completes, so the update neither applies NOR records a hold, and the environment freezes - // with nothing anywhere saying why. The timeout converts a stall into the honest answer - // (Indeterminate ⇒ HOLD, named), which the very next tick re-evaluates from scratch. - .Timeout(AnswerBudget) - .Catch((Exception ex) => Observable.Return(ReleaseAvailability.IsUpdatable( - new ReleaseTarget(targetVersion, null), - [], - ReleaseArtifacts.Unreadable(ex is TimeoutException - ? $"the availability check did not answer within {AnswerBudget.TotalSeconds:0}s" - : ex.Message)))); - - /// - /// How long the whole verdict may take. Generous — it bounds a stall, it is not a performance - /// budget: the reads behind it are a mesh query and a handful of directory stats, so anything - /// approaching this is wedged rather than slow. Deliberately shorter than the poll interval, so - /// a stalled tick can never overlap the next one. - /// - /// 🚨 Configurable (SelfUpdate__AvailabilityAnswerBudget), and that is the - /// SECONDARY half of #4742 — never the fix. What blew this budget was the denominator being - /// re-enumerated from the share on every tick (); widening a - /// bound over a store that only grows buys a longer freeze with the same ending. The key exists - /// so an operator whose share is genuinely slow can state that in configuration rather than wait - /// for a release — and a non-positive value falls back to - /// , because "no timeout" would - /// turn a stall into a tick that never completes, which is the failure this bound exists to - /// convert into an honest hold. - /// - private TimeSpan AnswerBudget => - options.AvailabilityAnswerBudget > TimeSpan.Zero - ? options.AvailabilityAnswerBudget - : SelfUpdateOptions.DefaultAvailabilityAnswerBudget; - - /// - /// 🚨 The roll SELECTOR (#3479): which release should this environment be on? - /// - ///
Maintainer, 2026-09-06: "Whenever a new platform / plugin is published, check - /// for each environment which is the latest platform version shipping all plugins, if different - /// from current version ⇒ update."
- /// - /// 🚨 It selects the NEWEST ADMITTED candidate, always (policy - /// platform-rolls-independently-of-packages). The blockquote above is the question the - /// selector was built to answer; a package answer no longer decides it. The head of the - /// candidate list is judged by exactly the code runs — - /// over - /// 's observation — and that verdict is passed through - /// by the walk in : - /// a package that is not available for the release, and an availability that could not be - /// determined, become on the SELECTED release. - /// No candidate is declined, so a caller must not implement a fallback to an older release. - /// - /// What each unreadable input becomes. The plugin list unread or empty, or the - /// published root unreadable while the caller supplied the candidates: the head is still - /// selected and the advisory says that no package was checked - /// (). The published root unreadable with NO candidates - /// supplied (they would have come from that root), or a candidate universe that could not be - /// listed: — nothing can be selected, and that - /// is said. A deployment that consumes no CI bakes: . - /// A selection that does not answer within the budget, or faults: Indeterminate. - /// - /// It costs ONE inventory read and ONE candidate observation per selection. The - /// mesh query and the monotone denominator are read once, and only the head is observed. - /// - /// Cold and total, like : every failure resolves to an outcome - /// carrying its reason, so a caller can subscribe without a Catch. - ///
- /// What this environment runs — different from current ⇒ - /// update is the selection's own comparison, so it has to be given. - /// The releases to choose from, newest first. Null ⇒ every - /// release the published root carries a marker for, ordered by under - /// — which lets an environment answer for itself without listing a - /// container registry. - /// Which releases are eligible when the candidates are derived. Default - /// : which releases exist is a question about the - /// PUBLICATIONS, and whether this install would apply the answer is a separate decision that - /// stays with the poller. - /// Optional second opinion about each candidate — e.g. the combo gate's - /// recorded Red. Returns what it says, or null when it has nothing against the candidate. - /// 🚨 It is an ADVISORY on the selected release (policy - /// platform-rolls-independently-of-packages), never a reason to walk past it. - /// Exclude unverified edge builds when deriving candidates. - public virtual IObservable SelectRollTarget( - string? currentVersion, - IReadOnlyList? candidatesNewestFirst = null, - Func? condemned = null, - UpdatePolicyKind policy = UpdatePolicyKind.Continuous, - bool requireCiGreen = true) => - Observable.Defer(() => - { - var environment = EnvironmentName(); - - // The ONE applicability rule, shared with IsUpdatable and the poller's unwired path. - // A deployment that consumes no CI bakes has nothing to select ON: it compiles its - // content at every boot, so every release ships all its plugins in the only sense that - // can be observed here. Saying so is not the same as selecting the newest. - if (NotApplicableReason(configuration) is { } notApplicable) - return Observable.Return(NotEnforced(environment, currentVersion, notApplicable)); - - var publishedRoot = PublishedRoot!; - - return floorCache - .Observe(pool, publishedRoot, logger) - .SelectMany(f => - // 🚨 Tested FIRST, and separately from ServesBakes: a configured root that does - // not exist or faults on read produces a floor with no bundles — the SAME SHAPE - // as a root that genuinely serves none — and they mean opposite things. See - // Verdict() for the full reasoning; this is the same order, one level up. - f.Refusal is { } refusal - // 🚨 An unreadable root is a PACKAGE question that could not be asked, so - // it holds nothing (policy - // platform-rolls-independently-of-packages). With the candidates in - // hand the - // newest admitted one is selected and the advisory says nothing was - // checked; without them (they would have come from that very root) there - // is nothing to select, and that is still said as Indeterminate. - ? Observable.Return(candidatesNewestFirst is { Count: > 0 } - ? RollSelection.Unjudged( - new RollSelectionInputs(environment, currentVersion, - [.. candidatesNewestFirst], - PluginInventory.Unreadable(InventorySource, refusal)), - $"the published bundle root could not be read ({refusal}), so no " - + "package was checked against this release; the packages catch up " - + "through their own update policy") - : Indeterminate(environment, currentVersion, refusal)) - : !f.ServesBakes - ? Observable.Return(NotEnforced( - environment, currentVersion, NoPublicationsReason(publishedRoot))) - : Select(environment, currentVersion, publishedRoot, f, - candidatesNewestFirst, condemned, policy, requireCiGreen)) - .Do(outcome => logger?.LogInformation("[RollSelect] {Summary}", outcome.Summary)); - }) - // 🚨 The selector must ANSWER, always — same reasoning as IsUpdatable's budget. A selector - // that hangs is worse than one that declines: the tick never completes, so the environment - // neither updates NOR records why, and freezes with nothing anywhere saying so. - .Timeout(AnswerBudget) - .Catch((Exception ex) => Observable.Return(Indeterminate( - EnvironmentName(), - currentVersion, - ex is TimeoutException - ? $"the roll selection did not answer within {AnswerBudget.TotalSeconds:0}s" - : ex.Message))); - - /// - /// The selection proper, once the gate is known to apply and the denominator's floor has been - /// read: list the candidates, read the inventory ONCE, then walk. - /// - private IObservable Select( - string environment, - string? currentVersion, - string publishedRoot, - SealedBundleFloor floor, - IReadOnlyList? candidatesNewestFirst, - Func? condemned, - UpdatePolicyKind policy, - bool requireCiGreen) => - Candidates(publishedRoot, candidatesNewestFirst, policy, requireCiGreen) - .SelectMany(candidates => candidates.Refusal is { } listing - // 🚨 An unlistable candidate universe is an "I could not look", never an empty - // choice: reporting it as "no releases to choose from" would read as a healthy - // up-to-date environment when the storage is simply unreachable. - ? Observable.Return(Indeterminate(environment, currentVersion, listing)) - : RequiredPackages(floor).SelectMany(required => RollSelection.Select( - new RollSelectionInputs( - environment, - currentVersion, - candidates.Versions, - PluginInventory.Of(required, InventorySource)), - version => Observe(publishedRoot, required, version, condemned)))); - - /// - /// One candidate's verdict, produced by exactly the code runs — the - /// SHARED predicate, handed the inventory the walk already read rather than re-reading it per - /// candidate — and then, only if it cleared, the caller's second opinion. - /// - private IObservable Observe( - string publishedRoot, - ImmutableArray required, - string version, - Func? condemned) => - Judge(publishedRoot, required, version) - .Do(LogAdvisories) - .Select(verdict => condemned?.Invoke(version) is { } refusal - // 🚨 The second opinion (the combo gate's recorded Red) is a statement about the - // modules this instance has landed — a PACKAGE question — so under policy - // platform-rolls-independently-of-packages it - // is an ADVISORY on the candidate, never a reason to walk past it. - ? verdict.WithAdvisories( - [ - .. verdict.AdvisoryTexts, - LocalizableText.Keyed( - $"the platform rolls independently of packages — {refusal}; those modules " - + "keep their previous generation and catch up through their own update policy", - ReleaseAdvisoryCode.ModulesDoNotFollow, ("refusal", refusal)), - ]) - : verdict); - - /// - /// The candidate universe: what the caller supplied, or every release the published root has a - /// marker for, ordered newest-first by . A caller-supplied list is - /// taken AS ORDERED — it already applied its own policy. - /// - private IObservable Candidates( - string publishedRoot, - IReadOnlyList? supplied, - UpdatePolicyKind policy, - bool requireCiGreen) => - supplied is not null - ? Observable.Return(new PublishedReleaseCatalogue([.. supplied], null)) - : PublishedBundleCatalogue - .ObserveReleases(pool, publishedRoot, logger) - .Select(catalogue => catalogue.Refusal is not null - ? catalogue - : catalogue with - { - Versions = - [ - .. VersionSelect.PickTargets( - catalogue.Versions, policy, requireCiGreen), - ], - }); - - /// - /// 🚨 WHAT "this environment's plugins" means, answered once and named in every outcome. - /// - /// It is the install records — Plugins/*, nodeType:Package — and the - /// reason is not that they are the only reading available. They are not: an instance's modules - /// also arrive as per-Space _GitSync entries, and folds - /// both because a reader of one alone under-reports (measured on memex 2026-08-10: 42 sync - /// entries, ZERO install records). Two things nonetheless decide it here: - /// - /// The gate already reads them, and the predicate is SHARED. A selector whose - /// denominator differs from the gate's could choose a release the gate then holds — two - /// completeness answers about one environment, which is the drift this change exists to - /// prevent. - /// Only an install record carries the package id the bake names its bundle - /// by. A sync entry names a PARTITION; the published root holds <bundle>.zip. - /// Folding sync entries in would add names that can never match a bundle and hold every - /// environment forever. - /// - /// - /// 🚨 The failure mode of that source — a read that answers EMPTY — is no longer a pass: - /// refuses it by name. That is the whole point of - /// stating the source rather than assuming it. - /// - private const string InventorySource = - "the install records (Plugins/*, nodeType:Package)"; - - /// The environment being selected FOR. The algorithm's quantifier is per environment, - /// so an outcome that cannot name one says so rather than pretending to be global. - private string EnvironmentName() => - configuration[DeploymentReportService.DeploymentKey] is { Length: > 0 } deployment - ? deployment - : "this deployment (Hosting:Deployment is not configured)"; - - private static RollSelectionOutcome NotEnforced( - string environment, string? currentVersion, string reason) => - new(RollSelectionKind.NotEnforced, null, currentVersion, 0, 0, [], - $"{environment}: no release is selected — {reason}"); - - private static RollSelectionOutcome Indeterminate( - string environment, string? currentVersion, string reason) => - new(RollSelectionKind.Indeterminate, null, currentVersion, 0, 0, [], - $"{environment}: the roll selection could not be made ({reason}) — the releases to " - + "choose from could not be determined, so nothing is selected."); - - /// - /// The verdict, given what the denominator observation turned out to be. Three outcomes, and - /// the ORDER is the whole point. - /// - /// 🚨 An unreadable root is a REFUSAL (indeterminate), and it is tested FIRST. A configured root that - /// does not exist, or that faults on read, produces a floor with no bundles — the same shape as - /// a root that genuinely serves no bakes. They mean opposite things: the first is a mis-mounted - /// volume or a mistyped path on a deployment that DECLARES it consumes CI bakes (an availability - /// incident: cannot determine, which is not clearance to proceed), the second is the one stated - /// applicability exemption. Collapsing them would let a mis-mount clear the gate — the exact - /// vacuity this change removes, reintroduced one level up. - /// - private IObservable Verdict( - SealedBundleFloor floor, string publishedRoot, string? targetVersion) - { - // 🚨 UpdatabilityVerdict.Unavailable, not IsUpdatable(target, [], Unreadable(...)): the - // latter answers IsUpdatable=false but IsIndeterminate=FALSE, because that property reads - // the per-package verdicts and an empty package list has none. Callers separate "I could - // not look" (an availability incident to fix) from "I looked and it is incompatible" (a - // release to re-bake) on exactly that flag, so an unreadable denominator reported without - // it would be surfaced as a compatibility verdict about the release — the conflation - // #1754 forbids. - if (floor.Refusal is { } refusal) - return Observable.Return(UpdatabilityVerdict.Unavailable(refusal)); - - if (!floor.ServesBakes) - return Observable.Return( - UpdatabilityVerdict.NotEnforced(NoPublicationsReason(publishedRoot))); - - return RequiredPackages(floor) - .SelectMany(required => required.IsEmpty - // 🚨 THE VACUITY REFUSAL (#3479). With no packages required, every release is - // vacuously available and this gate answers a confident green having compared two - // empty sets — the SAME shape #3441 removed from HasContent, reappearing at the - // package LIST itself. It is a REFUSAL, and Unavailable rather than a - // compatibility verdict because the remedy is to fix the read (or to stop - // declaring that this deployment consumes CI bakes), never to re-bake a release. - // - // The case is not hypothetical: this environment's install records measured ZERO - // on 2026-08-10 while it carried 42 modules. On a roll path it is an advisory - // saying no package was checked; the verdict is re-evaluated from scratch on every - // tick, so it disappears once the portal has written its records. - ? Observable.Return(UpdatabilityVerdict.Unavailable(VacuousDenominatorReason)) - : Judge(publishedRoot, required, targetVersion).Do(LogAdvisories)); - } - - /// - /// 🚨 THE SHARED PREDICATE, with its two IO halves (#3651) — read the target's publication - /// (), link every landed module against the surface - /// it carries (), then decide - /// (). - /// Both IO halves run on the file-system pool, never on a hub action block; the rule is pure. - /// Every roll path — the poller's selection, the per-version verdict, the endpoints — comes - /// through here, so no caller can judge a candidate without the measurement. - /// - private IObservable Judge( - string publishedRoot, ImmutableArray required, string? version) - { - var policy = Policy; - // A root no publisher writes to carries no marker for ANY release (policy - // release-identity-from-registry): the registry's own record is asked then, and only then. - // The publisher's marker, where there is one, is read without a network call. - return pool.InvokeBlocking(_ => PublishedBundleCatalogue.HasReleaseMarker(publishedRoot, version)) - // false = established absent. Present, or undetermined, never asks the registry. - .SelectMany(marked => marked == false - ? registryIdentities.Resolve(version).Select(record => record?.Identity) - : Observable.Return(null)) - // 🚨 A registry-fed root holds a SNAPSHOT (policy registry-fed-gate-reads-live-publication, - // #6622): where the registry named the identity, each source's publication is read LIVE, - // now, and that — not what this pod pulled at start — is the sealed set judged. - .SelectMany(registryIdentity => registryIdentity is null - ? Observable.Return<(string? Identity, LivePublicationReading? Live)>((null, null)) - : pool.InvokeBlocking(_ => PublishedBundleCatalogue.SourcesOnRoot(publishedRoot)) - .SelectMany(sources => livePublications.Read(registryIdentity, sources)) - .Select(live => (Identity: (string?)registryIdentity, Live: (LivePublicationReading?)live))) - .SelectMany(resolved => pool.InvokeBlocking(_ => - { - var observation = PublishedBundleCatalogue.ReadWithLivePublication( - publishedRoot, version, resolved.Identity, resolved.Live, logger); - var measured = ModuleLinkObservation.Measure(observation.Artifacts, required, logger); - return ReleaseAvailability.IsUpdatable(observation.Target, required, measured, policy); - })); - } - - /// - /// 🚨 The advisories SAY something and decide nothing, so they are logged (Information) beside - /// the verdict rather than folded into it: the boot-compile cost and a link check that could - /// not be made (#3651), and the declared floors (#3648). On 2026-09-07 the floor sentences were - /// the HOLD reasons ("77 plugins required … every one declined"); now they are what an - /// operator reads while the roll proceeds on what is measured. - /// - private void LogAdvisories(UpdatabilityVerdict verdict) - { - if (verdict.Advisories.IsDefaultOrEmpty) - return; - logger?.LogInformation( - "[ReleaseGate] {Count} advisory line(s) beside the verdict — reported, never a hold " - + "(#3651/#3648): {Advisories}", - verdict.Advisories.Length, string.Join("; ", verdict.Advisories)); - } - - /// Why an empty denominator is a refusal rather than a pass — see the call site. - private const string VacuousDenominatorReason = - "this deployment consumes CI bakes and its published root serves them, yet it reports ZERO " - + "installed packages — so every release would 'ship all' of nothing and this gate would " - + "pass having compared two empty sets. Cannot determine package availability, which is " - + "never read as available. Either the install records (Plugins/*, nodeType:Package) are not " - + "being read, or this deployment genuinely deploys no packages — in which case " - + $"{ShippedPrebuiltBundles.PublishedRootConfigKey} should not be configured for it."; - - /// - /// Why the gate does not apply to a root that holds no sealed publication under ANY identity. - /// - /// 🚨 This case used to be a silent PASS, and it was the worst one. With no publication - /// under the live identity every installed package was judged non-content-bearing, so the - /// content half of the gate checked NOTHING and still reported updatable — a green tick over - /// zero evidence. It is stated here instead: an instance whose root serves no bakes already - /// compiles its content at every boot, exactly like one with no root configured, so the honest - /// answer is the same — updatable with a - /// reason the caller logs, never a verdict that pretends to have looked. - /// - private static string NoPublicationsReason(string publishedRoot) => - $"the published bundle root '{publishedRoot}' holds no sealed publication under any " - + "framework identity, so this deployment adopts nothing today and already compiles its " - + "content at every boot — the release-availability gate has nothing to enforce here. " - + "(If that is unexpected, the bake lanes are not reaching this environment's storage: " - + "check BAKE_PUBLISH_TARGETS and the publish-bake runs.)"; - - /// - /// What this environment deploys, as the gate's inputs. - /// - /// 🚨 Content is required by EVIDENCE, and the evidence may not come from the artifact - /// under judgement (#3441). A package counts as content-bearing when it has EVER been - /// sealed under any framework identity this root holds - /// () - /// — not, as before, when it happens to be sealed under the identity running right now. - /// - /// The old reading made the denominator a function of the very thing that breaks: a - /// package whose bake stopped being produced silently left the set of packages the gate asks - /// about, so every subsequent roll was green about precisely the package that had regressed — - /// "we kept rolling without edu being properly baked". Reading it monotonically makes - /// the gate say what it is for: what this environment has ever been able to adopt, it must - /// still be able to adopt. - /// - /// The exemption that made the old reading attractive is preserved exactly, and for the - /// same reason: a module-only or NodeType-less package produces no bundle ever, has therefore - /// never been sealed under any identity, and is still not demanded — demanding one would hold - /// that environment forever, and an environment silently frozen for weeks is its own - /// outage. - /// - private IObservable> RequiredPackages(SealedBundleFloor floor) => - InstalledPackages() - // The activation sidecar is a file read, so the package list is built on the - // file-system pool rather than on the query's hub thread. - .SelectMany(installed => pool.InvokeBlocking(_ => BuildRequiredPackages(installed, floor))); - - /// - /// The gate's inputs from the install records plus the ACTIVATION SIDECAR (#3651): each - /// package's module by name (the record's module field) and, when a generation of it is - /// landed on this instance, the entry DLL of the ACTIVE generation — through the one resolution - /// rule boot and the landing service share (), - /// so what the gate links against the target's surface is exactly what boot would load. - /// - private ImmutableArray BuildRequiredPackages( - ImmutableArray installed, SealedBundleFloor floor) - { - var moduleRoot = ModuleRootPath; - var landed = ModuleActivationSidecar.Read(moduleRoot, - message => logger?.LogWarning("[ReleaseGate] activation sidecar: {Message}", message)) - .Entries - .Where(entry => entry.Enabled && !string.IsNullOrWhiteSpace(entry.Name)) - .GroupBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase); - - // The build this environment RUNS: bytes adopted under the running platform's compatibility - // key (policy platform-backwards-compatibility). Its key and any ceiling its bundles declare - // are what the declared-break hold compares a target against; on the ordinary path the - // target carries the same key and no ceiling is declared, so nothing here can hold a roll. - var liveKey = PrebuiltAssemblySeeder.LiveFrameworkMvid; - var (installedRanges, rangeRefusal) = PublishedBundleCatalogue.RangesForIdentity(PublishedRoot, liveKey, logger); - var required = installed - .Select(manifest => new RequiredPackage( - manifest.Id, - manifest.Id, - // 🚨 #3648 — the declared floor passes through AS DECLARED. It used to - // be filtered to "only when the running platform already satisfies it" - // (LiveFloorOf), a regression-check reading that still held every - // production portal on 2026-09-07: the predicate treated an unmet floor - // as a BLOCKER. It is an advisory now (UpdatabilityVerdict.Advisories), - // so there is nothing to protect the verdict from. - manifest.MinMeshVersion, - floor.Bundles.Contains(manifest.Id)) - { - ModuleName = string.IsNullOrWhiteSpace(manifest.Module) ? null : manifest.Module, - LandedModulePath = LandedPathOf(moduleRoot, manifest.Module, landed), - InstalledKey = liveKey, - InstalledCeiling = installedRanges.TryGetValue(manifest.Id, out var range) ? range.Ceiling : null, - InstalledRangeUnreadable = rangeRefusal - ?? (installedRanges.TryGetValue(manifest.Id, out var read) ? read.Unreadable : null), - }) - .ToImmutableArray(); - // 🚨 PRINT THE DENOMINATOR. A completeness gate whose expected count nobody can - // read is one nobody can tell from a vacuous one — the count being non-zero is - // the claim, so it is logged rather than inferred from a pass. - logger?.LogInformation( - "[ReleaseGate] denominator: {ContentBearing} of {Installed} installed package(s) " - + "carry content ({Landed} with a landed module generation to link), taken from " - + "{Identities} framework identity(ies) this root has published: {Packages}", - required.Count(p => p.HasContent), - required.Length, - required.Count(p => p.LandedModulePath is not null), - floor.Identities, - string.Join(", ", required.Where(p => p.HasContent).Select(p => p.Name).Order(StringComparer.Ordinal))); - return required; - } - - /// The ACTIVE landed generation's entry DLL for a package's module, or null when the - /// package ships no module, none is landed, or the landed bytes are gone (then nothing would - /// load across the roll, and there is nothing to measure). - private static string? LandedPathOf( - string moduleRoot, string? moduleName, IReadOnlyDictionary landed) - { - if (string.IsNullOrWhiteSpace(moduleName) || !landed.TryGetValue(moduleName, out var entry)) - return null; - return ModuleActivationBoot.LandedModuleDllExists(moduleRoot, entry) - ? ModuleActivationBoot.LandedDllPath(moduleRoot, entry) - : null; - } - - /// - /// This environment's install records — the same query the bundle index serves from, so the - /// gate and the catalogue can never disagree about what is installed. - /// - private IObservable> InstalledPackages() => - hub.ServiceProvider.GetRequiredService() - .Query(MeshQueryRequest.FromQuery( - $"namespace:{PackageInstaller.InstalledPartition} " - + $"nodeType:{PackageInstaller.PackageNodeType}")) - .Where(change => change.ChangeType == QueryChangeType.Initial) - .Take(1) - .Select(change => change.Items - .Select(node => node.ContentAs(hub.JsonSerializerOptions)) - .Where(manifest => manifest is { Id.Length: > 0 }) - .Select(manifest => manifest!) - .ToImmutableArray()); -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateConfiguration.cs b/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateConfiguration.cs deleted file mode 100644 index 74cc240efa..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateConfiguration.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Microsoft.Extensions.Configuration; -using MeshWeaver.Mesh; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.PluginCatalog; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// Wires the platform self-update feature: the Admin/UpdatePolicy node type, the -/// ACR tag lister + Kubernetes deployment updater seams, and the polling hosted service. -public static class SelfUpdateConfiguration -{ - /// Registers the self-update poller and its dependencies. Runs in the Distributed portal - /// and the Monolith; on a non-Kubernetes host it degrades to detect-and-notify (it records the - /// available version but patches nothing). NOT registered in the MAUI client (no hosted-service - /// lifecycle there — MAUI detect-and-notify is handled separately). - public static TBuilder AddSelfUpdate(this TBuilder builder, SelfUpdateOptions? options = null) - where TBuilder : MeshBuilder - { - builder.AddUpdatePolicyType(); - builder.ConfigureServices(services => - { - // Admin/UpdatePolicy governs the PLATFORM IMAGE roll only. Until 2026-09-14 it also - // gated every module's unattended landing (PlatformModuleUpdatePolicy → the retired - // IModuleUpdatePolicy seam); each package now carries its own policy on its install - // record (PackageManifest.UpdatePolicy) — "separate for the platform and for each module". - // The deployment gate (#1754): "may this environment be rolled to that release?". - // Registered unconditionally — the SAME verdict has to be readable by all three paths - // that roll a version (the poller below, CD's post-promote assertion and a manual - // kubectl set image via /api/plugins/is-updatable), and a gate wired for only one of - // them is not a gate. Platform-neutral (a query plus file-system reads). - services.AddSingleton(); - // The last check's verdict, readable from OUTSIDE the instance: /health's `self_update` - // entry (policy control-first-never-silent). A mesh-scoped instance, never static. - services.TryAddSingleton(); - // 🚨 The gate's DENOMINATOR, remembered per framework identity (#4742). A mesh-scoped - // singleton, so its lifetime is the mesh's and nothing survives disposal: the published - // root is append-only, and re-walking every identity it has ever held — on a network - // share, inside the verdict budget — is what held memex.meshweaver.cloud six days behind - // main while CD kept sealing. Registered beside the gate rather than inside it so the - // poller, /api/plugins/is-updatable and CD's post-promote assertion share one reading. - services.AddSingleton(); - // 🚨 The COMBO gate (#2274): "can that image still serve the modules this instance has - // landed?" — the question an artifact check cannot answer, and the one that would have - // caught the memex.systemorph.com trap. Registered unconditionally and for the same - // reason as the availability gate: every path that rolls a version has to honour the - // same verdict. Producing one needs docker (see IComboGateRunner) and is therefore - // off-cluster; with no runner registered this gate CONSULTS the verdict landed on - // Admin/UpdatePolicy, and a candidate with no verdict clears nothing. - // Platform-neutral (a policy read plus, where a runner exists, file-system + process IO). - services.AddSingleton(); - // 🚨 GitSync's "is this instance behind?" (ModuleSyncDecision.HoldUnverifiedFloors): a - // lagging instance does not take sources whose platform floor was not stamped for them. - // Platform-neutral (a policy read), so registered beside the gates. - services.TryAddSingleton(); - // Bind from configuration when the caller passes nothing. Without this the defaults were - // baked into the image and a SelfUpdate__* value in the configmap silently did nothing — - // the failure mode where an operator sets a knob, sees no effect, and concludes - // self-update is dead. (The former PollInterval is now RetryInterval: the check is - // event-driven, so the value only paces re-establishing a faulted watch.) - services.AddSingleton(sp => - options - ?? sp.GetService()?.GetSection(SelfUpdateOptions.SectionName) - .Get() - ?? new SelfUpdateOptions()); - // The poller lists ACR tags (Azure.Identity) and patches k8s deployments (X.509/TLS) via - // APIs that are [UnsupportedOSPlatform("browser")]. It is a server-side hosted service and - // is never wanted in a Blazor WASM client, so skip registration on browser. The guard also - // satisfies CA1416 for the browser-unsupported impl types without cascading the platform - // attribute up through AddSelfUpdate's callers (this is a browser-supporting assembly). - if (!OperatingSystem.IsBrowser()) - { - // 🚨 TryAdd, and the AKS/ACR module registers the real ones with a plain AddSingleton. - // The pairing is ORDER-INDEPENDENT: whichever runs first, the LAST registration of a - // service type is what GetRequiredService returns and TryAdd declines when any - // registration already exists. Module listed ⇒ real ACR reader + Kubernetes patcher; - // module absent ⇒ these fallbacks keep the poller's constructor resolvable and put it - // in the detect-and-notify state IDeploymentUpdater.CanPatch has always described. - // - // The POLLER stays here, never in a module: self-update is how a deployment receives - // new bits — including modules — so gating it behind one would mean an install that - // lost the module could not update anything, including re-installing the module. - services.TryAddSingleton(); - services.TryAddSingleton(); - // The OCI Distribution lister for an install whose SelfUpdate:Registry is NOT an - // Azure Container Registry — the read-through mirror another installation serves - // (#3353). Registered by the PLATFORM, not the AKS module: it needs no Azure SDK - // and no Kubernetes, only the plugin-registry credential this install already - // holds, and the poller selects it by registry host at check time. The - // IAcrTagLister above (module-supplied or the NoRegistry fallback) stays exactly - // what it was for an ACR host. - services.AddSingleton(); - // ONE instance, three roles: the hosted poller, and the restart half of an explicit - // module reload (IModuleActivationRestart, Doc/Architecture/ModuleReload) — so a - // reload restarts through exactly the path the poller does, never a second one. - services.AddSingleton(); - services.AddHostedService(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); - // …and the image + roll halves of an instance reboot (Doc/Architecture/InstanceReboot). - services.AddSingleton(sp => sp.GetRequiredService()); - } - return services; - }); - return builder; - } -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHandover.cs b/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHandover.cs deleted file mode 100644 index d77e08c4dd..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHandover.cs +++ /dev/null @@ -1,641 +0,0 @@ -using System.Globalization; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using MeshWeaver.Data; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// Who APPLIES a platform release this install has detected (MeshWeaver#4098). -public enum SelfUpdateApply -{ - /// The install patches its own workloads — the pre-P3b shape, kept while the chart - /// still renders the self-patch Role (selfUpdate.canPatch: true). - SelfPatch, - - /// The install hands the release to the control instance's inbox and the control - /// plane opens the Roll — the end state: no portal holds a credential that changes - /// the cluster. - ControlLane, - - /// The install can neither patch nor hand over: it records the release for a person - /// to act on (the Monolith, a dev host, or a fleet instance whose control inbox is not - /// declared — the verdict names what is missing). - DetectOnly, -} - -/// -/// 🚨 The hand-over of a detected release (or a pending module restart) to the control lane — -/// the apply half of self-update once a portal no longer patches itself -/// (MeshWeaver#4098, P3b of Hosting/AksOperationsViaActions; -/// Doc/Architecture/SelfUpdateControlLane). -/// -/// The install keeps DETECTING exactly as before — the registry watch, Admin/UpdatePolicy, -/// the availability and combo gates all still decide WHICH tag is announced — and replaces the -/// Kubernetes PATCH with ONE signed event into the control instance's inbox -/// (/api/hooks/Hosting/PlatformBuilds, GitHub-style X-Hub-Signature-256 over the raw -/// body). The control plane turns it into a Hosting/InstanceAction Roll on this -/// deployment's record. That ROUTED Roll runs UNATTENDED when the record's updatePolicy is -/// Continuous and the tag is admitted both by the pattern this instance's policy admitted it -/// under and by the record's own update pattern, which is the ceiling (MeshWeaver.Plugins -/// ActionsExecutor.AdmittedUnattended); anything else — another policy, a tag outside the -/// record's pattern, a person's later edit of the action — waits for an approval in the mesh. No -/// pinned tag is involved: records do not pin an image (policy -/// platform-backwards-compatibility; the update policy chooses the image at roll time). -/// -/// The channel is the one every portal already has to the control instance — the -/// pair the Feedback hand-over uses (Hosting:ControlInbox:Url + Hosting:ControlInbox:Secret), -/// with the URL derivable from Hosting:ReportTo (the control instance the inventory report -/// goes to) so a consumer declares the control instance once. The control instance itself, which -/// lists Hosting/PlatformBuilds as a webhook target, delivers into its own inbox in-process -/// (). Hosting:Deployment — the record id — is required on every -/// route: the control plane routes by it, and an event naming no record is not a hand-over. -/// -/// 🚨 Idempotency lives on the control plane, not here. Every check that selects a -/// target announces it — the safety net makes that at most hourly — and the control plane treats -/// (deployment, newImage) as one request while an action for it is open or done. Re-delivery -/// is what closes a lost event without a watchdog: a delivery the control instance accepted and -/// could not route is announced again by the next check, and the first control plane that can -/// route it does. The instance owns detection; the roller owns pacing. -/// -/// Pure where it decides (, , -/// , ) so the rules are pinned without a mesh; the delivery -/// is the one IO edge, on the Http pool. Never logs, echoes or stores a secret. -/// -public class SelfUpdateHandover -{ - /// The event a detected platform release is announced as. - public const string ReleaseEvent = "self-update-available"; - - /// The event a pending module restart (restart-as-activation, #3650) is announced as. - public const string RestartEvent = "self-update-restart-pending"; - - /// The control inbox URL — the fleet's https://memex.systemorph.com/api/hooks/Hosting/PlatformBuilds. The same key the Feedback hand-over binds. - public const string UrlKey = "Hosting:ControlInbox:Url"; - - /// The HMAC secret the control inbox verifies — either byte-identical to the control instance's Hosting:PlatformWebhookSecret (the fleet secret), or this deployment's OWN announcement key, which the control instance holds as Hosting:PlatformWebhookSecret:{deployment} (MeshWeaver.Plugins#1913, Doc/Architecture/SelfUpdateAnnouncementKey). Never read except to sign. - public const string SecretKey = "Hosting:ControlInbox:Secret"; - - /// The control instance's base URL (the inventory report's destination); the inbox URL is derived from it when is absent. - public const string ReportToKey = DeploymentReportService.ReportToKey; - - /// This instance's Hosting/Deployment record id on the control instance — what the Roll is opened on. - public const string DeploymentKey = DeploymentReportService.DeploymentKey; - - /// The webhook target the control instance drains; the LOCAL route delivers to it in-process. - public const string InboxTarget = "Hosting/PlatformBuilds"; - - /// The inbox route appended to when no is set. - public const string InboxRoute = "/api/hooks/" + InboxTarget; - - /// The fleet's name for the control instance's own inbox secret — what a listed - /// Hosting/PlatformBuilds target DECLARES as its SecretConfigKey. Informational: the - /// local route reads the key the target actually declares and never assumes this one. - public const string LocalSecretKey = "Hosting:PlatformWebhookSecret"; - - /// The named HttpClient the POST goes through when a factory is registered. - public const string HttpClientName = "self-update-handover"; - - /// The reporter field: which component announced. - public const string Reporter = "self-update"; - - private static readonly TimeSpan DeliveryBudget = TimeSpan.FromSeconds(30); - - // Shared fallback when no IHttpClientFactory is registered — a long-lived immutable resource, - // not a cache, so it does not fall under the no-static-state rule (PluginBundleClient does the same). - private static readonly HttpClient SharedHttp = new() { Timeout = DeliveryBudget }; - - internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - /// How an announcement reaches the control plane. - public enum Route - { - /// No control inbox is declared (or no record id) — the install is detect-only. - None, - - /// A signed POST to the control instance's inbox. - Post, - - /// This IS the control instance: the event is delivered into its own listed inbox in-process. - Local, - } - - /// Where the resolved inbox URL came from — so a refusal names the key that was actually read. - public enum UrlSource - { - /// Neither nor named one. - None, - - /// , verbatim. - Declared, - - /// with appended. - Derived, - } - - /// - /// The hand-over's configuration as read — loggable: it carries whether each secret is PRESENT - /// and the NAMES of the keys involved, never a secret. is the resolved inbox - /// URL (declared or derived, says which), null when neither key names one. - /// is the configuration key the listed local target DECLARES - /// (SecretConfigKey), null when the target is not listed or declares none — a target - /// without one is an unsigned target by the inbox's own contract (#3312), never "the default". - /// - public sealed record Settings( - string Deployment, - string? Url, - bool SecretPresent, - bool LocalTargetListed, - bool LocalSecretPresent, - string? Instance) - { - /// Which key came from. An init-only PROPERTY, not a seventh - /// positional parameter: adding one to a public record's primary constructor — even with a - /// default — replaces the signature every compiled caller binds to (the #2274 shape). - public UrlSource UrlFrom { get; init; } = UrlSource.None; - - /// The SecretConfigKey the listed local target declares; null when it is not - /// listed or declares none. A property for the same reason as . - public string? LocalSecretKey { get; init; } - } - - /// The wire body of one announcement — the control plane's inbox contract. - public sealed record Announcement - { - public string Event { get; init; } = ReleaseEvent; - public string Deployment { get; init; } = ""; - /// This instance's home URL (PluginCatalog:HomeUrl), informational. - public string? Instance { get; init; } - public string? CurrentVersion { get; init; } - /// The release announced — absent on a restart announcement. - public string? NewVersion { get; init; } - public string? CurrentImage { get; init; } - public string? NewImage { get; init; } - public string? Policy { get; init; } - public string? Pattern { get; init; } - /// What woke the check (, or Manual for the Updates tab). - public string? Trigger { get; init; } - /// Why a restart is asked for — a restart announcement only. - public string? Reason { get; init; } - public string DetectedAt { get; init; } = ""; - public string Reporter { get; init; } = SelfUpdateHandover.Reporter; - - // ── a package-update-held ticket (PackageHoldHandover) only; omitted otherwise ── - /// The package whose update is held — a only. - public string? Package { get; init; } - /// The held version — a only. - public string? HeldVersion { get; init; } - /// The held version's declared minMeshVersion — a only. - public string? Floor { get; init; } - /// The version that keeps running — a only. - public string? InstalledVersion { get; init; } - /// What would unblock it — a only. - public string? Unblocks { get; init; } - /// How the control plane should classify it (blocking) — a only. - public string? Severity { get; init; } - } - - /// - /// The event a held package update is ticketed as (policy package-min-mesh-version, - /// ): one BLOCKING ticket per (deployment, package, held - /// version, floor, running) — the instance de-duplicates on its install record and re-sends - /// only when that state changes or once a day. - /// - public const string PackageHeldEvent = "package-update-held"; - - /// Where one announcement went, for the verdict and the log. - public sealed record Outcome(Route Route, string Destination, string Detail); - - private readonly IMessageHub hub; - private readonly ILogger? logger; - private readonly IIoPool httpPool; - private readonly HttpClient http; - - public SelfUpdateHandover(IMessageHub hub, ILogger? logger = null, HttpClient? http = null) - { - this.hub = hub; - this.logger = logger; - httpPool = hub.ServiceProvider.GetService()?.Get(IoPoolNames.Http) ?? IoPool.Unbounded; - this.http = http - ?? hub.ServiceProvider.GetService()?.CreateClient(HttpClientName) - ?? SharedHttp; - } - - // ───────────────────────────── the rules (pure) ───────────────────────────── - - /// - /// Who applies: the install itself only when BOTH the chart () - /// and the updater () say it may — the transitional - /// shape, retired by the chart namespace by namespace; else the control lane when a route - /// exists; else detect-only. Pure. - /// - public static SelfUpdateApply ApplyModeFor(bool optionsCanPatch, bool updaterCanPatch, Route route) => - optionsCanPatch && updaterCanPatch ? SelfUpdateApply.SelfPatch - : route != Route.None ? SelfUpdateApply.ControlLane - : SelfUpdateApply.DetectOnly; - - /// - /// The route the settings admit: needs a record id, an inbox URL and - /// the signing secret; needs a record id, NO control inbox declared, - /// the target listed on this instance, the target's DECLARED SecretConfigKey and that - /// key's secret present — the key is required by the route itself, not only by the reader, so a - /// caller that assembles by hand cannot reach local delivery without one. - /// - /// 🚨 A declared control inbox is EXCLUSIVE — an instance that names one is a - /// CONSUMER, whatever it also lists, so the only routes it admits are - /// (secret present) and (absent). It must never fall through to - /// : build derives its inbox URL from Hosting:ReportTo, - /// maps no , and legitimately LISTS because it - /// owns the fleet's build queue. The fall-through stored the release in build's OWN inbox, whose - /// watcher classifies a as a non-build event and DELETES it — while - /// the boot line reported apply=control-lane with a verified delivery and - /// named nothing. A control plane silently talking to itself, read off a - /// step that could not fail. A missing secret is a REFUSAL that names the key (#4098). Pure. - /// - public static Route RouteFor(Settings settings) - { - if (settings.Deployment.Length == 0) - return Route.None; - if (settings.Url is { Length: > 0 }) - return settings.SecretPresent ? Route.Post : Route.None; - if (settings.LocalTargetListed && settings.LocalSecretKey is { Length: > 0 } && settings.LocalSecretPresent) - return Route.Local; - return Route.None; - } - - /// - /// What a detect-only install is missing before it could hand over — the sentence the verdict - /// and the boot line carry, naming KEYS and never values. Null when a route exists. Pure. - /// - public static string? Missing(Settings settings) - { - if (RouteFor(settings) != Route.None) - return null; - if (settings.Deployment.Length == 0) - return $"no control inbox: {DeploymentKey} is not set, so no record on the control instance could be named"; - // The same "a URL is declared" test RouteFor takes, so the sentence blames the key the - // route actually refused on — never the URL keys for an instance that declared neither. - if (settings.Url is { Length: > 0 } && !settings.SecretPresent) - return settings.UrlFrom == UrlSource.Derived - ? $"no control inbox: {ReportToKey} names the control instance but {SecretKey} is empty, so nothing could be signed" - : $"no control inbox: {UrlKey} is set but {SecretKey} is empty, so nothing could be signed"; - if (settings.LocalTargetListed && settings.LocalSecretKey is null) - return $"no control inbox: {InboxTarget} is a listed webhook target without a {WebhookInbox.SecretConfigKeyName}, " - + "so this inbox would store the event unverified"; - if (settings.LocalTargetListed && !settings.LocalSecretPresent) - return $"no control inbox: {InboxTarget} is a listed webhook target but its {settings.LocalSecretKey} is empty, so nothing could be signed"; - if (settings.Url is not { Length: > 0 }) - return $"no control inbox: neither {UrlKey} nor {ReportToKey} names the control instance"; - return "no control inbox is configured"; - } - - /// - /// The resolved inbox URL: verbatim when set, else - /// with appended; null when neither is set. Only absolute http(s) URLs - /// count — anything else declares no inbox rather than half of one. Pure. - /// - public static string? ResolveUrl(string? controlInboxUrl, string? reportTo) => - ResolveUrlAndSource(controlInboxUrl, reportTo).Url; - - /// The resolved inbox URL together with which key it came from. Pure. - public static (string? Url, UrlSource Source) ResolveUrlAndSource(string? controlInboxUrl, string? reportTo) - { - var declared = (controlInboxUrl ?? "").Trim(); - if (declared.Length > 0) - return IsHttpUrl(declared) ? (declared, UrlSource.Declared) : (null, UrlSource.None); - var control = (reportTo ?? "").Trim().TrimEnd('/'); - if (control.Length == 0) - return (null, UrlSource.None); - return IsHttpUrl(control) ? (control + InboxRoute, UrlSource.Derived) : (null, UrlSource.None); - } - - private static bool IsHttpUrl(string value) => - Uri.TryCreate(value, UriKind.Absolute, out var uri) - && uri.Scheme is "http" or "https" - && uri.UserInfo.Length == 0; - - /// The raw body an announcement is signed over — camelCase, nulls omitted, event first. - public static string Body(Announcement announcement) => JsonSerializer.Serialize(announcement, Json); - - /// GitHub's webhook signature shape (sha256= + lowercase hex HMAC-SHA256 of the raw body) — the same signer the inventory report uses. - public static string Sign(string body, string secret) => DeploymentReportService.Sign(body, secret); - - public static string Stamp(DateTimeOffset instant) => - instant.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture); - - /// Reads the settings off the host's configuration. Presence only for the secrets. - public static Settings ReadSettings(IConfiguration? configuration, PluginCatalogOptions? catalog = null) - { - string Setting(string key) => configuration?[key]?.Trim() ?? ""; - var local = LocalTarget(configuration); - // 🚨 The target's OWN SecretConfigKey, never a default: a listed target that declares none - // is an unsigned target by the inbox's contract (#3312), and delivering to it would store - // the event with its signature never checked — the pairing degrading silently. - var localSecretKey = local?.SecretConfigKey is { Length: > 0 } key ? key : null; - var instance = catalog?.HomeUrl; - if (string.IsNullOrWhiteSpace(instance)) - instance = Setting("PluginCatalog:HomeUrl"); - var (url, source) = ResolveUrlAndSource(Setting(UrlKey), Setting(ReportToKey)); - return new Settings( - Setting(DeploymentKey), - url, - Setting(SecretKey).Length > 0, - local is not null, - localSecretKey is not null && Setting(localSecretKey).Length > 0, - string.IsNullOrWhiteSpace(instance) ? null : instance.Trim()) - { - UrlFrom = source, - LocalSecretKey = localSecretKey, - }; - } - - private static WebhookInbox.WebhookTarget? LocalTarget(IConfiguration? configuration) => - WebhookInbox.ReadTargets(configuration).FirstOrDefault(t => string.Equals( - WebhookInbox.NormalizeTarget(t.Path), InboxTarget, StringComparison.Ordinal)); - - /// The settings of the hub this hand-over serves. Virtual: a test presents a configured control inbox without standing up a second mesh. - public virtual Settings ReadSettings() => - ReadSettings(hub.ServiceProvider.GetService(), hub.ServiceProvider.GetService()) - with - { - // The signing key may be set in the PORTAL (/Admin/Settings/ControlLane) rather than - // mounted — and a key revoked there counts as absent even if one is mounted. - SecretPresent = SigningSecret().Length > 0, - }; - - /// - /// The key this install signs with: the one a global administrator entered in the portal - /// (, live, no restart) or, when none was, the mounted - /// . Read at every use and never captured, because the key is rotatable. - /// Trimmed exactly as the mounted value always was. - /// - private string SigningSecret() => InstanceSecrets.Resolve(hub, SecretKey)?.Trim() ?? ""; - - /// The verdict of a key test (). - /// Whether the control instance verified the test signature. - /// As whom it verified: the per-sender key's name (e.g. the deployment id), - /// or null when the fleet-wide secret verified it. - /// The inbox URL that answered. - /// What came back, in one sentence, keyed for the viewer's language. Never a value. - public sealed record TestOutcome(bool Accepted, string? Sender, string Destination, LocalizableText Text) - { - /// The English rendering of , for logs and tests. - public string Detail => Text.English; - } - - /// The event a key test carries. The inbox never stores a test, so no consumer ever sees it. - public const string TestEvent = "self-update-key-test"; - - /// - /// Tests the signing key against the control instance: POSTs a signed, VERIFY-ONLY test to the - /// control inbox (), which checks the signature and - /// stores nothing, and reports what the control instance answered. The result is also recorded - /// as the key's last use. Cold. Fails when no inbox route exists, naming what is missing. - /// - public IObservable Test() => - // The portal-entered key is read from a live catalog: wait for its first listing, or the - // test would sign with the mounted key (or none) during the first moments after a start. - CatalogLoaded().SelectMany(_ => - { - var settings = ReadSettings(); - if (RouteFor(settings) != Route.Post) - return Observable.Throw(new InvalidOperationException( - Missing(settings) ?? "this instance delivers to its own inbox; there is no control instance to test against")); - var url = settings.Url!; - var body = Body(new Announcement - { - Event = TestEvent, - Deployment = settings.Deployment, - Instance = settings.Instance, - DetectedAt = Stamp(DateTimeOffset.UtcNow), - }); - var secret = SigningSecret(); - var signature = Sign(body, secret); - return httpPool.Invoke(async ct => - { - using var request = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }; - request.Headers.TryAddWithoutValidation(WebhookInbox.SignatureHeader, signature); - request.Headers.TryAddWithoutValidation(WebhookInbox.VerifyOnlyHeader, "true"); - using var response = await http.SendAsync(request, ct).ConfigureAwait(false); - var answer = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - return TestOutcomeOf(url, (int)response.StatusCode, answer); - }) - .SelectMany(outcome => InstanceSecrets - .RecordUse(hub, SecretKey, outcome.Accepted, outcome.Text) - .Select(_ => outcome)); - }); - - /// Emits once the portal-entered keys have been read (immediately when there is no catalog). - private IObservable CatalogLoaded() => - hub.ServiceProvider.GetService()?.WhenLoaded - ?? Observable.Return(System.Reactive.Unit.Default); - - /// - /// Reads a key test's answer. Accepted only on a 2xx whose body says verified for both - /// the status and the signature. A 401 means the control instance holds no matching key. Pure. - /// - public static TestOutcome TestOutcomeOf(string url, int statusCode, string? answer) - { - var (status, signature) = InboxAnswerOf(answer); - var sender = SenderOf(answer); - if (statusCode is >= 200 and < 300 && status == "verified" && signature == "verified") - return new TestOutcome(true, sender, url, sender is null - ? LocalizableText.Keyed( - "accepted by the control instance (verified with the fleet-wide secret, not a key of this deployment's own)", - "secret.test.fleet") - : LocalizableText.Keyed($"accepted by the control instance as '{sender}'", - "secret.test.accepted", ("sender", sender))); - if (statusCode == 401) - return new TestOutcome(false, null, url, LocalizableText.Keyed( - "refused by the control instance (401): it holds no key matching this one — the two fingerprints differ", - "secret.test.refused")); - if (statusCode is >= 200 and < 300) - return new TestOutcome(false, sender, url, status == "accepted" - ? LocalizableText.Keyed( - "the control instance does not support key tests yet (it stored the test instead of only verifying it)", - "secret.test.unsupported") - : LocalizableText.Keyed( - $"the control instance answered {statusCode} but did not report a verified signature (signature: {signature ?? "unreadable"})", - "secret.test.unverified", ("code", statusCode), ("signature", signature ?? "unreadable"))); - return new TestOutcome(false, null, url, LocalizableText.Keyed( - $"the control instance answered {statusCode}", "secret.test.status", ("code", statusCode))); - } - - /// The sender field of an inbox answer, or null. Pure. - public static string? SenderOf(string? answer) - { - if (string.IsNullOrWhiteSpace(answer)) - return null; - try - { - using var doc = JsonDocument.Parse(answer); - return doc.RootElement.ValueKind == JsonValueKind.Object - && doc.RootElement.TryGetProperty("sender", out var value) - && value.ValueKind == JsonValueKind.String - ? value.GetString() - : null; - } - catch (JsonException) - { - return null; - } - } - - // ───────────────────────────── the delivery (IO) ───────────────────────────── - - /// - /// Announces to the control plane by whichever route the settings admit. Cold; errors when the - /// inbox refuses or is unreachable (the caller records that as a failed hand-over and the next - /// check announces again), and when no route exists — a caller that reached this with - /// made the decision exists to make. - /// - public IObservable Announce(Announcement announcement) => - // Wait for the portal-entered key to be READ before choosing a route and a key: before the - // catalog's first listing, a portal key (or a portal revocation) would be missed. - CatalogLoaded().SelectMany(_ => - { - var settings = ReadSettings(); - var body = Body(announcement with { Deployment = settings.Deployment, Instance = announcement.Instance ?? settings.Instance }); - return RouteFor(settings) switch - { - Route.Post => Post(settings.Url!, body), - Route.Local => DeliverLocally(body), - _ => Observable.Throw(new InvalidOperationException( - Missing(settings) ?? "no control inbox is configured")), - }; - }); - - private IObservable Post(string url, string body) - { - // Read at delivery, never captured: the secret is rotatable (portal-set or mounted). - var secret = SigningSecret(); - if (secret.Length == 0) - return Observable.Throw(new InvalidOperationException( - $"{UrlKey} is set but {SecretKey} is empty — nothing could be signed, nothing was sent")); - var signature = Sign(body, secret); - return httpPool.Invoke(async ct => - { - using var request = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }; - request.Headers.TryAddWithoutValidation(WebhookInbox.SignatureHeader, signature); - using var response = await http.SendAsync(request, ct).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) - { - var text = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - throw new SelfUpdateHandoverRejectedException( - $"the control inbox at {url} answered {(int)response.StatusCode} {response.ReasonPhrase} {text}".TrimEnd() - + ((int)response.StatusCode == 401 - ? $" — {SecretKey} and the control instance's {LocalSecretKey} are not byte-identical" - : "")); - } - // 🚨 The BODY says whether the signature was exercised (#3312), and only "verified" is a - // hand-over. "not-required" means the control instance declares no SecretConfigKey for - // the target and checked nothing — the pairing degraded silently, and the consumer may - // still drop the event; an unparseable body is an inbox this sender does not know. Both - // are FAILED hand-overs that name what came back, never a recorded success. - var answer = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - var (status, verdict) = InboxAnswerOf(answer); - if (status != "accepted" || verdict != "verified") - throw new SelfUpdateHandoverRejectedException( - $"the control inbox at {url} answered {(int)response.StatusCode} but did not report an ACCEPTED, " - + $"VERIFIED delivery (status: {status ?? "unreadable"}, signature: {verdict ?? "unreadable"}) — " - + (verdict == "not-required" - ? $"the control instance declares no {WebhookInbox.SecretConfigKeyName} for {InboxTarget}, so the event was stored unverified" - : status is null && verdict is null - ? "the answer is not the inbox contract" - : "the inbox did not accept the event as a verified delivery")); - var sender = SenderOf(answer); - return (Outcome: new Outcome(Route.Post, url, sender is null - ? $"accepted ({(int)response.StatusCode}), signature verified" - : $"accepted ({(int)response.StatusCode}), signature verified as '{sender}'"), Sender: sender); - }) - // The announcement's result is the key's LAST USE, shown beside its fingerprint in - // /Admin/Settings/ControlLane. Recorded only for a portal-set key (a no-op otherwise) — on - // EVERY outcome: a refusal, and equally a transport failure or a cancellation. - .SelectMany(done => InstanceSecrets - .RecordUse(hub, SecretKey, ok: true, done.Sender is { } sender - ? LocalizableText.Keyed($"announcement accepted by the control instance as '{sender}'", - "secret.use.announcementAccepted", ("sender", sender)) - : LocalizableText.Keyed("announcement accepted by the control instance (fleet-wide secret)", - "secret.use.announcementAcceptedFleet")) - .Select(_ => done.Outcome)) - .Catch(failure => InstanceSecrets - .RecordUse(hub, SecretKey, ok: false, LocalizableText.Keyed( - $"announcement not accepted: {failure.Message}", "secret.use.announcementFailed", ("reason", failure.Message))) - .SelectMany(_ => Observable.Throw(failure))); - } - - private IObservable DeliverLocally(string body) - { - var configuration = hub.ServiceProvider.GetService(); - var targets = WebhookInbox.ReadTargets(configuration); - var local = LocalTarget(configuration); - // The target's DECLARED key only (see ReadSettings) — read at delivery, never captured. - var secretKey = local?.SecretConfigKey is { Length: > 0 } key ? key : null; - var secret = secretKey is null ? "" : configuration?[secretKey]?.Trim() ?? ""; - if (local is null || secretKey is null || secret.Length == 0) - return Observable.Throw(new InvalidOperationException( - $"{InboxTarget} is not a listed webhook target declaring a {WebhookInbox.SecretConfigKeyName} whose secret is present on this instance")); - var headers = new[] { new KeyValuePair(WebhookInbox.SignatureHeader, Sign(body, secret)) }; - return WebhookInbox.Deliver(hub, targets, InboxTarget, "application/json", headers, body) - .Take(1) - .Timeout(DeliveryBudget) - .Select(result => result.Status == WebhookInbox.DeliveryStatus.Accepted - ? result.SignatureVerified - ? new Outcome(Route.Local, InboxTarget, $"stored at {result.NodePath}, signature verified") - : throw new SelfUpdateHandoverRejectedException( - $"the local inbox {InboxTarget} stored the event WITHOUT verifying its signature — " - + $"the target declares no {WebhookInbox.SecretConfigKeyName} this instance can resolve") - : throw new SelfUpdateHandoverRejectedException( - $"the local inbox {InboxTarget} answered {result.Status}")); - } - - /// - /// The inbox's answer, both halves ({"status":"accepted","signature":"verified"}): the - /// status and the signature fields, each null when absent or when the body is not - /// that contract. A hand-over needs BOTH — accepted AND verified: a verified - /// signature on a delivery the inbox did not accept is not a delivery. Pure. - /// - public static (string? Status, string? Signature) InboxAnswerOf(string? answer) - { - if (string.IsNullOrWhiteSpace(answer)) - return (null, null); - try - { - using var doc = JsonDocument.Parse(answer); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - return (null, null); - return (Field(doc.RootElement, "status"), Field(doc.RootElement, "signature")); - } - catch (JsonException) - { - return (null, null); - } - - static string? Field(JsonElement root, string name) => - root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String - ? value.GetString() - : null; - } -} - -/// The control inbox refused an announcement — the status and, for a 401, the pairing to check. -public sealed class SelfUpdateHandoverRejectedException(string message) : InvalidOperationException(message); diff --git a/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHostedService.cs b/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHostedService.cs deleted file mode 100644 index e0afcf05f5..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHostedService.cs +++ /dev/null @@ -1,2081 +0,0 @@ -using Microsoft.Extensions.Configuration; -using System.Reactive.Disposables; -using System.Threading; -using System.Collections.Immutable; -using System.Reactive; -using System.Reactive.Concurrency; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using MeshWeaver.Hosting.SelfUpdate; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// -/// The platform self-update poller. Reactively (no async/await; the network/IO leaves route through -/// the Http ) polls the registry a few times a day, picks the update -/// target per the live Admin/UpdatePolicy, and APPLIES it by whoever applies on this install -/// (, #4098): a fleet instance HANDS the release to the control lane — -/// one signed event into the control instance's inbox, from which the control plane opens the -/// Roll (); an install whose chart still renders the -/// self-patch Role patches its own portal + migration Deployments so k8s rolls them; anything else -/// records the available version for detect-and-notify. Mirrors ShippedReleaseSeedHostedService -/// (raw , SubscribeOn(TaskPoolScheduler.Default), one subscription). -/// Not sealed: and are the -/// fault-injection seams for the resilience test — they are the only two mesh touches this poller -/// makes, and therefore the only two ways a degraded mesh could ever stop an install rolling forward. -/// -/// 🔁 wedges-to-zero, the standing invariant here: NEITHER mesh touch may gate the roll-forward. -/// The registry poll and the k8s PATCH speak only to ACR and the API server, so they keep working -/// while the mesh is degraded — and a fresh image is precisely what recovers a degraded pod. The -/// policy READ was decoupled in #611; the availability WRITE in #1020. -/// -public class SelfUpdateHostedService : IHostedService, IModuleActivationRestart, IInstanceRebootActivation -{ - private readonly IMessageHub _hub; - private readonly IAcrTagLister _acr; - private readonly OciTagLister? _oci; - private readonly IDeploymentUpdater _updater; - private readonly SelfUpdateOptions _options; - private readonly ILogger? _logger; - private readonly IIoPool _http; - private readonly IIoPool _fileSystem; - private IDisposable? _subscription; - - /// - /// How many build-completion events this process has observed. The ONLY evidence that the event - /// channel is alive, and it exists solely to make the dead-channel report in - /// possible — nothing else can distinguish "nothing published" from - /// "something published and nothing told us". Mutable instance state, read with - /// because the watch and the check run on different pool threads. - /// - private int _buildEventsSeen; - - /// - /// 1 once the live policy read has produced a value — i.e. once the gate every trigger waits on - /// has opened. Chooses the retry cadence for a faulted watch (see - /// ): before it, the poller is inert and a fault - /// must be retried quickly; after it, the long interval paces re-establishment. An instance - /// field — its lifetime is this service's — never static. - /// - private int _policyEstablished; - - /// Whether the "Continuous without a pattern is Stable" advisory has been logged — - /// once per process, so a record written before the pattern existed is named on the first check - /// and not on every one. - private int _channelAdvisoryLogged; - - public SelfUpdateHostedService( - IMessageHub hub, - IAcrTagLister acr, - IDeploymentUpdater updater, - SelfUpdateOptions options, - ILogger? logger = null, - IoPoolRegistry? registry = null, - OciTagLister? oci = null) - { - _hub = hub; - _acr = acr; - _oci = oci; - _updater = updater; - _options = options; - _logger = logger; - // The ACR list + the k8s PATCH are outbound HTTP → the Http resource class. Falls back to the - // stateless unbounded pool when no registry is wired (tests). - _http = registry?.Get(IoPoolNames.Http) ?? IoPool.Unbounded; - // The one file read this poller makes — the activation record's plan-tier marker for the - // patcher's module (#4097) — goes through the FileSystem resource class, never inline on - // the hosted-service startup thread: the module root can be a slow shared volume. - _fileSystem = registry?.Get(IoPoolNames.FileSystem) ?? IoPool.Unbounded; - } - - public Task StartAsync(CancellationToken cancellationToken) - { - // 🚨 WHO APPLIES is decided once here for the boot line and again at every apply (the - // configuration is rotatable). Effective canPatch is the chart's declaration AND the - // updater's: a chart that renders no self-patch Role renders SelfUpdate__CanPatch=false, - // so the two can never disagree about whether a PATCH would be answered 403 (#4098). - var handoverSettings = ResolveHandover().ReadSettings(); - var route = SelfUpdateHandover.RouteFor(handoverSettings); - var apply = SelfUpdateHandover.ApplyModeFor(_options.CanPatch, _updater.CanPatch, route); - _logger?.LogInformation( - "[SelfUpdate] starting (event-driven, with a {SafetyNet} safety net); version={Version}, " - + "registry={Registry}/{Repo} listed as {RegistryKind}, canPatch={CanPatch}, apply={Apply}, retryInterval={Interval}.", - _options.SafetyNetCheckInterval > TimeSpan.Zero - ? _options.SafetyNetCheckInterval.ToString() - : "disabled", - ShippedReleaseSeed.InstalledPlatformVersion, - // 🚨 The HOST read from SelfUpdate:Registry, never the configured value. This is an - // Information line, so it leaves the pod for Loki at boot — before the OCI lister's - // refusal (which deliberately does not echo the value) has ever run — and a value of - // the shape `instance:mwi_…@evil.example` would have shipped the key to Loki here on - // every start (#4094 review). A value HostOf cannot read is named as unreadable, not - // printed; the lister's first check then says what is wrong with it. - SelfUpdateOptions.HostOf(_options.Registry) ?? "(unreadable — see SelfUpdate:Registry)", - _options.PortalRepository, - // 🚨 On the OCI path the line says WHERE the instance key may be presented, because that - // is the whole configuration and its absence is invisible otherwise: an install with no - // declared validator boots, serves, and only reveals the gap when the first check fails - // (#4093). Hosts only, never the key — and THREE states, not two: a declaration that is - // set but unreadable is a misconfiguration to name, never "NO validator declared". - UsesOciListing - ? _options.RegistryValidatorHost is { } validator - ? $"an OCI Distribution registry (instance-key auth; key validated at {validator})" - : _options.RegistryValidatorDeclared - ? "an OCI Distribution registry (instance-key auth; SelfUpdate:RegistryValidationUrl " - + "is SET but does not name an http(s) host — every check refuses until it does; " - + "the value is not repeated here)" - : "an OCI Distribution registry (instance-key auth; NO validator declared — the key " - + "is presented only if this host is itself a configured plugin registry, else " - + "every check refuses; see SelfUpdate:RegistryValidationUrl)" - : "an Azure Container Registry", - _options.CanPatch && _updater.CanPatch, - // Where a detected release GOES, in one word plus its destination — the whole of - // #4098 from an operator's chair: a control-lane install names the inbox it hands to, - // a detect-only one names the key that would make it a control-lane install. - apply switch - { - SelfUpdateApply.SelfPatch => "self-patch (this install patches its own workloads)", - SelfUpdateApply.ControlLane => - $"control-lane (a detected release is handed to {DescribeRoute(route, handoverSettings)}; the control plane rolls)", - _ => $"detect-only ({SelfUpdateHandover.Missing(handoverSettings)})", - }, - _options.RetryInterval); - - // 🚨 #4097 — canPatch=False used to be the whole story, and it pointed at - // Modules:Assemblies. When the patcher's package was refused by the instance's PLAN, the - // startup log now says so — the tier it needs and the plan the instance is on — as the - // next [SelfUpdate] line, read off the activation record on the FileSystem pool so the - // hosted-service startup thread never waits on the module volume. When it is the CHART - // that switched self-patch off (#4098), that is the reason and no record is read. - if (!_options.CanPatch) - _logger?.LogInformation( - "[SelfUpdate] canPatch=False (self-patch is switched off by {Section}:{Key} — the chart renders " - + "no self-patch Role for this install; the control lane rolls it)", - SelfUpdateOptions.SectionName, nameof(SelfUpdateOptions.CanPatch)); - else if (!_updater.CanPatch) - CannotPatchReason() - .Subscribe( - reason => _logger?.LogInformation("[SelfUpdate] canPatch=False{Reason}", reason), - ex => _logger?.LogWarning(ex, - "[SelfUpdate] could not read why this install cannot patch — the activation record was unreadable")); - - // 🚨 EVENT-DRIVEN WITH A SAFETY NET, and the event source is deliberately OUTSIDE the - // policy stream. - // - // The fast path is unchanged and is still the design: one pass at startup (to catch - // publications missed while this install was down), then a check per build completion of - // the platform OR of any module the environment deploys. Nothing beats an event for - // latency, and a timer that re-asks a question nothing has answered is a band-aid. - // - // 🚨 What the event-ONLY shape got wrong is its failure mode, and it cost a week of prod - // (#2494/#2553). Every event reaches this install across a chain of configuration that - // nothing re-verifies — a GitHub webhook registration, a `WebhookInbox` allowlist slot, an - // HMAC secret that must be byte-identical on both ends — and every joint of it fails - // SILENTLY: an unlisted inbox target answers 404 exactly like a wrong URL, a mismatched - // secret still answers 2xx and drops the delivery. An install whose event channel is dead - // therefore looks EXACTLY like an install that is up to date: healthy pods, no errors, and - // a check that simply never runs. memex sat three builds behind for 7 h in that state with - // nothing in the product able to say so. - // - // So the safety net is not the removed poll returning. A poll DRIVES the update; this - // BOUNDS how long a broken driver can hide, and it cannot change the roll cadence at all — - // a safety-net check passes through MinRollInterval like any other. See - // SelfUpdateOptions.SafetyNetCheckInterval. - // - // 🚨 The policy is STATE, not a driver — WithLatestFrom, never Switch. Under Switch every - // policy emission re-subscribed the watch, which RE-BASELINED it: a publication landing in - // that window was read as "current state" rather than as a new build and silently swallowed. - // One long-lived watch, the latest policy read at decision time, is what closes it. - // (CreatePolicySource emits the default exactly once before the first live read, so the - // startup pass always has a policy to run under.) - // The policy is shared STATE with a replayed latest value, connected for the lifetime of the - // service. Replay(1)+Connect rather than RefCount: a RefCount would drop to zero between the - // Take(1) below and the re-subscribe, re-running the seed and re-baselining everything - // downstream — the very class of bug this restructure exists to remove. - var policy = CreatePolicySource().Replay(1); - - // 🚨 The first check waits for a policy to exist. CreatePolicySource emits the default only - // AFTER its async seed, so a startup pass composed with WithLatestFrom alone fires before - // any policy is available and is silently dropped — no first check at all. Take(1) gates the - // trigger stream on that first emission; every LATER policy change is picked up without - // re-subscribing the watch. - // Five trigger sources: - // • the one startup pass, - // • a build completion from the platform or ANY module the environment deploys, - // • an admin CHANGING the policy — enabling updates must not wait for the next - // publication, which could be weeks away. DistinctUntilChanged so a re-emission of the - // same policy is not a trigger, and Skip(1) so the replayed CURRENT policy is not one - // either (the startup pass already covers it), - // • the safety net, so a dead event channel is bounded rather than permanent, - // • a landing wave on THIS process proposing a new module set (#3650) — a module shipped - // and landed, and the restart that activates it must not wait for an unrelated roll. - var checks = policy - .Take(1) - .SelectMany(_ => Observable.Merge( - Observable.Return(SelfUpdateTrigger.Startup), - BuildCompletionTicks().Do(_ => Interlocked.Increment(ref _buildEventsSeen)), - SafetyNetTicks(), - ModuleSetProposedTicks(), - // The pattern is part of the channel (Continuous + `3.0.1-ci*`), so editing it is a - // policy change and re-drives a check exactly like flipping the enum. - policy.DistinctUntilChanged(content => (content.Policy, UpdateChannelPattern.Normalize(content.Pattern))) - .Skip(1) - .Select(_ => SelfUpdateTrigger.PolicyChange))) - // 🚨 Read the CURRENT policy at decision time — never WithLatestFrom. That operator only - // pairs once its secondary has produced, and the startup trigger fires synchronously on - // subscribe, so whether the first check survives came down to Rx's internal subscribe - // ordering: it silently dropped the startup pass. policy is Replay(1), so Take(1) yields - // the latest value immediately and deterministically. - .SelectMany(trigger => policy.Take(1).Select(content => (trigger, content))) - // 🚨 #3790 — an externally paced trigger whose verdict is already a foregone - // conclusion is not a check. See IsDecisionPoint: this is a rate decision, NOT the - // silence #2553 removed, and the two are one line apart. - .Where(check => IsDecisionPoint(check.trigger, check.content)) - .SelectMany(check => RunOnce(check.trigger, check.content) - // wedges-to-zero: a check error (ACR outage, k8s 403) is a VERDICT, not a silence, - // and the watch stays live. No outer .Retry — that would be a resubscribe storm. - .Catch((Exception ex) => Observable.Return(SelfUpdateVerdict.CheckFailed(ex))) - // 🚨 THE POSTCONDITION, asserted rather than hoped for. Every branch of RunOnce - // names its outcome, so this should be unreachable — and that is exactly why it is - // here. The defect this whole restructure fixes was two bare `Where` clauses that - // dropped a check on the floor, and the only thing that stops a third one being - // added is making "produced nothing" itself an outcome that gets reported. - .DefaultIfEmpty(SelfUpdateVerdict.NoOutcome()) - .Take(1) - // 🚨 #3650 — after the platform half of EVERY check, the module half: a landed - // module generation waiting for a restart gets one, on the image this install - // runs, paced by the same floor. Composed here rather than inside RunOnce so the - // platform verdict it follows is already final (and already survived its own - // Catch), and so a fault in the restart is reported as what it is. - .SelectMany(verdict => ConsiderRestart(verdict) - .Catch((Exception ex) => Observable.Return(new SelfUpdateVerdict( - SelfUpdateOutcome.CheckFailed, - $"{verdict.Message} The pending restart FAILED: {ex.GetType().Name}: {ex.Message}", - verdict.Tag)))) - .SelectMany(verdict => ReportCheck(check.trigger, verdict))) - .SubscribeOn(TaskPoolScheduler.Default) - .Subscribe( - _ => _checksReported.OnNext(Unit.Default), - ex => _logger?.LogError(ex, "[SelfUpdate] update watch terminated unexpectedly.")); - - _subscription = new CompositeDisposable(checks, policy.Connect()); - return Task.CompletedTask; - } - - /// - /// One emission per CHECK that has been evaluated AND reported (#2777). An instance field — - /// its lifetime is this service's — never static. - /// - /// 🚨 It exists because the alternative is a test bounding a POLL. This service is - /// event-driven but its first check is still work that has to happen, so a test that waits - /// "up to 30 s for the held state to appear" is really waiting for the first check to run: - /// on a loaded shard it may simply not have, and the test then reports the absence of a - /// verdict as a wrong verdict. That is the same false-RED shape as #2793, and the answer is - /// the same — publish the condition, do not infer it from a bound. Production never - /// subscribes; nothing here changes what a check does or when it runs. - /// - private readonly Subject _checksReported = new(); - - /// - /// Test seam (InternalsVisibleTo): fires once per check whose verdict has been written. - /// Await the FIRST emission to know the service has evaluated at least once, then assert on the - /// state it produced — never race a timer against it. - /// - protected internal IObservable ChecksReported => _checksReported; - - /// - /// 🚨 Is this trigger a decision point under this policy? (#3790) - /// - /// Under a check can change nothing, and the code - /// says so in two places already: returns - /// before it lists a single tag, and - /// answers false for that outcome, so - /// the module-restart half (#3650) is not taken either. The whole effect of such a check is a - /// log line and a bookkeeping stamp repeating a verdict the record already carries. - /// - /// That would be harmless if the checks were paced by this install. They are not. The - /// BuildCompletion and ModuleSetProposed triggers are paced by the FLEET — every - /// platform and module publication anywhere wakes every replica — and on memex that measured - /// 158 checks in 5 h, each writing LastCheckedAt cross-hub into one shared leaf - /// from both replicas at once: 14 [MergeGuard] refused stale/reordered, 10 - /// OWNER_NACK_REENQUEUE, two 10-second [UpdateQueue] FAILED stalls in the hub - /// that also carries user writes, and Admin/UpdatePolicy at version 62,671 for a - /// content decision that had not changed in two days. - /// - /// 🚨 This is NOT the Where that #2553 removed, and the distinction is the - /// whole design. That one dropped EVERY check under None, so an install an admin had - /// deliberately pinned and an install whose updater was broken both left the record empty — - /// indistinguishable. Here still runs (the record - /// always carries the disabled verdict and the time this pod established it), - /// still runs (enabling updates must not wait for - /// a publication) and still runs (so - /// LastCheckedAt keeps moving on THIS service's own period and a dead checker is still - /// visible as a stale stamp). What stops is only the repetition, at a rate nobody here - /// chooses, of a sentence already on the node. - /// - /// Pure and static so the rule is pinned without a mesh, exactly as - /// pins the floor arithmetic. Internal - /// rather than private for the same reason — the truth table is the test. - /// - internal static bool IsDecisionPoint(SelfUpdateTrigger trigger, UpdatePolicyContent policy) - => policy.Policy != UpdatePolicyKind.None - || trigger is not (SelfUpdateTrigger.BuildCompletion or SelfUpdateTrigger.ModuleSetProposed); - - /// - /// The safety net (#2494): a bounded liveness floor on the CHECK, never on the roll. - /// - /// Off entirely when is zero or - /// negative, which is the shape a deployment that genuinely wants event-only can opt into - /// — deliberately, in configuration, where it is visible. - /// - /// Virtual for the same reason the other three seams are: a test must be able to drive - /// the safety-net path without waiting out an hour. - /// - protected virtual IObservable SafetyNetTicks() => - _options.SafetyNetCheckInterval <= TimeSpan.Zero - ? Observable.Never() - : Observable.Interval(_options.SafetyNetCheckInterval) - .Select(_ => SelfUpdateTrigger.SafetyNet); - - /// - /// Ticks once per landing wave THIS process ends with a new module set - /// (, #3650) — the event that makes "a - /// module version shipped" and "this install runs it" minutes apart instead of one unrelated - /// roll apart. Coalesced like the build events. A host with no landing service (a monolith - /// without the catalog) ticks never. Virtual for the same reason the other trigger seams are. - /// - protected virtual IObservable ModuleSetProposedTicks() => - Observable.Defer(() => ResolveLandingService()?.ModuleSetProposed ?? Observable.Never()) - .Throttle(_options.EventCoalesceWindow) - .Select(_ => SelfUpdateTrigger.ModuleSetProposed); - - /// - /// The module-landing service, resolved from the mesh's services — the seam through which the - /// pending-restart state () and the - /// module-set-proposed trigger reach this poller. Virtual so a test can hand in a landing - /// service rooted in a temp directory and raise the marker there. - /// - protected virtual ModuleLandingService? ResolveLandingService() => - _hub.ServiceProvider.GetService(); - - /// - /// 🚨 The module half of a check (#3650): a landed module generation loads only at a restart - /// (restart-as-activation), and until now that restart was whatever platform roll happened - /// next — a module could ship, land, and sit unloaded for days behind a fleet that had nothing - /// newer to roll to. So after the platform half of EVERY check, when that half patched nothing - /// (), the activation record is read and a - /// pending restart is TAKEN: a roll of the image this install runs, through - /// , paced by MinRollInterval exactly like a - /// roll — a restart drops the same live circuits. - /// - /// The record read is the landing service's own (, - /// on its pooled IO), never a file touched from a hub thread; a read that faults decides - /// nothing and leaves the platform verdict as it was, with a Warning that names itself. The - /// marker is cleared by the boot that follows the restart (ConfigureMemexMesh), which is - /// what keeps this from restarting twice — and the floor is what keeps two replicas that both - /// see the marker from issuing two rollouts. - /// - private IObservable ConsiderRestart(SelfUpdateVerdict platform) => - Observable.Defer(() => - { - var mayRestart = SelfUpdateVerdict.MayRestartAfter(platform); - if (!mayRestart && !SelfUpdateVerdict.MayActivateLiveAfter(platform)) - return Observable.Return(platform); - var landing = ResolveLandingService(); - if (landing is null) - return Observable.Return(platform); - return landing.GetActivation() - .Take(1) - .SelectMany(activation => !activation.PendingRestart - ? Observable.Return(platform) - : mayRestart - ? LiveFirst(platform) - : LiveOnly(platform)) - .Catch((Exception ex) => - { - _logger?.LogWarning(ex, - "[SelfUpdate] could not read the module activation record to decide a pending " - + "restart; the platform verdict stands and the next check asks again."); - return Observable.Return(platform); - }); - }); - - /// - /// The live module activation, resolved from the mesh's services — the seam through which a - /// pending module is swapped in LIVE before any restart is considered. Null on a host without - /// the plugin catalog, which keeps the restart path exactly as it was. Virtual for the same - /// reason is. - /// - protected virtual ModuleLiveActivation? ResolveLiveActivation() => - _hub.ServiceProvider.GetService(); - - /// - /// 🚨 LIVE FIRST (policy module-live-update-default): a landed module generation is swapped - /// into the running process, and ONLY what does not go live — a module that cannot be swapped - /// in-process, a swap that failed at runtime, a module this process does not hold in its own - /// context — is left to the automatic restart, which still needs no approval (#4607). A pass that - /// cannot establish what is pending keeps the restart that was always taken. - /// - private IObservable LiveFirst(SelfUpdateVerdict platform) - { - var live = ResolveLiveActivation(); - if (live is null) - return Restart(platform); - return live.ActivatePending("self-update: a landed module generation is pending activation") - .SelectMany(result => result.NeedsRestart - ? Restart(platform, honourFloor: true, - reason: $"a landed module generation did not go live in the running process — {result.Describe()}") - : Observable.Return(LiveVerdict(platform, result))); - } - - /// - /// 🚨 The LIVE half alone, after a platform release was HANDED to the control lane (MeshWeaver#6829). - /// The routed Roll that hand-over opens re-creates the pods, so a restart request here would only - /// race it () — but a live swap is not a restart: it - /// happens inside this process and races nothing. Before this, a hand-over skipped the module half - /// ENTIRELY, so on an install that sees a newer platform build on every check (every - /// BuildCompletion is one) a proposed module set waited for the roll even where it could go - /// live at once — and waited indefinitely while that roll waited for an approval. Here what can go - /// live goes live; what needs a restart is left to the handed-over roll, and the verdict says so. - /// - private IObservable LiveOnly(SelfUpdateVerdict platform) - { - var live = ResolveLiveActivation(); - if (live is null) - return Observable.Return(platform); - return live.ActivatePending("self-update: a landed module generation is pending activation (platform roll handed over)") - .Select(result => SelfUpdateVerdict.LiveWhileRollHandedOver(platform, result.WentLive.Count, result.NeedsRestart, result.Describe())); - } - - /// - /// The verdict of a live-activation pass that needs no restart, decided from what WENT LIVE (#6620): - /// a pass that swapped nothing in — nothing pending here, or every pending module already serving - /// the landed generation (, which a second, overlapping check - /// finds once the first has swapped it in) — is , - /// never "went LIVE". - /// - internal static SelfUpdateVerdict LiveVerdict(SelfUpdateVerdict platform, ModuleLiveActivationResult result) => - result.WentLive.IsEmpty - ? SelfUpdateVerdict.NothingToActivate(platform, result.Describe()) - : SelfUpdateVerdict.ActivatedLive(platform, result.Describe()); - - /// Why this install cannot patch, as the qualifier after canPatch=False (#4097): - /// the plan-tier sentence when the patcher's package was refused by the instance's plan, else - /// the standing hint. One file read on the FileSystem pool; emits once. - private IObservable CannotPatchReason() - { - var configuration = _hub.ServiceProvider.GetService(); - return _fileSystem - .InvokeBlocking(_ => UnavailableUpdateMechanics.PatcherTierRefusal(configuration)) - .Select(UnavailableUpdateMechanics.DescribeCannotPatch); - } - - /// The restart itself: detect-only says so; the floor defers; otherwise the updater rolls - /// the running image, or reports that it cannot. - private IObservable Restart(SelfUpdateVerdict platform) => - Restart(platform, honourFloor: true, reason: null); - - /// - /// 🚨 — the restart half of an explicit module reload - /// (Doc/Architecture/ModuleReload), taken through THIS service's one restart path: a - /// self-patch restart of the running image, or self-update-restart-pending handed to the - /// control lane, which routes it to an unattended Restart (no approval, no confirmation). The - /// same three apply modes, the same hand-over, the same verdict sentences — only two things - /// differ, both because the caller is an authorised one-shot request rather than a recurring - /// check: the roll FLOOR does not defer it (the reload issues exactly one, stamped on its node - /// before it asks), and the announcement carries the reload's own reason. - /// - /// Exactly one restart still holds against this service's own checks: a check that - /// follows a self-patch restart reads the fresh roll instant and the floor defers its own - /// restart; on the control lane the control plane treats an open or freshly done Restart as - /// having delivered the announcement. - /// - /// Why — carried into the announcement and the log line. - public IObservable RequestRestart(string reason) => - Restart(new SelfUpdateVerdict(SelfUpdateOutcome.NoNewerRelease, $"Module reload ({reason}):"), - honourFloor: false, reason: $"a landed module generation is pending activation — {reason}") - .Select(verdict => new ModuleRestartOutcome( - verdict.Outcome switch - { - SelfUpdateOutcome.Restarted => ModuleRestartKinds.Restarted, - SelfUpdateOutcome.RestartHandedOver => ModuleRestartKinds.HandedOver, - // The mechanism exists and the attempt failed — transient, retried (#6172). - SelfUpdateOutcome.RestartHandoverFailed => ModuleRestartKinds.Faulted, - _ => ModuleRestartKinds.Unavailable, - }, - verdict.Message)) - // A restart call that THREW (a Kubernetes API blip, a timeout) is a crash, never a - // decided "cannot restart" — the reload records it Faulted and retries (#6172). - .Catch((Exception ex) => Observable.Return(new ModuleRestartOutcome( - ModuleRestartKinds.Faulted, $"the restart request faulted: {ex.Message}"))); - - /// - /// 🚨 — the reboot's Image step - /// (Doc/Architecture/InstanceReboot), answered by THIS service's own selection: the tags the - /// registry lists, the channel this instance's update policy admits (), - /// the walk over the availability gate (), and the gate's verdict on the - /// chosen tag. "Newest" never means backwards (except the stranded-tag recovery the selection already - /// owns). No package answer keeps the reboot on the running image (policy - /// platform-rolls-independently-of-packages): an availability refusal and a recorded combo - /// Red are named in the detail as advisories and the newest admitted tag is chosen; the reboot - /// restarts on the running image only when the policy is None, nothing is newer, or the - /// selection itself failed — and the detail says which. - /// leaves the combo (dependent-suite) verdict out of the walk and the decision, - /// and the detail says so — a wedged instance does not wait for it. - /// - /// Whether the instance was judged wedged. - public IObservable SelectImage(bool wedged) - { - var installed = ShippedReleaseSeed.InstalledPlatformVersion; - var waived = wedged ? " The instance is WEDGED, so the dependent-suite (combo) verdict was NOT waited for." : ""; - return Observable.Defer(() => ReadPolicyStream().Take(1).Timeout(TimeSpan.FromSeconds(30))) - .SelectMany(policy => - { - if (policy.Policy == UpdatePolicyKind.None) - return Observable.Return(new RebootImageChoice(installed, null, - "this instance's update policy is None (pinned) — the image is not moved; restart on the running image.")); - return ListTags() - .Select(tags => VersionSelect.SelectCandidates(tags, installed, policy.Policy, policy.RequireCiGreen, policy.Pattern)) - .SelectMany(selection => selection.Candidates.Length == 0 - ? Observable.Return(new RebootImageChoice(installed, null, NothingToRoll(selection).Message + waived)) - : FirstRollable([.. selection.Candidates]) - .SelectMany(target => ChooseGated(policy, target ?? selection.Candidates[0], installed, wedged, waived))); - }) - .Catch((Exception ex) => Observable.Return(new RebootImageChoice(installed, null, - $"the image could not be selected ({ex.GetType().Name}: {ex.Message}) — restart on the running image", Failed: true))); - } - - /// - /// 🚨 The roll target THIS poller would take now — what /api/plugins/roll-target - /// answers (#4413). The SAME selection a check makes: the policy read, the registry's portal tag - /// listing (), the channel filter () - /// and the walk (, which also takes). - /// - /// The candidate universe is the registry's listing of - /// and never the published root's _releases/<version> markers. A marker is written by - /// main-cd's publish-bake, which needs promote and not arm, so a set the fleet - /// has not armed has a marker and no memex-portal-ai:<version> tag. A selection taken - /// over the markers named a release no pod can start from and the poller can never take - /// (measured: memex answered 3.1.11003 while that portal tag did not exist and - /// mw-plugin-test:3.1.11003 did). Combo verification asks THIS route which image to verify, - /// so the old universe made it pull an absent image. - /// - /// Every exit names its outcome. A pinned policy, an up-to-date install and a failed - /// listing each select nothing, and a failed listing is , - /// so a caller can tell "nothing to roll to" from "could not look". - /// - /// A diagnostic override for the running version ("what would you - /// pick from there"); null or blank reads the version this image runs. - public IObservable SelectRollTarget(string? currentVersion = null) - { - var installed = string.IsNullOrWhiteSpace(currentVersion) - ? ShippedReleaseSeed.InstalledPlatformVersion - : currentVersion; - // The route has already authorized its caller; the policy node is the instance's own - // record, read as System (policy authorize-as-caller-execute-as-system). - var accessService = _hub.ServiceProvider.GetService(); - return accessService.RunAsSystem(() => ReadPolicyStream().Take(1).Timeout(TimeSpan.FromSeconds(30))) - .SelectMany(policy => policy.Policy == UpdatePolicyKind.None - ? Observable.Return(NoTarget(RollSelectionKind.NoCandidates, installed, - "this instance's update policy is None (pinned), so it rolls to nothing")) - : ListTags() - .Select(tags => VersionSelect.SelectCandidates( - tags, installed, policy.Policy, policy.RequireCiGreen, policy.Pattern)) - .SelectMany(selection => selection.Candidates.Length > 0 - ? SelectAmong(installed, selection.Candidates) - : Observable.Return(NoTarget( - selection.Installed.Resolution == VersionSelect.InstalledTagResolution.Indeterminate - ? RollSelectionKind.Indeterminate - : RollSelectionKind.NoCandidates, - installed, - NothingToRoll(selection).Message)))) - .Catch((Exception ex) => Observable.Return(NoTarget(RollSelectionKind.Indeterminate, installed, - $"the roll target could not be selected ({ex.GetType().Name}: {ex.Message})"))); - } - - /// An outcome that selects nothing, saying why. - private static RollSelectionOutcome NoTarget(RollSelectionKind kind, string installed, string why) => - new(kind, null, installed, 0, 0, [], $"this instance: no roll target ({why})."); - - /// - /// The walk over the newest-first (non-empty), shared by the check - /// () and . The availability gate's - /// selection when it selects; otherwise the head, unjudged, with the reason as the advisory — - /// no package answer holds the platform (policy platform-rolls-independently-of-packages). - /// - private IObservable SelectAmong(string installed, ImmutableArray candidates) - { - RollSelectionOutcome Unjudged(string reason) => - RollSelection.Unjudged( - new RollSelectionInputs("this instance", installed, candidates, - PluginInventory.Unreadable("the release-availability gate", reason)), - reason); - - var gate = ResolveAvailabilityGate(); - if (gate is null) - return Observable.Return(Unjudged( - "no release-availability gate is registered on this host, so package availability was not checked")); - - return gate - .SelectRollTarget(installed, candidates) - // NotEnforced (a deployment that consumes no CI bakes) and Indeterminate (the selector - // could not answer in its budget) select nothing — and the newest admitted candidate is - // still the target, because nothing about packages holds the platform. - .Select(outcome => outcome.SelectedVersion is null ? Unjudged(outcome.Summary) : outcome) - // A selector that faults must not kill the tick: the head, which GateThenApply judges. - .Catch((Exception ex) => Observable.Return(Unjudged( - $"the release-availability gate faulted ({ex.GetType().Name}: {ex.Message})"))); - } - - /// The gate's verdict on the walked-to target, as the reboot's choice: rolled only when it clears. - private IObservable ChooseGated(UpdatePolicyContent policy, string target, string installed, bool wedged, string waived) - { - var gate = ResolveAvailabilityGate(); - // The recorded combo Red is named on EVERY branch — also when no availability gate is - // registered, exactly as the poller's unwired path still evaluates combo. A wedged - // instance did not wait for the verdict, so it has none to report (the `waived` text says so). - string RecordedRed() => - !wedged && ResolveComboGate() is { } combo && combo.Recorded(policy, target) is { Refuses: true } refused - ? $" The combo gate recorded RED for this instance's modules ({refused.Reason}) — an advisory: those modules keep their previous generation and catch up on their own." - : ""; - if (gate is null) - return Observable.Return(new RebootImageChoice(installed, target, - $"{target} is the newest admitted release. No release-availability gate is registered on this host, so " - + "package availability was not checked — which does not hold the platform (it rolls independently of packages)." - + RecordedRed() + waived)); - // 🚨 The platform rolls independently of its packages (policy - // platform-rolls-independently-of-packages): neither the - // availability verdict nor a recorded combo Red keeps the reboot on the running image — - // both are package answers, and the detail names them so the choice is never silent. - return gate.IsUpdatable(target).Select(raw => - { - var verdict = raw.ForPlatformRoll(); - // EVERY advisory, not only the waived hold: an already-updatable verdict carries its - // boot-compile / link / declared-floor advisories with WaivedHold null, and a waived - // one leads its Advisories with the waived reason — so the collection is the whole list. - var packages = verdict.Advisories.IsDefaultOrEmpty - ? "" - : " Package advisories — they do not hold the platform (it rolls independently of packages): " - + string.Join("; ", verdict.Advisories) + "."; - return new RebootImageChoice(installed, target, - $"{target} is the newest release the update policy admits ({policy.Policy}{(policy.Pattern is null ? "" : $", pattern {policy.Pattern}")})" - + (verdict.NotEnforcedReason is { } why ? $" (availability gate not enforced: {why})" : "") - + "." + packages + RecordedRed() + waived); - }); - } - - /// - /// 🚨 — the reboot's ONE roll/restart, through this - /// service's one path: a roll to the chosen image (migration FIRST, ) or - /// the control-lane hand-over (self-update-available), and with no target a restart of the - /// running image ('s path). The roll floor does not defer it: the reboot is - /// one explicit request, stamped on its node before it asks. - /// - public IObservable Activate(RebootImageChoice choice, string reason) => - Observable.Defer(() => - { - var settings = ResolveHandover().ReadSettings(); - var route = SelfUpdateHandover.RouteFor(settings); - var mode = SelfUpdateHandover.ApplyModeFor(_options.CanPatch, _updater.CanPatch, route); - // 🚨 THE SELF-HAND-OVER LOOP. Route.Local means THIS is the control instance: a hand-over would - // deliver its own restart into its own inbox, to be executed by the very control plane that is - // wedged. A control instance must self-patch through its own service account; when it cannot, - // the reboot is refused loudly rather than parked on itself. - if (mode != SelfUpdateApply.SelfPatch && route == SelfUpdateHandover.Route.Local) - return Observable.Return(new RebootActivationOutcome(RebootActivationKinds.Refused, - "this instance IS the control instance (its hand-over route is its own inbox), and it cannot self-patch " - + $"(SelfUpdate:CanPatch={_options.CanPatch}, updater can patch={_updater.CanPatch}) — handing its own restart to its own " - + "control lane would depend on the instance being rebooted. Give the control instance self-patch through its own " - + "service account (SelfUpdate:CanPatch and the portal Role's get,patch on apps/deployments); nothing was handed over")); - if (mode != SelfUpdateApply.SelfPatch) - return ActivateCore(choice, reason); - // 🚨 Keep serving: a self-patched roll is a rolling update of the portal Deployment, so it is issued - // only when that update cannot take the portal below its serving replicas (maxSurge ≥ 1, maxUnavailable 0). - return _http.Invoke(ct => _updater.ReadRolloutStrategyAsync(ct)) - .Catch((Exception ex) => - { - _logger?.LogWarning(ex, "[SelfUpdate] could not read the portal rollout strategy for a reboot"); - return Observable.Return(null); - }) - .SelectMany(reading => RolloutStrategyReading.NonDisruptiveRefusal(reading) is { } why - ? Observable.Return(new RebootActivationOutcome(RebootActivationKinds.Refused, why + " — nothing was rolled")) - : ActivateCore(choice, reason)); - }); - - private IObservable ActivateCore(RebootImageChoice choice, string reason) - { - if (choice.Target is not { } target) - return Restart(new SelfUpdateVerdict(SelfUpdateOutcome.NoNewerRelease, $"Reboot ({reason}):"), - honourFloor: false, reason: reason) - .Select(verdict => new RebootActivationOutcome(RebootKindOf(verdict.Outcome, reason), verdict.Message)) - // A restart call that THREW is a crash, never a decided "cannot restart" (#6172). - .Catch((Exception ex) => Observable.Return(new RebootActivationOutcome( - RebootActivationKinds.Faulted, $"the restart faulted: {ex.Message}"))); - return Observable.Defer(() => ReadPolicyStream().Take(1).Timeout(TimeSpan.FromSeconds(30))) - .SelectMany(policy => Apply(SelfUpdateTrigger.PolicyChange, policy, target, honourFloor: false)) - .Where(verdict => verdict.Outcome != SelfUpdateOutcome.NoOutcome) - .Take(1) - .Select(verdict => new RebootActivationOutcome(RebootKindOf(verdict.Outcome, reason), verdict.Message)) - .Catch((Exception ex) => Observable.Return(new RebootActivationOutcome( - RebootActivationKinds.Faulted, $"the roll to {target} faulted: {ex.Message}"))); - } - - /// - /// How a self-update verdict reads as a reboot's activation. A roll or restart on its way is - /// scheduled; an attempt that CRASHED (a hand-over that failed, a registry check that could not - /// be made) is and retried, never final (#6172); a - /// decided refusal (held, deferred, no restart path, a failed migration) is - /// . An outcome this switch does not name is still - /// reported Unavailable, but it is logged BY NAME so it is never folded away silently. - /// - /// The verdict's outcome. - /// The reboot's reason, for the log line. - internal string RebootKindOf(SelfUpdateOutcome outcome, string reason) - { - switch (outcome) - { - case SelfUpdateOutcome.Applied: return RebootActivationKinds.Rolled; - case SelfUpdateOutcome.Restarted: return RebootActivationKinds.Restarted; - case SelfUpdateOutcome.HandedOver: - case SelfUpdateOutcome.RestartHandedOver: return RebootActivationKinds.HandedOver; - case SelfUpdateOutcome.RestartHandoverFailed: - case SelfUpdateOutcome.HandoverFailed: - case SelfUpdateOutcome.CheckFailed: return RebootActivationKinds.Faulted; - case SelfUpdateOutcome.UpdatesDisabled: - case SelfUpdateOutcome.NoNewerRelease: - case SelfUpdateOutcome.Held: - case SelfUpdateOutcome.Deferred: - case SelfUpdateOutcome.DetectOnly: - case SelfUpdateOutcome.ComboBlocked: - case SelfUpdateOutcome.MigrationFailed: - case SelfUpdateOutcome.MigrationUnavailable: - case SelfUpdateOutcome.InstalledTagWithdrawn: - case SelfUpdateOutcome.RestartDeferred: - case SelfUpdateOutcome.RestartUnavailable: return RebootActivationKinds.Unavailable; - default: - _logger?.LogWarning( - "[Reboot] activation for '{Reason}' answered {Outcome}, which the reboot lane does not name — reported as {Kind}", - reason, outcome, RebootActivationKinds.Unavailable); - return RebootActivationKinds.Unavailable; - } - } - - private IObservable Restart(SelfUpdateVerdict platform, bool honourFloor, string? reason) - { - var installed = ShippedReleaseSeed.InstalledPlatformVersion; - var handover = ResolveHandover(); - var settings = handover.ReadSettings(); - var route = SelfUpdateHandover.RouteFor(settings); - switch (SelfUpdateHandover.ApplyModeFor(_options.CanPatch, _updater.CanPatch, route)) - { - case SelfUpdateApply.ControlLane: - // 🚨 A restart re-creates the pods the record declares — the unattended class on - // the control lane (a restore of an approved state) — so a landed module is - // activated without a person, exactly as an install that patched itself did it. - // Announced on every check while the marker stands; the control plane dedupes. - _logger?.LogInformation( - "[SelfUpdate] a landed module generation is pending activation — handing the restart to the " - + "control lane ({Destination}); this install does not roll its own pods.", - DescribeRoute(route, settings)); - return handover.Announce(RestartAnnouncement(installed, reason)) - .Select(outcome => SelfUpdateVerdict.RestartHandedOver( - platform, installed, outcome.Destination, outcome.Detail)) - .Catch((Exception ex) => - { - _logger?.LogWarning(ex, - "[SelfUpdate] the restart hand-over to the control lane FAILED; the next check announces it again."); - return Observable.Return(SelfUpdateVerdict.RestartHandoverFailed(platform, installed, ex.Message)); - }); - case SelfUpdateApply.DetectOnly when !_options.CanPatch: - return Observable.Return(SelfUpdateVerdict.RestartUnavailable( - platform, installed, - "this install does not self-patch, and " + (SelfUpdateHandover.Missing(settings) ?? "no control inbox is configured"))); - case SelfUpdateApply.DetectOnly: - return CannotPatchReason().Select(why => SelfUpdateVerdict.RestartUnavailable( - platform, installed, "this install does not self-patch" + why)); - } - - // The same floor read Apply makes, and skipped for the same reason when the floor is off: - // LastRolledAtAsync is a Kubernetes GET whose answer cannot change a decision the floor - // does not take. - var lastRolled = !honourFloor || _options.MinRollInterval <= TimeSpan.Zero - ? Observable.Return(null) - : _http.Invoke(ct => _updater.LastRolledAtAsync(ct)); - return lastRolled.SelectMany(lastRolledAt => - { - if (honourFloor && SelfUpdateVerdict.RestartDeferredBy( - platform, installed, lastRolledAt, _options.MinRollInterval, DateTimeOffset.UtcNow) - is { } deferred) - return Observable.Return(deferred); - - _logger?.LogInformation( - "[SelfUpdate] a landed module generation is pending activation — restarting the " - + "workloads on {Installed} (last rolled {LastRolled}).", - installed, lastRolledAt?.ToString("O") ?? "never"); - return _http.Invoke(ct => _updater.RestartAsync(ct)) - .Select(restarted => restarted - ? SelfUpdateVerdict.Restarted(platform, installed, lastRolledAt) - : SelfUpdateVerdict.RestartUnavailable(platform, installed, - "the deployment updater cannot roll the running image — it predates " - + "IDeploymentUpdater.RestartAsync; update the MeshWeaver.SelfUpdate.Aks module")); - }); - } - - /// - /// 🚨 Reports the outcome of ONE check — the single reporting site, and the thing whose absence - /// #2553 measured as "ZERO SelfUpdate log lines in 6.7 h while three builds behind". - /// - /// It reports TWICE, to two audiences with different failure modes: - /// - /// a LOG line, one per check, naming the trigger and the verdict; and - /// a durable stamp on Admin/UpdatePolicy - /// ( / LastCheckVerdict / - /// LastCheckTrigger). - /// - /// - /// Both, because the log alone was not enough and the reason is instructive: every line - /// this service had to say was LogInformation, the portal image caps its whole logger - /// prefix at Warning, and no deployment had added the category. So the mechanism was - /// working exactly as designed and reporting into a void — the one thing an operator could - /// read, the Updates tab, showed "No newer version detected yet" because that is what an - /// install that has never checked and an install that checked and found nothing both look - /// like. A node write does not depend on a log level anyone remembered to set. - /// - /// 🚨 THE DEAD-EVENT-CHANNEL REPORT is the one line that goes to Warning: a check woken - /// by the SAFETY NET, on an install that has seen no build-completion event at all, that - /// nevertheless FOUND a newer release. Each clause matters. "No events yet" alone is not - /// alarming — an install whose modules rarely build legitimately sees none for days — and - /// warning on it would train people to ignore the line. "A release was waiting and nothing - /// told us" is the #2494 symptom exactly, and it is only ever observable from here. - /// - /// Bookkeeping, never a gate (#1020): the stamp is best-effort and a failed write is a - /// warning that names itself, never something that can abort a check or hold a roll. - /// - private IObservable ReportCheck(SelfUpdateTrigger trigger, SelfUpdateVerdict verdict) => - Observable.Defer(() => - { - var eventChannelSilent = trigger == SelfUpdateTrigger.SafetyNet - && Volatile.Read(ref _buildEventsSeen) == 0 - && verdict.FoundNewerRelease; - - if (eventChannelSilent) - _logger?.LogWarning( - "[SelfUpdate] check ({Trigger}): {Verdict} 🚨 This install has received NO " - + "build-completion event since it started, yet a newer release was waiting — " - + "only the safety net found it. The event channel (GitHub webhook → " - + "WebhookInbox target → Admin/_Build) is not reaching this instance; every " - + "joint of it fails silently, so check them rather than this service.", - trigger, verdict.Message); - // 🚨 ONE classification (SelfUpdateVerdict.IsFailure) for the log level, the policy node's - // lastCheckFailed and the `self_update` entry on /health — three surfaces that can never - // disagree about whether this install's self-update is failing (policy - // control-first-never-silent). An install whose own tag no longer resolves (#3543), a - // landed module nothing will activate (#3650), a release neither applied nor handed over - // (#4098) and a roll the schema refused are all deliveries that stopped. - else if (verdict.IsFailure) - _logger?.LogWarning("[SelfUpdate] check ({Trigger}): {Verdict}", trigger, verdict.Message); - else - _logger?.LogInformation("[SelfUpdate] check ({Trigger}): {Verdict}", trigger, verdict.Message); - - // The PUBLIC half: /health's `self_update` entry reads this, so a failing self-update is - // visible from outside the instance — the CD arming reads control's (policy - // control-first-never-silent). In-memory and synchronous, so it cannot fail the way the - // node write below can; a host without the census (a test) simply has no public reading. - _hub.ServiceProvider.GetService()?.Record(new SelfUpdateCheckReading( - DateTimeOffset.UtcNow, trigger.ToString(), verdict.Outcome.ToString(), verdict.Message, verdict.IsFailure)); - - return RecordCheck(trigger, verdict) - .Catch((Exception ex) => - { - _logger?.LogWarning(ex, - "[SelfUpdate] could not record the check verdict on {Node}; the check itself " - + "still ran and its verdict is above.", UpdatePolicyNodeType.NodePath); - return Observable.Return(Unit.Default); - }); - }); - - public Task StopAsync(CancellationToken cancellationToken) - { - _subscription?.Dispose(); - // Complete the seam so a subscriber awaiting a check that will now never come is - // released rather than left hanging on a stopped service. - _checksReported.OnCompleted(); - return Task.CompletedTask; - } - - /// - /// The policy source driving the poller. Two fault-isolated stages, each with its own silent - /// resubscribe (🔁 wedges-to-zero: the 2026-07-23 prod hub-cache SubscribeRequest to - /// Admin/UpdatePolicy timed out while the pod was degraded, OnError'd through - /// .Switch() into the terminal Subscribe, and KILLED the poller for the life of the pod — - /// exactly when the update it polls for is what would have recovered it): - /// - /// Seed Admin/UpdatePolicy if absent (storm-safe, via a query — never a point-read - /// of a maybe-absent node); a seeding fault retries at the polling cadence. When the deployment - /// record declares a policy (SelfUpdate:DefaultPolicy is set), converge the EXISTING - /// node's policy and pattern to it (). - /// The live node stream, and NOTHING ELSE. 🚨 The configured default is never emitted - /// here (MeshWeaver#2731/#2797): it used to be prepended once, before the first live emission, - /// and that one synthetic value drove a full — ACR listing, candidate - /// selection and the Deployment PATCH — under a policy the install had never set. On a pinned - /// (None) install, EVERY pod (re)start therefore rolled the portal to the newest tag, - /// with the persisted None arriving a second later as a PolicyChange that - /// dutifully logged "updates are disabled" AFTER the roll had been issued. Stage 1 guarantees - /// the node EXISTS, so the live read is the first emission on its own — the poller now waits - /// for a policy it has actually read instead of guessing one. A stream fault retries inside - /// the RetryWhen, so a retry re-establishes the read silently (Copilot review on #611). - /// - /// Retries are delayed, Rx-composed resubscribes at the polling cadence — not a hot retry loop, - /// not a watchdog. - /// - /// 🚨 Nothing de-duplicates the CONTENT here, and that is deliberate. This source - /// used to end in DistinctUntilChanged(c => (c.Policy, c.RequireCiGreen)) — a leftover - /// from the Switch-based shape, whose comment still said "re-switch only on a REAL policy - /// change" long after the Switch was gone. With the watch moved OUT of the policy stream - /// there is nothing left to re-drive, so the operator no longer prevented a resubscribe; it - /// FILTERED the content, and this stream is also what StartAsync reads AT DECISION TIME - /// (policy.Take(1) off the Replay(1)). Every field other than those two was - /// therefore pinned to the first emission for the life of the pod: a combo verdict landed by - /// mw-combo-verify after startup was invisible to the gate that exists to honour it, and - /// the poller rolled to an image it had been told this instance's modules cannot run. The - /// trigger stream keeps its OWN DistinctUntilChanged(content => content.Policy), so a - /// content change that is not a policy change still triggers nothing — including this service's - /// own LastCheckedAt/LastCheckVerdict bookkeeping writes, which is why letting - /// them through cannot loop. - /// - private IObservable CreatePolicySource() - { - var accessService = _hub.ServiceProvider.GetService(); - // 🚨 The deployment record is AUTHORITATIVE when it declares a policy (policy - // `self-update-record-authoritative`): read off the RAW configuration key, so an unset key — - // which the options binder reads as Stable — stays "nothing declared" and the in-portal - // choice stands. Without this the record's value was a seed only, and an instance whose - // node predated the seeding followed a stale node for ever (Doc/Architecture/SelfUpdateFreeze, - // "The build instance"). - var declared = UpdatePolicyNodeType.DeclaredByDeployment(ResolveConfiguration(), _logger); - return Observable - .Defer(() => UpdatePolicyNodeType.EnsureExists( - _hub, accessService, _options.DefaultPolicy, _logger, _options.DefaultPattern) - .SelectMany(path => declared is null - ? Observable.Return(path) - : UpdatePolicyNodeType.ConvergeToDeclaration(_hub, accessService, declared, _logger) - // A convergence that cannot land (content this build cannot read — the - // typed write refuses rather than overwrite) must not gate the poller - // (#1020): the install then follows the node as it stands, and says so. - .Catch((Exception ex) => - { - _logger?.LogWarning(ex, - "[SelfUpdate] could not converge {Path} to the deployment record ({Policy}, " - + "pattern: {Pattern}); this install follows the node as it stands until the next start.", - UpdatePolicyNodeType.NodePath, declared.Policy, declared.Pattern ?? "none"); - return Observable.Return(path); - }))) - .RetryWhen(ResubscribeAfterRetryInterval("policy-node seeding")) - .Take(1) - .SelectMany(_ => Observable - .Defer(ReadPolicyStream) - // The gate has opened: from here a fault costs freshness, not the feature. - .Do(_ => Interlocked.Exchange(ref _policyEstablished, 1)) - .RetryWhen(ResubscribeAfterRetryInterval("policy stream"))); - } - - /// - /// The live Admin/UpdatePolicy read: node stream → parsed content. Virtual: the resilience - /// test overrides this to inject faults at the exact seam the prod hub-cache SubscribeRequest - /// timeout surfaced through. Only ever subscribed AFTER the seed stage, so the path exists. - /// - protected virtual IObservable ReadPolicyStream() - { - var workspace = _hub.GetWorkspace(); - var jsonOptions = _hub.JsonSerializerOptions; - return workspace.GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - // 🚨 An ABSENT node is not a policy (MeshWeaver#2731/#2797). UpdatePolicyContent.Policy - // defaults to Continuous and UpdatePolicyKind.Continuous is enum 0, so parsing a null - // node yields "roll to the newest tag" — the exact verdict a pinned install must never - // reach. Stage 1 has already guaranteed the node exists, so a null here is the stream - // not having loaded it yet: wait for the real one rather than deciding without it. - .Where(node => node is not null) - .Select(node => UpdatePolicyNodeType.Parse(node, jsonOptions)); - } - - /// - /// Ticks once per NEW green build of ANY repository that publishes — the platform and every - /// satellite alike — - /// the event-driven complement to the interval. The BuildCompletion node is a FACT the - /// GitHub webhook writes; on an install without the webhook the query never yields and the - /// interval still drives everything. Throttled so a burst of workflow completions coalesces - /// into one check. This is a THIRD mesh touch, and the standing invariant applies: it may never - /// gate or kill the poller — a fault re-establishes the watch silently at the polling cadence, - /// and still lists ACR itself, so a spurious tick costs one cheap tag - /// list and can never cause a wrong roll. - /// - protected virtual IObservable BuildCompletionTicks() => - Observable - .Defer(() => NewBuildEventsAcross(_hub - .GetQuery($"SelfUpdate.BuildTrigger:{_hub.Address}", - // 🚨 PATH-SCOPED (BuildCompletion.WatchQuery). Build records live in the ADMIN - // partition, which an UNSCOPED query does not reach — the bare - // `nodeType:BuildCompletion` this used to pass returned EMPTY however many - // records existed and however often they were written, so this watch never - // ticked and the install silently stopped self-updating. (Not a satellite rule: - // GitHubSyncConfig satellites in ordinary partitions ARE returned unscoped.) - BuildCompletion.WatchQuery))) - .Throttle(_options.EventCoalesceWindow) - .Select(_ => SelfUpdateTrigger.BuildCompletion) - .RetryWhen(ResubscribeAfterRetryInterval("build-completion watch")); - - /// - /// Turns a live read of one node into "a NEW write happened while we were watching": the - /// replayed current state (or its absence) is baseline, never an event — a pod start must not - /// look like a fresh build — and every subsequent version change is one event. Pure and static, - /// so the distinction is pinned by a unit test rather than re-derived from Rx operator lore. - /// - public static IObservable NewBuildEvents(IObservable node) => - node.Scan( - (Baselined: false, Version: 0L, IsEvent: false), - (state, current) => current is null - ? (true, state.Version, false) // absent baseline (or deleted) - : !state.Baselined - ? (true, current.Version, false) // replayed current state - : current.Version != state.Version - ? (true, current.Version, true) // a NEW green build - : (true, current.Version, false)) - .Where(s => s.IsEvent) - .Select(_ => Unit.Default); - - /// - /// The collection-wide form: "a NEW build completed for ANY repository we care about". - /// - /// Every repository that publishes writes its own BuildCompletion record under - /// Admin/_Build — the platform and every satellite alike — so watching the whole - /// collection is what makes "listen for updates of any module or platform" one subscription - /// rather than a per-repo fan-out that would have to be rebuilt whenever the installed set - /// changes. - /// - /// Same baseline rule as the single-node form and for the same reason: the replayed - /// current state is baseline, never an event, so a pod start cannot look like a fresh build. - /// After that, a record whose version moved OR a record that appeared is one event. A record - /// that DISAPPEARS is deliberately not an event — there is nothing to update toward. - /// - /// Pure and static, so the distinction is pinned by unit tests instead of re-derived - /// from Rx operator lore. - /// - public static IObservable NewBuildEventsAcross(IObservable?> nodes) => - nodes.Scan( - (Baselined: false, Versions: ImmutableDictionary.Empty, IsEvent: false), - (state, current) => - { - var snapshot = (current ?? []) - .GroupBy(n => n.Path) - .ToImmutableDictionary(g => g.Key, g => g.Max(n => n.Version)); - if (!state.Baselined) - return (true, snapshot, false); - var advanced = snapshot.Any(kv => - !state.Versions.TryGetValue(kv.Key, out var seen) || kv.Value != seen); - return (true, snapshot, advanced); - }) - .Where(s => s.IsEvent) - .Select(_ => Unit.Default); - - /// Retry signal for RetryWhen: log the fault and resubscribe after the retry - /// interval (delayed, Rx-composed — no hot loop). - private Func, IObservable> ResubscribeAfterRetryInterval(string stage) => - faults => faults.SelectMany(ex => - { - // 🚨 The cadence depends on whether a policy has EVER been read, because the two states - // cost completely different things. Before the first read the poller is INERT — every - // trigger, the safety net included, is gated on that first emission — so a fault there - // takes the whole feature out; after it, the last policy is retained and checks keep - // running, so a fault costs only freshness and the long interval is right. Pacing both - // at RetryInterval is what left memex-cloud ~400 builds behind (see - // SelfUpdateOptions.PolicyEstablishRetryInterval for the measurement). - var established = Volatile.Read(ref _policyEstablished) == 1; - var delay = _options.PolicyRetryDelay(established); - _logger?.LogWarning(ex, - "[SelfUpdate] {Stage} faulted; re-establishing in {Interval} ({State}).", - stage, delay, - established - ? "a policy has been read — checks continue against it meanwhile" - : "NO policy read yet — every check is gated on it, so this install is inert until it succeeds"); - return Observable.Timer(delay); - }); - - /// - /// Whether tags are listed through the OCI Distribution API rather than ACR's own: true for - /// any that is not an *.azurecr.io host AND - /// an was supplied. Without one (a host constructed without the - /// platform registration) the ACR seam is used as before, so nothing that worked stops. - /// - private bool UsesOciListing => _oci is not null && !_options.RegistryIsAzureContainerRegistry; - - /// - /// The tag listing, from whichever registry kind - /// names (#3353). The ACR path is BYTE-IDENTICAL to what it was: the module-supplied - /// inside the Http pool. A non-ACR host — the read-through mirror - /// another installation serves — is listed by , which authenticates - /// with this install's own plugin-registry instance key and follows every page. - /// - private IObservable> ListTags() => - UsesOciListing - ? _oci!.ListTags(_options.PortalRepository) - : _http.Invoke(ct => _acr.ListTagsAsync(_options.PortalRepository, ct)); - - /// One evaluation: list tags → pick target per policy → gate target > current → - /// record availability → (if armed) patch the workloads. - /// - /// 🚨 Returns a , not Unit, and that is the whole - /// #2553 fix rather than a style change. As IObservable<Unit> this method had two - /// ways to complete having said NOTHING — a policy of None and "no candidate is newer" - /// were both bare Rx Where clauses upstream — and an empty completion is - /// indistinguishable from a check that never ran. Every exit now names its outcome, so the - /// caller has something to log and something to record. - private IObservable RunOnce(SelfUpdateTrigger trigger, UpdatePolicyContent policy) - { - // 🚨 `None` means never update, and it used to be a `Where` in the trigger pipeline: the - // single most silent path in the service. An install deliberately pinned by an - // administrator and an install whose updater is broken produced byte-identical evidence - // (none), which is the exact confusion #2553 was filed about. It is a DECISION, so it says - // so — and it still costs no ACR call. - if (policy.Policy == UpdatePolicyKind.None) - return Observable.Return(SelfUpdateVerdict.UpdatesDisabled()); - - // 🚨 The CHANNEL, resolved once per check (maintainer, 2026-09-08): a continuous build is - // eligible only when the record's version pattern admits it, so `Continuous` with no - // pattern IS `Stable`. That is a real change for a record written before the pattern - // existed, so it is said — once per process, at Warning, naming the record and the pattern - // to set — rather than applied silently; SelectCandidates below applies the same - // resolution, so the log and the decision cannot disagree. - var channel = VersionSelect.ResolveChannel(policy.Policy, policy.Pattern); - if (channel.Advisory is { } advisory && Interlocked.Exchange(ref _channelAdvisoryLogged, 1) == 0) - _logger?.LogWarning("[SelfUpdate] {Advisory}", advisory); - - return ListTags() - // 🚨 The NEWEST ADMITTED release (policy platform-rolls-independently-of-packages): - // SelectCandidates filters the listing by the update policy, newest first and never - // backwards (IsNewer), and FirstRollable takes the head. No package answer walks past - // it — a missing content bake, an unresolvable identity or a combo Red is recorded as - // an advisory by GateThenApply, and the packages catch up on their own. (Before the - // policy the poller walked past unbaked candidates; holding the platform on a bake is - // what froze memex on 3.0.0-rc6 while three bakes published three other identities.) - // - // 🚨 And the selection asks a SECOND question of the same listing (#3543): does the tag - // this install RUNS still resolve in it? "Nothing is newer" is the honest verdict only - // when it does. When it does not, the install is stranded on an image no pod can start - // from, and nothing can ever be newer than a tag that already outranks everything left — - // so the eligible list becomes a RECOVERY set and the best available release is taken, - // backwards if need be. Both AKS portals sat in that state on 2026-09-07 printing the - // up-to-date sentence, and an operator had to move them by hand. - .Select(tags => VersionSelect.SelectCandidates( - tags, ShippedReleaseSeed.InstalledPlatformVersion, policy.Policy, policy.RequireCiGreen, - policy.Pattern)) - .SelectMany(selection => selection.Candidates.Length == 0 - // 🚨 "We asked and the answer was no" — the OTHER formerly-silent exit, and the one - // that made a stalled install unfalsifiable from outside. This is the normal, happy - // outcome of most checks, and it has to be SAID: it is the only thing that - // distinguishes a healthy up-to-date install from one whose checker is dead. Which - // of the three "nothing to roll to" states this is, is NothingToRoll's job. - // 🚨 …and it is where a LEGACY hold has to be cleared. Under policy - // platform-rolls-independently-of-packages this service writes no hold, so - // a hold on the record was written by an older build — and the instance it froze - // only receives this code through an external roll, typically onto the very tag - // it held. From then on nothing is newer, this branch is the only one a check - // takes, and the candidate path's unconditional clear never runs: the Updates tab - // would call the RUNNING build held for ever. - ? ClearLegacyHold(policy).Select(_ => NothingToRoll(selection)) - : FirstRollable([.. selection.Candidates]) - .SelectMany(target => RecordAvailable(target!) - // 🚨 BOOKKEEPING, NOT A GATE (#1020). RecordAvailable writes two cosmetic fields - // (LatestAvailableTag / CheckedAt) that drive the admin tab; the ROLL-FORWARD does not - // depend on them. Chaining Apply after it with .SelectMany made a failed status write - // abort the update: in production the Admin/UpdatePolicy node hub was unreachable (its - // SubscribeRequest never answered — the silo's routing was wedged), every 6 h tick - // timed out after 30 s in this write, and the portal sat 37 h on a stale image while - // the poller kept ticking and the ACR check kept succeeding. The update it would not - // apply is exactly what recovers a degraded pod, so the write must never gate it: - // surface the fault (never swallow it — the warning names the tag and the node) and - // carry on to Apply, which touches only ACR + the k8s API and works while the mesh - // is degraded. Concat, not Merge — the record still lands FIRST when it succeeds. - .Catch((Exception ex) => - { - _logger?.LogWarning(ex, - "[SelfUpdate] could not record available tag {Tag} on {Node}; applying the update anyway.", - target, UpdatePolicyNodeType.NodePath); - return Observable.Return(Unit.Default); - }) - // IgnoreElements keeps the record's own emissions out of the verdict - // stream while preserving Concat's ordering — the record still lands FIRST, - // and the Select below is unreachable by construction. - .IgnoreElements() - .Select(_ => SelfUpdateVerdict.NoOutcome()) - .Concat(GateThenApply(trigger, policy, target!))) - // A roll taken on the recovery path says so — on the verdict, not only in a log - // line, because that is the field the Updates tab and the next session read. - .Select(verdict => selection.IsRecovery - ? verdict.Recovering( - ShippedReleaseSeed.InstalledPlatformVersion, selection.Installed.Explanation) - : verdict)); - } - - /// - /// 🚨 Nothing is newer — which of the THREE states is that? (#3543) - /// - /// - /// The installed tag is still published ⇒ genuinely up to date. The normal, happy - /// outcome. - /// The installed tag is GONE from a populated listing ⇒ STRANDED, and here with nothing - /// eligible left to recover to. An operator has to act, so the verdict says so instead of - /// printing the up-to-date sentence. - /// The listing could not answer ⇒ neither. Collapsing a failed read into a real negative is - /// how an install ends up trusting a verdict nobody measured, so the "no newer release" sentence - /// carries the reason it could not be checked rather than implying it was. - /// - /// - private static SelfUpdateVerdict NothingToRoll(VersionSelect.RollCandidates selection) - { - var installed = ShippedReleaseSeed.InstalledPlatformVersion; - return selection.Installed.Resolution switch - { - VersionSelect.InstalledTagResolution.Withdrawn => - SelfUpdateVerdict.InstalledTagWithdrawn(installed, selection.Installed.Explanation), - VersionSelect.InstalledTagResolution.Indeterminate => - SelfUpdateVerdict.NoNewerRelease(selection.Listed, installed) - .InstalledTagUnchecked(selection.Installed.Explanation), - _ => SelfUpdateVerdict.NoNewerRelease(selection.Listed, installed), - }; - } - - /// - /// 🚨 The NEWEST admitted candidate — the platform rolls independently of its packages - /// (policy platform-rolls-independently-of-packages). - /// - /// Returns the head of (newest first) whenever there is - /// one: with no availability gate registered, when the selection selects it, and when the - /// selection selects nothing or faults. No candidate is walked past. What the availability - /// gate and a recorded combo Red say about the target are answers about PACKAGES — a missing - /// bake, an unresolvable framework identity, a module that does not compile against the - /// candidate — and they are recorded as advisories by ; the - /// packages catch up through their own update policy. Holding the platform for a package's - /// sake is how an enterprise deployment deadlocked: the platform waited for a content bake - /// while every package update waited for that platform. - /// - /// - /// 🚨 The selection itself is still (#3479), reached through - /// — the algorithm is stated once, - /// and the poller is one of its callers, not a second copy of it. The endpoint - /// (/api/plugins/roll-target) that answers for CD and for an operator rolling by hand - /// asks the same method, so the three roll paths cannot select differently. - /// - private IObservable FirstRollable(string[] candidates) - { - if (candidates.Length == 0) - return Observable.Return(null); - - return SelectAmong(ShippedReleaseSeed.InstalledPlatformVersion, [.. candidates]) - .Select(outcome => outcome.SelectedVersion ?? candidates[0]); - } - - /// - /// 🚨 The release-availability question (#1754), asked of the target before the roll — and - /// answered as an ADVISORY, never a hold (policy - /// platform-rolls-independently-of-packages). - /// - /// Whether every package this environment deploys has a usable artifact for the target — - /// a sealed content bake under its framework identity, a landed module that links — is a - /// question about the PACKAGES. Its answer is passed through - /// : a refusal, and an answer that could not - /// be determined, become advisories and the roll proceeds. The packages that do not follow keep - /// their previous generation or compile at boot, and catch up through their own update - /// policy. - /// - /// It stays LOUD: the waived reason is logged at Warning, recorded on the policy node as - /// (never - /// — this service writes no hold, and clears one an older build recorded), and carried on the - /// check verdict, so the Updates tab names every package that did not follow. - /// - /// Like , the bookkeeping write can never gate the roll - /// (#1020): recording is best-effort, and a gate that is not registered at all is reported by - /// as an unanswered package question rather than as an exception - /// that kills the tick. - /// - private IObservable GateThenApply(SelfUpdateTrigger trigger, UpdatePolicyContent policy, string target) => - Observable.Defer(() => - { - var gate = ResolveAvailabilityGate(); - if (gate is null) - return GateNotWired(trigger, policy, target); - - return gate.IsUpdatable(target) - .SelectMany(raw => - { - // 🚨 THE PLATFORM ROLLS INDEPENDENTLY OF ITS PACKAGES (policy - // platform-rolls-independently-of-packages). A package- - // availability verdict — a missing bake, an unresolvable framework identity, a - // package without an artifact for the target, an unreadable catalogue — is - // NEVER a hold on this roll: it becomes an ADVISORY, logged here, recorded on - // Admin/UpdatePolicy beside the (cleared) hold so the Updates and About tabs - // name every package that will not follow, and carried on the check verdict. - // The packages catch up through their own update policy and the held-update - // lane — never the reverse. Holding here deadlocked an enterprise deployment - // on 2026-10-11: the platform waited for a content bake of 3.1.10502 while - // every package update waited for platform >= 3.1.10493. - var verdict = raw.ForPlatformRoll(); - if (verdict.WaivedHold is { } waived) - _logger?.LogWarning( - "[SelfUpdate] package availability does NOT hold the platform roll to {Tag} " - + "(the platform rolls independently of packages; {Count} package(s) catch up " - + "on their own{Indeterminate}): {Reason}", - target, verdict.Lagging.Count(), - raw.IsIndeterminate ? ", availability could not be determined" : "", - waived); - if (verdict.NotEnforcedReason is { } notEnforced) - _logger?.LogInformation( - "[SelfUpdate] release-availability gate not enforced for {Tag}: {Reason}", - target, notEnforced); - // 🚨 The advisories ride beside the verdict, never inside it (#3651, #3648): - // the packages this roll would recompile at boot, a landed module whose - // loadability on the target could not be measured, a declared floor the - // target does not rank above — and, under policy - // platform-rolls-independently-of-packages, the waived package hold. - if (!verdict.Advisories.IsDefaultOrEmpty) - _logger?.LogInformation( - "[SelfUpdate] rolling to {Tag} with {Count} advisory line(s) — " - + "reported, never a hold: {Advisories}", - target, verdict.Advisories.Length, - string.Join("; ", verdict.Advisories)); - return ComboThenApply(trigger, policy, target, verdict); - }); - }); - - /// - /// 🚨 The combo question (#2274), asked last before the roll — reported, never a hold - /// (policy platform-rolls-independently-of-packages). - /// - /// "A sealed bake exists for it" was never the whole question. Whether the candidate can - /// SERVE the modules this instance has actually landed is a separate one — and it can fail to while - /// every artifact is present, because a framework-identity change invalidates the assembly - /// cache by design and an optional parameter added to a record's primary constructor replaces - /// the signature. memex.systemorph.com was trapped between two failing states for exactly that - /// reason: rolling forward aborted the host with a MissingMethodException, and so did - /// re-fetching the bundles, from the other side. - /// - /// The three verdicts are never conflated (): - /// - /// Green ⇒ cleared. The roll proceeds and the clearance is logged with its - /// caveats. - /// Red ⇒ ADVISORY. Every failing module is NAMED on Admin/UpdatePolicy where the Updates tab reads it, logged at Warning - /// and carried on the check verdict, and the roll proceeds: those modules keep their previous - /// generation and catch up through their own update policy. - /// NotVerifiable — and its sibling, no verdict at all ⇒ NEITHER. It does not clear - /// (that would reproduce the outage, treating "we could not find out" as "all clear") and it - /// does not refuse (that would freeze every instance the moment this shipped, since producing a - /// verdict needs docker a pod does not have). The roll rests on the other gates, and the fact is - /// recorded on the check verdict and logged at Warning when a patch is actually issued — an - /// UNVERIFIED roll is a state an operator can see, never a silent one. - /// - /// - /// The policy node as read this tick. - /// The candidate. - /// The availability verdict for the candidate, already passed - /// through — recorded on the policy node so - /// its advisories reach the Updates tab (#3651); null when no availability gate ran. - private IObservable ComboThenApply( - SelfUpdateTrigger trigger, UpdatePolicyContent policy, string target, UpdatabilityVerdict? availability = null) - { - var combo = ResolveComboGate(); - return (combo is null - // Not registered is not a pass: it folds to NotVerified, which grants nothing. - ? Observable.Return(ComboVerificationGate.NotRegistered(target)) - : combo.Clearance(policy, target, _options.PortalImage(target))) - .SelectMany(clearance => - { - // 🚨 A RED combo verdict is an ADVISORY under policy - // platform-rolls-independently-of-packages, not a hold. It names modules - // this instance has landed that do not survive the candidate — a statement about - // PACKAGES. Each failing module is named on Admin/UpdatePolicy (the Updates tab), - // logged at Warning, and carried on the check verdict; the module keeps its - // previous generation (the boot-time probe and keep-the-previous-generation - // fallback are the safety net) and catches up through its own update policy. - var advised = availability; - if (clearance.Refuses) - { - _logger?.LogWarning( - "[SelfUpdate] the combo gate recorded RED for {Tag} — an ADVISORY, not a hold: " - + "the platform rolls independently of packages and the named modules catch up " - + "on their own — {Reason}", - target, clearance.Reason); - advised = WithComboAdvisory(availability, clearance); - } - else if (clearance.IsCleared) - _logger?.LogInformation( - "[SelfUpdate] combo gate cleared {Tag}: {Reason}", target, clearance.Reason); - - // Clearing is unconditional, exactly as on the availability path: a previous hold - // that no longer applies must disappear from the admin tab the moment it is - // resolved. The availability verdict rides along so what it SAID about this tag - // (the boot-compile cost, an unmeasurable module — #3651; the packages that do not - // follow the roll and a combo Red — policy - // platform-rolls-independently-of-packages) is recorded with the clear - // rather - // than lost with it. - return RecordHold(target, advised).Catch(PolicyStateWriteFailed(target, "the package advisories (and the clearing of any stale hold)")) - .IgnoreElements() - .Select(_ => SelfUpdateVerdict.NoOutcome()) - .Concat(Apply(trigger, policy, target) - .Select(verdict => Qualify(verdict, clearance)) - .Select(verdict => advised?.WaivedHold is { } waived - ? verdict.WithPackageAdvisory(waived) - : verdict)); - }); - } - - /// - /// Carries a non-clearing combo answer onto the check verdict — the durable half — and raises - /// the log to Warning at the one moment the risk is actually TAKEN: a patch issued without - /// clearance. A deferred or detect-only check took no risk, so it says so without shouting. - /// - private SelfUpdateVerdict Qualify(SelfUpdateVerdict verdict, ComboClearance clearance) - { - if (clearance.IsCleared) - return verdict; - if (clearance.Refuses) - { - if (verdict.Outcome == SelfUpdateOutcome.Applied) - _logger?.LogWarning( - "[SelfUpdate] rolled {Tag} although the combo gate recorded RED — the platform rolls " - + "independently of packages: {Reason}", - clearance.CandidateTag, clearance.Reason); - return verdict.WithPackageAdvisory($"the combo gate recorded RED — {clearance.Reason}"); - } - if (verdict.Outcome == SelfUpdateOutcome.Applied) - _logger?.LogWarning( - "[SelfUpdate] rolled {Tag} WITHOUT combo clearance — {Reason}", - clearance.CandidateTag, clearance.Reason); - return verdict.Unverified(clearance.Reason); - } - - /// - /// The combo Red expressed as what it is under policy - /// platform-rolls-independently-of-packages — an ADVISORY on the availability - /// verdict the roll records: one - /// per FAILING MODULE (so the Updates tab can name them all) and one advisory line naming the - /// refusal, leading. The verdict stays updatable: a module that does not survive the candidate - /// keeps its previous generation and catches up on its own; it never holds the platform. - /// - private static UpdatabilityVerdict WithComboAdvisory(UpdatabilityVerdict? availability, ComboClearance clearance) - { - var current = availability ?? new UpdatabilityVerdict(true, [], null); - return (current with - { - Packages = - [ - .. current.Packages.IsDefault ? [] : current.Packages, - .. (clearance.Verdict?.FailedModules ?? []).Select(module => - new PackageAvailability( - module.ModuleId, - PackageAvailabilityKind.ComboVerificationFailed, - module.Failures.Count > 0 ? module.Failures[0] : clearance.Reason)), - ], - }).WithAdvisories( - [ - LocalizableText.Keyed( - "the platform rolls independently of packages — the combo gate recorded RED for this " - + $"release: {clearance.Reason}; those modules keep their previous generation and catch " - + "up through their own update policy", - ReleaseAdvisoryCode.ComboRed, ("reason", clearance.Reason)), - .. current.AdvisoryTexts, - ]); - } - - /// - /// 🚨 No gate is registered on this host — the roll proceeds, SAYING that package - /// availability was not checked. - /// - /// The gate answers a question about PACKAGES — does every package this environment - /// deploys have a usable artifact for the target release — and no answer to it, not even its - /// absence, holds the platform (policy platform-rolls-independently-of-packages). A - /// host with no gate registered has not answered the question; it has failed to ask. That is - /// an ADVISORY, and it is never SILENT: logged at Warning on every tick, recorded on the - /// policy node among the advisories so the Updates tab names it, and carried on the check - /// verdict. Registering the gate replaces it with the real package answer on the next tick. - /// - /// 🚨 Only where there is something to verify. The advisory is scoped by - /// : on a deployment that consumes - /// no CI bakes a registered gate would itself answer - /// , so its ABSENCE is not an unanswered question - /// — it is the same answer arrived at from configuration, and it is logged at Information. - /// - /// The combo question is still asked on both branches. Before the policy this state was - /// a hold logged at Error, with as its - /// escape hatch; that option no longer has any effect here. - /// - private IObservable GateNotWired(SelfUpdateTrigger trigger, UpdatePolicyContent policy, string target) => - Observable.Defer(() => - { - // 🚨 "CANNOT VERIFY" AND "VERIFIED AS NOTHING TO VERIFY" ARE DIFFERENT STATES, and only - // the first is an advisory. A deployment that consumes no CI bakes already compiles - // its content at every boot, so a REGISTERED gate would have answered NotEnforced for - // it: the gate being absent there tells you nothing new, and is said as "not enforced". - // - // The applicability rule is read from CONFIGURATION through the gate's own static, so - // the answer here and the answer a registered gate would give cannot drift apart. - if (ReleaseAvailabilityService.NotApplicableReason(ResolveConfiguration()) - is { } notApplicable) - { - _logger?.LogInformation( - "[SelfUpdate] release-availability gate not enforced for {Tag}: {Reason} " - + "(no gate is registered on this host either, which changes nothing here — " - + "there is nothing for it to check against).", - target, notApplicable); - // 🚨 The COMBO gate still runs. "This deployment consumes no CI bakes" answers - // the availability question and nothing else: an instance can carry landed modules - // whose content the candidate cannot serve whatever its bundle root says, which is - // precisely the state #2274 was filed about. - return ComboThenApply(trigger, policy, target); - } - - // 🚨 The unanswered package question is an ADVISORY under policy - // platform-rolls-independently-of-packages, like every other - // package-availability answer. It is still never silent: logged at Warning on every - // tick, recorded on Admin/UpdatePolicy beside the cleared hold, carried on the check - // verdict — and the combo gate still runs. AllowUnverifiedRoll is no longer needed to - // roll here; it only quietens nothing, by design. - var verdict = UpdatabilityVerdict.Unavailable( - "no release-availability gate is registered on this host, so nothing checked " - + "whether the packages this environment deploys have usable artifacts for this " - + $"release. Register {nameof(ReleaseAvailabilityService)} (it is wired by " - + "AddSelfUpdate) to have the packages that will not follow the roll named.") - .ForPlatformRoll(); - - _logger?.LogWarning( - "[SelfUpdate] rolling {Tag} with package availability UNCHECKED{Allowed} — {Reason}", - target, - _options.AllowUnverifiedRoll - ? $" ('{SelfUpdateOptions.SectionName}:{nameof(SelfUpdateOptions.AllowUnverifiedRoll)}' is set)" - : "", - verdict.WaivedHold); - - return ComboThenApply(trigger, policy, target, verdict); - }); - - /// - /// The release-availability gate, resolved from the mesh's services. Virtual: the third - /// documented injection seam, so a test can pin what the poller DOES with a verdict without - /// also staging an artifact store (the verdict itself is pinned against a real one elsewhere). - /// - protected virtual ReleaseAvailabilityService? ResolveAvailabilityGate() => - _hub.ServiceProvider.GetService(); - - /// - /// The combo gate (#2274), resolved from the mesh's services — the fourth documented injection - /// seam, so a test can pin what the poller DOES with a combo verdict without staging docker and - /// a module repository. - /// - /// 🚨 A null here is NOT a pass: folds it into - /// , which grants no clearance. AddSelfUpdate - /// registers the gate unconditionally, so no host in this repo can reach the state at all. - /// - protected virtual ComboVerificationGate? ResolveComboGate() => - _hub.ServiceProvider.GetService(); - - /// - /// The host's configuration, resolved from the mesh's services. Virtual for the same reason - /// is: whether the release-availability gate APPLIES to - /// this deployment is read from configuration, so a test must be able to present a - /// bake-consuming deployment and a bake-free one without standing up two meshes for a one-key - /// difference. - /// - protected virtual IConfiguration? ResolveConfiguration() => - _hub.ServiceProvider.GetService(); - - /// - /// The control-lane hand-over (#4098) — the fifth documented injection seam, so a test can pin - /// what the poller DOES with a configured control inbox (announces, patches nothing, records - /// the hand-over) against an in-process inbox rather than the fleet's. Never null: an - /// unconfigured hand-over answers , which is the - /// detect-only state, not an error. - /// - protected virtual SelfUpdateHandover ResolveHandover() => - _handover ??= new SelfUpdateHandover(_hub, _logger); - - private SelfUpdateHandover? _handover; - - /// The destination a verdict or a log line names — the inbox URL, or the local target. - private static string DescribeRoute(SelfUpdateHandover.Route route, SelfUpdateHandover.Settings settings) => - route switch - { - SelfUpdateHandover.Route.Post => settings.Url ?? "(no url)", - SelfUpdateHandover.Route.Local => $"this instance's own {SelfUpdateHandover.InboxTarget} inbox", - _ => "(no route)", - }; - - /// The self-update-available event for : what runs, what was selected, under which policy, and what woke the check. - private SelfUpdateHandover.Announcement ReleaseAnnouncement( - UpdatePolicyContent policy, SelfUpdateTrigger trigger, string target) - { - var installed = ShippedReleaseSeed.InstalledPlatformVersion; - return new SelfUpdateHandover.Announcement - { - Event = SelfUpdateHandover.ReleaseEvent, - CurrentVersion = installed, - NewVersion = target, - CurrentImage = _options.PortalImage(installed.Split('+')[0]), - NewImage = _options.PortalImage(target), - Policy = policy.Policy.ToString(), - Pattern = UpdateChannelPattern.Normalize(policy.Pattern), - Trigger = trigger.ToString(), - DetectedAt = SelfUpdateHandover.Stamp(DateTimeOffset.UtcNow), - }; - } - - /// The self-update-restart-pending event: the image this install runs, and why the pods should be re-created on it. - private SelfUpdateHandover.Announcement RestartAnnouncement(string installed, string? reason = null) => - new() - { - Event = SelfUpdateHandover.RestartEvent, - CurrentVersion = installed, - CurrentImage = _options.PortalImage(installed.Split('+')[0]), - // Callers pass the whole sentence: a live swap that did not go live and an explicit - // module reload explain the restart differently. - Reason = reason is { Length: > 0 } - ? reason - : "a landed module generation is pending activation (restart-as-activation, #3650)", - DetectedAt = SelfUpdateHandover.Stamp(DateTimeOffset.UtcNow), - }; - - /// - /// Stamps a successful hand-over on the policy node, as System — bookkeeping for the Updates - /// tab (), never a gate: the announcement has - /// already left, and a failed stamp is a warning that names itself. - /// - protected virtual IObservable RecordHandover(string tag, string destination) - { - var accessService = _hub.ServiceProvider.GetService(); - return accessService.RunAsSystem( - () => _hub.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Update((node, cur) => node with - { - Content = (cur ?? new UpdatePolicyContent()) with - { - HandedOverTag = tag, - HandedOverAt = DateTimeOffset.UtcNow, - HandedOverTo = destination, - }, - }) - .Select(_ => Unit.Default)); - } - - /// A failed bookkeeping write on the policy node, logged with WHAT it was writing — - /// never phrased as a hold, because under policy - /// platform-rolls-independently-of-packages this service records no hold, and a log - /// line naming one would send an operator after a state that does not exist. The roll's - /// decision is unaffected (#1020). - /// The tag the write was about. - /// What was being written, in words. - private Func> PolicyStateWriteFailed(string target, string what) => - ex => - { - _logger?.LogWarning(ex, - "[SelfUpdate] could not write {What} for {Tag} on {Node}; nothing is held, and the " - + "check's decision stands.", what, target, UpdatePolicyNodeType.NodePath); - return Observable.Return(Unit.Default); - }; - - /// - /// Clears a hold an OLDER build recorded, on the path where no candidate is evaluated (so - /// 's unconditional clear never runs). Only the hold fields: the - /// advisories recorded for the running build stay. Costs no mesh touch when no hold is - /// recorded — the happy path of nearly every check — and a failed write is logged and never - /// changes the check's verdict, exactly as a failed hold write does not. - /// - private IObservable ClearLegacyHold(UpdatePolicyContent policy) - { - if (string.IsNullOrEmpty(policy.HeldTag)) - return Observable.Return(Unit.Default); - var held = policy.HeldTag!; - _logger?.LogInformation( - "[SelfUpdate] clearing the hold an earlier build recorded on {Tag}: the platform rolls " - + "independently of packages, so no package answer holds a roll any more.", held); - var accessService = _hub.ServiceProvider.GetRequiredService(); - return accessService.RunAsSystem( - () => _hub.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Update((node, cur) => cur is null - ? node - : node with - { - Content = cur with - { - HeldTag = null, HeldReason = null, - HeldIndeterminate = false, HeldAt = null, - }, - }) - .Select(_ => Unit.Default)) - .Catch(PolicyStateWriteFailed(held, "the clearing of the hold an earlier build recorded")); - } - - /// - /// Writes (or clears, on null or on an updatable verdict) the availability hold on the policy - /// node, as System — the same shape and the same reasons as . - /// 🚨 An UPDATABLE verdict clears the hold exactly as null does and additionally records what - /// the gate SAID about the tag (#3651, ) — the - /// boot-compile cost and an unmeasurable module must be visible on the Updates tab, not only - /// in a pod log. Virtual so a test can fault it and prove the hold DECISION survives a failed - /// hold WRITE. - /// - protected virtual IObservable RecordHold(string tag, UpdatabilityVerdict? verdict) - { - var accessService = _hub.ServiceProvider.GetService(); - // 🚨 RunAsSystem, never `Observable.Using(AccessContextScope.AsSystem, …)` (#1444/#1790): - // `AsSystem(x)` IS `x.ImpersonateAsSystem()`, so the helper hides the shape. The write here - // is CROSS-HUB, which is the worst case for `Using` — Rx disposes the scope when the inner - // observable terminates, i.e. on the Admin partition hub's response thread, while the - // subscriber (the self-update poller's tick) keeps `system-security` latched. RunAsSystem - // opens and closes inside one Subscribe; the Update is still ISSUED as System, which is what - // the cross-hub patch stamps. - // 🚨 The TYPED write (#3542). `cur` is null ONLY when the node carries no content at all; - // content that is present but unreadable faults instead of arriving as null, so a record - // this build cannot parse is REFUSED here rather than replaced by a default — see - // UpdatePolicyNodeType.ParseContent. - return accessService.RunAsSystem( - () => _hub.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Update((node, cur) => - { - var current = cur ?? new UpdatePolicyContent(); - var advisories = verdict is { Advisories.IsDefaultOrEmpty: false } - ? string.Join("; ", verdict.Advisories) - : null; - // 🌍 The same advisories with their reason codes (#6540), so the Updates tab - // renders them in the viewer's language; the joined English above stays for - // older readers and is what an unkeyed item falls back to. - ImmutableList? advisoryItems = advisories is null - ? null - : [.. verdict!.AdvisoryTexts.Select(UpdateAdvisory.From)]; - return node with - { - Content = verdict is null || verdict.IsUpdatable - ? current with - { - HeldTag = null, HeldReason = null, - HeldIndeterminate = false, HeldAt = null, - AdvisoriesTag = advisories is null ? null : tag, - Advisories = advisories, - AdvisoryItems = advisoryItems, - } - : current with - { - HeldTag = tag, - HeldReason = verdict.HoldReason, - HeldIndeterminate = verdict.IsIndeterminate, - HeldAt = DateTimeOffset.UtcNow, - AdvisoriesTag = advisories is null ? null : tag, - Advisories = advisories, - AdvisoryItems = advisoryItems, - }, - }; - }) - .Select(_ => Unit.Default)); - } - - /// - /// Applies the picked target — by WHOEVER applies on this install (, - /// #4098): patch the workloads where the chart and the updater both say it may; else hand the - /// release to the control lane; else record-only (detect-and-notify), naming what is missing. - /// The intent is announced BEFORE the attempt, so a failing apply is diagnosable: the tick's - /// error sink logs a generic "check failed", and until #1020 that was the ONLY trace a stalled - /// install left — it read like a registry problem while the registry was fine. Deferred so the - /// announcement runs per subscription (per tick), not at composition. - /// - private IObservable Apply(SelfUpdateTrigger trigger, UpdatePolicyContent policy, string target, bool honourFloor = true) => - Observable.Defer(() => - { - var handover = ResolveHandover(); - var settings = handover.ReadSettings(); - var route = SelfUpdateHandover.RouteFor(settings); - switch (SelfUpdateHandover.ApplyModeFor(_options.CanPatch, _updater.CanPatch, route)) - { - case SelfUpdateApply.ControlLane: - // 🚨 No roll floor here: the floor paces POD RESTARTS, and this install restarts - // nothing — the control plane's Roll does, under its own approval and its own - // unattended rate limit. Every check that selects a target announces it; the - // control plane dedupes by (deployment, image). - _logger?.LogInformation( - "[SelfUpdate] handing {Tag} to the control lane ({Destination}) — this install does not patch itself.", - target, DescribeRoute(route, settings)); - return handover.Announce(ReleaseAnnouncement(policy, trigger, target)) - .SelectMany(outcome => RecordHandover(target, outcome.Destination) - .Catch((Exception ex) => - { - _logger?.LogWarning(ex, - "[SelfUpdate] could not record the hand-over of {Tag} on {Node}; the announcement itself went out.", - target, UpdatePolicyNodeType.NodePath); - return Observable.Return(Unit.Default); - }) - .IgnoreElements() - .Select(_ => SelfUpdateVerdict.NoOutcome()) - .Concat(Observable.Return( - SelfUpdateVerdict.HandedOver(target, outcome.Destination, outcome.Detail)))) - .Catch((Exception ex) => - { - _logger?.LogWarning(ex, - "[SelfUpdate] the hand-over of {Tag} to the control lane FAILED; the next check announces it again.", - target); - return Observable.Return(SelfUpdateVerdict.HandoverFailed(target, ex.Message)); - }); - case SelfUpdateApply.DetectOnly: - return Observable.Return(SelfUpdateVerdict.DetectOnly( - target, SelfUpdateHandover.Missing(settings) ?? "no control inbox is configured")); - } - - // 🚨 The pacing floor. A roll is a POD RESTART, so without it publication frequency is - // restart frequency and every restart drops the live circuits of everyone using the - // portal. The floor bounds the AUTOMATIC cadence only: `kubectl rollout restart` still - // takes the newest image immediately (the startup pass), and a main-cd dispatch still - // bypasses the batch window, so nothing here delays an urgent fix. - // - // Deferring is safe WITHOUT a timer for the same reason an availability hold is: the - // next publication event re-decides it. The floor never schedules anything. - // A disabled floor must not pay for the stamp read: LastRolledAtAsync is a Kubernetes GET - // in the AKS implementation, and its answer cannot change the decision when the floor is - // zero. Skipping it removes an API call and a failure point from the happy path. - // An explicit reboot is ONE authorised roll, stamped on its request before it asks — the - // floor paces the AUTOMATIC cadence only (the same exemption a module reload's restart has). - if (!honourFloor || _options.MinRollInterval <= TimeSpan.Zero) - return MigrateThenPatch(target, lastRolledAt: null); - - return _http.Invoke(ct => _updater.LastRolledAtAsync(ct)).SelectMany(lastRolledAt => - { - var since = lastRolledAt is null ? (TimeSpan?)null : DateTimeOffset.UtcNow - lastRolledAt.Value; - if (since is { } elapsed && elapsed < _options.MinRollInterval) - return Observable.Return( - SelfUpdateVerdict.Deferred(target, elapsed, _options.MinRollInterval)); - - return MigrateThenPatch(target, lastRolledAt); - }); - }); - - /// - /// 🚨 THE SCHEMA MOVES FIRST. Runs the migration for to completion and - /// only then patches the portal image — the order helm upgrade always had and a - /// set image roll never did, which is how both AKS portals ended up on 2026-09-03 with - /// new pods refusing to start on DbVersionGate (db_version 54, image expecting 55) behind - /// an old ReplicaSet that still answered 200, for seven hours, with nothing reporting it. - /// - /// The outcome decides, and the four non-success outcomes are NOT one case: - /// - /// / - /// — the migration ran and the schema demonstrably did not move ⇒ the roll is REFUSED and - /// recorded as . Rolling anyway would only - /// reproduce the wedge. - /// 🚨 — the cluster answered 403 to the Job, - /// so the migration could not be attempted and NOTHING was established about the schema ⇒ - /// the roll is REFUSED as (#4764). This - /// branch used to patch anyway "loudly", and that is the move which cannot be recovered from - /// in-process: measured on memex-cloud 2026-09-19, the new pod went - /// CrashLoopBackOff on DbVersionGate at 3.3 s while four old pods kept - /// answering 200, nothing converged and nothing rolled back. Refusing asks nothing new of an - /// operator either: a 403 means this install's chart predates - /// memex-portal/rbac.yaml's batch/jobs grant, so a helm upgrade is - /// already owed — and it runs the migration Job itself. - /// — the updater has no migration - /// mechanism at all (its MeshWeaver.SelfUpdate.Aks generation predates the seam) ⇒ the - /// roll proceeds, because refusing on "this install can never migrate" would freeze it for - /// ever and silently, the worse failure shape (#2553). What changed is that it is no longer - /// recorded as a plain Applied: the verdict carries - /// , so the policy node distinguishes a roll whose - /// schema moved from one that rolled blind — #4764's second ask. - /// - /// 🚨 The published schema step refines the two outcomes that establish nothing - /// (#4764 (b3); policy db-migration-planned). The schema a build expects used to be only a - /// constant compiled into it, so "could not run the migration" and "do not know" were the same - /// fact. The release marker now carries ExpectedDbVersion (), - /// so when both the running and the target release publish it the decision is a comparison of - /// two numbers — NotSupported across a schema bump is REFUSED naming both, and - /// Forbidden on a target that keeps the schema may roll. The shared predicate - /// is the - /// rule; this switch only says it in words. An unknown step changes nothing. - /// Both calls run on the Http pool like every other Kubernetes edge here; the migration - /// wait is bounded by inside the updater. - /// - private IObservable MigrateThenPatch(string target, DateTimeOffset? lastRolledAt) => - ReadSchemaStep(target).SelectMany(step => _http.Invoke(ct => _updater.RunMigrationAsync(target, ct)) - .SelectMany(outcome => - { - // The qualifier a roll taken WITHOUT its migration carries on the policy node, or - // null when the schema provably moved. Never a silent difference (#4764). - string? unmigrated = null; - switch (outcome) - { - case MigrationRunOutcome.Completed: - _logger?.LogInformation( - "[SelfUpdate] database migration for {Tag} completed; patching the portal image.", target); - break; - case MigrationRunOutcome.Failed: - case MigrationRunOutcome.TimedOut: - _logger?.LogCritical( - "[SelfUpdate] database migration for {Tag} ended {Outcome} — the schema did NOT " - + "move, so the portal image is NOT patched. A roll on top of an unmigrated " - + "schema is exactly the DbVersionGate crash-loop behind a 200 that this step " - + "exists to prevent. Read the Job log and Doc/Architecture/DatabaseMigrationProcedure.", - target, outcome); - return Observable.Return(SelfUpdateVerdict.MigrationFailed(target, outcome)); - case MigrationRunOutcome.Forbidden when step.KeepsSchema: - // The Job could not be created, but there is nothing for it to establish: - // the target expects no newer schema than the running release, whose pods - // passed DbVersionGate at that number. The missing grant blocks nothing. - _logger?.LogInformation( - "[SelfUpdate] the migration Job for {Tag} was refused (403), but {Step} — " - + "the schema does not move, so the portal image is patched.", - target, step.Describe()); - break; - case MigrationRunOutcome.Forbidden: - // 🚨 REFUSED, not "rolled loudly" (#4764). A 403 establishes nothing about - // the schema, and the move it used to make — patch and let DbVersionGate - // veto the new pods — is unrecoverable in-process and invisible from the - // front door. The remedy is already owed: the grant arrives with the very - // helm upgrade that renders the migration Job. - _logger?.LogCritical( - "[SelfUpdate] roll to {Tag} REFUSED: the cluster refused the migration Job " - + "(403 — the portal service account has no batch/jobs grant; the chart's " - + "memex-portal/rbac.yaml grants it on the next helm upgrade), so nothing " - + "established that the schema is where that build needs it. The portal image is NOT " - + "patched: a build expecting a newer db_version than the database has would " - + "crash-loop on DbVersionGate behind pods that keep answering 200. Run a helm " - + "upgrade for this instance — it both grants batch/jobs and runs the migration " - + "Job. Doc/Architecture/SelfUpdateSchemaWall.", - target); - return Observable.Return(SelfUpdateVerdict.MigrationUnavailable( - target, outcome, - "The portal service account has no batch/jobs grant, so the migration Job could " - + "not be created; run a helm upgrade for this instance — the chart's " - + "memex-portal/rbac.yaml adds the grant AND its own Job runs the migration.")); - case MigrationRunOutcome.NotSupported when step.MovesSchema: - // 🚨 REFUSED: the published markers say this target moves the schema, and - // this install cannot move it — rolling is now KNOWN to crash-loop on - // DbVersionGate, so it is no longer the lesser harm (#4764 (b3)). - _logger?.LogCritical( - "[SelfUpdate] roll to {Tag} REFUSED: {Step}, and this install's deployment " - + "updater cannot run a database migration. Doc/Architecture/PlanningADatabaseMigration.", - target, step.Describe()); - return Observable.Return(SelfUpdateVerdict.SchemaAhead(target, outcome, step)); - case MigrationRunOutcome.NotSupported when step.KeepsSchema: - _logger?.LogInformation( - "[SelfUpdate] {Tag} cannot be migrated by this install, but {Step} — the schema " - + "does not move, so the roll is not blind.", - target, step.Describe()); - break; - case MigrationRunOutcome.NotSupported: - // Rolls on, because an install that can NEVER migrate would otherwise freeze - // for ever and silently (#2553) — but the record says it rolled blind. - _logger?.LogWarning( - "[SelfUpdate] rolling {Tag} WITHOUT running its database migration: this " - + "install's IDeploymentUpdater cannot run one (it predates the seam, or the " - + "host is not Kubernetes). DbVersionGate is the only net. " - + "Doc/Architecture/DatabaseMigrationProcedure.", - target); - unmigrated = - "this install's deployment updater cannot run a database migration — it predates " - + "IDeploymentUpdater.RunMigrationAsync; update the MeshWeaver.SelfUpdate.Aks " - + "module. If this build expects a newer db_version than the database has, the " - + "new pods refuse to start (DbVersionGate) while the old ones keep answering 200."; - break; - } - - return _http.Invoke(ct => _updater.PatchToVersionAsync(target, ct)) - .Select(_ => - { - var applied = SelfUpdateVerdict.Applied( - target, ShippedReleaseSeed.InstalledPlatformVersion, lastRolledAt); - return unmigrated is null ? applied : applied.Unmigrated(unmigrated); - }); - })); - - /// - /// The published schema step for a roll to — both releases' - /// ExpectedDbVersion markers (), read on the file-system - /// pool. An unreadable reading is , which leaves the - /// decision exactly where it was before the field existed (the migration's own outcome decides); - /// the reader has already logged why. Virtual: the markers live on a mounted share, which is the - /// documented IO seam a test replaces. - /// - protected virtual IObservable ReadSchemaStep(string target) => - ReleaseSchemaMarker.ObserveStep( - _fileSystem, - ResolveConfiguration()?[MeshWeaver.Hosting.ShippedPrebuiltBundles.PublishedRootConfigKey], - ShippedReleaseSeed.InstalledPlatformVersion, - target, - _logger) - .Catch((Exception ex) => - { - _logger?.LogWarning(ex, - "[SelfUpdate] could not read the published ExpectedDbVersion markers for {Tag}; " - + "only the migration's own outcome decides this roll.", target); - return Observable.Return(ReleaseSchemaStep.Unknown(target)); - }); - - /// - /// Stamps the outcome of one check on the policy node, as System — the durable half of - /// , and the one that survives a deployment forgetting to set a log - /// level. Virtual so a test can fault it and prove the check itself is unaffected. - /// - /// 🚨 It cannot feed itself. The check trigger reads - /// policy.DistinctUntilChanged(c => c.Policy) and the policy source itself is - /// DistinctUntilChanged((Policy, RequireCiGreen)), so a write that touches only these - /// three bookkeeping fields emits nothing downstream and can never schedule another check — - /// the reconcile-feeds-itself write storm (#223) is structurally excluded rather than avoided - /// by convention. - /// - protected virtual IObservable RecordCheck(SelfUpdateTrigger trigger, SelfUpdateVerdict verdict) - { - var accessService = _hub.ServiceProvider.GetService(); - // RunAsSystem, never `Observable.Using(AccessContextScope.AsSystem, …)` — see - // RecordHold below (#1444/#1790). Typed write — see RecordHold (#3542). - return accessService.RunAsSystem( - () => _hub.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Update((node, cur) => node with - { - Content = (cur ?? new UpdatePolicyContent()) with - { - LastCheckedAt = DateTimeOffset.UtcNow, - LastCheckVerdict = verdict.Message, - LastCheckFailed = verdict.IsFailure, - LastCheckTrigger = trigger.ToString(), - // 🚨 Written on EVERY check, so it CLEARS itself the moment the tag - // resolves again — the same unconditional-clearing rule the availability - // hold follows. A healed strand that lingered would be a stale scare. - UnresolvedInstalledTag = verdict.UnresolvedInstalledTag, - }, - }) - .Select(_ => Unit.Default)); - } - - /// Record the newest available tag on the policy node (as System). Drives the admin tab - /// and the detect-and-notify path. Touches only the bookkeeping fields; preserves Policy. - /// Virtual: the second fault-injection seam for the resilience test (alongside - /// ) — it is where the #1020 prod stall surfaced. - protected virtual IObservable RecordAvailable(string tag) - { - var accessService = _hub.ServiceProvider.GetService(); - // RunAsSystem, never `Observable.Using(AccessContextScope.AsSystem, …)` — see - // RecordHold above (#1444/#1790). Typed write — see RecordHold (#3542). - return accessService.RunAsSystem( - () => _hub.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Update((node, cur) => node with - { - Content = (cur ?? new UpdatePolicyContent()) with - { - LatestAvailableTag = tag, - CheckedAt = DateTimeOffset.UtcNow, - }, - }) - .Select(_ => Unit.Default)); - } -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateVerdict.cs b/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateVerdict.cs deleted file mode 100644 index 3b95952ab3..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateVerdict.cs +++ /dev/null @@ -1,713 +0,0 @@ -using MeshWeaver.PluginCatalog; - -using MeshWeaver.Hosting.SelfUpdate; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// What woke a self-update check. Named, because "why did this run" is half of what makes -/// a check's outcome readable — and because a check that only ever runs on the SAFETY NET is the -/// signature of a dead event channel (#2494), which is invisible unless the trigger is recorded. -public enum SelfUpdateTrigger -{ - /// The single pass at startup, catching publications missed while this install was down. - Startup, - - /// A BuildCompletion record moved — the platform or any module this environment - /// deploys published. The intended primary driver. - BuildCompletion, - - /// An admin changed Admin/UpdatePolicy. Enabling updates must not wait for the - /// next publication. - PolicyChange, - - /// The safety net (). - /// Nothing told this install anything; it asked anyway. - SafetyNet, - - /// - /// A landing wave on THIS process proposed a new module set - /// (, #3650): a module version shipped - /// and landed, and the restart that activates it is what this check exists to take. Appended, - /// never inserted: the members before it keep their ordinals. - /// - ModuleSetProposed, -} - -/// The outcome of exactly one self-update check. -public enum SelfUpdateOutcome -{ - /// 🚨 The structural backstop: the check pipeline terminated without producing an - /// outcome at all. It should be unreachable — every branch below is exhaustive — and it exists - /// precisely so that a future filter added to the pipeline cannot re-create the silence this - /// enum was introduced to remove. Reported at Warning, naming itself. - NoOutcome, - - /// Admin/UpdatePolicy says None: this install never updates. A DECISION, - /// and previously the single most silent path in the service — a bare Rx Where that - /// discarded the check with no log line and no record. - UpdatesDisabled, - - /// The registry was listed and holds nothing newer than the installed version. - /// 🚨 The other formerly-silent path, and the one that matters most: "we asked and the answer - /// was no" is a completely different fact from "nothing ever asked", and until this outcome - /// existed the two were indistinguishable from outside the process. - NoNewerRelease, - - /// A newer release exists and the release-availability gate refused it. - Held, - - /// A newer release exists and the roll floor deferred it. - Deferred, - - /// A newer release exists and this install does not self-patch (detect-and-notify). - DetectOnly, - - /// A newer release exists and the workloads were patched to it. - Applied, - - /// The check itself faulted (ACR outage, k8s 403, …). The watch stays live. - CheckFailed, - - /// - /// 🚨 A newer release exists and the COMBO gate (#2274) refused it: a verdict recorded on - /// Admin/UpdatePolicy says at least one module this instance runs FAILS against that - /// image. Distinct from , which is the availability gate's refusal — the two - /// answer different questions ("does an artifact exist" vs "can the candidate serve what we - /// already run") and an operator fixes them in different places. - /// - /// 🚨 Appended, never inserted: the members before it keep their ordinals. - /// - ComboBlocked, - - /// - /// 🚨 A newer release exists, every gate passed, and the roll was REFUSED because the database - /// migration for it failed or did not complete: the schema demonstrably did not move, so the - /// image must not (DbVersionGate would only refuse the new pods anyway, behind a portal - /// that still answers 200). Appended, never inserted. - /// - MigrationFailed, - - /// - /// 🚨 The tag this install RUNS no longer resolves in the registry, and there is nothing eligible - /// to recover to. The install is STRANDED: its workloads name an image that has been untagged, so - /// no new pod can start, and no future publication can rescue it by being "newer" — see - /// . - /// - /// Distinct from , and that distinction is the whole of #3543: - /// the two printed the SAME sentence, so an install that could never move again looked exactly - /// like a healthy up-to-date one. Both AKS portals sat here on 2026-09-07 and had to be moved off - /// by an operator kubectl set image. - /// - /// 🚨 Appended, never inserted: the members before it keep their ordinals. - /// - InstalledTagWithdrawn, - - /// - /// Nothing newer was rolled, and the workloads were RESTARTED on the image they run because a - /// landed module generation was waiting for exactly that (PendingRestart, #3650). Not a - /// newer release — stays false — so a - /// safety-net check that restarts never fires the dead-event-channel report. Appended, never - /// inserted. - /// - Restarted, - - /// - /// A restart is pending and the roll floor (MinRollInterval) deferred it — a restart is - /// a roll and drops the same live circuits, so it is paced like one. Re-decided on the next - /// check, never scheduled. Appended, never inserted. - /// - RestartDeferred, - - /// - /// 🚨 A restart is pending and this install CANNOT take it: it does not self-patch, or its - /// updater predates . A landed module that nothing - /// will ever activate is a state an operator has to see, so it is reported at Warning and on - /// the policy node, never folded into "no newer release". Appended, never inserted. - /// - RestartUnavailable, - - /// - /// A newer release exists and it was HANDED to the control lane (MeshWeaver#4098): one signed - /// self-update-available event reached the control instance's inbox, and the control - /// plane opens the Roll. This install patched nothing — the correct state of a portal - /// that holds no credential for the cluster. Appended, never inserted. - /// - HandedOver, - - /// - /// A newer release exists, this install does not patch itself, and the hand-over to the control - /// lane FAILED (the inbox refused the signature, or was unreachable). Named apart from - /// because the CHECK succeeded — the release is known, the record says - /// so, and the next check announces it again. Appended, never inserted. - /// - HandoverFailed, - - /// - /// A landed module generation is pending activation and the RESTART was handed to the control - /// lane (self-update-restart-pending) — a restart re-creates the pods the record - /// declares, so the control plane takes it unattended. Appended, never inserted. - /// - RestartHandedOver, - - /// - /// 🚨 A newer release exists, every gate passed, and the roll was REFUSED because the database - /// migration for it could not be ATTEMPTED at all — the cluster refused the Job - /// (). - /// - /// Distinct from , and the distinction is the operator's - /// whole next move: MigrationFailed means the migration ran and broke — read its log — - /// while this means the mechanism is not there, and a helm upgrade both grants it and - /// runs the migration Job itself. Distinct from and - /// for the same reason those two are distinct from each other: three - /// gates, three places to go. - /// - /// 🚨 Appended, never inserted: the members before it keep their ordinals. - /// - MigrationUnavailable, - - /// - /// A landed module generation was pending and went LIVE in the running process (policy - /// module-live-update-default): swapped into its own load context, its hubs recycled — - /// no restart was announced or taken. Appended, never inserted. - /// - ActivatedLive, - - /// - /// A landed module generation is pending activation and the RESTART hand-over to the control - /// lane FAILED (the inbox was unreachable or refused the announcement). Named apart from - /// because it is TRANSIENT — the mechanism exists and the next - /// attempt may get through — whereas RestartUnavailable is a decided answer (this install - /// cannot restart itself at all). A module reload records this as Faulted and retries it, - /// never a final Failed (MeshWeaver#6172). Appended, never inserted. - /// - RestartHandoverFailed, - - /// - /// The activation record marks a landed module generation for activation, but the per-process - /// reader establishes that NOTHING landed is pending here — so nothing went live, and a restart - /// would load nothing new (MeshWeaver#6620). The verdict carries the reader's own line, which names - /// why (a generation declined in favour of the image's copy, quarantined, or running a previous - /// generation). Before this it was reported as — "went LIVE without a - /// restart" — while the old assembly kept serving. Appended, never inserted. - /// - NothingToActivate, -} - -/// -/// The outcome of ONE self-update check — the value that makes "this install checked and found -/// nothing" distinguishable from "this install never checked". -/// -/// 🚨 Why this type exists (#2553). memex sat three builds behind for 6.7 h having -/// emitted ZERO self-update log lines, and there was no way to tell from outside the process which -/// of three states it was in: the check never ran, the check ran and decided nothing was newer, or -/// the check ran and everything it had to say was filtered out by the log configuration. The -/// service reported an outcome on some paths and returned Unit on others, so "silence" was a -/// legitimate result — two Rx Where clauses (policy None, and no candidate newer than -/// the installed version) discarded a whole check with nothing written anywhere. -/// -/// Making the check's return type a VERDICT rather than Unit is what removes that -/// possibility: every branch has to name its outcome, the reporting site is single, and a pipeline -/// that somehow produces nothing is itself reported as . -/// The verdict is then both LOGGED and RECORDED on the policy node, because a log line depends on a -/// per-category log level that a deployment may simply not have set — which is exactly what -/// happened — while a node write does not. -/// -/// Pure: no hub, no logger, no Rx. The messages are pinned by unit tests. -/// -/// Which of the exhaustive outcomes this check reached. -/// The one-sentence verdict, ready to log and to store. -/// The release the verdict is about, when there is one. -public sealed record SelfUpdateVerdict(SelfUpdateOutcome Outcome, string Message, string? Tag = null) -{ - /// - /// 🚨 The tag this install RUNS, when the check established that it no longer resolves in the - /// registry; null on every other outcome (#3543). - /// - /// It rides the verdict so RecordCheck can stamp it on Admin/UpdatePolicy in - /// the write it already makes every tick — and so it is CLEARED, unconditionally, by the first - /// check that finds the tag again. A strand that has healed must disappear from the admin tab - /// rather than linger as a stale scare; that is the same rule the availability hold follows. - /// - public string? UnresolvedInstalledTag { get; init; } - - /// - /// True when the check established that a newer release EXISTS — whatever then happened to it. - /// - /// This is the discriminator the dead-event-channel report needs. "The safety net woke us - /// and no build event has ever arrived" is not on its own alarming: an install whose modules - /// rarely build legitimately sees no events for days. "The safety net woke us, no build event - /// has ever arrived, AND there was a newer release waiting" is the #2494 symptom exactly — - /// something published and nothing told this install about it. - /// - public bool FoundNewerRelease => Outcome is SelfUpdateOutcome.Held or SelfUpdateOutcome.Deferred - or SelfUpdateOutcome.DetectOnly or SelfUpdateOutcome.Applied - or SelfUpdateOutcome.ComboBlocked or SelfUpdateOutcome.MigrationFailed - or SelfUpdateOutcome.MigrationUnavailable - or SelfUpdateOutcome.HandedOver or SelfUpdateOutcome.HandoverFailed; - - /// - /// 🚨 True when this verdict is a FAILURE of the update path — the check faulted or produced - /// nothing, a release could neither be applied nor handed over, a migration refused the roll, a - /// landed module can never be activated, or the install's own tag no longer resolves. The ONE - /// classification: the reporting site logs exactly these at Warning, records it on - /// Admin/UpdatePolicy.lastCheckFailed, and the self_update entry on /health - /// reads Degraded on it (policy control-first-never-silent) — so the three surfaces can - /// never disagree about whether an instance's self-update is failing. - /// - public bool IsFailure => Outcome is SelfUpdateOutcome.NoOutcome - or SelfUpdateOutcome.CheckFailed - // An install whose own tag no longer resolves cannot start a new pod (#3543). - or SelfUpdateOutcome.InstalledTagWithdrawn - // A landed module nothing will ever activate (#3650). - or SelfUpdateOutcome.RestartUnavailable - // A release this install could neither apply nor hand over (#4098). - or SelfUpdateOutcome.HandoverFailed - or SelfUpdateOutcome.RestartHandoverFailed - // A roll the schema refused: the install stays behind until someone acts. - or SelfUpdateOutcome.MigrationFailed - or SelfUpdateOutcome.MigrationUnavailable - || UnresolvedInstalledTag is not null; - - /// The policy says never update. - public static SelfUpdateVerdict UpdatesDisabled() => new( - SelfUpdateOutcome.UpdatesDisabled, - "updates are disabled on this install (Admin/UpdatePolicy = None); the registry was not listed."); - - /// The registry was listed, holds nothing newer, AND still holds the installed tag — - /// i.e. this install is genuinely up to date. 🚨 The second half of that is not decoration: until - /// #3543 this sentence was also printed by an install whose own tag had been withdrawn, which is - /// the opposite state and unrecoverable by construction. - public static SelfUpdateVerdict NoNewerRelease(int tagsListed, string installed) => new( - SelfUpdateOutcome.NoNewerRelease, - $"no newer release: {tagsListed} tag(s) listed, none newer than the installed {installed}."); - - /// - /// 🚨 The installed tag does not resolve in the registry and nothing eligible is left to recover - /// to — the terminal strand. Says what an operator has to do, because nothing in the process can - /// do it: no publication can rescue an install whose tag outranks everything remaining. - /// - public static SelfUpdateVerdict InstalledTagWithdrawn(string installed, string explanation) => new( - SelfUpdateOutcome.InstalledTagWithdrawn, - $"STRANDED on {installed}: {explanation}. There is no eligible release to recover to, so this " - + "install cannot move itself — an operator has to point the workloads at a published tag " - + "(kubectl set image), or the withdrawn tag has to be restored in the registry.") - { - UnresolvedInstalledTag = installed, - }; - - /// - /// 🚨 Qualifies a verdict reached on the RECOVERY path: nothing was newer, but the installed tag - /// no longer resolves, so the check chose the best AVAILABLE release instead of reporting "up to - /// date". The roll may go BACKWARDS in lineage, and that is the point — an image that exists beats - /// one that does not. - /// - /// Rides the verdict rather than only a log line for the reason - /// exists at all, and carries - /// so the Updates tab can say so too. - /// - public SelfUpdateVerdict Recovering(string installed, string explanation) => this with - { - Message = $"{Message} RECOVERY — {explanation}; nothing is newer than a withdrawn tag by " - + "construction, so the best AVAILABLE release was chosen instead of reporting 'up to date'.", - UnresolvedInstalledTag = installed, - }; - - /// - /// 🚨 Qualifies "nothing newer" when whether the installed tag still resolves could NOT be - /// established — the third state (). - /// Saying so is the point: a failed read reported as a clean negative is how an install ends up - /// trusting a verdict nobody measured. - /// - public SelfUpdateVerdict InstalledTagUnchecked(string explanation) => this with - { - Message = $"{Message} Whether the installed tag still resolves was NOT established: {explanation}.", - }; - - /// A newer release exists and the availability gate refused it. - public static SelfUpdateVerdict Held(string tag, string? reason) => new( - SelfUpdateOutcome.Held, - $"HOLDING {tag} — {reason ?? "no reason recorded"}", tag); - - /// A newer release exists and the roll floor deferred it. - public static SelfUpdateVerdict Deferred(string tag, TimeSpan elapsed, TimeSpan floor) => new( - SelfUpdateOutcome.Deferred, - $"{tag} is available but this install rolled {elapsed} ago, inside the {floor} floor — " - + "deferring. The next publication (or the next safety-net check) re-decides it.", tag); - - /// A newer release exists and this install does not self-patch. - public static SelfUpdateVerdict DetectOnly(string tag) => new( - SelfUpdateOutcome.DetectOnly, - $"update available: {tag} (detect-and-notify — this install does not self-patch).", tag); - - /// - /// A newer release exists, this install does not self-patch, and it could not hand over either: - /// names the configuration that would make it a control-lane - /// install (keys, never values). The sentence an operator needs is the one that says WHY a - /// detected release goes nowhere. - /// - public static SelfUpdateVerdict DetectOnly(string tag, string missing) => new( - SelfUpdateOutcome.DetectOnly, - $"update available: {tag} (detect-and-notify — this install does not self-patch, and {missing}).", - tag); - - /// - /// A newer release exists and it was handed to the control lane (#4098). Says what happens next - /// and where, because from this install's point of view nothing else will: the Roll is opened, - /// approved and executed on the control instance. - /// - public static SelfUpdateVerdict HandedOver(string tag, string destination, string detail) => new( - SelfUpdateOutcome.HandedOver, - $"update available: {tag} — handed to the control lane ({destination}: {detail}); the control " - + "plane opens a routed Roll for this deployment, which runs UNATTENDED when the deployment " - + "record's update policy is Continuous and its update pattern admits this tag, and waits for " - + "an approval in the mesh otherwise. This install does not patch itself.", - tag); - - /// - /// A newer release exists, this install does not self-patch, and the hand-over FAILED — the - /// detail names the status (and, on a 401, the pairing to check). The next check announces again. - /// - public static SelfUpdateVerdict HandoverFailed(string tag, string detail) => new( - SelfUpdateOutcome.HandoverFailed, - $"update available: {tag} — the hand-over to the control lane FAILED: {detail}. This install " - + "does not patch itself; the next check hands the release over again.", - tag); - - /// The workloads were patched. - public static SelfUpdateVerdict Applied(string tag, string installed, DateTimeOffset? lastRolledAt) => new( - SelfUpdateOutcome.Applied, - $"applied update {tag} (was {installed}; last rolled {lastRolledAt?.ToString("O") ?? "never"}).", - tag); - - /// - /// 🚨 A newer release exists and the COMBO gate refused it — the recorded verdict for that - /// candidate is Red for this instance's module set. The reason NAMES every failing module: an - /// unnamed refusal is unactionable, and an environment that quietly stops updating is its own - /// outage. - /// - public static SelfUpdateVerdict ComboBlocked(string tag, string reason) => new( - SelfUpdateOutcome.ComboBlocked, - $"BLOCKED by the combo gate: {reason}", tag); - - /// - /// 🚨 Qualifies a verdict that was reached WITHOUT combo clearance — the - /// / no-verdict state, which is neither a pass nor - /// a refusal. - /// - /// It has to ride the check verdict rather than only a log line, for the reason - /// exists at all: a log line depends on a - /// per-category log level a deployment may simply not have set, a node write does not. An - /// unverified roll that leaves no durable trace is indistinguishable from a verified one. - /// - public SelfUpdateVerdict Unverified(string reason) => - this with { Message = $"{Message} UNVERIFIED — {reason}" }; - - /// - /// 🚨 The roll went ahead over a PACKAGE answer that used to hold it — a waived availability - /// hold or a recorded combo Red (policy platform-rolls-independently-of-packages). Said - /// on the verdict, the durable half, so the Updates tab - /// and the next session read which packages did not follow, not only a pod log. - /// - /// What the package answer said. - public SelfUpdateVerdict WithPackageAdvisory(string advisory) => - this with - { - Message = $"{Message} PACKAGE ADVISORY (the platform rolls independently of packages; " - + $"they catch up through their own update policy) — {advisory}", - }; - - /// - /// 🚨 The roll was refused: the migration Job for ended - /// , so the schema did not move and the image stays where it is. - /// Names the Job so an operator can read its log. - /// - public static SelfUpdateVerdict MigrationFailed(string tag, MigrationRunOutcome outcome) => new( - SelfUpdateOutcome.MigrationFailed, - $"roll to {tag} REFUSED: the database migration Job for it ended {outcome} — the schema did " - + "not move, so the portal image was not patched (rolling anyway would only make the new pods " - + "refuse to start on DbVersionGate behind a portal that still answers 200). Read the Job's " - + "log (kubectl logs job/memex-migration-su-) and Doc/Architecture/DatabaseMigrationProcedure.", - tag); - - /// - /// 🚨 The roll was REFUSED because the migration could not be ATTEMPTED: the cluster answered - /// 403 to the Job (), so nothing established that the - /// schema is where needs it. - /// - /// Why refusing is the honest answer here, when it is not for - /// . A 403 says this install's chart predates - /// memex-portal/rbac.yaml's batch/jobs grant, so an operator helm upgrade is - /// already required — and that upgrade renders the migration Job itself. Refusing therefore asks - /// for nothing that was not already owed, and it removes the one move that cannot be recovered - /// from in-process: patching the image, having established nothing, and letting - /// DbVersionGate veto the new pods three seconds into their boot, behind an old - /// ReplicaSet that keeps answering 200 (#4764, measured on memex-cloud 2026-09-19 — - /// CrashLoopBackOff, restarts=2 by 05:06Z, nothing converging and nothing rolling back). - /// The verdict names both halves — what could not be run, and the one command that fixes it — - /// because a refusal an operator cannot act on is its own outage. - /// - public static SelfUpdateVerdict MigrationUnavailable(string tag, MigrationRunOutcome outcome, string remedy) => new( - SelfUpdateOutcome.MigrationUnavailable, - $"roll to {tag} REFUSED: the database migration for it could not be run ({outcome}), so " - + "nothing established that the schema is where this build needs it — and a build that " - + "expects a newer db_version than the database has crash-loops on DbVersionGate behind a " - + $"portal that still answers 200. {remedy} " - + "Doc/Architecture/SelfUpdateSchemaWall and Doc/Architecture/DatabaseMigrationProcedure.", - tag); - - /// - /// 🚨 Qualifies a roll that was taken WITHOUT its database migration — the - /// state, which is neither a migrated roll nor a - /// refused one. - /// - /// It has to ride the verdict rather than only a log line, for the reason - /// exists at all: a log line depends on a - /// per-category log level a deployment may simply not have set, a node write does not. #4764's - /// second ask is exactly this — lastCheckVerdict reported the patch as done while the - /// crash-loop lived only on the pod, so "rolled with its schema moved" and "rolled blind" were - /// the same recorded sentence. They are now different ones. - /// - /// 🚨 And it is a QUALIFIER, not a refusal, deliberately: NotSupported means the - /// updater has no migration mechanism at all, and refusing every roll on that would freeze the - /// install silently, which is the worse failure shape (#2553). What makes the difference safe to - /// leave is that the state is now RECORDED, and the remedy — update the - /// MeshWeaver.SelfUpdate.Aks module — is named in it. - /// - public SelfUpdateVerdict Unmigrated(string reason) => - this with { Message = $"{Message} UNMIGRATED — {reason}" }; - - /// - /// 🚨 Whether the portal image may be patched, given how its migration ended — the ONE - /// decision, read by every route that patches (#4764). - /// - /// Completed proves the schema moved; NotSupported proves only that this - /// install has no migration mechanism at all, and rolls anyway because refusing there would - /// freeze it for ever and silently (#2553, and the roll is then recorded - /// ). Everything else is a refusal: Failed/TimedOut because - /// the schema demonstrably did not move, Forbidden because nothing was established and the - /// helm upgrade that grants the missing permission runs the migration itself. - /// - /// 🚨 It exists as a predicate rather than as two switch statements because there are TWO - /// routes that patch — the poller (MigrateThenPatch) and the Updates tab's manual Apply — - /// and the second skipped the migration entirely, so an admin click made exactly the image-only - /// roll the poller had stopped making. Two copies of this rule would drift the same way again; - /// SelfUpdateSchemaBumpRefusalTest drives every outcome through the poller and asserts its - /// behaviour equals this predicate, so an outcome added to the enum cannot be handled one way in - /// one route and another in the other. - /// - public static bool MayPatchAfter(MigrationRunOutcome outcome) => - MayPatchAfter(outcome, ReleaseSchemaStep.Unknown("")); - - /// - /// 🚨 , refined by the PUBLISHED schema step - /// (, #4764 (b3); policy db-migration-planned) — the form - /// every route that patches reads. - /// - /// The two numbers change exactly the two outcomes that establish nothing about the schema, - /// and in opposite directions: - /// - /// NotSupported with a target that MOVES the schema is REFUSED. The blanket rule - /// rolled it "because refusing would freeze the install", which was the right call only while - /// nobody could tell a schema-bumping release from any other; with both numbers published the - /// roll is known to crash-loop on DbVersionGate, so rolling is no longer the lesser - /// harm. - /// Forbidden with a target that KEEPS the schema MAY patch: the database already - /// has what the target expects (the running pods passed DbVersionGate at the running - /// release's number), so the missing batch/jobs grant blocks nothing that matters. - /// - /// An unknown step (either marker absent) leaves the blanket rule exactly as it was. - /// - public static bool MayPatchAfter(MigrationRunOutcome outcome, ReleaseSchemaStep step) => outcome switch - { - MigrationRunOutcome.Completed => true, - MigrationRunOutcome.NotSupported => !step.MovesSchema, - MigrationRunOutcome.Forbidden => step.KeepsSchema, - _ => false, - }; - - /// - /// 🚨 The roll was REFUSED because the target is PUBLISHED to expect a newer schema than the - /// running release, and this install could not run its migration at all - /// (). Names both numbers, so the refusal says - /// exactly what a person has to move — never the silent crash-loop it replaces. - /// - public static SelfUpdateVerdict SchemaAhead(string tag, MigrationRunOutcome outcome, ReleaseSchemaStep step) => new( - SelfUpdateOutcome.MigrationUnavailable, - $"roll to {tag} REFUSED: {step.Describe()}, and this install could not run the migration ({outcome}) — " - + "rolling would make every new pod refuse to start on DbVersionGate behind pods that keep answering " - + "200. Run the migration for this instance first (a helm upgrade, or a control-plane Roll, which " - + "migrates first), or update the MeshWeaver.SelfUpdate.Aks module so it can. " - + "Doc/Architecture/PlanningADatabaseMigration.", - tag); - - /// The check faulted. - public static SelfUpdateVerdict CheckFailed(Exception ex) => new( - SelfUpdateOutcome.CheckFailed, - $"check FAILED: {ex.GetType().Name}: {ex.Message}"); - - /// 🚨 The pipeline produced no verdict — a defect in this service, reported as one. - public static SelfUpdateVerdict NoOutcome() => new( - SelfUpdateOutcome.NoOutcome, - "the check produced NO outcome — a filter in the self-update pipeline swallowed it. " - + "This is a defect in SelfUpdateHostedService, not a state of this install."); - - // ── The pending-restart half (#3650): a module swap is a restart, and the restart happens ── - - /// - /// The workloads were restarted on the image they run, to activate a landed module generation. - /// Carries the platform verdict it followed () so the record still says - /// what the check found about the registry. - /// - public static SelfUpdateVerdict Restarted(SelfUpdateVerdict after, string installed, DateTimeOffset? lastRolledAt) => new( - SelfUpdateOutcome.Restarted, - $"{after.Message} RESTARTED on {installed}: a landed module generation was pending activation " - + $"(last rolled {lastRolledAt?.ToString("O") ?? "never"}); the workloads were rolled on the " - + "same image so it loads.", - installed) - { - UnresolvedInstalledTag = after.UnresolvedInstalledTag, - }; - - /// The pending module generations went LIVE in the running process — no restart. - public static SelfUpdateVerdict ActivatedLive(SelfUpdateVerdict after, string detail) => new( - SelfUpdateOutcome.ActivatedLive, - $"{after.Message} A landed module generation was pending activation and went LIVE without a " - + $"restart ({detail}).", - after.Tag) - { - UnresolvedInstalledTag = after.UnresolvedInstalledTag, - }; - - /// - /// A landed generation is marked for activation and NOTHING is pending in this process — nothing - /// went live, no restart is taken, and the reader's own line says why (#6620). - /// - public static SelfUpdateVerdict NothingToActivate(SelfUpdateVerdict after, string detail) => new( - SelfUpdateOutcome.NothingToActivate, - $"{after.Message} A landed module generation is marked for activation, but nothing landed is " - + $"pending in this process: nothing went live and a restart would load nothing new ({detail}).", - after.Tag) - { - UnresolvedInstalledTag = after.UnresolvedInstalledTag, - }; - - /// - /// The live half after a platform release was HANDED to the control lane (MeshWeaver#6829): the - /// outcome stays 's ( — the roll - /// is still the event), and the message says what went live in the process and what waits for the - /// handed-over roll. No restart is requested in any case. A pass that swapped nothing in and left - /// nothing for a restart — the activation marker outlives a successful live swap until the next - /// boot, so every later handed-over check finds it set with nothing pending here — says that - /// nothing is pending in this process, never that an activation is still pending. - /// - /// The platform verdict (the hand-over). - /// How many landed modules were swapped in live. - /// Whether anything pending could not go live in the process. - /// The live pass's own description. - public static SelfUpdateVerdict LiveWhileRollHandedOver(SelfUpdateVerdict after, int wentLive, bool needsRestart, string detail) => - after with - { - Message = after.Message + (wentLive > 0 - ? $" A landed module generation was pending activation and went LIVE without a restart ({detail})." - : needsRestart - ? $" A landed module generation is pending activation and nothing went live in the process ({detail})." - : $" A landed module generation is marked for activation, but nothing landed is pending in this process: nothing went live and the handed-over roll would load nothing new for it ({detail}).") - + (needsRestart - ? " What did not go live is activated by the handed-over roll, which re-creates the pods; no separate restart is requested, as it would only race that roll." - : ""), - }; - - /// - /// 🚨 Whether the LIVE half of a pending activation may follow 's verdict - /// where the restart may not (MeshWeaver#6829): a release HANDED to the control lane. A live swap - /// happens inside this process and races no roll, so the hand-over — which only rules out a second - /// restart request — is no reason to leave a swappable module unloaded until the roll lands. - /// Pure; pinned by SelfUpdateVerdictTest. - /// - public static bool MayActivateLiveAfter(SelfUpdateVerdict platform) => - MayRestartAfter(platform) || platform.Outcome == SelfUpdateOutcome.HandedOver; - - /// The roll floor deferred a pending restart, exactly as it defers a roll. - public static SelfUpdateVerdict RestartDeferred(SelfUpdateVerdict after, string installed, TimeSpan elapsed, TimeSpan floor) => new( - SelfUpdateOutcome.RestartDeferred, - $"{after.Message} A landed module generation is pending activation, but this install rolled " - + $"{elapsed} ago, inside the {floor} floor — deferring the restart. The next check re-decides it.", - installed) - { - UnresolvedInstalledTag = after.UnresolvedInstalledTag, - }; - - /// 🚨 A restart is pending and this install cannot take it — named, so an operator can. - public static SelfUpdateVerdict RestartUnavailable(SelfUpdateVerdict after, string installed, string reason) => new( - SelfUpdateOutcome.RestartUnavailable, - $"{after.Message} A landed module generation is pending activation and this install cannot " - + $"restart itself ({reason}) — restart the portal workloads to load it " - + "(kubectl rollout restart deployment/).", - installed) - { - UnresolvedInstalledTag = after.UnresolvedInstalledTag, - }; - - /// - /// The pending restart was handed to the control lane (#4098): the control plane re-creates - /// the pods on the image they run — a restore of the declared state, taken unattended. - /// - public static SelfUpdateVerdict RestartHandedOver(SelfUpdateVerdict after, string installed, string destination, string detail) => new( - SelfUpdateOutcome.RestartHandedOver, - $"{after.Message} A landed module generation is pending activation — handed to the control " - + $"lane ({destination}: {detail}); the control plane restarts the workloads on {installed}.", - installed) - { - UnresolvedInstalledTag = after.UnresolvedInstalledTag, - }; - - /// The restart hand-over failed: named with the cause, so the pending module is a state - /// an operator can see — and its own outcome (), - /// because unlike it is transient: the next attempt may get through. - public static SelfUpdateVerdict RestartHandoverFailed(SelfUpdateVerdict after, string installed, string detail) => - RestartUnavailable(after, installed, $"the hand-over to the control lane failed: {detail}") with - { - Outcome = SelfUpdateOutcome.RestartHandoverFailed, - }; - - /// - /// 🚨 Whether a pending restart may follow 's verdict at all. Only a - /// check that PATCHED nothing has a restart to take: an applied roll IS the restart (the new - /// pods boot the landed set), a refused migration leaves the image deliberately where it is, a - /// failed check decided nothing, and a disabled policy means never — an operator who pins the - /// image restarts by hand. A release HANDED to the control lane is a roll in flight there: the - /// Roll it becomes restarts the pods, so a second request for the same instance would only race - /// it. A hand-over that FAILED is not a roll in flight — nothing was handed to anyone — so the - /// pending restart is still considered (and, on the same broken inbox, reported as unavailable - /// naming the cause rather than silently skipped). Pure; pinned by SelfUpdateVerdictTest. - /// - /// 🚨 is deliberately NOT in this - /// list, unlike . A restart re-creates the pods on - /// the image that is ALREADY running, whose schema the database already satisfies — the wall the - /// migration refusal stands on is about the TARGET build, not this one. A broken migration is - /// fixed in minutes; a missing migration mechanism waits for an operator's helm upgrade, - /// and holding a landed module generation unactivated for that whole time would be a second - /// freeze caused by the first (#3650). - /// - public static bool MayRestartAfter(SelfUpdateVerdict platform) => platform.Outcome - is not (SelfUpdateOutcome.Applied or SelfUpdateOutcome.MigrationFailed - or SelfUpdateOutcome.CheckFailed or SelfUpdateOutcome.UpdatesDisabled - or SelfUpdateOutcome.NoOutcome - or SelfUpdateOutcome.HandedOver); - - /// - /// The roll floor applied to a pending restart: the verdict when - /// the install rolled less than ago, or null when the restart - /// may proceed — a floor of zero or less never defers, and an install that never rolled (or - /// cannot tell) is free. The same rule Apply uses for a roll, stated once and pure so - /// the interval arithmetic is pinned without a mesh. - /// - public static SelfUpdateVerdict? RestartDeferredBy( - SelfUpdateVerdict after, string installed, DateTimeOffset? lastRolledAt, TimeSpan floor, DateTimeOffset now) - { - if (floor <= TimeSpan.Zero || lastRolledAt is null) - return null; - var elapsed = now - lastRolledAt.Value; - return elapsed < floor ? RestartDeferred(after, installed, elapsed, floor) : null; - } -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/UnavailableUpdateMechanics.cs b/memex/Memex.Portal.Shared/SelfUpdate/UnavailableUpdateMechanics.cs deleted file mode 100644 index 219bfdd4ad..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/UnavailableUpdateMechanics.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.IO; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh.Security; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// -/// The fallbacks used when the MeshWeaver.SelfUpdate.Aks module is not listed — i.e. this -/// install is not an AKS/ACR deployment (or deliberately does not roll itself). -/// -/// 🚨 The poller itself is NOT a module and never becomes one. Self-update is how a -/// deployment receives new bits — including new modules — so putting it behind a module creates a -/// bootstrap loop: an install that lost the module could no longer update anything, including -/// re-installing the module. What IS optional is the AKS-specific MECHANICS (list tags on a -/// container registry, patch a Kubernetes Deployment), and those are what moved. -/// -/// The degraded state is not new and not an error path: has -/// always carried precisely so a non-Kubernetes install -/// runs detect-and-notify — it records the available version and patches nothing. These -/// fallbacks are that state, made explicit rather than implied by a missing registration. -/// -internal static class UnavailableUpdateMechanics -{ - /// The module that supplies the ACR reader and the Kubernetes patcher — what - /// is false without. Ships in the registry's - /// Hosting package (tier: enterprise), which is why a lower-plan instance runs - /// detect-only (#4097). - internal const string PatcherModule = "MeshWeaver.SelfUpdate.Aks"; - - /// - /// 🚨 WHY this install cannot patch, when the reason is the instance's PLAN (#4097): the - /// registry declared the patcher's package in the default set and refused it by tier, and the - /// default install recorded that on the activation record. Read at startup so the - /// canPatch=False line names the tier instead of pointing at Modules:Assemblies, - /// which is not where the module was going to come from. Null when no such refusal is - /// recorded — a non-Kubernetes install, or a plan that covers the package. - /// - internal static PlanTierRefusal? PatcherTierRefusal(IConfiguration? configuration) - { - try - { - return ModuleActivationSidecar.ReadTierRefusals(ModuleRoot.Resolve(configuration)) - .Values.FirstOrDefault(r => r.IsForModule(PatcherModule)); - } - catch (Exception e) when (e is IOException or UnauthorizedAccessException) - { - return null; - } - } - - /// The startup line's qualifier after canPatch=False: the plan-tier sentence - /// when the patcher's package was refused by plan, otherwise the standing hint. - internal static string DescribeCannotPatch(PlanTierRefusal? patcherRefusal) => - patcherRefusal is { } refused - ? $" ({refused.Describe()} The Kubernetes patcher ships in that package, so this install " - + "runs detect-and-notify until the instance's plan covers it — or hands over to the " - + "control lane when a control inbox is configured.)" - : $" (no Kubernetes patcher is registered: a fleet instance hands a detected release to the " - + "control lane when a control inbox is configured; a standalone Kubernetes install lists " - + $"{PatcherModule} under Modules:Assemblies or installs the Hosting package from the registry)"; - - /// - /// Reports no tags, so the poller finds no candidate version. Correct for an install with no - /// container registry to read: the alternative — leaving - /// unregistered — makes the poller's constructor unresolvable and takes the whole host down. - /// - internal sealed class NoRegistry(ILogger? logger = null) : IAcrTagLister - { - private bool warned; - - public Task> ListTagsAsync(string repository, CancellationToken ct) - { - if (!warned) - { - warned = true; - logger?.LogInformation( - "Self-update: no container-registry reader is registered, so no candidate version can be " - + "found. This is expected unless the deployment lists MeshWeaver.SelfUpdate.Aks under " - + "Modules:Assemblies (the module supplies the ACR reader and the Kubernetes patcher)."); - } - return Task.FromResult>([]); - } - } - - /// - /// Detect-and-notify: reports false and refuses to - /// patch. Identical to what a non-Kubernetes install has always done. - /// - internal sealed class DetectOnly : IDeploymentUpdater - { - public bool CanPatch => false; - - /// Never — an install with no patcher has never rolled and never will, so the - /// floor can only ever pass. Correct rather than a hole: it cannot roll anyway. - public Task LastRolledAtAsync(CancellationToken ct) => - Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) => - throw new InvalidOperationException( - $"Cannot roll this install to '{versionTag}': no deployment patcher is registered. " - + "CanPatch is false, so the poller must not have called this — list " - + "MeshWeaver.SelfUpdate.Aks under Modules:Assemblies for a Kubernetes install."); - } -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/UpdateAdvisory.cs b/memex/Memex.Portal.Shared/SelfUpdate/UpdateAdvisory.cs deleted file mode 100644 index 4bbd7ba94f..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/UpdateAdvisory.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Collections.Immutable; -using MeshWeaver.Data; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// -/// 🌍 One release-availability advisory as Admin/UpdatePolicy persists it -/// (, MeshWeaver#6540): the English sentence, the -/// reason code that renders it in the viewer's language (a — the -/// catalog key) and the code's named arguments. The same shape a notification persists its title -/// in (TitleKey/TitleArgs): the writer has no viewer in scope, several viewers with -/// different languages read the one record, so the key travels and the text is resolved at render -/// time. -/// -/// The advisory in English — the log line's wording, and the fallback when -/// is null or no catalog carries it. -public sealed record UpdateAdvisory(string Text) -{ - /// The reason code / catalog key, or null for text this process did not author (a - /// verbatim upstream diagnostic). - public string? Key { get; init; } - - /// The named arguments for 's {name} placeholders; values read - /// back from JSON arrive as , which - /// LocalizationCatalog.GetNamed formats explicitly. - public ImmutableDictionary? Args { get; init; } - - /// The persisted form of a composed advisory. - /// The verdict's advisory (UpdatabilityVerdict.AdvisoryTexts). - /// The item to store. - public static UpdateAdvisory From(LocalizableText text) => - new(text.English) { Key = text.Key, Args = text.PersistedArgs() }; - - /// - /// The advisory in — the catalog rendering of bound - /// to , or the stored English when there is no key or no catalog carries it - /// (an unknown code is never given a meaning of its own). - /// - /// The VIEWER's language tag (host.ViewerLocale()). - /// The text to render; never null. - public string Localize(string? locale) => - Key is { Length: > 0 } key - ? LocalizationCatalog.GetNamed(key, locale, Args, Text) - : Text; -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/UpdatePolicyNodeType.cs b/memex/Memex.Portal.Shared/SelfUpdate/UpdatePolicyNodeType.cs deleted file mode 100644 index 522c55d222..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/UpdatePolicyNodeType.cs +++ /dev/null @@ -1,627 +0,0 @@ -using System.Collections.Immutable; -using System.ComponentModel; -using System.Reactive; -using System.Reactive.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using MeshWeaver.Hosting.SelfUpdate; // UpdatePolicyKind — the policy value - -namespace Memex.Portal.Shared.SelfUpdate; - -/// -/// Content of the Admin/UpdatePolicy node: the admin-editable update -/// plus the self-update poller's bookkeeping (the latest tag it has seen on the registry and when). -/// Edited via the standard node-content editor (the enum renders as a dropdown); -/// the two bookkeeping fields are written by the poller as System and hidden from the editor. -/// -public record UpdatePolicyContent -{ - /// - /// The update strategy AS DECLARED on the record — null when the record carries no - /// policy field at all. Read instead; this exists so that "absent" - /// and "explicitly chosen" stay different facts on the wire (#3542). - /// - [Description("Update strategy")] - [Translation("de", "Update-Strategie")] - [JsonPropertyName("policy")] - public UpdatePolicyKind? DeclaredPolicy { get; init; } - - /// - /// The strategy this install actually follows. An ABSENT declaration reads as - /// — never as "enabled" (#3542). - /// - /// 🚨 Why a nullable backing field rather than reordering the enum, which was the obvious - /// repair and is WRONG: the hub serializer sets DefaultIgnoreCondition = WhenWritingDefault, - /// so whichever member is zero is dropped on write. Putting None at zero would have made an - /// explicit None unwritable — measured, it broke the MCP patch that turns auto-update OFF, - /// which is the safety-critical direction. A nullable field has null as its default, so - /// EVERY named member survives the round trip and only genuine absence is dropped. - /// - /// The consequence that matters: an install whose record lost its policy under its own - /// bookkeeping writes no longer rolls itself. That is how memex-cloud reached a withdrawn - /// 3.1.0-ci line "on a policy record that lost its own policy". - /// - /// 🚨 Reading an absent policy as None made the CONSEQUENCE safe; it did not stop - /// the record from losing the field. What did is that every bookkeeping write now goes through - /// the framework's TYPED write (Update<UpdatePolicyContent>), which refuses a node - /// whose content it cannot read instead of writing a default over it — see - /// . - /// - [JsonIgnore] - public UpdatePolicyKind Policy - { - get => DeclaredPolicy ?? UpdatePolicyKind.None; - init => DeclaredPolicy = value; - } - - /// - /// 🚨 The version PATTERN that admits continuous builds — a glob over the registry tag, e.g. - /// 3.0.1-ci* (). Read only under - /// : a pre-release tag is eligible ONLY when this - /// admits it, and Continuous with no pattern is Stable (clean releases only) — - /// the poller says so once at Warning, naming this field. Under Stable a pattern, when - /// set, narrows the clean releases considered (e.g. 3.0.* keeps an install on one line). - /// Null or blank = no pattern. - /// - /// The fleet's own setting while no clean release above 3.0.0 exists is - /// 3.0.0-ci*; it stops matching the day 3.0.1 is tagged, which is the intended - /// way for "follow the line" to end. See Doc/Architecture/ReleaseProcess §1. - /// - [Description("Version pattern for continuous builds, e.g. 3.0.1-ci*")] - [Translation("de", "Versionsmuster für Continuous-Builds, z. B. 3.0.1-ci*")] - [JsonPropertyName("pattern")] - public string? Pattern { get; init; } - - /// - /// When true (default) the install only rolls to builds that PASSED CI ("green"). - /// The continuous-delivery pipeline already publishes an image ONLY when "MeshWeaver Build and - /// Test" succeeds, so the verified channel contains green builds exclusively; this flag is the - /// forward-looking guard that keeps that guarantee if an "edge" channel (publish-on-every-build, - /// tags carrying the edge pre-release label) is ever added — green-only ignores those. - /// Set false to also accept unverified edge builds (bleeding-edge / pre-merge testing). - /// - /// 🚨 is REQUIRED, not decoration — the same - /// reason NotificationSettings and GitHubSyncConfig carry it on every - /// default-true bool. The hub serializer uses , - /// which omits the CLR default, and the CLR default for a bool is false — so an admin - /// unticking this box wrote a value the serializer dropped, and the next read re-applied this - /// initializer's true. The checkbox could be unticked and never stayed unticked: another - /// setting on this node that silently discards what an operator chose (#3542). - /// - [Description("Only update to CI-verified (green) builds")] - [Translation("de", "Nur auf CI-geprüfte (grüne) Builds aktualisieren")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public bool RequireCiGreen { get; init; } = true; - - /// The newest image tag the poller has found on the registry (for the admin UI / - /// detect-and-notify). Written by the poller; not user-editable. - [Browsable(false)] - public string? LatestAvailableTag { get; init; } - - /// When the poller last recorded . - [Browsable(false)] - public DateTimeOffset? CheckedAt { get; init; } - - /// - /// 🚨 When this install last COMPLETED an update check — whatever the check concluded. - /// - /// Distinct from , and the difference is the defect it closes - /// (#2553). is stamped beside , so it - /// only ever advances on a check that FOUND something newer. An install that checks hourly and - /// finds nothing therefore has no record of ever having checked — indistinguishable from an - /// install whose checker is dead, which is precisely the state memex was in for 7 h while its - /// Updates tab read "No newer version detected yet." - /// - /// Written by the poller on EVERY check, on every outcome path, as a best-effort - /// bookkeeping write that can never gate the roll (#1020). Not user-editable. - /// - [Browsable(false)] - public DateTimeOffset? LastCheckedAt { get; init; } - - /// The one-sentence verdict of the check stamps — see - /// SelfUpdateVerdict. Durable on purpose: a log line depends on a per-category log level - /// that a deployment may simply not have set (and had not), a node write does not. Not - /// user-editable. - [Browsable(false)] - public string? LastCheckVerdict { get; init; } - - /// - /// Whether is a FAILURE of the update path - /// (SelfUpdateVerdict.IsFailure: the check faulted, a release could neither be applied - /// nor handed over, a migration refused the roll, …) — so a reader such as the fleet console - /// can flag a failing self-update without parsing the sentence (policy - /// control-first-never-silent). Null on a record written before the field existed. Not - /// user-editable. - /// - [Browsable(false)] - public bool? LastCheckFailed { get; init; } - - /// What woke that check — Startup, BuildCompletion, PolicyChange - /// or SafetyNet. An install whose checks are ONLY ever SafetyNet has a dead event - /// channel, and that is not visible from anything else. Not user-editable. - [Browsable(false)] - public string? LastCheckTrigger { get; init; } - - /// - /// The release this install last HANDED to the control lane (MeshWeaver#4098) — the tag a - /// self-update-available event named — with and - /// . Written by the poller on every successful hand-over so the - /// Updates tab can say "announced to the control instance at T" rather than "update - /// available" for ever; the apply itself is the control plane's Roll, whose state lives on - /// the control instance. Not user-editable. - /// - [Browsable(false)] - public string? HandedOverTag { get; init; } - - /// When was last announced. - [Browsable(false)] - public DateTimeOffset? HandedOverAt { get; init; } - - /// Where it was announced — the control inbox URL, or the local target on the control instance. - [Browsable(false)] - public string? HandedOverTo { get; init; } - - /// - /// Combo-verification verdicts per candidate tag — what the Candidate Release Protocol's - /// instance gate found when it ran this instance's module set inside a candidate image - /// (mw-combo-verify). Written via - /// (upsert by tag, newest first, bounded - /// at ); the admin Updates tab joins - /// this against so a blocked upgrade reads "cannot update to - /// X — these modules do not compile/test against it" instead of an eternal "update available". - /// Not user-editable. - /// - [Browsable(false)] - public ImmutableList ComboVerifications { get; init; } = []; - - /// - /// The tag the release-availability gate (#1754) most recently REFUSED, or null when nothing is - /// held. Written by the poller beside ; not user-editable. - /// - /// 🚨 This exists so a hold is a VISIBLE, recoverable state rather than a silence. An - /// instance that quietly stops updating for weeks because one stale package blocks it is its own - /// outage — worse than the roll it prevented, because nothing shows it happened. - /// - [Browsable(false)] - public string? HeldTag { get; init; } - - /// Why was refused, in one sentence naming the package(s). - /// Not user-editable. - [Browsable(false)] - public string? HeldReason { get; init; } - - /// - /// True when the hold is "the catalogue could not be read" rather than "a package cannot survive - /// this release". Kept apart because they are different incidents with different fixes, and a UI - /// that blurred them would send an operator to re-bake something that was never broken. - /// - [Browsable(false)] - public bool HeldIndeterminate { get; init; } - - /// When the hold was last recorded — so a stale hold is recognisable as stale. - [Browsable(false)] - public DateTimeOffset? HeldAt { get; init; } - - /// - /// The tag the availability gate most recently evaluated and had something to SAY about - /// without deciding on it (#3651) — held or not. Written beside on - /// every gate evaluation, so the Updates tab renders the advisories only for the tag they - /// describe. Not user-editable. - /// - [Browsable(false)] - public string? AdvisoriesTag { get; init; } - - /// - /// 🚨 What the gate reported about WITHOUT holding on it (#3651, - /// UpdatabilityVerdict.Advisories), one line per item joined with "; ": the - /// packages that would recompile at boot because no bake is sealed for the target, a landed - /// module whose loadability on the target could not be measured, a declared platform floor - /// the target does not rank above. Cleared (null) when the last evaluation had nothing to say. - /// This exists so a roll that compiles content at boot is a VISIBLE state on the tab an - /// operator already looks at, not a fact that lives only in a pod log. Not user-editable. - /// - [Browsable(false)] - public string? Advisories { get; init; } - - /// - /// 🌍 as localizable items (MeshWeaver#6540): one per advisory, each - /// its English, its reason code (a — the catalog key) and its - /// named arguments, so the Updates tab renders them in the VIEWER's language. Written beside - /// on every gate evaluation and cleared with it; a record written - /// before it existed carries only , and the tab renders that stored - /// English as before. Not user-editable. - /// - [Browsable(false)] - public ImmutableList? AdvisoryItems { get; init; } - - /// Whether is the tag describe. - public bool HasAdvisoriesFor(string? tag) => - !string.IsNullOrEmpty(tag) - && !string.IsNullOrEmpty(Advisories) - && string.Equals(AdvisoriesTag, tag, StringComparison.OrdinalIgnoreCase); - - /// - /// 🚨 The version this install RUNS, when the last check found that it is no longer published in - /// the registry (#3543); null otherwise. Written by the poller on EVERY check, so it is - /// cleared the moment the tag resolves again — the same unconditional-clearing rule the - /// availability hold follows, and for the same reason: a healed state that lingers is a stale - /// scare. - /// - /// This exists because the state was INVISIBLE. An install stranded on a withdrawn tag and - /// a perfectly up-to-date one produced byte-identical evidence — an empty - /// and "no newer release" — while the first cannot start a new - /// pod at all and can never be rescued by a publication, since nothing outranks a tag that - /// already outranks everything left. Not user-editable. - /// - [Browsable(false)] - public string? UnresolvedInstalledTag { get; init; } - - /// - /// Whether a hold RECORD exists for . 🚨 This is a question about the - /// record, NOT about why the install is standing still — see - /// before rendering it as a reason (#3812). - /// - public bool IsHeld(string? tag) => - !string.IsNullOrEmpty(tag) - && string.Equals(HeldTag, tag, StringComparison.OrdinalIgnoreCase); - - /// - /// 🚨 Whether the hold on is a LIVE verdict — one the checker that - /// wrote it would still clear (#3812). - /// - /// answers "is there a hold record"; both readers of it were asking - /// "is this why the install is not moving right now", and those stopped being the same question - /// the moment a check could decline to evaluate. and - /// are written ONLY by the poller's hold write, which runs only when a - /// candidate is actually evaluated; is written on EVERY check. Under - /// the check records "updates are disabled" and evaluates - /// nothing — so the hold is never revisited, never cleared, and the fresh - /// beside it makes a two-day-old record read as a current - /// verdict. - /// - /// Measured on memex 2026-09-09: heldAt 2026-09-07T22:27Z, lastCheckedAt - /// 2026-09-09T10:41Z, and the reason quoted three module floors that #3648 had stopped holding - /// on two hours after that heldAt. #3706 was filed on that frozen record and read it as - /// live. #3795 sharpened it: under None the build-completion triggers are now filtered - /// out before they check, so nothing revisits the hold at all. - /// - /// 🚨 The record is deliberately NOT cleared when updates are disabled — the last real - /// evaluation is the only diagnostic an operator has when they turn updates back on. What - /// changes is that a surface must present it as history, and say the current reason (updates - /// are disabled) as the current reason. - /// - public bool IsHoldOperative(string? tag) => - IsHeld(tag) && Policy != UpdatePolicyKind.None; - - /// The recorded verdict for , when one exists. - public ComboVerification? VerificationFor(string? tag) => - string.IsNullOrEmpty(tag) - ? null - : ComboVerifications.FirstOrDefault(v => - string.Equals(v.CandidateTag, tag, StringComparison.OrdinalIgnoreCase)); -} - -/// -/// The Admin/UpdatePolicy singleton node — the platform's auto-update strategy. Mirrors -/// AiSettingsNodeType (typed-content node + storm-safe create-on-absent via a query, never a -/// point-read of a maybe-absent path) and the Admin-partition anchoring of -/// ShippedReleaseSeed.PlatformVersionNode. -/// -public static class UpdatePolicyNodeType -{ - /// NodeType discriminator. - public const string NodeType = "UpdatePolicy"; - - /// The Admin partition that holds platform-level data (schema admin). - public const string AdminPartition = ShippedReleaseSeed.AdminPartition; - - /// The singleton instance id. - public const string NodeId = "UpdatePolicy"; - - /// Full path of the policy node: Admin/UpdatePolicy. - public const string NodePath = $"{AdminPartition}/{NodeId}"; - - /// Registers the UpdatePolicy node type + its content type for typed (de)serialization. - public static TBuilder AddUpdatePolicyType(this TBuilder builder) where TBuilder : MeshBuilder - { - builder.AddMeshNodes(CreateMeshNode()); - builder.AddAutocompleteExcludedTypes(NodeType); - builder.ConfigureHub(config => config.WithType(nameof(UpdatePolicyContent))); - return builder; - } - - /// MeshNode TYPE DEFINITION for nodeType:UpdatePolicy. - public static MeshNode CreateMeshNode() => new(NodeType) - { - Name = "Update Policy", - Icon = "/static/NodeTypeIcons/rocket.svg", - IsSatelliteType = false, - ExcludeFromContext = new HashSet { "search", "create" }, - HubConfiguration = config => config - .AddMeshDataSource(source => source - .WithContentType()) - }; - - /// - /// Create-on-absent (idempotent, reactive, as System) of Admin/UpdatePolicy with the given - /// default policy. Existence is read via GetQuery (empty-on-absent) — NEVER a point - /// GetMeshNodeStream(path) probe of the maybe-absent node (which NotFound-resubscribe-storms - /// on a fresh DB). Emits the node path when it exists. An existing node is left untouched HERE - /// (its admin-chosen policy is preserved) — when the deployment record declares a policy the - /// caller follows this with , which is what makes the record - /// authoritative (policy self-update-record-authoritative). is seeded beside the - /// policy (); null seeds none. - /// - public static IObservable EnsureExists( - IMessageHub hub, AccessService? accessService, UpdatePolicyKind defaultPolicy, ILogger? logger = null, - string? defaultPattern = null) - { - var meshService = hub.ServiceProvider.GetService(); - if (meshService is null) - return Observable.Return(NodePath); - var workspace = hub.GetWorkspace(); - - MeshNode BuildNode() => new(NodeId, AdminPartition) - { - NodeType = NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent - { - Policy = defaultPolicy, - Pattern = UpdateChannelPattern.Normalize(defaultPattern), - }, - }; - - // 🚨 RunAsSystem, never `Observable.Using(AccessContextScope.AsSystem, …)` (#1444/#1790): - // `AsSystem(x)` IS `x.ImpersonateAsSystem()`, so the helper hides the shape rather than - // changing it. RecordVerification below already states the reason — impersonation is an - // AsyncLocal and `Using` disposes it on whichever thread the sequence terminates on, leaving - // the caller latched as System — and hand-rolls the seal with Observable.Create; this is the - // same seal, taken off the framework shelf. - return accessService.RunAsSystem( - () => workspace - .GetQuery($"{NodeType}|{NodePath}", $"path:{NodePath} nodeType:{NodeType}") - .Take(1) - .SelectMany(nodes => - { - var existing = nodes.FirstOrDefault(n => - string.Equals(n.NodeType, NodeType, StringComparison.OrdinalIgnoreCase)); - if (existing is not null) - return Observable.Return(NodePath); - logger?.LogInformation( - "[SelfUpdate] seeding {Path} = {Policy} (pattern: {Pattern}).", - NodePath, defaultPolicy, UpdateChannelPattern.Normalize(defaultPattern) ?? "none"); - return meshService.CreateNode(BuildNode()) - .Select(_ => NodePath) - // Idempotent: a concurrent first-writer (other replica) won the create race. - .Catch(ex => IsAlreadyExists(ex) - ? Observable.Return(NodePath) - : Observable.Throw(ex)); - })); - } - - /// - /// The configuration key the deployment record's updatePolicy renders as - /// (SelfUpdate__DefaultPolicy in the pod environment). Read RAW, never through the bound - /// SelfUpdateOptions: the binder answers for an - /// unset key, and "the record declares Stable" and "nothing declares anything" are different - /// facts here — policy self-update-record-authoritative. - /// - public const string DeclaredPolicyConfigKey = "SelfUpdate:DefaultPolicy"; - - /// The configuration key the deployment record's updatePattern renders as - /// (SelfUpdate__DefaultPattern). Rendered together with - /// ; an absent pattern under a declared policy means the - /// record declares NO pattern. - public const string DeclaredPatternConfigKey = "SelfUpdate:DefaultPattern"; - - /// - /// What the deployment record declares for the platform image policy — the values - /// makes Admin/UpdatePolicy carry. - /// - /// The declared strategy. - /// The declared version pattern, normalised; null = none. - public sealed record DeploymentDeclaration(UpdatePolicyKind Policy, string? Pattern); - - /// - /// The policy the deployment record DECLARES through configuration, or null when it - /// declares none (the key is unset or blank) — the non-fleet install, whose in-portal choice - /// stands. A value that is not a policy name is logged at Warning and treated as undeclared: - /// the renderer refuses anything but the three names, so such a value did not come from a record. - /// - public static DeploymentDeclaration? DeclaredByDeployment(IConfiguration? configuration, ILogger? logger = null) - { - var raw = configuration?[DeclaredPolicyConfigKey]; - if (string.IsNullOrWhiteSpace(raw)) - return null; - if (!Enum.TryParse(raw.Trim(), ignoreCase: true, out var policy) - || !Enum.IsDefined(policy)) - { - logger?.LogWarning( - "[SelfUpdate] {Key} is set but is not an update policy name; Admin/UpdatePolicy is " - + "left as it is (the value is not repeated here).", DeclaredPolicyConfigKey); - return null; - } - return new DeploymentDeclaration(policy, UpdateChannelPattern.Normalize(configuration?[DeclaredPatternConfigKey])); - } - - /// Whether already carries exactly what - /// says. An ABSENT policy field never matches. - public static bool Carries(UpdatePolicyContent content, DeploymentDeclaration declared) => - content.DeclaredPolicy == declared.Policy - && string.Equals(UpdateChannelPattern.Normalize(content.Pattern), declared.Pattern, StringComparison.Ordinal); - - /// - /// 🚨 Makes an EXISTING Admin/UpdatePolicy carry the policy and pattern the deployment - /// record declares — policy self-update-record-authoritative. Call after - /// ; emits the node path. - /// - /// Why it exists: the record's updatePolicy/updatePattern render as the - /// SEED keys, and returns early for a node that exists, so an - /// instance whose node predates the seeding kept whatever it was created with for ever. The - /// build instance was that instance: record Continuous + 3.0.0-ci*, node created - /// Stable with no pattern, and every check answered "none newer than the installed - /// 3.0.0-ci.9412" while the armed set was 3.0.0-ci.9564 (Doc/Architecture/SelfUpdateFreeze). - /// - /// Touches and - /// ONLY — never RequireCiGreen nor any - /// bookkeeping field — through the TYPED write (see ), as System. - /// Already equal ⇒ no write at all. Cold. - /// - public static IObservable ConvergeToDeclaration( - IMessageHub hub, AccessService? accessService, DeploymentDeclaration declared, ILogger? logger = null) - { - var workspace = hub.GetWorkspace(); - var jsonOptions = hub.JsonSerializerOptions; - // Two sealed System scopes rather than one around the whole chain: RunAsSystem impersonates - // at SUBSCRIBE, and the write is created on the read's emission — it gets its own scope so - // it is issued as System whatever thread the read emitted on. - return accessService - .RunAsSystem(() => workspace.GetMeshNodeStream(NodePath) - // EnsureExists ran first, so the node exists; a null is the stream not loaded yet. - .Where(node => node is not null) - .Take(1) - .Select(node => Parse(node, jsonOptions))) - .SelectMany(current => - { - if (Carries(current, declared)) - return Observable.Return(NodePath); - logger?.LogInformation( - "[SelfUpdate] converging {Path} to the deployment record: {Old} → {New}", - NodePath, Describe(current.DeclaredPolicy, current.Pattern), - Describe(declared.Policy, declared.Pattern)); - return accessService.RunAsSystem(() => workspace.GetMeshNodeStream(NodePath) - .Update((node, cur) => - { - var content = cur ?? new UpdatePolicyContent(); - return Carries(content, declared) - ? node - : node with - { - Content = content with { Policy = declared.Policy, Pattern = declared.Pattern }, - }; - }) - .Take(1) - .Select(_ => NodePath)); - }); - } - - private static string Describe(UpdatePolicyKind? policy, string? pattern) => - $"{(policy?.ToString() ?? "(absent)")} (pattern: {UpdateChannelPattern.Normalize(pattern) ?? "none"})"; - - /// How many combo verdicts Admin/UpdatePolicy retains. Candidates supersede - /// fast; the node must never grow without bound. - public const int MaxRecordedVerifications = 8; - - /// - /// Records a combo-verification verdict on Admin/UpdatePolicy (as System — the same - /// identity the poller's bookkeeping writes under): upsert by - /// , newest - /// first, bounded at . Touches ONLY the verification - /// field; the admin-chosen policy is preserved. Cold — subscribe to run the write. - /// - /// 🚨 The System scope is opened and closed SYNCHRONOUSLY around the subscribe (the - /// shape, and for its reason: impersonation is an - /// AsyncLocal, and Observable.Using would dispose it on whichever thread the - /// sequence terminates on, leaving the caller running as System). - /// - /// 🚨 The TYPED write, never ParseContent + the untyped overload — see - /// for what that shape destroys. - /// - public static IObservable RecordVerification(IMessageHub hub, ComboVerification verdict) - { - var accessService = hub.ServiceProvider.GetService(); - return Observable.Create(observer => - { - using (AccessContextScope.AsSystem(accessService)) - return hub.GetWorkspace().GetMeshNodeStream(NodePath) - .Update((node, cur) => - { - var current = cur ?? new UpdatePolicyContent(); - var verifications = current.ComboVerifications - .RemoveAll(v => string.Equals( - v.CandidateTag, verdict.CandidateTag, - StringComparison.OrdinalIgnoreCase)) - .Add(verdict) - .OrderByDescending(v => v.VerifiedAt) - .Take(MaxRecordedVerifications) - .ToImmutableList(); - return node with - { - Content = current with { ComboVerifications = verifications }, - }; - }) - .Select(_ => Unit.Default) - .Subscribe(observer); - }); - } - - /// Parses the policy content from a node (handles typed content, a degraded - /// , an as-written JsonNode and a same-named record from another - /// build); returns defaults when absent or unreadable. 🚨 READ-ONLY — see - /// . - public static UpdatePolicyContent Parse(MeshNode? node, JsonSerializerOptions options) => - ParseContent(node?.Content, options); - - /// - /// Parses the policy content from a node's Content value, defaulting when it is absent - /// or cannot be read. - /// - /// 🚨 READ-ONLY. A WRITE MUST NEVER BE BUILT ON THIS. Every write on - /// Admin/UpdatePolicy is the framework's TYPED write — - /// stream.Update<UpdatePolicyContent>((node, cur) => node with { Content = (cur ?? - /// new UpdatePolicyContent()) with { … } }) — and never - /// Update(node => … ParseContent(node.Content, …) …). - /// - /// A read must stay bad-data tolerant: a settings tab that throws is worse than one - /// showing the fail-closed . That tolerance is exactly what - /// makes it wrong for a write. This method answers new UpdatePolicyContent() for BOTH - /// "there is no content" and "the content is present and this build cannot read it", and a - /// bookkeeping write built on it PERSISTS that empty record — a failed READ becoming a write - /// that erases the admin's policy, the latest available tag, every combo verdict and any live - /// availability hold. That is #3542's proposal 3, and it survived #3607, which changed only - /// what an ABSENT policy MEANS. - /// - /// Measured on origin/main at 5453be493: with the record holding - /// {"policy":"None","latestAvailableTag":"3.0.0-ci.8009","comboVerifications":"corrupt"} - /// — one field this build cannot deserialize — one RecordAvailable-shaped write - /// completed silently and left - /// {"requireCiGreen":true,"latestAvailableTag":"3.0.0-ci.9999","comboVerifications":[]}. - /// The policy field was gone: precisely the production state #3542 opens on. - /// - /// The typed overload is the framework's own answer to exactly this and needs no local - /// guard: null there means ABSENT and only absent, while content that is present but - /// unreadable faults the observable with a MeshNodeStreamException carrying the path, - /// the runtime type and a JSON excerpt — the write does not happen. Every bookkeeping - /// caller already wraps its write in a .Catch that logs and carries on (#1020: a - /// bookkeeping write may never gate the roll), so an unreadable record is refused loudly and - /// left intact instead of overwritten silently. - /// - public static UpdatePolicyContent ParseContent(object? content, JsonSerializerOptions options) => - // ObjectAsExtensions.As, not a hand-rolled JsonElement switch: it also covers the - // as-written JsonNode DOM and a same-named record from another collectible assembly, both - // of which the switch answered with a silent default. - content.As(options) ?? new UpdatePolicyContent(); - - /// True if the exception (or any inner) reports an "already exists" outcome — the - /// idempotent-create success signal. - private static bool IsAlreadyExists(Exception ex) - { - for (var e = ex; e is not null; e = e.InnerException) - if (e.Message?.Contains("already exists", StringComparison.OrdinalIgnoreCase) == true) - return true; - return false; - } -} diff --git a/memex/Memex.Portal.Shared/SelfUpdate/VersionSelect.cs b/memex/Memex.Portal.Shared/SelfUpdate/VersionSelect.cs deleted file mode 100644 index abadfc7572..0000000000 --- a/memex/Memex.Portal.Shared/SelfUpdate/VersionSelect.cs +++ /dev/null @@ -1,451 +0,0 @@ -using System.Collections.Immutable; -using System.Text.RegularExpressions; -using NuGet.Versioning; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Plugin.Packaging; - -namespace Memex.Portal.Shared.SelfUpdate; - -/// -/// Picks the update target from a registry's image tags, per the update policy, and decides whether a -/// target is newer than the running version. -/// -/// 🚨 The key is the SEALED-PUBLICATION LINEAGE, never the version string (#3542). Every -/// continuous image carries a run number, in one of two notations of ONE lineage (policy -/// platform-semver-versioning): <line>[-.]ci.<run> (e.g. 3.0.0-ci.8059) or -/// the SemVer form <major>.<minor>.<run> (e.g. 3.1.10050), where -/// <run> is the GitHub Actions run number of the delivery workflow that published it — -/// Directory.Build.props calls that number MONOTONIC and load-bearing, and -/// MissedBuildFact already orders publications by the same value. The version LINE in front of -/// it is a label an author maintains by hand, and a hand-maintained label can be wrong: on -/// 2026-09-05 Directory.Build.props briefly read 3.1.0, so ten sets were published as -/// 3.1.0-ci.7832…7841 and then withdrawn. Ordered by SemVer those outrank every later, sealed -/// 3.0.0-ci.79xx set FOREVER — and they did: memex-cloud rolled itself onto -/// 3.1.0-ci.7841 and then reported "500 tag(s) listed, none newer than the installed -/// 3.1.0-ci.7841" on every subsequent check, because nothing ever outranks the highest-sorting -/// tag. SemVer §11.4 produces the same trap one layer down: the pre-release identifiers ci and -/// rc9 compare as TEXT, so 3.0.0-rc9.ci.7824 (2026-09-04) sorts above -/// 3.0.0-ci.7977 (2026-09-07). -/// -/// So the run number is the ordering key wherever both sides carry one, and the version string -/// is what it always was — a label. A tag with a higher line and a LOWER run number is exactly the -/// slip case, and it loses. -/// -/// An official release tag (clean 3.0.0) carries no run number of its own: it is a -/// PROMOTION, a retag of one sealed continuous set (release.yml), so its lineage lives on the -/// sibling tag it was cut from and cannot be read off the tag. That is why the two questions this -/// class answers use the key differently, and deliberately: -/// -/// needs a TOTAL order over a heterogeneous set, so lineage-bearing -/// tags order among themselves by run number and rank ahead of the promotion tags, which order among -/// themselves by SemVer. Under no continuous tag is eligible at -/// all, so that branch is a pure SemVer ordering exactly as before; under -/// a promotion tag names the same bytes as a continuous tag -/// already in the list, so ranking it behind them costs nothing. -/// is a PAIRWISE question with no transitivity to preserve, so it answers -/// on the strongest key BOTH sides share: run number when both are continuous builds, SemVer when -/// either side is a promotion. That is what keeps a Stable install on a continuous build able to -/// reach the clean release it is waiting for. -/// -/// -/// 🚨 The comparison itself is NOT defined here. It lives in -/// , in MeshWeaver.Plugin.Packaging — the lowest assembly -/// that both this selector and ModulePlatformFloor already reference. The reason is measured: -/// the module platform floor makes the SAME wrong assumption at a second call site (SemVer puts -/// ci below rc, so a floor of 3.0.0-rc8 is unsatisfiable by every -/// 3.0.0-ci.N, permanently), and two private ideas of "newer" is how fixing one leaves the -/// other broken. This class keeps only what is specific to picking an IMAGE TAG: the tag-shape -/// filters, the policy, and the total order a heterogeneous listing needs. -/// -/// 🔴 The run number must stay MONOTONIC (Directory.Build.props, fed from -/// GITHUB_RUN_NUMBER). A non-monotonic build number would make a newer build sort lower and -/// break "pick the newest" — that is the one property this ordering rests on, and it is the property -/// the version string does NOT have. -/// -public static class VersionSelect -{ - // The multi-arch container build publishes, per version, a manifest-list tag (e.g. 3.0.0-ci.43) PLUS - // one image per RID (3.0.0-ci.43-linux-x64, 3.0.0-ci.43-linux-arm64). The RID suffix parses as an extra - // SemVer pre-release identifier that sorts ABOVE the clean tag (numeric 43 < alphanumeric 43-linux-x64), - // so without this filter PickTarget rolls to the x64-only image — wrong arch on an arm64 node, and never - // the intended manifest list. Drop RID-suffixed tags; the manifest list is the canonical deploy tag. - private static readonly Regex RuntimeIdentifierSuffix = - new(@"-(linux|win|osx)-(x64|x86|arm|arm64)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - - // 🔴 A real platform tag is DOTTED SemVer (3.0.0 / 3.0.0-ci.133). The multi-arch build ALSO pushes a - // 7-char git-sha tag per version (e.g. 6943991, 4779b4e) and a bare `main` tag — those are NOT deploy - // targets. But NuGetVersion.TryParse accepts a bare number, so an ALL-DIGIT sha like "6943991" parses - // as the version 6943991.0.0 and sorts ABOVE every real release (3.x). PickTarget would then pick that - // sha and pin the self-updater to whatever release it belongs to — the prod symptom where every portal - // froze on ci.122 (whose sha, 6943991, is all digits) and reverted any manual roll to a newer ci.N - // (ci.133's sha, 4779b4e, has letters so it never even parsed). Require the MAJOR.MINOR.PATCH dotted - // shape that a git-sha / `main` can never have. - private static readonly Regex PlatformVersionTag = - new(@"^\d+\.\d+\.\d+([-+].*)?$", RegexOptions.Compiled); - - // 🚨 The MOVING image pointers (2026-09-08): CD re-points `-latest`, `-latest` - // and `-latest` at every publication of that line, so an install STARTS from - // one and self-updates from there. The three-part one — `3.0.1-latest` — has the dotted shape - // above and NuGet-parses as a pre-release of 3.0.1, so without this filter it would be listed as a - // release, ranked in the promotion band, and could be selected by a pattern such as `3.0.1*`. - // A pointer is not a version: it names different bytes tomorrow, and rolling a Deployment onto - // it makes "what does this install run" unanswerable. Never a candidate, under any policy or - // pattern. `3-latest` / `3.0-latest` already fail the dotted shape; this names the rule for all. - private static readonly Regex MovingPointer = - new(@"-latest$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - - /// The CD run number was published by — see - /// , which owns it. Kept here as the name this - /// file's own documentation and tests refer to, never as a second implementation. - public static long? BuildOrdinal(string version) => PlatformReleaseOrder.BuildOrdinal(version); - - /// - /// The best tag to roll to under , or null when nothing qualifies. - /// considers every parseable tag (incl. build-numbered - /// pre-releases); considers only DELIBERATELY CUT releases — - /// no pre-release label AND no run number, so a SemVer-notation continuous build such as - /// 3.1.10050 is not Stable although it is not a pre-release; always returns null. - /// A , when given, narrows either to the tags it admits. - /// Returns the ORIGINAL tag string (so the image patch uses the exact registry tag). - /// - /// Literally the head of — one filter, one ordering, no second - /// copy of "which release is best" to drift from the first. - /// - public static string? PickTarget( - IEnumerable tags, UpdatePolicyKind policy, bool requireCiGreen = true, string? pattern = null) => - PickTargets(tags, policy, requireCiGreen, pattern).FirstOrDefault(); - - /// - /// What ONE policy record means for the image roll: which is - /// actually applied, under which pattern, and — when that differs from what the record SAYS — - /// the sentence an operator needs to read. - /// - /// The policy applied. - /// The normalized version pattern applied, or null. - /// Non-null exactly when the record declares Continuous without a - /// pattern: the channel is then Stable, and this names the record and the pattern to set. - /// Logged ONCE per process by the poller; never a hold, never a fault. - public readonly record struct UpdateChannel(UpdatePolicyKind Policy, string? Pattern, string? Advisory); - - /// - /// 🚨 The channel rule (maintainer, 2026-09-08), stated once. "By default we will not - /// upgrade as long as no version without -ci… is labelled ⇒ we want to have a clean label - /// 3.0.1 to upgrade. If we want to get the -ci… we have to specify the pattern - /// 3.0.1-ci*." - /// - /// - /// — nothing; the pattern is ignored. - /// — deliberately cut releases only (no pre-release - /// label, no run number); a pattern narrows them. - /// WITH a pattern — the tags the pattern admits, - /// continuous builds included, newest run first. - /// WITHOUT a pattern — is Stable: - /// no pre-release is ever eligible on its own. The record is not rewritten (an operator's - /// choice is theirs to edit), the advisory names what to set, and the poller logs it once. - /// - /// - /// Pure. applies this itself, so the decision can never be - /// taken on an unresolved channel; deliberately does NOT — it is the - /// LISTING every reader of a registry or a published root uses ("which publications exist, in - /// what order"), and the availability service enumerates publications through it under - /// Continuous regardless of what this install would apply. - /// - public static UpdateChannel ResolveChannel(UpdatePolicyKind policy, string? pattern) - { - var normalized = UpdateChannelPattern.Normalize(pattern); - return policy switch - { - UpdatePolicyKind.None => new(UpdatePolicyKind.None, null, null), - UpdatePolicyKind.Continuous when normalized is null => new( - UpdatePolicyKind.Stable, null, - $"{UpdatePolicyNodeType.NodePath} declares policy Continuous with no version pattern, " - + "so it follows CLEAN releases only (the same as Stable): a continuous build is " - + "eligible only when a pattern admits it. To follow the current line's continuous " - + "builds set `pattern` on the record, e.g. \"3.*\" — every continuous build of major 3, " - + "in both the 3.0.0-ci. and the 3.. notation (Doc/Architecture/PlatformVersioning)."), - _ => new(policy, normalized, null), - }; - } - - /// - /// Every eligible tag under , NEWEST FIRST — where "newest" is the - /// sealed-publication lineage described on , not the version string. - /// is simply this sequence's first element. - /// - /// 🚨 Why the caller needs the whole ordered list and not just the best one. A - /// target is only rollable if a sealed content bake exists for the framework identity that - /// exact image resolves to. Picking the newest tag and stopping means one unbaked release - /// freezes the instance FOREVER: it holds, the next platform build produces another unbaked - /// tag, the bake publishes yet another identity, and the two never meet. That is not - /// hypothetical — memex sat on 3.0.0-rc6 held against 3.0.0-rc7.ci.4928 while three separate - /// bakes published three other identities, every job green throughout. - /// - /// Walking the list newest-first and taking the first SEALED one converts that deadlock - /// into ordinary progress: the instance always advances to the best release that can actually - /// serve, and a not-yet-baked newer build simply waits its turn instead of blocking the ones - /// behind it. It never rolls into a boot storm, because unsealed candidates are skipped rather - /// than forced. - /// - /// This is a listing, not a decision: it says which tags are ELIGIBLE and in what order, - /// never which of them is newer than what runs. makes that call — - /// forward-only, except on the one path where the installed tag has been proven WITHDRAWN and - /// rolling backwards is the only way out. - /// - /// 🚨 A NARROWS, on top of the policy: Stable + - /// 3.0.* lists one line's clean releases, Continuous + 3.0.1-ci* lists that - /// line's continuous builds and nothing else — 3.0.2-ci.1 and the clean 3.0.1 - /// alike are out, because the glob says so. Whether an absent pattern turns Continuous - /// into Stable is 's decision, taken by - /// ; this listing answers only for the arguments it is given. - /// - public static IReadOnlyList PickTargets( - IEnumerable tags, UpdatePolicyKind policy, bool requireCiGreen = true, string? pattern = null) - { - if (policy == UpdatePolicyKind.None) - return []; - - var parsed = Parse(tags); - - // 🚨 Stable = DELIBERATELY CUT releases. Under the SemVer notation (policy - // `platform-semver-versioning`) a continuous build carries no pre-release label either — - // `3.1.10050` is as "clean" to NuGet as a release — so `!IsPrerelease` alone would hand - // every Stable install every main build the day the notation flips. A tag that carries a - // run number is a continuous publication whatever its shape, and stays out of Stable. - if (policy == UpdatePolicyKind.Stable) - parsed = parsed.Where(x => !x.ver.IsPrerelease && PlatformReleaseOrder.BuildOrdinal(x.tag) is null); - - // Compiled once per listing, not once per tag (Copilot review on #3723). - if (UpdateChannelPattern.Compile(pattern) is { } glob) - parsed = parsed.Where(x => glob.IsMatch(x.tag)); - - // CI-green gate: the verified channel (continuous delivery, which builds+pushes ONLY when the - // test workflow is green) never carries the `edge` pre-release label. An unverified "edge" - // channel (publish-on-every-build, e.g. `3.0.0-edge.51`) would. requireCiGreen excludes those, - // so the install never auto-rolls to a build that hasn't passed CI. Off => edge builds eligible. - if (requireCiGreen) - parsed = parsed.Where(x => !IsEdge(x.ver)); - - return - [ - .. parsed - // 🚨 THE ORDER, and the whole of #3542 — and it is NOT defined here. Lineage first: - // a continuous build is ranked by the run number that published it, so a mislabelled - // line can never outrank a later sealed set; promotion tags (a clean release, which - // has no run number of its own) follow, ordered among themselves by SemVer. That - // banding is what makes it a TOTAL order, and it is shared with every other caller - // that ranks platform builds — the release-marker index, the bundle sweep and the - // retention plan all read the same `_releases/` names this reads tags. A - // second private copy of "which build is newest" is how #3542 came back at another - // call site while this one was already fixed. - .OrderByDescending(x => x.tag, PlatformReleaseOrder.Newest) - .Select(x => x.tag), - ]; - } - - /// The structural filter every reader of a tag listing applies: drop the per-RID images, - /// the git-sha / main pointers and the moving -latest pointers, keep what parses - /// as a platform version. - private static IEnumerable<(string tag, NuGetVersion ver)> Parse( - IEnumerable tags) => - tags - .Where(t => !RuntimeIdentifierSuffix.IsMatch(t)) // exclude per-RID image tags; keep the manifest list - .Where(t => !MovingPointer.IsMatch(t)) // exclude `3-latest` / `3.0-latest` / `3.0.1-latest` (see MovingPointer) - .Where(t => PlatformVersionTag.IsMatch(t)) // exclude bare git-sha / `main` tags (see PlatformVersionTag) - .Select(t => (tag: t, ver: NuGetVersion.TryParse(t, out var v) ? v : null)) - .Where(x => x.ver is not null) - .Select(x => (x.tag, ver: x.ver!)); - - /// An UNVERIFIED edge/pre-merge build — identified by an edge SemVer pre-release - /// label (e.g. 3.0.0-edge.51). Verified CD builds use -ci.<n> or a clean release, - /// never edge. - private static bool IsEdge(NuGetVersion version) => - version.ReleaseLabels.Any(label => string.Equals(label, "edge", StringComparison.OrdinalIgnoreCase)); - - /// - /// True if is strictly newer than — - /// by SEALED-PUBLICATION LINEAGE when both name a continuous build, and by SemVer when either side - /// is an official release (which carries no run number of its own). An unparseable current version - /// (e.g. "unknown" on an unstamped build) returns false — we never auto-update when - /// we can't establish the running version. - /// - /// The rule itself is , shared with every other - /// caller that has to compare two platform builds. This is the name the self-updater calls it by. - /// - public static bool IsNewer(string targetTag, string currentVersion) => - PlatformReleaseOrder.IsNewer(targetTag, currentVersion); - - /// - /// What ONE check may roll to, and why — the whole selection decision, pure, so it is testable - /// without a poller, a registry or a mesh. - /// - /// Newest first, ready for the availability/combo walk. Empty when there - /// is nothing to roll to. - /// 🚨 True when these candidates are NOT newer than what is installed: - /// the installed tag no longer resolves, so the best AVAILABLE release was taken instead of - /// reporting "up to date". The roll may go backwards in lineage — deliberately, because an image - /// that exists beats one that does not. - /// Whether the installed tag still resolves, and the sentence explaining - /// it. - /// How many tags the registry answered with — quoted in the verdict, and the - /// number a reader uses to judge whether the listing was worth anything. - public readonly record struct RollCandidates( - ImmutableArray Candidates, - bool IsRecovery, - InstalledTagCheck Installed, - int Listed); - - /// - /// 🚨 The check's decision, in one place: what may this install roll to? - /// - /// Normally: every eligible tag that is NEWER than what runs, newest first. The interesting - /// part is what happens when that set is EMPTY, because until #3543 there was only one answer for - /// it — "up to date" — and two states produce it. The second is that the installed tag has been - /// WITHDRAWN: the workloads name an image that no longer exists, no new pod can start, and no - /// future publication can rescue the install by being "newer", since the withdrawn tag outranks - /// everything left by construction. That is not "current"; it is stranded, and the way out is to - /// take the best AVAILABLE release even though it is older. - /// - /// The third state — the listing could not answer — takes NEITHER branch. See - /// : a failed read that recovered as if the tag - /// were withdrawn would roll the whole fleet backwards off one bad ACR response. - /// - /// 🚨 "Continuous follows the ci line" (maintainer, 2026-09-07). An install already - /// ON the continuous line stays on it: the clean release is NOT a Continuous target, even though - /// correctly reports it as newer. Those are different questions and both - /// answers are wanted — see . - /// - /// 🚨 And a continuous build is eligible only when the record's - /// admits it (maintainer, 2026-09-08): the channel is 'd FIRST, - /// so Continuous with no pattern decides exactly as Stable does — clean releases - /// only, until 3.0.1 exists — and 3.0.0-ci* can never select 3.0.1. - /// - public static RollCandidates SelectCandidates( - IReadOnlyList tags, - string installedVersion, - UpdatePolicyKind policy, - bool requireCiGreen = true, - string? pattern = null) - { - var channel = ResolveChannel(policy, pattern); - policy = channel.Policy; - var eligible = PickTargets(tags, policy, requireCiGreen, channel.Pattern); - var installed = CheckInstalledTag(tags, installedVersion); - var staysOnTheCiLine = OnTheContinuousLine(installedVersion, policy); - - var newer = eligible - .Where(tag => !staysOnTheCiLine || PlatformReleaseOrder.BuildOrdinal(tag) is not null) - .Where(tag => IsNewer(tag, installedVersion)) - .ToImmutableArray(); - - if (newer.Length > 0) - return new(newer, IsRecovery: false, installed, tags.Count); - - // 🚨 RECOVERY deliberately drops the line rule. The install cannot start a pod on the image it - // names at all; an image that EXISTS beats line purity, and the verdict says RECOVERY so the - // departure is visible. Refusing a promotion tag here could leave an install stranded with a - // perfectly good release sitting in the registry. - return installed.Resolution == InstalledTagResolution.Withdrawn - ? new([.. eligible], IsRecovery: true, installed, tags.Count) - : new([], IsRecovery: false, installed, tags.Count); - } - - /// - /// 🚨 "Continuous follows the ci line" — the maintainer's decision, 2026-09-07, encoded. - /// - /// Once v3.0.0 is tagged, SemVer ranks the clean 3.0.0 above every - /// 3.0.0-ci.<n>, so a Continuous install would jump onto the release and then sit - /// there while later sealed ci builds accumulate below it — the release outranks them all. That is - /// the #3542 freeze again, wearing the release's clothes. The clean release belongs to the Stable - /// policy; Continuous means the continuous line. - /// - /// So: an install whose running build carries a run number is ON the line, and under - /// only tags that also carry one are candidates. An - /// install that is NOT on the line (it runs a promotion) is not held there — the version string - /// decides for it, so it rejoins at the next line's first ci build. - /// - /// 🚨 This deliberately does NOT change , and the two must not be - /// merged. "Is the release newer than this ci build?" is YES — Stable needs that answer to reach - /// the release at all, and a capability question (is the running platform at least as new as X) - /// needs it too. "Should a Continuous install take it?" is NO. One predicate cannot carry both, - /// so the POLICY decision lives here, where the policy is in scope, and the ORDER stays a pure - /// fact about publications. - /// - private static bool OnTheContinuousLine(string installedVersion, UpdatePolicyKind policy) => - policy == UpdatePolicyKind.Continuous - && PlatformReleaseOrder.BuildOrdinal(installedVersion) is not null; - - /// - /// Whether the tag this install RUNS still resolves in a registry listing — the three-valued - /// answer to a question that must never be collapsed into a boolean (#3543). - /// - public enum InstalledTagResolution - { - /// 🚨 The listing could not answer the question at all: the running build reports no - /// parseable platform version, or the listing carries no platform tags to compare against. A - /// failed read, NOT a negative — reporting it as would roll an install - /// off a perfectly good image on the strength of a read that never happened. - Indeterminate, - - /// The listing contains the installed version. "Nothing newer" here genuinely means - /// up to date. - Resolved, - - /// 🚨 The listing is populated and does NOT contain the installed version: the image - /// this install's Deployment names has been untagged, so it cannot start a new pod at all. - /// "Nothing newer than the installed X" is then not a healthy verdict — it is a STRAND, and by - /// construction nothing will ever be newer than a tag that outranks everything left. - Withdrawn, - } - - /// The plus the sentence that explains it — carried - /// together so a caller can report the reason without re-deriving it. - /// Which of the three states the listing established. - /// One clause, ready to fold into a verdict message. - public readonly record struct InstalledTagCheck(InstalledTagResolution Resolution, string Explanation); - - /// - /// 🚨 Does the tag this install runs still exist in ? - /// - /// The listing is already fetched — the "no newer release" verdict quotes its size — so this - /// costs nothing beyond a scan, and it is the difference between the two states that until now - /// printed the same sentence: "I am current" and "the version I run was withdrawn and I - /// can never move again". Both portals sat in the second on 2026-09-07 and had to be moved off - /// by an operator. - /// - /// Compared by parsed VERSION, not by string: the running value carries - /// +build.<ticks> build metadata that the registry tag does not, and SemVer ignores - /// metadata in comparison. The structural filters are the same ones - /// applies — a per-RID image or a git-sha pointer is not the tag a - /// Deployment names — but the POLICY filters deliberately are not: the question is whether the - /// image still exists, never whether this install would choose it. - /// - public static InstalledTagCheck CheckInstalledTag(IEnumerable tags, string installedVersion) - { - if (!NuGetVersion.TryParse(installedVersion, out var installed)) - return new( - InstalledTagResolution.Indeterminate, - $"the running build reports '{installedVersion}', which is not a platform version, so " - + "whether its image still exists could not be established"); - - var listed = Parse(tags).Select(x => x.ver).ToArray(); - - // 🚨 The denominator, checked before the verdict. A listing with no platform tags in it is the - // shape of a read that answered nothing — an unreachable registry, the wrong repository, a - // paged response that came back empty — and it is INDISTINGUISHABLE from a registry that - // genuinely holds none. Calling that "withdrawn" would strand-recover every install in the - // fleet off a single bad read. - if (listed.Length == 0) - return new( - InstalledTagResolution.Indeterminate, - "the registry listing carried no platform version tags at all, which is the same shape " - + "as a read that returned nothing — so it cannot be read as 'the installed tag is gone'"); - - return listed.Any(v => v == installed) - ? new(InstalledTagResolution.Resolved, $"the installed {installedVersion} is still published") - : new( - InstalledTagResolution.Withdrawn, - $"the installed {installedVersion} is NOT among the {listed.Length} platform tag(s) " - + "in the registry — the image this install's workloads name has been untagged, so no " - + "new pod can start from it"); - } -} diff --git a/memex/Memex.Portal.Shared/Seo/IconRasterizer.cs b/memex/Memex.Portal.Shared/Seo/IconRasterizer.cs deleted file mode 100644 index 538bf4f976..0000000000 --- a/memex/Memex.Portal.Shared/Seo/IconRasterizer.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Collections.Immutable; -using SkiaSharp; -using Svg.Skia; - -namespace Memex.Portal.Shared.Seo; - -/// -/// Turns a node's <svg> mark into PNG bytes, so the per-content favicon reaches the -/// browsers that cannot read an SVG one. -/// -/// Why this exists (issue #2075, item 3). The SEO head emits the node's own icon as an -/// image/svg+xml data: URI (). Safari renders -/// no SVG favicon at all — not from a data URI, not from a URL — so on macOS and iOS every node -/// page wore the portal favicon and the whole feature was invisible to those users. A feature a -/// whole browser cannot see is not shipped for them. The cure is the standards-based one: declare -/// the SVG *and* a PNG, and let each browser take the one it can read. -/// -/// Why an SVG parser, and why authored marks decide it. SkiaSharp draws; it does not -/// parse SVG. The portal's icon population is mixed — generated backplates (structured, drawable -/// directly) and AUTHORED markup (arbitrary) — and a census of the store packages found every one -/// of the 56 marks to be authored inline <svg>, the issue's own Chess board -/// among them. Drawing only the generated half would have produced an endpoint Safari honours that -/// still leaves the reported node showing the portal favicon. Hence Svg.Skia beside -/// SkiaSharp; see Doc/Architecture/ContentFaviconRasterization. -/// -/// No state, deliberately. Unlike — which holds a decoded -/// typeface — nothing here survives a call: the parse, the surface and the encode are all per -/// request, so there is no instance to own and no cache to scope to a mesh. Repeat cost is carried -/// by the endpoint's strong ETag and max-age instead of by memory the process never frees -/// (AGENTS.md: no static collections, and an unbounded icon cache keyed by node path is exactly -/// one). -/// -public static class IconRasterizer -{ - /// The favicon size a browser actually paints in a tab strip. - public const int FaviconSize = 32; - - /// The apple-touch-icon size — Safari's bookmark / Add-to-Dock tile. - public const int AppleTouchSize = 180; - - /// - /// The sizes the endpoint will render, smallest first. An ALLOW-LIST rather than a clamped - /// range: the route is anonymous and cacheable, so an unbounded size parameter is an unbounded - /// number of distinct renders and cache entries for one node. A constant lookup — written once - /// at type init, never at runtime. - /// - public static readonly ImmutableArray SupportedSizes = - [16, FaviconSize, 48, 64, 96, 128, AppleTouchSize, 192, 512]; - - /// Whether is one this rasterizer will render. - public static bool IsSupportedSize(int size) => SupportedSizes.Contains(size); - - /// - /// Rasterizes into a square PNG of pixels, or - /// null when the markup carries nothing drawable. - /// - /// The mark is fitted to the square by its own viewBox — scaled by the smaller - /// axis and centred, so a non-square mark keeps its aspect ratio instead of being stretched. - /// The ground is left TRANSPARENT: a favicon is painted onto whatever the browser's tab strip - /// or bookmark bar uses, and every mark reaching here already paints its own plate, so - /// inventing a background here would draw a border around a mark that already has one. - /// - /// 🚨 That premise is LOAD-BEARING, and it was false (#4350). It holds because - /// — the one producer of the markup this method - /// rasterizes — puts the icon through IconBackplate.Ensure. It does NOT hold of authored - /// markup in general, and the failure it produces is the invisible kind rather than the loud - /// one: an icon authored as a currentColor outline has no surrounding text to inherit a - /// color from here, so it paints in the INITIAL color — black — and the transparent ground - /// leaves nothing else. Measured: a black hairline, every painted pixel under RGB 40, the rest - /// of the canvas alpha 0. does not catch it (pixels WERE - /// painted), so it is served behind a 200 and simply cannot be seen on a dark tab strip. The - /// fix is the plate at the producer, never a background invented here — inventing one would - /// draw a border around every mark that already has its own. - /// - /// Pure: same markup and size → same bytes, which is what makes the endpoint's strong - /// ETag meaningful. - /// - /// Inline <svg> markup — the node's icon, byte for byte. - /// The square edge in pixels; see . - /// The size is not positive. - public static byte[]? Render(string svg, int size) - { - using var image = RenderImage(svg, size); - if (image is null) - return null; - using var data = image.Encode(SKEncodedImageFormat.Png, 100); - return data.ToArray(); - } - - /// - /// The svg drawn into a transparent -pixel square, as an image the - /// caller composes onto its own canvas — the share card draws the node's mark this way. Null - /// for markup that does not parse, has no extent, or paints NOTHING (see ), - /// so a caller can fall back to a picture of its own rather than draw an invisible one. - /// - internal static SKImage? RenderImage(string svg, int size) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(size); - if (string.IsNullOrWhiteSpace(svg)) - return null; - - using var document = SKSvg.CreateFromSvg(svg); - var picture = document?.Picture; - // A zero-extent cull rect would divide by zero below. Svg.Skia never actually produces one - // — an empty root reports 1×1 — so this is a guard against the arithmetic, NOT the - // "draws nothing" test; that one is made below, on the pixels, where it can be true. - if (picture is null || picture.CullRect.Width <= 0 || picture.CullRect.Height <= 0) - return null; - - var info = new SKImageInfo(size, size, SKColorType.Rgba8888, SKAlphaType.Premul); - using var surface = SKSurface.Create(info); - var canvas = surface.Canvas; - canvas.Clear(SKColors.Transparent); - - var bounds = picture.CullRect; - var scale = Math.Min(size / bounds.Width, size / bounds.Height); - canvas.Translate( - (size - (bounds.Width * scale)) / 2f, - (size - (bounds.Height * scale)) / 2f); - canvas.Scale(scale); - canvas.Translate(-bounds.Left, -bounds.Top); - canvas.DrawPicture(picture); - canvas.Flush(); - - // 🚨 An svg that PARSES but paints nothing — an empty root, a mark whose whole body was - // stripped, a viewBox that collapses — encodes a perfectly valid, perfectly invisible PNG. - // Served, that is a blank tab icon behind a 200, which reads as a working endpoint and is - // the one failure nobody can see. Answer "no icon" instead and let the caller 404. - if (IsFullyTransparent(surface)) - return null; - - return surface.Snapshot(); - } - - private static bool IsFullyTransparent(SKSurface surface) - { - using var pixels = surface.PeekPixels(); - if (pixels is null) - return false; // cannot read the buffer → do not CLAIM it is blank - var span = pixels.GetPixelSpan(); - // Rgba8888: alpha is the fourth byte of every pixel. - for (var i = 3; i < span.Length; i += 4) - if (span[i] != 0) - return false; - return true; - } -} diff --git a/memex/Memex.Portal.Shared/Seo/OgCardRenderer.cs b/memex/Memex.Portal.Shared/Seo/OgCardRenderer.cs deleted file mode 100644 index 5d1c30e57c..0000000000 --- a/memex/Memex.Portal.Shared/Seo/OgCardRenderer.cs +++ /dev/null @@ -1,802 +0,0 @@ -using System.Collections.Immutable; -using System.Reflection; -using System.Text; -using MeshWeaver.Graph; -using SkiaSharp; - -namespace Memex.Portal.Shared.Seo; - -/// -/// Everything the share card can say about one page — read off the MeshNode by the -/// endpoint, never authored a second time. Init-only properties, no primary constructor: a -/// record's primary constructor is a binary contract with every module compiled against it (see -/// ), and this shape will grow as nodes learn to say more about themselves. -/// -public sealed record OgCardContent -{ - /// The headline — the node's name. - public required string Title { get; init; } - - /// The supporting text — the node's description, abstract or tagline. - public string? Description { get; init; } - - /// The small label above the title — the node's category, else its type. - public string? Eyebrow { get; init; } - - /// The node's own mark as inline <svg> markup (backplated), drawn large - /// on the card. Null draws the default badge instead, so every card carries a picture. - public string? IconSvg { get; init; } - - /// A price label such as CHF 490, shown as a chip; null for pages that sell nothing. - public string? Price { get; init; } - - /// The node path, printed in the footer so a card lifted into a feed still says WHERE - /// on the instance it points. - public string? Path { get; init; } - - /// Stable string (the node path) the accent colour is derived from. - public string AccentSeed { get; init; } = ""; -} - -/// -/// Draws the 1200×630 share card a public page falls back to when it has authored no image of -/// its own — so "this page has an Open Graph card" is the DEFAULT, not something each page has to -/// remember to do. -/// -/// Why this exists. Before it, og:image was emitted only when a node happened -/// to carry an authored image, and in practice no store plugin ever did (the resolver read -/// poster/thumbnail while PluginContent declares ogImage — the names -/// never met). Every share of every public page was a bare text link. -/// -/// What it draws. Whatever the node can say about itself: category as the eyebrow, -/// name as the title, description, a price chip for something that is for sale, the node's OWN -/// mark rendered large on the right, and the instance name plus the path in the footer. A node -/// with no mark gets a default badge — a rounded tile in the card's accent carrying the page's -/// initial — so no card is ever text on a dark rectangle (2026-09-18: the Store shared into -/// iMessage as a bare title, and the tiny site favicon was the only picture on the bubble). -/// -/// Why Skia, and why a font file. The NoDependencies native build links no -/// fontconfig and no freetype, so the portal image needs nothing apt-installed and its base image -/// is untouched; the flip side is that Skia can then find no system font at all, so the card -/// carries its own (Open Sans, Apache-2.0). Latin/Greek/Cyrillic render; a CJK title falls back to -/// tofu, which is why keeps the node name legible through layout (size, colour, -/// position) rather than through glyphs alone. -/// -/// Registered as a singleton — the typeface is decoded once and held as an INSTANCE field, -/// never a static cache. -/// -public sealed class OgCardRenderer : IDisposable -{ - /// The card's pixel width — the Open Graph recommended size, declared in the head as og:image:width. - public const int Width = 1200; - - /// The card's pixel height — declared in the head as og:image:height. - public const int Height = 630; - - private const int Margin = 84; - - /// The edge of the square the icon (or default badge) is drawn in. - internal const int IconSize = 264; - - /// Gap between the text column and the icon square. - private const int IconGap = 64; - - /// Left edge of the icon square — the text column ends before it. - internal const int IconLeft = Width - Margin - IconSize; - - /// Vertical centre of the content band (below the rule, above the footer). - internal const float ContentCentreY = (Margin + (Height - Margin - 30f)) / 2f; - - private static readonly SKColor Paper = new(0xF8, 0xFA, 0xFC); - private static readonly SKColor Muted = new(0x94, 0xA3, 0xB8); - private static readonly SKColor Faint = new(0x64, 0x74, 0x8B); - - private readonly SKTypeface typeface; - private readonly string siteName; - - /// The colour the mark is drawn in — the brand cyan, or the instance's own. - private readonly string markColor; - - /// The brand's cyan, the mark's colour on the public instance. - internal const string BrandCyan = "#00d4ff"; - - /// Creates the renderer, decoding the embedded font once. - /// The instance name printed in the card's footer. - public OgCardRenderer(string siteName) : this(siteName, null) { } - - /// The instance name printed in the card's footer. - /// The instance's own colour for the mark (Portal:InstanceColor, - /// a #rrggbb); null or malformed keeps the brand cyan. The public instance wears the - /// brand; a company or client portal wears its colour, so two portals' cards tell apart in a - /// feed (maintainer, 2026-09-23). - public OgCardRenderer(string siteName, string? markColor) - { - this.siteName = string.IsNullOrWhiteSpace(siteName) ? "Memex" : siteName.Trim(); - this.markColor = markColor is { Length: 7 } hex && hex[0] == '#' && hex.Skip(1).All(Uri.IsHexDigit) - ? hex.ToLowerInvariant() - : BrandCyan; - using var stream = typeof(OgCardRenderer).GetTypeInfo().Assembly - .GetManifestResourceStream("Memex.Portal.Shared.Seo.OpenSans-Regular.ttf") - ?? throw new InvalidOperationException( - "The embedded share-card font is missing. Without it the NoDependencies Skia build " - + "has no font at all and every card would render blank — check the EmbeddedResource " - + "LogicalName in Memex.Portal.Shared.csproj."); - typeface = SKTypeface.FromStream(stream) - ?? throw new InvalidOperationException("The embedded share-card font could not be decoded."); - } - - /// - /// Renders the card as PNG bytes. Pure: same inputs → same bytes, which is what lets the - /// endpoint serve a strong ETag and let crawlers cache hard. - /// - public byte[] Render(OgCardContent card) - { - using var image = DrawCard(card); - using var data = image.Encode(SKEncodedImageFormat.Png, 100); - return data.ToArray(); - } - - /// - /// The same drawn card as , encoded as a baseline JPEG within - /// — what /api/og/{node}.jpg answers when the authored - /// picture it stands for cannot be fetched or decoded, so the head's declared type and size stay - /// true whichever picture is served. - /// - /// What the card says about the page. - public byte[] RenderJpeg(OgCardContent card) - { - using var image = DrawCard(card); - // The drawn card is flat colour and text — tens of kilobytes at the first rung — so an - // over-budget encoding is a defect in the renderer, not content to degrade around. - return EncodeShareJpeg(image) - ?? throw new InvalidOperationException("The drawn card exceeded the share-image byte budget."); - } - - private SKImage DrawCard(OgCardContent card) - { - var info = new SKImageInfo(Width, Height, SKColorType.Rgba8888, SKAlphaType.Premul); - using var surface = SKSurface.Create(info); - Draw(surface.Canvas, card, AccentFor(card.AccentSeed)); - return surface.Snapshot(); - } - - /// - /// 🚨 The byte budget an authored share picture is normalised into. WhatsApp — the strictest - /// unfurler measured — drops a preview image above roughly 300 KB, and does so AFTER it has - /// reserved the large layout from twitter:card=summary_large_image, which is exactly the - /// empty-box card a shared post produced (2026-10-07). Kept under that with headroom. - /// - public const int MaxShareImageBytes = 280_000; - - /// The media type of every normalised authored picture (). - public const string AuthoredMediaType = "image/jpeg"; - - /// JPEG qualities tried in order until the encoding fits . - /// A fixed ladder rather than a search, so the same source always yields the same bytes and the - /// route's strong ETag holds. - private static readonly ImmutableArray JpegQualityLadder = [85, 78, 70, 62, 54, 46]; - - /// - /// 🚨 An AUTHORED share picture — a post's mediaUrl, a page's ogImage on another - /// host — re-drawn as the one shape every unfurler accepts: exactly ×, - /// a BASELINE (non-progressive) JPEG, no larger than . - /// - /// Why the portal redraws a picture it did not make. An authored URL used to be - /// declared in the head as-is, with no type and no size, pointing at whatever host the author - /// used. A Social post's Supabase JPEG (1264×848, PROGRESSIVE, served with - /// x-robots-tag: none) then unfurled in WhatsApp as an EMPTY large card — title, - /// description and domain present, the picture box reserved and blank — while the drawn card, - /// which is always a declared 1200×630 image on our own origin, did not. Every property the - /// unfurler can object to (format, encoding, byte size, dimensions, the host's headers) is fixed - /// here instead of hoping the author's host gets all of them right. - /// - /// Nothing is cropped. The whole picture is fitted inside the card; the margin left - /// by a different aspect ratio is filled with the same picture enlarged and blurred, so a - /// portrait photo or a slide with text at its edge survives intact. Pure: same bytes in, same - /// bytes out — no clock, no randomness. - /// - /// The encoded source picture (any format Skia decodes: JPEG, PNG, WebP, GIF, BMP). - /// The normalised JPEG, or null when is not a decodable - /// picture, exceeds , or cannot be encoded within - /// . - public byte[]? NormaliseAuthored(byte[] source) - { - using var decoded = DecodeUpright(source); - if (decoded is null || decoded.Width <= 0 || decoded.Height <= 0) - return null; - using var picture = SKImage.FromBitmap(decoded); - - var info = new SKImageInfo(Width, Height, SKColorType.Rgba8888, SKAlphaType.Premul); - using var surface = SKSurface.Create(info); - var canvas = surface.Canvas; - canvas.Clear(new SKColor(0x0F, 0x17, 0x2A)); - var sampling = new SKSamplingOptions(SKCubicResampler.Mitchell); - - // The backdrop: the picture scaled to COVER the card, blurred and dimmed, so the margin a - // different aspect ratio leaves reads as part of the picture rather than as a frame. - var cover = Math.Max((float)Width / picture.Width, (float)Height / picture.Height); - var coverRect = Centred(picture.Width * cover, picture.Height * cover); - using (var blur = new SKPaint { ImageFilter = SKImageFilter.CreateBlur(36f, 36f, SKShaderTileMode.Clamp) }) - canvas.DrawImage(picture, coverRect, sampling, blur); - using (var dim = new SKPaint { Color = new SKColor(0, 0, 0, 0x59) }) - canvas.DrawRect(0, 0, Width, Height, dim); - - // The picture itself, CONTAINED: every pixel the author published stays on the card. - var contain = Math.Min((float)Width / picture.Width, (float)Height / picture.Height); - using (var paint = new SKPaint { IsAntialias = true }) - canvas.DrawImage(picture, Centred(picture.Width * contain, picture.Height * contain), sampling, paint); - - using var card = surface.Snapshot(); - return EncodeShareJpeg(card); - } - - /// - /// The normalised share picture for an authored image URL (), - /// fetched through — the SSRF-guarded, Http-pool fetch — with the - /// normalisation run inside the pooled leaf. Errors when the URL is refused, fails, or is no - /// usable picture; the caller decides the fallback. - /// - /// 🚨 Deliberately NOT cached in the process. A cache keyed by URL is unbounded across - /// public nodes and edits, and stale for an author who replaces the bytes behind the same URL; - /// the response is shared-cacheable for a day with a strong ETag instead, so the unfurlers and - /// any CDN in front hold it, and the bounded Http pool caps what a burst can cost. - /// - /// The authored absolute image URL. - /// The mesh's external fetcher. - public IObservable AuthoredCard(string url, OpenGraphPreviewService fetcher) => - fetcher.FetchImage(url, bytes => - NormaliseAuthored(bytes) - ?? throw new InvalidDataException( - $"'{url}' is not a picture this portal can decode within its pixel and byte budgets.")); - - /// 🚨 The most pixels a source is ever decoded into. The transfer cap - /// () does not bound this: a tiny crafted - /// PNG can declare enormous dimensions. The source is decoded at the smallest scale its codec - /// offers that still covers the card, and refused when even that exceeds this budget (64 MB of - /// RGBA) — the route then serves the drawn card. - public const int MaxDecodedPixels = 16_000_000; - - private static SKRect Centred(float width, float height) - { - var left = (Width - width) / 2f; - var top = (Height - height) / 2f; - return new SKRect(left, top, left + width, top + height); - } - - /// Decodes with its EXIF orientation applied — a phone photo - /// stored sideways must not unfurl sideways. Null for bytes that are no picture. - private static SKBitmap? DecodeUpright(byte[] source) - { - using var data = SKData.CreateCopy(source); - using var codec = SKCodec.Create(data); - if (codec is null || codec.Info.Width <= 0 || codec.Info.Height <= 0) - return null; - - // The smallest decode that still COVERS the card (the backdrop is drawn at cover scale), in - // the codec's own terms: a JPEG decodes at 1/2, 1/4, 1/8 natively; other formats answer - // their full size. Dimensions read in stored orientation, so compare against both axes. - var stored = codec.Info; - var need = Math.Max( - Math.Max((float)Width / stored.Width, (float)Height / stored.Height), - Math.Max((float)Width / stored.Height, (float)Height / stored.Width)); - var size = need < 1f ? codec.GetScaledDimensions(need) : new SKSizeI(stored.Width, stored.Height); - if ((long)size.Width * size.Height > MaxDecodedPixels) - return null; - - var info = new SKImageInfo(size.Width, size.Height, SKColorType.Rgba8888, SKAlphaType.Premul); - var raw = new SKBitmap(info); - var result = codec.GetPixels(info, raw.GetPixels()); - if (result is not (SKCodecResult.Success or SKCodecResult.IncompleteInput)) - { - raw.Dispose(); - return null; - } - if (codec.EncodedOrigin == SKEncodedOrigin.TopLeft) - return raw; - - var swap = codec.EncodedOrigin is SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightTop - or SKEncodedOrigin.RightBottom or SKEncodedOrigin.LeftBottom; - var upright = new SKBitmap(swap ? raw.Height : raw.Width, swap ? raw.Width : raw.Height, - raw.ColorType, raw.AlphaType); - using (var canvas = new SKCanvas(upright)) - { - canvas.SetMatrix(OrientationMatrix(codec.EncodedOrigin, raw.Width, raw.Height)); - using var rawImage = SKImage.FromBitmap(raw); - canvas.DrawImage(rawImage, 0, 0, new SKSamplingOptions(SKFilterMode.Nearest)); - } - raw.Dispose(); - return upright; - } - - /// The EXIF orientation as the matrix that maps the stored pixels ( - /// × ) onto the upright canvas — x' = sx·x + kx·y + tx, y' = ky·x + sy·y + ty. - internal static SKMatrix OrientationMatrix(SKEncodedOrigin origin, int w, int h) => origin switch - { - SKEncodedOrigin.TopRight => new SKMatrix(-1, 0, w, 0, 1, 0, 0, 0, 1), - SKEncodedOrigin.BottomRight => new SKMatrix(-1, 0, w, 0, -1, h, 0, 0, 1), - SKEncodedOrigin.BottomLeft => new SKMatrix(1, 0, 0, 0, -1, h, 0, 0, 1), - SKEncodedOrigin.LeftTop => new SKMatrix(0, 1, 0, 1, 0, 0, 0, 0, 1), - SKEncodedOrigin.RightTop => new SKMatrix(0, -1, h, 1, 0, 0, 0, 0, 1), - SKEncodedOrigin.RightBottom => new SKMatrix(0, -1, h, -1, 0, w, 0, 0, 1), - SKEncodedOrigin.LeftBottom => new SKMatrix(0, 1, 0, -1, 0, w, 0, 0, 1), - _ => SKMatrix.Identity, - }; - - /// Baseline JPEG down the fixed quality ladder until it fits the byte budget, or null - /// when even the last rung does not: bytes over the budget the head declares are never served — - /// that would recreate the empty card this exists to prevent — so the caller falls back to the - /// drawn card instead. - private static byte[]? EncodeShareJpeg(SKImage image) - { - foreach (var quality in JpegQualityLadder) - { - using var data = image.Encode(SKEncodedImageFormat.Jpeg, quality); - if (data.Size <= MaxShareImageBytes) - return data.ToArray(); - } - return null; - } - - /// - /// Renders the card from its parts. Kept for callers compiled against the four-argument shape; - /// new callers pass an , which is the only way to reach the icon, - /// the price chip and the path. - /// - /// The headline — the node's name. - /// The supporting line; may be null or empty. - /// Small label above the title (category or node type); may be null. - /// Stable string (the node path) the accent colour is derived from. - public byte[] Render(string title, string? description, string? eyebrow, string accentSeed) => - Render(new OgCardContent - { - Title = title, - Description = description, - Eyebrow = eyebrow, - AccentSeed = accentSeed, - }); - - /// - /// The card for a page that is no public node — the home page, a route the resolver does not - /// know, a node the anonymous gate withholds. It says only what is already public: the - /// instance's name and host, under the MeshWeaver mark. Nothing about the node the request - /// named reaches it. - /// - /// The host the page was served from, printed as the description. - public byte[] RenderSite(string? host) => - Render(new OgCardContent - { - Title = siteName, - Description = string.IsNullOrWhiteSpace(host) ? null : host.Trim(), - AccentSeed = siteName, - IconSvg = MarkTile(markColor), - }); - - /// - /// The picture the instance card carries: the MeshWeaver mark — three nodes joined through a - /// centre — in cyan on its navy tile, self-plated so it rasterises the same on every card. - /// Before 2026-09-22 the instance card wore the default badge, the site name's initial on an - /// accent tile, which is what an unbranded page gets; a share of the home page should show - /// the brand, not an "M". Same drawing as the portal's MeshWeaverLogo and - /// wwwroot/favicon.svg; the single source is Systemorph/Memex - /// Memex.Website/site/icon.svg. - /// - /// Edge of the footer mark, in card pixels — the height of the footer's type. - private const float FooterMarkSize = 30f; - - /// The bare mark in the given colour, no plate — the footer's. - internal static string MarkGlyph(string color) => MeshWeaverMarkGlyph.Replace(BrandCyan, color, StringComparison.Ordinal); - - /// The plated tile with the mark in the given colour — the instance card's. - internal static string MarkTile(string color) => MeshWeaverMarkTile.Replace(BrandCyan, color, StringComparison.Ordinal); - - /// The bare mark in cyan, no plate — the footer's, beside the instance name. - internal const string MeshWeaverMarkGlyph = - "" + - "" + - "" + - "" + - ""; - - internal const string MeshWeaverMarkTile = - "" + - "" + - "" + - "" + - "" + - ""; - - private void Draw(SKCanvas canvas, OgCardContent card, SKColor accent) - { - canvas.Clear(new SKColor(0x0B, 0x11, 0x20)); - - // Ground: a dark vertical gradient, then two accent glows — a wide one bottom-left so the - // card reads as lit rather than flat-filled, and a fainter one behind the icon so the mark - // sits on a halo instead of floating on black. - using (var bg = new SKPaint()) - { - bg.Shader = SKShader.CreateLinearGradient( - new SKPoint(0, 0), new SKPoint(0, Height), - [new SKColor(0x0F, 0x17, 0x2A), new SKColor(0x08, 0x0C, 0x18)], - null, SKShaderTileMode.Clamp); - canvas.DrawRect(new SKRect(0, 0, Width, Height), bg); - } - using (var glow = new SKPaint()) - { - glow.Shader = SKShader.CreateRadialGradient( - new SKPoint(Margin, Height), Height * 1.1f, - [accent.WithAlpha(0x4E), accent.WithAlpha(0x00)], - null, SKShaderTileMode.Clamp); - canvas.DrawRect(new SKRect(0, 0, Width, Height), glow); - } - using (var halo = new SKPaint()) - { - halo.Shader = SKShader.CreateRadialGradient( - new SKPoint(IconLeft + (IconSize / 2f), ContentCentreY), IconSize * 1.25f, - [accent.WithAlpha(0x30), accent.WithAlpha(0x00)], - null, SKShaderTileMode.Clamp); - canvas.DrawRect(new SKRect(0, 0, Width, Height), halo); - } - - // The accent rule: the one hard edge on the card, and the thing that makes a row of - // shared links read as one family. - using (var rule = new SKPaint { Color = accent, IsAntialias = true }) - canvas.DrawRect(new SKRect(0, 0, Width, 10), rule); - - DrawIcon(canvas, card, accent); - DrawText(canvas, card, accent); - DrawFooter(canvas, card); - } - - // ── The picture ──────────────────────────────────────────────────────────────────────── - - /// - /// The node's own mark, or the default badge when it has none this can draw. Either way a - /// picture — the whole point of the card over a text link. - /// - private void DrawIcon(SKCanvas canvas, OgCardContent card, SKColor accent) - { - var box = new SKRect(IconLeft, ContentCentreY - (IconSize / 2f), IconLeft + IconSize, ContentCentreY + (IconSize / 2f)); - - // A soft shadow under whatever is drawn, so the mark reads as a tile lying on the card. - using (var shadow = new SKPaint { IsAntialias = true, Color = new SKColor(0, 0, 0, 0x66) }) - { - shadow.MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 18); - canvas.DrawRoundRect(new SKRect(box.Left + 6, box.Top + 14, box.Right + 6, box.Bottom + 14), 56, 56, shadow); - } - - if (TryDrawSvg(canvas, card.IconSvg, box)) - return; - DrawDefaultBadge(canvas, card, accent, box); - } - - /// - /// Draws inline svg scaled to fit , centred, through the SAME rasterizer - /// the favicon route uses — so a mark that parses but paints nothing (an empty root) is - /// refused here too, instead of leaving the icon square blank behind a picture that "exists". - /// False when there is no svg or it cannot be drawn — an authored icon that fails here is a - /// content defect, but the card is the wrong place to surface it; the caller draws the badge. - /// - private static bool TryDrawSvg(SKCanvas canvas, string? svg, SKRect box) - { - if (string.IsNullOrWhiteSpace(svg)) - return false; - try - { - // Rendered at twice the box and downsampled, so curved marks keep their edges. - using var image = IconRasterizer.RenderImage(svg, IconSize * 2); - if (image is null) - return false; - using var paint = new SKPaint { IsAntialias = true }; - canvas.DrawImage(image, box, new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear), paint); - return true; - } - catch (Exception) - { - return false; - } - } - - /// - /// The default picture: a rounded tile in the card's accent carrying the page's initial — the - /// same idea as the per-instance favicon badge, at share-card scale. Drawn from the title so - /// two icon-less pages still share with two different pictures. - /// - private void DrawDefaultBadge(SKCanvas canvas, OgCardContent card, SKColor accent, SKRect box) - { - using (var tile = new SKPaint { IsAntialias = true }) - { - tile.Shader = SKShader.CreateLinearGradient( - new SKPoint(box.Left, box.Top), new SKPoint(box.Right, box.Bottom), - [accent, Darken(accent, 0.62f)], - null, SKShaderTileMode.Clamp); - canvas.DrawRoundRect(box, 56, 56, tile); - } - // A thin lighter rim so the tile has an edge against the halo behind it. - using (var rim = new SKPaint { IsAntialias = true, Style = SKPaintStyle.Stroke, StrokeWidth = 2, Color = Paper.WithAlpha(0x40) }) - canvas.DrawRoundRect(box, 56, 56, rim); - - if ((Initial(card.Title) ?? Initial(siteName)) is not { } letter) - return; - using var font = new SKFont(typeface, 150) { Embolden = true }; - using var ink = new SKPaint { IsAntialias = true, Color = Paper }; - var metrics = font.Metrics; - var baseline = box.MidY - ((metrics.Ascent + metrics.Descent) / 2f); - canvas.DrawText(letter, box.MidX, baseline, SKTextAlign.Center, font, ink); - } - - /// The first letter or digit of , upper-cased; null when it - /// opens with nothing the embedded font can be trusted to draw (an emoji, punctuation). - private static string? Initial(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - return null; - foreach (var rune in text.Trim().EnumerateRunes()) - { - if (Rune.IsLetterOrDigit(rune) && rune.IsBmp) - return Rune.ToUpperInvariant(rune).ToString(); - return null; - } - return null; - } - - private static SKColor Darken(SKColor colour, float factor) => - new((byte)(colour.Red * factor), (byte)(colour.Green * factor), (byte)(colour.Blue * factor), colour.Alpha); - - // ── The words ────────────────────────────────────────────────────────────────────────── - - private void DrawText(SKCanvas canvas, OgCardContent card, SKColor accent) - { - using var ink = new SKPaint { IsAntialias = true }; - var textWidth = (float)(IconLeft - IconGap - Margin); - - // Lay the block out BEFORE drawing any of it, so the whole thing can be centred beside the - // icon. Top-anchoring looks composed only when a description happens to be long; a bare - // title (no description authored — the common case for a Space) left a dead third of the - // card empty and read as unfinished. - var eyebrow = OneLine(card.Eyebrow); - var hasEyebrow = eyebrow.Length > 0; - const float EyebrowBlock = 66f; - const float DescSize = 28f; - const float DescLeading = 40f; - const float DescGap = 30f; - using var descFont = new SKFont(typeface, DescSize); - var description = OneLine(card.Description); - var price = OneLine(card.Price); - const float ChipHeight = 50f; - const float ChipGap = 30f; - - var areaTop = (float)Margin; - var areaBottom = Height - Margin - 30f; // above the footer line - var fixedHeight = (hasEyebrow ? EyebrowBlock : 0) + (price.Length > 0 ? ChipGap + ChipHeight : 0); - - // The band is a budget, not a suggestion: a long title, a long description and a price - // chip together overflowed into the footer (the first course card drawn). The title gets - // up to three lines; if that leaves no room for at least two lines of description, the - // title is refitted to two lines (ellipsized) — a card that names the page and says what - // it is beats one that finishes the name and says nothing. - var titleText = OneLine(card.Title); - var titleLines = FitLines(titleText, 68, 44, 3, textWidth, out var titleSize); - var titleLeading = titleSize * 1.14f; - var descRoom = areaBottom - areaTop - fixedHeight - (titleLines.Count * titleLeading) - DescGap; - if (description.Length > 0 && descRoom < 2 * DescLeading && titleLines.Count > 2) - { - titleLines = FitLines(titleText, 68, 44, 2, textWidth, out titleSize); - titleLeading = titleSize * 1.14f; - descRoom = areaBottom - areaTop - fixedHeight - (titleLines.Count * titleLeading) - DescGap; - } - var descMaxLines = Math.Clamp((int)Math.Floor(descRoom / DescLeading), 0, 3); - var descLines = description.Length == 0 || descMaxLines == 0 - ? [] - : Wrap(description, descFont, textWidth, descMaxLines); - - var blockHeight = fixedHeight - + (titleLines.Count * titleLeading) - + (descLines.Count > 0 ? DescGap + (descLines.Count * DescLeading) : 0); - - var y = areaTop + Math.Max(0, ((areaBottom - areaTop) - blockHeight) / 2f); - - if (hasEyebrow) - { - using var eyebrowFont = new SKFont(typeface, 23); - ink.Color = accent; - y += 24; - canvas.DrawText(Spaced(eyebrow.ToUpperInvariant()), Margin, y, SKTextAlign.Left, eyebrowFont, ink); - y += EyebrowBlock - 24; - } - - using (var titleFont = new SKFont(typeface, titleSize) { Embolden = true }) - { - ink.Color = Paper; - foreach (var line in titleLines) - { - y += titleLeading; - canvas.DrawText(line, Margin, y, SKTextAlign.Left, titleFont, ink); - } - } - - if (descLines.Count > 0) - { - ink.Color = Muted; - y += DescGap; - foreach (var line in descLines) - { - y += DescLeading; - canvas.DrawText(line, Margin, y, SKTextAlign.Left, descFont, ink); - } - } - - if (price.Length > 0) - { - y += ChipGap; - DrawChip(canvas, price, Margin, y, ChipHeight, accent); - } - } - - /// A pill in the accent — the price, the one number worth putting on the picture. - private void DrawChip(SKCanvas canvas, string text, float left, float top, float height, SKColor accent) - { - using var font = new SKFont(typeface, 25) { Embolden = true }; - const float PadX = 22f; - var width = font.MeasureText(text) + (PadX * 2); - var rect = new SKRect(left, top, left + width, top + height); - - using (var fill = new SKPaint { IsAntialias = true, Color = accent.WithAlpha(0x26) }) - canvas.DrawRoundRect(rect, height / 2, height / 2, fill); - using (var stroke = new SKPaint { IsAntialias = true, Style = SKPaintStyle.Stroke, StrokeWidth = 2, Color = accent }) - canvas.DrawRoundRect(rect, height / 2, height / 2, stroke); - - using var ink = new SKPaint { IsAntialias = true, Color = accent }; - var metrics = font.Metrics; - var baseline = rect.MidY - ((metrics.Ascent + metrics.Descent) / 2f); - canvas.DrawText(text, left + PadX, baseline, SKTextAlign.Left, font, ink); - } - - /// Footer: the instance on the left, the path on the right — so a card lifted into a - /// feed still says where it came from and where on it. - private void DrawFooter(SKCanvas canvas, OgCardContent card) - { - var baseline = Height - Margin + 10f; - using var ink = new SKPaint { IsAntialias = true, Color = Faint }; - using var footFont = new SKFont(typeface, 24); - // The mark before the instance name, so every card — whatever the node's own icon on the - // right — is recognisably ours in a feed (maintainer, 2026-09-23: "improve also og cards - // with this logo"). Drawn through the same rasterizer as the icon square; if that ever - // refuses the svg the name simply starts at the margin, as it did before. - float textLeft = Margin; - using (var mark = IconRasterizer.RenderImage(MarkGlyph(markColor), (int)(FooterMarkSize * 2))) - { - if (mark is not null) - { - var top = baseline - (FooterMarkSize * 0.78f); - using var paint = new SKPaint { IsAntialias = true }; - canvas.DrawImage(mark, new SKRect(Margin, top, Margin + FooterMarkSize, top + FooterMarkSize), - new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear), paint); - textLeft = Margin + FooterMarkSize + 12f; - } - } - canvas.DrawText(siteName, textLeft, baseline, SKTextAlign.Left, footFont, ink); - - var path = OneLine(card.Path); - if (path.Length == 0) - return; - var crumbs = path.Replace("/", " › "); - var room = Width - (Margin * 2) - FooterMarkSize - 12f - footFont.MeasureText(siteName) - 60; - using var pathFont = new SKFont(typeface, 22); - var shown = Wrap(crumbs, pathFont, room, 1); - if (shown.Count > 0) - canvas.DrawText(shown[0], Width - Margin, baseline, SKTextAlign.Right, pathFont, ink); - } - - // ── Layout helpers ───────────────────────────────────────────────────────────────────── - - /// Collapses any whitespace run (a markdown paragraph break, a tab) to one space. - private static string OneLine(string? text) => - string.IsNullOrWhiteSpace(text) - ? "" - : string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); - - /// - /// The largest size in [, ] at which - /// wraps into at most lines. - /// - private List FitLines( - string text, float max, float min, int maxLines, float width, out float size) - { - for (size = max; size > min; size -= 3) - { - using var probe = new SKFont(typeface, size) { Embolden = true }; - var lines = Wrap(text, probe, width, maxLines + 1); - if (lines.Count <= maxLines) - return lines; - } - size = min; - using var floorFont = new SKFont(typeface, size) { Embolden = true }; - return Wrap(text, floorFont, width, maxLines); - } - - /// - /// Greedy word wrap to , capped at ; the - /// last line is ellipsized when text remains. A single word longer than the line is broken - /// mid-word rather than overflowing the card. - /// - private static List Wrap(string text, SKFont font, float width, int maxLines) - { - var lines = new List(); - var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); - var line = new StringBuilder(); - - foreach (var word in words) - { - var candidate = line.Length == 0 ? word : $"{line} {word}"; - if (font.MeasureText(candidate) <= width) - { - line.Clear().Append(candidate); - continue; - } - if (line.Length > 0) - { - lines.Add(line.ToString()); - line.Clear(); - if (lines.Count == maxLines) - return Ellipsize(lines, font, width); - } - // A word that cannot fit alone is chopped to what does. - if (font.MeasureText(word) > width) - { - var cut = word; - while (cut.Length > 1 && font.MeasureText(cut) > width) - cut = cut[..^1]; - lines.Add(cut); - if (lines.Count == maxLines) - return Ellipsize(lines, font, width); - continue; - } - line.Append(word); - } - - if (line.Length > 0 && lines.Count < maxLines) - lines.Add(line.ToString()); - else if (line.Length > 0) - return Ellipsize(lines, font, width); - return lines; - } - - private static List Ellipsize(List lines, SKFont font, float width) - { - if (lines.Count == 0) - return lines; - var last = lines[^1]; - while (last.Length > 1 && font.MeasureText(last + "…") > width) - last = last[..^1]; - lines[^1] = last.TrimEnd() + "…"; - return lines; - } - - /// Letter-spacing for the small-caps eyebrow — Skia has no tracking property. - private static string Spaced(string text) => string.Join(" ", text.ToCharArray()); - - /// - /// A stable accent per node: the same page always shares in the same colour, and different - /// pages differ. Hues are drawn from a fixed set so every card stays on-brand instead of - /// landing on whatever an unconstrained hash produces (mud, or neon). - /// - internal static SKColor AccentFor(string seed) - { - SKColor[] palette = - [ - new(0x38, 0xBD, 0xF8), // sky - new(0x2D, 0xD4, 0xBF), // teal - new(0xA7, 0x8B, 0xFA), // violet - new(0xF5, 0x9E, 0x0B), // amber - new(0xF4, 0x72, 0xB6), // pink - new(0x4A, 0xDE, 0x80), // green - ]; - var hash = 17; - foreach (var c in seed ?? "") - hash = unchecked((hash * 31) + c); - return palette[Math.Abs(hash) % palette.Length]; - } - - /// - public void Dispose() => typeface.Dispose(); -} diff --git a/memex/Memex.Portal.Shared/Seo/OpenSans-Regular.ttf b/memex/Memex.Portal.Shared/Seo/OpenSans-Regular.ttf deleted file mode 100644 index 2cc82d2ed0137dafa2869af5828b452de49a6aa3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 107280 zcmaf62Vj&%_W$N<+k49TXga9Gg6hbizgaDyM2*@HLU5ZEtkwXNGfPhFZ0s>;h z$RT1Va)<>L5fu>|qH>DmJpFU(d8a~l`2S|U-4xXSpBR(uH~VJh&70TXTY(6I;KcuA zA+2ms@1AG6tr#Sbd+G$C)$pEWJ<3ggF~28}x%XpKyPlN;1_^$_jKA?*u=gA^xZCAt zvybBMR|G-4GGI_{{;r}Orv=ip63<7DnKEkH{=fa_8vZ^Y2-31Kb7rO4`X%fU$e>^G zeE0ZicTBl=)M1A}`g9NkUELj{W=<14LJ;s~G5B}fIdA-|4qN&N0%;?V+`0+lMvWDZ zjhQBp4ny(0XaZiaylI#T_-*ia%L!9v-TjZk&Y$A>!-8Pldgs(Jqq5zf{3wt-Jk|}F zGV1PWN_WvHkoJGa3u$+anlkRzwfCh8q-q=1_wKZ*GiNP4YM3gJq0ZOCf+Xk!&kyJzj z=S7z)hp$#lkiW*eH($R@mh*R8g`BWkG8jZzw%G|Wi)M8|#OxI%p|mu|;S@>|ave@m z;s}KtfJuTCx*^`es{;xa7jTKStkdt)-5aV7VcS!LlCN^8u37E zg{fhSAn9aD)*Dn&Cjofqt$YR-fH_<#Cw1Y<&4Krfpu z=Khfu#F8r59rpeazsE7aLIya9#bI$MK9^)vgxpYR$XQa7n|s@B*lvzPV0(`Jz7b??#g7TUE=e=(&N*D(O{{ zT1LMkIRjS=BzaX2R1tmsS0uL^Jtr-p`xeoGWIy{}M5-2&I@*_gFQofO70!Ig_2sfz zbqZ-hrqEUxA1=&tWF-4!xwYG(1O&maI1Ea=+zg9q=@*gIme$tRzLDhCIW62R`bB)L z-9V08@nXlhe80|;e70K$-)rpuPJ%tM{c|K}hvZguo?u~mW>%rs8**f378Vp07lu4u zpDr`Yks|5}@S}*hy*`Jk%H}tZESmOi_dypTZ#{nIiA6`BD%h}f`?iYO$bA>0pAMTk zW;}Um*)zU>TneUc%g7~1yY5@IWUupZozi1TCll?TfBXD#y&`Ss{1i#ocX&2gV!urg z6k)>k@71=-8NqH%@l?_3xGQ%3Fd7J1i7#4!3uCg=96@ zI;UMu+n_bmpX@ah6^G2dBO#Y9tF5)Yk}LT$rPi&}a-C_0ib$F)l;#Kt*l4b=1Z2wY zaI>AV<0*>m$(3JBtfI&5&A>sn6SYm_X6r~1<`8uxn5FV-zN6PIi^knCBr^4pA0MTC zrVelO2z_ov-QX@QUwL}>v1QxIrVibGd&`!SoUfjn^Yg|J|3_ZeW5Liq3kUTZHS*f_ ztz>UmWPI1z%dakeef;gCCzWj6`^1LHhi;?uZhB%2{bD`+^x&l7?}4o`jzLN}$8aAJ zBuQ38#wp&?_z$~YF$SUW2KJ7D^tc$nJ1jzruvsS?(fr8Toh#O_d3ZMiOMjys_Z}x_e)yTZ{nFDk4^W4Q3uUY77OX=1 zaFV3RL}xJ?Oa_}>HWG_yHd~C~UwW$`l)jSRK%4CF*v>P2iVJ)*%au{ANaC6t@=zjO z^6S0>I}iLsmnM^?IjWn^o_aWy9veX>(TyWWdFtV*WCh#J1mUurEuREMwF|AmVZj+buzBF7n2fL`jxJn~)YWpuH7tll zR!t&p?o!gRYqye6_etG*^yuET9Fm9#ZitYJoUU9)L_t<0Np!}K63E4E7B5CG)i6rp z*H9G3U;kci%Wt6%SaUlCvnr_m1cRqf#GsRGeIk-yyJvh6aUo_GgXyBf?hNHS3ID%? zzaqE&*MGnN@0Fjg{8In<#yvF~Hq`9dD1J(->4Rh;nL+L)Gw8i^Eq#f8O0q~Nd}Yu} zSoLwN`V3ZW6k3Mu27%~_$t)XmI$}^s0+x>}?t~cPG88N<5Ofq2tEw)Ggv2wu^`3(F zhLR;KW#{5qo_70YkQ_i93n`ib?&*ij_azXWOK@3gqK@2!uG9s0(VxBt2A1Nx%)!)mhR;O2)1 z&0gMl!1TT653Zm;yhBgv*-4B5UX!qbETMb2Ws>01=>*A}Zc$rhOFpm9+b81l88b3c z`b08xMn|8Bu@TEm5y7eiwqxyf;N+M<@?tr1ASRU39QHu4uz_h~xoo}CmF*87sHLCL z|IB*n*4sZEN#@fL53had^>z1-tesLd?7PM9e=m<%c`!xqty_EU^I)4Dxp^d;7*}t4 zVDdc$<dDRd5}I2D5^nskyNDU!=g6sOWRVzB9)PD!Uq4d)ZT zS!~Hnl4?p6zgq-<1aM|S@LPG(g-4?$;^6}q=rW_eeQR1wD(L}Ixkmc5KAU{Kx^84? zbPn)W4g4j8CK82?;Z&RCckA6gNp6uO09k^ddOSEokE)vcMAYA*t`Tkdvd1k1)43Rj ze6t7Aby+T^x)h0Rzaa*CiT<*<{GD+JPSWMKKRTpX{4jbXW2Us=t5cUL9k8=)sAfCK zPc9bsZK1t=3^TK_s$86^S7<4eg@cI>Qvgbc>X0%s%qDAczlh0dbxUqvpNQKp>7kK8 z9LK1P9cu|!KH|s9#j#xoTC~;w|LS}hqlL4gH^sfEJeD|x_eAks% zFaP%5W%^(8-9y{gt{Jv5Qn^l?PM#)DxmNq>Mf%#lD{p>DuaUv8Kl|jGnu^8ccO0C+ z=n-d{1AMANYS>N`MG(OlC9yA*CV`z{{2t&I5O6~eA<5#1`peQ=(OM;WOZC;W;9$^6 zO*7BNeV`>llqQL0njn!L=?au9!eRlXQj+O{R;zUjOF<>ke+DpZ>F+?YZ+{@4%$FHsIe`wmH&tI##`+=4BjM~&Q z{D^eN-D8K}->vh5!^h2KXFCbIV?4trWC&fu=}C@sqY+5M*=AW&aI$WzRdl-(aI`v6 z*DoSACp#z3w&}qV&>F}N4AK?kszqS|xTV*rZ36d}X(pL+Ms&igvdZy4{KsT2K0M>) zFR#C|;nLmIy?Wcj>u%jLbl^IvyuQZ0IuVyNH0YLpyz?c|Z=n}S`=fgve!Swo@`(q> zvz2qnA&=#n6nMBD*Fq4yKC5d$#A}k($xu(gwPU9%VS0 zOJ0)w(q&MsPG}wW=tLPW8uYSK8G+$c8L<%pNqieFCu6n^<{1;o1mAO|-Ss1+-O{k- z%jq43W0dl=~bz5MJ~dZG(ob}|A-}(lI5hF%-%o)qvbBgs+^`d?_=h z(Wr!x#RWk8I)D(kFmUsGkQKiUjNADnkDJRdv1Kq_7Cnp`7JJlSngHdZWguU65|IW1G16` zACdt_uKoAqS*Nwv;Kl323lDw2c*4!MjDG9Do7eqb+S+^Y!+nQ{ecf~P$P*l8ix(wM?8u zULCSv%J$)1Y)>&zM*G74w>y&Y~KPL>BY}{utHJI|A_~ic0T@C=uLn(zt+_y4Rqy zutmTo;os7)_2;DA=zMWRbhlWo9Nt8;H%7m1GLu2b4=2J_Ad;R)M$-)wG1WiDLl`l} zl}YR_HUpVi8wU^I(Qh}!PEP5<`4o2H*84@#nxvOoBnwHsp?N80v!i#!EGxd=5vB1E zZ`=h5z%#{PT@&uN|TPq3@9ZN!YxGJ}1&> zNk%4FLB@VbZaFk$V-1UPQfOXo{0Y)e8#^5*(TGF z)YgidPDks+SC>Uk0wp=(`_bwq6sJIcv_cTe(B)wfV7W0;X3OR4W$ZrSIbF*L_Ue~g zwfCx6sNe#%Cg|a@gq944o!di<4oQV&VHM!vD-AKPB#1OFjWw|mAVIH(nGslF2tQ#) z5UG;d_15h~AZPXDJgv3rsadgaeXo9ma`;+>4BJ-Pzx%}1pJLR)JS}#iUD#uQpd>aM z7`ek~l5{q`M09aV`Q5z1*um89AuS|o^LBMdBz+?Y!YE3IA=yv)FV|rvHDTB2<`r6Fs zkN^Jlm!JL}g;l+a-i13dfs7-|=`^~Nen{UU`J^>0>pc3F#sy_YUk02|K{(L}yaUgI z%WVV=8;zB>TtefAn!uKETY&jA60=n+i5F(ll4xPzp z)FDRT^%*R~CPEIm0UrMM6u9}$Wj8-|$IKE_$UBUV@;%Eb(C2I&~pdXV} zr|AxQ_S?F>Pd)#sI5N6ZIsEon`q}vCRB`0m)vMRs2byJ;hzJhcl3_2!1!X?LrN|kX z<`l{61sC?p5+q6!QW9`zarVQU1#&PgZt}v&$l{h1m!?pom_h0DdS#LR;|E&5a@czl zYWHA8=O6drnN>E;gA%fJ3Bseb9B?t3!ZESlMO)WCw?ukU*D^~&{qXWh|v z)XjMV;jkf~DnT9w{ORDeaSB8slB9>rNCr=kB_`mSvPH&en3v5H5LeXFJh>OmQ&Kl^ zd2LT^G$`7Dut0-4xCaVPAnSDt`Jd{S{xAYF~f( zx!0A$_5H8XFGyOww71I3OAf zoJ>M2!h#huZpQg64g%Z<&|M6AY!X*Ud37(n;3**9_VkOrbbj48?Y&8bM6DyP_5oMn z*LBkQuCKZ8-(DZWw&tDKdgN(oUj4#HPCoReRLxESqDkI}b(n-;*eRDeI!-2&yAIJ#;*#hBv0gkDeO~My?aSaZtjz~} zy^t2R!B4H|Bm<-h(Me3k#xTb;3*ZhwLH_W{bs-NNr3w3}|Co4DyjZ^|dRoktmIB&9 zKx5q4!2EJhS#*LlEBq8<|htcm_T=03$cLi9MPkIyU#1m07QBh90*~` zOPIR&uySeDZ%lg=^297TsJsOBoDeo3eyCSqUW%|S8!6KT?BeM1$iXq>)6H}xJt$^L zTkFS*-$s4VChF;{($?#}uyV&CW-SxEJA|{~2ZL!bbhNaCzPdypWVw7na;gEGg6ps# zn~dsxFohH29&Hi?r51+3B_!uvT@SW8lQyG`e4*gQQcsUVg^(-oY)Ws*!x-fYJ;NF4 zdQ(b@-|y5*(Ah*&O3#RBLP*DBE02H+CYDDOFBmMCf#!esV>ehJZWY=yin;$a=Fjvv zGH}5QnRV;-N&^G>fsftu*mI)m*wlNL|21^;OQT+So?0JyrslP$r);~U_ufZHU%Ohi zXnxhgHu?KsjJjvnZW*J~O_@1t1lD~3Ty-|05-vnm%fjhKpU-A9Crf52EnpGM9;d_T zz~N!Rs#kEAN1!n@t{L>5&?L?hqojN%cY9}M1$9iLbn8MzKF(k@zAx_o=wCm6F!`Cz z=HT2Ky?)l4wHvqAZrQj|9!4+FpYZkmfPt$J&3ItpxIHUQe)G+#OXog#mr=t^V0eW* zT<{98ootXk1}VX(TCg&^;Ka&UNT7-9I@>F;W`6bI zzkc}O(p-y9u31WV)NXmCcI_iu*F8Zpi49+Eb`R)BPW<-$+~?j7etYWj*>^vPt#(*kz@gv?7Q~kPh_6&nCX~dd2$8fb`YA&D)<~ zr59TW05sEE_S5_@qZo z^MIMwLb1?QxGQ{9j=w{OIkk(D=OQjeY@OaBHN$W0*1d(T&{kL;(Rb<@F{bNV+w?ZQ zw{>f=XQZ{QRcX&iE4#N{&xkiEMq}|?3-2{tZzUx;+@NgGHrP$zEXEUe-Da_?8sf1N z7OY_IDlL8<*FqX&&Db6fM;8|0H#zT2i#DIUn3prDV%W}2U7P5t zEbKh2b#U*Ubtm1|gvjs(lOsLulseCx^ZhUP;+zgZ29<%%y#nkvo8ixI6ZX1HCW~J0 zPxRV*MZ95yO#t;lYvG(DvDqZ9CI+Ux>1;W-ck0o-fA7W_soTHQJ@^*6{+gX0*WDJM zT*GNHaB3Hdpzm9aMw3ae%8FigIHA4T?RveWGkGMNrZMD#;#trdY6E`bXeC@Df`HSk zQMZm%3G#$f2tUHNbEex5X>zJ*e2K% zloWwuk_mlAn+%Brh{G5@T0MhX5){hAL65`X#PJ6W&Y;vXqlMSwar$M8ADlMTZ+F>X zIWal_A;f}PxCFSWaZOvJ{3Zm^NEyBm!f|Q*|BO1+jA?ToSzj}4?zS~GOOy1uPfa3Z zfIjco++)v)r z4R$+-)owT1{)||g(`v(77^Uvp$(9j1I{mfR8KoZkfX{n#gioqTg3*|hQ4;)TF2Mpl zEzxiEf{)oHdo$+W)QUyiMl57LJfZQ9<6@7dKV4b(YNSkYx`t;%2yFUJ( zAFu4)C~mENXxA?HfPo{2&@O7h#$lE81Nv_!w57|(&SZRZ>gzY&y2#`aR$9z)rD+xR zWUrK%=(qdR0*OgJTXIT@*Wz;N!1?VKp*&*wzr-fzq!5yt3dDn54HC3C=KD~^{xzGG zZBMR!WXpp0uKe)9=kp8+OKMD(nR5@G&-nU{%V*DCSOF)13D#!2+Ks=xN!}k@{)9$H zQWn-}M>NE3F&KHn&}&H8k3; zS+QK5m@L#xS83*#Z-0ExfJ0S_S5DovWl`zJC!Tq-)8h}!xx4MyhfX3jXG=|wt*zP( z8XUg$rjo&T_IcouUQ5d=y0*EgLt)Q{vBuQv--~;da`1oVn{d04nc|e>1fS7mclC@| z!*-hv2c?T0RN`CBoO=j7;e4PgK+9r+x;W%vzErnYbha6okTkJ1eR=!#@{!~w`tpd` z7TrRNgA5Q?R`&QOT@;-^W)i#6d!T2P$h~l*3&MWl(wWUhm(k-jTP$|H8#yw#)m{^m z1Wd0v308)Y7|jUX5gS*|Lvi~TUN)Ggp(3XXHsVe?^5FNCR=sKVn+NGg0G;z? zSt02sw!g;gY1m3?5YSpc+PDo#$tgas)dH(s)+ah4ZuE?Gf_Z9IFiQ!DS;7c^AQ6Ct zLYz)UQ^hWA$|6iD=bm;I=v$?neUZMuf6`RF-jwG&UH3`{w_Xmuu#cV-mvlP!^zG4w z@E?q!m3>MM6^gT?EB4K9xl#NCi^8h1utz=LV|v)3Q&i|(ERlxjO&%GWWlLf!(#+wQ zCCu!qAUR5xJWm27?Qe7mS#zAeMc+6s=7~N!oa~8y8$C;o(=xypao<(I_b{&qwkdgi zG8{!v%PmQINcGbE2Cab^+)efdn$ZMpaj|&thXF3VWzg3LX?FSFmi4VDE_6?YT!B)#ewGBFk-NX$ofiz0KS+qS%POXM{<+rUn5QdJQ(SG^PG4_@K{< z-$W0Jw@sIZELv253~*WQ7u)KYSat04(Y3mbqpY4R=uA$SjT zIi?2{A`t0-dg4(%uzoyV54kvd`iXb$y?gPjj}Cu*>5_T;aB-zryOrclh^!Kak0kkz z>|3F}L@#`pWzPB#vib_Oi}T~M8-6xe+;FUb_T#dfxqQi$RWOr&smGdoVa+U4C>b>8 z@hC>86Jhw|WUJM$NGYkFEW~5^d$l!lC5qn}w%n{jCW_hWp;g0^u8Xa{STtU`eDwUB z#rNF%)`_XJ?wT%^W_?JqUQ$==-$LIWb*s240r%hE6TvmnL9KoLOO71IK_yLMNlEGwM$Chr9H!? zy~5bl3GnZWJoa-_IK`{CNbuT1eUtT4i)6ndx{MYPU$RLjBl{tqV+z7(REpwM!V_qj zI*`HgW|?8ij2Uj2N@pgnt3LA*S-G^&LwR}CQ}*qC^odmu{ZOR7{&pI1TqX54?b{;- zd@DoeFT8iA6IO-PHn~hWjZ^Ox&P;F_ES5yCLo|6^N=m9Z!Jp7SV(|Ns;$`m}aU}=} z5`>y3oHR>K(x|vmvVncGC@6ju!qb41DiE|-d!n|>*46VDkQzFyYlW0_?b=(fz4mwI zbX9e~zpSJe7JfQ*S(~k^a(}+GkaRu>-YN?7i7NZ#ozSqe;DfTX5YiEjZIzws@nq=W zi9wz^=YQJlu7cvMVjs@JSM1Xv<3;DwWijrc%PP(+j%y{r*U^flBbH8Bym0*T+aIW? zSTcP1_=OLQT{^tFV$t?lvv%y9F>AYc{+`JzhgbLRT|NB4>2sFcfrmZu`?AS@nYQ!M z=`(ikVYHeG{IU3pR~Qm*=d|ifCc7CqT?WbQwt95F1f$iev#H4E5?mgeE7c|1TsD`P zcvLgX3}XBenc^`1c>XuDRXO`)TZx@E7acuD-Qo-V9iNPi|~{E$RJY{CtgPuVWQB8S?CzzGumHMYQ3qOk_d%)GVS z4C5Sv@re3gsipoGb{f}zl{Tu$$eQkg{JYG2!KJoN>EQRLAiFWB-c;(5Iu*5Os|E~# zte%m8$6)UnF@WvFn5Y2u(Ok)Q#jRL&gPD(o(=zTdBONVesZnXkgw}#n^!b=>nWd^L zhV-wfI{WH~M@JQoUfOrotod7yZ5&v!<)80;d|&^U2R^W>{hc!(dZ63d`=7{LzV5lQ z!BX~+VyMN*G%;I|XNgA07t-NU(VsLKCABInm69)eOwS!i>z~ z;>?UfvY;>{qqsODqfnV%(5_uUeqLUF>?iX!j75HWnHJv{ieO7+W%(pqZfdIAVDl-( z9gu^Ak|7J)k1K!qoljr5Z%)TM>o>hlM!dnkU#7d?IZJn+ zoVcH~-M62#etJLs@W6ih!P9bZ|JJ>`+y2Gf;>VB9{kQX+5IxG@LwCRNDy@0vEE)FN zU+MY3?kCv?VsB&jyj*gN)0I=WOQ`~)!#*|1NQjr|wP}GCMx)d3MFN2&N)~4-w));} zui*OdyWb#2LALC&bI%Ag{UA~jIsP3PNOj@9PSKq(eC6#^w?005{n!aPGpZLar`E zMMbK(S<$o1<0eaqu_EFl@M&deKL(N^bD2wgF0DoEZ0KzIIZ2&O_lj9$QxCeE?&?W4 zB{lxatC0A(H}|mgTnRe@!V)p zg;fF;)j(5vU^f#~bz3-;&)Zz|iJ4RbwR;S)&bEY~)@O_u)bU9sr zN@45(8hIAmx5hlU@fQrD5JKk)N<7qwQi%VD*u?X9R*o8S+az*uaK9^i&n{h0l$uAb zUHX5?O}siN+^6fZ%K0gDHW^KKNb?udSN}hPBiFC0&^Og}xN(2%*O%36Kchg#y$}?z zP#P{?!JTAwdmwDJfa@WbJJD`@(JQfwm-uGWN+j?!?brGNl-Dgjd}vA4>Z-aW17}PhR5g7DJ3r)=B1i9}m+?!Iqzhxui@*GwKz)}<^bIIL~aTxhyYV=e|n zyyW}9lN)0$tPBNAPZuQ1+fPkWC(rg(VAt6#;IRz9BMSy z)Q|=A>hte$v+;@HRXgd6Dw};et(QkaOScJGh%qWKBo&incOchBvMI=f)5^)T%*@!` zVwWlC;B3xb6bi^AXS_f2rmBz5eEbiwEv;7%{8lN2oL8?AnF9W1u`gLJ4P$aEY(zZF zf=I)F2vRF!u27g!aaskbZoa95R`?*E@7AM7w^-#v2E3njph73p92`~?SOm*fw>l6> zwhOs$<~QZS{HSBr7#GsTA)S}hZP^%V+qTnyn+Hr8dRK8By)2601@exVl>P7A@_L0PcFR`>n&g zZEqim+u9A1Yo#NL>Mz|Axn5Mq>cqS2uK zk$eD>kV`^}5!vTIq`h?SY?4YppG}5R2!;6XqM1(YNp2@2deDhUjlY6F^Z}uq1HQ6E zpST2PlL@^{AUd(LlpH-G7@kFO3KY!3LdHwlVJxd$VPm21v$0=btT0RJkFmOgqMZ$8 zBNO3w2!Oj}3bTI``1nf@L=5Vb7}OjMDs~*AbEPU+OvyqkmeZ|wrvyn0TZ^Du&TcIv zBchd@jOxC$0g+6*!_+(C2sY|~@b%t40$Oh@3e6=3)Ao2788hL0+F{%zvUJ?SCia!s zzt5<7UHtdoQ@L?;r&nJ)@lh}wDH_+cZeFLGx)n2%D|FBLs!4tNbr?H6v;ESM$7;(b zMh4~%opoC($ywS1MWtcv68{vtZYFqZOSU_q`}LQ}KtcbkXjd|YoWegCF-NG@MkM^n z2+~IgY9o@HkI0SBf~WEy!pMk^5GD!57-6iF#FiXL<4RO*jG!ZxLW!^l5ebTuT7)yT zAzIfz+y-Wb195pXu`7y4aT{DJl7Q?;7cnB!!)~-2iHf8GVr@(U&@9Xx#GH^{2W6jS zhcTN2u5HB8;X+~t2mfY)Gj&na8KsvGh{vSU(c@xr^s-oXt%{tO!_#VG$&=(=@ere; z1>mU2PsN%ufoh#_k0=W4b)Lh?@s6Zyj`t%RBg7KN*^MLS2&LKx&!3DSeXd{AMkF>L z!LX{$G70tI467muG+?ze)XgD$C2h2o&}VO03CmNQ*<{3=>!X^lgi(pOt8oPBL;rol zO4uw@b!;WtER(M5ny&=6C>ONY3+HGCr@AGa@9-eK&>{#fIKmSAxDv?cb=Y*Jx~Fw# zb(eII4LY4{FxV?121)ib%eG}cgHC3MwM{8-g;_{8+Cc2#N5yR2?jnt@}p;D@2RE;8ZD7OQJbvNvq z=jn00$Ap*+b8Xfr#p^a zHDK#&WD3i89I>=={Tp-xv2U&KJCLP4(yLpgr+Xvu5huel%J|82h@EaZ$2-E{G2VIO zh&jSpZA8+ajKJwG&_<*+AHmK}n`M%)nV+3Vvb3{fw-08`e7+LgGjNkRLY@Z2$%o)> zft$$l_}%ibn27~7tEvNvfY)b1nx+BQ$6~M;;4kNSn%ot`Lh!M$o2F}^Dv)VgV2o*U zW2)vnq-uVCw6^v`;wRqvt9y~G`No@){qhwGukUr?Z1kV2_iT6&%i@_a+mN}?p2^7U z)=F|v@R`j*vZJ+>v6Nn})XDzULpvn(?7wpS%g-L4T)M5-hlB1~II>62KH+5x>Gw6CeD?O| z@=r@=mUj=NwJr(WzJ1)zr+RG7%01j?a{0jdRi%>(ONJFz4!(4)uYB;}-?jtiJU>Yd z;9SbEIaTP6oK(ooOh@eOZ+t!nW(%IT`;+IS&qw?@+5l*Bm%m^6G!CB(eEtvKubhuR z7bbln@EhHc17Pw}TS0Yv1gZQ*8_`M|fqRJ&DvP6K3k5*8jKS3Xuw%5r0zCi}|#XE;Hg+dUV#@haW4bI5m3lqTThRqM?%n6X$#YlJ8T23PYU$=Dj+|y^djR~gjNdk3l&EANTn)fUN*u53Y_VUQ z{Xp&P-{WVO_-D`O4B^ky|KvI8!{Lir&7Sl5Ieaqk$3J*KKND^KNpMs%+R)fZA@1@7 zp&nk6z4(pg!-v8NUZc^DI4of?6_*Rm5s(;;pb^}2>~#L?)c`@ErpD!fm`piH&$fgj@?K%CiC$+qg9<%!|*^B}Yq=xjJ+qZi2Z%>UE zA1M6by**>23qlteW$-vUKl!2B$-lxW!y7-j8=uda&Yw3>&JE8=A18kvZ1#SBvKstJ z0qw4}<^vcX=dobEg5i7xb2JF4d;snu&tie!#4Dr=H-%Hv)Fijvj*QBdL8stIGJ6e@ zAqDxAUdf01*DT$%X|gFN0rV*WKj!JCSwSB!h+*kx8L>2$G(=;`&~CMpKK$Xw4?erc zEbEc(M0)Mrj5HRKyI!h`4506!#tI2HyVCoW(n7sJ+Z7B;I(j<$n^WY#hv(wE;rrEg zGYMRj@s^y1{esyjcC`+7joYN8U(E6mHEjkkG}$;BBe?7({rFq5c+?~A!Ni)MWEdjhjLtl09*5BX(yKfBWO3YL>Z>NkG(Fg=C4Cb zm(Owy40ayVz|dWS+0mpyj{^shR`64VG=aV_2TNzq<1^j>0h-`G4MIkI%{M?GeK-#y z4QtjQDh*$Ao#(gg_OAsFWwRoasik%0A zCkk1@*l=-%>a+?eDIUMqZ&&43*@>P6^q}e)@z@-xj$DUikQ@$!3zr&k>vWz;oYW|J zG#i39^og5@39&3eX7g)yf3vLO%t9V%40%Ex=)ZrOU2LLZ@?y=lZGV_zJg9f|qN7I_ zUCU_9G6tP;8=q4;x6eW8%svM;8|akD9jC%5o$CaoA9Mof3=Q0cL*SIILFg~oH3;S= z=LHCi(lrSEuW<-Tak|qWu+z{W3?NfUAzMR(pFvv#qZ$T*Q4Kr-Y!wZf&1u?=RitV# z_^p_!X-ssKj4W#Ho&5Fj$cy@&_X9K%TeYv*syUTutM03PsD*v~bygL7TAp1`Pe8S$(XWw~9Xa?YFQO;Tzx?)G0zFobPob zyVqjCwRaj!a%!5-n}`%Pm&t7E8L^vL3^>;;c@xd%M6YCka;2qKL&eNxNd|5X-+KQJT-*7f2Dg0I9Is-CPsANw5`GDAGZSJIE*UVja4Uu zSO7y%$;}fOT^7=AWD%$6zS^sH_A2-Re2DBOX!8vaDx2<`+5Xt=5JZ;Z+0%*~%R^^A z2JUH%Wlzh!7(BXAwu3UYxphKYaWB6WyJ4B#iM;kqfz7TH`awcqepcfwj^nqXtw!rK z=1q;?WtO!r4q)6sERK!XrQxiR57JJot{Ho1$6$*k!g4<60lbc!bG+W4?N}^f7mnKt zNpd-2;JIPH#f1D3iHM3`vD!q7qRM)~V3T91iJ-v-_dXBVuq?oM{<)NUmX0QWJ54t& zJ$TS8m(!H!HuCFqIuE~oRWlv9(D*a+&_gOR&Eoo)=JX&u2JE-d?Zb!xu@Q-AREC>w z!AsnSf0}lR@n^tbd&py_7NMQUEqrHqAKNkg2j4NDQ8^NyQS3mHuRjL)Oi(zk$!DCV zxHJAvqKX}W6Fk?&<+lcb;gZbKl6-gxwp`=}am%hlJA1F21LP@%aSs|}yn9VNus z-RXLs-xU<#jdE$Pv?4MN7r1Ueo7FX$;T62K>bA1oO1> zA)5w z8#HUTaX!))VhQs#=%NU{$}ImeOSoBBiZ!G697qEv+=5d8PV6*Y$X0~vybj?Oc8kTT zce{N)(JCb-IgM72O+tT=CQSw!vtgP0kbS|Fi`RpYpp&`jSOL7&d_X!qx~S)%iIeJI zjMc$gAAO1UA-HF%e1`tIqaG2wYyCI;UFvdm=*c(PZRBz5;h>`q;S`t6X4fSpiFTxF z@?tH!%kHq*wfyr~WeQG|ITo7+-L%#4d{dUV8OucHQoyZB8AFOY-?TDT(qJs8yX(xU zmAp)4pER$bvSHxR7v8O3wY3%}!?cSNoQvnPGlyn(il2el;Q3`*)o9Me2Qmzke%z`P z8@x#z0_Wixg#Iy2Tx^KDX%LuZr$Oisu?MY8bJE1u!0AqdFo?LZ2Ie)$;VKTZEjbKM z2^x$+=pn>LwBcfaX$e?|`VD#wxRHr8DBLbVwYgl$g2j=nDro_m6aIReU@}2bFi8f~ zuOp2PrQ?!QK+r9IRe8Q!yuwRsgcSGXvJe}R+<9Aqd>@aou~-|4RT>sMGSzRQ!ua9z zIr=CWPQv4dN!IAIVpjBm*ss3qH+r3rU#8u1iyK{#CZS2e13s;HiM$=Xb}q191p#?> z3ZxGFRk~DElcqpd%M%8N+otNRqSu>|>+!V6(98LuoK~h*@IIsVL{zM%cI_00$)1p| zz11B3W)9AncT-8@zDupY3~M0P+_l-XScMtX@M$U=>BR*%om+kh>51xh zOWwMv7>Y&Oa4R7D95r&!}x z?k)t!%xxM^)mXUJYk|j5Swb%PTpiK$2we zvRXKYd0us6xeV9kMc?4gX_H#h*H^8Im&}m1^!#l1LaUt&r;FedWo0whg+)Jq%J8ej z>{v9F$J>D0sTzJ8I5+p`aUGs}?8v}h_-QuSH! zgoL>6t*wFCrW%C)!U_#Sg9l7o1LOS~g#I{G+#^qO2p={;s1&vV0`s$Ji(tH8^GsGZ zz^H`(g^kdb!A9H=za)c%&tp(D6Solx^NedN88ktI5{oi1DDb7B^MUHBp*vk1n9t;iy@sAy(Z9zlN#p6iF47^d{{64%Cxan!n! zJ4TMpxuy50h`fy~$*4}5e-D-@oCN>$DQAQI01h1c8Go! zI21WSb)&1kP9GGq;RG?HyG0>cGPKXlw6+lq3CKV(xUC84)`SFWx}sb#L7RZ8^ zftRf#vclBcM$MA_fkH4IyFj@y@5)bDQZ_V87i-ann7gYXEz^YiKS=P`Az{g<)l5b%$$NWDT7ve^{_= z+>F`vMdU!oHSL!s&?>pQ(}vdecDvfr|CDs@TvFVn3$m!W{~NnEz{j48*l$naj&N~X zXSbrx?Xx`|Q_GfWd!Z=Vsq57nncbx&JwgtX*^}KfVn)Y}v@`^6((JYjgm1h?k1XVB z`Jk}_ zl9$MyJ@*kY-mt@8H@AA^6FrNo-Whl9bi88+shvN5+`M_?#{FeVSB|cNuzJDX`fk+p zOf35?tFfWS;(H3x7UqPScl1!YjGjAd%;=eOM|9}Yr31dfxwMQk&Y_mEJ1NXTW+~%P zjXrK3S?BkDuQr09jSfagJ>oZ?=frRrTVo^q9EPN&IC>n=x3?slhpmqR4+c<9-`6le`9b|tw)OPeUhm;#1Lyi6!Pc* z4$WSOdFG6Xi*KFV?csJczVQpyK*h|(o&06%Z^^!wp^>!38mpDj(AJTIj{181!s`h5 z-GMA8ZN;I{y~^n27@L8yYm~3F{g@@bA3giDj(GAp;~SY-p%jtjP=-`h9I|qoVS<|7 zd+@lC#tFtJ z$_UF8=kfBTh25qwoa?dfACynw0d5(#89WJo2U;&e4wyYWar{kQ38ir~ehlgD(2^a; z$&8x@+)D18fdhJ{=ZCDLEi-RzH>7{Bw7mAVF_!7_#f-KYox99ijvt+FnztNcM)(FZ z9@e@7v04tR;xfo?k43)E)ku!Kq_u%5W;FcvP0PDdk7Yd7aTFPsH!hL=Hd6kn{jnaN z(pTF1%bw}TezINFV-CAI6Wq;pSQbpiKh1)#PbxGYbxz3}EC09G<$=D6|LH48BnkCR zOVm2_qH;rq;nQ4zn4~0h9Ydp$1hXDrQi_wEY79AXX3wrrEWJLfNf^Cp-IFMEO?^Hv zFI4$eL1uPdaWJ!h)Wv^Os-Jwo-Tm1vazVR-c3K$}|EXy+h=>{?;ag&RXj(zju5V%y zGiTi67RzmiKNPMbaH#j7naoy=0-`z#_&cH>@pmA8uKDoT2;~q)WT6lHMh)uk#(W9w zrHqehgU=<<;QZqTIA6j)Ao7{=QMin?MYR!Pi>5PGb10pOvH47v_!!Kjh8Pqwr?e>) z5|0U9{}xuwI;K?)lC{^yz$+EDtt=8hlH zH%WWa2Av(+0lnBk6|O9c0;tThukDC?$yM$pC)kxBp9gYyw>!OHHsJ$_PzBG_B3O(Z zolI>cOr*f=9g@!?Aj{h+>sV{R-=U5(6`+iLrVx&|@FXhL{R5>H z^|PhA`k>Tnqa<&-HgEH0%p5x>%$$#zIn^la^GkHB(um*#F9gMarraz&HP!>R@oc!3 z%!ntTm}{esTDiNDdX<%P#IQGk?q-&=b}kPjjCd?>hv<&)L|QF zZPZ6!q_K(p0Hxl{i(*S-nM!UW#tr%w`9( z;9drmc^U9G{;{5W*d;6+U=@B0KhU-?kj)lTJ;7RJ&?Vx?Ey;HF0Fs6`#%5aOK(5dabL;dbDzDDpq!~& z_UofHG`jfn&+jMlt{uOwsPA#{pO(hfZ+q=;_&xRUU*Hf} z22V&uMOm6;Np>Z;yndY#O_kCDb~LiK%ZXmqphqW8KfhKCy&>KVI8F&n)!`q-1ELP_ z);IyM1bn0jD5J25m9I*HD_8$o7d=ew+_aO7CLLLm*BzV49hA& zZ=OR&oFPSIBz>HoI!kNr-dz0v>Hk?h5rI$8$p4+eAEzju_Ey(Qe0SXI680q@}shNzCmaNRfuLl32Vbo;kvj7=5vhJFE!`Q_;{V zBBrykwk4`q`Q*mUPttSisz12$^XKz+6`2#V7kOG#B?sDdy*c8vbb4a$$&YPcouZZR zy|)6N+F&M5l3i6byhFGD&Z50%80RM5JY>kt*aySbURd@n9HAeF^sVWd zbDyVXMlY)cpZ|B|zQ^mA=Vff%zJ1R_>z2=FouQ4`i4_rKCu&aZHkl|gS_~Tu#ERx5 zi0bf+QjP0ialrz7QWEd?a+8RDFRCcI>%uv-bl(pnTzVxt=b!uNI*UtZiegdqq?ADRUglEx$PT{nS7IrDs>qSVig_6_8E~HC-)sJX_-^;rt zHo;btP%lSPtQp;n#r()&Y>5x*AiQp0>klOcR?d2P&mGU-+iTjRHxC{)`H6R*p+9_o znf~yFIBNc?w_iB8Zufp^Z~sTfEZ*FiP26XE=_12j`?)i@X`s=&T5QWaX??^@A zg`E#S#f(LP-y+a&20lL}N45uj$tL3R+11P}cZQgOI!;?gYKCZ%GBPZQiAcRnv|B*U z&Duus5*kj+4KfFq^&qF7*92<4MVN(A*nF2BcR)O|o}ON}wyI|A@=xFW@vgGiWpny&d91wr_ zkpBI_P7C&JShlcc!PM1fj(3`N`lnX(M`h`%MRjQPe5q#ioZcC!Q*Pa`XV>HoT@qVf ze0jz@C%^g_;-oRo=tk7PNtZOOMVi@QgrR`k+fo)VZqOxT*QH56Kh}+?A-kkWk2DbY zGYzps)+Oy)Z%AdvFDuj>a3UupbMJ7L*KSwQQxx`PVv-kK(rn%ryf{fO@?H$+m}x)< z`5Us`;wRZq&B1B2>4$6OJ6!>%(}hn53W%ay@$=Ome*4eQKVPNJJ=@giJbB|2yEYod zYSU)POefJfWF1*WHj!m?^0m(qj0(|r=$lG5KDgi;)PSS{Pn-wiuG$22aVJ)t)nT{6 zWtDf!*=I+m1`-a2n+5t;029ZU+e?@}=+d0Um&&|)Z+ zi+2w$Z)vG^bY8r<-kcXQ&P(V!h}8Wsa@M&h*ZH*AbT6!VF4sfwKi(b7D&Q0L&>xL? zPORvh!FxJs8Z$c{2e=pSkmU%mPsQ7JF>#Px|IDK`XForN22KaNU%BaN@;td2A44L4 z4vbHa-I+W_A+o=1YH|yg+1yHC%{X=Wp%(3-*7uCGZ*R}c&d7x0E>o}vL8zLqRkJN_ zXfw{ZDAUJM#903k*!=Ok#O^%1`6`%vz=L7D_wD|7}pLn`f{ORCBm7}|~bSB?5tm}p`J=YZ!ck!pceqiOhS?2JC z**o&htCmzgeEZBhao#*Sjfk7dEGz8NkMsT+bj50zl8q*v3q4{@7RBpx8Z0)nQv{KD z6tD*tv1Kw#;}K2R7mpML(+gwCXz)V@L4fjt%=S4|7iqBO)75g{_d~OtOA?~ux%zVH zSVh@G(asgckO?)oo~&D;9i6PuebR&8c8M^1l2cR(1*Z_Ac4HFHux^}N`*1a8P{x=5 zPhQuG;`ztfAq?zsfa(}x<^ZuB{5zk0025y0mbwq0ag^On#s1=P% z@2^Zxn>?)B=G)8H=eNqubY|caqf$swAYoPLa3<6|0iIB7 z12WN(AO)<+F>Gms7|X}_U&g)zFsdqT`<`2-_nJPL^bjBb5`f)HA*Q_7-b=_+NLj zbT0R}?+N~&SKfPlKXV*&r0y(aFre(*0R2vsfDj;#!RUszLIS}Jc*do?k^gdX zY4ufXa_xq_pWJidaklWbi32O2djs$YpIBk&J6T?Ig^^u;~A;eHd( zQFEU^ealVJ2VCDlet(BOHB_y{+AWWSVe)GJgfwGDFsvn*{Gc9AcB8@WWP?+{Z$d!o zdm0;?^1cw15+*&#ZZD9bATOKcH=3XjeiDTHo=%lL+OB0i_VTJD9ftL6*REUFxvRTw zH6!0>D!8uin#pzX&Q<5a3&&hzs2^Go}a0+#lRy3j@@znAr^O0cyNsg9yFv%M9 zW(>EO{h(VJdH8rzc5P1%wQnbQ27ayGd(hyyIK}zZpKtkg7yt1W{vChE#dFvvW|jf+ zics|QtDo4l%_Ie2yC24y%SG$bEJ}{o3uw@Em!jn5=eXTcioqEGd|?0TObJU}kP{2g zM?mDpr(cioX&JD|iQ82NQPb^OT|x;`RxAa=^)Rqs_8%|nTi>_*mT6U)(R-~VY}b1&dm^R0|+-g@cbqaUrQVP*W-9jiV%`XEyuet`e>a3lZge)9L2$x@?A z5ZzKk@vFTXo{hYreon?`^Y}-Z3YdOuQF->`Qyr965zv z>V2S57$^ELUm7W*O|eP?^Z-6D^>g?k5-5kxptu7z&8;RTDTsM0AlCB16#)YbZU{Jh znytg;Y%a11GgR_9cyNS}br8?$GUh0~|IU9-lTStSEzJ7cj$_;Z4pyceY$2=se$Rj> zwUUef+Q@$Z)e0!K?n2@9b!_d3^~2C=ky(RG90O(I;PvI+$o~(YM4bN!_h^TCm2sbM zjG36JBvhgg0M1`>vKoR)W(P8JLgv8ggam6KfIbHlE06`O9yPfIvcP}KVFMKkAw|f5 zl8eE;^Z-;8;R-a>NgFfy_mG;;^L6D_Lsw4g7b))9p;tj!-(GU;AL+dc=^+b ztnQspwGgj}{@SoGaZOT>Nf+4>_V?)h(JNQ3y7xA;9`k6dLhIdV|DZ??B_2CH9vhBP z;E|FfG7#;V6DhQa3zYx=OUl;>=2Oy0EV^yn$SMeWt13wV0!3npt zexY0*tz7on^G8pM#x^rr>5W`bLnojIJ}ue}UScSdz{G6OG>_ehQ`hTsfu2a@@k(}= zVz9?N#f7ai>O`Apt&?A&#rl_R?EVuU*VU}zHMH!1<*HJ1cwh7v_UrAjC9kh_^*lvZ z38@GBb-uJiU+cbj{zx*``UT@yT=61ZA$O8uSr>YC2zqdgFBKkNE7_o+$v`-ecxk~Y z4e$`+579gV1+#>Xq-L#(+D-TpMEgZwnvECk<~{13FqXb3B&+-6T@9cmxz*3`syjE_cMG)+tE@R{qQBPT93(x^S_ejl z=+y!J1>D0d6;#?Ti^Im?N3$3;&F8nc;E0EBJi}`<2nm8|rj;j7MDmr#^FL#4rUAp+ zg9(aeV<#cuSR1sX(X?aEmeV$Q?ehp@Jm-051&twJDhHV&f5}QNexWY?DO&xHSc{t@ zF_=H>bHib@&<$qckil+O92T>QIV88y1C0eQYd_I~?Uuel7 zthG{j)$?eiQgnvqV5ZHG-v)D=01m~31WQg%Xi#;IYJtSD{7$;Iu*-yE+{Dn_3Kd>t)6j$J$qW9USzCBY%fPJ`JD76%v7WNdcG z7pKPn{{#@JN@6BXT&2|~)k~G5Nb0673^KLM+(EWWRcoM0$^`f!IG}3RxCQ%r~)ReB@tavlPBg;J^xO0G$h%&SECDQhCnDr^VS7-mo6bTjk|ks_XNd*H#`=_=l38q;89Fq zbu@Vyg$8KLH?Ltm-eZL9&y5|~K(!95AJ_5hQ1>yLZXeXOQ`gcV-N$rRJX+{S9_Ia? zks;cz{ZOuE2lc)6yQo)F%n-kC6^qiZC&?rm*i3OI(wH>Gl=SNb;*bHcU)mXv#m!M4 z^is$^^fDlUzt3Zm%_=x4>?XV9j`0G<79jRR++HLT1&fVrA?yU50ThXn7I=r>K0 z7yYlpc)S^#<$%@XMo~Nh{eBG@?3%_rh+c~D528Ob$IZq&uut@CCEQ})o(US{r2A%iY`ieCY?uwOo6;wt&0&S8wM`XnWQmPmV zm2NCFui4l^qMyd-l5yd0L+j)b_*btVBH|zs=M>0>X^(b|z&DCrV0GB-D4$IIDn@ ztRkAG8h}wQDgex2QGgNxjyCL>xb}g;{KK;fx-FtstsrHzAdmjW2Y{~q;x9mj^ECdu zao>~A9{)^NjvkEOe}X5iTy+n^M}oE1U_o0f*tJ3AGp8$Hg>r&(!AECjA~rAT5Rk+i zfS9*}ua?dsI$Lh{9TEf3 zl#}m#x_0%d6^o)IDRrD32sgCHLo1kSnH-3ZdVNMzN&v442RND$AC%uMGUC(|69I&s zFjd=UhSfv-UkyC54hN8xB{n|K3fS})-r+y7y5nc51*dpA3oc)bwbmUc%?g|}g3CU^ z4-Zwqsi?^*K`0QxpiQ%(2{x|=MP6&o{M=HQu?&F|3lv;r7SOH_l%z{u3M@+ORQ-;M z`tLqwl zMQ9FgSRLW?Rw)q1ulQ5&OrPRUSwZ?#$HoT%>=lCG8s6~e4`2P``)|Mg=11^bZsT)M z@qCR=%nBI7NLDmhBH}UC+kfX5|M3+Eo;nCYza6}KUV5#(7Nt1rio54L&?O`M>tCv_ z=NG@`mjL*C!-s4co7;188UKxckKIfD{K4>AI3&V32In+TLckB;G-C5w)%38M;!S}! zKPAO#vmrWZb6P>VMWhw6^$?RNEi@!up22;nYe@zYFX@CF)8cz^CpCZ3tW`_SeZ*7Q zIksu(@~!+7;uxXOq8bD$6Sv&1lun!FHG6NLwYe6~cDed0{-C^NXdiwYWv3!rS|OBF zkwYYdoz`ej8C;n<3`_XV9avHb!UWf zsK5WW?o3(->CTzZoz=3$ZKtwM9gl6G6bu!O=8JJDrYVG4m%7x_z0i_wmT{ddi|mDWti z?efme?DhS;6Z_Z8JZ&=%y~KV5Hz9jf?H~PK_C~*to{$ToABe9KdX2X9MJ~W%cs4UA ziYeO>3A8)IrUV0=ni=5?ge{#Jp$x>nGBN^|02W9fU_p?{(h}n&9{XysMldt!f(tB$ zh)t9dO(0^z86QTS`Vv;wc;)RAyVf0h@sks`-~8{7zF%_Sh5Og>W1Hk9^*d79EFb*P z-3QA}*()Y(dUC*~;Wv(Xi7H%ZMc*K17pyBLItalVXEWHG4xqTIcBjn`B6N5%@gefU zn>7qk$->-5qtKYk${4JT!ANkr>f}R;7TtXk3vuB7a;3K6puFv_I~zyiA(%b!JcA9E z?E9EJgWRdR@Lf5C?<&?F8!i9~hyy#Z%%| z3itC|S$c)rYH2t86D*sUC;ap+?(e$vlX4FCcahG_B*h7$D8=J;Din+{+6I8&Mz10G z1DzQvbIeohogG=@l!X0h8C z@X!ibPZalp%G>HRfbmEl*}^e#nLyHsTEc|3r5n9$xID48Ho664%S+kuhJ)ZI@Q5n- z%cEa3*A*CRk%b1r8EB6tvy8AkU~OoS#gWq#RUl|fQioBdp={a}MJT3$2x@7~!0Qgy zEA05Ig$|DA2)r|c)L%$|d?;2FFA; zNz^Ha?!%KrjSVV%4AN-a8C96@q4!5*-wiKg8uC$cJ?0drs`_mjGy#n?0lSnOu%R@> zX>+B3?gi4%5YSrs({;VSNpEY;F7ruLjEzdgjuMrC2TyA7tXa5FjT%N@~^|JQA{Kj&8M8Xr~fBEs{PdrMM`{Y|} zac<)?-cQgY$^ZrL4|+68jMZ*P^D`JLVXuHk!UC(&f*o%+sZO|x5Pw$uOs7i`;ee)w zi)GMct-zExQ`@+(R_5J_r;z6U7JHx9sh+lnW)!xKDY^BOD`Gjp5WKCc4eNgzWT zJx*F@xwEGYB0Gg3F;P9Td#wm7NwA7LM}-xwb@avHUKQq!|qn0xfR+J|>t ztkj=)pmAXI0t1Lb<3QyAX-HVpS}OWwgYI6~>j^l0e87b_>PCNVAaJvGUJ8Jo_on<~w9n9(>!wU`a1$UU})3hiZ3W zGc-1ixtc#ndjPvwTZooegw_L#g?I~9ugz-pnv`II%`bt+6YYV2#f0pRcw7F9#nRjw z(Dp)}p)C|JV8dC#d&fRF$;)fi%-Y(9Gf2!Z-MUU*`p}lgpCm~D@m7bxN`Mu2oLq&{7i5uYwifz&3hq0ZfPeJox zk-JtcS&xX}@g4R0Lr0Gpbq!WpIiyYv&h{j!FYE!c8E!C-3~Pm^!D$UyajZ8J{7TqJ z&=FhC3c#CM+AYFEAzTz>t5D=G7g;tU1i^+sF~8fYQO~?+fqIeedUE@u2Mfz~4h7zi zg`f46uM1`!dVY2Nqn%bIW%APqvHi>iHz4xbOVB>DSgwk+`<=x?K~)=sub>~Xf*MJr z|0s}Mgknb{MMFicY<2X-PXN-Z$-~Z?%%qKG46%af^!xU@Xjg{>H ze!gr`1AaS}lx#wxtH}h#+T^r09UP=fUv8_E=%xyg^~Fizqaa7dSFfk9}Rs zzvaI&AG7jPKe3Z3+i%@i4`H&ORUTnCK~@UQQXsuSvqb&UR5*KD;8|h~_;3;tM{2G# zG14U!Jcdr6%b1;=Cdx(hEo*)I$VzFAjTzB*+Xi0@8ah5^*L ze=A`>U_YU10u{0Bgy{GDe+iw;8Q00^ysg8^N~D#K=#2tCcSTJ~c0%+xogpVyKBadV zAO%4%j5F~6TKRFr0V9?@a0Qx}hUJA9;3x1l4BJ*-v}VT6I+vUu{lHxJ#Ht5x_CW=5 z!P0D#p18W?u_sp_*#~xJv>pCAQ9Wj$8h5Ks!mhqCW#wte+I z^OG#(dz3@bi@Zln$82mIbq$2Hkcftz!UjsDC>z3QV6yhQOaTO8Yy#rM2`R;#PLrEd zda;#a;_-h**+lqOG+5vFNI@wP@*0)4(#OYnCBL3q>g(CX+8>$kj?KUT>beF;gz&OKRmowR}yj ze*QKb;shciy!R%<=P~_N(V!lgO-Ay7#$c!4Xx5WSf1x{~24~`q=rs7+P_*Ag)ZtKD zX@_Yma3?T@XcFnDsK-!Mq`1Yca4Es{Mw327r#4A2=p|&0vma`S+8V^hy=aXmx2`vr z`hcWJ#2uO{{)6zQG}oI0>v^siyQZA;%kMCs?bh#*8IL%&z5`;C$XOQmXhO8p??LtE zUG$yB2u+T1=L?(a&Ba~B?i;CJBb~%u=$c|N&9MromX+^dbXc#q#S(8z>&kba2wxhf zwtP{SCiMddlrR#1s4quhl!oy2JBDYnCu(d^?*oA5TSH2%*kjp9m-a_mafP9gRN z)`mu2q*Puyg=ZM0ln6Z5a8|=VZjfXAQ83l;Sxi$`Dn6CU#%Gja^u>!-u3AYeDp5Jg z`ob$`lsx;H28v}W`w>-}#EF^Pi4s|i*|xw9ofq}aR*nWnG8+(6F8@o7nar9If2l7R zJTV9#N+vSMOPxtKl-}AuUE3cR&;N51OZ|w}OHY1*&5(Y;&U2zCdMo zRs@2XIdX2<+3enn+RaFC0J-SmSe7yY0ehL+FYgt9>6DTpHkAo2STZR#+(BnjqZB8W z3(`_FDIDt&+vx0k>!P%-_UMPrefyA^=7LM6sgEu1?xMS!?JybL9y|Cx=7A@Y2iA*=+iEv$?`3xk0ai$_gee!romWaJTwI!e&`EDPT4;B4D9VSW(h6E4&mY z)c>j|?}8(>J(+ldUSepn!8xgwYD$Tct*qdW7QI%)_p-tG#;#Ys3N>6* z4WYU;ww+&>7JK#_`b7xZ2M`zo|6l?=kvU0`it$)R0o>^6Q^+O7~kkN)grzsO^i&mps8Rm%wB{c+Xu z&Nl@A+VL4o zFa#5f9`Iy>-CT@WZ1TvWi_a$QbNv&WmO^W87xWm=rJ>9U8KA|1Y$z)&(UV&IswF>B zi?_eKdd%LvW3Fc3Z{ND%G1ht05Sf@$^%VKmOF0U;gt9JNDg~ z=GQ;`4XK{xOSEYy~Lj8CC%NzL;^99i>+Cg%D3` z@IJUY4@jD+xbvbag>l86#;HOPZ2hu$>m=Ib4nE7mg9BCwrR1E$;x&Xvt0CS2rI zc*+gXawvPtEnN}GLu3uk39C!ijGD&_)DB~1wPbR*2Umlfzyba;``~JeK_hliXckhr zC}n~toxB&#l7yrPrPcx4muO)q$qogg2U$n{=i1%76?b%l+&6ldty}_I$Ff8%crg0S zgJ=mNFKPw&p|Yjfk%-;wc4wp|S)G2_nUs{yY}gqNg?omF zgjKR&Qo}(h=o?%e{5_8>HL}Tu(Jh%+TVuim&5iS=`_WSIiFgNoh%w6&3KFvO<=w}& zb=$P=*4x=mBA4c)1;cFPJ8$6w9J5204p_eA(309EVL>w6;pa^Je3DpZ6=@5yNr_P_ zAcau@b$z5PBPAs@CkPgEyIJ;_gVt0-LEGevE2>@I%4#Lq>T)<-)?`Hk4Sky{s?!Xq z3DEtk5#H17U>f`&^$Q<@Rpc^A5S{Csc6*HLoB(5eNc+|aN5bjNX-ryW#&B!5nKPC= z{BVyO&YbDU*OCnyc^rwK5|3{0_}26F2Zqgas5NQ#+{;c5?f25DC49h@!;}X<|3?&j zB&VW3tlhCzAU^brMN4y}Igy^JLDY98fiJ?Cl;u_}NkKzyo)y_SmDQ#M^eVNoT26DO zh0+SsV1Op1C8T*XGNi%P8Ah)IWWce0Hm$M$I(=~|$I__?+2|+0yQZvVNx{73mX_gv z%Amv=Qj+=6A&f_Vk!5z0_sTwAw{`OaPkqnNwy)hJ%bRxQvaIiqJwBio6`V7clt-3L4#&=3edz+CQ?}@%$yZ`W0lShQF8v5C%H;hDtm840-rbiAu z|M0ehhqnIk!?&mZw0+lenUg|4d>fvS4V?+GPGu|3q!gS-$i&V}N^+W014_VbQ?qk2 zo%TeFX7kE6*=7iNA#{n0RPRJHR;W0G1Z)`coN3vW8jvSm7Uw&VjaYnMbOg!nS#r%Y zp6g7T@7wt3_19P4v~l^oe#Hui2$8Y*D)thwG7#3nb&(=7G#qBPODTa2m#ZKzAQ>_KA#Zk~HP5Rg zn!JhL#5_}8{-EkS4^pO>QG}CY0cF}oh#b*BIy75n($TDhk!sZvPqQW5NXyewDGmG8 zH(!VjHn|>rMXobBHpz|K*R9+^Upxax;*cSuMh_b<0|}IWHoXy>r=iQg?^Omg9(e5F z3y(gw?}dkNnm=#u+}X3E>C2a6Ub5x$+A{Rh2u)!~1U!aL4B>%Zuodz@na*N zzBO|l(;j~6(yIG-^l`qmwEvVP`48PDuVntx-t&;TcS#xmN&GWLmG}xNh629A2Fh5C zDAz}yldOAHMNWkHDTQi{tJC4LG~rC5tKqr9_lPnt`W(Cg^HA<@-HW_QNAuOVH$uDm zy%A-Qa^&td{oZrp_io#KH9SL5Vf3phPDJ;fLHCw>7F~94F{+gH_$Iu&f)O#t7=lYn z@)?!QFO<3wYLNk?-)Aa@d*=@T7Hq2 zesk>QCt1n%F;|Ra8`_2+31;)}8s4~{DI3R4zw6>1^Y272aCJK$5Z(ZEE{Ms3>lO+3U=sLRg4>h!i3DTS`et5@A*3 z>SA9UQnaG5i1amt!eOGD5_=ed46WT0cZ}KGtJ~%gOLy&By6@K8o?o`3v8MNoPQ^1S z29F;nZ|^;$tYqeZ!Q&^$Czn1y_2$WQp1$wy=T3k4EPr9t_;F*VTzmCYws7tT>gT4!LP*2}7%! z^p~_Ykv3UFtCQPAoG@|m3q#0IXrM{)fFE`!7;!4qFx_h01?v%+5kFUYp&VnrJ zuDuFbWR}t+U}i)PyJE4Ljj|v?hmO-?E~p-kWtx>)?3D`k8t+yCD)3kNtEjy{D}ND9 ziH7BKBIQO{tf{zj3NRvaq=As@M(_{s7nUo%4=t+c{UP)|#`rYQwT=Mb%q{^-4zUV{ zMZLpFOZYPEHGLUC#gZ$Yud{45kD4>-M(e}2J?h76c1|8P;)ZP-a5oVxAZ7r)NkR>4W%!Wun$Y*#i^x+TE~q=qYb9+M~l4OUn-}w2F&8y1W&Uc z^XtI)x@~UgOx}><(0RmL&TqU}lpG~E4Rt@s?qOrc4j(>t>@WljUfcFC>$+{5D($L$ z{HeN~k3Y%FzB|KS{~Ay7fj51xb{G+8A5};AG(>w92yt11G-8@&cKSj-+2>On#*mVZ z6HaJ7EgUuwsrW}~8DM;X`imFg(vD>g#b}iG)~w|-*`}2>{3lr6X=&D7itp{WC*T-4 z@#@`I*mB#obN`Ap)KxB%S7>#J2o*&9E+qve!AXi5O7p_go}7?o$1ui{XX0W}JWvn4 zL#$9%@4Twhbu(v7EFT=|8fO3GOMN}m$!iv^dMFfJ znvrvLb>#@RDW5$$`AZwg%zTXd4vc$H$|PpYWKS5rOyo{xXQd&T2<1vL;E)siVUt2k z+4@+3f30)m1Dlh7h}Mw0Us{K zJB%h-Nt49f#2SKDkfYqsc7ThuTprFJ#a~^xd2HiPI5$hs$93qVADMLRkR_cFu;+LS zloX>;(Ol5a+ZSrC^mJ=}a=x6OpJ)kZLRN{oj>0iyJjEWN>*#bmp?}6$9|;CP;D0Fb zC7FxU)eGAR={J-Nz{cP9;QHN<-}}HL1FsoW{%~p4@R3zxu1cxsU*GqNzE`cP)ZUyw z`Od+Ew#`{_$Ibn2Zr^@P--)+JCw3b+uv^a|DVB^X_RntRg#*H!#+7%&Y-OS+TcP0+ z*BRDsk~>OYeF7 z?NQfVi(mB^cawzHRq-XpquLLU)>incg3!1^SYKrS6(X0oOzJB2l==X5^=jl1O_Oex zZUfuMg~;+=k?!39od|uis&Yid{AshVzje~GyZiOc&&$pN+D$Ncx53jjdGVy{J13MC zPV3mVA~TepqPQzNm)xXF1dGn_C>wag=@GGXxpO#acUF5e* zy;-jwqq|SH7ZoehY@?@7n>Ky4!rHY>O-W4fdYrcM&ZQm0>6FY$>Qn5a`6t-V@ZSh> zY#`thq;1@2bG=p!uvZLgY?w=2uj#Mjnl;c?6KXQK=Nd6#bD3j%8p z`Qosi#hRvdO^FitVzHm;dr9o)QhFa>F$ZvKX9UHFo6~_aV_0KAAOjj1)@No@N&o?P zRk6aCwRc#D8NW%hUe+A1&WH|VZh)LP5Jz{Y2WH@-;T7g;>@aJV{4#SbVD5{%X5quY z@ry^6FJ8QS`QqE};d7VcgPp6NF}r=+?(JucJ-MiN`>ZyFT?Z9qk7Q#zrW7O&ubHAA zVI5{2JvxiOd-OL1`CgeZ;}!lMJ;A_d&APY?^))hcUhFz!2CK!h2hn313z=&nb4DjK z*TA(~x2}EQp)KoPfBm&_eXcI*jgzx)=~dP2h79OLpKmG19>gzNCYpw`2l!3b-ALMV z6+ddM!`Y}vglp73lE|#VY((Hem{!m>FU{x3NOL&SG87qSd7(bpm{T!_MIFqFMq6Ff zu9KV#(r%%Fh27b2Uw{3e(8u-HFLk>1jnu=I*feP<8q%D?YfF|bEuAh>`?Kaj7b zxOR-WXE1*fS_mT7O=#({C?Fz>&_W<1Eb#1|1!7J+R1HszlqVa5){fcjL!kn>ot$ko zdJJWqeU1UuQn-D1Ky^WTXZuilaK8B3``T-Hc}4xJ^O%;}j3M%SuO8ls%YJ<&LnIPn z0~KtPiTo!zll1X1LY$P~{oI@^-6GB+lmm;#jALG}7(c;3T(aa8ldijBHF$0?B82kyDrA3N#D{`Mj}LV^68`siG|Ql- zQV@9hV%W_LtauSuj}yJCWPvF<{|k6T!3ZMJ2;mc zI&W|M?waW5a-p)E=D-7Q!2)P?K7l)yi*mYfI4#$1v8O2p;Es8L&SUah0p3VSNLk4e zd;l(p%{Vjx>El>=aCXI8r3)9~Z7J;tg`A*U^dl%Y(*P$#OlpI{D<-~k<@`f$F6W=$ zr+s*F-doR~>1*oCPxkCRecqVqJEo19H)EFXs~>-4MgY7tHvIV2{{6~3cX2-HnO$pY z4j;btfhV8l|0IvfG3WwwjPszqcfe6har>=czr*TlVlzZ8g5fx=Px35@E8s$>E1Gbc zrRWEu)>ojnf-hqf0}TW2JvRD*a-x|_QXVC}N$>Ns@cF!tS{bml`^aJ#%?IF)=I0bS z3H}CH3>e>eNM$R9rfS96(gJVTuQ1ZH+;*43AfLT!-(@|#@zzE;d%3M z*mf%?uxnY`q)wJkYOlj~rzbfaC@Z(Y6Oxc9TalYs_}ZrwH78XF>n09=o81xh{{$W6 z_u*t^yZfiSn7^Q?l7IdE!ccZ-Oj#dw`Mz6ymG$NGAM0L`U+VL^l2U-3f-!;3VK@bw zBUc(IV`Q6bkHOlExJ_bs^$&hOgnp0KqFT~0UPO4qq#pzfPPaP+jwh=@&B*kDqQ)FT zc|1sJ0J0h{Fru^*1czGgYV<`E7ZIa;da#FFS^|e2QrQJ+kDy1DwZkY%eglYM+u1nQ zhxL1|xJ&YWN9xka*Eh@q#_F&C{z>r3Q+ZL0o907XpNUff4?Ud=L`(ix{tik`3Ro_x zX5OQgBP&cxRDXqYWxs;H6nw#Fz z^(zQ+*L`st*l|BzQPL%8uQRl8;(*2%w>|yr!;d`s^j4i3_g*GHO}x0PoxN_H8Ql78 z&b`l{I(_;S4H@-TpWEtAO|dXXGD_ne!DgI@H)1NJt}p1ymp?wF zVt}O>mL|?)x0cwYe`ud0T)zQ$Vi&+9`uzi|-W=Qm6}X3q@lKbRO@jp6$T3q_S@ z8v7r3?dV}-wWCGA`Or41KVb#+jbyMCyVHrHJEH{!L}?*ENDKpt&u&U}CL)a~G2lZY zlW>8Ryg^|BI^?K%blizg%zq4z2kHpUJTd+5Oo$qxDxxw2R8Wu94jZ(`nZA4ViUf1V zeY1|gg%W|TNDKTx?y=B%$ClusmmH1t>xNx(c`PfVb~z!<|5pFmo7!^ z>c@PecxQgYUGz?4qWbYA20U&s12#}ho<&XSK|a^823jQcxvXh+nEsO23hsnLY;&@` zgxZA`qq~_%dZh&14D!UJ^SQN_DYoI2eFpYO&*{e`?ZZ_Wb9Ot53kvcL<1M&%SNOMEe{d+1bp31sS08ZtL|e zQlC$SY;DS=T!y;BwXg?@D5jMWXp>tAsD#|)fL2^0g%YvXU3qz4L!#O?+nY_*k0@@A zAs=DX3R@Py(ZpjAV@EfoRHEZz!YPT^Bf?o2SXulmDh%L#hA7Z>b{pHS-|ZW}V2q!? zG`;?6)6gfUU6Szg7n^VE-)>B|b&sEXVdccSiTAzm&f{8Pe&~UrsE>S_ujb?0l@zyU z53vR8YIe)e2SWMatNIo7ihBmLW4vrIcuzW@wE&Y-a+F+)@3{~7;`(3H+ncCLsTp59**X2+6B_+3cX9ysR3(#Kd9`=IgC{cR7j=fGFFksHsmk)t88kd3A` zLIw~P39v;?fE;7a06_Pga9XDTVHa>Ws$@!n!`XyVBUy3+jRfd7V&lPrKrpy{l;>^yAC*P^iYg z`-wWSv0%mh_ufj9mD;&gLv{-?Zc%#*Z=ly{4Zv4nwIl0Ru{(f^q$2kgNCGa&;j|(# zI$mQ4@qpsf7Wo44CW?%Kc;Y+7+bQ{7Prcv8M(|yz1KNjjfbh59)>38R@~vvcD*ie@ zfntEetIjr_(9<~K`N#SVBbu3xJX0_td3}b2RGXFnQzV>`j7r^P)!+%DZdVBgeNeMK zEdh9-vgruLE#V0CMSK|!k3!iE7*davO;On#ZO!MrF(khf?G7Tb|7>|>)8F_}{WOgo0s?n+e65V`@UIRtoXoWKU7)8H47Uab@5V;eKBUeksYds54g zniA!+V0DPNa#NmjC#V|gf^YdRRy|ohf!WVqwWU+>y%QdL!X+m}zcfF&v-^S@X8M@r zg2zdfr*L`tZI$_B!&t;X|W14`FO}j3mRqVpHr$4D&KTEkK5tz=>;8tcr-l ziexm3P&FlyV7DPa=|=PdW0T3kxj8^PS2nHRx`ChFBTwEYPdX4C%?1zaoR$!g>!QQy ziz3}h&y)9$zhNh>QFt*_stTbN01I3*SZoG6m@kn)1Hu8IprHcRf}%26RF(Z<6TmGa zT7hk^paKG-OWwuat3NsC!MP{u*?VE;Ea;qJPNC`#_6-lx7pe!^O}}^QXlenZxe++* z@SG>Xcb@IASgf+*!J0D}t;XaOl)2H`^Cj3E4y((ic#Vpc;?h(AiggH&SWIUZx?oJ0 z#z!T#{PfY0F**iq*_tiu_~(aM2EPuA?i@30lP4aIPGmjir`Wui1S=H(qBSSa#AsEp z(&2@DCZnx56%0O%mKQr3m7xIcc4!8cXh=Y%gE1-Dq}m8MF2V0DboX@2Ze;K|QLt)| z9T1b$V%jQh*onCr*L_Umiac%Y8H{TP9xLQ5r8B21oR*gAl9|zvm6^uEp->pgk|!0y zF+)yGO$aG2Q-T;;vB5+=ruu9p(+iwiR;q;=09i-Q-!i^< z*)uCL`%b7%u1gwqW3Tk(&)-vc?tyc`|oV&bk zX+npAGq2@;QqrS)K?hSYg+FTSFmwvk?V;BWNQwR`TaqGU`7>;2_bEe)P3-z9tN79Q zI)5GWkp&xLT%AP4X7=l53xc|(e?l<)mstV@lD@jcTp7FbF}G}<(C(gH_lEjUxh|nTaqRfs z;pKalmv!dD09NxMM=UiyUk*dX{ab0iCf}GB?JFP7yKXw>>&U?_G4cEj=B$i*O1lJQ zexj$7B3=G>PXT34J?)R479vkgbQiTK)ZLbw=KrU^e=wP?yK5-o&y1Kgu!ES=;e#w& zELT*O_eD|#mO?{Dc@)~8#Wly-Su>)eN(Mw>E9SY0yTsK+0KME`a{HG6f<9$`D1coGJ?RsJR4*CxGvyas)@u zAZ%FvS~2ATw2*y4azm8Gy(mUuHt7B{7plu!#4g}I&&@3*rlk1A6n}1VS*n`B@Z+`B z<8Ha7|3d?YKH7K6lqt_0{P^oX@8c02&uQulGl%awQa-O}(deb4mqv;^Bowrr)nU)k zm+B53N6qV(_ujpttk`JCtu4{Cnq{{yF`MpP&1?ecsjS~kH>LDm*fF{5J#&XU=2(Ue z2)?*x)xjiF!k+uz{lKhY6(PHwFH#n3l~R9cX{0ik-^tqE37WQG z_RTeldSu+l)%80vvX+-Q=iQLpamCDQU@gHvLHrZQJDMjv88JLjyxsuyqnR5<$N3%* z$!?AF4X;Ij#ZkTm&91H4gCWK#j&u-9M2QL4W;ce!icx3p(NVs0WbMf1+%VB`=l^4G zEU^Ff^=A!U<&^suprdSuw{a!VJSMOee0F1B=4LkjZ~lY2mvh)S3`xT}LOCHL+l1}_ z{OO?EZA@^vVCVvT201>?U?>R9FBpUq0{|ZIVL;=K`!N1z+nXF2Xgp*{Ab`M<5z0*q z55||w$xYjKTB1|Tk3PC}n_XV#+}_%O!QY8qdGMt}FDfIReqvWWt!dKtDuhooJ>oJ1 zyg|1#s2X{nC`pQC_hUB`>7i~A3cZe~%y760x2SrqYh^h<_@CIw){J6X_~V8=0OcWKNC$1K=W znw}Si&E|p4=8Zp#maf7!@~O|98}Bs*hh3jNU{qDRwA7r!E-4paqCLca89yI(V(G1m zI$9Q+szx`S#cC5hB+FaAgDh`lzWO07Z>kiEVV3IGsNIEUeM#(^@%S^kMh8zVrB%s& z<{j`wlAp0XJS#aiK;VYk=O&;&v9LonxL~rAQ&Uou^aNnvi)9IT$GBsSq$LIs$)lL$ zm~6y|wp@}e9BSEdV-3@89NLW}8&K72`3N`%H_ljs=n|7Vl^kI~C_>&86iAm!Tb2cOEK%+xRRwcB4BZ zLrFnGM~X3M(em45W#rhQEV!L^B_%0ng!fB}84E%yrULfX>akD|gmr`A(5SnFx-4bW z8GvFypGQ9q1ba&^{TgKgF8&_BkhI`7AG~7k!FA~J-L^0Q4_&*4@Y^rC2X>kndhzYI zUkuHhgYOuD_xaqmvu5S9ds!#+d|BJ+)7$d7{DWWk$@~sE!}D3uFTb$D+!4cb`6=q< z+)L}!lj>f~b318hq)md&X)Z|4_A4OZQqq&nHUx?DvJ0FA01@&yUFm7bX87!pH6*ly z|J6%jk%EneCN(D)j!EDP(^(e?20Vn(AkICwtz6mq6#xS``PrY%kA63>A8L~^DD?ib?D`&U($m|9;AIt3*0|Wz6`QUf&}6TPwC__EDkxw%{jV5W(alz7 zxn{UuMi#isHY`%nB_q-$GO#+Yi<%PX2tca#9b0&hsK*Jezb1Y9mlMTghqqkdK*Sd` zd48lvkQWU^c_h9lWq~|K`eB5MZ*qQONYHIjr{2=V1ZN(y`8&49XknzZ9qy|8Zkbq6 z*sbQ~*ziN7!HUj_LM56(UB6b zF(ogrZAPS@eNgXCM$3?)#a#fq-Dz-dOC$sjHY(78|1PW97FUvArTS(2JlwCkiMMj}_U{ zK@rmh{O}KF#=c79o=&Vtu?oqx{kvrdJ+-B)bLG(V;zLE@qQiyZp-P`_W7_qMK?E4@)aPTofugT%0zP)#ug;ziDp1$(DKOGQX*| zGv3L@?CbbOa-hz7D)TCK&mTEZp%vhC1?Ki~lDR+}b($@H3v!JTy>R?_k$OiCYYTzj z=w?TYBQ!(Ej^Z-%I$_c}df*p>TTH3=HxO5vEc`+9;iesaPBF@Adw(})y4^~0&_r7kguN{Lm+@_6nabC2x>0; zrtL`WXmZKvp;u~@Jdg86WKIm(*`esZ$$xt4si#VZme=|Kzvp2-R*(@~U2@{gr*FBF zqBGe?q>)X%)TqwW3^-#8BViRqp$0(pT3mJy+)aKUktlA16NRgGi`ix+fX)+;2s9p0 zCzM=oc9|x=f{JF$#+NMbU$~#2>&?O^_}M-z{QUfbEZmo$`!h@H#mf1seOT|>xx3i- z+IhQJ_dfjP+PSrS+pf82$8mM7Qm^erI}mM=3>KHc1-~mIBa{+D6|;I23y&3t6^VC& z-XTKKY`LLLhm!m@o!C)BL19U|ww=@;+Z7j=mgcwbP*l>HJoy*Y^U7)ML);&k&_*yT z0Exz?)06(Wn3MuNeVPf=5m4_qKr%BN@bHu}^_Gnru9|go&D4U43-7CPX*YE3GHZOu zJrUx3A+it8svpWH45fk*7pyWi(C0d#Xd_A#JDSjDI*j~2vX@{fv9ol~2Z{qIPh5WKdowpU0J$Jh&em z|M|!|IjXINbwITMK-xwH8;4vD?c#!nhO7i0=rx4!t@U~K}uY$$B?QY~ur^77yQNWU$?ec!;J{&u9* zwd%>XZSxCXnK0(JpXi6`DHBJtK)Zt6qLEW3j){L)PRD8Hk49aYPP$2M!FUl+jmOzr@9Ms zomnLgxX~%sziHEI6i(0VRHh(aNj3TmCpea5u7=zcl2`5~Wt|bEHqdKi$&!5BZ@d z@x8!3tY4o+gO#c7+>wDkp*wRj^$XQa^oO6`$phj}bVvQptj?7?BC+qx5se#M)jjcD z{+qn&K>s&xZ2U&<_e!_^#jkdxGYNRbDBm3mFK-4SAcb~|&4?5UFZkws3F*Lv%gXe5 z3>uCm4>0QBS#o%68StvHgcLt8E|Kr@x8f6HiXq{3zbMw~WuUZp2LUmH(iT|SCGhaZ z@U$6J#}d*hX#@?&wp%~|(DC5GHLq`d)N5`$c1Z4XXqV+7%b1x{ZZvPR?SoPJ-u!p2 zR^Pbz_vp8*M#+BmKMkdZf;Bs*SB@F;$a?ez2p}2}SGzI0Ln3V~Ml-5(3{J*eE`KU` za>MD0!{v4gQZ1L?oRQ-6Gk>Co;0$BE5NP<9Cya_7Ae>IXhVkYjUVdeOoc-{#GdxU4MhHgU^@tW)5F$TopDe3~?XK8^@?Bc10>cnGOFpc)qEsuOHch!6k3Znc z4rXL!nk;P!vNICXouK3aw3_OJRqb>EQY~3R8ckfCK_G}Mi{CYx=3T@3)Tz2kbizI; z%Ay|%4h^B{#Cst>Gwn~^Y_{6viKfVtGd}tLz})dm2h7;DQQlHln7?r9k_j7qLx!!- z;N1-!H;x>N%%7k6Gi$dFeI`Nq{Lu02uimt68(KH%w+~^TGO>e%?5B=8!)y1*YiZteC%;yr8j7x zM)KO)ZQJArl*4@2VxG*-FJ@m*507z4J&hhFOW>+9qZBmJ;V>cF{n2(r%Qz`D9J_RN7g9=qvJ*{WD&51!$`ThrvIW9Y%y>t z*Vv+v7Mr7u7Gz?)fx!IZ{*+BcjQ^WFnuLX9Q7&RsL>o#iiyj_7S)A#Gb2vS>WbH`h_swm&6VZ;YbV^iXxTk`_VpW7D{tYpVO^Pv z8Nc9`ZX0^O{o&bn-5imv`}hGHJoz7!IZ3UPapqjW2>MCV8QgBgW4GCWwjeRT5mb17 zkJlv#E}YDXCFAtiGn!Z~^d>^C;RCjIOfwXk5;iL0vuHE^hor{JVfgPd!VC7v-W_BA zkn&;|p~E-PUZ9mKUv3Ct4E-eQKqKvUm`#AHcX+&Rw9*gWHQD2_Dw5afa3d3xaxTmQ z@sXT{u-eFPNFSHA@ptWO1uZ=L=#H_i!G$YyaA7`DoE+_T|iJb#j zZGVD!X;c=o5yCs)m-i;*VZ4uRDc(2XJFVWOS^>tM@b=RQ;{yBy*qmk!2P3FXG&`Y} z3yAd?O#ylXA81VRp%p>vRuC0rGFrT%KkDN89TTJ9Y(}W>!DG?Ag0Vn;{hlc5#-j!D z2hn9>O)i56nXt0KsYB6uaOyDGsv>krIhhi`vHd2%?wb6D_%Qzd!L8;t2JOoh?|Gyu z4(`iGM1N(AngD*O$2b7KL61Fx7fO~D3%vUths~pafY)R(NG=b-prg=`!lEr7DLN~* zsoqg|68zKvT3GBy&#oShh&0=ui;Xc37 zD}iW0wVE7?8)v=U?J<*b5lfB;>Zt}TmvUUQAgBv{P-u`yr#N#)-XKQho#?+d*KN_% zJ$oMHGv(7_L|(r;dQ?Ry3D}8fPcpO#kpW>bso?qvdRzu~Fwu~d>;o@d&~HqX5(yCv z`dlHDO$n7u@S^D156$kjcz_K9)k@!i!U-+BMcaH^cM|=Mf}^J_kIjCQ_hlcy$!ltM zZq-H}IS?sog;>i{jGG;KEy*}ffGcMJO9hS?lPnpn z9tGAlGQ==gB;P4s5O0vaSCEF$+=j4=HOd>s=p2iFx229Fvm%-u9~;^*NSS5O2_*G% ztQZ?T6R=`HXtr5Y14_5)nHFS8OUMtwCjP&ki75%^qYmSA3J(XLGihesqTUj6A0k0`dS#>lxDr6owP+@zQFGww#QEri-v#kE!liaP9exYW zEQ{aNLQ3d3rA^CIZwU?rdN@9X;)eePRQ1N%jZI+Hx;{>MFL*qS13YxI#9a4`gb8gN z;OD3-1d%Rawh&eq$^^Vxib>JKBQfo}^_eL|2Bk;P7Y#tzFpKZ5k4hK-i8X4e7e8+? z075=!I^HQ_j|^CivI($)NZ|r#y^|4~Y`p(SUH1!2=Ge?*vBeQuTXkOVI_VX9BdFuD z#Kp@xHk1?_eZ(ALsIE(FC~DHh~FVonSOWEB`dizOtb z&^pm`tomk~gT`BD6cUeaQfp#bjffq9TPWJMPIdLI0Y$}C&{8%lx`j55seJXZE&(KHp|2k*VP_sGibok#6iKk0U%m6bSy_MX>7y_Hsph>%l4FH~^2ckSdz~cR3lUeLW69Y<_nHgc zYbEJk$T`tXmJ9gA-shc-9)acAcI5+rm1+?RM0ji*Teq+a6#^;pOQk-CXqNw8#PJku`aN02FCW56PGnt(M( zFu<}OIHBN*5T{~X6~==|XAr5~WnK_#RMqlf_1|6XHHG_sQ_qLN`1)EM9DV3-0{1gyc6A#I!<BNXQu?NV~g30Tob#* zh)&OmGfA?mpl1f0xXo|LP#MlAWU0~#fxM`A#0B%NKQ?Bbp&1AH(id5nrN01X#DWdA z{ZRb(_F=h54ie&9^cy~ww0UAPp#Ee376CSVJrTM_WJ zAZx&cCxN*g`iV%thY2NHW!(nU=~0>=-L%1CdO=(#5sz0Ef|{p)?bh1HTlh=LP*6{N zpv1ucl|!qj*C5{1z>f!?b=OD;N17r#QAT4!n9T!!EQ%llcI7gIjh27QGo#63`y&Rp z?b};%!0D5JIT0M%u@&?GD&{{x?O=Yh!GYE9@B{K+L4XaL-WYH?fPwAs+v1v8>vrf{ zu!+?{SIhJ$lsm@k0AbUb+W`t1Er4czhdM|fV!sQZ-XKSlt$x2LV0XJ+u7He|_;AQV zmYM=4Fg+p&==M0Eo?|KMv6aiv2!-1~OiFr;zZ@G@#EZc=0aXA&z|y+qTWaoptiFEN zk{VfJbBG&n!TkS6+k3!ARdj#jGjs3mruRxnvdN~BwwoSmIwheKNGLJAU`Rp=p@?*l zKq%5I$b*Q8h`OkcVi&O?A|jRt8z6SEcOjeiJ9F=DHmJYn{k@<6KX7;MojdoOGv`b_ zGjk4|Z8_vRoSlYGtI`}#F~{2ty7&nfe@++m(_#arCwAS#`l=r!1t;6kx46*Xhz=7opy8QOS6@sPp2`w70 z4+F{+Zy2)%{r=UV9l{?5pZ#)=ldy%8?T?-0UMRU<*p!bc3-f~YNiQ!xbAbm;w3ocd zD9eK!Idt~?u!8Er7CJ*u!-+$bydb;26GMuY02H zu4ib^G1E8;Jc(G`3Pv8>WvRn>10LhFqcG0WV>Jgmn_z7!!qd|sB+|npAcV*N7uF6HLcrFGVbkx$=P<)%;fbYERpTE1{gb%X`!9(t~Hd_mG-T zO6$g^Cl9ZWgI~7MmNk~m1vn!3U1;3y=nz=TE?&S4mS6s$j#v0tDg~#&Ka^);?iU34z6cIye1FxIi5Dn`8!Q$pVI3Ui?YbA}Qx7QLgf zwbYt7&TB7U#g3!M8`lqqrAA5PezIKFT7C`e@tF&i%%6{){-M`Jd-(p^e^r&IKVhA2+^qTt8%cRG4&W|DHSU<`P1i%_S6$IqjTm6O0r` z`TM&`Vd2q!emJVn&r*kn>%ILYS4=IU;KtJd3(5wxYcd%GcZb*2m@ z!#tKtEDYoR((EK5F@0DRWEKXY!C`J#IIMIE!9}Wq<3|n@!P&-S_JSLjQ$>0(#{EA? zPvla_kG=iu*}oUxrn?X1EAn#+rJfi5Mu0i8W3mj;vE>_~1< zaS)arVa^lAN){Yqlh`cRw03?m~AOvpfk$BrjR^`vhao z>H5PLy`n|4ILTpX(FCPjyYBDg;e?f4Q0j*DEw>J0<0 zpoBZ&usKX|5w17}tjF;26OIws59P$Lz`7#SPY_fU?Ck0}Epx>R(;?9r0DBsh7~{M19)|1o-1#JmQNHFuICeOM-*>bLg9re zxfoN6QKUaF9T-kazR-6Pw9M8)HN~J>3a3`@#%OUwmF`EbWF6I+S7>z*JHW5mJWMgX?6su zqv{<8b_^UY0^rY*EHyVb+ITB0a!JU4DWB{P`o77~k8$y?Vl?bG9DqHX2c2d_W#faeBe4 zpF50PalNp+q8JuCIXJJlV%oa#P${m$i=xutp~e1-Cq-Uz26$79O%UD`l`?dVN5zEZ zzv&;*)nTh&IrKjfARYZ9$r4rr;N~!j#HHa2;s2KRkK777^a<%-@ZOO{T7BIp5V$WD z0fEdVBgE+kiA4wyHW0BdNNlLplS-cj4c-dTZ{H;PZD7^53%`j6#WUsx zuz$emj8W@qlwf~14?jOgN9G0> zL+)4%cgK>V3zn5!ytSB?Y3)bd1NE??3+MK5mEK$LnHI)~l?cr^8p_qd+~rxC z;+>6~3QhNh!Z8dMSO5G4HZ*(Rd*dhev*zIgRd-)kw(qtzx4HBl?~!L)-oh#W-z_Jo z%f-gmo(zlK788Co=7IkhxzeayD7z zj9l{Z7@#QQF)|n9G3BgGw5_zrd_=~fP%$C{rhO=6{H#p04bC|5Dowz_S~91m?2w1Kl`e;NU8(N~tfyDC1K`|_(7&u^sP z_D+!2NuwOk9((0Z{4yMX9YjHbpuExLD(e>AEkD5$uj72ROy2FaiR(8T7S5G zh4(IXPsHET#SH_YFz`hUZi9`V(o@JfyyLTFmj8VBRrh1B-1*`=uP&l5_O6#Q>1kct zt2cdQ`L^)3>Cf!lf2fUGHZ9@u7xqQ8k3cHIz6c#pa2|_~lfA7q5F>24N>hatZ*qRq z@g^~j_7v>4LJ%?&5FpoQRGCHEWTwF2vKKJO+!2b*xVKUGv;h=Y9nw6*p^#)n^5l$w zBC!M;6y6q8iS}lSB6+M-^7bZiAQWzyoWZp{po%Yed&8EAXB!s&UX*qPi!Ak*b{iJ{ z4KH7CE9C?$;YJ%4%=zW@BFC^cB}(ZxYL1OIT7_>$QA$5opul4SFYEnKf&k@r!TF~- z6mULC;rt*g=Y^Lk&UwY(7#)AV;C!H!^8$jPLqGuMhpBYL+vreWfb$9rGWRisjyPLx z6e#VGjY7v%(xXBdG7yTeF@ue)C<9YbnR#wvZBT?(Owe*n;R)IRr3Hw!LE##z1i=&D z)_}7xE5f0~C{R#CFbcp_>rvQ$#cqBXW-h4Ckz44+D9F(Z-b^t=w=Hw`kA$En;Yw7Y z&O0HE>b)KcMQlw{7bQf`jU8Hre}$_0NQD}M>s7_r=I^1J+07Oos0QW2jaT31!+n%3 zzEbNXa{Qu_V;UrKEKB_*7Uf>ENaXlal`BXqN>jXzb4iv?o@cbKx8t2oT28i!vg&2& z1UM8$hAF8fWw%W<;#1MKM3Gq{eg!*@kiz0Ak#{1!C;Fan zcjiewT;UbUQR|G+m6Nb9qjQDBSi)S~y)^y%EnuFqUvIujDJ_$|bo?CFdo~!Yn;ec$A6`zlX_VmQI+@)(E!WGwBaeC#;0|bFIX2wc-(mlTp2iVJwBhEF@p6l(MLEz z!2_QKvbQ%DQFO%DN8*QqCAjHDAAz+Du9`%d;H#j#XxJ$yQzOFAFMEdRBdEIgm$C|W ze)KOLvQ%TA+0T;G6CXYQ=*e?*|68xK)<4I+P2ay^)8^G`tCS)94vvP5Knq4iU827- zz-~xHh!lWjegoWE8YC$qQX4e3EC8n`J-AadJ<*RV^W{pZWJSF%HnrL<&tkmECurDu z3B`jTkozze*1`CCWwh3t6N>|1>1k}j$6rDV7{2w;XElosoj$tYUn%orcfIvIgxlkF z=8bb7vDPb(U0%|Rg_(c{UY|1a5PNRJPRq|-t(<1GlG=6HI}?sQ?31%&_G3&FV1Z}aN^i=Z{N1O{z(0_Ilr!a=a+|e(<&`%x}mG};G0js z`UYdC*kRZoXj%sQ17jw=a8J);gB(&UXLc@Iw2q###J=?MnOAmhMNTH6SA7v<&q$sV zuMnpQSU-yh(Ypm`qoTvyu~izge{4H*2nvE8DagYCN)d-^^|yb`No2BEtbhudpZTyi z51?cUMg=g;l5;;i@Z<}YwH}(D?HgNY*pEf)#%Gn>)o}FebJa^Uj$IG{1oG-}I=;FT zXZ<6>*TqEKz7|FSd=}3=%cX$6;0&Z-zgfQc{OZ&@yum$BGeF(Cn*pA=0W(KR$Qn)W!Yl8jm+l ztF)~Cp!ey!jvha}?=aoHW`i*MFf_Mq+=LhI+4xwXL*T>PEf(wQ_atbZJfMFK;WX>K z0z(D|1^I?}Nv>LbDEy(w=xJmbZjdnW5AhuwF0?P|kpF+8nVY*1ma)P?u%MT-80L2| z_cyS&Ehl$&(yTRIx9%Kq=gDW^K6zv79S3&MgqM2`PnlG>Zrp9_9?f!&Twk|ate=Ck zr0+{u%z6}@9hx2NV;B@1j9tWVXB4D^`7vmwB!B3J$Cc@^BhR{&tkzarfyT&KsOqfB zvGjB==*MgZ|AgCxs5IHdGA1;xcgFefhG?}ug9l@q z&0u#a&aZz?08Bb<*;py=rehUA2A{=5xb% zIepQ|6ym{koHK?>IwB%o#!L$RfJbV0=-e{k$cZ#4f#c;_*OxI-%wk2s)eyN2If!$sdVJ%oaw<(S^8N|#T2TQ`FItI^k zmJ?8>Z+n=Qe6{eKskt-XnSoCEBY6C#k64zm@2A5q#}T@fR(wjQ{o541Jt`4f*e~JV zar!PVb$k|JM#~wgnY{T@b5J#a8OZqll8 zAH8|{{N6j%njrs-(#G}L!5WCw6=ou2U)T%updJp`C5QHpLd`)jXt6H-i;;^RTVZ7a4q5g7J*&#Ln*exk@Zix254lm}>(hAFgc?;e>@bq)fov<8~ zO5siV1nQhjb9kLQ`2riY5x84`yA)+6itoAe)w_@ggC@w+K=g1jhk=qfisT+F1qZ+m z5geS4E0gT|V=;8QrX%o9jY#262BWNFH=K3HN-J6kEUscq$1QcIMMdqV4i_mqQ{MRR z+KvA@^UArmKlC{{g8Aj26W9)~~zVxSv_rUnQosahE+ai7iw;_)2 ziDmBq%g6Qdmmg21Kl|Zv+o6jqnzs*RIuckNv zj7XtO<~U9P)0xBKf!SrXQOA4eC{uBI6eN_-);0PlKHIZZ|HI0^J&2wX|XL|{WCVf9i4{_&SH71 z3MbDtV-qqFR$BpwL^B~1%;1YioZz*jOnJdcAtf!>^%Rj&=wb8TG2B+Wl zjlwCdjh?0QUKLJ8Kc@seHa)a-qkKuFr(Z9qz_Dtgbx32SXGA|7UJG<%6?#ZCoj?+> zTS2FAo|oNzy*95Gnokv~P|GI3)G|*8zVmc8{q_l@!wDdygXS3)#p`A0Ex^Hc%U;<_ z?3{$YS;QX|@rtIJ*DFO+T_nuUA}C&1)XM;W$IBQdVFjSz*vA zeV-^Wlxk&#q4`jSp`}ya5D+5lAjmB`UNZ$1sCCB#KN(dPzIhqobj%htO3;Bc4}cC{ z6T)o#oFnw^{csk_$NvI{Yf-)I;4GA(&A0{*awKPonyJvU2p$;}tds`aXDM9 zCX1BF9j}=`h?<#d<+#{+pyP7K&I2x0>4bpPFZU&g|4hgo%O4^|yO29|u)%;Q8NPc# zz~E&{dPJ0|J1l!?o#s5^i}3}IuM+V~QQDt@7E|a#dAtUiNVROjN*Lmy{}g2t{71Z^ z)#Nk^-6^ZnY*unR*p^$!vP#g%HK)96o)a{(U@MIZ3|=Ax45>@AR7gN#A+{EXOQ2Jf z2mu8p>t#UUB}*yd{d(MfI1B#KE-ZMn(DYm}@iQL+Gy zr<6}2B}$f?C|SvN9M{$e`(oE|NbrlG^d2nE1_>kOKn{sja0p}pPA#DfD;=yN_W=;{ z$T~p_GyD^TJkjqkTYCbj0|H786EyUfoTz~i(5eX<>`IQZXk()St(pP@6zo-~7?=;< zsww%PQ+^ToP?Y~1f@LrFIG{if-j=jsKs1b=yCud$|~<`A>@&n3Iv-UjB6p}Jt-jA`VR#HmzJOb`KVAJ z=AL2w_f z7a)WC+ks`jS39plH)`zoznNb0>idHOj#GE(xY}gdU6J`f$j4+#RNXvjd3qOBY{Yde}qvdRQYjs;? zd0K0#Id|8jvFV4WF07Ex70s&62^_v;eEEUrXSMn^SMc23hTOfYVjYt0g_D_{gmAR( z_~-q1XstQ5!y9Q3+4i@*TVB~Vw^7C0`|WiLryWi$lF#MTRuuXR#D~jUeVZ!P+%D1n zj@)t&!T}yQ3K|4evY(qVqe*a@0pf25Kj1L0JPh^103fd)p$R34mWcM@L`Z=%A4B zkU&pIXe+{riwg;te4h)1o5R=Cz0@8r@Rb2oFbanhb9Rb#92m1xERJ{&pxTlUIXZ9D z;Fuw9vs`P7BFE;942l`zGSj)b+@6oxw2;UUQ}W#TusdWjHMCpy_wy65 ztKj^GC41_sbCUJxktyHSuUoi!R=r1)lM&4&3AA+5`Q$d7@`C?TCoD3$a9}DlzD9;^ zi=H0p_5_?3nEy%U@YoUMw^`0xt_E!MuAz2NHvey-Y{r%g1xh6EM^e#n-Sf4ujfKYHccH}&kMxnI(M38IcfRfjW51>{k6+qa^56> zgbcYHClI3`eu_g=#2FrlU_iprgJScTYKO+UmMD&@reZ zn;>xr)4Fm1D@6Wb(z{ou(5-jOkKfQ$x_kD_=YD+Yy_HW`9%t;CH8iQKVQS8d`Vr&i z9Xz2Z@s#^3K4IDRW!&fn|y+-Vn<~B#JHSEn7JEU*#*kV3p`N;B1$FW)0y<0(- zSjxBDeBUeEZmc*oZ`#bSSH68&(rtVw#KG^8o6lS{#P3NmVSUeO^RBhmFHS9N9X%Nf z;qYK$IVjf&sd$mx>@XNQWDHJQiKLOdeQ=yuGafq(V50;}Ad=2QiV$+bi4@e={)KR8 z>3PG>OSd@Ct@v$)%Q^#gD#&%`4)!LqXO5n;taWjU&9`I)S$b&uQt2febe{d*N7HBb z?%|wV15O6Z*TI4jG|PPP!okO1k_QJ7Fb(VczPC*dTi7G+G4_q}o)sMM0sY!9o)REZUrNginD`VWSp;wbx$XfP|k zdPzFfd&qJxRLE=oJPoJ~fEp{H28o(E7kQ6FDdN3pPIh>ZXE?OTMD~=TqTNEqmpQ>q z3^)5#ROS=KbmPl#n$=oQc^UevPkS8V;2WU4Yz|0q7}w8>TJ`L_3P;J2y$v1tlj^?z z#n~m}(YEJ5>3d_x`Gppr&AYd3n{{jHlx_68s|S2G4?>|ynL6W(H$I^bw_4t(#-sbU z+&yl2VdFz}oIki zX}Qd>b7BfR(|d*`^(N8%>wC|FA6__-ij%|`AH&2w+TO)U&_!*NHk%(}A0>?7w`uaLvoR$NJYd~*4IXIDSP|a~r=}Qhc}Yp3Q)%t{94V&iGHfX(SkxJv zY|YJdjB)ny63Jl@EuC!QT)-5XF4LK8-%TWe194-|*)r)az9J*O(~$%=jF;rTPk=F4 z>qMdve~5Ds-kZMY+eLH_QH(lArE%!0G?+7wN+} z*vo7#Tg4t==OkY#N4iexkakKBN?<}S^P+EndU?aSILx*%P?u15Ey zLyp68hxZ)=9ZMX$9gjLWIAu9CIc;}3?sU;P)Oot|Zs&6@@h*#8UUT{0HNv&Xb+hZU zuAjMixuv^JbZc|l=60{!EAA2Qi`*aa(0NSr*zOtQS?9Uk^Gz=guV}AjUXOeI={?c= zF7J1IVtg8W?)G`j*WY)v?{eS6zTf(3{fhk7;NPPdB2D$b)Bl%%K><4gP6mz++#Yy3 z@SDJ21}z)3W6-HV=YqV0s)JSr9Sizlu=n7C!A*nj9{l0ppMo8NLxPtCZw$U8_<`VO zgUU3>yGK?}zG*lQ4 zM7W^gjgJ@}F+O5W#3K;*7-l#6^kSiMtc;PkcP_ z#l&|KKTrHU$suV_Qe4uoq~fGGNzF+&Cf%L%yRpmUXNopun#P*SOf{w!7-TtSde_vK z9GW~Wc}emk$)BZYQYNHamr|dyFr_mU?TfVU(}$;TN#B?LNcs!uZ>4{d{!4}~NHCE|0i6(sg9$$d-}IN4`4p&5`eoygc&fQBy}981?C>U$b@D8QG(<*Jf|Z-kE(q z`}3Umob@@!a^B3Pxx;hwbBlAQ<<8En$!*H*$bC5X$=u_)Kj+2e&C1)6_kO-1zcT;9 z0{4RH1?2@#6#5pX7T!>}y71M}hS9S|UqAZx(Pzf^j2Sy-@|eysOUJAkb7;&%W4*@) zjV&I#YV0FpzZ;i2Zt1wAMGi%^MfVmxQ5;#ER=lcsL-DrayNmB1uN_}HzG8gq_~qk| zjDKf*?*z9AArq1&WKWnfp=LtogtZfHpJ1MFbi#WRew*k!F?iz8iH#F)oA}7YA1BS6 zw0KhY?tmuFKH+%l<*Jgh>`_nm1bM7g3E+1T;P@Yv@P(Gu4WBKv&v*nkr%f4>cbr&j3 z6+SKL~0sN%_rlNIk*PN=-K@|&t*Rkv4Jsz+5XuYRW{zGidH&$W%U7wR_F z8|yRcv+M7u|Ft2hp}t{J!-huM=-Rlv@u#_WTtDpkGfhLAmNq>*Pnwr9uVUWXd54>~ zv;?)RXgS|%YF*j-?)<#@OXfe)rfn-}JJ@!mUDxj2KB;|U`>76(j?#|99iMfEbk6DA z*ZJXs;05gqUh2y2dc5n!t}_eS!qkPs7q%?Cf8h^{jEm+kdSKDz#m2?$i=SQMyd-bQ z;w4|)P;|qwr3p*ZmKH9ZvGnlLt2gG~xbDW+miaC#S+;Z8d&@(Xw=92p`HRcnS^nAb z-&Z)U@LUnNV#tc2D>kfnZ^h*my(`^UhOJClnZI(&%J)`&z4FQ`*Hs~_jH_~1ja@Z) zRrRWlRclx6T6J*M(N(XmdVke7t1PQMR)?)FUfs2N{p#CS-?RFm)z7Scb&bcG>1(#H z*}dl1wKZ!`tczZ^V%=Le#oe^%rWe+GudiEgUjJQpLU(uf&E0#t4|PA<{e1V??oYaZ z-Qcic(1!R8BR5RiP_?0B!}<+(Yu$_nX)zpG{Gl zGB=IeG<#F?rd6AEZ92H==%y2!&TaZ?)0NFGn+I=>*_^sLdvo#TIh&g|FWbC%^WM$o z&Br#sx%txOpEmbxao*y;#jquHOTm`uTk5v7Z8@;z;@04;bGJUf_0OA=Z(efqshfY< z7O-vDw)}0Yw%xz&+wBv!Z`=OS_Rn@W?(p2vykqf>Uw5*dZae4iT(NWO&V4%{+WGR% z|Lpv7=byV=b_MTB+%;;~xLvb$)$dxgYsIc@yKdig|E_=UdV1IEyI_D)^sF@S{rsvU z71y~B|AjbWM1j8(X(k*LA>{42*WSKzTkqSB`y8Ny$G&yOV=+-WMUIKk2mL+24CEFm zjjYkckreEkSf$-b8ekcHK7Em_VdZ2Bt`YKFG9B+4u-ASEu^ifN-uzA4W#uo-TMM>m@#)T}^yiHC(Lvcb5 z!}D1@e@;%YL%?U~tH7R>6LK^;q01sCv<|q2k)`q(HT)6pYRFuel#Rq zyoWf*OOWTS$j3f%05tm}Op&J(le81I?)T93z*jDRPR!D2(vGlQUP{^-tcb}~BpNu) zn9c5E+P*V-)NFPi|vfiUSAG6l9F zN5~EITZChH{;j;n{WG{YZ-$dyv$yw@D%P_e{aN2F`mPCzHNErFmqT zhT{+dgBwa>z3 zA>_bipmefVhe!y**MtnT zBux#?sL#`67nD)d5xn1uZ%^>DDo5f%eF4wzQo}MeRN`0ReG&;~{(V=u969c5Lk)Z} z*y4D715deJ?L%D^S*lwPS)dN`dZ30CYPgcuOI|nrF+`me_2!z8mmAQu|Nl1R^EB} zcy<7c&+*LLksw}lVfphL)*a1 z3~1x+9LLRND&&XtLTfvRHf;=lC)!4|ck+5Nj<<6>f7&YW6>XfhoXpeal1cb}IFFYv zp-nnPW^$RLEjuWmM?F9sJbResK;k6tC-eDx=|fRR%<@yB%{wT{Okv-TGTITekpyYx zb6IOrNFc%k()&oy+q)CM515)~@Ql4zS}obHO%gC11ukCq@Sexwyo!1WzVN<5eA`9bI4tck$o2pkrwt@0`6c5U%|3-+ zRz6yJCE790E5Nk!O2gA{CWjny$q9#i+^-;B+Or5FaXFC@4s{6q(MRyM=|%KaJ!sqK zfgjsQEU(|1OGF0W@?kyF%rDd_Gi2zYxu`z#y#J0kwNH_r!gsvAISO8)KhVHxy8I#8 ztqUb)T>`$@Ok%aMh;s<+EA1ao|cd)7%_-T8rOG~VxO|O(8u$Tol-7o1Y|FxKk^|E(f$n z@MjXq%PPuPopuBnjZ3eFb0Jqgo`}|<4TJ28ao>RQ5(#fZU6L1ZQQ`72gmN7sE#O=M~N4&uY7D^?N78*Eaw5U6F%Q>(RGqwej%SrQ7_t| zlz&1qgn8YtQZUOzc*UY9X8L4QH4q2pn))B(7F zW2q*WwCgPB%exSsM|;TeqRvXZpF5AXrW17Sf%lv}@N=_=UetX_Vs!^Z`Q`P8m-!^f z0^XERXL&!HL6T4wy+D`flThXv=#Y8+6?JGF;%6fr@3VN{@g=@DARnuUhkTjDYJNvv zlh7}%Q1a}s9(lWrXWkx$A}?wmjrPz()HB}p@%&lCpE3TMPNKE%qJE+tYSxoO$j5G8 z|Iu&q@C0@?nzfIR5t>4hVhwrw#M>EO*Fg&(Tbec4;f!+Uq2X=tw|M>)G-V;=uy}nQ zrqIa0wUz;EXzgFPY=vCk#rSv;slzy1gjQTuzQ~8bpD)p7p&alsU<<8%uPRHMOwn%f zwu85GqVKyVw9;s88?7gVtbF=^4Xu3r zAHq4{`?!C4&t-}QgIwmlYQyIkIkIm_S7-_X_s zYiGg}r*(|>Ir_3|E@-?@;L_lFm<{WDS-J1K5BERe`W(;GaXpFk(-Z7djE9gO`eNle zL2f13XUFHX4n4gGFc0hEGILX{BxV;%j$0V}ew z@2rs-lP?hVT{h9r!uK&8hAC%i9zjH3uNEszWVJ&NE7cSFFaAy*1MNK_dT6XU6^h>4 z1wH)@cz%g2$68$>_GFJGH^SP%X`HM{rj@jsE~7WoJE@s|Py1L9%V1Bjm)QySA^S;! z{aE;5c9#s&dZ}C5DBU96D%~mFD;<(vm)?}#kzM79@+5h-e4V@&cBZz<&&toqufqUzk2DJS-|KF|0hCg=@pz!@a@>g@=U4g^vlZ2(OKN^@?oiQ}@Q{k&kg?FL{l; zL1SD?cgUH99EI{PrCFJ8j$Op0IW3|Y~CgkHb=`Q5sKIshdL1Z`NW3oI) zu8?n%x5zgmAIIRq^ob(^wWn3)I zy$u#e7{+q6{Kw+>N$Dp6mll75b&gM#UUK_n$tBm1b1!~(@!N~vT>R?d#fuj%zJKw( zi)SyMy!gt+TQ4rS*g?p}fQ!x-9Y1cqV7YMR!p|38xe$3F{KB9Mffu|kxL=UYe}4Xr z^QS&&#MpWwn~IWV|M!IbgSf}e^Z&))Eo$1s|M-nH#XlxcUo7}<3Doy|cQXE@+sW6` zZt{(E2l-CgLw=C{MSha@!Zi0d9A=MX4RkkLnC_wfqI;$N(%tkfx}V-n572v9BfFmd zN`I3MN`X(nS*VnTdObW~)7Av`QJz{k_W-Obh6$ViZ41k1JBu{`eYxB%H0aD+HT$#xR{gL?Rx;Gy6AXOD{E;WXl%Gsr6 zsS@9IO9kEC>&%{U=2%0%Id<_U0pL-sIo^<8V2BD{6!oK|W=*7rLErrgL3Rw^ zTt+_ZVk*@bZKTI9gugViJTskA8pi*E3L%y5?m~mUu)CtW@@U`kDudp`(0#?V^+%@pxZYzQ(JZY(o@JmKhZ0X-fkD zfBzcOraUkQ91aWPY}jx#n^fV!ynJ%0@~kISK@XAaq_{FOtKe^*v%c{y;cu2(-`GB{ zFhI6NQ%k#{k{MfLC;-PdRGODpp>)mVf-!iQ-F^)UGjw}->a&tSBhu za4fr0EhGg;j7j*SvH}8Y3yIom6qRoDJC}mLv-K8~k=Fp0u8q~Ww zeQ8jbIlBxJE;E$YmhoZ-4#i#oI{YD9C@a%Si>4ZiCeJK|RKv=|8~%cWlOqeR0b?i) zQox`9njIn?^rb9FDnmjKMCc1~W5^wjU$ZXK0hb3DE@FAX%^j{Ur9s4+8f2Jb^#!&0 zYC8Tr04N@$$h?YrTpXpF41-j^Xe4D>PGh?SS%`$Kw=U z2Jk|!FE!K}$_x#9bM~ZC&LYkp!DW>hg6E11p-Sopoe1jo_=ZcW42?>+$EOL| zw>pN6(U<6HNJ;1zsU$Rnx??c=V2CunC@QqLC?&M0FfueM)uSXbC88wIyDwBH_l0Vu zzR+=FQ$ok$op*{?3HBqE$SL?*f**uY*cc*>E({EXO*}(Nc*)?Dpc20n-x5#iUgDAB zUgGXP(Vd05pN1<*_da*n=%S>ArjU{rvWz@Pz9X^+q09ZKh90F`j!c^x7guyt*M}~` zandX^y~!LomH%c>o@v(JWF{puXO$kIbW_=yo*t4LTx3q3T58q@mlc_7aN`kt#E;~b zwYSH`mA7|v#__+7xb_ZlWBn120p$eqV;}5LoPb${Kk3vw3hROc{^T*(TaW!;eV2K3 z|F6Y|-{ULqtK0j&v8?I)%5pDa0xVAr`27F+0V*Z_w}Gr6ACgW&>d z{Lhu_lxt*su7Kdaoe?H6YPdf2kCDNLZhQl6)-yEdL!=3dT>lX>vd}Dpz4^w|lis``7 z23!gidK(t&m;W2cjew&U{OJ&Rs{!nJg7)hX-$Lr}JCGMVpPaKi)gpvd_(G3wdA>Qc z4ndv5{YJr?Y7xVwpghk7f4CId0jCjivGTJW+{!^LPtS4iT>ca9x%@i7{h=fY|GLD# zL|_>BHJ3S;TcVKuJf!@8{jLLf<+A2fan9F+LPZLR0_HqOyjbLh)7~yp>XFa>d~_fk z=Tr{hRDvGmY2Y`!WKeJXN4ooAC5pV(Bacl3=x9ewF=p|3q<~C787vSbox>#P zAjDr^9pwBsf7q)>D7=B8mdVhfXtD3sfjHu%fiw2ix}vVTV+Wrn>c2NyC|}}-_A3DU zxd*}W=wK2|La>)lPr^|54cNmSNuscuKL%$DhQMxt1S%3i8LxZE*vp&>|4HfC+nY(U zAk$%FI2l1kl2OpXqrH7eVE*ZR%s1+n(Rc&vli{z266}L{(dTxZmNN8wr%7`@)PMHyU^Yp zCm)d?$R6y${Du5Xenp#iKQvD-lLt`JtH~CWfRm8pE96z|$JJrat|R#p+&fF&AZO5x z)RXVgR=-2uB5y-xUz2ahde}*9B-f+9G@&%?g_P!_1n~OXi4wd3CHE_`5bf0>vV<%p zH$b;_Cpx4Xp+{OlE|YJ`6WAH-47)b2{PYLuB^Fp}@r2)9Z|sjG)Q9?_&!w;p8bAZ- zAlL*POoM3%Ebi#xWhNZ^D*vDcKC`A#&_u=1SXf|-qw&~poJf{J;jsgWMU>q z=oC7YPNOAooi&4&(lYoFm_@FTtK<{%DV-vI60zJ7fJJxK4Rhvhv_5q-}F)X7`YwegvaR<^hx>@ zeVRT)pQX>yWAu4?oW4L`q%YBz=_~XEeU+Z1uhCQVb$XhGt>N`}70)A^nJ+rx)l&`Z2vkKcSz}&*KJVst%i8z`1 zZ|t&PNuDR`$bIA`mc)$Y8S*SMk*8QP`HiIry-YfEgPCL_`4`J#L)kDkoQ=S!;vl&V zql)jy-549}Cih^Jv7PK7kF$|%6w78gESKf6d{)2;*=RO~jb-Cl5i4fn*#tI`O=6ST z6gHJjV{fOgyPfT3cd$L|Uu-YClkH=7vHk3Bc7WZ(4zhdMA$A`- z%`iu-y~W;U@342Fb3Mo2gZHivphfxhAM8(d1wOBOnT7R1e@i6> z9j=T+N?J(=mya-pDmlYQysP8}{j>)h`FKg*k`MM``$;h6B?U@@q#$Xq6fA{Ep^{z- zlfor~6d^@QQBt%NBgINXq&O*FN{|wzB*`e5q+}^YN|n;2bSXp1l(M9u(lBYbG(sAQ z;d?fAc;`xaQod9G9p7kaj5JmnClyJ>(s*ftG!ed{CQDPKsnRs5M4B$mfbpg>X{Izw znk~(d%CR@RLaLOiq-v=~s+H=bdZ|Hbl;%p;OHI-|saa}~TBZ3?o79dyXOQaj5rP7VkGT261A+3~FNvowb(ps2RzDelgH$WrbBW;p4OIxI^(#_H~X}h!o zyU=$DJ^gLc?b2@P4rvecnDOK^^yH_=>(XB6@AqN-;ZLr^lnzMuKx@hM`S(eOrTgXP z&ZZ{K*u=`F4qaz+qtTd?@7S`SwynLorLER!Y<*khf?EF4X>4V6XNS0TA6wnnR^2(T zuBmn*f8`2=PiBoT|3k1-05r5$JMS>RXy?uh&&7p>tk8 zEa&R}dpWPBr9-a9k2b%$65zBoN{|oOl^wbQ6-lj%q(DJZD*~qid{ zCO6>6X>3PhQ;p!Bwo%+Vk5woaENfKmoyH3Cd6c$M+&Yg{XjM`v_ikftSrRG7j^hDq zoa;QUAGPz`{(F}qJ6c_?AMjA$R$JTLRM}k9SgkD<64o|}z@^weU6b8|wpig?lL+Ku z&YvdyXeTJ&H!I&yu=~WN+3rC*LHWK};d^ssYfF1aTT5$0tyIulFV!~J>n5twZBeB= zQIT$o2wW#NbT-#lwsp>Hs_g7=ZLxpSPElxRQ)rlCht{Rd?m;_6p`lF)rYc{xi@Q^0hHK%T~>-hm%&i4e86Qv|vaHCLT#u1b_#b&5b!($?5q zuj%B!t|j()a_zK#(v_&CxKjz8XY^C(++}xPW_Q1+|6V&&$?IYfIL)+`C8x!rCokS5x`l&n;pic_A6R}ye)rZwQsfg|PkG)0xI zt(B-M^Qvkp+4xR2p_7$1qQD9&*rY~jN<)igYGeJpN@-eUr%t6vn$*xJ<>8vt-l(9; z$@1VZtyFQ??rLIdoKvNZDK52kIdcF(3afa&#GOy4^4|KXh}4Mhc`;r{{ACBvqRfG5t{2&B64_G-&CO%Yoor#Fu0_eC)Yj0VZRZ&@X+$7(AfGCJ zsTFyw#szXTEnEOD9ITZr_iM;>QDG|5Q(uY)C-Jekb*!vwY&02-$tl*mG?VQv*>>0e zHq}-AA}x)x)%KLmpKR$fY-GPN_rz){3pi+Vx_Yg}NxcWDPL&FTx@To&dUO=_6zTD_>Pv8k!CTJ-M@=v|v? z+uP@Qh(Ur~f9Py??`e16#vAuqm&LVhE!GUV)U|ZB^*?|R>w|N9<3j7btDwsEB*?Qq zximL6+dkryCL4`LH58QNIaz&9QNvU!yGlt-lzf37p}>z&;4u}X6!7#21sqd>LN7v*-c*>Xz)LPnajdCqZ>(%t z*eJSl$U@`*VX9_gLrYt;rbYagh~G~BD=0}(AR1E?hzL{UhL)D=xyY(&n_9X=s#G;q zs!_q0nx@d7nx;OdtIz35K2p<_e59reDvYV=f{zFV9}$ZDAryQ>DENp_@DZWlBSOJP zgbJTh)2;XfUl1?&Voc3Y@n@*`GgSN;D*g-=e};-bL&cw=;?Gd=XQ=oyRQwq#{tOj= zhKfH!#h;<#&s6bes`xWi{Fy5LOcj5oia%4upQ+-_RPko2cr#VJnJV5)6>p}BH&ex% zrQ*p_@n8_g%U5caiYH6OlcnY}OU0L^;>%L;WvTeGRD4+~zAP1AmWnS&&1a69&m0wh zj*34=#h;_%&r$K`sQ7bK{5dNA92I|#ia$ripQGZ>QSs-f_;XeKxhno#6@RXZKUc+{ ztK!d9@#m`eb5;DgD*jv*f3AvOt&gd>D*jv*e{P|(;A@_dkE0YG=czR0sWjxNG~}r? zcDRPh(8_?7nDn5MReX=rx}&}r>X62no+@@W>oTz4RoRmrkRxdrqBQb!T0`B&;F zp4I#-brjEP{_`?;UBh#xwXWelOO4C1i&N=P>MGKxbmXag$W!T1>Zz$fsiz24I+S{f zXO#}6p5j@hL#d~DR^_17Q#`A3Q0gh3RXHg26wfMtrJkA!lzNI#m4i}G@vO>0si$~W z<)GA4Jgah0>M5R8IVklM&uadadWvT?|4Kc@vzmXUo|+1jdWulZuTn4Ztmap#mv~n5 ztJKS6quMW|7z@4o)i`1#jlp7-U7Q#@^Ej{mbbK6hO){a3;&E*TiUfe0zpR?e{v9` zY~1MhC|86S;PUqv+w%8fSj%t3h?XbdgIR>Eu8uba$);qZ!?fz!8jQLt9Xiw-O+7EN z?sh6&{_IpQvd3>7xN?`@c-ZT7d4yKc+UitQtyWRb>QtR98#~UCt8j#pLgsfa{Kq<_ zQKwnPH0$Kbm?i{mOjRl-LTg1;1)i5fuyaeosvvshj$26N~jEX3E6zdaP!1 z%RP<)t24fE!wNdz|1(I$J7J|=i?=R-?T(doU#zGHsc$q`f9G^EK=j6HdmvwBZ)&Ws zB<2CXkT-d>$^N&cx`{m1Bz{|X{PXs|ynK*X?SI=L3G&8(-*%%3F#~^-$$t=I|C>TS zLX7RVgG2tr{ulYB$Q}RHiC@0yNZDb)<7Dx~@05%G@_Q}^E%NS%eE~OM#qc$(`hE)s zK8bXanieNqz!gpZ3Ju~lIJs~hE5(<{r*^3l!R>hL0oaVS{H<7f=WFcWWBvbUtXJ1# zr5UTh{5%8Jphg2G#Y)e;DmC1+B0StltvntQ7;G2MAU`Lp9bLO}v>It8r1eHxUwpyK z%}Q`U$)N&|`^=`Gsy}Y{)h%{U+N(G76!^}XLSQ4_h`Dyx1*dqGv~d7sZaABQ)hnu} zVSpfu-2m8cFSxDbDIM_+;Fk-i7>xZIF{~U^k0le?91#{vJ;=vc)C7jORQwj>3;2Ek z9z{k9WhAB$&p0W~|6)cOSH=w0Iu2PoX6!hLT?0_o zZ@l09wmEghrb!3%i>CbSJS0qes{Fv~VV_NVWMt_{&y08Xu5{gCT&Z<5u9SY*i{1Z> zdE<+cK(RxL7vCKsyU#}pz}tPv(ZtDZHHQKRx;J(ZS>?14&F{Ppp%F8 z9&MA!!|28%b>6xuwKemgDlvr^2lE&wZ{P7;ZQb5d*P+jAX=`n1tL$jR7cs^t{$BD9 zwSQkztA}12O>Vt@QeKWe*=RBuL;PKnF~BpWnKDx|Q%p1Qm~PB4W~h(G6*v9=LLihB z3W2fx1g5mr=qFXSbu7~7w6;R)BC>*P&1*$3)7)WPNyF{A2PsP{v4sWkPHZKmt>Z>>M^5mpeICWm=}}#??|m@nO(*HwFZ~~a3JiaG&gGBp%}n{%kje)>m$$B|t~(tV=hidc@tx}%M&38}i)DfL zKlR?1H`U*CV%DgTulnj@y|G4>}*X1m~_s$z0uR3P9@JWbQ z_@BBP{hS=uX6G&X)n&z7JxoF!x@#qE2S-|r*xXtY94QAF{cPpF*b^odP%AVVog^89 zc>*`tPxf&c_4egYCfqjR^sQ$$A3Og;SNREtE@J_I?J18m4&Oi2m}xC>&i_>6{ER+a zpc-%2R_`dAza6 zICk&oy#;IY8ag^!hbAR;b#*0*1q)uf6EWOLYQ4U(rL{e&x~(Y*r6#YX2BjGVnU~}1 z_)mf{Bf*%Ni1)@yYep%RON>*EldaE2wzlBEBB*WaX#X!LI*cxyaBoWcWb9Y5FPNy- z(n?0jyziq{$CFiq6K_qAEk9X&PspkGa+0zr=Yiujhc%P_pPxBC%`j*9BKK)qDxSY})7F?Nj}n*v)7^Q;HL-1b9769!klsNeQYN8;ROy|7 zw9tF60V#qI6h#E22`CDvz@bPLP^3r)Q52Bgi-L3k5m4k!P}Ji&_uO|spLgH6pUWTl z%pPX;B$K_?cdg%EdlJy|KAo80IN>LQD4<6tANDv^2veODAA}ViehmFo{M!k1Zz)3MlV)w zCWxxgQX@OI5o@+w98afThX&-zs$*$hl1NgRbe?<8Im%CdA5r{*`|bgLJ^`QKz-Geq zFx5Tvvu8hrR|K;42lUx@5sN{kM!adh^hPFy;+!;B>3l)Tk1S83T&)sSW~xlGeK5^> zf_u`-={?OeX@(-^xI2z1=3^}{k}r&{2R=C=&&#CGHQ+R~!RI{3UGCq5G zgW95O-Wv^bySL#M8WTF$tlC{gMqkM07i`A6KJ@uy-{8I)vJjq>b}Eu=Z443Jm*jZe zl%(lVv#Axof4{`>J;|Fn0psWmScXjIZ+7!bAN71lxh`%2#Md(tD@F(my<7BfPgMhG zf?xoRXZsZf#5UyDdoc{a!ue&e{D1mbgqIgc3UGY8IAR>E1G|Dh3Y1Y@K&N|z0}u-0 zfCL;6l>{V@a3BE(rw+j8-;OIZE>areSQZaDC60l3DP4s&r5%#}*fcQ3zG$)_`^#a;*4*wT zchoc9Ia~Hl-ZCgB=5H<;&OcQgOjI&>!}OUoBi=j5fCV_>1hvq7A)(&O3Z-g`p%y_L z|3c@@E{{zq^X%hWDm7CQ_C=odqJgiiJ&D zt7X5y`zml`%F0U;CBk}dhx32XF-qVN_@BhSBQpd z$(L)Na-evu=%H25d7%*=2%%CwdhP_WoA4jA zuui!tt1EnQr1T2i#2uZj8>O`>S?+1QQv+A7ksL8_1!UkmfD)hRTM$T#eYha=C+Pht zj2OP3Km)-gWhDkHf&Ibo@C=p$K!S1i6u6jfq5z8Tz8??30TJ*!%=rpPL15R?G-aI>QL@E%G3`8V+ze$2P zKtv=UcXT-j_Hp6Ax*P`H_Fi^=Lf*dM1f6e?owuK$Gs**y|K0%v5MhP0vw-OUru^Rq zjLEIQ;$}>2jw$R90$Z2`0hlEdm}Lc+(s&e>AUn%14)u_}P8(4q?e$$&xeg>^qfY0)x3WkA>!5 zM@7bNb)A5y@HJhfd~!b@kM!1x^PaO{N*13SpPPxg9UGC0r&-#KxyY@j@tZ2ACO%8Q zsuNxF4<_)Yon zDD4$}WfKXw4=>||vMt`734SSdm$Y)zXlGBaylPalJRW{7HXl?LEq6@Yk>qKG_J`^C z3(Q`$p~`syZ{+zL?loH=Y@?c4?8H)|CPy}RHjZcCw)Nh|m{ONR3mS@!m5FCAeQot>wnG4!c{b_O;@g(t5SeDQ6t`YgV1HtJ=-l0& zCTk8ksmS87?OAWW4=58O>kUG9AY))O4B;EriOiz6VvEj~l=>K&#d5jHHmvw+lsMV* z%fyvlo-LeK(qQ7mnPKTc1Ma&t5kVx>&BZ+Z;g6?;3bQ_p+@0%I z!%bb}Y#mUHf2-hs!Q%L1X`OK4oL6m6hS}lsbT93P&{vV~dV$7P0Pn2~h%`xXG=J;*qA4*B_MgZUv%JOD{@NSb0cpcSZKZ}aMYHu! zBj)y+=LRPz47e_X4>EL-R@p_%(I0~YaB#{6Ox9o`A~2Ysh={lufEy0M-pY#TxgXID zM0EXIQhy$R3&aU7$uZ_1slS2;SngqG!>$XKeH^C!elT8u=j&mto(9e?4(|5sh6V=g z$_6@8l41ZL0Ej9B0!or1;;?W1;r!g6p^JxufC0+J!<*f}!8gFg&LIm;1;zvf>|j7n z_(Pq6;0{fZ6QsgT(m?eId@$M1BO0U5od37nffE2X=A+>pWBG9hetO5>v>D)=HUk5z z8YIETKZ}12#zo-pZ_WR%F9;0b|2ni^A3re`v~qi?aL&ZO!;-G8;5SMp8akceN7F%u zwPURs6D@*$j|j&17{E5t{{9ZTI(j-pnHBI~q*oe*-@D zPrh3xtow}bxBfDyJN6WLVwLaneG}4zEuZqn=(Sy@zabQ z_r>D7^BX><;rrE7%$5=YU515i|%~bRE!r>(S>fG9Teycqd6R%mrF>Joez`eq^+l?HTAL+7@dT z0oc9xY6V9}ds2QX7g1U#bDEKvYyJ~{wa`W^bgRK_jWbyJX&z^o~=dX)|+03JSGEChIhVz6(*!*!!-en0K+UqmR^+3Kcd_(veQ?!l?M*| zuvoAG+AR1A2Il}2cJeSNcRvbh!Jyn4umsGH+F($w_fM~N6qo<+uJ)@KbsY2Icrelw zDL{O5T^zzg8JUFwvmZ=j2*Q8yc86iJrSF#_RkF)k6>vQPBks&gs#8-qz4i_2Dy1=j zT_uWX3!haVEYLneq_?}cYPpKmd~O)@)vl+a;Bu|HVjCT>@*wE?sgzG$Te3SA!tfY_ zN+|=`_tn8jU*nB6X-mhV8nQE$j|{`kJ3m29YU-#moi_5Ol($q!$0e0|TTewuFukul zGaX!P(L!R`iN}d*g7pTK%=b8QGH;;@v<^fm*)|N5^)1-lBN?CCa%DCooMJEh)al>a zXMgHFNqLRsFD!B5|GT+(h;_A(}MF+|! zp21(2X{4DB?CnU5;n)zI3a1LLBPx1)ue^#aN%=Obcl=h2QW5b0f3nqhO^jhnJqIU* z?WHj%5=)l*E}w0r*U5wI=9&}aPFggHUL$Y3phZ^}B>TlJn*w8WA?HVexayk9*MsD@ zNxf^7xB41hKUt6`71#TyIiDoS0y5GEWTfd2Mq<&n&b!@#+)@XhQ3d|d{xWL*bJVM^ zzyyUV5SS8hI9wD2CRiDG*c8Sz|7M*3Gr-MF=W5MA^yMH4y4y9#9aKl&kM7%F%^)D? z&3D)F?|3F^yp4UqU1Msd?y)SLweW$tZoA{h6ayc#nJ^8rFOS5WE~#*lR>mHCc@F-R zl77KD)D;n_RGk~A;zmHT*&6U6N^h)Dsl&%yA10nfEa4fuGDtCW!r;854J5dwVBgoJ zgr%@(TdZF6g#AdGbPZ#O@Plw<*fmP#+lVh!3${FCt`sjVw!Xv}yc}4*WTd?|UK@LV zHX*)Tl3zdLowp0wGcf`0I9P`$v~Kp{W$xyw&iJrO!}zg1yt8Q@&eAtk>Q?R-2cHx# z>RO6ao>%dF2Je3w1(@sd&)Al~csvt(W$s21w#JispL{x`ll8<-Vgf6R%s%@|zfgsn zD*i88tVG7CqX+hL*h9lU58qLqtnw8a4Ydld87&ACTNcyWKIQovwf;f_9?rcUzg%r` zR5G&&nqr-Dqgs`IC2t}9yqwl(iDlDf|B#=|8J$L9Nv$|Yt)jCa<>Ezsm6@J8S9&uW zY`ogm5Rl8Ep`{ta)~bK>1oyb!jUOsjilFoe_L+1h{b!aKixgca;UyuJ8{32L{gH^(+Wx;)X8| z@x{L7W<5ILw2J7-my`!WxT`;r@lU~Lj$s4)w@DGwZ}ON{WGI*>EBtFxjuJ-7K{$Ym zzg$pWzCnjl&ap%C{DPhT6IHE$+o&sk;jX!@bB|2&b>v|8P5KtV=qtr_0dV>#3!Fas zKDYBPs%n_&BQU2B6MzdJ!rSZ!njpM&04+e{s13qf_OIamCyLtd`$qlZJ`bUL`)ufE zz6aC%Y);rPZshbGU(L_?UW?{5RkAv&_JL*iNvSe0;Y!KDCLb}m5qtOZulyxFo>0K% zn&LmX_hcv2bFRK~bdoaA9#5;OVhtP;6U@j*8)4y;;?+klxJ=a$t%gjxdexuo@*I*= z^T!^hpYTAFe(>NjoC`GY=Qe&kp1Bz()3)h6>9Bp@sNiEt74$6cEABe^tOkZEYHBJA z8Qv-R8xlnycTYw)*V2_5UQ(?1OnN7VA$oqD#5JNRft z8a$-;h1g_V{&c&c$89^58h@FNsz4(Ru1|ygE_c13$Fn%1x(aM#bwm4MzrhQvNG0qz zGlm2=vdsI?F((Enca4PRP0vpphHkFPRI2&!tBX4pqAhiA_rB5!s5CYtDs#bB3Ok=| zu`_{p+@qm0XbSEGV@SXfeh*N*gDHEqMgZrL1|{>9n`NF(}g2ptP;bgcK6`N~&N z#%Pjwv5^|B-CGC5??iUEMV(Gfkv)2QEq!2`b8($lQHCe^EeeHn?YSen5|#xM4z2Ba z7t$c%t<-bPFd*dJfxr#d}&kG^GB#4~{G;+=@j$=*p-j!$+6wn5G-O5h?oZ==75MgT=w0SK!<%K zaqZb&twra_cKwSdx}F^5(=1w0Yu+3Sdi({5Nc#`4#XrAspuD}zWph@?hVdnd`)WX| z4zsw8nopTz`IJb|6N_Z|QsWc9SOhK_g4Ib@xiXkvsU$SHXYnDCP}yE4y|}0_Ij1jk z9+zFmb$!7SP8r%4#>4~_5MNVzc$;u(ig;78ikDY}x%e_ta3-ZEhSFfw`BB2q>iGtS zKDmaLbNi^~^cky^d&2uz55F+Gu00F!(oXHeQE`H9M5?eeQuma_TKQNqnN(#)^T?6$ z67wY&?Uy}}Gpn7I-^r^q#Ar02kTF?1$Yy%9Qo5qo)Ho-J7JGS+nD<^hFL7A=Rx?W(&( z54NjtnKrlI*q;$NXLSamzraVBsEOdOafiJr3F#$2PUELnFZooezm$$T*&u?^`!-$i zoK}kS*_*+0-b(Tf86B539Y#NgmO5by7wo(B>OvSH=?9>;Z zO(%!cJ&$VkXMvZfEsHX emxH{Er_r_vkq9Az!MrS}wcAXCDl!QR>wf@{5>=i6 diff --git a/memex/Memex.Portal.Shared/Seo/PublicSite.cs b/memex/Memex.Portal.Shared/Seo/PublicSite.cs deleted file mode 100644 index 23d262696f..0000000000 --- a/memex/Memex.Portal.Shared/Seo/PublicSite.cs +++ /dev/null @@ -1,303 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Seo; - -/// -/// 🚨 THE ONE PLACE THAT KNOWS WHICH HOST IS THE PUBLIC WEB SITE. -/// -/// A portal can serve two hosts from one process: a PUBLIC host (www.meshweaver.cloud) -/// that is the canonical address of everything a signed-out visitor may read — the landing, the -/// documentation, the store, the courses — and an APP host (memex.meshweaver.cloud) that -/// carries sign-in, the APIs, MCP, gRPC and the plugin registry. Search engines key ranking to the -/// host, so public content must have exactly ONE address: the canonical link, the sitemap, the -/// Open Graph URL and robots.txt all read the public host from here, and the app host answers a -/// signed-out request for a public page with a permanent redirect to it -/// (). -/// -/// Nothing here is on when Portal:PublicHost is unset: a single-host deployment -/// keeps behaving exactly as before, with the request's own host as the canonical one. Full -/// design: Doc/Architecture/PublicWebPresence. -/// -public static class PublicSite -{ - /// The public host name, e.g. www.meshweaver.cloud. Unset ⇒ single-host. - public const string PublicHostKey = "Portal:PublicHost"; - - /// - /// The node a signed-out visitor sees at / — the landing page, e.g. MeshWeaver. - /// Unset ⇒ the root is not a node page (the portal's own welcome route answers it). - /// - public const string LandingPathKey = "Portal:LandingPath"; - - /// The configured public host, trimmed, or null when the deployment has one host. - public static string? PublicHost(IConfiguration configuration) - { - var value = configuration[PublicHostKey]; - return string.IsNullOrWhiteSpace(value) ? null : value.Trim().TrimEnd('/'); - } - - /// The configured landing node path, trimmed of slashes, or null. - public static string? LandingPath(IConfiguration configuration) - { - var value = configuration[LandingPathKey]; - return string.IsNullOrWhiteSpace(value) ? null : value.Trim().Trim('/'); - } - - /// - /// The base URL every crawler-facing absolute link is built on: https://{PublicHost} - /// when a public host is configured, else the request's own scheme and host. The public host - /// is always https — it is a name on the internet, not a listener. - /// - public static string CanonicalBaseUrl(IConfiguration configuration, HttpRequest request) - => PublicHost(configuration) is { } host - ? $"https://{host}" - : $"{request.Scheme}://{request.Host}"; - - /// - /// True when this request arrived on a host that is NOT the public one while a public host is - /// configured — i.e. on the app host. Such a response must never be indexed: it is the same - /// page under a second address. - /// - public static bool IsAppOnlyHost(IConfiguration configuration, HttpRequest request) - => PublicHost(configuration) is { } host - && !string.Equals(request.Host.Value, host, StringComparison.OrdinalIgnoreCase); - - /// - /// The host that owns SIGN-IN, e.g. memex.meshweaver.cloud. Unset ⇒ this deployment has - /// one sign-in host and nothing is redirected. - /// - public const string AuthHostKey = "Portal:AuthHost"; - - /// - /// The ONE path a sign-in starts on — Login.razor navigates to - /// /auth/login?provider=X. The callback paths (/signin-*) are deliberately NOT - /// here: see . - /// - public const string SignInInitiationPath = "/auth/login"; - - /// The configured sign-in host, trimmed, or null when sign-in is not host-bound. - public static string? AuthHost(IConfiguration configuration) - { - var value = configuration[AuthHostKey]; - return string.IsNullOrWhiteSpace(value) ? null : value.Trim().TrimEnd('/'); - } - - /// - /// True when a sign-in is STARTING on a host that does not own sign-in. Pure, so the rule is - /// pinned without a server. - /// - public static bool StartsSignInOnWrongHost(IConfiguration configuration, HttpRequest request) - => AuthHost(configuration) is { } authHost - && request.Path.StartsWithSegments(SignInInitiationPath, StringComparison.OrdinalIgnoreCase) - && !string.Equals(request.Host.Value, authHost, StringComparison.OrdinalIgnoreCase); - - /// - /// 🚨 A SIGN-IN STARTS ON THE HOST THE IDENTITY PROVIDERS WERE TOLD ABOUT — nowhere else. - /// - /// An OAuth challenge builds redirect_uri from the host the request arrived on. - /// A portal serving brand hosts therefore asked each provider to redirect to a host that was - /// never registered with it, and every provider refused — measured 2026-09-23 on - /// www.meshweaver.cloud, where all three sent their own host: - /// redirect_uri=https://www.meshweaver.cloud/signin-microsoft (and -google, - /// -linkedin), while the registered value is the app host. LinkedIn answered "The - /// redirect_uri does not match the registered value" and Google "Access blocked: This app's - /// request is invalid". - /// - /// Why a redirect and not a registration. Registering every brand host with every - /// provider is N hosts × M providers, and each new brand host silently breaks sign-in again - /// until someone remembers. Sending the VISITOR to the sign-in host first keeps - /// redirect_uri constant forever, so one registration per provider is enough — and it is - /// what this file already says the split means: the app host "carries sign-in, the APIs, MCP, - /// gRPC and the plugin registry". - /// - /// 🚨 Only the INITIATION path moves, never a /signin-* callback. A callback - /// carries the correlation and nonce cookies that the challenge set, and those are scoped to the - /// host that issued it — redirecting a callback across hosts would drop them and fail the - /// handshake. After this, challenges only ever start on the auth host, so their callbacks land - /// there too, with their cookies. - /// - /// 🚨 302, not 301. The sign-in host is configuration and may move; a permanent - /// redirect is cached by browsers past any deploy that changes it. - /// - /// Unset Portal:AuthHost ⇒ this does nothing, so a single-host deployment - /// (memex.systemorph.com has no public host at all) is untouched. Register BEFORE the - /// endpoints that serve . - /// - public static IApplicationBuilder UseAuthHostRedirect(this IApplicationBuilder app) - => app.Use((http, next) => - { - var configuration = http.RequestServices.GetRequiredService(); - if (!StartsSignInOnWrongHost(configuration, http.Request)) - return next(http); - - var authHost = AuthHost(configuration)!; - var logger = http.RequestServices.GetService()?.CreateLogger(typeof(PublicSite)); - logger?.LogInformation( - "[AUTH-HOST] sign-in started on {Host}, which does not own sign-in — sending the visitor to {AuthHost} " - + "so redirect_uri names the host the identity providers were told about", - http.Request.Host.Value, authHost); - http.Response.Redirect( - $"https://{authHost}{http.Request.Path}{http.Request.QueryString}", permanent: false); - return Task.CompletedTask; - }); - - /// - /// The node path a request path stands for: the landing node for / when one is - /// configured, otherwise the path itself with its slashes trimmed. Empty for / with no - /// landing configured. - /// - public static string NodePathFor(IConfiguration configuration, string? requestPath) - { - var trimmed = (requestPath ?? "").Trim('/'); - return trimmed.Length == 0 ? LandingPath(configuration) ?? "" : trimmed; - } - - /// - /// 🚨 THE APP HOST DOES NOT SERVE PUBLIC PAGES TO STRANGERS — it sends them to the public host. - /// - /// A signed-out GET for HTML on the app host, for a path that resolves to a page - /// the admits, answers 301 to the - /// same path on the public host. Everything else is untouched: a signed-in user (their session - /// cookie lives on the app host), a private page (which goes on to the sign-in redirect as - /// before), an API or asset request, and every request when no public host is configured. So - /// a link shared from inside the app keeps working for its author and lands a stranger on the - /// address search engines know. - /// - /// decides publicness, reactively; the default asks the - /// mesh through — the same gate the head is built from — and - /// a test hands in its own decision so the routing rule is pinned without a mesh. The ONE - /// Task bridge is here, at the ASP.NET middleware boundary, through - /// - /// (never .ToTask()). Register AFTER authentication (it reads User.Identity) and - /// before the page endpoints. - /// - /// The decision is the boolean projection of the gate on purpose: "not public" and "the - /// gate could not decide" both mean NO redirect here, and no redirect is the fail-closed - /// action — the request continues into the app's own handling, which evaluates the tri-state - /// gate itself and answers the visitor (sign-in, or unavailable). A 301 that named a page on - /// the public host on an undetermined verdict would be the assertion this avoids. - /// - public static IApplicationBuilder UsePublicHostRedirect( - this IApplicationBuilder app, Func>? isPublicPage = null) - { - var decide = isPublicPage ?? DefaultIsPublicPage; - return app.Use(async (http, next) => - { - var configuration = http.RequestServices.GetRequiredService(); - var publicHost = PublicHost(configuration); - if (publicHost is null - || !IsAppOnlyHost(configuration, http.Request) - || !HttpMethods.IsGet(http.Request.Method) - || http.User.Identity?.IsAuthenticated == true - || !AcceptsHtml(http.Request)) - { - await next(http); - return; - } - - var nodePath = NodePathFor(configuration, http.Request.Path.Value); - if (nodePath.Length == 0 || !SeoResolver.IsCandidatePath(nodePath)) - { - await next(http); - return; - } - - var logger = http.RequestServices.GetService()?.CreateLogger(typeof(PublicSite)); - var isPublic = await decide(http, nodePath) - .FirstAsync() - .Catch(_ => Observable.Return(false)) - .ObserveCompletion( - ex => logger?.LogWarning(ex, "Public-host decision for '{Path}' faulted after the response had settled", nodePath), - http.RequestAborted); - if (!isPublic) - { - await next(http); - return; - } - - var target = $"https://{publicHost}{http.Request.Path}{http.Request.QueryString}"; - http.Response.Redirect(target, permanent: true); - }); - } - - /// - /// 🚨 HEAD IS ANSWERED, NOT REFUSED. Razor component endpoints accept only GET - /// and POST, so a link checker, an uptime probe or a crawler asking HEAD /Doc got - /// 405 from a page that serves fine — measured 2026-09-11. This runs the request as a - /// GET and discards the body, which is what HEAD means. - /// - /// 🚨 Register BEFORE UseRouting: an endpoint is matched by method, so once routing - /// has run the GET-only page route has already failed to match and the rewrite changes - /// nothing (the 405 is routing's own answer). A host that never calls UseRouting - /// explicitly gets one inserted ahead of its first middleware, which puts this after it. - /// - public static IApplicationBuilder UseHeadAsGet(this IApplicationBuilder app) - => app.Use(async (http, next) => - { - if (!HttpMethods.IsHead(http.Request.Method)) - { - await next(http); - return; - } - var method = http.Request.Method; - http.Request.Method = HttpMethods.Get; - var body = http.Response.Body; - http.Response.Body = Stream.Null; - try - { - await next(http); - } - finally - { - // Restore both: middleware that resumes after `next` (logging, tracing) must see - // the request as the HEAD it was. - http.Request.Method = method; - http.Response.Body = body; - } - }); - - private static bool AcceptsHtml(HttpRequest request) - { - var accept = request.Headers.Accept.ToString(); - return accept.Length == 0 - || accept.Contains("text/html", StringComparison.OrdinalIgnoreCase) - || accept.Contains("*/*", StringComparison.Ordinal); - } - - // The production decision: the same anonymous-gated resolution the crawler head is built - // from. A page the gate refuses — or a mesh that does not answer — is "not public", and the - // request continues to the app's own handling (sign-in redirect), never to a redirect that - // would name a page on the public host. Cold, reactive; bridged once by the caller. - // - // 🪪 ASKED AS THE STRANGER (MeshWeaver#5227). This middleware runs BEFORE UserContextMiddleware - // on purpose (a request that only bounces must never mint a guest identity), so no - // AccessContext exists for the request yet. SeoResolver.Resolve captures the AMBIENT identity - // for its authoritative owner read, and it captured null: the read left portal/reads-{meshId} - // with no identity, the never-null guard refused it ("hub=portal/reads-…, - // message=GetDataRequest, target=Store was posted with no AccessContext"), the resolver's - // fail-open read the refusal as "not public", and the app host SERVED every public page - // instead of redirecting it. The decision is literally "may a signed-out visitor read this?", - // so it runs as the identity the portal gives that visitor — ResolveHttpCaller, the one - // resolver for surfaces that run outside UserContextMiddleware; every request that reaches - // this line is unauthenticated, so that is the well-known Anonymous identity. It widens - // nothing: Anonymous reads exactly what the anonymous gate admits. - private static IObservable DefaultIsPublicPage(HttpContext http, string nodePath) - { - var hub = http.RequestServices.GetService(); - if (hub is null) - return Observable.Return(false); - var stranger = UserContextMiddleware.ResolveHttpCaller( - http.User, http.RequestServices, - http.RequestServices.GetService()?.CreateLogger(typeof(PublicSite))); - return hub.ServiceProvider.GetService() - .RunAs(stranger, () => SeoResolver.Resolve(hub, nodePath)) - .Select(data => data is not null && data.Remainder is null); - } -} diff --git a/memex/Memex.Portal.Shared/Seo/SeoResolver.cs b/memex/Memex.Portal.Shared/Seo/SeoResolver.cs deleted file mode 100644 index c3329c4641..0000000000 --- a/memex/Memex.Portal.Shared/Seo/SeoResolver.cs +++ /dev/null @@ -1,1032 +0,0 @@ -using System.Collections.Immutable; -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.Graph; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Seo; - -/// -/// A page's OWN icon, ready to hang off a <link rel="icon"> in its head. -/// -/// The icon as something an href can carry: a URL the portal already -/// serves (a content-collection file, a shipped glyph), or an inline data: URI for an icon -/// whose value is MARKUP rather than a location (an inline <svg>, an emoji). -/// The media type to declare, or null when the value alone does not pin one -/// down — declaring the WRONG type is worse than declaring none, and a consumer that ranks icons -/// by type would then rank this one on a lie. -public sealed record PageIcon(string Href, string? Type) -{ - /// - /// The link relation — icon for the tab strip, apple-touch-icon for Safari's - /// bookmark / Add-to-Dock tile, which is a SEPARATE channel and never falls back to the - /// favicon. - /// - /// 🚨 An init PROPERTY, deliberately not a third primary-constructor parameter. - /// A record's primary constructor is a BINARY contract with every module already compiled - /// against it — adding a parameter replaces the signature even when it carries a default, and - /// the host aborts at boot on the mismatch in both directions. A new property is additive, so - /// this cannot split an image; scripts/check-record-signatures.py is what says so. - /// - public string Rel { get; init; } = "icon"; - - /// The sizes attribute, for a raster icon that has exactly one pixel size. Null - /// for a scalable (SVG) icon, where declaring a size would tell a browser that ranks icons by - /// size something untrue. Same init reasoning as . - public string? Sizes { get; init; } -} - -/// -/// What the crawler-facing head needs to know about the requested page: the resolved node and -/// the pieces of its content the meta tags are built from. Only ever produced for pages an -/// ANONYMOUS visitor may read (the decision) — a private node's -/// name/description must never leak into markup served to a logged-out crawler. -/// -public sealed record SeoPageData(MeshNode Node, string? Description, string? Image) -{ - /// The node's pre-rendered markdown body, when the node CARRIES one. Prefer - /// : this is null for every node whose markdown was never mirrored onto the - /// node (a plugin cover's body, a node read through a projection that drops the mirror), - /// and a crawler served nothing for exactly those pages (#4056). - public string? PreRenderedHtml => Node.PreRenderedHtml; - - /// - /// The part of the request path BEYOND the resolved node — a layout-area route - /// (/{node}/{area}/{id}) or a path that does not exist and fell back to its nearest - /// ancestor. Null when the URL named the node exactly. The head reads it to keep a fallback - /// page out of the index: the framework answers such a URL with the ancestor and HTTP 200, - /// which a search engine otherwise files as a soft 404 against the ancestor. - /// - /// init property, not a primary-constructor parameter — the record's constructor is - /// a binary contract with every module compiled against it (see ). - /// - public string? Remainder { get; init; } - - /// - /// 🚨 THE PAGE BODY A CRAWLER READS — the node's content as HTML, rendered on the server so the - /// FIRST HTTP response carries it. A Blazor Server page otherwise ships an empty - /// <body> and fills it over the circuit, and a crawler does not hold a circuit: - /// measured 2026-09-11 on memex.meshweaver.cloud, every public page — documentation, course - /// lessons, plugin covers — arrived at Googlebot with a rich head and no text at all, and the - /// host had zero pages in Google's index. - /// - /// The authored markdown is the source of truth, rendered through the same renderer - /// as the interactive view. Cached HTML is a fallback only when the source is absent: - /// neither stored HTML field records which source or renderer produced it, so preferring - /// either can resurrect an old page after an edit. Null when the node has no - /// document-shaped content (a data node, a pure layout-area page). Only ever produced for a - /// node the admitted, like every other member here — a gated - /// chapter is refused before this is computed. - /// - public string? Body => SeoResolver.RenderBody(Node); -} - -/// -/// 🚨 THE CARD A GATED PAGE SHARES WITH — composed from the nearest ancestor the -/// ADMITS, plus the request path's own segments. Never from the page -/// that was withheld. -/// -/// What it fixes. A link into a private subtree of a PUBLIC root unfurled as the bare -/// site card. Measured 2026-09-20 on www.meshweaver.cloud: /InitechReporting carried -/// og:title "Fund Reporting", a description and /api/og/InitechReporting.png, while every -/// descendant — …/Funds, …/Funds/InsuranceCore, -/// …/Funds/InsuranceCore/2026-06-30 — carried og:title "MeshWeaver" and -/// /api/og.png. Not depth (/Doc/Architecture/AccessControl unfurls fully on the same -/// host) but ACCESS: that partition's _Policy declares a RedirectOnDenied and no -/// PublicRead, so its root is a public listing over gated content. -/// -/// -/// 🚨 Why this discloses nothing. The tail of is the request path's -/// own segments — they are in the URL the sharer pasted into the chat, so rendering them back tells -/// the reader nothing they are not already looking at. Everything else belongs to the PUBLIC -/// ancestor and is already served to anyone who asks: its name, its description, and its share image -/// — whatever it authored, else its drawn /api/og/{ancestor}.png — which the same gate already -/// serves anonymously, so an unfurler can fetch what the head declares. The withheld node's -/// own , description, icon and content are never read — see -/// , which cannot read them because it is never given -/// them. -/// -/// -/// When no ancestor is public either there is no card here, and the caller keeps the site -/// card. That is the honest floor: a private page under a private root says only what its URL -/// already said. -/// -/// The ancestor's title, then the requested path's segments below it. -/// The ancestor's description, plus the partition's call to action when it -/// declares one. Null when the ancestor has neither. -/// The ancestor's share image — root-relative or absolute, exactly as -/// returns it, so the caller prefixes the host and declares its -/// size the same way it does for a public page. -/// The node the card was built from. Not rendered; it is what a log line -/// and a test name to say WHICH ancestor answered. -public sealed record SeoAncestorCard( - string Title, string? Description, string Image, string AncestorPath); - -/// -/// 🚨 THE CARD A GATED PAGE SHARES WHEN ITS OWNER OPTED IN — the node's OWN name, its own authored -/// summary, its own share image and its own mark, for a page whose CONTENT a logged-out visitor -/// still may not read. -/// -/// Opt-in, default off. It is produced only where -/// is set on the node's scope or an ancestor's; -/// with the flag unset — every partition, until someone sets it — a gated page behaves exactly as it -/// did, which is the control that matters. What it answers is the question a partition owner keeps -/// asking of a gated link: couldn't it say the name of the node, and the description, and the -/// icon? It can, once they say so. -/// -/// 🚨 This IS a disclosure, and that is the point. A link into an opted-in partition -/// tells whoever holds it what the page is CALLED and what it is ABOUT. That is why it is not a -/// default and never inferred: the precedent is /health, which was deliberately changed to -/// print the PARTITION and not the node's own name (#4258/#3890) because a control that works by -/// disclosing other people's node titles is a disclosure surface wearing an instrument's colours. -/// The difference here is consent — the owner of the data states it, per scope, in the same -/// _Policy that governs everything else about that subtree. -/// -/// What it deliberately cannot carry: the node itself, and therefore the BODY. The -/// crawler-facing body component renders a , which only -/// produces and which this page is still refused — so the opt-in -/// cannot be widened into serving content by a caller that misreads it. -/// -/// The node's own name (its id when it has no name). -/// The node's AUTHORED summary, plus the partition's call to action when -/// it declares a . Null when it has neither. -/// Never the body — see . -/// The node's own share image, exactly as -/// returns it: authored when it declares one, else its drawn /api/og/{path}.png. The routes -/// serve it under the same flag (), so what the head -/// declares can actually be fetched. -/// The node the card describes — what a log line and a test name. -/// The node's own icon links, as -/// builds them for a public page: empty for a node with no mark of its own. -public sealed record SeoPreviewCard( - string Title, - string? Description, - string Image, - string NodePath, - IReadOnlyList Icons); - -/// -/// A node whose PICTURE the share-card and icon routes may draw, and WHICH decision cleared it. -/// -/// 🚨 The second field is not bookkeeping — it decides the response's cache directive, and -/// getting that wrong makes a revocable disclosure unrevocable. A node the -/// admits is public, max-age=… cacheable: everything drawn on its -/// card is already served to anonymous callers on the page itself, and crawlers refetch cards -/// aggressively. A node cleared only by is NOT, -/// because a policy is revocable and a shared cache never re-asks the origin — so it is served -/// private, no-store, and flipping the flag off stops the origin serving it on the next -/// request instead of up to a day later. -/// -/// 🚨 What that does NOT reach is the UNFURLER's own copy: Slack, Teams, iMessage and LinkedIn -/// keep a preview for hours to days and no response header controls it (the same reason a fixed page -/// can take hours to re-scrape). Revocation is therefore immediate at the origin and -/// eventually-consistent at the consumer, and Doc/Architecture/LinkPreviews says so where an -/// owner reads about the flag. -/// -/// The node whose picture may be drawn. -/// True when the gate admitted it — the shared-cacheable case. False -/// when only the preview opt-in cleared it. -public sealed record ShareableNode(MeshNode Node, bool AnonymousReadable); - -/// -/// Server-side SEO resolution for the initial HTTP response. Reactive end to end; the ONE -/// Task bridge sits at the Razor static-SSR boundary (), the -/// same adapter shape the MCP/REST surfaces use. Fail-open to null: a slow or faulted mesh -/// never delays page delivery — the page just ships the generic head. -/// -public static class SeoResolver -{ - /// Per-request stash key so the head and body components resolve ONCE. - public const string HttpContextItem = "Memex.Seo.PageData"; - - /// - /// How long any one of these resolutions may take before the page ships without it. Named - /// rather than repeated, so the public-page pass and the public-ancestor pass of the SAME HTTP - /// response cannot drift into different budgets. - /// - private static readonly TimeSpan ResolveBudget = TimeSpan.FromSeconds(3); - - /// Route prefixes that are never mesh nodes — skipped without touching the mesh. - /// "mcp" is deliberately NOT here: Mcp is a real partition (the MCP Server store - /// plugin) whose cover renders at /Mcp, so it resolves like any node page; MCP - /// protocol traffic never reaches the page render path this resolver serves (the endpoint - /// route and NonfileRouteConstraint keep it off). - private static readonly ImmutableHashSet NonNodePrefixes = ImmutableHashSet.Create( - StringComparer.OrdinalIgnoreCase, - "login", "api", "_blazor", "_framework", "_content", "dev", "static", "webhooks"); - - /// Whether the request path can be a node page worth resolving. - public static bool IsCandidatePath(string? path) - { - var trimmed = (path ?? "").Trim('/'); - if (trimmed.Length == 0) - return false; - var first = trimmed.Split('/')[0]; - return !NonNodePrefixes.Contains(first); - } - - /// - /// Resolves the request path to its node and gates it through - /// . Emits null when the path is no node, the node - /// is not anonymous-readable, or anything errors/times out. Cold. - /// - public static IObservable Resolve(IMessageHub hub, string path) - { - var access = hub.ServiceProvider.GetService(); - var caller = access?.Context ?? access?.CircuitContext; - return ResolveGated(hub, path) - // The permission fold can emit outside the HTTP/circuit execution context. Carry - // the actual caller into the owner read; neither the anonymous verdict nor the - // emission thread's identity is a replacement for the requesting viewer. - .CarryAccessContext(hub.ServiceProvider, caller) - .SelectMany(gated => gated is not { Readable: true } readable - ? Observable.Return(null) - // Path resolution is discovery and may carry an older query snapshot. Read the - // admitted node from its owner, then recheck anonymous access: a policy may have - // changed during that read, and a signed-in caller's read grant is not publicness. - : hub.GetMeshNode(readable.Resolution.Prefix) - .SelectMany(node => node is null - ? Observable.Return(null) - : AnonymousGate.AllowAnonymous(hub, readable.Resolution.Prefix) - .Take(1) - .Select(allowed => allowed - ? new SeoPageData(node, ExtractDescription(node), ShareImage(node)) - { - Remainder = string.IsNullOrEmpty(readable.Resolution.Remainder) - ? null - : readable.Resolution.Remainder, - } - : null))) - .Timeout(ResolveBudget) - .Catch(_ => Observable.Return(null)); - } - - /// - /// 🚨 THE ONE RESOLVE-AND-GATE PASS. Every SEO surface — the page head, the share card route, - /// the icon route — asks this and nothing else, so there is no second permission rule here to - /// drift from the page's. It answers TWO facts and keeps them apart: the node the URL names, and - /// whether the admits it. - /// - /// The node is returned whether or not the gate admits it, and that is not a leak — - /// it is the same shape this has always had (the gate is evaluated AFTER the resolution because - /// is the router's own literal resolution and is not access-filtered). - /// What decides disclosure is what each CALLER does with Readable: - /// discards the node entirely when it is false, and the preview path below discloses only what - /// opts in. - /// - /// Raw: no time bound and no Catch — every public entry point applies its own - /// and fail-open, exactly as before. - /// - private static IObservable ResolveGated(IMessageHub hub, string path) - { - var resolver = hub.ServiceProvider.GetService(); - if (resolver is null) - return Observable.Return(null); - return resolver.ResolvePath((path ?? "").Trim('/')) - .Take(1) - .SelectMany(resolution => resolution?.Node is not { } node - ? Observable.Return(null) - // The BOOLEAN projection is correct here and stays (#2901): "not public" and "the - // gate could not find out" both mean WITHHOLD the rich metadata, and neither is - // asserted to a human — an omitted og: block is not a claim about the visitor. A - // caller whose answer becomes a redirect, a status code or a message must use - // AnonymousGate.Evaluate and branch on IsUndetermined first. - : AnonymousGate.AllowAnonymous(hub, resolution.Prefix) - .Take(1) - .Select(allowed => (GatedNode?)new GatedNode(resolution, node, allowed))); - } - - /// What one URL resolved to, and whether a logged-out visitor may read it. - /// The literal path resolution — Prefix is the scope every - /// policy and permission question below is asked about. - /// The node the URL names, gated or not. - /// The 's fail-closed verdict. - private sealed record GatedNode(AddressResolution Resolution, MeshNode Node, bool Readable); - - /// - /// The static-SSR boundary bridge — the only Task on this surface, and it goes through - /// rather than Rx's .ToTask(): - /// the latter resumes its awaiter INLINE on the signalling thread, which here is whichever mesh - /// hub answered the path resolution — so the rest of the Razor render would run on that hub's - /// action block (forbidden since 2026-08-30). - /// - public static Task ResolveAsync(IMessageHub hub, string path) - { - var logger = hub.ServiceProvider.GetService() - ?.CreateLogger(typeof(SeoResolver)); - // Resolve()'s trailing Catch turns every FAULT into a value, so on the fault path it emits - // exactly once. FirstAsync is kept because the remaining case it does NOT cover is an - // upstream that completes EMPTY: FirstAsync raises there, and ObserveCompletion would - // otherwise settle with a null that reads as "no SEO data" — the same answer a private node - // gets. Behaviour is unchanged from the .ToTask() this replaced, which raised identically. - return Resolve(hub, path) - .FirstAsync() - .ObserveCompletion(ex => logger?.LogWarning( - ex, "SEO resolution for '{Path}' faulted after the head had already been produced", path)); - } - - /// - /// 🚨 THE CALL-TO-ACTION KEY. The one sentence this surface adds to a gated page's card. It is - /// platform-owned text, so it follows the VIEWER's language and lives in the catalog - /// (strings.{en,de}.json) like every other string a human reads. Public so the head, the - /// tests and a translator can all name the same key. - /// - public const string CallToActionKey = "seo.gatedCard.accessRoute"; - - /// - /// How far above the requested path a public ancestor is looked for. The walk is over the - /// ancestors of the deepest node that EXISTS (), never - /// over the raw URL segments, so a 40-segment URL into nothing costs one resolution and stops — - /// this bound only ever bites content nested deeper than any in the fleet, and it is what keeps - /// an anonymous request from buying an unbounded number of permission folds. - /// - private const int MaxAncestorsWalked = 12; - - /// - /// 🚨 THE FALLBACK for a page the gate WITHHELD: the nearest anonymous-readable ancestor's card, - /// captioned with the requested path. Emits null — meaning "keep the site card" — when the URL - /// matches no node at all, when no ancestor is public either, or when anything errors or times - /// out. Cold. - /// - /// Call it only when answered null: this walk starts STRICTLY ABOVE - /// the node the URL resolved to, so it can never re-serve a page that was withheld. - /// - /// The hub whose path resolver and permission evaluator answer. - /// The node path the visitor asked for. - /// The VIEWER's language tag, read explicitly off their AccessContext by - /// the caller — never from an ambient culture. Null ⇒ English. - public static IObservable ResolvePublicAncestor( - IMessageHub hub, string path, string? locale = null) - { - var resolver = hub.ServiceProvider.GetService(); - var requested = (path ?? "").Trim('/'); - if (resolver is null || requested.Length == 0) - return Observable.Return(null); - - return resolver.ResolvePath(requested) - .Take(1) - // 🚨 The walk starts at the deepest node that EXISTS, not at the URL's last segment. - // ResolvePath already falls back to the nearest existing ancestor — that is how - // `/InitechReporting/Subscribe`, a layout-area route, resolves to `InitechReporting` and unfurls - // as it today — so a resolution of null means NO node matches any prefix of this URL. - // There is nothing above it to find, and the honest answer is the site card. - .SelectMany(resolution => resolution is null - ? Observable.Return(null) - : NearestPublicAncestor(hub, AncestorPaths(resolution.Prefix)) - .SelectMany(ancestor => ancestor is null - ? Observable.Return(null) - : CallToAction(hub, resolution.Prefix, locale) - .Select(callToAction => - ComposeAncestorCard(ancestor, requested, callToAction)))) - // Same budget and the same fail-open-to-null as Resolve: a slow or faulted mesh costs - // the card, never the page, and the caller's site card is the floor. - .Timeout(ResolveBudget) - .Catch(_ => Observable.Return(null)); - } - - /// - /// The static-SSR boundary bridge for — same shape and same - /// reasoning as : ObserveCompletion, never .ToTask(), - /// because the latter would resume the rest of the Razor render inline on whichever mesh hub - /// answered. - /// - /// The hub whose path resolver and permission evaluator answer. - /// The node path the visitor asked for. - /// The viewer's language tag; null ⇒ English. - public static Task ResolvePublicAncestorAsync( - IMessageHub hub, string path, string? locale = null) - { - var logger = hub.ServiceProvider.GetService() - ?.CreateLogger(typeof(SeoResolver)); - return ResolvePublicAncestor(hub, path, locale) - .FirstAsync() - .ObserveCompletion(ex => logger?.LogWarning( - ex, - "The public-ancestor card for '{Path}' faulted after the head had already been produced", - path)); - } - - /// - /// 🚨 THE COMPOSITION — and the whole disclosure argument in one signature: it takes a - /// the gate ADMITTED plus the requested PATH, and there is - /// deliberately no overload that takes the withheld node. Pure — no hub, no IO — so everything - /// the card can possibly say is decided here, and is testable without a mesh. - /// - /// The nearest anonymous-readable ancestor's page data. - /// The node path the visitor asked for. - /// The localized sentence to append, or null for none. - public static SeoAncestorCard ComposeAncestorCard( - SeoPageData ancestor, string requestedPath, string? callToAction) - { - ArgumentNullException.ThrowIfNull(ancestor); - var tail = TailBelow(requestedPath, ancestor.Node.Path); - var ancestorTitle = ShareTitle(ancestor.Node); - var description = FirstNonEmpty(ContentString(ancestor.Node, "ogDescription"), ancestor.Description) is { } text - ? callToAction is null ? text : $"{text} {callToAction}" - : callToAction; - return new SeoAncestorCard( - tail.Length == 0 ? ancestorTitle : $"{ancestorTitle} · {tail}", - description, - ancestor.Image ?? SiteCard, - ancestor.Node.Path); - } - - /// - /// The requested path's segments BELOW , joined for reading — - /// exactly the text the sharer pasted, in their own spelling, and nothing else. Empty when the - /// requested path IS that ancestor, or is not under it at all: the walk cannot produce the - /// latter, and an empty tail is the one answer that invents nothing if it ever does. - /// - private static string TailBelow(string requestedPath, string ancestorPath) - { - var requested = requestedPath.Trim('/'); - var ancestor = ancestorPath.Trim('/'); - if (ancestor.Length == 0) - return requested.Replace("/", SegmentSeparator); - if (!requested.StartsWith(ancestor, StringComparison.OrdinalIgnoreCase)) - return ""; - return requested[ancestor.Length..].Trim('/').Replace("/", SegmentSeparator); - } - - /// Punctuation, not words: the path separator spaced out for a card, and the - /// title/caption divider the page <title> already uses. Nothing here is language, - /// so nothing here is translated. - private const string SegmentSeparator = " / "; - - /// - /// The strict ancestors of , DEEPEST FIRST — the candidate order for - /// "the nearest public ancestor". The node itself is excluded on purpose: this surface is only - /// ever reached because the gate withheld it, and a card built from it would BE the leak. - /// - private static IEnumerable AncestorPaths(string nodePath) - { - var segments = (nodePath ?? "").Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); - var walked = 0; - for (var depth = segments.Length - 1; depth > 0 && walked < MaxAncestorsWalked; depth--, walked++) - yield return string.Join('/', segments.Take(depth)); - } - - /// - /// The first of the anonymous gate admits, or null when none is. - /// Concat over a LAZY sequence with Take(1): the next ancestor is resolved only - /// because the previous one was withheld, and nothing above the hit is read at all. - /// - private static IObservable NearestPublicAncestor( - IMessageHub hub, IEnumerable ancestors) => - ancestors - .Select(ancestor => Resolve(hub, ancestor)) - .Concat() - .Where(data => data is not null) - .Take(1) - .DefaultIfEmpty(null); - - /// - /// The localized call to action for a gated path, or null when the partition offers no route in. - /// A is the owner SAYING there is one (a - /// sign-up page, a course cover); without it, telling a reader to sign in would be advice that - /// leads nowhere. - /// - /// The redirect TARGET is deliberately not named on the card. It is not in the URL the - /// sharer pasted, so printing it would disclose something new — and the link on the card already - /// goes there, because that is what the redirect does to whoever clicks it. - /// - /// The policy read gets its own Catch: a faulted policy chain costs the sentence, not the - /// whole card — the same fail-open-to-less-information this resolver applies throughout. A read - /// that never emits is bounded by the caller's budget instead. - /// - private static IObservable CallToAction(IMessageHub hub, string deniedPath, string? locale) => - hub.GetRedirectOnDenied(deniedPath) - .Take(1) - .Select(redirect => string.IsNullOrWhiteSpace(redirect) - ? null - : LocalizationCatalog.Get(CallToActionKey, locale)) - .Catch(_ => Observable.Return(null)) - .DefaultIfEmpty(null); - - /// - /// 🚨 THE NODE'S OWN CARD FOR A GATED PAGE — and it exists ONLY because the partition's owner - /// switched on. Emits null for every path that - /// is readable anyway (the caller already has for those), for every path - /// whose scope has not opted in — which is every scope by default — and for anything that errors - /// or times out. Cold. - /// - /// What it discloses, exhaustively: the node's Name, its AUTHORED summary - /// ( — Description, else abstract/description/ - /// tagline/summary/headline), its share image, and its own icon links. - /// Never its body: this returns no and carries no - /// , so the body the crawler-facing SeoPrerenderedBody renders - /// cannot be reached from it — that component reads a SeoPageData, which only - /// produces, and still refuses this node. - /// - /// 🚨 The summary is an authored summary, not the first line of the body. That is a - /// property of , which reads six summary members and never - /// content or body; is the only thing that touches those, - /// and nothing here calls it. If a future member is added to the description chain, it must be - /// an authored summary for the same reason. - /// - /// The node stays OUT of /sitemap.xml and off the public host, because both read - /// (via PublicSite) and neither reads this — unfurlable is not - /// indexable, and the head states noindex to say so. - /// - /// The hub whose path resolver, gate and policy chain answer. - /// The node path the visitor asked for. - /// The VIEWER's language tag, read explicitly off their AccessContext by - /// the caller — never from an ambient culture. Null ⇒ English. - public static IObservable ResolvePreview( - IMessageHub hub, string path, string? locale = null) => - ResolveGated(hub, path) - .SelectMany(gated => gated is not { Readable: false } withheld - ? Observable.Return(null) - : hub.GetPublicPreview(withheld.Resolution.Prefix) - .Take(1) - .SelectMany(optedIn => optedIn - ? CallToAction(hub, withheld.Resolution.Prefix, locale) - .Select(callToAction => ComposePreviewCard(withheld.Node, callToAction)) - : Observable.Return(null))) - .Timeout(ResolveBudget) - .Catch(_ => Observable.Return(null)); - - /// - /// The static-SSR boundary bridge for — ObserveCompletion, - /// never .ToTask(), for the reason states. - /// - /// The hub whose path resolver, gate and policy chain answer. - /// The node path the visitor asked for. - /// The viewer's language tag; null ⇒ English. - public static Task ResolvePreviewAsync( - IMessageHub hub, string path, string? locale = null) - { - var logger = hub.ServiceProvider.GetService() - ?.CreateLogger(typeof(SeoResolver)); - return ResolvePreview(hub, path, locale) - .FirstAsync() - .ObserveCompletion(ex => logger?.LogWarning( - ex, - "The public-preview card for '{Path}' faulted after the head had already been produced", - path)); - } - - /// - /// The card, from the node and nothing else. Pure — no hub, no IO — so everything a previewed - /// page can possibly say is decided here and is testable without a mesh. - /// - /// The node whose page was withheld, on a scope that opted in. - /// The localized sentence to append, or null for none. - public static SeoPreviewCard ComposePreviewCard(MeshNode node, string? callToAction) - { - ArgumentNullException.ThrowIfNull(node); - var summary = ShareDescription(node); - return new SeoPreviewCard( - ShareTitle(node), - summary is null - ? callToAction - : callToAction is null ? summary : $"{summary} {callToAction}", - PreviewImage(node), - node.Path, - PreviewIconLinks(node)); - } - - /// - /// 🚨 THE PICTURE A PREVIEWED PAGE MAY DECLARE — and it is NOT always - /// , which is the whole point of this method existing. - /// - /// An authored image is typically /api/content/{partition}/content/og.png: the - /// portal's own access-controlled content route, which this opt-in deliberately does not - /// open — that route serves file BYTES, not the four strings the flag consents to. Declaring it - /// for a gated node would promise an og:image that anonymous unfurlers receive a 404 for, - /// which is WORSE than no card: several drop the whole preview when the promised picture does not - /// fetch. So a root-relative authored image falls back to the DRAWN card, which - /// does serve under the same flag. - /// - /// An ABSOLUTE authored image is re-served through , exactly as on - /// a public page: the route serves it under this same flag, and the picture is some other host's - /// bytes, so redrawing them discloses nothing the URL in the head did not already point at. A - /// PUBLIC page is untouched by this method — its authored image is fetchable precisely because - /// the gate admits the node. - /// - /// The withheld node, on a scope that opted in. - private static string PreviewImage(MeshNode node) => - ExtractImage(node) is { } authored && IsExternal(authored) - ? AuthoredCard(node.Path) - : GeneratedCard(node.Path); - - /// - /// 🚨 THE ICON CHANNELS A PREVIEWED PAGE MAY DECLARE: only the ones that are anonymously - /// fetchable BY CONSTRUCTION — the inline-svg data: URI, which is self-contained and - /// carries nothing beyond the mark itself, and the /api/icon/{path}.png rasters, which - /// honour this same flag. - /// - /// A node whose icon is a content: reference resolves to /api/content/…, still - /// Read-gated, and returns that ONE link and no raster - /// channels for it — so a previewed page would publish exactly one icon link and it would be - /// broken. Such a node gets NO icon link here, which is the same honest fallback the icon route - /// already documents for a node with no usable mark: the portal favicon stays, rather than a link - /// that 404s. - /// - /// The withheld node, on a scope that opted in. - private static IReadOnlyList PreviewIconLinks(MeshNode node) => - ResolveIconLinks(node) - .Where(link => - link.Href.StartsWith("data:", StringComparison.OrdinalIgnoreCase) - || link.Href.StartsWith("/api/icon/", StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - /// - /// 🚨 THE ONE PREDICATE THE IMAGE ROUTES ASK — the node whose picture /api/og/{path}.png - /// and /api/icon/{path}.png may draw: one the gate admits, or one whose scope opted in to - /// . Null ⇒ 404, which is also what a missing - /// node answers, so the routes still cannot be used as an existence oracle. - /// - /// It exists so the routes and the HEAD cannot disagree: a head that declares - /// og:image for a previewed page while the route 404s would ship a card with a broken - /// picture — worse than no card, because several unfurlers drop the whole preview when the image - /// they were promised does not fetch. Everything those two routes draw is exactly what the head - /// discloses (name, authored summary, category, mark, price chip, instance + path) and never the - /// body, which is why one flag can govern both. - /// - /// The policy read happens only on the REFUSED leg, so a public page costs exactly what it - /// costs today and a path that names no node costs no policy read at all. - /// - /// 🚨 It answers WHICH decision cleared the response, not just that one did, because the two - /// are cacheable differently — see . - /// - /// The hub whose path resolver, gate and policy chain answer. - /// The node path the picture was asked for. - public static IObservable ResolveShareableNode(IMessageHub hub, string path) => - ResolveGated(hub, path) - .SelectMany(gated => gated is not { } resolved - ? Observable.Return(null) - : resolved.Readable - ? Observable.Return(new ShareableNode(resolved.Node, true)) - : hub.GetPublicPreview(resolved.Resolution.Prefix) - .Take(1) - .Select(optedIn => optedIn ? new ShareableNode(resolved.Node, false) : null)) - .Timeout(ResolveBudget) - .Catch(_ => Observable.Return(null)); - - /// - /// The node's current document body as HTML, for . Authored - /// markdown — content, body, or a bare string — always wins over cached HTML, - /// including an empty source after the author clears a page. The interactive view's - /// owns the rendering and relative-link resolution. - /// Only a node without source falls back to its mirrored HTML or its content's - /// prerenderedHtml. Null for content that is not a document. Pure: no hub, no IO. - /// - public static string? RenderBody(MeshNode node) => MarkdownBody.Render(node); - - /// - /// The page description for meta tags and the share card: the node's Description, else the - /// content's abstract/description, else the sales copy a catalog root carries - /// instead — tagline, summary, headline (the Store root has a headline and - /// a tagline and no description, so its card and its og:description were empty). Both - /// content shapes (typed record, untyped JSON) resolve through . - /// - public static string? ExtractDescription(MeshNode node) => - FirstNonEmpty( - node.Description, - ContentString(node, "abstract"), - ContentString(node, "description"), - ContentString(node, "tagline"), - ContentString(node, "summary"), - ContentString(node, "headline")); - - /// - /// The title a SHARE card carries: the content's ogTitle when the node authored one, - /// else the node's own name (its id when it has none). The default is the node itself, so a - /// card needs no configuration; ogTitle exists only to give a share a different headline - /// from the page's own, and leaves the page title, the tab and every in-app listing untouched. - /// - public static string ShareTitle(MeshNode node) => - FirstNonEmpty(ContentString(node, "ogTitle"), node.Name) ?? node.Id; - - /// - /// The description a SHARE card carries: the content's ogDescription when the node - /// authored one, else . The page's own meta description - /// stays — the override is for the unfurl, not the search - /// snippet. - /// - public static string? ShareDescription(MeshNode node) => - FirstNonEmpty(ContentString(node, "ogDescription"), ExtractDescription(node)); - - /// - /// The AUTHORED share image, or null when the node carries none (the caller then falls back to - /// the generated card — see ). - /// - /// 🚨 ogImage is listed FIRST because it is the field store plugins actually - /// declare. This read used to check only poster and thumbnail, so every plugin's - /// hand-made og.png was ignored and no store page has ever emitted an og:image — - /// the tag is written only when this returns non-null. poster and thumbnail - /// remain for markdown pages and video nodes. mediaUrl is a social post's own visual - /// (SocialPost.MediaUrl): without it a shared post unfurled as the drawn text card while - /// the picture it was published with sat unread on the node (2026-10-05). - /// - /// Root-relative or absolute URLs only: a bare filename would resolve against whatever - /// path the crawler happened to fetch. - /// - public static string? ExtractImage(MeshNode node) - { - var candidate = FirstNonEmpty( - ContentString(node, "ogImage"), - ContentString(node, "poster"), - ContentString(node, "thumbnail"), - ContentString(node, "mediaUrl")); - return candidate is not null - && (candidate.StartsWith('/') || candidate.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - ? candidate - : null; - } - - /// - /// The image a public page shares with: whatever it authored, else the card the portal draws - /// for it. Never null — "this page has an Open Graph card" is the default, not an opt-in. - /// - /// 🚨 An authored image on ANOTHER HOST is never declared as-is: it is re-served from this - /// origin as — normalised to a declared 1200×630 baseline JPEG within - /// the unfurlers' byte budget (). Declared raw, a - /// Social post's Supabase picture unfurled in WhatsApp as an EMPTY large card (2026-10-07): the - /// head named a picture with no type and no size, on a host whose encoding and headers the - /// portal does not control, while twitter:card=summary_large_image had already committed - /// the unfurler to the large layout. A ROOT-RELATIVE authored image is this origin's own and is - /// declared unchanged. - /// - public static string ShareImage(MeshNode node) => - ExtractImage(node) is { } authored - ? IsExternal(authored) ? AuthoredCard(node.Path) : authored - : GeneratedCard(node.Path); - - /// - /// The URL of a node's AUTHORED share picture re-served from this origin — - /// /api/og/{node}.jpg, always a 1200×630 baseline JPEG (the drawn card, as JPEG, when the - /// authored picture cannot be fetched). The .jpg suffix is the contract the head reads the - /// declared type from, beside the drawn card's .png. - /// - public static string AuthoredCard(string nodePath) => $"/api/og/{nodePath.Trim('/')}{AuthoredCardSuffix}"; - - /// The suffix that marks apart from . - public const string AuthoredCardSuffix = ".jpg"; - - /// - /// The media type of a portal-served card (): image/jpeg for - /// the re-served authored picture, image/png for the drawn one. Null for anything else — - /// an image this portal does not serve has no type it could truthfully declare. - /// - public static string? CardMediaType(string? image) => - image is null || !IsGeneratedCard(image) ? null - : image.EndsWith(AuthoredCardSuffix, StringComparison.OrdinalIgnoreCase) ? OgCardRenderer.AuthoredMediaType - : "image/png"; - - /// A picture on some other host: an absolute http(s) URL, or a scheme-relative - /// (//host/…) network-path reference, which every consumer resolves to another host - /// although it starts with /. - internal static bool IsExternal(string image) => - image.StartsWith("http://", StringComparison.OrdinalIgnoreCase) - || image.StartsWith("https://", StringComparison.OrdinalIgnoreCase) - || image.StartsWith("//", StringComparison.Ordinal); - - /// The absolute URL to FETCH an external picture from — a scheme-relative reference is - /// resolved as https, the scheme the share page itself is served on. - internal static string AbsoluteSource(string image) => - image.StartsWith("//", StringComparison.Ordinal) ? "https:" + image : image; - - /// - /// The generated card's URL for one node path. The .png suffix is deliberate: some - /// unfurlers (iMessage's LinkPresentation among them) weigh an extension when deciding whether - /// an og:image is a picture, and the route accepts the suffix. - /// - public static string GeneratedCard(string nodePath) => $"/api/og/{nodePath.Trim('/')}.png"; - - /// The card for a page that is no public node (the home page, a private node) — the - /// instance's own card, saying only its name and host. - public const string SiteCard = "/api/og.png"; - - /// Whether a share image is one the portal SERVES as a card — the drawn PNG and the - /// re-served authored JPEG () are both exactly - /// ×, so the head can declare - /// the size, and the type; an image on this origin's content route - /// has dimensions unknown here. - /// - /// Takes the image as returns it, BEFORE the head prefixes the - /// host: the drawn cards are the two ROOT-RELATIVE shapes this route serves and nothing else. - /// An authored absolute URL that happens to contain /api/og/ on some other host is not - /// the portal's card, and a substring test would have declared its size as if it were. - /// - public static bool IsGeneratedCard(string? image) => - image is not null - && (image.StartsWith("/api/og/", StringComparison.OrdinalIgnoreCase) - || string.Equals(image, SiteCard, StringComparison.OrdinalIgnoreCase)); - - /// The one media type that tells every consumer "this icon scales losslessly". - private const string SvgMediaType = MeshNodeImageHelper.SvgMediaType; - - /// - /// 🚨 THE PAGE'S OWN ICON — the node's icon, so a node page identifies itself rather than the - /// portal it happens to live in. - /// - /// Every page of a Blazor portal serves ONE site-wide <link rel="icon"> (see - /// App.razor), so every link preview of every node — our own OgCard, and equally a - /// Slack / Teams / LinkedIn unfurl — drew the same MeshWeaver logo. Yet each node already - /// carries a distinctive icon, which the portal renders everywhere INSIDE the app. This lifts - /// that same icon into the head, where the standards-based icon channel is, so every consumer - /// gets it for free without knowing anything about MeshWeaver. - /// - /// 🚨 It is the icon ON THE NODE — — and nothing else. No - /// synthesised badge, no letter tile, no NodeType stand-in: those would put a picture in the - /// head that the node never chose and that the app never renders for it. A node that carries no - /// icon of its own simply keeps the portal favicon, which is the honest answer for a page with - /// no mark of its own. - /// - /// The value is resolved exactly the way the in-app icon is - /// () — a content: reference becomes - /// the access-controlled content URL, a URL stays that URL, and an inline <svg> - /// goes through the same backplate policy the app applies (), which - /// leaves an icon that paints its own plate exactly as authored — so the tab, the card and the - /// app can never disagree about what a node looks like. - /// - /// The node whose page is being served. - /// Its own icon, or null when it carries none an href can point at. - public static PageIcon? ResolveIcon(MeshNode node) - { - // An inline is MARKUP, not a location, so it travels as a data URI — the same - // mechanism App.razor already uses for the per-instance favicon. The markup is the node's - // icon through the backplate policy and nothing else (see ResolveIconSvg): an authored - // plate survives byte for byte, and one that has none gets the same generated plate the app - // draws, because a tab strip is a ground this process does not control. - if (ResolveIconSvg(node) is { } svg) - return new PageIcon(SvgDataUri(svg), SvgMediaType); - - var icon = MeshNodeImageHelper.ResolveContentPath(node.Icon, node.Path); - if (string.IsNullOrWhiteSpace(icon)) - return null; - - // A URL or data URI the node carries — used as written. - if (MeshNodeImageHelper.IsImageUrl(icon)) - return new PageIcon(icon, MediaTypeOf(icon)); - - // Anything else (an emoji, a bare word) is a character, not a picture, and no href can - // carry it. The portal favicon stays rather than inventing a graphic for it. - return null; - } - - /// - /// The node's own icon AS SVG SOURCE, or null when it carries none that is markup. - /// - /// This is the exact value percent-encodes into its - /// data:image/svg+xml href — one resolution, two consumers, so the - /// in the head and the PNG the rasterizer serves for the SAME page can - /// never be pictures of different things. - /// - /// Only INLINE markup qualifies. An icon that is a URL (a content-collection file, a - /// shipped glyph) is a location this process would have to fetch — over its own - /// access-controlled route, from an anonymous request — to rasterize; a raster URL needs no - /// rasterizing at all, because Safari reads those already. - /// - /// 🚨 It goes through the backplate policy () — the - /// same policy the in-app icon passes (), so - /// "the tab, the card and the app can never disagree" stays true. It is not cosmetic here - /// (#4350): BOTH consumers of this value render on a ground this process does not control, and - /// neither of them has any surrounding text for a currentColor outline to inherit from. - /// In a data: URI the markup is a DOCUMENT, so such an icon paints in the initial color; - /// and leaves its canvas transparent precisely BECAUSE it - /// was told every mark carries its own plate. Measured on the pixels: a black hairline on - /// nothing — invisible in a dark tab strip and on a dark link-preview card, and served behind a - /// 200, so nothing reports it. An icon that already paints a full-bleed plate — every authored - /// store mark, every thread identicon — is returned byte-identical, so nothing that worked - /// before changes. - /// - /// The node whose page is being served. - public static string? ResolveIconSvg(MeshNode node) - { - var icon = MeshNodeImageHelper.ResolveContentPath(node.Icon, node.Path); - return MeshNodeImageHelper.IsInlineSvg(icon) ? IconBackplate.Ensure(icon!) : null; - } - - /// The route the rasterized favicon of one node is served from. - /// The node's mesh path, which is also its public URL path. - /// The square edge in pixels. - public static string RasterIconUrl(string nodePath, int size) => - $"/api/icon/{nodePath}.png?size={size}"; - - /// - /// 🚨 EVERY icon link the page's head should carry, in declaration order. - /// - /// Why more than one. Safari renders no SVG favicon — not from a data URI, not - /// from a URL — so a page whose only icon link is the node's <svg> mark wore the - /// portal favicon on every Mac and iPhone, in and out of circuit (issue #2075, item 3). The - /// standards-based answer is not to give up the scalable icon: declare BOTH and let each - /// browser take the one it can read. A browser that understands SVG prefers it (scalable beats - /// a fixed 32 px on a retina tab strip); Safari skips it and takes the PNG. - /// - /// Why apple-touch-icon is separate. It is a different channel with a - /// different job — the large bookmark / Start-Page / Add-to-Dock tile — and Safari does NOT - /// fall back to the favicon for it: with none declared it draws its own letter tile. So a node - /// with a mark needs one pointed at that mark, or its tile says nothing about it. - /// - /// Nothing is synthesised, exactly as promises: a node with no - /// icon of its own yields NO links at all, and a node whose icon is already a raster image - /// yields the single link it always did — Safari reads those without help. - /// - /// The node whose page is being served. - /// The links to declare; empty when the node has no icon an href can carry. - public static IReadOnlyList ResolveIconLinks(MeshNode node) - { - if (ResolveIcon(node) is not { } icon) - return []; - if (ResolveIconSvg(node) is null) - return [icon]; - return - [ - icon, - new PageIcon(RasterIconUrl(node.Path, IconRasterizer.FaviconSize), "image/png") - { - Sizes = $"{IconRasterizer.FaviconSize}x{IconRasterizer.FaviconSize}", - }, - new PageIcon(RasterIconUrl(node.Path, IconRasterizer.AppleTouchSize), "image/png") - { - Rel = "apple-touch-icon", - Sizes = $"{IconRasterizer.AppleTouchSize}x{IconRasterizer.AppleTouchSize}", - }, - ]; - } - - /// The media type an icon URL pins down by itself, or null when it does not — a - /// consumer ranking icons by type must never be told a wrong one. - private static string? MediaTypeOf(string icon) => - icon.StartsWith("data:" + SvgMediaType, StringComparison.OrdinalIgnoreCase) - || (!icon.StartsWith("data:", StringComparison.OrdinalIgnoreCase) - && icon.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) - ? SvgMediaType - : null; - - // One encoding rule for the whole product: the head, the tab and the app all carry an inline - // icon the same way (MeshNodeImageHelper.SvgDataUri). - private static string SvgDataUri(string svg) => MeshNodeImageHelper.SvgDataUri(svg); - - /// - /// A string member of the node's content, by camelCase JSON name. Content arrives here in two - /// shapes and both must resolve the same way: untyped (node-native - /// types the portal hub hasn't registered), or a TYPED record when the hub knows the CLR type — - /// a markdown page resolves as MarkdownContent, so reading only the JsonElement shape - /// silently dropped og:image for every markdown node's thumbnail. - /// - public static string? ContentString(MeshNode node, string property) => - node.Content switch - { - JsonElement { ValueKind: JsonValueKind.Object } je - when je.TryGetProperty(property, out var value) - && value.ValueKind == JsonValueKind.String => value.GetString(), - JsonElement or null => null, - var typed => TypedMember(typed, property) as string, - }; - - /// - /// A string-to-string map member of the node's content, by camelCase JSON name (both shapes) — - /// e.g. a course's translations ({"de-CH": "AgenticPrimerDe"}), which the head - /// turns into hreflang links. Empty when absent or not a map of strings. - /// - public static IReadOnlyDictionary ContentStringMap(MeshNode node, string property) - { - switch (node.Content) - { - case JsonElement { ValueKind: JsonValueKind.Object } je - when je.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Object: - return value.EnumerateObject() - .Where(p => p.Value.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(p.Value.GetString())) - .ToImmutableDictionary(p => p.Name, p => p.Value.GetString()!, StringComparer.OrdinalIgnoreCase); - case JsonElement or null: - return ImmutableDictionary.Empty; - default: - return TypedMember(node.Content, property) switch - { - IEnumerable> typed => - typed.ToImmutableDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase), - _ => ImmutableDictionary.Empty, - }; - } - } - - /// A decimal member of the node's content, by camelCase JSON name (both shapes). - public static decimal? ContentDecimal(MeshNode node, string property) => - node.Content switch - { - JsonElement { ValueKind: JsonValueKind.Object } je - when je.TryGetProperty(property, out var value) - && value.ValueKind == JsonValueKind.Number => value.GetDecimal(), - JsonElement or null => null, - var typed => TypedMember(typed, property) is decimal d ? d : null, - }; - - /// The PascalCase CLR property matching a camelCase JSON member name. - private static object? TypedMember(object content, string jsonName) => - content.GetType() - .GetProperty(char.ToUpperInvariant(jsonName[0]) + jsonName[1..])? - .GetValue(content); - - private static string? FirstNonEmpty(params string?[] values) => - values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); -} diff --git a/memex/Memex.Portal.Shared/Settings/AboutSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/AboutSettingsTab.cs deleted file mode 100644 index dda841286e..0000000000 --- a/memex/Memex.Portal.Shared/Settings/AboutSettingsTab.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Application.Styles; -using MeshWeaver.Data; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Layout.DataGrid; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Utils; -using Memex.Portal.Shared.SelfUpdate; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Settings; - -/// -/// Global settings "About" tab — the ONE page that identifies what this portal is running: -/// the platform build (version + the git commit it was built from, linking straight to GitHub), -/// whether that build is current, the runtime, and the live installed-plugin inventory with -/// each plugin's version. Ungated: visible to every user — plugins are part of the build identity, -/// and the inventory is read-only (browsing and provisioning packages is the Store's job; the old -/// admin-only "Plugin Catalog" settings tab is retired in favour of this page). -/// -/// The "is it current?" line comes from — a two-state verdict -/// projected out of the platform-admin-only Admin/UpdatePolicy node, so an ordinary user can -/// answer the question without being handed the update policy itself. -/// -/// Build identity comes from the assembly attributes baked in by -/// Directory.Build.props ( / -/// ), so it reflects the deployed image with no -/// runtime plumbing. The plugin inventory is the live install registry -/// () — it re-renders the moment an -/// install lands. Pure + a over a plain row -/// record — no hand-built HTML (GUI rule). -/// -public static class AboutSettingsTab -{ - /// The tab id under /_Setting/GlobalSettings (GlobalSettingsNodeType.TabHref). - public const string TabId = "About"; - - /// Data id the plugin grid binds to (rows published via host.UpdateData). - private const string PluginsDataId = "aboutPlugins"; - - // The menu entry is a seeded UiContribution node (PlatformSettingsTabAreas) — the tab's - // registration surface is the SettingsAbout layout area, not a compiled provider. - - internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack) - { - stack = stack.WithView(Controls.H2(host.Localize("ui.aboutMeshWeaver")).WithStyle("margin: 0 0 8px 0;")); - stack = stack.WithView(Controls.Markdown(host.Localize("about.intro"))); - - stack = stack.WithView(Controls.Markdown(BuildInfoMarkdown(host))); - - // ── "Is that build current?" — the derived verdict, for EVERY user. ── - // The comparison (running vs newest known) lives on Admin/UpdatePolicy, which only platform - // admins can read, so PlatformUpdateStatus.Observe reads it as System and lets ONLY the - // verdict out. Unknown (no self-update wired / checks off / read failed) renders nothing — - // an unfounded "up to date" would be worse than no line. - stack = stack.WithView((h, _) => PlatformUpdateStatus.Observe(h.Hub) - .Select(status => (UiControl?)UpdateStatusView(h, status)) - .StartWith((UiControl?)Controls.Stack.WithWidth("100%"))); - - // ── Installed plugins — the live inventory, one row per install record. ── - stack = stack.WithView(Controls.H3(host.Localize("about.plugins.title")).WithStyle("margin: 16px 0 4px 0;")); - stack = stack.WithView((h, _) => CatalogLayoutAreas.ObserveInstalledManifests(h) - .Select(plugins => plugins.Count == 0 - ? (UiControl)Controls.Markdown(h.Localize("about.plugins.none")) - : PluginsGrid(h, plugins))); - return stack; - } - - /// - /// The build-identity block. Version is always present; the commit line links to the exact - /// GitHub commit when the SHA was baked in (CI + local git), otherwise it is omitted with a note. - /// - private static string BuildInfoMarkdown(LayoutAreaHost host) - { - var version = ShippedReleaseSeed.InstalledPlatformVersion; - var sha = ShippedReleaseSeed.CommitHash; - var commitLine = sha is { Length: > 0 } - ? $"**{host.Localize("about.buildCommit")}:** [`{Short(sha)}`]({ShippedReleaseSeed.CommitUrl})" - : $"**{host.Localize("about.buildCommit")}:** _{host.Localize("about.buildCommitMissing")}_"; - - // Serving-since sits directly under the build id: together they answer "which build, and - // since when" — the pair the header chip deliberately keeps short. - var startedLine = StartedAtMarkdown( - ShippedReleaseSeed.StartedAtUtc, - host.Hub.ServiceProvider.GetService().ViewerZoneId(), - key => host.Localize(key)); - - return - $"**{host.Localize("about.version")}:** `{version}`\n\n" + - $"{commitLine}\n\n" + - (startedLine is null ? "" : $"{startedLine}\n\n") + - $"**{host.Localize("about.repository")}:** [Systemorph/MeshWeaver]({ShippedReleaseSeed.RepositoryUrl})\n\n" + - $"**{host.Localize("about.runtime")}:** .NET {Environment.Version}"; - } - - /// - /// The "serving since" line — WHEN this build started answering, in the viewer's own zone. - /// - /// The version answers "which build" and never "since when". After a roll the version can - /// be identical (a re-deploy of the same image) or minutes old, and only a timestamp separates - /// "this has been live all week" from "this landed while you were reading" — the question asked - /// every time somebody needs to know whether a portal actually rolled forward. - /// - /// 🚨 The stored value is UTC; this renders it in the VIEWER's zone. A bare UTC clock is - /// the thing that misleads — 13:35 UTC shown to a Zurich reader is an hour wrong in summer, and - /// silently so. Pure and localizer-taking so the conversion is testable without a hub. - /// - /// Returns null when the start time is unknown (default — a host that - /// refuses process introspection, or WASM). Rendering the epoch, or substituting "now", would - /// read as a fresh deploy on every render. - /// - internal static string? StartedAtMarkdown( - DateTimeOffset startedAtUtc, string? viewerZoneId, Func localize) - { - if (startedAtUtc == default) return null; - - var local = DisplayTimeExtensions.ToDisplayTime(startedAtUtc, viewerZoneId); - return $"**{localize("about.lastDeployed")}:** {local:yyyy-MM-dd HH:mm} (UTC{local:zzz})"; - } - - /// - /// The update-status line: a language-neutral glyph plus one translated phrase. Returns an empty - /// stack for so nothing renders. - /// - private static UiControl UpdateStatusView(LayoutAreaHost host, PlatformUpdateStatus status) => - UpdateStatusMarkdown(status, key => host.Localize(key)) is { } markdown - ? Controls.Markdown(markdown) - : Controls.Stack.WithWidth("100%"); - - /// - /// The update-status markdown, or null when there is nothing to say. Takes the localizer as - /// a function so the wording is unit-testable without a hub. - /// - internal static string? UpdateStatusMarkdown(PlatformUpdateStatus status, Func localize) => - StatusLine(status, localize) is { } line - // 🚨 The platform rolls independently of packages (policy - // platform-rolls-independently-of-packages). When it rolls — or rolled — with - // package advisories recorded for the build, the About tab SAYS so in one translated - // sentence, rather than reading "update held" for a hold that no longer exists. The - // advisories themselves are server-written diagnostics (package names and reasons, in - // English): like a hold's reason they are admin-scoped and stay on the Updates tab — - // the status projected to this surface carries only the fact that they exist. - ? line + (status.HasAdvisories ? "\n\n" + localize("about.platformRolls") : "") - : null; - - private static string? StatusLine(PlatformUpdateStatus status, Func localize) => - status.Availability switch - { - PlatformUpdateAvailability.UpdateAvailable => - $"**{localize("about.updateStatus")}:** ⬆️ {localize("about.updateAvailable")} — " + - $"`{status.LatestVersion}`", - // A held update reads DIFFERENTLY from an available one on purpose: an install that has - // refused a build must not look like one that is simply about to take it. The reason - // itself is admin-scoped and lives on the Updates tab; here the ordinary user learns - // that the deployment is deliberately not moving. - PlatformUpdateAvailability.UpdateHeld => - $"**{localize("about.updateStatus")}:** ⏸️ {localize("about.updateHeld")} — " + - $"`{status.LatestVersion}`", - PlatformUpdateAvailability.UpToDate => - $"**{localize("about.updateStatus")}:** ✅ {localize("about.upToDate")}", - _ => null, - }; - - /// - /// The inventory grid — a over plain rows (the framework's ONE - /// way to render tabular data), columns localized. ModuleVersion is the module's own - /// content hash from manifest.lock, shortened for display — the exact-content identifier - /// support needs when "same version, different content" is the question. - /// - private static UiControl PluginsGrid(LayoutAreaHost host, IReadOnlyList plugins) - { - var rows = ToRows(plugins); - host.UpdateData(PluginsDataId, rows); - return new DataGridControl(new JsonPointerReference(LayoutAreaReference.GetDataPointer(PluginsDataId))) - .WithColumn(new PropertyColumnControl - { Property = nameof(PluginRow.Name).ToCamelCase() } - .WithTitle(host.Localize("about.plugins.column.name"))) - .WithColumn(new PropertyColumnControl - { Property = nameof(PluginRow.Version).ToCamelCase() } - .WithTitle(host.Localize("about.plugins.column.version"))) - .WithColumn(new PropertyColumnControl - { Property = nameof(PluginRow.Module).ToCamelCase() } - .WithTitle(host.Localize("about.plugins.column.module"))) - .WithColumn(new PropertyColumnControl - { Property = nameof(PluginRow.Kind).ToCamelCase() } - .WithTitle(host.Localize("about.plugins.column.kind"))) - .WithColumn(new PropertyColumnControl - { Property = nameof(PluginRow.Partition).ToCamelCase() } - .WithTitle(host.Localize("about.plugins.column.partition"))); - } - - /// - /// Maps install-registry manifests to grid rows: display name falls back to the package id, - /// the module content hash is shortened for display, and null-able fields render empty rather - /// than "null". Public so the mapping is directly testable. - /// - public static IReadOnlyList ToRows(IEnumerable plugins) => plugins - .Select(p => new PluginRow( - p.Name ?? p.Id, - p.Version ?? "", - Short(p.ModuleVersion ?? ""), - p.Kind.ToString(), - p.TargetPartition ?? "")) - .ToList(); - - /// One row of the plugin grid — a plain record so the grid binds it directly. - public record PluginRow(string Name, string Version, string Module, string Kind, string Partition); - - /// Short display form of a SHA/hash (first 12 chars); full value still drives links. - private static string Short(string sha) => sha.Length <= 12 ? sha : sha[..12]; -} diff --git a/memex/Memex.Portal.Shared/Settings/AdminAppOverviewTab.cs b/memex/Memex.Portal.Shared/Settings/AdminAppOverviewTab.cs deleted file mode 100644 index cf21df91e5..0000000000 --- a/memex/Memex.Portal.Shared/Settings/AdminAppOverviewTab.cs +++ /dev/null @@ -1,90 +0,0 @@ -using MeshWeaver.Application.Styles; -using MeshWeaver.Data; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Layout.DataGrid; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Utils; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Settings; - -/// -/// The Admin app's landing tab — /Admin (/Admin/Settings/Overview): what THIS -/// instance is, then the About page (build, "is it current?", installed plugins). The instance facts -/// are the ones no other tab carries — the public host and whether the image serves a closed type -/// set — and the About part follows them ONCE: the platform version, commit and runtime are About's -/// lines, and the registry and the instance id this installation registers under are the -/// Registration tab's, so the Overview does not repeat either. -/// -/// Composed, not duplicated: the About part IS , and -/// every other administration surface is its own tab of the app (Updates, Invitations, Sign-in -/// providers, …). Rendered with a over plain rows. -/// -public static class AdminAppOverviewTab -{ - /// The tab id. - public const string TabId = "Overview"; - - private const string FactsDataId = "adminOverviewFacts"; - - /// One fact about the instance. - /// What is described (localized). - /// Its value. - public sealed record InstanceFact(string Fact, string Value); - - /// Registers the Overview tab on the Admin app (not relocated: the id is new). - /// The hub configuration. - public static MessageHubConfiguration AddAdminAppOverviewTab(this MessageHubConfiguration config) - => config.AddAdminAppTab(new SettingsMenuItemDefinition( - Id: TabId, - Label: "Overview", - ContentBuilder: (host, stack, _) => BuildContent(host, stack), - Icon: FluentIcons.Info(), - // First: /Admin lands here (the settings page opens its first tab). - Order: -100, - Keywords: ["overview", "about", "instance", "host", "version", "build", "commit", - "registry", "instance id", "image", "runtime", "framework"]) - { LabelKey = "adminApp.overview" }, - relocated: false); - - internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack) - { - var facts = Facts( - host.Hub.ServiceProvider.GetService(), - host.Hub.ServiceProvider.IsClosedTypeSet(), - key => host.Localize(key)); - host.UpdateData(FactsDataId, facts); - - stack = stack - .WithView(Controls.H2(host.Localize("adminApp.instanceTitle")).WithStyle("margin: 0 0 8px 0;")) - .WithView(new DataGridControl(new JsonPointerReference(LayoutAreaReference.GetDataPointer(FactsDataId))) - .WithColumn(new PropertyColumnControl { Property = nameof(InstanceFact.Fact).ToCamelCase() } - .WithTitle(host.Localize("whoAmI.column.fact"))) - .WithColumn(new PropertyColumnControl { Property = nameof(InstanceFact.Value).ToCamelCase() } - .WithTitle(host.Localize("whoAmI.column.value")))); - // The About page every user sees — the build, whether it is current, the installed plugins. - return AboutSettingsTab.BuildContent(host, stack); - } - - /// - /// The instance facts, in reading order — only the ones no other tab of the app shows. Pure — - /// every input is an argument, so the rows are testable without a hub. - /// - internal static IReadOnlyList Facts( - IConfiguration? configuration, - bool closedTypeSet, - Func localize) - { - var host = configuration?["Portal:BaseUrl"] ?? configuration?["PublicBaseUrl"]; - return - [ - new(localize("adminApp.fact.host"), string.IsNullOrWhiteSpace(host) ? "—" : host), - new(localize("adminApp.fact.closedTypeSet"), localize(closedTypeSet ? "whoAmI.yes" : "whoAmI.no")), - ]; - } -} diff --git a/memex/Memex.Portal.Shared/Settings/AdminMenuGate.cs b/memex/Memex.Portal.Shared/Settings/AdminMenuGate.cs deleted file mode 100644 index 7629472f8d..0000000000 --- a/memex/Memex.Portal.Shared/Settings/AdminMenuGate.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Reactive.Linq; -using System.Threading.Channels; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Settings; - -/// -/// Gate for the platform-wide Admin menu (the GlobalSettings area). A tab is shown only to a viewer -/// holding at the Admin scope — the platform-admin scope. -/// Bridges the IObservable permission check to IAsyncEnumerable via a Channel (no -/// .ToTask() on a hub round-trip — see AsynchronousCalls.md). -/// -/// The platform-admin grant is an AccessAssignment in the Admin/_Access namespace (routed to -/// the admin schema). The evaluator derives the assignment's scope from that namespace -/// (Admin/_Access → scope Admin, stripping /_Access), so the gate checks -/// GetEffectivePermissions("Admin"). The old check used root scope "", which matched no -/// grant once global-admin moved out of the root _Access namespace — so Invitations/Inbox never -/// appeared. -/// -/// 🚨 We must WAIT for the first emission that grants , NOT snapshot -/// the first emission with FirstAsync(). The grant is a RUNTIME AccessAssignment row, so -/// emits an empty -/// static seed first and only enriches to All once the synced AccessAssignment query lands. -/// FirstAsync() would capture that premature empty → the gate ALWAYS returned false. Filtering for -/// the positive (Where(isAdmin)) with a bounded wait makes the answer correct: an admin's grant -/// arrives within the window; a non-admin never emits a positive and falls through to the safe default -/// (false / hide the menu). One-shot + disposed, so this is NOT the long-lived synced-state Timeout -/// anti-pattern. -/// -internal static class AdminMenuGate -{ - /// The scope whose Admin grant designates a platform (global) admin. The evaluator derives - /// scope from the AccessAssignment NAMESPACE (Admin/_Access → scope Admin) via an - /// ordinal match, so this MUST match the stored namespace casing — Admin, not admin. - private const string AdminScope = "Admin"; - - // Bounded wait for the admin grant to surface on the live permission stream. The grant is typically - // present in the first enriched emission; this is the ceiling for the synced query's cold-start, - // after which a non-admin (no positive ever emitted) resolves to "not admin". - private static readonly TimeSpan GrantWait = TimeSpan.FromSeconds(5); - - /// - /// Pure-reactive platform-admin check: emits false immediately (so a menu renders without - /// the gated tab), then true if the viewer's Admin-scope grant surfaces within - /// . Waits for the POSITIVE (filter true), NOT the first emission (which can - /// be the premature empty seed). No Task, no await, no Channel bridge. - /// - public static IObservable IsPlatformAdmin(LayoutAreaHost host) - { - var accessService = host.Hub.ServiceProvider.GetService(); - var viewerId = accessService?.Context?.ObjectId ?? accessService?.CircuitContext?.ObjectId; - if (string.IsNullOrEmpty(viewerId)) - return Observable.Return(false); - - return host.Hub.IsGlobalAdmin(viewerId) - .Where(isAdmin => isAdmin) - .Take(1) - .Timeout(GrantWait) - .Catch(_ => Observable.Return(false)) - .StartWith(false); - } -} diff --git a/memex/Memex.Portal.Shared/Settings/ApiTokensSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/ApiTokensSettingsTab.cs deleted file mode 100644 index 4379c1d666..0000000000 --- a/memex/Memex.Portal.Shared/Settings/ApiTokensSettingsTab.cs +++ /dev/null @@ -1,372 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Application.Styles; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Memex.Portal.Shared.Authentication; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Settings; - -/// -/// Settings tab for managing personal API tokens. -/// Registered via AddApiTokensSettingsTab() on MessageHubConfiguration. -/// -/// Built from framework controls only. This tab previously emitted hand-built HTML strings -/// for every element — including an inline onclick that reached into document and -/// navigator.clipboard — and hard-coded its English copy, so a German viewer read it in -/// English. Both are now bugs by AGENTS.md ("Never hand-roll UI", "ALWAYS think about -/// internationalization"); the rendering is Controls.* and every user-visible string is a -/// catalog key. -/// -public static class ApiTokensSettingsTab -{ - public const string TabId = "ApiTokens"; - - public static MessageHubConfiguration AddApiTokensSettingsTab( - this MessageHubConfiguration config) - { - // A tab of the PERSON APP: the tokens are the signed-in person's, so it shows on their own - // settings page only — never on a Space's, a node's or the instance's — and an old link on - // any other settings page redirects there. - return config.AddPersonAppTab( - new SettingsMenuItemDefinition( - Id: TabId, - Label: "API Tokens", - ContentBuilder: BuildApiTokensContent, - Icon: FluentIcons.Key(), - Order: PersonApp.ApiTokensOrder, - RequiredPermission: Permission.None) - { LabelKey = "settings.apiTokens" }); - } - - internal static UiControl BuildApiTokensContent( - LayoutAreaHost host, StackControl stack, MeshNode? node) - { - var tokenService = host.Hub.ServiceProvider.GetRequiredService(); - var accessService = host.Hub.ServiceProvider.GetService(); - var userContext = accessService?.Context; - var userId = userContext?.ObjectId ?? ""; - var userName = userContext?.Name ?? ""; - var userEmail = userContext?.Email ?? ""; - - stack = stack - .WithView(Controls.H2(host.Localize("settings.apiTokens"))) - .WithView(Controls.Markdown(host.Localize("apiTokens.intro"))); - - const string createDataId = "apiTokenCreate"; - const string resultDataId = "apiTokenResult"; - - host.UpdateData(createDataId, new Dictionary - { - ["label"] = "", - ["expiryDays"] = 365 - }); - // NOTE: Do NOT initialize resultDataId here — CreateToken saves a MeshNode - // which triggers the workspace stream, causing the Settings page to rebuild. - // If we set resultDataId="" here, the rebuild would overwrite the token display - // that the click handler just set. Instead, the reactive view uses .StartWith(). - // - // The token list below subscribes to `tokenService.GetTokensForUser(userId)` - // — a live synced query (workspace.GetQuery under the hood). New tokens - // appear on CreateNode commit, revokes flip rows to "Revoked" on - // workspace.GetMeshNodeStream(...).Update commit, deletes drop rows on - // DeleteNode commit. No refresh trigger needed. - - var formRow = Controls.Stack - .WithOrientation(Orientation.Horizontal) - .WithHorizontalGap(12) - .WithVerticalAlignment(VerticalAlignment.Bottom) - .WithWrap(true); - - formRow = formRow.WithView(new TextFieldControl(new JsonPointerReference("label")) - { - Label = host.Localize("apiTokens.field.label"), - Placeholder = host.Localize("apiTokens.field.labelPlaceholder"), - DataContext = LayoutAreaReference.GetDataPointer(createDataId) - }.WithWidth("240px")); - - formRow = formRow.WithView(new NumberFieldControl(new JsonPointerReference("expiryDays"), "Int32") - { - Label = host.Localize("apiTokens.field.expiryDays"), - DataContext = LayoutAreaReference.GetDataPointer(createDataId) - }.WithWidth("140px")); - - formRow = formRow.WithView(Controls.Button(host.Localize("ui.generateToken")) - .WithAppearance(Appearance.Accent) - .WithClickAction(ctx => - { - ctx.Host.UpdateData(resultDataId, host.Localize("apiTokens.starting")); - - // Subscribe (no await) to read current form data, then kick off the token - // creation via the service's observable API — fires hub.Post + RegisterCallback - // internally, never blocks the click handler. - ctx.Host.Stream.GetDataStream>(createDataId) - .Take(1) - .Subscribe(data => - { - var label = data?.GetValueOrDefault("label")?.ToString()?.Trim() ?? ""; - var expiryDays = 0; - if (data?.GetValueOrDefault("expiryDays") is { } ed) - int.TryParse(ed.ToString(), out expiryDays); - - if (string.IsNullOrEmpty(label)) - { - ctx.Host.UpdateData(resultDataId, host.Localize("apiTokens.needLabel")); - return; - } - // Reserved for tokens the OAuth sign-in issues (OAuthTokenLifetime). - if (OAuthTokenLifetime.IsReservedLabel(label)) - { - ctx.Host.UpdateData(resultDataId, host.Localize("apiTokens.reservedLabel")); - return; - } - - DateTimeOffset? expiresAt = expiryDays > 0 - ? DateTimeOffset.UtcNow.AddDays(expiryDays) - : null; - - ctx.Host.UpdateData(resultDataId, - $"{host.Localize("apiTokens.creating")} **{label}**…"); - - // Observable-based service call — subscribes to the underlying - // hub.Post + RegisterCallback pipeline without any await. - tokenService.CreateToken(userId, userName, userEmail, label, expiresAt) - .Subscribe( - result => - { - // The token is shown ONCE — only its hash is stored. A fenced - // block renders it selectable/copyable without this tab - // shipping its own clipboard JavaScript. - ctx.Host.UpdateData(resultDataId, - $"**{host.Localize("apiTokens.copyNow")}**\n\n" - + $"```\n{result.RawToken}\n```"); - // No list refresh trigger — the synced query - // below re-emits automatically when the new - // node commits to the workspace. - }, - ex => ctx.Host.UpdateData(resultDataId, - $"{host.Localize("apiTokens.error")} {ex.Message}")); - }); - - return Task.CompletedTask; - })); - - // The create form as a settings section: the platform's card, not an inline-styled box. - stack = stack.WithView(SettingsLayoutArea.Section(host.Localize("apiTokens.createHeading"), formRow)); - - // Result area (newly created token display) — full width so the token text has room. - stack = stack.WithView((h, _) => - h.Stream.GetDataStream(resultDataId) - .Select(markdown => string.IsNullOrEmpty(markdown) - ? (UiControl?)Controls.Stack.WithWidth("100%") - : (UiControl?)Controls.Stack.WithWidth("100%").WithView(Controls.Markdown(markdown))) - .StartWith((UiControl?)Controls.Stack.WithWidth("100%"))); - - stack = stack.WithView(Controls.H3(host.Localize("apiTokens.yourTokens"))); - - // Live token list — bound directly to the synced query. The view - // re-renders whenever the underlying mesh-query collection changes - // (token created, revoked, deleted). No refresh trigger pattern; - // see Doc/Architecture/SyncedMeshNodeQueries.md for the canonical - // shape — every emission is a complete snapshot. - stack = stack.WithView((h, _) => - string.IsNullOrEmpty(userId) - ? Observable.Return(Controls.Markdown(host.Localize("apiTokens.noIdentity"))) - : tokenService.GetTokensForUser(userId) - .Select(tokens => tokens.Count == 0 - ? (UiControl?)Controls.Markdown(host.Localize("apiTokens.none")) - : BuildTokenList(host, tokens, tokenService, resultDataId))); - - return stack; - } - - private static UiControl BuildTokenList( - LayoutAreaHost host, - IReadOnlyList tokens, - ApiTokenService tokenService, - string resultDataId) - { - var container = Controls.Stack.WithWidth("100%").WithVerticalGap(8); - - // 🚨 Render instants in the VIEWER's zone, resolved explicitly off AccessContext — never - // the process's ambient culture, which on Blazor Server is the container's and identical - // for every simultaneous viewer. Formatting counts as localization: a token created at - // 23:30Z shows a day EARLIER than it happened for anyone east of UTC, so even the - // date-only fields are wrong, not merely unlocalised (#2302). - var zoneId = host.Hub.ServiceProvider.GetService().ViewerZoneId(); - - foreach (var token in tokens) - { - var expired = token.ExpiresAt.HasValue && token.ExpiresAt.Value < DateTimeOffset.UtcNow; - var statusKey = token.IsRevoked ? "apiTokens.status.revoked" - : expired ? "apiTokens.status.expired" - : "apiTokens.status.active"; - var never = host.Localize("apiTokens.never"); - - // One token = one row on the platform's card (no hand-drawn border); its children are - // the token facts, the status and the row's actions. - var row = Controls.Stack - .WithWidth("100%") - .WithOrientation(Orientation.Horizontal) - .WithVerticalAlignment(VerticalAlignment.Center) - .WithHorizontalGap(16); - - // Markdown, not an HTML string: the label and hash are user/system data and Markdown - // renders them as text rather than as markup we had to hand-escape. - row = row.WithView(Controls.Markdown( - $"**{token.Label}** \n" - + $"`{token.HashPrefix}` · {host.Localize("apiTokens.created")} " - + $"{DisplayTimeExtensions.ToDisplayTime(token.CreatedAt, zoneId):yyyy-MM-dd}" - + $" · {host.Localize("apiTokens.expires")} " - + $"{(token.ExpiresAt is { } exp ? DisplayTimeExtensions.ToDisplayTime(exp, zoneId).ToString("yyyy-MM-dd") : never)}" - + $" · {host.Localize("apiTokens.lastUsed")} " - + $"{(token.LastUsedAt is { } used ? DisplayTimeExtensions.ToDisplayTime(used, zoneId).ToString("yyyy-MM-dd HH:mm") : never)}") - .WithStyle("flex: 1;")); - - row = row.WithView(Controls.Markdown($"**{host.Localize(statusKey)}**")); - - var capturedForDelete = token; - // Delete button — available for revoked or expired tokens to clean up the list. - if (token.IsRevoked || expired) - { - row = row.WithView(Controls.Button(host.Localize("common.delete")) - .WithAppearance(Appearance.Outline) - .WithClickAction(ctx => - { - ctx.Host.UpdateData(resultDataId, - $"{host.Localize("apiTokens.deleting")} **{capturedForDelete.Label}**…"); - - // Reactive: subscribe to the factored-out observable, exactly as the - // revoke button below does — Delete(...) folds the OnError path into an - // emission so the test drives the SAME composition the UI subscribes to. - // - // 🚨 It is three-valued on purpose. DeleteToken emits what it REMOVED, not - // that it ran (MeshWeaver#4668): `false` means the token was already gone — - // the second admin on the same live list, or this row clicked twice. - // Reporting that as "Deleted token" is the one claim this screen must not - // make, because the row it names is one nobody here took away. - Delete(tokenService, capturedForDelete.NodePath, capturedForDelete.Label) - .Subscribe(outcome => ctx.Host.UpdateData( - resultDataId, DeleteOutcomeMarkdown(host, outcome))); - return Task.CompletedTask; - })); - } - - if (!token.IsRevoked) - { - var captured = token; - row = row.WithView(Controls.Button(host.Localize("ui.revoke")) - .WithAppearance(Appearance.Outline) - .WithClickAction(ctx => - { - ctx.Host.UpdateData(resultDataId, - $"{host.Localize("apiTokens.revoking")} **{captured.Label}**…"); - - // Reactive: subscribe to the factored-out observable. - // Revoke(...) bridges the service call to a single outcome - // record so the test can assert on the same composition - // the UI subscribes to — no await, no Task.Run. The - // list row flips to "Revoked" automatically when the - // synced query sees the IsRevoked change. - Revoke(tokenService, captured.NodePath, captured.Label).Subscribe( - outcome => ctx.Host.UpdateData(resultDataId, OutcomeMarkdown(host, outcome))); - return Task.CompletedTask; - })); - } - - container = container.WithView(Controls.Stack.WithWidth("100%").WithView(row) - .AddSkin(Skins.Card.WithWidth("100%"))); - } - - return container; - } - - /// - /// Outcome of a token revoke/delete invocation — surfaced to both the - /// click handler and the test. is the user-facing - /// pass/fail; carries the optional error detail to - /// embed in the result text. Kept internal so it stays a presentation- - /// layer concern, not an exported API. - /// - internal record TokenActionOutcome(bool Success, string Label, string? Message = null); - - /// - /// Factored-out revoke pipeline — single observable composition shared by - /// the click handler and the test. Bridges - /// to an outcome that includes - /// the label (so the message can be rendered without recapturing it) and - /// folds the OnError path into a successful emission of - /// (Success=false, Message=...) so the test never has to assert on - /// observable termination semantics. Subscribe once — Take(1)-equivalent - /// shape because the underlying service emits exactly one value. - /// - internal static IObservable Revoke( - ApiTokenService tokenService, string nodePath, string label) - => tokenService.RevokeToken(nodePath) - .Select(success => new TokenActionOutcome(success, label)) - .Catch(ex => - Observable.Return(new TokenActionOutcome(false, label, ex.Message))); - - private static string OutcomeMarkdown(LayoutAreaHost host, TokenActionOutcome outcome) - { - if (outcome.Success) - return $"{host.Localize("apiTokens.revoked")} **{outcome.Label}**"; - return string.IsNullOrEmpty(outcome.Message) - ? host.Localize("apiTokens.revokeFailed") - : $"{host.Localize("apiTokens.revokeFailed")} {outcome.Message}"; - } - - /// - /// What a delete did. Three-valued rather than the revoke path's pass/fail, because - /// reports what it REMOVED and "removed nothing" is - /// neither a success to announce nor a failure to apologise for — it is its own outcome, and - /// collapsing it into either of the other two is the whole defect this exists to prevent. - /// - internal enum TokenDeleteResult - { - /// The token was there and this click is what took it away. - Removed, - - /// Nothing was there to remove — already deleted elsewhere, or this row clicked twice. - AlreadyGone, - - /// The delete was refused or faulted; says why. - Failed, - } - - /// Outcome of a token delete — surfaced to both the click handler and the test. - /// Which of the three things happened. - /// The token's label, so the message renders without recapturing it. - /// The failure detail, when . - internal record TokenDeleteOutcome(TokenDeleteResult Result, string Label, string? Message = null); - - /// - /// Factored-out delete pipeline — the single observable composition shared by the click handler - /// and the test, mirroring . Folds the OnError path into an emission so the - /// test never has to assert on observable termination semantics. - /// - internal static IObservable Delete( - ApiTokenService tokenService, string nodePath, string label) - => tokenService.DeleteToken(nodePath) - .Select(removed => new TokenDeleteOutcome( - removed ? TokenDeleteResult.Removed : TokenDeleteResult.AlreadyGone, label)) - .Catch(ex => - Observable.Return(new TokenDeleteOutcome(TokenDeleteResult.Failed, label, ex.Message))); - - private static string DeleteOutcomeMarkdown(LayoutAreaHost host, TokenDeleteOutcome outcome) - => outcome.Result switch - { - TokenDeleteResult.Removed => $"{host.Localize("apiTokens.deleted")} **{outcome.Label}**", - TokenDeleteResult.AlreadyGone => - $"{host.Localize("apiTokens.deleteAlreadyGone")} **{outcome.Label}**", - _ => string.IsNullOrEmpty(outcome.Message) - ? host.Localize("apiTokens.deleteFailed") - : $"{host.Localize("apiTokens.deleteFailed")} {outcome.Message}", - }; -} diff --git a/memex/Memex.Portal.Shared/Settings/ControlLaneSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/ControlLaneSettingsTab.cs deleted file mode 100644 index a0922dba33..0000000000 --- a/memex/Memex.Portal.Shared/Settings/ControlLaneSettingsTab.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Data; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Messaging; -using Memex.Portal.Shared.SelfUpdate; - -namespace Memex.Portal.Shared.Settings; - -/// -/// Admin settings tab Control lane: where a global administrator of THIS instance -/// GENERATES its announcement key (shown once with its fingerprint, then sent to the control -/// instance's operators over a secure channel, who register it), checks the fingerprint, and tests -/// the pairing. A key handed over the other way can also be pasted and saved. The self-updater signs -/// its hand-over to the control instance with that key (). See -/// Doc/Architecture/SelfUpdateAnnouncementKey. -/// -/// Built on the platform's write-only secret control (): -/// a generated key is shown ONCE; a pasted key is typed into a password box. Either is saved -/// encrypted () and never shown again. The page shows only whether a key is present, its fingerprint, who saved it and -/// when, and the last announcement's result. -/// -/// 🚨 Live, no restart. The self-updater reads the key at every announcement, so a key -/// saved here is used by Test connection and by the next announcement at once. -/// -public static class ControlLaneSettingsTab -{ - /// The tab id — a tab of the Admin app (/Admin/Settings/ControlLane); the old - /// /GlobalSettings/ControlLane deep link redirects there. - public const string TabId = "ControlLane"; - - /// The section id (namespaces its layout data). - internal const string SectionId = "controlLaneKey"; - - /// Layout-data key of the one-line result of Test connection. - internal const string TestResultId = "controlLaneTestResult"; - - internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack) - { - const string key = SelfUpdateHandover.SecretKey; - host.UpdateData(TestResultId, ""); - // Cold, one subscription per view: the catalog replays its current state to each. - var status = InstanceSecrets.ObserveStatus(host.Hub, key); - - var test = Controls.Button(host.Localize("ui.controlLaneTest")) - .WithClickAction(ctx => - { - RunTest(ctx.Host); - return Task.CompletedTask; - }); - - stack = stack - .WithView(Controls.H2(host.Localize("ui.controlLane")).WithStyle("margin: 0 0 8px 0;")) - .WithView(WriteOnlySecretSection.Render(host, new SecretSectionSpec(SectionId, status.Select(s => s.Secret)) - { - Intro = host.Localize("ui.mdControlLaneIntro"), - InputLabel = host.Localize("ui.controlLaneKeyLabel"), - Placeholder = host.Localize("ui.controlLaneKeyPlaceholder"), - Detail = status.Select(s => DetailMarkdown(s, (k, a) => host.Localize(k, a), host.ViewerLocale())), - GenerateLabel = host.Localize("ui.controlLaneGenerate"), - RegenerateLabel = host.Localize("ui.controlLaneRegenerate"), - Verbs = new SecretSectionVerbs - { - // The key is generated HERE, on this instance, on the server, and shown ONCE with - // its fingerprint; the administrator hands it to Systemorph over a secure - // channel, and Systemorph registers it on the control instance. Until then the - // control instance refuses this instance's announcements (Test connection says so). - Generate = () => InstanceSecrets.Generate(host.Hub, key) - .Select(g => new SecretGenerated(g.Value, g.Status.Secret, host.Localize("ui.controlLaneGeneratedNote"))), - Save = value => InstanceSecrets.Set(host.Hub, key, value).Select(s => s.Secret), - }, - ExtraButtons = [test], - })); - - stack = stack.WithView((h, _) => h.Stream.GetDataStream(TestResultId) - .Select(msg => (UiControl?)(string.IsNullOrEmpty(msg) - ? Controls.Stack.WithWidth("100%") - : Controls.Markdown(msg))) - .StartWith((UiControl?)Controls.Stack.WithWidth("100%"))); - - return stack; - } - - /// Test connection: a signed, verify-only test to the control instance; its verdict is shown and recorded. - private static void RunTest(LayoutAreaHost host) - { - host.UpdateData(TestResultId, host.Localize("ui.controlLaneTesting")); - new SelfUpdateHandover(host.Hub).Test() - .Subscribe( - outcome => host.UpdateData(TestResultId, - TestMarkdown(outcome, (k, a) => host.Localize(k, a), host.ViewerLocale())), - // A keyed refusal renders in the viewer's language; a transport failure is the - // network stack's own words, shown verbatim (LocalizableText.Verbatim's rule). - ex => host.UpdateData(TestResultId, - host.Localize("ui.controlLaneTestFailed", WriteOnlySecretSection.Describe(ex, host.ViewerLocale())))); - } - - /// The test's verdict as one paragraph. A refusal reads as a MISMATCH to re-enter. Pure over the localizer. - internal static string TestMarkdown(SelfUpdateHandover.TestOutcome outcome, Func localize, string? locale) => - outcome.Accepted - ? outcome.Sender is { } sender - ? localize("ui.controlLaneTestMatch", [sender]) - : localize("ui.controlLaneTestFleet", []) - : localize("ui.controlLaneTestMismatch", [outcome.Text.Localize(locale)]); - - /// - /// What the standard status does not say: that the key comes from the deployment configuration - /// rather than this page, that no key means no hand-over, and the last announcement's result. - /// Names and instants only — never a value. Pure over the localizer. - /// - internal static string DetailMarkdown(InstanceSecretStatus status, Func localize, string? locale) - { - var lines = new List(); - if (status.Origin == InstanceSecretStatus.FromConfiguration) - lines.Add(localize("ui.controlLaneKeyMounted", [])); - else if (!status.Secret.Present || status.Secret.Enabled == false || status.Secret.Deleted) - lines.Add(localize("ui.controlLaneKeyNone", [])); - if (status.LastUsedAt is { } used) - lines.Add(localize(status.LastUseOk == true ? "ui.controlLaneLastOk" : "ui.controlLaneLastFailed", - [WriteOnlySecretSection.Stamp(used), status.LastUseResult?.Localize(locale) ?? ""])); - return string.Join("\n\n", lines); - } -} diff --git a/memex/Memex.Portal.Shared/Settings/InboxSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/InboxSettingsTab.cs deleted file mode 100644 index c27a81bf1e..0000000000 --- a/memex/Memex.Portal.Shared/Settings/InboxSettingsTab.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System.Collections.Immutable; -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.Application.Styles; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Layout.DataGrid; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.Utils; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Settings; - -/// -/// Admin Inbox tab in the platform-wide GlobalSettings (Admin) menu — lists mail received from -/// non-users (filed into Admin/Inbox by the inbound processor). Known-user mail is -/// handled by an agent thread and never lands here. -/// -/// Platform admins only: the menu entry is a seeded UiContribution node with -/// Gates.AdminOnly (), and the SettingsInbox -/// layout area re-asserts the admin gate for direct URLs. -/// -public static class InboxSettingsTab -{ - public const string TabId = "Inbox"; - private const string ResultDataId = "inboxResult"; - private const string ListDataId = "inboxList"; - - internal static UiControl BuildInboxContent(LayoutAreaHost host, StackControl stack) - { - stack = stack.WithView(Controls.H2(host.Localize("settings.inbox")).WithStyle("margin: 0 0 8px 0;")); - stack = stack.WithView(Controls.Markdown( - "Email received from people who are **not** Memex users. (Mail from a known " + - "user is handled by an agent thread, not shown here.)")); - - stack = stack.WithView((h, _) => - h.NoticeView(ResultDataId)); - - // The inbox — a TEMPLATE (Doc/GUI/DataBinding → "Templates first, data later"): the grid is - // declared at once and fed by the synced Admin/Inbox query; each row carries its own Archive - // button, a ROW-SCOPED action ("Row-scoped actions") — the click says which mail it is. - var meshService = host.Hub.ServiceProvider.GetRequiredService(); - var accessService = host.Hub.ServiceProvider.GetService(); - - stack = stack.WithView(MailGrid(host, InboxRowsFeed(host), - (ctx, row) => Archive(ctx, host, row, meshService, accessService))); - - return stack; - } - - /// The column of that holds each row's Archive button. - internal const int ArchiveColumn = 5; - - /// - /// The inbox grid over — the template half, separate from the feed so its - /// row-scoped action is testable. The Archive column's ONE button hands the row it was clicked in - /// to ; a click that carries no row does nothing. - /// - internal static DataGridControl MailGrid( - LayoutAreaHost host, IObservable> rows, Action archive) - => rows - .BindGrid(ListDataId, host.Localize("inbox.empty"), message => host.Localize("inbox.listFailed", message)) - // Each row is its own click: archiving one mail never holds up — or is refused behind — - // archiving the next (Doc/GUI/ButtonPendingState → "Idempotence"). - .WithRowKey("path") - .WithColumn(new PropertyColumnControl { Property = nameof(MailRow.From).ToCamelCase() } - .WithTitle(host.Localize("inbox.column.from"))) - .WithColumn(new PropertyColumnControl { Property = nameof(MailRow.Subject).ToCamelCase() } - .WithTitle(host.Localize("inbox.column.subject"))) - .WithColumn(new PropertyColumnControl { Property = nameof(MailRow.Received).ToCamelCase() } - .WithTitle(host.Localize("inbox.column.received"))) - .WithColumn(new PropertyColumnControl { Property = nameof(MailRow.Status).ToCamelCase() } - .WithTitle(host.Localize("inbox.column.status"))) - .WithColumn(new PropertyColumnControl { Property = nameof(MailRow.Preview).ToCamelCase() } - .WithTitle(host.Localize("inbox.column.preview"))) - .WithColumn(new TemplateColumnControl(Controls.Button(host.Localize("ui.archive")) - .WithAppearance(Appearance.Outline) - .WithClickAction(ctx => - { - if (ctx.RowAs() is { } row) - archive(ctx, row); - return Task.CompletedTask; - })) - .WithTitle(host.Localize("ui.archive"))); - - /// - /// Archives the mail of the clicked row, or says it is archived already. The row is client - /// input, so it only says WHICH mail: the target is re-derived from the inbox query itself, read - /// now (), and the write — which runs as System — only ever reaches a - /// mail that query lists, in its CURRENT state. A path outside the inbox is refused. - /// - private static void Archive( - UiActionContext ctx, LayoutAreaHost host, MailRow clicked, IMeshService meshService, AccessService? accessService) - => CurrentMail(host, clicked.Path).Subscribe(row => - { - if (row is null) - { - ctx.Host.UpdateData(ResultDataId, ActionNotice.Error(host.Localize("inbox.notInInbox"))); - return; - } - if (row.IsArchived) - { - ctx.Host.UpdateData(ResultDataId, ActionNotice.Info(host.Localize("inbox.alreadyArchived"))); - return; - } - if (accessService is null) - { - ctx.Host.UpdateData(ResultDataId, ActionNotice.Error("No access service is registered; the mail cannot be archived.")); - return; - } - ctx.Host.UpdateData(ResultDataId, ActionNotice.Info($"Archiving mail from {row.FromAddress}…")); - // The node itself, read once for the write (it exists: the inbox query just listed it). - ctx.Host.Hub.GetWorkspace().GetMeshNodeStream(row.Path).Take(1) - .SelectMany(node => EmailOf(node, ctx.Hub.JsonSerializerOptions) is { } email - ? Observable.Using( - () => accessService.ImpersonateAsSystem(), - _ => meshService.UpdateNode(node with { Content = email with { Status = EmailStatus.Archived } })) - : Observable.Throw(new InvalidOperationException($"{row.Path} holds no email."))) - .Subscribe( - _ => ctx.Host.UpdateData(ResultDataId, ActionNotice.Success($"Archived mail from {row.FromAddress}.")), - ex => ctx.Host.UpdateData(ResultDataId, ActionNotice.Error(ex.Message))); - }, ex => ctx.Host.UpdateData(ResultDataId, ActionNotice.Error(ex.Message))); - - /// - /// The row of the inbox query, as it is NOW, whose path is — or null when - /// the query lists no such mail. One read of the feed; the action's trust boundary. - /// - internal static IObservable CurrentMail(LayoutAreaHost host, string? path) - => string.IsNullOrEmpty(path) - ? Observable.Return(null) - : InboxRowsFeed(host).Take(1) - .Select(rows => rows.FirstOrDefault(r => string.Equals(r.Path, path, StringComparison.Ordinal))); - - /// - /// The feed half: the inbound mail in Admin/Inbox as rows, newest first, re-emitted by - /// the synced query on every change. Display values are resolved for the viewer when the feed is - /// built. Builds no control. - /// - internal static IObservable> InboxRowsFeed(LayoutAreaHost host) - { - var options = host.Hub.JsonSerializerOptions; - var statusText = ImmutableDictionary.Empty - .Add(EmailStatus.New, host.Localize("inbox.status.new")) - .Add(EmailStatus.Read, host.Localize("inbox.status.read")) - .Add(EmailStatus.Archived, host.Localize("inbox.status.archived")); - return host.Hub.GetWorkspace() - .GetQuery("inbox:list", $"namespace:{EmailNodeType.AdminInboxNamespace} nodeType:{EmailNodeType.NodeType}") - .Select(nodes => (IReadOnlyList)nodes - .SelectMany(n => EmailOf(n, options) is { Direction: EmailDirection.Inbound } email - ? [(node: n, email)] - : Array.Empty<(MeshNode node, MeshWeaver.Mesh.Email email)>()) - .OrderByDescending(x => x.email.ReceivedAt) - .Select(x => MailRow.Of(x.node, x.email, statusText.GetValueOrDefault(x.email.Status, x.email.Status.ToString()))) - .ToList()); - } - - /// One row of the inbox grid — display text plus what Archive needs. - internal record MailRow( - string From, string Subject, string Received, string Status, string Preview, - string Path, string FromAddress, bool IsArchived) - { - - internal static MailRow Of(MeshNode node, MeshWeaver.Mesh.Email email, string status) - { - var body = email.Body ?? ""; - var from = $"{email.FromName ?? email.From} <{email.From}>"; - return new( - from, - email.Subject, - email.ReceivedAt.ToString("yyyy-MM-dd HH:mm"), - status, - body.Length > 140 ? body[..140] + "…" : body, - node.Path ?? "", - email.From, - email.Status == EmailStatus.Archived); - } - } - - private static MeshWeaver.Mesh.Email? EmailOf(MeshNode n, JsonSerializerOptions? options) => n.Content switch - { - MeshWeaver.Mesh.Email e => e, - JsonElement je => Safe(je, options), - _ => null - }; - - private static MeshWeaver.Mesh.Email? Safe(JsonElement je, JsonSerializerOptions? options) - { - try { return JsonSerializer.Deserialize(je.GetRawText(), options); } - catch { return null; } - } -} diff --git a/memex/Memex.Portal.Shared/Settings/InstancesSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/InstancesSettingsTab.cs deleted file mode 100644 index 9a413e0c67..0000000000 --- a/memex/Memex.Portal.Shared/Settings/InstancesSettingsTab.cs +++ /dev/null @@ -1,208 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Application.Styles; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Layout.DataGrid; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.Utils; -using Memex.Portal.Shared.Authentication; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Settings; - -/// -/// Settings tab where a user registers their own MeshWeaver installations and gets each one an -/// instance key. Self-service by design — registering is identity, not entitlement: a new instance -/// authenticates against this portal's plugin registry and is allowed to pull nothing until a -/// platform admin grants it specific plugins (grants live in the Admin partition, out of the -/// owner's reach). -/// -/// Built from framework controls only — a over a plain row -/// record and Controls.* for everything else. No hand-built HTML: see AGENTS.md, "Never -/// hand-roll UI". -/// -public static class InstancesSettingsTab -{ - /// Settings menu id. - public const string TabId = "MeshWeaverInstances"; - - private const string FormDataId = "instanceRegisterForm"; - private const string ResultDataId = "instanceRegisterResult"; - private const string ListDataId = "instanceList"; - - /// Registers the person app's "Connected instances" tab, beside API Tokens. Available to - /// every signed-in person on their own settings page — registering an installation grants it - /// nothing, so it needs no permission gate — and nowhere else (). - public static MessageHubConfiguration AddInstancesSettingsTab(this MessageHubConfiguration config) => - config.AddPersonAppTab( - new SettingsMenuItemDefinition( - Id: TabId, - Label: "Connected instances", - ContentBuilder: BuildContent, - Icon: FluentIcons.Server(), - Order: PersonApp.ConnectedInstancesOrder, - RequiredPermission: Permission.None) - { LabelKey = "settings.connectedInstances" }); - - internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack, MeshNode? node) - { - var accessService = host.Hub.ServiceProvider.GetService(); - var context = accessService?.Context; - var userId = context?.ObjectId ?? ""; - - stack = stack - .WithView(Controls.H2(host.Localize("settings.instances"))) - .WithView(Controls.Markdown(host.Localize("instances.intro"))); - - host.UpdateData(FormDataId, new Dictionary - { - ["instanceId"] = "", - ["displayName"] = "", - ["homeUrl"] = "", - }); - - stack = stack.WithView(RegisterSection(host, userId, context)); - - // The freshly minted key, rendered once. Deliberately NOT seeded here: registering writes a - // MeshNode, the workspace stream rebuilds this page, and a seeded empty value would wipe the - // key the click handler just put there. StartWith supplies the initial blank instead. - stack = stack.WithView(host.Stream - .GetDataStream(ResultDataId) - .StartWith("") - .Select(markdown => string.IsNullOrEmpty(markdown) - ? (UiControl)Controls.Stack - : Controls.Markdown(markdown))); - - return stack.WithView(InstanceList(host, userId)); - } - - private static UiControl RegisterSection(LayoutAreaHost host, string userId, AccessContext? context) - { - var form = Controls.Stack - .WithOrientation(Orientation.Horizontal) - .WithHorizontalGap(12) - .WithVerticalAlignment(VerticalAlignment.Bottom) - .WithWrap(true); - - var dataContext = LayoutAreaReference.GetDataPointer(FormDataId); - - form = form - .WithView(new TextFieldControl(new JsonPointerReference("instanceId")) - { - Label = host.Localize("instances.field.id"), - Placeholder = host.Localize("instances.field.idPlaceholder"), - DataContext = dataContext, - }.WithWidth("200px")) - .WithView(new TextFieldControl(new JsonPointerReference("displayName")) - { - Label = host.Localize("instances.field.name"), - DataContext = dataContext, - }.WithWidth("220px")) - .WithView(new TextFieldControl(new JsonPointerReference("homeUrl")) - { - Label = host.Localize("instances.field.url"), - Placeholder = "https://…", - DataContext = dataContext, - }.WithWidth("260px")) - .WithView(Controls.Button(host.Localize("instances.register")) - .WithAppearance(Appearance.Accent) - .WithClickAction(ctx => - { - ctx.Host.UpdateData(ResultDataId, host.Localize("instances.registering")); - // Read the current form values, then register. Subscribe — never await: the - // service is a cold observable and the click handler must not block. - ctx.Host.Stream.GetDataStream>(FormDataId) - .Take(1) - .Subscribe(data => Register(ctx.Host, host, data, userId, context)); - return Task.CompletedTask; - })); - - // A settings section: the platform's card, not an inline-styled box. - return SettingsLayoutArea.Section(host.Localize("instances.registerHeading"), - Controls.Stack - .WithWidth("100%") - .WithVerticalGap(12) - .WithView(Controls.Markdown(host.Localize("instances.registerHint"))) - .WithView(form)); - } - - private static void Register( - LayoutAreaHost target, LayoutAreaHost host, - Dictionary? data, string userId, AccessContext? context) - { - var instanceId = data?.GetValueOrDefault("instanceId")?.ToString()?.Trim() ?? ""; - var displayName = data?.GetValueOrDefault("displayName")?.ToString()?.Trim() ?? ""; - var homeUrl = data?.GetValueOrDefault("homeUrl")?.ToString()?.Trim() ?? ""; - - if (!MeshWeaverInstanceService.IsValidInstanceId(instanceId)) - { - target.UpdateData(ResultDataId, host.Localize("instances.error.invalidId")); - return; - } - - var service = host.Hub.ServiceProvider.GetRequiredService(); - service.Register(userId, context?.Name ?? "", context?.Email ?? "", - instanceId, displayName, homeUrl: homeUrl) - .Subscribe( - result => target.UpdateData(ResultDataId, - // The key is shown ONCE — only its hash is stored, so there is no second chance - // to read it. Say so where the user is looking, not in a doc. - $"**{host.Localize("instances.keyIssued")}**\n\n" - + $"```\n{result.RawKey}\n```\n\n" - + host.Localize("instances.keyShownOnce") + "\n\n" - + host.Localize("instances.noGrantsYet")), - ex => target.UpdateData(ResultDataId, - $"{host.Localize("instances.error.registerFailed")} {ex.Message}")); - } - - /// - /// The instance list — a TEMPLATE: the grid is declared at once and bound to - /// /data/instanceList; feeds it (Doc/GUI/DataBinding → - /// "Templates first, data later"). - /// - private static UiControl InstanceList(LayoutAreaHost host, string userId) - => Controls.Stack - .WithView(Controls.H3(host.Localize("instances.yourInstances"))) - .WithView(InstanceRows(host, userId) - .BindGrid(ListDataId, host.Localize("instances.none"), - message => host.Localize("instances.error.listFailed", message)) - .WithColumn(new PropertyColumnControl - { Property = nameof(InstanceRow.InstanceId).ToCamelCase() } - .WithTitle(host.Localize("instances.column.id"))) - .WithColumn(new PropertyColumnControl - { Property = nameof(InstanceRow.DisplayName).ToCamelCase() } - .WithTitle(host.Localize("instances.column.name"))) - .WithColumn(new PropertyColumnControl - { Property = nameof(InstanceRow.Path).ToCamelCase() } - .WithTitle(host.Localize("instances.column.path")))); - - /// - /// The feed half: the viewer's registered instances as rows. A live query, not a one-shot - /// read — a registration made above commits a node, the query re-emits, and the grid picks it - /// up without any refresh trigger. Builds no control. - /// - internal static IObservable> InstanceRows(LayoutAreaHost host, string userId) - { - var query = $"nodeType:{MeshWeaver.Graph.Configuration.MeshWeaverInstanceNodeType.NodeType} " - + $"namespace:{userId}/{MeshWeaverInstanceService.InstanceNamespace}"; - return host.Hub.ServiceProvider.GetRequiredService() - .Query(new MeshWeaver.Mesh.Services.MeshQueryRequest { Query = query, UserId = userId }) - .Select(Rows); - } - - private static IReadOnlyList Rows(IReadOnlyCollection results) => - results.Select(r => new InstanceRow( - r.Id ?? r.Path.Split('/').Last(), - r.Name ?? "", - r.Path)) - .OrderBy(r => r.InstanceId, StringComparer.Ordinal) - .ToList(); - - /// One row of the instance grid — a plain record so the grid binds it directly. - internal record InstanceRow(string InstanceId, string DisplayName, string Path); -} diff --git a/memex/Memex.Portal.Shared/Settings/InvitationsSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/InvitationsSettingsTab.cs deleted file mode 100644 index 6bb1b2bd30..0000000000 --- a/memex/Memex.Portal.Shared/Settings/InvitationsSettingsTab.cs +++ /dev/null @@ -1,237 +0,0 @@ -using System.Collections.Immutable; -using System.Reactive.Linq; -using System.Threading.Channels; -using MeshWeaver.Application.Styles; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Layout.DataGrid; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Memex.Portal.Shared.Authentication; -using Memex.Portal.Shared.Email; -using MeshWeaver.Utils; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Settings; - -/// -/// Admin settings tab for managing invitation-only onboarding. Lists outstanding -/// s and lets an admin invite an email (which creates the invitation -/// node and sends a no-reply email via ) or revoke one. -/// -/// Platform admins only: the menu entry is a seeded UiContribution node with -/// Gates.AdminOnly (), and the -/// SettingsInvitations layout area re-asserts the admin gate for direct URLs. -/// -public static class InvitationsSettingsTab -{ - public const string TabId = "Invitations"; - private const string ResultDataId = "invitationResult"; - private const string FormDataId = "invitationForm"; - private const string ListDataId = "invitationList"; - - internal static UiControl BuildInvitationsContent( - LayoutAreaHost host, StackControl stack) - { - var invitationService = host.Hub.ServiceProvider.GetRequiredService(); - var accessService = host.Hub.ServiceProvider.GetService(); - var viewerId = accessService?.Context?.ObjectId ?? accessService?.CircuitContext?.ObjectId; - // The invitation EMAIL is sent by the node-driven InvitationEmailSender hosted service - // (watches Pending invitations, stamps EmailSentAt). This handler just creates the node. - - stack = stack.WithView(Controls.H2(host.Localize("settings.invitations")).WithStyle("margin: 0 0 8px 0;")); - stack = stack.WithView(Controls.Markdown( - "When invitation-only onboarding is enabled (`Features:Onboarding:InvitationOnly`), " + - "only invited emails may complete onboarding. Invite someone below — they receive an email and " + - "may sign in with that address to get started.")); - - // ── Invite form ─────────────────────────────────────────────────────── - host.UpdateData(FormDataId, new Dictionary - { - ["email"] = "", - ["note"] = "", - }); - - var formRow = Controls.Stack.WithOrientation(Orientation.Horizontal) - .WithStyle("gap: 12px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 8px;"); - - formRow = formRow.WithView(new TextFieldControl(new JsonPointerReference("email")) - { - Label = "Email to invite", - Placeholder = "person@example.com", - DataContext = LayoutAreaReference.GetDataPointer(FormDataId) - }.WithWidth("320px")); - - formRow = formRow.WithView(new TextFieldControl(new JsonPointerReference("note")) - { - Label = "Note (optional)", - Placeholder = "e.g. New teammate", - DataContext = LayoutAreaReference.GetDataPointer(FormDataId) - }.WithWidth("240px")); - - formRow = formRow.WithView(Controls.Button(host.Localize("ui.invite")) - .WithAppearance(Appearance.Accent) - .WithClickAction(clickCtx => - { - var h = clickCtx.Host; - h.UpdateData(ResultDataId, ActionNotice.Info("Sending invitation…")); - h.Stream.GetDataStream>(FormDataId) - .Take(1) - .Subscribe(data => - { - var inviteEmail = data?.GetValueOrDefault("email")?.ToString()?.Trim() ?? ""; - var note = data?.GetValueOrDefault("note")?.ToString()?.Trim(); - if (string.IsNullOrEmpty(inviteEmail) || !inviteEmail.Contains('@')) - { - h.UpdateData(ResultDataId, ActionNotice.Error("Enter a valid email address.")); - return; - } - - invitationService.CreateInvitation(inviteEmail, viewerId, note) - .Subscribe( - _ => h.UpdateData(ResultDataId, - ActionNotice.Success($"Invited {inviteEmail} — an invitation email will be sent shortly.")), - ex => h.UpdateData(ResultDataId, - ActionNotice.Error($"Failed to create invitation: {ex.Message}"))); - }); - return Task.CompletedTask; - })); - - stack = stack.WithView(formRow); - - // Result area (live HTML for invite / revoke outcomes). - stack = stack.WithView((h, _) => - h.NoticeView(ResultDataId)); - - // ── Existing invitations ─────────────────────────────────────────────── - stack = stack.WithView(Controls.H3(host.Localize("settings.invitations"))); - - // The invitations — a TEMPLATE (Doc/GUI/DataBinding → "Templates first, data later"): the - // grid is declared at once and fed by the synced Admin/Invitation query; each row carries its - // own Revoke button, a ROW-SCOPED action ("Row-scoped actions") — the click says which - // invitation it is. - stack = stack.WithView(InvitationGrid(host, InvitationRowsFeed(host), - (ctx, row) => Revoke(ctx, host, row, invitationService))); - - return stack; - } - - /// The column of that holds each row's Revoke button. - internal const int RevokeColumn = 8; - - /// - /// The invitation grid over — the template half, separate from the feed so - /// its row-scoped action is testable. The Revoke column's ONE button hands the row it was clicked - /// in to ; a click that carries no row does nothing. - /// - internal static DataGridControl InvitationGrid( - LayoutAreaHost host, IObservable> rows, Action revoke) - => rows - .BindGrid(ListDataId, host.Localize("invitations.none"), message => host.Localize("invitations.listFailed", message)) - // Each row is its own click: revoking one invitation never holds up — or is refused behind — - // revoking the next (Doc/GUI/ButtonPendingState → "Idempotence"). - .WithRowKey("path") - .WithColumn(new PropertyColumnControl { Property = nameof(InvitationRow.Email).ToCamelCase() } - .WithTitle(host.Localize("invitations.column.email"))) - .WithColumn(new PropertyColumnControl { Property = nameof(InvitationRow.Status).ToCamelCase() } - .WithTitle(host.Localize("invitations.column.status"))) - .WithColumn(new PropertyColumnControl { Property = nameof(InvitationRow.Invited).ToCamelCase() } - .WithTitle(host.Localize("invitations.column.invited"))) - .WithColumn(new PropertyColumnControl { Property = nameof(InvitationRow.InvitedBy).ToCamelCase() } - .WithTitle(host.Localize("invitations.column.invitedBy"))) - .WithColumn(new PropertyColumnControl { Property = nameof(InvitationRow.Emailed).ToCamelCase() } - .WithTitle(host.Localize("invitations.column.emailed"))) - .WithColumn(new PropertyColumnControl { Property = nameof(InvitationRow.Accepted).ToCamelCase() } - .WithTitle(host.Localize("invitations.column.accepted"))) - .WithColumn(new PropertyColumnControl { Property = nameof(InvitationRow.Space).ToCamelCase() } - .WithTitle(host.Localize("invitations.column.space"))) - .WithColumn(new PropertyColumnControl { Property = nameof(InvitationRow.Note).ToCamelCase() } - .WithTitle(host.Localize("invitations.column.note"))) - .WithColumn(new TemplateColumnControl(Controls.Button(host.Localize("ui.revoke")) - .WithAppearance(Appearance.Outline) - .WithClickAction(ctx => - { - if (ctx.RowAs() is { } row) - revoke(ctx, row); - return Task.CompletedTask; - })) - .WithTitle(host.Localize("ui.revoke"))); - - /// Revokes the clicked row's invitation, or says that only a pending invitation can - /// be revoked. The row is client input: the invitation is re-derived from the invitation query, - /// read now (), so only a listed invitation that is STILL pending - /// is revoked. - private static void Revoke(UiActionContext ctx, LayoutAreaHost host, InvitationRow clicked, InvitationService invitationService) - => CurrentInvitation(host, clicked.Path).Subscribe(row => - { - if (row is null || !row.IsPending) - { - ctx.Host.UpdateData(ResultDataId, ActionNotice.Info(host.Localize("invitations.notPending"))); - return; - } - ctx.Host.UpdateData(ResultDataId, ActionNotice.Info($"Revoking {row.Email}…")); - // The node itself, read once for the write (it exists: the invitation query just listed it). - ctx.Host.Hub.GetWorkspace().GetMeshNodeStream(row.Path).Take(1) - .SelectMany(node => InvitationService.TryGetInvitation(node, ctx.Hub.JsonSerializerOptions) is { } inv - ? invitationService.Revoke(node, inv) - : Observable.Throw(new InvalidOperationException($"{row.Path} holds no invitation."))) - .Subscribe( - _ => ctx.Host.UpdateData(ResultDataId, ActionNotice.Success($"Revoked invitation for {row.Email}.")), - ex => ctx.Host.UpdateData(ResultDataId, ActionNotice.Error(ex.Message))); - }, ex => ctx.Host.UpdateData(ResultDataId, ActionNotice.Error(ex.Message))); - - /// The row of the invitation query, as it is NOW, whose path is - /// — or null when the query lists no such invitation. - internal static IObservable CurrentInvitation(LayoutAreaHost host, string? path) - => string.IsNullOrEmpty(path) - ? Observable.Return(null) - : InvitationRowsFeed(host).Take(1) - .Select(rows => rows.FirstOrDefault(r => string.Equals(r.Path, path, StringComparison.Ordinal))); - - /// - /// The feed half: every invitation as a row, newest first, re-emitted by the synced query on - /// every change. PATH-scoped (path:Admin/Invitation) so it routes to the admin schema; a - /// namespace:Admin-only query fans out cross-schema, which excludes admin. Builds no control. - /// - internal static IObservable> InvitationRowsFeed(LayoutAreaHost host) - { - var options = host.Hub.JsonSerializerOptions; - var statusText = ImmutableDictionary.Empty - .Add(InvitationStatus.Pending, host.Localize("invitations.status.pending")) - .Add(InvitationStatus.Accepted, host.Localize("invitations.status.accepted")) - .Add(InvitationStatus.Revoked, host.Localize("invitations.status.revoked")); - return host.Hub.GetWorkspace() - .GetQuery("invite:list", $"path:{InvitationNodeType.Namespace} scope:children nodeType:{InvitationNodeType.NodeType}") - .Select(nodes => (IReadOnlyList)nodes - .SelectMany(n => InvitationService.TryGetInvitation(n, options) is { } inv - ? [(node: n, inv)] - : Array.Empty<(MeshNode node, Invitation inv)>()) - .OrderByDescending(x => x.inv.InvitedAt) - .Select(x => InvitationRow.Of(x.node, x.inv, statusText.GetValueOrDefault(x.inv.Status, x.inv.Status.ToString()))) - .ToList()); - } - - /// One row of the invitation grid — display text plus what Revoke needs. - internal record InvitationRow( - string Email, string Status, string Invited, string InvitedBy, string Emailed, string Accepted, - string Space, string Note, string Path, bool IsPending) - { - - internal static InvitationRow Of(MeshNode node, Invitation inv, string status) => new( - inv.Email, - status, - inv.InvitedAt.ToString("yyyy-MM-dd"), - inv.InvitedBy ?? "", - inv.EmailSentAt is { } sent ? sent.ToString("yyyy-MM-dd") : "", - inv.AcceptedAt is { } accepted ? accepted.ToString("yyyy-MM-dd") : "", - inv.SpacePath ?? "", - inv.Note ?? "", - node.Path ?? "", - inv.Status == InvitationStatus.Pending); - } -} diff --git a/memex/Memex.Portal.Shared/Settings/NotificationsSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/NotificationsSettingsTab.cs deleted file mode 100644 index 415a6220a6..0000000000 --- a/memex/Memex.Portal.Shared/Settings/NotificationsSettingsTab.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Application.Styles; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Settings; - -/// -/// Per-user "Notifications" settings tab — lets every user choose, PER FEATURE (approvals, inbox, -/// triage, access grants, chat, system, and any feature a module registers), which channels reach -/// them: the in-app bell, Microsoft Teams and email. One section per feature, each the STANDARD -/// node-content editor bound directly to that feature's -/// {userId}/_Settings/Notifications/{feature} node -/// (each checkbox writes one field through the node stream — no /data replica, no save -/// subscription). The node is created on first view with the preference the user already had -/// (), so opening the tab changes no -/// delivery. Visible to everyone (). -/// -/// These preferences drive . The default for a feature -/// the user never set is the bell and Teams. The AI-triage routing -/// (NotificationRule/NotificationChannel) still layers on top for users who author -/// rules; the deterministic email path defers to it for them. -/// -public static class NotificationsSettingsTab -{ - public const string TabId = "Notifications"; - - public static MessageHubConfiguration AddNotificationsSettingsTab(this MessageHubConfiguration config) - // A tab of the PERSON APP (PersonApp.AddPersonAppTab): the preferences are the signed-in - // person's, so the tab shows on their own settings page only. - => config.AddPersonAppTab( - new SettingsMenuItemDefinition( - Id: TabId, - Label: "Notifications", - ContentBuilder: BuildContent, - Icon: FluentIcons.Alert(), - Order: PersonApp.NotificationsOrder, - RequiredPermission: Permission.None, - Keywords: ["notifications", "teams", "email", "bell", "channels", "approvals", "inbox", "triage", "apps", "deliver quietly"]) - { LabelKey = "settings.notifications" }); - - /// - /// The features the tab offers a row for: the platform's own - /// () plus every - /// a module registered, one row per feature key, in . - /// - internal static IReadOnlyList Features(IServiceProvider services) - => NotificationFeatures.BuiltIn - .Concat(services.GetServices()) - // A key that could not name a preference node (NotificationFeatures.IsValidKey) gets - // no row: Raise refuses such a feature, so there is nothing to choose channels for. - .Where(d => NotificationFeatures.IsValidKey(d.Feature)) - .GroupBy(d => d.Feature, StringComparer.Ordinal) - .Select(g => g.First()) - .OrderBy(d => d.Order) - .ThenBy(d => d.Feature, StringComparer.Ordinal) - .ToList(); - - internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack, MeshNode? node) - { - var accessService = host.Hub.ServiceProvider.GetService(); - var userId = accessService?.Context?.ObjectId ?? accessService?.CircuitContext?.ObjectId; - - stack = stack.WithView(Controls.H2(host.Localize("settings.notifications")).WithStyle("margin: 0 0 8px 0;")); - stack = stack.WithView(Controls.Markdown(host.Localize("settings.notificationsIntro"))); - stack = stack.WithView(Controls.Markdown(host.Localize("settings.notificationsTeamsHint"))); - - if (string.IsNullOrEmpty(userId)) - { - stack = stack.WithView(Controls.Markdown(host.Localize("ui.mdSignInForNotifs"))); - return stack; - } - - // One section per feature: ensure the feature's node exists (create-on-absent, seeded with - // what the user already had), then bind the standard node-content editor to it — each bool - // renders as a labelled checkbox that auto-saves through the node stream. No hand-rolled - // form, no /data copy. - foreach (var feature in Features(host.Hub.ServiceProvider)) - { - var key = feature.Feature; - stack = stack.WithView(Controls.H3(host.Localize(feature.LabelKey)).WithStyle("margin: 16px 0 4px 0;")); - stack = stack.WithView((h, _) => NotificationFeaturePreferenceNodeType - .EnsureExists(h.Hub, userId!, key) - .Select(path => (UiControl?)MeshNodeContentEditorControl.ForType(path, typeof(NotificationFeaturePreference))) - // A seed refused because the legacy settings could not be read is shown, not - // swallowed — and nothing was written, so the person's choice is intact. - .Catch((Exception _) => Observable.Return( - Controls.Markdown(host.Localize("ui.mdNotifPrefsUnavailable")))) - .StartWith((UiControl?)Controls.Markdown(host.Localize("ui.mdLoadingNotifPrefs")))); - } - - // iOS Settings → Notifications: below the kinds, one section per INSTALLED APP — its - // master switch, "Deliver quietly", and which channels it may use. Listed from the same - // single-partition query the home's Apps grid makes; each editor binds the app's - // {userId}/_Settings/Notifications/Apps/{appId} node, created on first view with the default - // the dispatcher already applies, so opening the tab changes no delivery. - stack = stack.WithView(Controls.H2(host.Localize("settings.notificationsApps")).WithStyle("margin: 24px 0 8px 0;")); - stack = stack.WithView(Controls.Markdown(host.Localize("settings.notificationsAppsIntro"))); - stack = stack.WithView((h, _) => h.Hub.GetWorkspace() - .GetQuery($"{NotificationAppPreferenceNodeType.NodeType}|tab|{userId}", - $"path:{userId}/{AppNodeType.UserNamespace} scope:children nodeType:{AppNodeType.NodeType} sort:name") - .Take(1) - .Select(apps => (UiControl?)AppSections(host, userId!, apps ?? [])) - .Catch((Exception _) => Observable.Return( - Controls.Markdown(host.Localize("ui.mdNotifPrefsUnavailable")))) - .StartWith((UiControl?)Controls.Markdown(host.Localize("ui.mdLoadingNotifPrefs")))); - - return stack; - } - - private static UiControl AppSections(LayoutAreaHost host, string userId, IEnumerable apps) - { - var sections = Controls.Stack; - var any = false; - foreach (var app in apps.Where(a => NotificationApps.IsValidKey(a.Id))) - { - any = true; - var id = app.Id; - sections = sections.WithView(Controls.H3(string.IsNullOrWhiteSpace(app.Name) ? id : app.Name!) - .WithStyle("margin: 16px 0 4px 0;")); - sections = sections.WithView((h, _) => NotificationAppPreferenceNodeType - .EnsureExists(h.Hub, userId, id) - .Select(path => (UiControl?)MeshNodeContentEditorControl.ForType(path, typeof(NotificationAppPreference))) - .Catch((Exception _) => Observable.Return( - Controls.Markdown(host.Localize("ui.mdNotifPrefsUnavailable")))) - .StartWith((UiControl?)Controls.Markdown(host.Localize("ui.mdLoadingNotifPrefs")))); - } - return any ? sections : Controls.Markdown(host.Localize("settings.notificationsAppsNone")); - } -} diff --git a/memex/Memex.Portal.Shared/Settings/PlatformSettingsTabAreas.cs b/memex/Memex.Portal.Shared/Settings/PlatformSettingsTabAreas.cs deleted file mode 100644 index 82be86d761..0000000000 --- a/memex/Memex.Portal.Shared/Settings/PlatformSettingsTabAreas.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; - -namespace Memex.Portal.Shared.Settings; - -/// -/// The platform's settings tabs served as DATA instead of compiled -/// provider registrations (design #1645). The CONTENT stays compiled — each tab's existing -/// BuildContent is exposed as a normal layout AREA — while the menu ENTRY (label, icon, -/// order, grouping, admin gate) is a seeded UiContribution node the compiled aggregator -/// projects under the closed gate vocabulary. A plugin contributes a settings tab the exact same -/// way; these seeds prove the lane on the platform's own tabs. -/// -/// Tranche 1 (WS7 slice 2, #1648): What's New, About and Privacy. Tranche 2 (WS7 slice 4) -/// adds the platform-admin Administration tabs: Invitations and Inbox (the -/// invitation-only onboarding manager and the non-user mail inbox — the pair that used to be the -/// dedicated Admin menu), Updates (the Admin/UpdatePolicy auto-update strategy — -/// stable/continuous/none), Published to the web (every page a logged-out visitor can open, -/// read from the SAME enumeration /sitemap.xml renders, so the two cannot drift) and -/// Token Usage (per-model _Usage analytics with cost from ModelPricing). -/// -public static class PlatformSettingsTabAreas -{ - /// Layout area rendering . - public const string WhatsNewArea = "SettingsWhatsNew"; - - /// Layout area rendering . - public const string AboutArea = "SettingsAbout"; - - /// Layout area rendering (admin-gated). - public const string PrivacyArea = "SettingsPrivacy"; - - /// Layout area rendering (admin-gated). - public const string InvitationsArea = "SettingsInvitations"; - - /// Layout area rendering (admin-gated). - public const string InboxArea = "SettingsInbox"; - - /// Layout area rendering (admin-gated). - public const string UpdatePolicyArea = "SettingsUpdatePolicy"; - - /// Layout area rendering (admin-gated). - public const string PublishedArea = "SettingsPublished"; - - /// Layout area rendering (admin-gated). - public const string ControlLaneArea = "SettingsControlLane"; - - /// Layout area rendering (admin-gated). - public const string ServiceIdentitiesArea = "SettingsServiceIdentities"; - - - /// - /// Registers the tranches' tab contents as layout areas on the per-node hubs. The settings - /// pane embeds them via the contributed entries' Area; the pane supplies the - /// padding/scroll container, so each area builds into a plain full-width stack. - /// - public static MessageHubConfiguration AddPlatformSettingsTabAreas(this MessageHubConfiguration config) - => config - // The Administration tabs are Admin-app tabs now; recorded on every node hub so an old - // /_Setting/GlobalSettings/{id} (or any other settings page's) link redirects into the app. - .RelocateSettingsTabsToAdminApp([.. AdminAppTabIds]) - .AddLayout(layout => layout - .WithView(WhatsNewArea, (host, _) => WhatsNewSettingsTab.BuildContent(host, PaneStack())) - .WithView(AboutArea, (host, _) => AboutSettingsTab.BuildContent(host, PaneStack())) - // Privacy's tab content is the ADMIN EDITOR of the public statement (the statement - // itself is served anonymously at /privacy). - .WithView(PrivacyArea, (host, _) => AdminGated(host, - () => PrivacySettingsTab.BuildContent(host, PaneStack()))) - .WithView(InvitationsArea, (host, _) => AdminGated(host, - () => InvitationsSettingsTab.BuildInvitationsContent(host, PaneStack()))) - .WithView(InboxArea, (host, _) => AdminGated(host, - () => InboxSettingsTab.BuildInboxContent(host, PaneStack()))) - .WithView(UpdatePolicyArea, (host, _) => AdminGated(host, - () => UpdatePolicySettingsTab.BuildContent(host, PaneStack()))) - .WithView(PublishedArea, (host, _) => AdminGated(host, - () => PublishedSettingsTab.BuildContent(host, PaneStack()))) - .WithView(ControlLaneArea, (host, _) => AdminGated(host, - () => ControlLaneSettingsTab.BuildContent(host, PaneStack()))) - .WithView(ServiceIdentitiesArea, (host, _) => AdminGated(host, - () => ServiceIdentitiesSettingsTab.BuildContent(host, PaneStack())))); - - /// - /// The admin-gated area body. Each admin tab's contributed entry hides the tab via - /// Gates.AdminOnly, but an area is directly addressable by URL — so the area re-asserts - /// the same gate instead of trusting the menu to be the only door. TakeLast(1) waits for the - /// gate to RESOLVE (it opens with a synthetic false so menus render ungated tabs immediately) — - /// a neutral pane shows meanwhile, so an admin never flashes the denial. - /// - private static IObservable AdminGated(LayoutAreaHost host, Func content) - => AdminMenuGate.IsPlatformAdmin(host) - .TakeLast(1) - .Select(isAdmin => isAdmin - ? content() - : (UiControl)Controls.Markdown(host.Localize("ui.accessDeniedAdminsOnly"))) - .StartWith((UiControl)PaneStack()); - - /// - /// Every area registers — MUST list exactly the - /// WithView entries above. The seed-integrity test asserts each seed's Area - /// points at one of these, so a seed can never dangle against an unregistered area. - /// - internal static IReadOnlyList Areas { get; } = - [ - WhatsNewArea, AboutArea, PrivacyArea, InvitationsArea, InboxArea, - UpdatePolicyArea, PublishedArea, ControlLaneArea, ServiceIdentitiesArea, - ]; - - /// - /// The seeded menu entries, exposed for the pinning tests (same contract as - /// AiMenuContributions.Seeds). Node id = the tab's former compiled tab id, - /// keeping every /GlobalSettings/{Id} deep link stable across the migration; every - /// label/icon/order/group is copied EXACTLY from the former compiled definition. - /// - internal static IReadOnlyList Seeds { get; } = - [ - // Ungated tabs — visible to every signed-in viewer. - Seed(WhatsNewSettingsTab.TabId, "What's New", new UiContribution - { - Context = UiContribution.SettingsContext, - Area = WhatsNewArea, - Label = "What's New", - LabelKey = "settings.whatsNew", - Icon = "Sparkle", - Order = 910, - }), - Seed(AboutSettingsTab.TabId, "About", new UiContribution - { - Context = UiContribution.SettingsContext, - Area = AboutArea, - Label = "About", - LabelKey = "settings.about", - Icon = "Info", - Order = 900, - }), - // Tabs of the ADMIN APP (/Admin/Settings/{id}), platform admins only, in the app's sections — - // "People & sign-in" (Invitations, Privacy, Published) and "Operations" (Updates, Control - // lane, Inbox): the per-node settings lane (NodeSettings) gated to the AdminApp node type and - // AdminOnly, and the area re-asserts the gate for direct URLs. They used to be global-settings - // tabs (/_Setting/GlobalSettings/{id}); an old link redirects into the app (see - // AddPlatformSettingsTabAreas). Ordered by tab Order within the section. - Seed(InvitationsSettingsTab.TabId, "Invitations", new UiContribution - { - Context = UiContribution.NodeSettingsContext, - Area = InvitationsArea, - Label = "Invitations", - LabelKey = "settings.invitations", - Icon = "Mail", - Group = AdminAppNodeType.PeopleGroup, - GroupKey = AdminAppNodeType.PeopleGroupKey, - GroupIcon = "People", - Order = AdminAppNodeType.PeopleOrder + 10, - Gates = AdminAppOnly, - }), - Seed(InboxSettingsTab.TabId, "Inbox", new UiContribution - { - Context = UiContribution.NodeSettingsContext, - Area = InboxArea, - Label = "Inbox", - LabelKey = "settings.inbox", - Icon = "Mail", - Group = AdminAppNodeType.OperationsGroup, - GroupKey = AdminAppNodeType.OperationsGroupKey, - GroupIcon = "Wrench", - Order = AdminAppNodeType.OperationsOrder + 40, - Gates = AdminAppOnly, - }), - Seed(UpdatePolicySettingsTab.TabId, "Updates", new UiContribution - { - Context = UiContribution.NodeSettingsContext, - Area = UpdatePolicyArea, - Label = "Updates", - LabelKey = "settings.updates", - Icon = "ArrowSync", - Group = AdminAppNodeType.OperationsGroup, - GroupKey = AdminAppNodeType.OperationsGroupKey, - GroupIcon = "Wrench", - Order = AdminAppNodeType.OperationsOrder, - Gates = AdminAppOnly, - }), - Seed(ControlLaneSettingsTab.TabId, "Control lane", new UiContribution - { - Context = UiContribution.NodeSettingsContext, - Area = ControlLaneArea, - Label = "Control lane", - LabelKey = "settings.controlLane", - Icon = "Key", - Group = AdminAppNodeType.OperationsGroup, - GroupKey = AdminAppNodeType.OperationsGroupKey, - GroupIcon = "Wrench", - Order = AdminAppNodeType.OperationsOrder + 10, - Gates = AdminAppOnly, - }), - Seed(PublishedSettingsTab.TabId, "Published to the web", new UiContribution - { - Context = UiContribution.NodeSettingsContext, - Area = PublishedArea, - Label = "Published to the web", - LabelKey = "settings.published", - Icon = "Globe", - Group = AdminAppNodeType.PeopleGroup, - GroupKey = AdminAppNodeType.PeopleGroupKey, - GroupIcon = "People", - Order = AdminAppNodeType.PeopleOrder + 30, - Gates = AdminAppOnly, - }), - Seed(ServiceIdentitiesSettingsTab.TabId, "Service identities", new UiContribution - { - Context = UiContribution.NodeSettingsContext, - Area = ServiceIdentitiesArea, - Label = "Service identities", - LabelKey = "settings.serviceIdentities", - Icon = "Bot", - Group = AdminAppNodeType.PeopleGroup, - GroupKey = AdminAppNodeType.PeopleGroupKey, - GroupIcon = "People", - Order = AdminAppNodeType.PeopleOrder + 40, - Gates = AdminAppOnly, - }), - Seed(PrivacySettingsTab.TabId, "Privacy", new UiContribution - { - Context = UiContribution.NodeSettingsContext, - Area = PrivacyArea, - Label = "Privacy", - LabelKey = "settings.privacy", - Icon = "Shield", - Group = AdminAppNodeType.PeopleGroup, - GroupKey = AdminAppNodeType.PeopleGroupKey, - GroupIcon = "People", - Order = AdminAppNodeType.PeopleOrder + 20, - Gates = AdminAppOnly, - }), - ]; - - /// - /// Seeds the menu entries as platform-static UiContribution nodes under - /// Admin/UiContribution. - /// - public static MeshBuilder AddPlatformSettingsTabContributions(this MeshBuilder builder) - => builder.AddMeshNodes(Seeds); - - /// - /// The gate every Administration seed carries: the Admin app's node only, platform admins only. - /// - private static UiContributionGates AdminAppOnly => new() - { - AdminOnly = true, - NodeTypes = [AdminAppNodeType.NodeType], - }; - - /// - /// The seeded tabs that are Admin-app tabs — the ids an old global-settings link may still name. - /// - internal static IReadOnlyList AdminAppTabIds { get; } = - [ - InvitationsSettingsTab.TabId, InboxSettingsTab.TabId, UpdatePolicySettingsTab.TabId, - PublishedSettingsTab.TabId, PrivacySettingsTab.TabId, ControlLaneSettingsTab.TabId, - ServiceIdentitiesSettingsTab.TabId, - ]; - - private static MeshNode Seed(string id, string name, UiContribution content) - => new(id, "Admin/UiContribution") - { - NodeType = UiContributionNodeType.NodeType, - Name = name, - Content = content, - }; - - private static StackControl PaneStack() => Controls.Stack.WithWidth("100%"); -} diff --git a/memex/Memex.Portal.Shared/Settings/PrivacySettingsTab.cs b/memex/Memex.Portal.Shared/Settings/PrivacySettingsTab.cs deleted file mode 100644 index 58e341e3e2..0000000000 --- a/memex/Memex.Portal.Shared/Settings/PrivacySettingsTab.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Application.Styles; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Settings; - -/// -/// Admin settings tab for the public privacy statement (Admin/Privacy, served at -/// /privacy). Gated to platform admins (). -/// Edits the statement through the STANDARD node-bound markdown editor -/// ( — the same path the Markdown node's own -/// editor uses; the node's content IS a MarkdownContent, so the whole-content auto-save is -/// the correct shape here). The node is created on first open, prefilled with the generic EU/CH -/// default statement (). -/// -public static class PrivacySettingsTab -{ - public const string TabId = "Privacy"; - - // The menu entry is a seeded UiContribution node with Gates.AdminOnly - // (PlatformSettingsTabAreas); the SettingsPrivacy layout area re-asserts the admin gate. - - internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack) - { - stack = stack.WithView(Controls.H2(host.Localize("auth.privacyStatement")).WithStyle("margin: 0 0 8px 0;")); - stack = stack.WithView(Controls.Markdown( - "This statement is shown publicly at [/privacy](/privacy) — no login required — and is " + - "what external app registrations (e.g. LinkedIn) link as the privacy policy URL. It " + - "starts from a generic statement drafted for EU (GDPR) and Swiss (revFADP) law; edit it " + - "below to match your deployment. Changes are saved automatically.")); - - // Editor bound DIRECTLY to Admin/Privacy (the reference shape, MarkdownEditLayoutArea): its - // Value is a POINTER into the node's MarkdownContent, resolved and kept live on the GUI side - // through IMeshNodeStreamCache — never the statement read once on this hub and baked in. - // The one thing the slot waits for is EnsureExists (create-on-absent as System, prefilled - // with the default statement): a write PRECONDITION, so the editor never binds to a node - // that is not there yet (a point read of an absent node trips the storm breaker). It reads - // no value of the node. - stack = stack.WithView((h, _) => PrivacyStatementNode - .EnsureExists(h.Hub, h.Hub.ServiceProvider.GetService()) - .Select(_ => (UiControl?)BuildEditor(h.Hub.Address.ToString(), h.ViewerLocale())) - .Catch(ex => - Observable.Return((UiControl?)Controls.Markdown(host.Localize("privacy.loadFailed", ex.Message)))) - .StartWith((UiControl?)Controls.Markdown(host.Localize("ui.mdLoadingPrivacy")))); - - return stack; - } - - /// - /// The privacy-statement editor, bound by pointer to Admin/Privacy's markdown and - /// auto-saving through the node stream. Reads nothing. - /// - internal static MarkdownEditorControl BuildEditor(string hubAddress, string? locale) - => new MarkdownEditorControl - { - Value = new JsonPointerReference(MarkdownEditLayoutArea.MarkdownBodyPointer), - DataContext = LayoutAreaReference.GetMeshNodeDataContext(PrivacyStatementNode.NodePath), - } - .WithDocumentId(PrivacyStatementNode.NodePath) - .WithHeight("calc(100vh - 320px)") - .WithMaxHeight("none") - .WithPlaceholder(LocalizationCatalog.Get("privacy.placeholder", locale)) - .WithAutoSave(hubAddress, PrivacyStatementNode.NodePath); -} diff --git a/memex/Memex.Portal.Shared/Settings/PrivacyStatementNode.cs b/memex/Memex.Portal.Shared/Settings/PrivacyStatementNode.cs deleted file mode 100644 index 72d17a3ba7..0000000000 --- a/memex/Memex.Portal.Shared/Settings/PrivacyStatementNode.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Settings; - -/// -/// The Admin/Privacy node — the portal's privacy statement, served publicly at -/// /privacy (anonymous — a privacy statement must be readable BEFORE login; external -/// consoles like the LinkedIn developer portal link it as the app's privacy policy URL). -/// A plain Markdown node (registered type — no new type wiring); admins edit it through -/// the standard node-bound markdown editor in the Admin settings (). -/// While the node does not exist, readers fall back to — a generic -/// statement drafted for EU (GDPR) and Swiss (revFADP) law. -/// -public static class PrivacyStatementNode -{ - /// The Admin partition that holds platform-level data (schema admin). - public const string AdminPartition = ShippedReleaseSeed.AdminPartition; - - /// The singleton node id. - public const string NodeId = "Privacy"; - - /// Full path of the privacy-statement node: Admin/Privacy. - public const string NodePath = $"{AdminPartition}/{NodeId}"; - - /// - /// The generic default statement (EU GDPR + Swiss revFADP), rendered whenever the - /// Admin/Privacy node has not been created/customized yet, and used as the initial - /// content when the settings tab creates the node. - /// - public const string DefaultStatement = """ - # Privacy Statement - - This statement explains how this portal (the "Service") processes personal data. It is - written to satisfy the EU General Data Protection Regulation (GDPR) and the Swiss Federal - Act on Data Protection (revFADP). - - ## 1. Controller - - The operator of this portal is the controller for the processing described here. To - exercise any of your rights or ask questions about this statement, contact the portal - operator or your administrator. - - ## 2. What data we process - - - **Account data** — your name and e-mail address, received from the sign-in provider you - use (e.g. Microsoft, Google or LinkedIn) when you log in. - - **Content data** — the content you create, upload or edit in the Service, including - documents, messages, comments and files. - - **Usage and technical data** — log data such as IP address, browser type, timestamps and - error diagnostics, used to operate, secure and troubleshoot the Service. - - ## 3. Purposes and legal bases - - We process personal data to: - - - provide and operate the Service — performance of a contract (Art. 6(1)(b) GDPR); - - secure the Service, prevent abuse and diagnose faults — legitimate interests - (Art. 6(1)(f) GDPR); - - comply with legal obligations (Art. 6(1)(c) GDPR). - - Under the Swiss revFADP, the same processing rests on the corresponding statutory - justification grounds. - - ## 4. Cookies - - The Service sets strictly necessary cookies only: an authentication session cookie and the - technical cookies required to maintain the connection. No advertising or cross-site - tracking cookies are used. - - ## 5. Recipients and processors - - - **Identity providers** — when you sign in through an external provider (e.g. Microsoft, - Google, LinkedIn), that provider processes your login under its own privacy policy. - - **Hosting** — the Service is hosted on Microsoft Azure in data centres located in the - EU/EEA or Switzerland. - - **AI features** — where the Service offers AI-assisted features, content you submit to - those features may be processed by the configured model provider for the sole purpose of - producing the requested output. - - We do not sell personal data. - - ## 6. International transfers - - Where personal data is transferred outside the EU/EEA or Switzerland, we rely on adequacy - decisions or standard contractual clauses (and their Swiss-recognised equivalents). - - ## 7. Retention - - Personal data is kept only as long as needed for the purposes above: account and content - data for the lifetime of your account or the underlying contractual relationship, log data - for a short rolling window, and backup copies for the duration of the backup cycle. Data is - deleted or anonymised thereafter. - - ## 8. Your rights - - You have the right to access, rectify, erase and receive a copy of your personal data, to - restrict or object to its processing, and to data portability. You may lodge a complaint - with a supervisory authority — in the EU, your national data protection authority; in - Switzerland, the Federal Data Protection and Information Commissioner (FDPIC). - - ## 9. Security - - We apply appropriate technical and organisational measures, including encryption in - transit, access control and audit logging. - - ## 10. Changes - - This statement may be updated from time to time; the current version is always available - at this address. - """; - - /// - /// Create-on-absent (idempotent, reactive, as System) of Admin/Privacy prefilled with - /// . Existence is read via GetQuery (empty-on-absent) — - /// NEVER a point GetMeshNodeStream(path) probe of the maybe-absent node (which - /// NotFound-resubscribe-storms on a fresh DB). An existing node is left untouched (the - /// admin-edited statement is preserved). Emits the node path when it exists. - /// - public static IObservable EnsureExists( - IMessageHub hub, AccessService? accessService, ILogger? logger = null) - { - var meshService = hub.ServiceProvider.GetService(); - if (meshService is null) - return Observable.Return(NodePath); - var workspace = hub.GetWorkspace(); - - MeshNode BuildNode() => new(NodeId, AdminPartition) - { - NodeType = "Markdown", - Name = "Privacy Statement", - State = MeshNodeState.Active, - Content = new MarkdownContent { Content = DefaultStatement }, - }; - - // 🚨 RunAsSystem, never `Observable.Using(AccessContextScope.AsSystem, …)` (#1444/#1790): - // `AsSystem(x)` IS `x.ImpersonateAsSystem()`. `Using` opens the AsyncLocal on the - // SUBSCRIBING thread — here the settings tab's render/circuit — and disposes it wherever the - // create terminates, leaving that thread running as `system-security`. RunAsSystem seals - // both ends inside one Subscribe; the read-then-create below is unchanged. - return accessService.RunAsSystem( - () => workspace - .GetQuery($"{NodeId}|{NodePath}", $"path:{NodePath} nodeType:Markdown") - .Take(1) - // A never-emitting query (backend routing/subscription failure) must not hang the - // create — trip to OnError so the caller (settings tab) degrades gracefully. - .Timeout(TimeSpan.FromSeconds(10)) - .SelectMany(nodes => - { - if (nodes.Any()) - return Observable.Return(NodePath); - logger?.LogInformation("[Privacy] creating {Path} with the default statement.", NodePath); - return meshService.CreateNode(BuildNode()) - .Select(_ => NodePath) - // Idempotent: a concurrent first-writer (other replica) won the create race. - .Catch(ex => IsAlreadyExists(ex) - ? Observable.Return(NodePath) - : Observable.Throw(ex)); - })); - } - - /// - /// The statement markdown for the PUBLIC /privacy page: the Admin/Privacy node's - /// content when the node exists, else . Reads as System — the - /// page is anonymous by design and the statement is public, while the Admin partition is not - /// readable by regular (let alone anonymous) principals. Absence is detected via - /// GetQuery (empty-on-absent, storm-safe); the content itself comes off the live node - /// stream (query rows carry stale Content by design). Any failure degrades to the default - /// statement — the page never hangs and never errors. - /// - public static IObservable GetStatement(IMessageHub hub) - { - var accessService = hub.ServiceProvider.GetService(); - var workspace = hub.GetWorkspace(); - // 🚨 RunAsSystem, never `Observable.Using(AccessContextScope.AsSystem, …)` (#1444/#1790). - // This is the sharpest instance of the defect in the tree: the subscriber is the ANONYMOUS - // /privacy request. `Using` would leave `system-security` latched on that ASP.NET request - // thread after the read — an unauthenticated flow continuing with Permission.All, with no - // audit trail. RunAsSystem opens and closes the scope inside the one Subscribe, so the - // System read still happens and the request thread is handed back exactly what it had. - return accessService.RunAsSystem( - () => workspace - .GetQuery($"{NodeId}|{NodePath}", $"path:{NodePath} nodeType:Markdown") - .Take(1) - // A never-emitting query must not hang the anonymous page — trip to OnError so - // the trailing Catch degrades to the default statement (honours "never hangs"). - .Timeout(TimeSpan.FromSeconds(10)) - .SelectMany(nodes => nodes.Any() - ? workspace.GetMeshNodeStream(NodePath) - .Where(node => node is not null) - .Take(1) - .Timeout(TimeSpan.FromSeconds(10)) - .Select(node => ParseStatement(node!.Content, hub.JsonSerializerOptions)) - : Observable.Return(DefaultStatement))) - .Catch(_ => Observable.Return(DefaultStatement)); - } - - /// Extracts the markdown off the node's Content, robust to the typed - /// , a degraded , or a raw string; - /// falls back to when empty/unparseable. - public static string ParseStatement(object? content, JsonSerializerOptions options) - { - var markdown = content switch - { - MarkdownContent c => c.Content, - string s => s, - JsonElement je => TryDeserialize(je, options)?.Content, - _ => null, - }; - return string.IsNullOrWhiteSpace(markdown) ? DefaultStatement : markdown; - } - - private static MarkdownContent? TryDeserialize(JsonElement je, JsonSerializerOptions options) - { - try { return JsonSerializer.Deserialize(je.GetRawText(), options); } - catch { return null; } - } - - /// True if the exception (or any inner) reports an "already exists" outcome — the - /// idempotent-create success signal. - private static bool IsAlreadyExists(Exception ex) - { - for (var e = ex; e is not null; e = e.InnerException) - if (e.Message?.Contains("already exists", StringComparison.OrdinalIgnoreCase) == true) - return true; - return false; - } -} diff --git a/memex/Memex.Portal.Shared/Settings/PublishedSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/PublishedSettingsTab.cs deleted file mode 100644 index 0f7d7c30a6..0000000000 --- a/memex/Memex.Portal.Shared/Settings/PublishedSettingsTab.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Application.Styles; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Layout.DataGrid; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.Utils; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Seo; - -namespace Memex.Portal.Shared.Settings; - -/// -/// 🚨 WHAT IS ON THE INTERNET — the published surface, in a list you can actually look at. -/// -/// "Published to the web" has always been a real, enforced state: an explicit Anonymous -/// Read grant, which AnonymousGate fails closed on and which decides whether a page -/// renders for a logged-out visitor, whether it appears in /sitemap.xml, and whether it gets -/// SEO metadata and a share card. What it was NOT is visible — nothing in the product told -/// you which nodes were public, so the honest answer to "what does the world see?" was to fetch the -/// sitemap and read XML. -/// -/// This tab is that answer, rendered. It reads -/// — the SAME enumeration the sitemap renders — so the -/// list a human reads and the list crawlers get cannot drift apart. -/// -/// Publishing moves nothing. A page's public URL is its ordinary mesh path; there is no -/// Www/ namespace and deliberately so — relocating public content would rewrite every shared -/// link, every canonical and every og:url, which is the opposite of publishing well. -/// -/// Deliberately READ-ONLY. Granting or revoking public access is an access decision and lives -/// on the node's Access tab; offering an edit control here would put the truth in two places. -/// -public static class PublishedSettingsTab -{ - /// The tab's stable id. - public const string TabId = "PublishedToTheWeb"; - - // The menu entry is a seeded UiContribution node with Gates.AdminOnly - // (PlatformSettingsTabAreas) — the published surface is not secret, but "what is public" is - // an administration question; the SettingsPublished layout area re-asserts the admin gate. - - /// One published page, as a plain row the grid binds to. - public sealed record PublishedRow - { - /// The page's display name. - public string Page { get; init; } = ""; - /// The public URL path — the node's ordinary path; publishing moves nothing. - public string Url { get; init; } = ""; - /// The node type, so a Space reads differently from a store plugin. - public string Type { get; init; } = ""; - /// The authored share image, or "generated card" when the portal draws one. - public string ShareImage { get; init; } = ""; - /// When the page last changed — a stale public page is worth seeing. - public DateTime Changed { get; init; } - } - - internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack) - => stack - .WithView(Controls.Markdown(host.Localize("ui.mdPublishedIntro"))) - .WithView((h, _) => LivePublishedList(h), "PublishedList"); - - /// - /// The published surface, resolved once per render. Cold and bounded by the enumeration's own - /// timeout; fails to an explanatory line rather than an empty tab. - /// - /// 🚨 An empty grid is an ASSERTION here — "nothing on this deployment is public" — so it - /// is shown only when the enumeration actually established that. The same rule the sitemap - /// route applies for the same reason (#4751), and the reason - /// hands out - /// instead of a bare list. - /// - private static IObservable LivePublishedList(LayoutAreaHost host) => - SeoEndpoints.EnumeratePublished(host.Hub) - .Select(surface => (UiControl?)(surface.AssertsWhatItDidNotCheck - ? Controls.Markdown(host.Localize("published.surfaceUnreadable", surface.Undecided!)) - : BuildGrid(host, surface.Pages))) - .Catch(ex => Observable.Return( - Controls.Markdown(host.Localize("published.surfaceUnreadable", ex.Message)))) - .StartWith(Controls.Markdown(host.Localize("published.reading"))); - - /// The published surface as grid rows, ordered by URL so it reads as a site map. - public static PublishedRow[] RowsFor(IReadOnlyList pages) => - pages - .OrderBy(p => p.Path, StringComparer.OrdinalIgnoreCase) - .Select(RowFor) - .ToArray(); - - /// One published page as a row. Pure, so the columns are testable without a mesh. - public static PublishedRow RowFor(PublishedPage page) => new() - { - Page = page.Node.Name ?? page.Node.Id, - // The node's ordinary path IS its public URL — publishing never relocates anything. - Url = "/" + page.Path, - Type = page.Node.NodeType ?? "", - // The same precedence the crawler-facing head uses, so this column cannot claim one thing - // while the og:image meta tag says another. - ShareImage = SeoResolver.ExtractImage(page.Node) ?? "generated card", - Changed = page.Node.LastModified.UtcDateTime, - }; - - private static UiControl BuildGrid(LayoutAreaHost host, IReadOnlyList pages) - { - if (pages.Count == 0) - return Controls.Markdown(host.Localize("ui.mdPublishedNone")); - - var rows = RowsFor(pages); - - return Controls.Stack - .WithView(Controls.Markdown( - $"**{rows.Length} page{(rows.Length == 1 ? "" : "s")}** published.")) - .WithView(Controls.DataGrid(rows) - .WithColumn(new PropertyColumnControl - { Property = nameof(PublishedRow.Page).ToCamelCase() }.WithTitle("Page")) - .WithColumn(new PropertyColumnControl - { Property = nameof(PublishedRow.Url).ToCamelCase() }.WithTitle("Public URL")) - .WithColumn(new PropertyColumnControl - { Property = nameof(PublishedRow.Type).ToCamelCase() }.WithTitle("Type")) - .WithColumn(new PropertyColumnControl - { Property = nameof(PublishedRow.ShareImage).ToCamelCase() }.WithTitle("Share image")) - .WithColumn(new PropertyColumnControl - { Property = nameof(PublishedRow.Changed).ToCamelCase() }.WithTitle("Last changed"))); - } -} diff --git a/memex/Memex.Portal.Shared/Settings/ServiceIdentitiesSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/ServiceIdentitiesSettingsTab.cs deleted file mode 100644 index 644b05a7d5..0000000000 --- a/memex/Memex.Portal.Shared/Settings/ServiceIdentitiesSettingsTab.cs +++ /dev/null @@ -1,371 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Application.Styles; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Layout.DataGrid; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.Utils; -using Memex.Portal.Shared.Authentication; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Settings; - -/// -/// The Admin app's Service identities tab (People & sign-in): the non-person principals -/// this instance lets authenticate () — create one, grant it access, -/// issue / rotate / revoke its mw_ tokens, revoke the identity. Platform admins only: the -/// entry is gated to the Admin app and AdminOnly, the area re-asserts the gate, and every write -/// lands in the Admin partition, which only a global admin can write. -/// -/// Built from framework controls only — Controls.*, a over -/// plain rows for the identity list — and every visible string is a catalog key. A token is shown -/// ONCE, in a fenced block, and only its hash is stored. See Doc/Architecture/ServiceIdentities. -/// -public static class ServiceIdentitiesSettingsTab -{ - /// The tab id — also the id of its seeded menu entry. - public const string TabId = "ServiceIdentities"; - - private const string CreateDataId = "serviceIdentityCreate"; - private const string ActDataId = "serviceIdentityAct"; - private const string ResultDataId = "serviceIdentityResult"; - private const string ListDataId = "serviceIdentityList"; - private const string TokensDataId = "serviceIdentityTokens"; - - /// The query every emission of the identity list is read from. - internal const string IdentitiesQuery = - $"namespace:{ServiceIdentity.Namespace} nodeType:{ServiceIdentity.NodeType}"; - - /// Builds the tab's content into . - /// The layout host. - /// The pane's stack. - /// The content. - internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack) - { - var tokens = host.Hub.ServiceProvider.GetRequiredService(); - - host.UpdateData(CreateDataId, new Dictionary { ["name"] = "", ["description"] = "" }); - host.UpdateData(ActDataId, new Dictionary - { - ["service"] = "", - ["label"] = "", - ["expiryDays"] = 365, - ["scope"] = "", - ["role"] = "Editor", - }); - - stack = stack - .WithView(Controls.H2(host.Localize("serviceIdentities.title"))) - .WithView(Controls.Markdown(host.Localize("serviceIdentities.intro"))); - - stack = stack.WithView(Section(host, "serviceIdentities.createHeading", Row() - .WithView(Text(host, CreateDataId, "name", "serviceIdentities.field.name", "240px")) - .WithView(Text(host, CreateDataId, "description", "serviceIdentities.field.description", "360px")) - .WithView(Controls.Button(host.Localize("serviceIdentities.create")) - .WithAppearance(Appearance.Accent) - .WithClickAction(ctx => - { - ctx.Host.Stream.GetDataStream>(CreateDataId).Take(1) - .Subscribe(data => ServiceIdentities - .Create(host.Hub, Field(data, "name"), Field(data, "description")) - .Subscribe( - created => ctx.Host.UpdateData(ResultDataId, - $"{host.Localize("serviceIdentities.created")} **{created.Id}**"), - ex => ctx.Host.UpdateData(ResultDataId, - $"{host.Localize("serviceIdentities.error")} {ex.Message}"))); - return Task.CompletedTask; - })))); - - stack = stack.WithView(Section(host, "serviceIdentities.actHeading", Controls.Stack.WithWidth("100%") - .WithView(Row() - .WithView(Text(host, ActDataId, "service", "serviceIdentities.field.service", "240px")) - .WithView(Text(host, ActDataId, "label", "serviceIdentities.field.label", "240px")) - .WithView(new NumberFieldControl(new JsonPointerReference("expiryDays"), "Int32") - { - Label = host.Localize("serviceIdentities.field.expiryDays"), - DataContext = LayoutAreaReference.GetDataPointer(ActDataId), - }.WithWidth("140px")) - .WithView(ActionButton(host, "serviceIdentities.issueToken", Appearance.Accent, (ctx, data) => - { - var days = int.TryParse(Field(data, "expiryDays"), out var d) ? d : 0; - var label = Field(data, "label"); - if (label.Length == 0) - { - ctx.Host.UpdateData(ResultDataId, host.Localize("serviceIdentities.needLabel")); - return; - } - tokens.CreateServiceToken(Field(data, "service"), label, - days > 0 ? DateTimeOffset.UtcNow.AddDays(days) : null) - .Subscribe( - result => ctx.Host.UpdateData(ResultDataId, TokenShownOnce(host, result)), - ex => ctx.Host.UpdateData(ResultDataId, - $"{host.Localize("serviceIdentities.error")} {ex.Message}")); - }))) - .WithView(Row() - .WithView(Text(host, ActDataId, "scope", "serviceIdentities.field.scope", "320px")) - .WithView(Text(host, ActDataId, "role", "serviceIdentities.field.role", "160px")) - .WithView(ActionButton(host, "serviceIdentities.grant", Appearance.Outline, (ctx, data) => - ServiceIdentities.Grant(host.Hub, Field(data, "service"), Field(data, "scope"), Field(data, "role")) - .Subscribe( - granted => ctx.Host.UpdateData(ResultDataId, - $"{host.Localize("serviceIdentities.granted")} `{granted.Path}`"), - ex => ctx.Host.UpdateData(ResultDataId, - $"{host.Localize("serviceIdentities.error")} {ex.Message}")))) - .WithView(ActionButton(host, "serviceIdentities.revokeIdentity", Appearance.Outline, (ctx, data) => - ServiceIdentities.Revoke(host.Hub, Field(data, "service")) - .Subscribe( - revoked => ctx.Host.UpdateData(ResultDataId, - $"{host.Localize("serviceIdentities.revokedIdentity")} **{revoked.Id}**"), - ex => ctx.Host.UpdateData(ResultDataId, - $"{host.Localize("serviceIdentities.error")} {ex.Message}"))))))); - - // The result line — the one place a raw token is ever shown. - stack = stack.WithView((h, _) => h.Stream.GetDataStream(ResultDataId) - .Select(markdown => string.IsNullOrEmpty(markdown) - ? (UiControl?)Controls.Stack.WithWidth("100%") - : Controls.Stack.WithWidth("100%").WithView(Controls.Markdown(markdown))) - .StartWith((UiControl?)Controls.Stack.WithWidth("100%"))); - - // The live lists — TEMPLATES (Doc/GUI/DataBinding → "Templates first, data later"): one grid - // of every identity, one grid of every identity's tokens, each declared at once and fed by a - // synced query (a create, a revoke or a new token re-emits it — no refresh trigger). Each token - // row carries its own Rotate and Revoke buttons, ROW-SCOPED actions ("Row-scoped actions"): - // the click says which token it is. - var failed = (Func)(message => $"{host.Localize("serviceIdentities.error")} {message}"); - stack = stack - .WithView(Controls.H3(host.Localize("serviceIdentities.listHeading"))) - .WithView(IdentityRowsFeed(host) - .BindGrid(ListDataId, host.Localize("serviceIdentities.none"), failed) - .WithColumn(new PropertyColumnControl { Property = nameof(IdentityRow.ObjectId).ToCamelCase() } - .WithTitle(host.Localize("serviceIdentities.column.id"))) - .WithColumn(new PropertyColumnControl { Property = nameof(IdentityRow.Name).ToCamelCase() } - .WithTitle(host.Localize("serviceIdentities.column.name"))) - .WithColumn(new PropertyColumnControl { Property = nameof(IdentityRow.Status).ToCamelCase() } - .WithTitle(host.Localize("serviceIdentities.column.status"))) - .WithColumn(new PropertyColumnControl { Property = nameof(IdentityRow.IssuedBy).ToCamelCase() } - .WithTitle(host.Localize("serviceIdentities.column.issuedBy")))) - .WithView(Controls.H3(host.Localize("serviceIdentities.tokensHeading"))) - .WithView(TokenGrid(host, TokenRowsFeed(host, tokens), - rotate: (ctx, token) => Rotate(ctx, host, tokens, token), - revoke: (ctx, token) => Revoke(ctx, host, tokens, token))); - - return stack; - } - - /// The column of that holds each row's Rotate button. - internal const int RotateColumn = 7; - - /// The column of that holds each row's Revoke button. - internal const int RevokeColumn = 8; - - /// - /// The token grid over — the template half, separate from the feed so its - /// row-scoped actions are testable. The Rotate and Revoke columns each hold ONE button that hands - /// the row it was clicked in to / ; a click that - /// carries no row does nothing. - /// - internal static DataGridControl TokenGrid( - LayoutAreaHost host, IObservable> rows, - Action rotate, Action revoke) - => rows - .BindGrid(TokensDataId, host.Localize("serviceIdentities.noTokens"), - message => $"{host.Localize("serviceIdentities.error")} {message}") - // Each row is its own click: rotating or revoking one token never holds up — or is refused - // behind — the same action on the next (Doc/GUI/ButtonPendingState → "Idempotence"). - .WithRowKey("nodePath") - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.ServiceId).ToCamelCase() } - .WithTitle(host.Localize("serviceIdentities.column.id"))) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.Label).ToCamelCase() } - .WithTitle(host.Localize("serviceIdentities.field.label"))) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.HashPrefix).ToCamelCase() } - .WithTitle(host.Localize("serviceIdentities.column.token"))) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.Status).ToCamelCase() } - .WithTitle(host.Localize("serviceIdentities.column.status"))) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.Created).ToCamelCase() } - .WithTitle(host.Localize("apiTokens.created"))) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.Expires).ToCamelCase() } - .WithTitle(host.Localize("apiTokens.expires"))) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.LastUsed).ToCamelCase() } - .WithTitle(host.Localize("apiTokens.lastUsed"))) - .WithColumn(RowButtonColumn(host.Localize("serviceIdentities.rotate"), rotate)) - .WithColumn(RowButtonColumn(host.Localize("ui.revoke"), revoke)); - - /// A template column holding ONE button whose click hands its row to . - private static TemplateColumnControl RowButtonColumn(string label, Action act) - => new TemplateColumnControl(Controls.Button(label) - .WithAppearance(Appearance.Outline) - .WithClickAction(ctx => - { - if (ctx.RowAs() is { } token) - act(ctx, token); - return Task.CompletedTask; - })) - .WithTitle(label); - - /// Rotates the clicked row's token, or says that only a live token can be rotated. - /// The row only says WHICH token: service id, node path, term and revoked flag come from the - /// token feed as it is NOW (). - private static void Rotate(UiActionContext ctx, LayoutAreaHost host, ApiTokenService tokens, TokenRow clicked) - => WithLiveToken(ctx, host, tokens, clicked, token => - { - // Same term length as the token it replaces, counted from now. - DateTimeOffset? expiresAt = token.ExpiresAt is { } old - ? DateTimeOffset.UtcNow + (old - token.CreatedAt) - : null; - ServiceIdentities.Rotate(tokens, token.ServiceId, token.NodePath, token.Label, expiresAt) - .Subscribe( - result => ctx.Host.UpdateData(ResultDataId, TokenShownOnce(host, result)), - ex => ctx.Host.UpdateData(ResultDataId, - $"{host.Localize("serviceIdentities.error")} {ex.Message}")); - }); - - /// Revokes the clicked row's token, or says that only a live token can be revoked. - /// The token is the feed's row as it is NOW (). - private static void Revoke(UiActionContext ctx, LayoutAreaHost host, ApiTokenService tokens, TokenRow clicked) - => WithLiveToken(ctx, host, tokens, clicked, token => - tokens.RevokeToken(token.NodePath).Subscribe( - ok => ctx.Host.UpdateData(ResultDataId, ok - ? $"{host.Localize("apiTokens.revoked")} **{token.Label}**" - : host.Localize("apiTokens.revokeFailed")), - ex => ctx.Host.UpdateData(ResultDataId, - $"{host.Localize("apiTokens.revokeFailed")} {ex.Message}"))); - - /// Hands the token feed's CURRENT row for the clicked token to ; - /// a token the feed does not list, or one revoked since the render, is refused on the result - /// line. - private static void WithLiveToken( - UiActionContext ctx, LayoutAreaHost host, ApiTokenService tokens, TokenRow clicked, Action act) - => CurrentToken(host, tokens, clicked.NodePath).Subscribe( - token => - { - if (token is null || token.IsRevoked) - ctx.Host.UpdateData(ResultDataId, host.Localize("serviceIdentities.tokenRevoked")); - else - act(token); - }, - ex => ctx.Host.UpdateData(ResultDataId, $"{host.Localize("serviceIdentities.error")} {ex.Message}")); - - /// The token feed's row, as it is NOW, whose node path is - /// — or null when no identity lists such a token. - internal static IObservable CurrentToken(LayoutAreaHost host, ApiTokenService tokens, string? nodePath) - => string.IsNullOrEmpty(nodePath) - ? Observable.Return(null) - : TokenRowsFeed(host, tokens).Take(1) - .Select(rows => rows.FirstOrDefault(r => string.Equals(r.NodePath, nodePath, StringComparison.Ordinal))); - - /// The feed half of the identity grid: the identity rows of every emission of the - /// synced identity query. Builds no control. - internal static IObservable> IdentityRowsFeed(LayoutAreaHost host) - => host.Hub.GetWorkspace() - .GetQuery("service-identities", IdentitiesQuery) - .Select(nodes => Rows(host, nodes)); - - /// - /// The feed half of the token grid: every token of every identity, newest first per identity, - /// re-emitted when an identity or any of its tokens changes. Display values are resolved for the - /// viewer (their time zone, their language) when the feed is built. Builds no control. - /// - internal static IObservable> TokenRowsFeed(LayoutAreaHost host, ApiTokenService tokens) - { - var zoneId = host.Hub.ServiceProvider.GetService().ViewerZoneId(); - var never = host.Localize("apiTokens.never"); - var revoked = host.Localize("apiTokens.status.revoked"); - var expired = host.Localize("apiTokens.status.expired"); - var active = host.Localize("apiTokens.status.active"); - - TokenRow ToRow(IdentityRow identity, ApiTokenInfo token) - { - var isExpired = token.ExpiresAt is { } end && end < DateTimeOffset.UtcNow; - return new TokenRow( - identity.ObjectId, - token.Label, - token.HashPrefix, - token.IsRevoked ? revoked : isExpired ? expired : active, - DisplayTimeExtensions.ToDisplayTime(token.CreatedAt, zoneId).ToString("yyyy-MM-dd"), - token.ExpiresAt is { } exp ? DisplayTimeExtensions.ToDisplayTime(exp, zoneId).ToString("yyyy-MM-dd") : never, - token.LastUsedAt is { } used ? DisplayTimeExtensions.ToDisplayTime(used, zoneId).ToString("yyyy-MM-dd HH:mm") : never, - token.NodePath, - token.IsRevoked, - token.CreatedAt, - token.ExpiresAt); - } - - return IdentityRowsFeed(host) - .Select(identities => identities.Count == 0 - ? Observable.Return>([]) - : identities - .Select(identity => tokens.GetTokensForService(identity.ObjectId) - .Select(list => list.Select(token => ToRow(identity, token)))) - .CombineLatest(lists => (IReadOnlyList)lists.SelectMany(l => l).ToList())) - .Switch(); - } - - /// The identity rows, ordered by object id. Pure over the query snapshot. - internal static IReadOnlyList Rows(LayoutAreaHost host, IEnumerable nodes) - => nodes - .Where(n => n.Path is not null) - .Select(n => - { - var identity = n.ContentAs(host.Hub.JsonSerializerOptions); - return new IdentityRow( - n.Id, - n.Name ?? n.Id, - host.Localize(identity?.IsRevoked == true - ? "serviceIdentities.status.revoked" - : "serviceIdentities.status.active"), - identity?.IssuedBy ?? ""); - }) - .OrderBy(r => r.ObjectId, StringComparer.Ordinal) - .ToList(); - - /// One row of the identity grid — a plain record so the grid binds it directly. - internal record IdentityRow(string ObjectId, string Name, string Status, string IssuedBy); - - /// - /// One row of the token grid — every display column as text for the viewer, plus what Rotate and - /// Revoke need (, the term). - /// - internal record TokenRow( - string ServiceId, string Label, string HashPrefix, string Status, - string Created, string Expires, string LastUsed, - string NodePath, bool IsRevoked, DateTimeOffset CreatedAt, DateTimeOffset? ExpiresAt); - - private static string TokenShownOnce(LayoutAreaHost host, TokenCreationResult result) - => $"**{host.Localize("apiTokens.copyNow")}**\n\n```\n{result.RawToken}\n```"; - - private static StackControl Row() => Controls.Stack - .WithOrientation(Orientation.Horizontal) - .WithStyle("gap: 12px; align-items: flex-end; flex-wrap: wrap;"); - - private static UiControl Section(LayoutAreaHost host, string headingKey, UiControl body) - => Controls.Stack.WithWidth("100%") - .WithStyle("padding: 16px; background: var(--neutral-layer-2); border-radius: 8px; gap: 12px; margin-bottom: 24px;") - .WithView(Controls.H3(host.Localize(headingKey))) - .WithView(body); - - private static UiControl Text(LayoutAreaHost host, string dataId, string field, string labelKey, string width) - => new TextFieldControl(new JsonPointerReference(field)) - { - Label = host.Localize(labelKey), - DataContext = LayoutAreaReference.GetDataPointer(dataId), - }.WithWidth(width); - - /// A button that reads the action form once and hands it to . - private static UiControl ActionButton(LayoutAreaHost host, string labelKey, string appearance, - Action?> act) - => Controls.Button(host.Localize(labelKey)) - .WithAppearance(appearance) - .WithClickAction(ctx => - { - ctx.Host.Stream.GetDataStream>(ActDataId).Take(1) - .Subscribe(data => act(ctx, data)); - return Task.CompletedTask; - }); - - private static string Field(Dictionary? data, string key) - => data?.GetValueOrDefault(key)?.ToString()?.Trim() ?? ""; -} diff --git a/memex/Memex.Portal.Shared/Settings/StepUpSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/StepUpSettingsTab.cs deleted file mode 100644 index 5fb06672c6..0000000000 --- a/memex/Memex.Portal.Shared/Settings/StepUpSettingsTab.cs +++ /dev/null @@ -1,58 +0,0 @@ -using MeshWeaver.Application.Styles; -using MeshWeaver.Domain; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Memex.Portal.Shared.Authentication; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Settings; - -/// -/// The person app's Security tab — how this person confirms approvals -/// (Doc/Architecture/ApprovalStepUp). Framework controls only: a title, the explanation, and -/// one button that opens the enrolment page. The enrolment itself runs on that page, because the -/// WebAuthn ceremony must run in the page that asks for it (a full page load, not a circuit hop). -/// -public static class StepUpSettingsTab -{ - /// The tab id. - public const string TabId = "Security"; - - /// Registers the tab on the person app. - /// The hub configuration. - /// The configuration. - public static MessageHubConfiguration AddStepUpSettingsTab(this MessageHubConfiguration config) => - config.AddPersonAppTab( - new SettingsMenuItemDefinition( - Id: TabId, - Label: "Security", - ContentBuilder: BuildContent, - Icon: FluentIcons.ShieldLock(), - Order: PersonApp.SecurityOrder, - RequiredPermission: Permission.None) - { LabelKey = "settings.security" }); - - internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack, MeshNode? node) - { - var enabled = StepUpOptions.From(host.Hub.ServiceProvider.GetService()).Enabled; - stack = stack - .WithView(Controls.H2(host.Localize("settings.security"))) - .WithView(Controls.Markdown(host.Localize("stepUp.settings.intro"))) - .WithView(Controls.Markdown(host.Localize("stepUp.settings.entra"))); - if (!enabled) - stack = stack.WithView(Controls.Markdown(host.Localize("stepUp.settings.off"))); - return stack.WithView(Controls.Button(host.Localize("stepUp.enroll.manage")) - .WithAppearance(Appearance.Accent) - .WithClickAction(ctx => - { - // An MVC endpoint: a client-side navigation would never reach it. - ctx.NavigateTo(StepUpController.EnrollPath, forceLoad: true); - return Task.CompletedTask; - })); - } -} diff --git a/memex/Memex.Portal.Shared/Settings/UpdatePolicySettingsTab.cs b/memex/Memex.Portal.Shared/Settings/UpdatePolicySettingsTab.cs deleted file mode 100644 index ef017be22f..0000000000 --- a/memex/Memex.Portal.Shared/Settings/UpdatePolicySettingsTab.cs +++ /dev/null @@ -1,592 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.Extensions.Configuration; -using System.Globalization; -using System.Reactive.Linq; -using MeshWeaver.Application.Styles; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Memex.Portal.Shared.SelfUpdate; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using MeshWeaver.Hosting.SelfUpdate; - -namespace Memex.Portal.Shared.Settings; - -/// -/// Admin settings tab for the platform auto-update strategy (Admin/UpdatePolicy). Edits the -/// policy through the STANDARD node-content editor bound directly to the node stream (the -/// Policy enum renders as a dropdown — no /data replica, no save subscription), shows the -/// running version + the latest tag the poller has seen, and offers a manual "apply now" for -/// installs that can self-patch. -/// -/// Platform admins only: the menu entry is a seeded UiContribution node with -/// Gates.AdminOnly (), and the -/// SettingsUpdatePolicy layout area re-asserts the admin gate for direct URLs. -/// -public static class UpdatePolicySettingsTab -{ - public const string TabId = "UpdatePolicy"; - private const string ResultId = "updatePolicyResult"; - private const string StatusId = "updatePolicyStatus"; - - /// - /// The feed half of the live status line: the policy node's status, rendered for the viewer - /// (their language, their time zone), on every change of the node. Builds no control. - /// - internal static IObservable StatusFeed(LayoutAreaHost host) - { - var zoneId = host.Hub.ServiceProvider.GetService().ViewerZoneId(); - var locale = host.ViewerLocale(); - return host.Hub.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Select(node => StatusMarkdown( - UpdatePolicyNodeType.Parse(node, host.Hub.JsonSerializerOptions), - (key, args) => host.Localize(key, args), - zoneId, - locale)); - } - - internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack) - { - stack = stack.WithView(Controls.H2(host.Localize("ui.platformUpdates")).WithStyle("margin: 0 0 8px 0;")); - stack = stack.WithView(Controls.Markdown(host.Localize("ui.mdUpdatePolicyIntro"))); - - // 🚨 When the deployment record declares the policy, the self-updater converges the node to - // it at every start (policy `self-update-record-authoritative`), so a local edit below does - // not stick past the next restart. Say so rather than let the editor pretend otherwise. - if (UpdatePolicyNodeType.DeclaredByDeployment(host.Hub.ServiceProvider.GetService()) - is { } declared) - stack = stack.WithView(Controls.Markdown(host.Localize( - "ui.mdUpdatePolicyManagedByRecord", declared.Policy, declared.Pattern ?? "—"))); - - // Running version (the installed platform version baked into the binary). - stack = stack.WithView(Controls.Markdown( - $"**Running version:** `{ShippedReleaseSeed.InstalledPlatformVersion}`")); - - // Policy editor — bound DIRECTLY to Admin/UpdatePolicy. EnsureExists (create-on-absent, as - // System) before binding so the editor binds to an existing node. The seed is the SAME - // default the poller seeds (SelfUpdateOptions.DefaultPolicy — Stable), never a second one. - stack = stack.WithView((h, _) => - { - var options = h.Hub.ServiceProvider.GetService() ?? new SelfUpdateOptions(); - return UpdatePolicyNodeType - .EnsureExists(h.Hub, h.Hub.ServiceProvider.GetService(), options.DefaultPolicy, - defaultPattern: options.DefaultPattern) - .Select(_ => (UiControl?)MeshNodeContentEditorControl.ForType( - UpdatePolicyNodeType.NodePath, typeof(UpdatePolicyContent))) - .StartWith((UiControl?)Controls.Markdown(host.Localize("ui.mdLoadingUpdatePolicy"))); - }); - - // Live status: the latest available tag, when last checked, and — when the combo gate has - // verified that tag against THIS instance's module set (mw-combo-verify) — its verdict. A - // red verdict replaces the eternal "update available" with "cannot update to X" naming - // every failing module (Doc/Architecture/CandidateReleaseProtocol → "Report the verdict - // where an admin looks"). - // A TEMPLATE (Doc/GUI/DataBinding → "Templates first, data later"): the markdown is declared - // at once and fed by StatusFeed. - stack = stack.WithView(StatusFeed(host) - .BindMarkdown(StatusId, "", ex => host.Localize("ui.updateStatusUnavailable", ex.Message))); - - // Manual apply — by WHOEVER applies on this install (#4098): a control-lane install hands the - // latest tag to the control instance (the same event the poller sends, trigger "Manual"), - // a self-patching one patches via the Http pool, anything else says what is missing. - stack = stack.WithView(Controls.Button(host.Localize("ui.applyUpdateNow")) - .WithAppearance(Appearance.Accent) - .WithClickAction(ctx => - { - ApplyNow(ctx.Host); - return Task.CompletedTask; - })); - - // Result line. - stack = stack.WithView((h, _) => h.Stream.GetDataStream(ResultId) - .Select(msg => (UiControl?)(string.IsNullOrEmpty(msg) - ? Controls.Stack.WithWidth("100%") - : Controls.Markdown(msg))) - .StartWith((UiControl?)Controls.Stack.WithWidth("100%"))); - - return stack; - } - - /// - /// The "apply now" action — by WHOEVER applies on this install (#4098). Reads the policy node - /// once, at the click, for the tag to apply; renders nothing. - /// - private static void ApplyNow(LayoutAreaHost h) - { - var updater = h.Hub.ServiceProvider.GetService(); - var options = h.Hub.ServiceProvider.GetService() ?? new SelfUpdateOptions(); - var handover = new SelfUpdateHandover(h.Hub); - var settings = handover.ReadSettings(); - var apply = SelfUpdateHandover.ApplyModeFor( - options.CanPatch, updater?.CanPatch == true, SelfUpdateHandover.RouteFor(settings)); - if (apply == SelfUpdateApply.DetectOnly) - { - h.UpdateData(ResultId, h.Localize("ui.updateCannotSelfPatch", - SelfUpdateHandover.Missing(settings) ?? "no control inbox is configured")); - return; - } - var pool = h.Hub.ServiceProvider.GetService()?.Get(IoPoolNames.Http) - ?? IoPool.Unbounded; - h.Hub.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath).Take(1).Subscribe(node => - { - var content = UpdatePolicyNodeType.Parse(node, h.Hub.JsonSerializerOptions); - var tag = content.LatestAvailableTag; - if (string.IsNullOrEmpty(tag)) - { - h.UpdateData(ResultId, h.Localize("ui.updateNoneDetected")); - return; - } - // 🚨 The manual roll honours the SAME release-availability gate as the poller - // (#1754). A gate only the unattended path respects is not a gate — and this - // button is exactly the moment an operator, seeing an update that never - // applied, would force the roll the poller refused for good reason. - // - // 🚨 Under policy platform-rolls-independently-of-packages nothing the gate - // answers refuses this button — the - // platform rolls independently of its packages, so a refusal is turned into - // advisories below (ForPlatformRoll), exactly as the poller does. The paragraphs - // that follow are why the UNWIRED gate is still reported rather than read as a pass. - // - // 🚨 An UNWIRED gate is an unanswered package question (an ADVISORY under the - // policy above, a hold before it). It used to resolve to NotEnforced, - // which is `IsUpdatable: true` — so the one host where nothing checks anything - // was also the one host where this button never refused. NotEnforced is the - // single stated applicability exemption (a deployment that consumes no CI - // bakes); a missing registration is not an exemption, it is the absence of a - // verdict, and it must not render as a pass. - // - // 🚨 …and the hold is SCOPED to what is actually unverifiable. On a deployment - // that consumes no CI bakes a registered gate answers NotEnforced anyway, so - // its absence is the same answer reached from configuration — not an - // unanswered question. Holding there would freeze an install the gate was - // never going to protect. - var gate = h.Hub.ServiceProvider.GetService(); - var decision = gate is not null - ? gate.IsUpdatable(tag) - : Observable.Return( - ReleaseAvailabilityService.NotApplicableReason( - h.Hub.ServiceProvider.GetService()) is { } notApplicable - ? UpdatabilityVerdict.NotEnforced(notApplicable) - : UpdatabilityVerdict.Unavailable( - "no release-availability gate is registered on this install, so " - + "nothing could check whether the packages it deploys have " - + "usable artifacts for this release — package availability " - + "is unchecked, not confirmed")); - decision.Subscribe(raw => - { - // 🚨 The platform rolls independently of its packages (policy - // platform-rolls-independently-of-packages). - // The button decides exactly as the poller does: a package-availability - // refusal and a recorded combo Red are ADVISORIES, never a refusal of the - // roll — they are logged here and the poller records them on the policy - // node; the packages catch up through their own update policy. - var combo = h.Hub.ServiceProvider.GetService(); - var clearance = combo is null - ? ComboVerificationGate.NotRegistered(tag) - : combo.Recorded(content, tag); - var decision = ManualRollDecision(raw, clearance, key => h.Localize(key), h.ViewerLocale()); - if (decision.ProceedsOverPackageAnswer) - h.Hub.ServiceProvider.GetService() - ?.CreateLogger(typeof(UpdatePolicySettingsTab)) - .LogWarning( - "[SelfUpdate] manual roll to {Tag} proceeds over package advisories — " - + "the platform rolls independently of packages: {Advisories}", - tag, string.Join("; ", decision.Advisories)); - // Said on the result line too — the roll the operator just started is one - // whose package advisories they must be able to see without a pod log. - var note = decision.Note; - if (apply == SelfUpdateApply.ControlLane) - { - // The same announcement the poller makes, so the control plane cannot - // tell a click from a check — and dedupes both the same way. - var installed = ShippedReleaseSeed.InstalledPlatformVersion; - handover.Announce(new SelfUpdateHandover.Announcement - { - Event = SelfUpdateHandover.ReleaseEvent, - CurrentVersion = installed, - NewVersion = tag, - CurrentImage = options.PortalImage(installed.Split('+')[0]), - NewImage = options.PortalImage(tag), - Policy = content.Policy.ToString(), - Pattern = UpdateChannelPattern.Normalize(content.Pattern), - Trigger = "Manual", - DetectedAt = SelfUpdateHandover.Stamp(DateTimeOffset.UtcNow), - }) - .Subscribe( - outcome => h.UpdateData(ResultId, h.Localize("ui.updateHandedOver", tag, outcome.Destination) + note), - ex => h.UpdateData(ResultId, h.Localize("ui.updateHandoverFailed", ex.Message))); - return; - } - // 🚨 THE SCHEMA MOVES FIRST HERE TOO (#4764). This button honoured the - // availability gate, the combo gate and the control-lane route — and skipped - // the migration entirely, so an admin click made exactly the image-only roll - // across a db_version boundary that the poller had stopped making: the new - // pods refuse to start on DbVersionGate, Kubernetes restarts them for ever, - // and the old ReplicaSet keeps answering 200. Worse than the poller's old - // blind branch, because a click leaves no verdict anywhere to inspect. - // - // The decision is SelfUpdateVerdict.MayPatchAfter — the same predicate the - // poller's outcome is held to, so the two routes cannot drift apart when an - // outcome is added to the enum. - // - // 🚨 …refined by the PUBLISHED schema step (#4764 (b3)): both releases' - // ExpectedDbVersion markers, read off the same share the availability gate - // reads, so a click across a known schema bump that this install cannot - // migrate is refused naming both numbers. - var fileSystem = h.Hub.ServiceProvider.GetService()?.Get(IoPoolNames.FileSystem) - ?? IoPool.Unbounded; - ReleaseSchemaMarker.ObserveStep( - fileSystem, - h.Hub.ServiceProvider.GetService()?[ - MeshWeaver.Hosting.ShippedPrebuiltBundles.PublishedRootConfigKey], - ShippedReleaseSeed.InstalledPlatformVersion, tag) - // No Catch: a fault on this read is NOT an absent marker (Read already - // answers absent/garbled/unreadable files as UNKNOWN, logged) — it - // reaches the error arm below and nothing is applied. - .SelectMany(step => pool.Invoke(ct => updater!.RunMigrationAsync(tag, ct)) - .Select(outcome => (outcome, step))) - .Subscribe( - reading => - { - var (outcome, step) = reading; - if (!SelfUpdateVerdict.MayPatchAfter(outcome, step)) - { - // The two refusals stay DIFFERENT sentences: a migration that ran - // and broke sends the operator to the Job's log, one that could - // not be created sends them to a helm upgrade. The outcome name - // renders verbatim — machine text, like the gate diagnostics - // above. - // A refusal the published schema step decided (NotSupported - // across a known bump) is a could-not-run, like the 403, and - // carries both numbers verbatim beside the outcome. - h.UpdateData(ResultId, outcome switch - { - MigrationRunOutcome.NotSupported => h.Localize( - "ui.updateSchemaAheadManual", tag, step.Describe()), - MigrationRunOutcome.Forbidden => h.Localize( - "ui.updateMigrationUnavailableManual", tag, - step.Known ? $"{outcome}; {step.Describe()}" : outcome.ToString()), - _ => h.Localize("ui.updateMigrationFailedManual", tag, outcome.ToString()), - }); - return; - } - pool.Invoke(ct => updater!.PatchToVersionAsync(tag, ct)).Subscribe( - _ => h.UpdateData(ResultId, h.Localize( - outcome == MigrationRunOutcome.NotSupported && !step.KeepsSchema - ? "ui.updateRollingUnmigrated" - : "ui.updateRolling", - tag) + note), - ex => h.UpdateData(ResultId, h.Localize("ui.updateApplyFailed", ex.Message))); - }, - ex => h.UpdateData(ResultId, h.Localize("ui.updateApplyFailed", ex.Message))); - }); - }); - } - - /// How many caveat lines a NotVerifiable verdict renders before "… and N more". - /// Failing MODULES are never truncated — breadth-complete reporting is the gate's point. - private const int MaxRenderedCaveats = 6; - - /// - /// The status line(s): latest available tag + check time, joined with the combo gate's verdict - /// for that tag when one is recorded. Pure and localizer-seamed so the three verdict renderings - /// are pinned by unit tests. Module ids, tags, digests, and the gate's failure/caveat - /// diagnostics render VERBATIM — they are machine text, the same rule as compiler output - /// (InstanceComboReader.ProvenanceCaveat documents the choice); every label around them - /// is localized. - /// - /// - /// The viewer's IANA zone (AccessService.ViewerZoneId()); null renders UTC. Optional so - /// the existing callers and tests compile unchanged — but the production caller passes it, - /// because an instant formatted in UTC is simply the wrong time for the person reading it - /// (#2302), and on Blazor Server the ambient culture is the CONTAINER's, identical for every - /// simultaneous viewer. - /// - /// The viewer's language tag (host.ViewerLocale()), for the parts the - /// RECORD carries as reason codes rather than through — the - /// release-availability advisories (#6540); null renders English. - internal static string StatusMarkdown( - UpdatePolicyContent content, Func localize, string? zoneId = null, - string? locale = null) => - // 🚨 ABOVE everything else, on every branch (#3543). An install whose own version has been - // withdrawn from the registry cannot start a new pod and can never see anything "newer" — - // nothing outranks a tag that already outranks what is left. Until this line existed, that - // install rendered the same "No newer version detected yet" as a perfectly healthy one, and - // both AKS portals sat in it on 2026-09-07 until an operator moved them by hand. It is - // prefixed rather than folded into a branch because the Red / NotVerifiable renderings below - // deliberately replace the availability line, and a strand must not be droppable that way. - (string.IsNullOrEmpty(content.UnresolvedInstalledTag) - ? "" - : localize("ui.updateInstalledTagWithdrawn", [content.UnresolvedInstalledTag!]) + "\n\n") - + AvailabilityMarkdown(content, localize, zoneId, locale); - - /// - /// The hand-over sentence for (#4098): where and when this install handed - /// it to the control instance — or null when the tag was never handed over. Pure; pinned by - /// PlatformUpdateStatusTest. - /// - internal static string? HandedOverLine( - UpdatePolicyContent content, string tag, Func localize, string? zoneId) - { - if (string.IsNullOrEmpty(content.HandedOverTag) - || !string.Equals(content.HandedOverTag, tag, StringComparison.OrdinalIgnoreCase)) - return null; - var when = content.HandedOverAt is { } at - ? DisplayTimeExtensions.ToDisplayTime(at, zoneId).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) - : "?"; - return localize("ui.updateHandedOverLine", [tag, content.HandedOverTo ?? "?", when]); - } - - /// The availability half of : the latest tag, the check - /// time, the hand-over state, the availability hold and the combo verdict. - private static string AvailabilityMarkdown( - UpdatePolicyContent content, Func localize, string? zoneId, string? locale) - { - // 🚨 "No newer version detected yet" is a CLAIM, and until #2553 this surface made it - // without evidence. An install that has checked hourly and found nothing and an install - // whose checker has not run since the pod started produce the identical empty - // LatestAvailableTag — and the second is the one that matters, because it is how memex sat - // three builds behind for 7 h while this line reassured whoever read it. The two are now - // different sentences, and the honest one comes first: without a completed check there is - // nothing to report but the absence of a check. - if (string.IsNullOrEmpty(content.LatestAvailableTag)) - return content.LastCheckedAt is { } checkedAt - ? localize("ui.updateNoNewerVersion", []) - + " " + localize("ui.updateCheckedAt", - [DisplayTimeExtensions.ToDisplayTime(checkedAt, zoneId).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)]) - : localize("ui.updateNeverChecked", []); - - var tag = content.LatestAvailableTag!; - var available = localize("ui.updateLatestAvailable", [tag]) - + (content.CheckedAt is { } at - ? " " + localize("ui.updateCheckedAt", [DisplayTimeExtensions.ToDisplayTime(at, zoneId).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)]) - : "") - // 🚨 Where the release WENT (#4098). On the control lane "latest available" is not a - // release waiting for this install to act — it was handed to the control instance, and - // the roll is a decision taken there. Without this line the tab reads "update - // available" for ever on an install that has done everything it can; the durable - // HandedOver* fields exist for exactly this sentence. Rendered only for the tag the - // tab is about: a hand-over of an OLDER tag is history, not the current state. - + (HandedOverLine(content, tag, localize, zoneId) is { } handedOver ? "\n\n" + handedOver : ""); - - // 🚨 The availability hold (#1754) is reported BEFORE the combo verdict, because it is the - // reason this install is not moving RIGHT NOW. A hold that only showed up in the logs would - // leave an operator reading "update available" for weeks with nothing explaining why the - // version never changes — the silent freeze this gate must never become. - // 🚨 A hold RECORD is not a live verdict (#3812). HeldAt/HeldReason are written only when a - // candidate is actually EVALUATED; LastCheckedAt is written on every check. With updates - // switched off the check records "updates are disabled" and evaluates nothing, so the hold - // is never revisited — and the fresh LastCheckedAt beside it makes a days-old record read - // as the reason this install is standing still. That is not hypothetical: #3706 was filed - // on memex's frozen record and quoted three module floors that #3648 had stopped holding on - // two hours after the hold was written. The record is still worth showing — it is the only - // diagnostic an operator has before turning updates back on — but it is shown as HISTORY, - // and "(held {date})" is dropped with it, because that phrasing reads as an ongoing state. - if (content.IsHeld(tag)) - { - var operative = content.IsHoldOperative(tag); - available += "\n\n" - + (operative - ? localize( - content.HeldIndeterminate ? "ui.updateHeldUnknown" : "ui.updateHeld", [tag]) - : content.HeldAt is { } recordedAt - ? localize("ui.updateHoldHistorical", - [tag, DisplayTimeExtensions.ToDisplayTime(recordedAt, zoneId).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)]) - : localize("ui.updateHoldHistoricalUndated", [tag])) - + (string.IsNullOrEmpty(content.HeldReason) - ? "" - : "\n\n> " + Sanitize(content.HeldReason!)) - + (operative && content.HeldAt is { } heldAt - ? "\n\n" + localize("ui.updateHeldAt", [DisplayTimeExtensions.ToDisplayTime(heldAt, zoneId).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)]) - : ""); - } - - // 🚨 What the gate SAID about this tag without holding on it (#3651): the packages the - // roll compiles at boot, a landed module whose loadability there could not be measured, a - // declared floor the target does not rank above. Rendered whether or not the tag is held, - // because a roll that compiles content at boot is a state an operator must be able to see - // on this tab — a cost that lived only in a pod log is how a "routine" update surprises. - if (content.HasAdvisoriesFor(tag)) - available += "\n\n" - + localize("ui.updateAdvisories", [tag]) - // Each advisory is bounded on its own, so one long advisory (a waived package - // hold) never cuts off the ones after it. - + "\n\n> " + string.Join("; ", AdvisoryLines(content, locale).Select(Sanitize)); - - var verdict = content.VerificationFor(tag); - if (verdict is null) - return available; - - var verifiedAt = localize("ui.updateVerifiedAtLine", - [DisplayTimeExtensions.ToDisplayTime(verdict.VerifiedAt, zoneId).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture), verdict.ImageDigest ?? "?"]); - // 🚨 Caveats are surfaced on EVERY verdict — ComboVerification.Caveats documents them as - // mandatory-to-surface. A Green over a moving pin, or a Red whose input diverged, must - // never render as an unqualified answer (Copilot review on #1099). - return verdict.Verdict switch - { - ComboVerdictKind.Green => - available + "\n\n" - + localize("ui.updateVerifiedGreen", [verdict.Modules.Count]) - + " " + verifiedAt - + CaveatBlock(verdict, localize), - // 🚨 A Red is an ADVISORY under policy platform-rolls-independently-of-packages: - // the availability line stays — the build IS - // what this install rolls to — and the failing modules are named beneath it as the - // packages that will not follow, never as a reason the platform stays. - ComboVerdictKind.Red => - available + "\n\n" - + localize("ui.updateBlocked", [tag]) + "\n\n" - + FailedModuleList(verdict, localize) - + CaveatBlock(verdict, localize) + "\n\n" + verifiedAt, - _ => - localize("ui.updateNotVerifiable", [tag]) + "\n\n" - + NotVerifiedModuleList(verdict) - + CaveatList(verdict, localize) - + RetiredBlock(verdict, localize) + "\n\n" + verifiedAt, - }; - } - - /// - /// 🌍 The recorded advisories in the VIEWER's language (MeshWeaver#6540): each - /// entry through its reason code, falling back - /// to its stored English when it carries no code or the catalog does not know it. A record - /// written before the items existed carries only the joined English - /// (, "; "-separated) and renders exactly that. - /// - /// The policy record. - /// The viewer's language tag; null renders English. - internal static IEnumerable AdvisoryLines(UpdatePolicyContent content, string? locale) => - content.AdvisoryItems is { Count: > 0 } items - ? items.Select(item => item.Localize(locale)) - : (content.Advisories ?? "") - .Split("; ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - /// Every failing module — never truncated — with its first failure line (and a count - /// of the rest). - private static string FailedModuleList( - ComboVerification verdict, Func localize) => - string.Join("\n", verdict.FailedModules.Select(module => - { - var first = module.Failures.Count > 0 ? Sanitize(module.Failures[0]) : ""; - var more = module.Failures.Count > 1 - ? " " + localize("ui.updateMoreFailures", [module.Failures.Count - 1]) - : ""; - return $"- **{module.ModuleId}** — {first}{more}"; - })); - - /// The per-module reasons of a NotVerifiable verdict: every module the gate could - /// not evaluate that carries a named reason (a refusal, a fetch failure, a missing root, not - /// discovered) — never truncated, so one blocked module does not hide another. Modules that - /// merely rode along (materialised fine, gate never ran) carry no reason and are not - /// listed. - private static string NotVerifiedModuleList(ComboVerification verdict) - { - var lines = verdict.Modules - .Where(m => m.Outcome == ModuleVerificationOutcome.NotVerified && m.Failures.Count > 0) - .Select(m => $"- **{m.ModuleId}** — {Sanitize(m.Failures[0])}") - .ToList(); - return lines.Count == 0 ? "" : string.Join("\n", lines) + "\n\n"; - } - - /// The caveats as a labeled block appended to a Green/Red verdict; empty when there - /// are none. Followed by the retired modules, which every verdict carries. - private static string CaveatBlock( - ComboVerification verdict, Func localize) => - (verdict.Caveats.Count == 0 - ? "" - : "\n\n" + localize("ui.updateCaveats", []) + "\n\n" + CaveatList(verdict, localize)) - + RetiredBlock(verdict, localize); - - /// - /// 🌍 The modules reported as retired, not verifiable (MeshWeaver#3848): their source - /// folder is gone, so they neither fail nor hold the verdict — and are still named, under a - /// heading in the viewer's language. Empty when there are none. - /// - private static string RetiredBlock( - ComboVerification verdict, Func localize) => - verdict.RetiredModules is not { Count: > 0 } retired - ? "" - : "\n\n" + localize("ui.updateRetiredModules", [retired.Count]) + "\n\n" - + string.Join("\n", retired.Select(r => $"- **{r.ModuleId}**")); - - private static string CaveatList( - ComboVerification verdict, Func localize) - { - var lines = verdict.Caveats - .Take(MaxRenderedCaveats) - .Select(caveat => $"- {Sanitize(caveat)}") - .ToList(); - if (verdict.Caveats.Count > MaxRenderedCaveats) - lines.Add("- " + localize("ui.updateMoreItems", [verdict.Caveats.Count - MaxRenderedCaveats])); - return string.Join("\n", lines); - } - - /// - /// What the manual "Apply update now" button decides about the package answers for - /// tag — the SAME rule the poller applies (policy - /// platform-rolls-independently-of-packages), stated once and pure so it is pinned - /// behaviourally rather than through the UI. There is deliberately no "refuse" member: the - /// button always goes on to the hand-over or the migrate-then-patch, and this only says what - /// it rolls over. - /// - /// The availability gate's PACKAGE verdict (or the unwired / - /// not-enforced stand-in), before . - /// The recorded combo answer for the tag. - /// The localizer for the note's heading. - /// The viewer's language tag, for the advisories that carry a reason code - /// (#6540); null renders English. The returned - /// stay English — they are the log line. - internal static ManualRollAdvisories ManualRollDecision( - UpdatabilityVerdict availability, ComboClearance clearance, Func localize, - string? locale = null) - { - var verdict = availability.ForPlatformRoll(); - // EVERY advisory the verdict carries, then the combo one. A waived hold leads - // verdict.Advisories (ForPlatformRoll), and an already-updatable verdict carries its - // boot-compile / link / declared-floor advisories there with WaivedHold null — reading - // only WaivedHold would drop those. - ImmutableArray advisories = - [ - .. verdict.Advisories.IsDefault ? [] : verdict.Advisories, - .. clearance.Refuses ? new[] { clearance.Reason } : [], - ]; - // The same lines in the viewer's language: the verdict's through their reason codes, the - // combo gate's reason verbatim (it is that gate's own diagnostic). - ImmutableArray rendered = - [ - .. verdict.AdvisoryTexts.Select(text => text.Localize(locale)), - .. clearance.Refuses ? new[] { clearance.Reason } : [], - ]; - var note = advisories.IsEmpty - ? "" - : "\n\n" + localize("ui.updatePackageAdvisoriesManual") + "\n\n> " - // Each advisory bounded on its OWN — bounding the joined text would cut the first - // long one short and drop every one after it. - + string.Join("; ", rendered.Select(Sanitize)); - return new(advisories, note, verdict.WaivedHold is not null || clearance.Refuses); - } - - /// The manual roll's package advisories — see . - /// Every advisory, the verdict's first, then a combo Red's. - /// The markdown appended to the result line; empty when there is none. - /// Whether a package answer that used to refuse the - /// button (a waived availability hold or a combo Red) is being rolled over — logged at - /// Warning. - internal sealed record ManualRollAdvisories( - ImmutableArray Advisories, string Note, bool ProceedsOverPackageAnswer); - - /// One markdown-safe line out of a multi-line diagnostic, bounded. - private static string Sanitize(string diagnostic) - { - var flat = diagnostic.ReplaceLineEndings(" ").Trim(); - return flat.Length <= 200 ? flat : flat[..200] + "…"; - } -} diff --git a/memex/Memex.Portal.Shared/Settings/WhatsNewSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/WhatsNewSettingsTab.cs deleted file mode 100644 index 4ddddade30..0000000000 --- a/memex/Memex.Portal.Shared/Settings/WhatsNewSettingsTab.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System.Globalization; -using System.Reactive.Linq; -using System.Text; -using MeshWeaver.Application.Styles; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Settings; - -/// -/// Global settings "What's New" tab — lists the platform release-note entries shipped as documentation -/// nodes under (one node per entry, produced by the /pullrequest -/// skill and shipped with the build), newest first. Each entry links to its full note in the normal -/// documentation view. Ungated: visible to every user. Reactive + pure (no -/// hand-built HTML), and it only LISTS children (a valid query use) — the note content is read by the -/// doc view when opened, never from the lagging query index (CQRS rule). -/// -/// Shape of the list. Entries are grouped by the day they shipped, newest day first. -/// Inside a day, a entry gets its own line with its one-line -/// description; the day's entries are bundled into a single "N fixes" line -/// of links. A release day routinely carries a handful of fixes and one or two features — listing all -/// of them flat (the pre-2026-08 shape) buried the features and made the page read as an -/// alphabetical wall, since the entry titles, not their dates, drove the eye. -/// -public static class WhatsNewSettingsTab -{ - /// - /// The tab id — the trailing segment of the tab's deep link, built with - /// (/_Setting/GlobalSettings/WhatsNew). - /// - public const string TabId = "WhatsNew"; - - /// The documentation namespace holding per-entry release-note nodes (served under Doc/). - public const string WhatsNewNamespace = "Doc/WhatsNew"; - - /// - /// The node type a release-note entry declares in its front matter (nodeType: WhatsNew) - /// when it does NOT live under — which is every entry authored - /// outside this repository. - /// - public const string EntryNodeType = "WhatsNew"; - - /// - /// The listing, as a UNION of two lanes (#2539) — GetQuery takes several queries and - /// unions their result sets, deduped by path. - /// - /// Why two. The feed used to be a single path:Doc/WhatsNew scope:children - /// listing, and that path exists only in the platform repository. A satellite — Plugins, - /// Education, Reinsurance, SocialMedia, Memex — had NO route to file an entry from its own PR, - /// so a user-noticeable fix landing there was simply absent from the changelog. That is not a - /// gap that holds steady: every extraction moves more user-visible behaviour out of core, so - /// the feed acquires a systematic bias toward platform work and a reader would conclude that - /// plugin-side fixes had stopped happening. - /// - /// The second lane keys on the node TYPE instead of a path, so a satellite writes - /// WhatsNew/<date>-<slug>.md in its own tree with nodeType: WhatsNew - /// in the front matter and it appears in the one feed — authorship stays with the change and no - /// cross-repo PR is needed. The 612 entries already under Doc/WhatsNew keep working - /// untouched, which is why this is a union and not a migration. - /// - public static readonly string[] ListingQueries = - [ - $"path:{WhatsNewNamespace} scope:children", - // The type lane is mesh-wide BY DESIGN — a satellite files its entries in its own tree — - // and says so (#3202 — fan-out is opt-in). - MeshWideQuery.OfType(EntryNodeType), - ]; - - /// - /// Category of an entry that announces something new or improved — rendered in full, with - /// its description. Written by the /pullrequest skill from the branch prefix. - /// - public const string FeatureCategory = "Feature"; - - /// - /// Category of an entry that announces a repair — bundled into the day's one-line "N - /// fixes" summary rather than given a paragraph of its own. - /// - public const string FixCategory = "Fix"; - - private const string ListDataId = "whatsNewList"; - - // The menu entry is a seeded UiContribution node (PlatformSettingsTabAreas) — the tab's - // registration surface is the SettingsWhatsNew layout area, not a compiled provider. - - /// - /// The feed half: the rendered feed for every emission of the synced listing. Builds no control. - /// - internal static IObservable WhatsNewFeed(LayoutAreaHost host) - => host.Hub.GetQuery("whatsnew:list", ListingQueries).Select(nodes => Render(nodes, host)); - - internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack) - { - stack = stack.WithView(Controls.H2(host.Localize("settings.whatsNew")).WithStyle("margin: 0 0 8px 0;")); - stack = stack.WithView(Controls.Markdown(host.Localize("ui.mdWhatsNewIntro"))); - - // Live list of entry nodes under Doc/WhatsNew — a TEMPLATE (Doc/GUI/DataBinding → "Templates - // first, data later"): the markdown is declared at once and fed by WhatsNewFeed. Listing - // children is a valid query use; the entry content is rendered by the doc view when the link - // is opened (never read from the lagging query index). A failure shows a generic message to - // this ungated, any-user page — never the raw exception; BindMarkdown logs it server-side. - stack = stack.WithView(WhatsNewFeed(host) - .BindMarkdown(ListDataId, host.Localize("ui.mdLoading"), _ => host.Localize("ui.mdWhatsNewFailed"))); - - return stack; - } - - /// - /// The entries as markdown: one section per ship day, newest first, features spelled out and - /// fixes bundled. A friendly note when there is nothing to show. - /// - private static string Render(IEnumerable nodes, LayoutAreaHost host) - { - // DistinctBy: an entry that lives under Doc/WhatsNew AND declares nodeType:WhatsNew matches - // both lanes. The synced layer already dedupes by path, so this is belt-and-braces — but a - // duplicated entry is VISIBLE in the feed, and the cost of being sure is one operator. - var entries = (nodes ?? []).Where(n => n is not null).DistinctBy(n => n.Path).ToList(); - if (entries.Count == 0) - return host.Localize("ui.mdWhatsNewEmpty"); - - // Explicit off the viewer's AccessContext — never ambient CurrentUICulture, which does not - // survive the hub's scheduler hop (Localization.md). - var culture = CultureInfo.GetCultureInfo(host.ViewerLocale()); - - return string.Join("\n\n", entries - .GroupBy(ShipDay) - .OrderByDescending(g => g.Key ?? DateOnly.MinValue) - .Select(g => RenderDay(g.Key, g, host, culture))); - } - - /// One day's section: the date, then its features, then its bundled fixes. - private static string RenderDay( - DateOnly? day, IEnumerable nodes, LayoutAreaHost host, CultureInfo culture) - { - // Path order inside a day is the authored order (date-prefixed ids), so the section is - // stable across renders rather than reshuffling on every query emission. - var ordered = nodes.OrderBy(n => n.Path, StringComparer.OrdinalIgnoreCase).ToList(); - var fixes = ordered.Where(IsFix).ToList(); - - var sb = new StringBuilder(); - if (day is { } shipped) - sb.Append("### ").Append(FormatDay(shipped, culture)).Append("\n\n"); - - foreach (var feature in ordered.Where(n => !IsFix(n))) - { - sb.Append("**").Append(Link(feature)).Append("**"); - if (!string.IsNullOrWhiteSpace(feature.Description)) - sb.Append(" \n").Append(feature.Description.Trim()); - sb.Append("\n\n"); - } - - if (fixes.Count > 0) - sb.Append('_').Append(host.LocalizePlural("plural.fix", fixes.Count)).Append("_ — ") - .Append(string.Join(" · ", fixes.Select(Link))); - - return sb.ToString().TrimEnd(); - } - - private static bool IsFix(MeshNode node) - => string.Equals(node.Category, FixCategory, StringComparison.OrdinalIgnoreCase); - - /// - /// The day an entry shipped, read off its ISO-dated id (2026-08-09-some-slug) — the naming - /// the skill writes. An entry that does not carry one sorts last, under no date heading. - /// - private static DateOnly? ShipDay(MeshNode node) - { - var id = LastSegment(node.Path); - return id.Length >= 10 - && DateOnly.TryParseExact( - id[..10], "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var day) - ? day - : null; - } - - /// - /// The date in the viewer's language, without the weekday: the culture's own long-date pattern - /// with dddd removed, so English reads "9 August 2026" and German "9. August 2026" without - /// either being hard-coded. - /// - private static string FormatDay(DateOnly day, CultureInfo culture) - { - var pattern = culture.DateTimeFormat.LongDatePattern; - var weekday = pattern.IndexOf("dddd", StringComparison.Ordinal); - if (weekday >= 0) - pattern = pattern.Remove(weekday, 4).Trim(' ', ','); - - return day.ToDateTime(TimeOnly.MinValue).ToString(pattern, culture); - } - - /// A markdown link to the entry's full note, label escaped so a bracketed title holds. - private static string Link(MeshNode node) - => $"[{(node.Name ?? LastSegment(node.Path)).Replace("[", "\\[").Replace("]", "\\]")}](/{node.Path})"; - - private static string LastSegment(string path) - => string.IsNullOrEmpty(path) ? path : path[(path.LastIndexOf('/') + 1)..]; -} diff --git a/memex/Memex.Portal.Shared/Setup/InstanceIdRules.cs b/memex/Memex.Portal.Shared/Setup/InstanceIdRules.cs deleted file mode 100644 index 44d1b2c3b9..0000000000 --- a/memex/Memex.Portal.Shared/Setup/InstanceIdRules.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Text.RegularExpressions; - -namespace Memex.Portal.Shared.Setup; - -/// -/// What a registry will accept as an instance id — checked HERE, before the id is claimed. -/// -/// 🚨 The rule is the registry's (MeshWeaverInstanceService.IsValidInstanceId), and it -/// is duplicated deliberately rather than called: the setup host composes no mesh and cannot reach -/// that service, and the alternative — discovering a malformed id from a 400 after the round trip — -/// wastes an id that may already have been claimed by the time the error is read. The two are -/// pinned against each other by InstanceIdRulesMatchTheRegistryTest. -/// -/// A guid satisfies it as-is, which is why the wizard mints one: 36 lowercase characters of -/// hex and single hyphens, no leading or trailing hyphen. -/// -public static partial class InstanceIdRules -{ - /// Lowercase letters, digits and single hyphens; 3–48 characters; not hyphen-terminated. - [GeneratedRegex("^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,47}$")] - private static partial Regex Pattern(); - - /// Whether is one a registry will accept. - /// The candidate id. - public static bool IsWellFormed(string? instanceId) => - !string.IsNullOrWhiteSpace(instanceId) && Pattern().IsMatch(instanceId); -} diff --git a/memex/Memex.Portal.Shared/Setup/InstanceManifestConfigurationExtensions.cs b/memex/Memex.Portal.Shared/Setup/InstanceManifestConfigurationExtensions.cs deleted file mode 100644 index 261fe0de33..0000000000 --- a/memex/Memex.Portal.Shared/Setup/InstanceManifestConfigurationExtensions.cs +++ /dev/null @@ -1,73 +0,0 @@ -using MeshWeaver.AI; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Configuration.Memory; - -namespace Memex.Portal.Shared.Setup; - -/// -/// Layers the instance manifest into a host's configuration — the step that turns the setup -/// wizard's answers into the keys the host already reads, so that NOTHING downstream has to know a -/// wizard exists. -/// -/// 🚨 Inserted at index 0, which is the whole design. Configuration sources are -/// last-wins, so the manifest sitting FIRST means every appsettings value, ConfigMap key, -/// environment variable and command-line argument outranks it. That is the rule -/// states for storage — "it never overrides a host that stated -/// its own storage" — obtained structurally rather than by a hand-written precedence check that -/// each new section would have to repeat. -/// -/// A snapshot, not a live file. The manifest is read once, here. It is written by the -/// setup surface, which then restarts the process; a source that reloaded would let a half-written -/// manifest reconfigure a running host mid-flight, and there is no code path that wants that. -/// -public static class InstanceManifestConfigurationExtensions -{ - /// - /// Adds the completed instance manifest under 's existing sources. - /// A host with no manifest, or an incomplete one, is left byte-identical — which is every - /// deployment that exists today. - /// - /// The configuration builder. For a ConfigurationManager this both - /// inserts the source and rebuilds, so values are readable immediately after the call. - /// The writable root the manifest and key file live on - /// (ModuleRoot.Resolve). - /// The master key the host's OWN sources answer with, so a - /// deployment-supplied key is used and the on-disk key file is consulted only when there is - /// none. Pass configuration["Ai:KeyProtection:MasterKey"]. - /// Called when a manifest exists but cannot be read. Pre-DI, so - /// production passes stderr. - /// The same builder, for chaining. - public static IConfigurationBuilder AddInstanceManifest( - this IConfigurationBuilder builder, - string rootDirectory, - string? configuredMasterKey = null, - Action? onUnreadable = null) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); - - var manifest = InstanceManifest.Read(rootDirectory, onUnreadable); - var masterKey = InstanceMasterKey.Resolve(rootDirectory, configuredMasterKey); - - var entries = new Dictionary( - InstanceManifestProjection.ToConfiguration( - manifest, - masterKey is null ? null : new ProviderKeyProtector(new LiteralMasterKeyProvider(masterKey))), - StringComparer.OrdinalIgnoreCase); - - // The key file's own value is projected too, so that a wizard-provisioned install reads - // back the key it minted. Configured-wins is preserved by the index-0 insert: when the - // deployment supplies the key, its own source outranks this entry and this one is the same - // value anyway (Resolve returned it). - if (masterKey is not null) - entries[ConfigMasterKeyProvider.ConfigKey] = masterKey; - - if (entries.Count == 0) - return builder; - - builder.Sources.Insert(0, new MemoryConfigurationSource { InitialData = entries }); - return builder; - } -} diff --git a/memex/Memex.Portal.Shared/Setup/InstanceSetupStatusAccessor.cs b/memex/Memex.Portal.Shared/Setup/InstanceSetupStatusAccessor.cs deleted file mode 100644 index 69d17fb775..0000000000 --- a/memex/Memex.Portal.Shared/Setup/InstanceSetupStatusAccessor.cs +++ /dev/null @@ -1,44 +0,0 @@ -using MeshWeaver.Mesh; - -namespace Memex.Portal.Shared.Setup; - -/// -/// Whether THIS instance is still awaiting first-run setup, resolvable from anywhere in the request -/// pipeline. -/// -/// Plumbed, not re-derived. The decision is made once, in -/// MemexConfiguration.ConfigureMemexMesh: no Graph:Storage configuration and no -/// COMPLETE instance.json ⇒ MarkAwaitingSetup() and an early return that configures no -/// storage, no data sources and no hubs. Re-deriving that here from the same inputs would be a -/// second implementation of one rule, free to disagree with the first — and the disagreement would -/// be a host that either serves the wizard over a live database or refuses to serve the wizard to an -/// instance that has none. -/// -/// 🚨 The value is read LAZILY, and that is load-bearing. The two portal hosts compose -/// in opposite orders — the monolith configures the portal BEFORE the mesh, the distributed host -/// after — so a value captured at registration time would be false on one of them and correct -/// on the other. The registration is a factory, so the flag is read when the service is first -/// resolved, which is after the whole builder has run either way. -/// -/// Reads MeshBuilder.IsAwaitingSetup at resolve time. -public sealed class InstanceSetupStatusAccessor(Func isAwaitingSetup) -{ - /// True when this instance has no storage and no completed setup manifest. False for - /// every deployment configured through appsettings — which is all of them until an operator - /// installs an empty image on purpose. - public bool IsAwaitingSetup { get; } = (isAwaitingSetup ?? throw new ArgumentNullException(nameof(isAwaitingSetup)))(); - - /// The accessor a host that never registered one answers with — a configured instance. - /// - /// Defaulting the OTHER way would park an ordinary host in a setup surface it has no - /// business showing; a wrong "configured" merely omits a banner. - public static InstanceSetupStatusAccessor Configured { get; } = new(static () => false); - - /// Reads the builder's verdict at resolve time. - /// The builder whose to read. - public static InstanceSetupStatusAccessor For(MeshBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - return new InstanceSetupStatusAccessor(() => builder.IsAwaitingSetup); - } -} diff --git a/memex/Memex.Portal.Shared/Setup/SetupAccessToken.cs b/memex/Memex.Portal.Shared/Setup/SetupAccessToken.cs deleted file mode 100644 index cb0091ce90..0000000000 --- a/memex/Memex.Portal.Shared/Setup/SetupAccessToken.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Security.Cryptography; - -namespace Memex.Portal.Shared.Setup; - -/// -/// The one-time token that gates the first-run wizard. -/// -/// 🚨 The setup surface is UNAUTHENTICATED by construction, and that is a hole unless -/// something closes it. It runs before any storage exists, so there is no user store to -/// authenticate against — and what it collects is a database connection string, provider API keys -/// and the list of ids that become platform administrators. An open form offering all of that to -/// whoever reaches the port first is not a theoretical exposure: a fresh instance is reachable the -/// moment its ingress resolves, and the person who set it up is not necessarily the first person -/// to load it. -/// -/// So the instance mints a token at startup and writes it to its own log, where only someone -/// who can already read the instance's output can see it — the same proof-of-access every local -/// notebook server uses. memex-local reads it back out of the pod log and opens the browser -/// with it; an operator on a server reads it from kubectl logs or docker logs. -/// -/// Per process, and deliberately not persisted. A restart mints a new one, which is -/// the correct behaviour: the token's whole meaning is "you can see this instance's console right -/// now". Persisting it would turn a proof of present access into a durable credential on the same -/// disk as everything it protects. -/// -public sealed class SetupAccessToken -{ - /// The token this process minted. - public string Value { get; } - - /// Mints a fresh token. One per process. - public SetupAccessToken() - // URL-safe: it travels as a query parameter when the installer opens the browser for you. - => Value = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); - - /// - /// Whether is this instance's token. - /// - /// Fixed-time comparison: the form can be submitted repeatedly, so an early-exit compare - /// would leak the token a character at a time to anyone patient enough to measure. - /// - /// What the form submitted. Null or blank never matches. - public bool Matches(string? candidate) - { - if (string.IsNullOrWhiteSpace(candidate)) - return false; - var expected = System.Text.Encoding.UTF8.GetBytes(Value); - var actual = System.Text.Encoding.UTF8.GetBytes(candidate.Trim()); - return CryptographicOperations.FixedTimeEquals(expected, actual); - } - - /// - /// The line written to the instance's console so an operator can find the token. - /// - /// The URL is included whole because the alternative — "open the portal and paste this" — - /// is where people paste it into the wrong field. Not a secret in the log's sense: the log is - /// already the trust boundary this token is defined by. - /// - /// The address the instance is reachable at, when it is known. - public string ConsoleBanner(string? baseUrl) => - $""" - - ╭──────────────────────────────────────────────────────────────────────────╮ - │ This instance has no database yet and is serving the SETUP wizard only. │ - ╰──────────────────────────────────────────────────────────────────────────╯ - Open: {(string.IsNullOrWhiteSpace(baseUrl) ? "" : baseUrl.TrimEnd('/'))}/setup?token={Value} - Token: {Value} - - """; -} diff --git a/memex/Memex.Portal.Shared/Setup/SetupCatalog.cs b/memex/Memex.Portal.Shared/Setup/SetupCatalog.cs deleted file mode 100644 index 7e94fcea36..0000000000 --- a/memex/Memex.Portal.Shared/Setup/SetupCatalog.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System.Collections.Immutable; - -namespace Memex.Portal.Shared.Setup; - -/// -/// What the first-run wizard may OFFER — the image's actual capabilities, never a wish list. -/// -/// 🚨 Every list here is discovered, and that is the load-bearing property. A wizard -/// that offered a hard-coded menu would let an operator record a backend the image cannot resolve -/// (Unknown storage type: 'Cosmos' at the next boot, after the answer is durable), or a -/// sign-in provider whose handler was never registered — the operator turns it on, the host -/// registers no scheme, and /auth/login?provider=X answers 400 Unknown provider with -/// every value correct. Both failures land a whole restart away from the choice that caused them. -/// So: storage comes from the keyed IStorageAdapterFactory registrations, sign-in from -/// SignInProviderCatalog, models from the registered -/// LanguageModelCatalogSources. -/// -/// The backends this image can actually open. -/// The login routes this image can actually serve. -/// The model providers this image can actually call. -/// The module assemblies this image ships and can boot. -/// -/// 🚨 , and are -/// INIT PROPERTIES, deliberately not primary-constructor parameters. Adding a parameter to a -/// record's primary constructor — even with a default — replaces the emitted constructor, so every -/// assembly already compiled against the old arity calls a method that no longer exists. A module -/// and the platform it loads into must agree on that signature EXACTLY and no image can serve a -/// mixed set, which is what the `Public surface (binary compatibility)` gate refuses. An init -/// property is binary-ADDITIVE: old callers keep working, new callers use an object initialiser or -/// `with { … }`. Same reasoning, same shape as InstanceRegistrationPayloads.Response.Plan. -public sealed record SetupCatalog( - ImmutableList Storage, - ImmutableList SignIn, - ImmutableList Ai, - ImmutableList Modules) -{ - /// What the REGISTRY says this instance is entitled to, once it has registered. Empty - /// before registration, and empty is a legitimate answer afterwards — a plan may grant nothing. - /// Never null. - public ImmutableList Packages { get; init; } = []; - - /// Who this instance registered as, or null before it has. - public MeshWeaver.Mesh.InstanceIdentitySelection? Identity { get; init; } - - /// Why the catalog could not be listed, when it could not be. Shown to the operator - /// rather than swallowed: an instance that registered but cannot see its plan is a state worth - /// naming, not a silently empty list. - public string? RegistryProblem { get; init; } - - /// An image that offers nothing — the shape a host with no contributor answers with, - /// which the surface renders as an explicit "this image ships no options" rather than as an - /// empty form somebody could submit. - public static SetupCatalog Empty { get; } = new([], [], [], []); -} - -/// -/// One package the registry says this instance may install. -/// -/// 🚨 is what lets a package answer the DATABASE question. It is -/// the Graph:Storage:Type the package's boot pack registers, and it is only offerable when -/// the image can already load that backend — see . -/// -/// The package id, as the catalog serves it. -/// Its display name. -/// What it is. -/// The storage backend it provides, or null. -/// Whether a "next, next, finish" install provisions it. -public sealed record SetupPackageOption( - string Id, - string Name, - string? Description = null, - string? StorageType = null, - bool PreSelected = false); - -/// One storage backend the image can open, as a keyed -/// IStorageAdapterFactory registration named it. -/// The key — exactly the value Graph:Storage:Type takes. -/// The label. Falls back to . -/// Whether the backend cannot open without one. -/// Whether the backend is rooted at a directory instead. -/// An example, shown as the field's placeholder. Never a real -/// credential — the hints are literals in code, seen by whoever is setting the instance up. -public sealed record SetupStorageOption( - string Type, - string DisplayName, - bool NeedsConnectionString = false, - bool NeedsBasePath = false, - string? ConnectionStringHint = null) -{ - /// The registry package that provides this backend, when one does. Null for a backend - /// compiled into the image. - /// - /// 🚨 A package is only listed here when the IMAGE can already open the backend — i.e. its - /// keyed factory is registered. A storage module the image lacks cannot be offered, because - /// landing it would have to happen BEFORE persistence selection reads Graph:Storage, and - /// package provisioning runs after the mesh is up. Offering one anyway would record a backend - /// that never resolves and fail at the NEXT boot, with the wizard gone. - /// - /// 🚨 An INIT property, not a sixth constructor parameter — see the note on - /// : adding a positional parameter would be a binary break for every - /// assembly compiled against the 5-arity constructor. - public string? PackageId { get; init; } -} - -/// One sign-in route the image can serve. -/// The scheme name — the value /auth/login?provider= takes. -/// The label on the sign-in button. -/// The configuration section its keys live under, in colon form. NOT derived -/// from the name: GitHub's is GitHub:OAuth. -/// True for the developer login — a boolean with no credential at all. -/// True for the one provider that takes a tenant. -/// Whether the host's own configuration already answers this route, -/// in which case the wizard shows it as configured and does not offer to overwrite it — the -/// manifest is projected UNDER configuration and could not win anyway. -public sealed record SetupSignInOption( - string Name, - string DisplayName, - string Section, - bool IsSwitch = false, - bool HasTenant = false, - bool AlreadyConfigured = false); - -/// One model provider the image can call. -/// The provider name, e.g. Anthropic. -/// The label. -/// The configuration section the provider package binds. -/// False for the providers that have none — a local Ollama, the -/// co-hosted Claude Code CLI, Copilot — so the wizard does not demand a key that does not exist. -/// Pre-filled endpoint, when the provider has a well-known one. -/// Whether the endpoint is the operator's to choose (a self-hosted, -/// OpenAI-compatible server) rather than the provider's own fixed address. -public sealed record SetupAiOption( - string Name, - string DisplayName, - string Section, - bool RequiresApiKey = true, - string? DefaultEndpoint = null, - bool TakesEndpoint = false); - -/// One module assembly the image ships. -/// The Modules:Assemblies entry — a file name, e.g. -/// MeshWeaver.Hosting.Grpc.dll. -/// The label. -/// What turning it on gets you. -/// Whether a "next, next, finish" install boots with it on. -public sealed record SetupModuleOption( - string Entry, - string DisplayName, - string? Description = null, - bool PreSelected = false); - -/// -/// Supplies the choices the wizard offers. Implemented where the catalogs live — the portal -/// composition — because core can enumerate keyed storage factories but not -/// SignInProviderCatalog or the model catalog sources, which are compiled in the host repo. -/// -/// Resolved OPTIONALLY: a host that registers none gets and -/// a surface that says so, rather than a null reference during boot of an instance that is already -/// in trouble. -/// -public interface ISetupCatalogProvider -{ - /// The choices this image can offer right now. - SetupCatalog Describe(); -} diff --git a/memex/Memex.Portal.Shared/Setup/SetupComposition.cs b/memex/Memex.Portal.Shared/Setup/SetupComposition.cs deleted file mode 100644 index fa1478384d..0000000000 --- a/memex/Memex.Portal.Shared/Setup/SetupComposition.cs +++ /dev/null @@ -1,387 +0,0 @@ -using System.Collections.Immutable; -using MeshWeaver.AI; -using MeshWeaver.Mesh; - -namespace Memex.Portal.Shared.Setup; - -/// -/// What the operator answered, in the shape the form submits it. One flat record so that composing -/// a manifest is a PURE function of the answers plus the image's catalogs — which is what lets -/// every rule below be asserted without a browser, a database or a host. -/// -public sealed record SetupAnswers -{ - /// The chosen Graph:Storage:Type. - public string StorageType { get; init; } = ""; - - /// The connection string, for a backend that needs one. - public string? ConnectionString { get; init; } - - /// The base path, for a backend rooted at a directory. - public string? BasePath { get; init; } - - /// Whether the developer login is on. - public bool EnableDevLogin { get; init; } - - /// Comma-separated ids granted platform admin through the developer login. - public string? DevAdminUsers { get; init; } - - /// External sign-in providers, keyed by scheme name. - public ImmutableList SignIn { get; init; } = []; - - /// Model providers and their keys. - public ImmutableList Ai { get; init; } = []; - - /// The embeddings endpoint. Blank means none — and vector search stays dark. - public string? EmbeddingEndpoint { get; init; } - - /// The embedding model. - public string? EmbeddingModel { get; init; } - - /// Module assemblies to boot. - public ImmutableList BootModules { get; init; } = []; - - /// Packages to provision into the mesh at first boot. - public ImmutableList ProvisionPackages { get; init; } = []; - - /// Who is answering, for the manifest's provenance field. - public string? SetUpBy { get; init; } -} - -/// -/// PHASE ONE's answers — what the operator types before anything else is offered. -/// -/// The human-readable instance name. -/// The guid the instance minted for itself. Not operator-editable. -/// The registry to register with. -/// A registration key, or blank for an open (free-plan) registration. -public sealed record IdentityAnswers( - string? Name, string? Id, string? RegistryUrl, string? BootstrapKey) -{ - /// The organisation this instance belongs to. Required by the wizard. - public string? Company { get; init; } - - /// The name of the person setting it up. Required by the wizard. - public string? OwnerName { get; init; } - - /// Their email. Required by the wizard. - public string? OwnerEmail { get; init; } - - /// Whether they accepted the privacy statement and the platform terms. Required — the - /// wizard registers by calling the endpoint directly, which does not enforce consent itself. - public bool ConsentAccepted { get; init; } -} - -/// One external sign-in provider's answers. -/// The scheme name. -/// The OAuth client id. Blank turns the provider off. -/// The tenant, for the provider that has one. -/// The client secret, in the clear as the form submitted it. Encrypted -/// by before it reaches the manifest. -public sealed record SetupSignInAnswer( - string Name, string? ClientId, string? TenantId, string? ClientSecret); - -/// One model provider's answers. -/// The provider name. -/// The API key, in the clear as the form submitted it. -/// The endpoint, when the operator chooses it. -public sealed record SetupAiAnswer(string Name, string? ApiKey, string? Endpoint); - -/// The composed outcome: a manifest to write, or the problems that stopped it. -/// The manifest to write, or null when is not empty. -/// Everything wrong with the answers, all of it — never just the first. -/// Things that are legal but will bite later, shown but not blocking. -public sealed record SetupPlan( - InstanceManifest? Manifest, - ImmutableList Problems, - ImmutableList Warnings); - -/// -/// Turns the operator's answers into the instance manifest — the single place the first-run wizard -/// decides what a new instance IS. -/// -/// 🚨 Pure, and it refuses. Nothing here writes a file, reads configuration or touches -/// a hub. Every rule is a validation the operator can still fix while the mistake is cheap: after -/// this the answer is durable, the process restarts, and a wrong one surfaces as a boot failure -/// whose message names a symptom rather than the choice that caused it. -/// -/// It writes the SAME artifact the fleet route writes — -/// — which is the property that keeps the interactive and fleet -/// provisioning paths from drifting until only one of them works. -/// -public static class SetupComposition -{ - /// - /// Composes the manifest, or the reasons it cannot be composed. - /// - /// What the operator submitted. Never null. - /// What the image offers, so an answer naming something it does not ship - /// is refused HERE rather than at the next boot. - /// The keyed storage backends this image registered. - /// Encrypts the secrets. Null means no master key could be provisioned, - /// which is refused outright: storing a live credential in plaintext is the 2026-08-24 leak, and - /// silently DROPPING it would produce a provider that renders a button and fails at the token - /// exchange. - /// The viewer-locale strings every refusal is phrased in — the wizard - /// runs pre-mesh, so the locale is passed in rather than resolved from an ambient culture. - /// The completion timestamp, injected so the result is assertable. - public static SetupPlan Compose( - SetupAnswers answers, - SetupCatalog catalog, - StorageBackendCatalog storage, - IProviderKeyProtector? protector, - SetupStrings strings, - DateTimeOffset now) - { - ArgumentNullException.ThrowIfNull(answers); - ArgumentNullException.ThrowIfNull(catalog); - ArgumentNullException.ThrowIfNull(storage); - ArgumentNullException.ThrowIfNull(strings); - - var problems = ImmutableList.CreateBuilder(); - var warnings = ImmutableList.CreateBuilder(); - - var storageOption = catalog.Storage.FirstOrDefault( - o => string.Equals(o.Type, answers.StorageType?.Trim(), StringComparison.OrdinalIgnoreCase)); - - if (string.IsNullOrWhiteSpace(answers.StorageType)) - problems.Add(strings.ProblemNoStorage); - else if (storageOption is null || !storage.Offers(answers.StorageType)) - // Naming what IS available turns a dead end into a next step. The list is short and - // contains no secrets — it is the set of keys the image compiled in. - problems.Add(strings.ProblemUnknownStorage( - answers.StorageType.Trim(), string.Join(", ", storage.Types))); - - if (storageOption is { NeedsConnectionString: true } - && string.IsNullOrWhiteSpace(answers.ConnectionString)) - problems.Add(strings.ProblemNoConnectionString(storageOption.DisplayName)); - - if (storageOption is { NeedsBasePath: true } && string.IsNullOrWhiteSpace(answers.BasePath)) - problems.Add(strings.ProblemNoBasePath(storageOption.DisplayName)); - - // 🚨 A sign-in answer with NO route at all is an instance nobody can enter. It is a - // recoverable state (the manifest can be edited on disk) but not one to walk into - // unknowingly, so it is refused rather than warned about. - var chosenSignIn = answers.SignIn - .Where(a => !string.IsNullOrWhiteSpace(a.ClientId)) - .ToImmutableList(); - var configuredElsewhere = catalog.SignIn.Any(o => o.AlreadyConfigured); - if (!answers.EnableDevLogin && chosenSignIn.IsEmpty && !configuredElsewhere) - problems.Add(strings.ProblemNoSignIn); - - if (answers.EnableDevLogin && string.IsNullOrWhiteSpace(answers.DevAdminUsers)) - // A PAIR: the login without the admin list is a portal you can enter and administer - // nothing on. Legal — someone else may hold Auth:GlobalAdmins — so a warning. - warnings.Add(strings.WarnDevLoginWithoutAdmins); - - var signInProviders = ImmutableList.CreateBuilder(); - foreach (var answer in chosenSignIn) - { - var option = catalog.SignIn.FirstOrDefault( - o => string.Equals(o.Name, answer.Name, StringComparison.OrdinalIgnoreCase)); - if (option is null) - { - problems.Add(strings.ProblemUnknownSignIn(answer.Name)); - continue; - } - - // 🚨 A route this DEPLOYMENT already answers is not recorded, whatever arrives here. - // The page disables those fields so a browser submits nothing for them, but the endpoint - // must not depend on the markup: the manifest is projected UNDER the host's own - // configuration, so an entry written here could never take effect, and a manifest that - // CLAIMS a provider the instance does not actually run on is worse than one that stays - // quiet — it is the "surface showing its own stored answer while the process runs on a - // different one" state (MeshWeaver.Plugins#980), preserved on disk. - if (option.AlreadyConfigured) - continue; - - // A scheme turned on with no secret renders its button and fails at the token - // exchange — strictly worse than an absent provider, so it is refused. - if (string.IsNullOrWhiteSpace(answer.ClientSecret)) - { - problems.Add(strings.ProblemNoSignInSecret(option.DisplayName)); - continue; - } - - if (Protect(answer.ClientSecret, protector, option.DisplayName, strings, problems) is not { } secret) - continue; - - signInProviders.Add(new InstanceSignInProvider - { - Name = option.Name, - Section = option.Section, - ClientId = answer.ClientId!.Trim(), - TenantId = option.HasTenant ? answer.TenantId?.Trim() : null, - ClientSecret = secret, - }); - } - - var aiProviders = ImmutableList.CreateBuilder(); - foreach (var answer in answers.Ai) - { - var option = catalog.Ai.FirstOrDefault( - o => string.Equals(o.Name, answer.Name, StringComparison.OrdinalIgnoreCase)); - if (option is null) - { - problems.Add(strings.ProblemUnknownAiProvider(answer.Name)); - continue; - } - - var hasKey = !string.IsNullOrWhiteSpace(answer.ApiKey); - var hasEndpoint = !string.IsNullOrWhiteSpace(answer.Endpoint); - - // 🚨 "Chosen" is decided from what was SUBMITTED, before any default is substituted. - // Applying option.DefaultEndpoint first made every provider that has one look chosen — - // an untouched row became a configured provider, which is the same defect as rendering - // the default into the field's value, one layer down. Nothing supplied ⇒ not chosen. - if (!hasKey && !hasEndpoint) - continue; - - // Only now: a row the operator DID answer inherits the provider's own endpoint when - // they left that field alone. - var endpoint = hasEndpoint ? answer.Endpoint!.Trim() : option.DefaultEndpoint; - - if (option.RequiresApiKey && !hasKey) - { - problems.Add(strings.ProblemNoApiKey(option.DisplayName)); - continue; - } - - string? protectedKey = null; - if (hasKey && Protect(answer.ApiKey, protector, option.DisplayName, strings, problems) is { } k) - protectedKey = k; - else if (hasKey) - continue; - - aiProviders.Add(new InstanceAiProvider - { - Name = option.Name, - Section = option.Section, - ApiKey = protectedKey, - Endpoint = endpoint, - }); - } - - var embeddings = string.IsNullOrWhiteSpace(answers.EmbeddingEndpoint) - ? null - : new InstanceEmbeddingsSelection - { - Endpoint = answers.EmbeddingEndpoint.Trim(), - Model = string.IsNullOrWhiteSpace(answers.EmbeddingModel) - ? InstanceEmbeddingsSelection.DefaultModel - : answers.EmbeddingModel.Trim(), - }; - - // 🚨 The warning that is the whole reason this step exists. Without an embedder every write - // stores a NULL embedding and the vector provider contributes nothing — search silently - // degrades to lexical, with no error, no log line and no way for the operator to tell. - if (embeddings is null) - warnings.Add(strings.WarnNoEmbeddings); - - var unknownModules = answers.BootModules - .Where(entry => !catalog.Modules.Any( - m => string.Equals(m.Entry, entry, StringComparison.OrdinalIgnoreCase))) - .ToImmutableList(); - foreach (var entry in unknownModules) - problems.Add(strings.ProblemUnknownModule(entry)); - - if (problems.Count > 0) - return new SetupPlan(null, problems.ToImmutable(), warnings.ToImmutable()); - - var manifest = new InstanceManifest - { - State = InstanceSetupState.Complete, - Storage = new InstanceStorageSelection - { - Type = storageOption!.Type, - // 🚨 Encrypted, because a connection string carries a password. It sits in the same - // file as the sign-in secrets and provider keys, and leaving this one readable while - // protecting those was an inconsistency, not a decision — see the projection. - ConnectionString = Blank(answers.ConnectionString) is { } cs - ? Protect(cs, protector, storageOption.DisplayName, strings, problems) - : null, - BasePath = Blank(answers.BasePath), - }, - BootModules = answers.BootModules, - // 🚨 The chosen backend's package rides along automatically. An operator who picks a - // database from the plugin list has chosen that plugin, and a manifest that recorded - // the backend without its package would configure a store whose plugin never installs. - ProvisionPackages = ResolvePackages(answers, catalog, storageOption), - UserPreInstallPackages = InstanceSetupDefaults.UserPreInstallPackages, - SignIn = new InstanceSignInSelection - { - EnableDevLogin = answers.EnableDevLogin, - DevAdminUsers = Blank(answers.DevAdminUsers), - Providers = signInProviders.ToImmutable(), - }, - Ai = new InstanceAiSelection - { - Providers = aiProviders.ToImmutable(), - Embeddings = embeddings, - }, - SetUpBy = Blank(answers.SetUpBy), - SetUpAt = now, - }; - - return new SetupPlan(manifest, [], warnings.ToImmutable()); - } - - /// - /// What this instance provisions at first boot. - /// - /// Three rules, in order: - /// - /// The chosen backend's package rides along automatically — an operator who picks a - /// database from the plugin list has chosen that plugin, and a manifest naming the backend - /// without its package would configure a store whose plugin never installs. - /// Whatever else the operator ticked. - /// 🚨 …and when the registry OFFERED NOTHING — an unreachable registry, or a plan that - /// lists nothing — fall back to the first-party pattern, so "next, next, finish" still produces - /// a working instance. When packages WERE offered and none were ticked, that is an answer: - /// install nothing. The distinction matters because the fallback is a default, not a policy; - /// applying it over an explicit empty choice would install packages the operator declined. - /// - /// - private static ImmutableList ResolvePackages( - SetupAnswers answers, SetupCatalog catalog, SetupStorageOption? storageOption) - { - var chosen = answers.ProvisionPackages; - if (storageOption?.PackageId is { } storagePackage - && !chosen.Any(p => string.Equals(p, storagePackage, StringComparison.OrdinalIgnoreCase))) - chosen = chosen.Add(storagePackage); - - return chosen.IsEmpty && catalog.Packages.IsEmpty - ? InstanceSetupDefaults.ProvisionPackages - : chosen; - } - - /// - /// Encrypts a secret, or records why it could not be. Never returns the plaintext: a manifest - /// carrying an unprotected credential is the defect ProviderKeyProtector throws to - /// prevent, and the setup surface has a master key by construction — so reaching this refusal - /// means the install could not provision one, which the operator must be told. - /// - private static string? Protect( - string? plaintext, IProviderKeyProtector? protector, string what, - SetupStrings strings, ImmutableList.Builder problems) - { - if (protector is null) - { - problems.Add(strings.ProblemNoMasterKey(what)); - return null; - } - try - { - return protector.Protect(plaintext?.Trim()); - } - catch (InvalidOperationException) - { - // The protector's own refusal, which already names the setting. Never echo the value. - problems.Add(strings.ProblemNoMasterKey(what)); - return null; - } - } - - private static string? Blank(string? value) => - string.IsNullOrWhiteSpace(value) ? null : value.Trim(); -} diff --git a/memex/Memex.Portal.Shared/Setup/SetupConsentDocuments.cs b/memex/Memex.Portal.Shared/Setup/SetupConsentDocuments.cs deleted file mode 100644 index b2874f70cb..0000000000 --- a/memex/Memex.Portal.Shared/Setup/SetupConsentDocuments.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace Memex.Portal.Shared.Setup; - -/// -/// The privacy statement and platform terms the first-run wizard shows, and the hashes that pin -/// WHICH text a person accepted. -/// -/// 🚨 The hash is the evidence, not the boolean. A consent record saying "accepted" -/// cannot answer the only question that matters later — accepted WHAT? These hashes travel with the -/// registration and are stored beside it, so a document that changes afterwards is visibly a -/// different document rather than silently the same one. -/// -/// The URLs are where the full documents live; the wizard links them rather than reproducing -/// them, because a setup page that inlines a licence is a page nobody reads. The SUMMARY is what it -/// shows inline — short enough to be read, specific enough to be honest about what registering -/// does. -/// -public static class SetupConsentDocuments -{ - /// Where the privacy statement lives. - public const string PrivacyStatementUrl = "https://memex.meshweaver.cloud/Doc/Legal/Privacy"; - - /// Where the platform terms live. - public const string TermsUrl = "https://memex.meshweaver.cloud/Doc/Legal/Terms"; - - /// - /// The version marker the hashes are taken over. - /// - /// Hashing a marker rather than fetching the live documents is deliberate: the wizard runs - /// before this instance has storage, often before it has general internet access, and a consent - /// step that cannot complete offline would block setup on a network fetch. Bumping this string - /// is what makes previously recorded consent visibly older than the current documents. - /// - private const string PrivacyStatementVersion = "privacy/2026-09-08"; - - /// The terms version the hash is taken over. See . - private const string TermsVersion = "terms/2026-09-08"; - - /// Hash of the privacy statement version this build shows. - public static string PrivacyStatementHash { get; } = Sha256(PrivacyStatementVersion); - - /// Hash of the terms version this build shows. - public static string TermsHash { get; } = Sha256(TermsVersion); - - private static string Sha256(string value) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); -} diff --git a/memex/Memex.Portal.Shared/Setup/SetupEndpoints.cs b/memex/Memex.Portal.Shared/Setup/SetupEndpoints.cs deleted file mode 100644 index b1302f030a..0000000000 --- a/memex/Memex.Portal.Shared/Setup/SetupEndpoints.cs +++ /dev/null @@ -1,510 +0,0 @@ -using System.Collections.Immutable; -using MeshWeaver.AI; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Setup; - -/// -/// Serves the first-run wizard — the production reader of MeshBuilder.IsAwaitingSetup that -/// its own doc comment has always demanded: "a host that reads this true must serve the SETUP -/// surface and nothing else". -/// -/// And nothing else, literally. While the instance is awaiting setup every request -/// that is not the wizard or a probe is redirected to it. That is not tidiness: with no storage -/// there are no hubs, so a page render, a sign-in callback or an API call reaches code whose -/// dependencies do not exist, and the failures are unhelpful in proportion to how far they get. -/// One surface, one answer. -/// -public static class SetupEndpoints -{ - /// The wizard's path. - public const string Path = "/setup"; - - /// - /// Paths that must keep working while the instance is in setup: the liveness probe, so an - /// orchestrator does not kill the very pod an operator is configuring. - /// - /// 🚨 /healthz answering 200 here is correct and deliberate. An instance - /// awaiting setup is not FAILING — it is waiting for a person, and a probe that reported it - /// unhealthy would restart it in a loop, minting a new setup token every time and making the - /// wizard unreachable in practice. - /// - private static readonly ImmutableHashSet AlwaysAllowed = - ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, - "/healthz", "/alive", "/ready", "/health"); - - /// - /// Maps the wizard and, while , the - /// short-circuit that sends everything else to it. - /// - /// Call EARLY in the pipeline — before authentication, before the Blazor router. A - /// configured instance pays one delegate that answers false and forwards. - /// - /// The application to map onto. - /// - /// True when this instance is awaiting setup and the wizard has taken the pipeline over — in - /// which case the caller must run the app IMMEDIATELY and map nothing else. - /// - /// 🚨 A bool, because "and nothing else" is the caller's job to honour and it cannot - /// be enforced from here. Mapping the rest of a portal on an instance with no storage is - /// not merely wasteful, it FAILS: MapMeshWeaver asserts that a permission evaluator is - /// registered, and on a setup-mode host ConfigureMemexMesh returned before - /// AddRowLevelSecurity ever ran. The host then dies at startup with a security assertion - /// instead of serving the wizard that would fix it — measured, not theorised. - /// - public static bool MapInstanceSetup(this WebApplication app) - { - ArgumentNullException.ThrowIfNull(app); - - var awaiting = app.Services.GetService()?.IsAwaitingSetup ?? false; - if (!awaiting) - return false; - - var token = app.Services.GetRequiredService(); - // 🚨 Console.WriteLine, NOT a logger, and that is the whole point. The token is the ONLY - // way into this surface, and it was written with LogInformation under the category - // `Memex.Portal.Shared.Setup.SetupEndpoints` — which every deployment filters to Warning. - // So on a real cluster the banner never appeared: the wizard was serving, the token existed, - // and nobody could learn it. A credential whose delivery a log level can suppress is not - // delivered. stdout is what `kubectl logs` and `docker logs` show regardless of - // configuration, and it is already how the hand-over banner reaches the operator. - Console.WriteLine(token.ConsoleBanner(app.Urls.FirstOrDefault())); - - app.Use((ctx, next) => - { - var path = ctx.Request.Path.Value ?? "/"; - if (path.StartsWith(Path, StringComparison.OrdinalIgnoreCase) || AlwaysAllowed.Contains(path)) - return next(); - ctx.Response.Redirect(Path); - return Task.CompletedTask; - }); - - // PHASE ONE while the manifest carries no identity, PHASE TWO once it does. The manifest - // is the state between them — a half-answered one keeping the instance in setup is exactly - // what InstanceSetupState.AwaitingModules is for, so no session, cookie or in-memory step - // counter is needed (and none would survive the restart-shaped lifecycle anyway). - app.MapGet(Path, async (HttpContext ctx) => - { - var strings = StringsFor(ctx); - var identity = IdentityOf(ctx); - if (identity is not { IsRegistered: true }) - return Html(SetupPage.RenderIdentity( - strings, MintInstanceId(), DefaultRegistry(ctx), - token: ctx.Request.Query["token"]), StatusCodes.Status200OK); - - return Html(SetupPage.Render( - await CatalogFor(ctx, identity), strings, token: ctx.Request.Query["token"]), - StatusCodes.Status200OK); - }); - - app.MapPost(Path + "/identity", (HttpContext ctx, IFormCollection form) => - RegisterIdentity(ctx, form, token)).DisableAntiforgery(); - - // 🚨 DisableAntiforgery is deliberate, and the setup token is what replaces it. Binding - // IFormCollection makes ASP.NET demand the antiforgery middleware; adding a SECOND - // unguessable form value would be the synchronizer-token pattern implemented twice, since - // `token` already IS one — it is required, per-process, high-entropy, and readable only - // from this instance's own console. A cross-site POST cannot carry it, which is precisely - // the property antiforgery exists to provide. The alternative — wiring UseAntiforgery into - // a host that has no storage, no session and no data protection keys yet — adds moving - // parts to the one surface that has to work when nothing else does. - app.MapPost(Path, (HttpContext ctx, IFormCollection form) => Apply(ctx, form, token)) - .DisableAntiforgery(); - - return true; - } - - /// - /// PHASE ONE — claim the id, register with the registry, and record the result so phase two can - /// list what this instance is entitled to. - /// - /// 🚨 The issued key is persisted BEFORE anything else can fail. The registry - /// returns it exactly once and never re-issues it, and the id cannot be re-registered after - /// deletion — so a crash between "registered" and "wrote it down" permanently burns that id. - /// The manifest write is therefore the very next thing after a successful response, ahead of - /// listing packages or rendering anything. - /// - private static async Task RegisterIdentity( - HttpContext ctx, IFormCollection form, SetupAccessToken token) - { - var strings = StringsFor(ctx); - var answers = new IdentityAnswers( - Blank(form["identity.name"]), Blank(form["identity.id"]), - Blank(form["identity.registry"]), Blank(form["identity.key"])) - { - Company = Blank(form["identity.company"]), - OwnerName = Blank(form["identity.ownerName"]), - OwnerEmail = Blank(form["identity.ownerEmail"]), - ConsentAccepted = form.ContainsKey("identity.consent"), - }; - - if (!token.Matches(form["token"])) - return Html(SetupPage.RenderIdentity( - strings, answers.Id ?? MintInstanceId(), DefaultRegistry(ctx), answers, - [strings.TokenInvalid]), StatusCodes.Status403Forbidden); - - var problems = ImmutableList.CreateBuilder(); - if (string.IsNullOrWhiteSpace(answers.Name)) - problems.Add(strings.ProblemNoInstanceName); - // 🚨 Ownership and consent are checked BEFORE the registry call, like the name: an id - // claimed for a submission that was going to be refused anyway is an id burnt for nothing, - // and ids are never re-issued. "Collect the ownership, THEN get the id and credentials" is - // the order the requirement states, and this is where that order is enforced. - if (string.IsNullOrWhiteSpace(answers.Company)) - problems.Add(strings.ProblemNoCompany); - if (string.IsNullOrWhiteSpace(answers.OwnerName)) - problems.Add(strings.ProblemNoOwnerName); - if (!LooksLikeAnEmail(answers.OwnerEmail)) - problems.Add(strings.ProblemNoOwnerEmail); - if (!answers.ConsentAccepted) - problems.Add(strings.ProblemNoConsent); - // The id is minted by us and rendered read-only, so a malformed one means the form was - // hand-crafted. Refuse rather than claim something the registry's alphabet rejects. - var id = string.IsNullOrWhiteSpace(answers.Id) ? MintInstanceId() : answers.Id.Trim(); - if (!InstanceIdRules.IsWellFormed(id)) - problems.Add(strings.ProblemBadInstanceId(id)); - var registry = string.IsNullOrWhiteSpace(answers.RegistryUrl) - ? DefaultRegistry(ctx) : answers.RegistryUrl.Trim(); - if (!Uri.TryCreate(registry, UriKind.Absolute, out _)) - problems.Add(strings.ProblemBadRegistry(registry)); - - if (problems.Count > 0) - return Html(SetupPage.RenderIdentity( - strings, id, DefaultRegistry(ctx), answers, problems.ToImmutable(), form["token"]), - StatusCodes.Status400BadRequest); - - var configuration = ctx.RequestServices.GetRequiredService(); - var root = ModuleRoot.Resolve(configuration); - var logger = ctx.RequestServices.GetRequiredService() - .CreateLogger(typeof(SetupEndpoints)); - - var ownership = new InstanceOwnershipSelection - { - Company = answers.Company!.Trim(), - OwnerName = answers.OwnerName!.Trim(), - OwnerEmail = answers.OwnerEmail!.Trim(), - }; - // The documents the operator was shown, by hash — "someone ticked a box" is not evidence. - var consent = new InstanceConsentSelection - { - AcceptedAt = DateTimeOffset.UtcNow, - PrivacyStatementHash = SetupConsentDocuments.PrivacyStatementHash, - TermsHash = SetupConsentDocuments.TermsHash, - }; - - InstanceRegistrationPayloads.Response registration; - try - { - var client = ctx.RequestServices.GetRequiredService(); - registration = await client.RegisterAsync( - registry, id, answers.Name!.Trim(), answers.BootstrapKey, - homeUrl: $"{ctx.Request.Scheme}://{ctx.Request.Host}", - ownership: ownership, consent: consent, cancellationToken: ctx.RequestAborted); - } - catch (SetupRegistryException ex) - { - logger.LogWarning("[InstanceSetup] registration refused: {Reason}", ex.Message); - return Html(SetupPage.RenderIdentity( - strings, id, DefaultRegistry(ctx), answers, [ex.Message], form["token"]), - StatusCodes.Status400BadRequest); - } - - // Encrypt before persisting, and provision a master key if this install has none — the same - // rule the rest of the wizard obeys: a secret that cannot be encrypted is refused, never - // written in the clear. - string? protectedKey = null; - try - { - var masterKey = InstanceMasterKey.EnsureCreated( - root, configuration[ConfigMasterKeyProvider.ConfigKey]); - protectedKey = new ProviderKeyProtector(new LiteralMasterKeyProvider(masterKey)) - .Protect(registration.InstanceKey); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) - { - // 🚨 The id is ALREADY CLAIMED at this point. Say so loudly: the operator must not - // simply retry with a fresh id and leak another one, they must make the root writable. - logger.LogError(ex, - "[InstanceSetup] registered as {Id} but could NOT protect the issued key under {Root}", - id, root); - return Html(SetupPage.RenderIdentity( - strings, id, DefaultRegistry(ctx), answers, - [strings.ProblemKeyUnstorable(id)], form["token"]), - StatusCodes.Status500InternalServerError); - } - - var manifest = (InstanceManifest.Read(root) ?? InstanceSetupDefaults.Manifest()) with - { - State = InstanceSetupState.AwaitingModules, - Identity = new InstanceIdentitySelection - { - Id = id, - Name = answers.Name!.Trim(), - RegistryUrl = registry, - InstanceKey = protectedKey, - Plan = registration.Plan, - Ownership = ownership, - Consent = consent, - }, - }; - manifest.Write(root); - logger.LogInformation( - "[InstanceSetup] registered as {Id} ({Name}) at {Registry} on plan {Plan}", - id, manifest.Identity!.Name, registry, registration.Plan ?? "(unstated)"); - - return Results.Redirect($"{Path}?token={Uri.EscapeDataString(form["token"].ToString())}"); - } - - /// - /// Whether a value is plausibly an email address. - /// - /// Deliberately shallow — one @ with something either side and a dot in the domain. - /// The registry is what actually reaches the owner, so a stricter local rule would only refuse - /// addresses that work; this catches the typo and the empty box, which is what it is for. - /// - private static bool LooksLikeAnEmail(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return false; - var at = value.Trim().IndexOf('@'); - if (at <= 0 || at == value.Trim().Length - 1) return false; - var domain = value.Trim()[(at + 1)..]; - return domain.Contains('.') && !domain.StartsWith('.') && !domain.EndsWith('.') - && !value.Trim()[..at].Contains('@') && !domain.Contains('@'); - } - - /// The identity this instance has already registered, or null. - private static InstanceIdentitySelection? IdentityOf(HttpContext ctx) - { - var configuration = ctx.RequestServices.GetRequiredService(); - return InstanceManifest.Read(ModuleRoot.Resolve(configuration))?.Identity; - } - - /// - /// A fresh instance id. A guid, because the id is claimed GLOBALLY and never re-issued — it - /// must not be something a person picks and might re-pick, and it must not collide with another - /// installation set up the same afternoon. Lowercase, which the registry's alphabet requires. - /// - private static string MintInstanceId() => Guid.NewGuid().ToString("d").ToLowerInvariant(); - - /// The registry to pre-fill, from configuration when the deployment states one. - private static string DefaultRegistry(HttpContext ctx) => - Blank(ctx.RequestServices.GetRequiredService()["PluginCatalog:RegistryUrl"]) - ?? "https://memex.meshweaver.cloud"; - - /// - /// Validates the submission, writes the manifest, and stops the process so it restarts - /// configured. - /// - /// 🚨 The restart is the delivery mechanism, not an afterthought. Storage adapters, - /// authentication schemes and module assemblies are all bound once while the host is BUILT; - /// nothing in this platform re-registers an authentication scheme or opens a second storage - /// backend after builder.Build(). So the honest artifact of this form is a written - /// manifest plus a restart, and saying so is what SignInSetupPlan already refuses to lie - /// about. - /// - private static async Task Apply(HttpContext ctx, IFormCollection form, SetupAccessToken token) - { - var strings = StringsFor(ctx); - var identity = IdentityOf(ctx); - // Phase two cannot be submitted before phase one: the options it offers are derived from - // what the registration entitles this instance to. - if (identity is not { IsRegistered: true }) - return Results.Redirect(Path); - var catalog = await CatalogFor(ctx, identity); - - if (!token.Matches(form["token"])) - // Deliberately NOT re-rendering the submitted answers: a wrong token means this is - // plausibly not the operator, and echoing back what was typed helps nobody legitimate. - return Html(SetupPage.Render(catalog, strings, problems: [strings.TokenInvalid]), StatusCodes.Status403Forbidden); - - var answers = ReadAnswers(form, catalog); - var configuration = ctx.RequestServices.GetRequiredService(); - var root = ModuleRoot.Resolve(configuration); - var logger = ctx.RequestServices.GetRequiredService() - .CreateLogger(typeof(SetupEndpoints)); - - // Provision the master key BEFORE composing, because composing encrypts. A deployment that - // supplied its own key keeps it — EnsureCreated never overwrites. - IProviderKeyProtector? protector = null; - try - { - var masterKey = InstanceMasterKey.EnsureCreated( - root, configuration[ConfigMasterKeyProvider.ConfigKey]); - protector = new ProviderKeyProtector(new LiteralMasterKeyProvider(masterKey)); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - // Composition turns a null protector into a refusal that names the setting and the - // writable-directory requirement — the operator's two ways out. - logger.LogError(ex, "Could not provision a key-protection master key under {Root}", root); - } - - var plan = SetupComposition.Compose( - answers, catalog, ctx.RequestServices.GetService() ?? StorageBackendCatalog.Empty, - protector, strings, DateTimeOffset.UtcNow); - - if (plan.Manifest is null) - return Html( - SetupPage.Render(catalog, strings, answers, plan.Problems, plan.Warnings, form["token"]), - StatusCodes.Status400BadRequest); - - // The identity was written in phase one and must survive completion — losing it would - // strand the issued key, which the registry never re-issues. - var completed = plan.Manifest with { Identity = identity }; - completed.Write(root); - logger.LogInformation( - "[InstanceSetup] manifest written to {Path}: storage {Storage}, {SignIn} sign-in route(s), " - + "{Ai} model provider(s), embeddings {Embeddings}. Restarting.", - InstanceManifest.PathFor(root), - completed.Storage?.Type, - completed.SignIn?.Providers.Count ?? 0, - completed.Ai?.Providers.Count ?? 0, - completed.Ai?.Embeddings is { IsConfigured: true } ? "configured" : "none"); - - // Stop only AFTER the response is on the wire, or the operator's browser reports a - // connection reset and they cannot tell a successful install from a crash. - var lifetime = ctx.RequestServices.GetRequiredService(); - ctx.Response.OnCompleted(() => - { - lifetime.StopApplication(); - return Task.CompletedTask; - }); - return Html(SetupPage.RenderDone(strings), StatusCodes.Status200OK); - } - - /// - /// Reads the form into answers. Only fields the catalog OFFERS are read, so a hand-crafted POST - /// cannot introduce a provider or module the image never advertised — the composition refuses - /// those too, and refusing twice is the correct amount for a surface that writes credentials. - /// - private static SetupAnswers ReadAnswers(IFormCollection form, SetupCatalog catalog) => - new() - { - StorageType = form["storage.type"].ToString(), - ConnectionString = Blank(form["storage.connectionString"]), - BasePath = Blank(form["storage.basePath"]), - EnableDevLogin = form.ContainsKey("signin.dev"), - DevAdminUsers = Blank(form["signin.devAdmins"]), - SignIn = - [ - .. catalog.SignIn.Where(o => !o.IsSwitch).Select(o => new SetupSignInAnswer( - o.Name, - Blank(form[$"signin.{o.Name}.clientId"]), - Blank(form[$"signin.{o.Name}.tenantId"]), - Blank(form[$"signin.{o.Name}.clientSecret"]))), - ], - Ai = - [ - .. catalog.Ai.Select(o => new SetupAiAnswer( - o.Name, - Blank(form[$"ai.{o.Name}.apiKey"]), - Blank(form[$"ai.{o.Name}.endpoint"]))), - ], - EmbeddingEndpoint = Blank(form["embedding.endpoint"]), - EmbeddingModel = Blank(form["embedding.model"]), - BootModules = [.. form["modules"].Select(v => v?.Trim()).OfType().Where(v => v.Length > 0)], - ProvisionPackages = - [ - .. form["packages"].Select(v => v?.Trim()).OfType().Where(v => v.Length > 0), - ], - }; - - private static SetupCatalog Catalog(HttpContext ctx) => - ctx.RequestServices.GetService()?.Describe() ?? SetupCatalog.Empty; - - /// - /// Phase two's catalog: what the IMAGE offers, plus what the REGISTRY says this instance is - /// entitled to. - /// - /// 🚨 A package that declares a StorageType becomes a database option ONLY when the - /// image can already open that backend. Landing a storage module the image lacks would have to - /// happen before persistence selection reads Graph:Storage, and package provisioning runs - /// after the mesh is up — so offering one would record a backend that never resolves and fail at - /// the NEXT boot, with the wizard gone. The package still appears in the plugin list; it just - /// does not pretend to answer the database question. - /// - /// A registry that cannot be reached is REPORTED, not swallowed. An instance that - /// registered but cannot see its plan is a state worth naming — an empty list would read as - /// "your plan includes nothing". - /// - // An HTTP endpoint helper, not hub-reachable code: the registry call is awaited on the request's - // own continuation, exactly like RegisterIdentity above. It used to block the request thread on - // GetAwaiter().GetResult(), which the ObservableToTaskBridgeGuard refuses in production code — - // a parked thread on a turn-based scheduler is a self-deadlock, and no timeout aborts it. - private static async Task CatalogFor(HttpContext ctx, InstanceIdentitySelection identity) - { - var catalog = Catalog(ctx) with { Identity = identity }; - var configuration = ctx.RequestServices.GetRequiredService(); - var root = ModuleRoot.Resolve(configuration); - - var masterKey = InstanceMasterKey.Resolve(root, configuration[ConfigMasterKeyProvider.ConfigKey]); - var key = masterKey is null || identity.InstanceKey is null - ? null - : new ProviderKeyProtector(new LiteralMasterKeyProvider(masterKey)).Unprotect(identity.InstanceKey); - - ImmutableList packages; - try - { - packages = await ctx.RequestServices.GetRequiredService() - .ListPackagesAsync(identity.RegistryUrl, key, ctx.RequestAborted); - } - catch (SetupRegistryException ex) - { - return catalog with { RegistryProblem = ex.Message }; - } - - var offered = packages - .Where(p => !string.IsNullOrWhiteSpace(p.Id)) - .Select(p => new SetupPackageOption( - p.Id, string.IsNullOrWhiteSpace(p.Name) ? p.Id : p.Name!, p.Description, - p.StorageType, PreSelected: false)) - .ToImmutableList(); - - // Only backends the image can actually open — see the remarks. - var openable = ctx.RequestServices.GetService() ?? StorageBackendCatalog.Empty; - var fromPackages = offered - .Where(p => p.StorageType is { } t && openable.Offers(t)) - .Where(p => !catalog.Storage.Any(o => string.Equals(o.Type, p.StorageType, StringComparison.OrdinalIgnoreCase))) - .Select(p => new SetupStorageOption( - p.StorageType!, p.Name, NeedsConnectionString: true) { PackageId = p.Id }) - .ToImmutableList(); - - return catalog with - { - Packages = offered, - Storage = catalog.Storage.AddRange(fromPackages), - }; - } - - /// - /// The viewer's locale, read EXPLICITLY from Accept-Language. - /// - /// 🚨 Not CultureInfo.CurrentUICulture, which is banned platform-wide: an ambient - /// culture on a shared thread pool is whatever the last request left there. There is no - /// AccessContext yet — that needs a mesh — so the header is the honest source, and it is - /// passed down as an argument rather than read again anywhere else. - /// - private static SetupStrings StringsFor(HttpContext ctx) - { - var header = ctx.Request.Headers.AcceptLanguage.ToString(); - // "de-CH,de;q=0.9,en;q=0.8" → "de-CH" → Locales.Resolve narrows it to a supported language. - var first = header.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(part => part.Split(';')[0].Trim()) - .FirstOrDefault(part => part.Length > 0 && part != "*"); - return new SetupStrings(first); - } - - private static IResult Html(string html, int status) => - Results.Content(html, "text/html; charset=utf-8", statusCode: status); - - private static string? Blank(string? value) => - string.IsNullOrWhiteSpace(value) ? null : value.Trim(); -} diff --git a/memex/Memex.Portal.Shared/Setup/SetupOnlyHost.cs b/memex/Memex.Portal.Shared/Setup/SetupOnlyHost.cs deleted file mode 100644 index cb76e36b07..0000000000 --- a/memex/Memex.Portal.Shared/Setup/SetupOnlyHost.cs +++ /dev/null @@ -1,154 +0,0 @@ -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace Memex.Portal.Shared.Setup; - -/// -/// The whole host an instance runs when it has no database — a web application that serves the -/// first-run wizard and nothing else. -/// -/// 🚨 A SEPARATE host, not a branch inside the portal's pipeline, and the reason is -/// measured rather than aesthetic. Two attempts to serve the wizard from inside the ordinary -/// startup both died before reaching it: -/// -/// MapMeshWeaver asserts that a permission evaluator is registered — and on a -/// setup-mode host ConfigureMemexMesh returned before AddRowLevelSecurity ever ran, so -/// the process exited with a security assertion. -/// Past that, EventSubscriptionRunner — an IHostedService a module registers -/// during InstallAssemblies, which happens BEFORE the storage decision — could not resolve -/// IMeshService, and the host failed to start. -/// -/// Both are the same fact seen twice: a portal's startup assumes a configured mesh from very -/// early on, and an instance awaiting setup does not have one. Every fix that keeps the ordinary -/// pipeline is a game of whack-a-mole against every service any module might register. Not building -/// the mesh host at all has no such tail. -/// -/// The decision is made from configuration alone, before any mesh is composed — which -/// it can be, because the manifest has already been layered into configuration by then. That is the -/// same input MemexConfiguration uses for MarkAwaitingSetup, so the two cannot -/// disagree about whether this instance is configured. -/// -public static class SetupOnlyHost -{ - /// - /// Whether this instance has no storage and must therefore be set up. - /// - /// 🚨 Decided on the RAW Graph:Storage:Type key, never on the bound record. - /// GraphStorageConfig.Type carries the initializer FileSystem, so binding a - /// section that EXISTS but names no type yields a working-looking file-system configuration — - /// and that section legitimately exists for other reasons (the deployed image states - /// UnanchoredQueryPolicy there deliberately). Binding would read "configured" off a - /// query policy, put the instance on container-ephemeral disk, and make this wizard - /// unreachable. Blank counts as absent too: an environment variable cannot be null, only - /// empty. - /// - /// The host's configuration, with the instance manifest already - /// layered in (see ). - public static bool IsAwaitingSetup(IConfiguration configuration) - { - ArgumentNullException.ThrowIfNull(configuration); - // 🚨 The RAW key, never the bound record. `GraphStorageConfig.Type` carries the initializer - // "FileSystem", so BINDING a section that exists but names no Type yields a working-looking - // file-system store — and the section legitimately exists for other reasons: the deployed - // image states Graph:Storage:UnanchoredQueryPolicy there so no chart or environment can - // forget it. Binding would therefore read "configured" off a section whose only content is - // a query policy, make the wizard unreachable again, and point a real instance at - // container-ephemeral disk. Absent and blank are both "no storage stated". - return string.IsNullOrWhiteSpace( - configuration[$"{InstanceManifestProjection.StorageSection}:Type"]); - } - - /// - /// Runs the setup-only host when this instance is awaiting setup, and answers whether it did. - /// - /// Call this FIRST in a host's Program.cs — before any mesh configuration, before - /// module installation, before the portal's own service registration — and return immediately - /// when it answers true. The call BLOCKS for the lifetime of the wizard: the operator submits - /// the form, the manifest is written, and the process stops so its supervisor restarts it into a - /// configured mesh. - /// - /// The application builder, with configuration already loaded. - /// Supplies what this image can offer. Invoked only in setup mode, so a - /// host pays nothing for it when configured. - /// Registers the keyed IStorageAdapterFactory services - /// this image ships, so the wizard offers exactly the backends it can actually open. Modules are - /// NOT installed in setup mode, so a module-supplied backend is deliberately not on offer: an - /// instance cannot be set up onto a backend whose assembly it has not got. - /// True when the wizard ran (and has now finished); false when this instance is - /// configured and the caller should carry on with its ordinary startup. - public static bool TryRun( - WebApplicationBuilder builder, - Func catalog, - Action registerBackends) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(catalog); - ArgumentNullException.ThrowIfNull(registerBackends); - - if (!IsAwaitingSetup(builder.Configuration)) - return false; - - registerBackends(builder.Services); - builder.Services.TryAddSingleton(sp => StorageBackendCatalog.Discover(builder.Services)); - builder.Services.TryAddSingleton(); - // The registry client: plain HTTP, no mesh. A named client so a host that configures - // resilience can attribute a boot-time registry timeout to this call path rather than to an - // anonymous shared pipeline. - builder.Services.AddHttpClient(client => - // A setup form is a PAGE. A multi-minute budget here is not resilience, it is a hang - // with nobody to tell — the operator is watching a spinner on the only surface the - // instance serves. - client.Timeout = TimeSpan.FromSeconds(30)); - builder.Services.TryAddSingleton(catalog); - // The status the surface itself gates on. Constant here — this host exists only in the - // awaiting-setup state, and re-deriving the answer a second time is how two code paths - // start disagreeing about whether an instance is configured. - builder.Services.TryAddSingleton(new InstanceSetupStatusAccessor(static () => true)); - - var app = builder.Build(); - // The probes FIRST, so an orchestrator does not kill the very pod being configured. An - // instance awaiting setup is not failing; it is waiting for a person. - // - // 🚨 ALL of them, and this is not defensive breadth — it is the chart's actual contract. - // The deployment probes /health (startup), /ready (readiness) and /alive (liveness); only - // /healthz was mapped here, so every probe 404-ed, the pod never went READY, the previous - // replica kept the traffic, and the wizard was unreachable through the ingress. The portal - // was serving it perfectly the whole time and no one could get to it. Measured on a real - // cluster, 2026-09-03 — SetupProbeEndpointsTest pins the set against the chart. - foreach (var probe in ProbePaths) - app.MapGet(probe, () => Results.Text("ok")); - app.MapInstanceSetup(); - app.Run(); - return true; - } - - /// - /// Every path the deployment may probe while this instance waits to be set up. - /// - /// 🚨 These are the CHART's paths, not a guess: deploy/helm gives the portal a - /// startup probe on /health, a readiness probe on /ready and a liveness probe on - /// /alive, and the ASP.NET service defaults add /healthz. A path missing here is - /// a probe that 404s, a pod that never reports READY, and a wizard nobody can reach — the - /// failure is total and it is silent, because the portal itself is working. - /// SetupProbeEndpointsTest reads the chart and holds this list to it, which is how - /// /ready arrived here in the same change that put it in the chart (#3330). - /// - public static IReadOnlyList ProbePaths { get; } = ["/healthz", "/health", "/alive", "/ready"]; - - /// - /// The message a host logs when it hands over to the wizard, so the reason appears in the log - /// even when the operator only ever looks at the pod's output. - /// - /// The writable root the manifest will be written to. - public static string HandOverBanner(string rootDirectory) => - $"This instance has no Graph:Storage configuration and no completed setup manifest at " - + $"{InstanceManifest.PathFor(rootDirectory)}. Serving the FIRST-RUN SETUP wizard and " - + "nothing else — the mesh is deliberately not composed, because a portal's startup assumes " - + "a configured store from very early on."; -} diff --git a/memex/Memex.Portal.Shared/Setup/SetupPage.cs b/memex/Memex.Portal.Shared/Setup/SetupPage.cs deleted file mode 100644 index 094040f67c..0000000000 --- a/memex/Memex.Portal.Shared/Setup/SetupPage.cs +++ /dev/null @@ -1,506 +0,0 @@ -using System.Collections.Immutable; -using System.Net; -using System.Text; - -namespace Memex.Portal.Shared.Setup; - -/// -/// The first-run wizard's page, rendered as self-contained HTML. -/// -/// 🚨 Hand-written markup here is not a breach of "never hand-roll UI" — it is the one -/// place the framework's UI cannot run. Every UiControl, layout area and data binding in -/// this platform resolves through a mesh: hubs, streams, a workspace, a storage adapter. This page -/// is served when there is no storage, therefore no mesh, therefore none of that. The rule exists -/// so that a developer with the framework available uses it; a surface whose whole purpose is to -/// answer "which database?" has it available by definition nowhere. The same reasoning already -/// produced ErrorRoutes' plain error page. -/// -/// No external asset, no script, no font. The instance may be air-gapped, the CDN -/// unreachable and the static pipeline unconfigured — and this is the page that has to work when -/// nothing else does. One document, inline CSS, no JavaScript required for the form to submit. -/// -/// 🚨 Every interpolated value is escaped through . The inputs -/// include a connection string and provider names, and the page re-renders them after a failed -/// submit — an unescaped round trip there is a reflected-XSS hole on the one surface that collects -/// the instance's credentials. -/// -public static class SetupPage -{ - /// Renders the wizard. - /// What the image offers. - /// The viewer's locale. - /// The answers to re-fill after a failed submit, or null on first load. - /// Blocking refusals, shown at the top. - /// Non-blocking notes, shown at the top. - /// The token to pre-fill, when it arrived in the query string. - public static string Render( - SetupCatalog catalog, - SetupStrings strings, - SetupAnswers? submitted = null, - ImmutableList? problems = null, - ImmutableList? warnings = null, - string? token = null) - { - ArgumentNullException.ThrowIfNull(catalog); - ArgumentNullException.ThrowIfNull(strings); - - var html = new StringBuilder(16 * 1024); - Open(html, strings, strings.Title); - - html.Append($"

{Escape(strings.Title)}

"); - html.Append($"

{Escape(strings.Intro)}

"); - - AppendIdentity(html, catalog, strings); - AppendMessages(html, "problems", strings.ProblemsHeading, problems); - AppendMessages(html, "warnings", strings.WarningsHeading, warnings); - if (catalog.RegistryProblem is { } registryProblem) - AppendMessages(html, "warnings", strings.WarningsHeading, [registryProblem]); - - html.Append("
"); - - // ── Token ─────────────────────────────────────────────────────────────────────────── - html.Append("
"); - html.Append($""); - html.Append($""); - html.Append($"

{Escape(strings.TokenHelp)}

"); - html.Append("
"); - - AppendStorage(html, catalog, strings, submitted); - AppendPackages(html, catalog, strings, submitted); - AppendSignIn(html, catalog, strings, submitted); - AppendAi(html, catalog, strings, submitted); - AppendModules(html, catalog, strings, submitted); - - html.Append($""); - html.Append("
"); - Close(html); - return html.ToString(); - } - - /// - /// PHASE ONE — the only question that must be answered before any other can be offered. - /// - /// The operator types a NAME. The id is a guid the instance mints for itself, shown but - /// not editable: it is claimed globally on the registry and can never be re-registered, so it - /// must not be something a person picks and might re-pick. - /// - /// The viewer's locale. - /// The guid this instance minted for itself. - /// The registry to pre-fill. - /// Values to re-fill after a failed submit. - /// Blocking refusals. - /// The setup token, pre-filled from the query string. - public static string RenderIdentity( - SetupStrings strings, - string instanceId, - string defaultRegistry, - IdentityAnswers? submitted = null, - ImmutableList? problems = null, - string? token = null) - { - ArgumentNullException.ThrowIfNull(strings); - - var html = new StringBuilder(8 * 1024); - Open(html, strings, strings.HelloTitle); - html.Append($"

{Escape(strings.HelloTitle)}

"); - html.Append($"

{Escape(strings.HelloIntro)}

"); - AppendMessages(html, "problems", strings.ProblemsHeading, problems); - - html.Append("
"); - html.Append("
"); - html.Append($""); - html.Append($""); - html.Append($"

{Escape(strings.TokenHelp)}

"); - html.Append("
"); - - html.Append($"

{Escape(strings.IdentityHeading)}

"); - html.Append($"

{Escape(strings.IdentityHelp)}

"); - html.Append("
"); - html.Append($""); - html.Append( - $""); - html.Append("
"); - // The id is shown, not asked. Rendered read-only so the operator can see and copy what is - // about to be claimed on their behalf — an id that appears from nowhere after the fact is - // worse than one they watched being minted. - html.Append("
"); - html.Append($""); - html.Append( - $""); - html.Append($"

{Escape(strings.InstanceIdHelp)}

"); - html.Append("
"); - html.Append("
"); - html.Append($""); - html.Append( - $""); - html.Append($"

{Escape(strings.RegistryHelp)}

"); - html.Append("
"); - html.Append("
"); - - // ── Ownership ─────────────────────────────────────────────────────────────────────── - // Collected BEFORE the register call: "collect the ownership, then get the id and - // credentials" is the order the requirement states, and an id is claimed permanently. - html.Append($"

{Escape(strings.OwnershipHeading)}

"); - html.Append($"

{Escape(strings.OwnershipHelp)}

"); - html.Append("
"); - html.Append($""); - html.Append( - $""); - html.Append("
"); - html.Append($""); - html.Append( - $""); - html.Append("
"); - html.Append($""); - html.Append( - $""); - html.Append($"

{Escape(strings.OwnerEmailHelp)}

"); - html.Append("
"); - - // The consent tick is deliberately NOT carried back on a re-render: an acceptance the - // person did not make on THIS submission is not an acceptance. - html.Append(""); - html.Append($"

{Escape(strings.ConsentHelp)}

"); - html.Append("
"); - - html.Append($"

{Escape(strings.RegistryHeading)}

"); - html.Append("
"); - html.Append($""); - html.Append($""); - html.Append($"

{Escape(strings.BootstrapKeyHelp)}

"); - html.Append("
"); - - html.Append($""); - html.Append("
"); - Close(html); - return html.ToString(); - } - - /// The page shown once the manifest is written and the process is stopping. - /// The viewer's locale. - public static string RenderDone(SetupStrings strings) - { - ArgumentNullException.ThrowIfNull(strings); - var html = new StringBuilder(2048); - Open(html, strings, strings.Done); - html.Append($"

{Escape(strings.Done)}

"); - html.Append($"

{Escape(strings.DoneDetail)}

"); - Close(html); - return html.ToString(); - } - - /// The registered identity, stated at the top of phase two so the operator can see - /// what this instance IS before answering anything else. - private static void AppendIdentity(StringBuilder html, SetupCatalog catalog, SetupStrings strings) - { - if (catalog.Identity is not { } identity) - return; - html.Append("
"); - html.Append($"{Escape(identity.Name)}"); - html.Append($"
{Escape(strings.RegisteredAs(identity.Id, identity.RegistryUrl))}
"); - if (!string.IsNullOrWhiteSpace(identity.Plan)) - html.Append($"
{Escape(strings.PlanIs(identity.Plan!))}
"); - html.Append("
"); - } - - /// - /// What this instance's plan entitles it to. Rendered even when EMPTY, with a note saying so — - /// an empty catalog is a legitimate answer, and silently showing nothing would read as a bug. - /// - private static void AppendPackages( - StringBuilder html, SetupCatalog catalog, SetupStrings strings, SetupAnswers? submitted) - { - if (catalog.Identity is null) - return; - html.Append($"

{Escape(strings.PackagesHeading)}

"); - html.Append($"

{Escape(strings.PackagesHelp)}

"); - if (catalog.Packages.IsEmpty) - { - html.Append($"

{Escape(strings.NoPackages)}

"); - return; - } - foreach (var package in catalog.Packages) - { - var on = submitted is null - ? package.PreSelected - : submitted.ProvisionPackages.Any(id => OrdinalEquals(id, package.Id)); - html.Append(""); - } - html.Append(""); - } - - private static void AppendStorage( - StringBuilder html, SetupCatalog catalog, SetupStrings strings, SetupAnswers? submitted) - { - html.Append($"

{Escape(strings.StorageHeading)}

"); - html.Append($"

{Escape(strings.StorageHelp)}

"); - - // The first option is the pre-selected one, and the contributor orders the list. Nothing is - // pre-selected when an answer came back — re-rendering must show what the operator chose. - var chosen = submitted?.StorageType; - var first = true; - foreach (var option in catalog.Storage) - { - var isChecked = chosen is null ? first : OrdinalEquals(chosen, option.Type); - html.Append(""); - first = false; - } - - // Both fields are always present rather than revealed per backend: the page carries no - // JavaScript by design, and a field that is only sometimes in the DOM is a field the form - // sometimes cannot submit. The composition ignores the one the chosen backend does not use. - var hint = catalog.Storage.Select(o => o.ConnectionStringHint).FirstOrDefault(h => h is not null); - html.Append("
"); - html.Append($""); - // 🚨 NEVER echoed back, for exactly the reason a client secret is not: a connection string - // CARRIES A PASSWORD, and re-rendering it after a failed submit puts that password in the - // page source, the browser cache and any screenshot of the form. This was inconsistent — - // the sign-in secrets were already withheld and this one was not (Copilot review). The - // operator retypes it, which is the same price the other credentials already pay. - html.Append( - $""); - html.Append("
"); - html.Append($""); - html.Append( - $""); - html.Append("
"); - } - - private static void AppendSignIn( - StringBuilder html, SetupCatalog catalog, SetupStrings strings, SetupAnswers? submitted) - { - html.Append($"

{Escape(strings.SignInHeading)}

"); - html.Append($"

{Escape(strings.SignInHelp)}

"); - - var devOption = catalog.SignIn.FirstOrDefault(o => o.IsSwitch); - if (devOption is not null) - { - var on = submitted?.EnableDevLogin ?? !devOption.AlreadyConfigured || devOption.AlreadyConfigured; - html.Append(""); - html.Append($"

{Escape(strings.DevLoginHelp)}

"); - html.Append("
"); - html.Append($""); - html.Append( - $""); - html.Append("
"); - } - - foreach (var option in catalog.SignIn.Where(o => !o.IsSwitch)) - { - var answer = submitted?.SignIn.FirstOrDefault(a => OrdinalEquals(a.Name, option.Name)); - html.Append("
"); - html.Append(Escape(option.DisplayName)); - if (option.AlreadyConfigured) - html.Append($" {Escape(strings.AlreadyConfigured)}"); - html.Append(""); - // 🚨 A route this DEPLOYMENT already answers is REPORTED, never offered for editing — - // the fields are disabled, so the browser submits nothing for them. Rendering them - // editable was a surface promising an effect it cannot deliver: the manifest is layered - // UNDER the host's own configuration, so anything typed here would be accepted, stored, - // and then silently outranked at the next boot. That is the shape of MeshWeaver.Plugins#980 - // — a surface showing its own stored answer while the process runs on a different one - // (Copilot review, which caught the doc and the markup disagreeing). - var locked = Disabled(option.AlreadyConfigured); - Field(html, $"signin.{option.Name}.clientId", strings.ClientIdLabel, answer?.ClientId, - extraAttributes: locked); - if (option.HasTenant) - Field(html, $"signin.{option.Name}.tenantId", strings.TenantLabel, answer?.TenantId, - extraAttributes: locked); - // 🚨 A submitted secret is NEVER echoed back into the form. Re-filling a password field - // after a failed submit is convenient and is also how a credential ends up in a browser - // cache, a screenshot and a page source. The operator retypes it. - Field(html, $"signin.{option.Name}.clientSecret", strings.ClientSecretLabel, null, - password: true, extraAttributes: locked); - html.Append("
"); - } - html.Append("
"); - } - - private static void AppendAi( - StringBuilder html, SetupCatalog catalog, SetupStrings strings, SetupAnswers? submitted) - { - html.Append($"

{Escape(strings.AiHeading)}

"); - html.Append($"

{Escape(strings.AiHelp)}

"); - - foreach (var option in catalog.Ai) - { - var answer = submitted?.Ai.FirstOrDefault(a => OrdinalEquals(a.Name, option.Name)); - html.Append($"
{Escape(option.DisplayName)}"); - if (option.RequiresApiKey) - Field(html, $"ai.{option.Name}.apiKey", strings.ApiKeyLabel, null, password: true); - if (option.TakesEndpoint) - // 🚨 The default endpoint is a PLACEHOLDER, never a value. Rendered as a value it - // is submitted whether or not the operator looked at the row, so a provider nobody - // chose arrives configured — measured on the first end-to-end run, where an - // untouched "local / OpenAI-compatible" row wrote itself into the manifest. The - // composition's "no key and no endpoint means not chosen" rule can only hold if an - // untouched field actually submits nothing. - Field(html, $"ai.{option.Name}.endpoint", strings.EndpointLabel, - answer?.Endpoint, placeholder: option.DefaultEndpoint); - html.Append("
"); - } - - html.Append($"

{Escape(strings.EmbeddingHeading)}

"); - html.Append($"

{Escape(strings.EmbeddingHelp)}

"); - Field(html, "embedding.endpoint", strings.EndpointLabel, submitted?.EmbeddingEndpoint); - Field(html, "embedding.model", strings.EmbeddingModelLabel, - submitted?.EmbeddingModel ?? MeshWeaver.Mesh.InstanceEmbeddingsSelection.DefaultModel); - html.Append("
"); - } - - private static void AppendModules( - StringBuilder html, SetupCatalog catalog, SetupStrings strings, SetupAnswers? submitted) - { - html.Append($"

{Escape(strings.ModulesHeading)}

"); - html.Append($"

{Escape(strings.ModulesHelp)}

"); - - foreach (var option in catalog.Modules) - { - var on = submitted is null - ? option.PreSelected - : submitted.BootModules.Any(e => OrdinalEquals(e, option.Entry)); - html.Append(""); - } - - html.Append("
"); - } - - private static void Field( - StringBuilder html, string name, string label, string? value, bool password = false, - string? placeholder = null, string extraAttributes = "") - { - var id = name.Replace('.', '-'); - html.Append("
"); - html.Append($""); - html.Append( - $""); - html.Append("
"); - } - - /// - /// disabled when a route is already answered by this deployment. - /// - /// disabled, not readonly. A disabled control is not submitted at all, so the - /// operator cannot send a value that would be silently outranked; a readonly one still posts - /// its contents. - /// - private static string Disabled(bool locked) => locked ? " disabled" : ""; - - private static void AppendMessages( - StringBuilder html, string cssClass, string heading, ImmutableList? messages) - { - if (messages is not { Count: > 0 }) - return; - html.Append($""); - } - - private static void Open(StringBuilder html, SetupStrings strings, string title) - { - // lang carries the resolved locale so a screen reader and the browser's own translation - // prompt both read the page as what it is. - var lang = MeshWeaver.Messaging.Locales.Resolve(strings.Locale); - html.Append(""); - html.Append(""); - html.Append(""); - html.Append($"{Escape(title)}"); - html.Append("
"); - } - - private static void Close(StringBuilder html) => html.Append("
"); - - private static bool OrdinalEquals(string? a, string? b) => - string.Equals(a?.Trim(), b?.Trim(), StringComparison.OrdinalIgnoreCase); - - /// - /// HTML-escapes a value for an attribute or text node. Null becomes empty. - /// - /// escapes < > & " ', which covers - /// both positions — every attribute in this page is double-quoted. - /// - private static string Escape(string? value) => - string.IsNullOrEmpty(value) ? "" : WebUtility.HtmlEncode(value); - - private const string Css = """ - :root{color-scheme:light dark;--fg:#1b1b1f;--bg:#fbfbfd;--card:#fff;--line:#d8d8e0; - --muted:#5b5b66;--accent:#3b5bdb;--warn:#8a5a00;--warnbg:#fff8e6;--err:#a11;--errbg:#fdeaea} - @media(prefers-color-scheme:dark){:root{--fg:#e8e8ed;--bg:#16161a;--card:#1e1e24; - --line:#33333d;--muted:#a0a0ad;--accent:#8ea3ff;--warn:#e0b050;--warnbg:#2a2312; - --err:#ff9a9a;--errbg:#2c1a1a}} - *{box-sizing:border-box} - body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 system-ui,-apple-system, - "Segoe UI",Roboto,sans-serif} - main{max-width:44rem;margin:0 auto;padding:2.5rem 1.25rem 4rem} - h1{font-size:1.6rem;margin:0 0 .25rem} - h2{font-size:1.1rem;margin:0 0 .35rem} - h3{font-size:1rem;margin:1.25rem 0 .35rem} - .lead{color:var(--muted);margin:0 0 1.75rem} - section{background:var(--card);border:1px solid var(--line);border-radius:10px; - padding:1.25rem;margin-bottom:1.25rem} - .help{color:var(--muted);font-size:.875rem;margin:.3rem 0 .9rem} - .field{margin:0 0 .9rem} - label{display:block;font-weight:600;font-size:.875rem;margin-bottom:.3rem} - input[type=text],input[type=password],textarea{width:100%;padding:.5rem .6rem; - border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg); - font:inherit} - input:focus,textarea:focus{outline:2px solid var(--accent);outline-offset:1px} - .choice{display:flex;gap:.55rem;align-items:baseline;font-weight:400;margin:0 0 .45rem} - .choice input{margin:0} - .choice em{color:var(--muted);font-style:normal;font-size:.875rem} - fieldset{border:1px solid var(--line);border-radius:8px;padding:.9rem;margin:0 0 .9rem} - legend{font-weight:600;font-size:.875rem;padding:0 .35rem} - .badge{font-weight:400;color:var(--muted);font-size:.8rem} - .identity{background:var(--card);border-color:var(--line);color:var(--fg)} - .identity .badge{display:block;margin-top:.2rem;font-family:ui-monospace,SFMono-Regular,monospace} - input[readonly]{color:var(--muted);font-family:ui-monospace,SFMono-Regular,monospace} - .notice{border-radius:8px;padding:.9rem 1.1rem;margin:0 0 1.25rem;border:1px solid} - .notice ul{margin:.4rem 0 0;padding-left:1.1rem} - .problems{background:var(--errbg);border-color:var(--err);color:var(--err)} - .warnings{background:var(--warnbg);border-color:var(--warn);color:var(--warn)} - button{background:var(--accent);color:#fff;border:0;border-radius:7px;padding:.6rem 1.4rem; - font:600 15px/1 inherit;cursor:pointer} - button:hover{filter:brightness(1.08)} - """; -} diff --git a/memex/Memex.Portal.Shared/Setup/SetupRegistryClient.cs b/memex/Memex.Portal.Shared/Setup/SetupRegistryClient.cs deleted file mode 100644 index 75ad2a546b..0000000000 --- a/memex/Memex.Portal.Shared/Setup/SetupRegistryClient.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System.Collections.Immutable; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; -using MeshWeaver.Mesh; -using MeshWeaver.PluginCatalog; - -namespace Memex.Portal.Shared.Setup; - -/// -/// The two registry calls the first-run wizard makes — register this instance, then list what its -/// plan entitles it to — with NO mesh behind them. -/// -/// 🚨 Why not InstanceRegistrationClient / RegistryPackageSource: both -/// take an IMessageHub, resolve an IIoPool from it, and (for registration) persist the -/// issued key as a mesh node. At the moment the wizard runs there is no hub, no pool and no store — -/// choosing the database is the question being asked. So these are plain HTTP, and the key is -/// persisted into the instance manifest instead, which is the pre-storage artifact for exactly this -/// kind of answer. -/// -/// On async: the repo bans it in hub-reachable and Blazor code, where it runs -/// continuations on the wrong scheduler and can park a turn-based actor. Neither exists here — this -/// is a plain ASP.NET request path in a host that deliberately composes no mesh, the same shape as -/// the ExternalAuthController callbacks. There is no Rx pipeline to resume inline and no -/// action block to block. -/// -public sealed class SetupRegistryClient(HttpClient http) -{ - private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); - - /// - /// Registers this instance and returns the id, the issued key and the plan it was enrolled on. - /// - /// 🚨 The key comes back exactly ONCE. The registry never re-issues it, and the id - /// cannot be re-registered after deletion, so a caller that does not persist the result has - /// permanently burnt that id. - /// - /// The registry base URL, e.g. https://memex.meshweaver.cloud. - /// The id to claim — a guid the instance minted for itself. - /// The human-readable name the operator typed. - /// A registration key, when the operator has one. EMPTY is the - /// ordinary case: an un-keyed registration is an OPEN one, which the registry enrols into its - /// default plan (the free tier). - /// Where this instance will be reachable, for the registry's records. - /// Cancels the call. - /// The registration outcome — never null on success. - /// The registry refused or could not be reached, with - /// a message written for the operator reading it on the setup page. - public async Task RegisterAsync( - string registryUrl, string instanceId, string displayName, - string? bootstrapKey = null, string? homeUrl = null, - InstanceOwnershipSelection? ownership = null, - InstanceConsentSelection? consent = null, - CancellationToken cancellationToken = default) - { - var url = Combine(registryUrl, InstanceRegistrationPayloads.Route); - var request = new InstanceRegistrationPayloads.Request( - BootstrapKey: (bootstrapKey ?? "").Trim(), - InstanceId: instanceId, - DisplayName: displayName ?? "", - Description: "", - HomeUrl: homeUrl ?? "") - { - // 🚨 INIT PROPERTIES, never constructor arguments — the record is shared verbatim by - // both sides of the wire, so a positional change is a binary break that aborts a host in - // either roll direction. A registry that predates these fields simply ignores them. - Company = ownership?.Company, - OwnerName = ownership?.OwnerName, - OwnerEmail = ownership?.OwnerEmail, - Consent = consent is null ? null : new InstanceRegistrationPayloads.InstanceConsentEvidence - { - AcceptedAt = consent.AcceptedAt, - AcceptedByName = ownership?.OwnerName, - AcceptedByEmail = ownership?.OwnerEmail, - PrivacyStatementHash = consent.PrivacyStatementHash, - TermsHash = consent.TermsHash, - }, - }; - - HttpResponseMessage response; - try - { - response = await http.PostAsJsonAsync(url, request, Json, cancellationToken); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) - { - // The operator can fix a wrong URL or an offline registry; say which one it is rather - // than surfacing a transport exception on a setup form. - throw new SetupRegistryException( - $"Could not reach the registry at {registryUrl}. Check the address and that this " - + $"machine can reach it ({ex.GetType().Name}).", ex); - } - - if (!response.IsSuccessStatusCode) - throw new SetupRegistryException(await DescribeFailureAsync(response, registryUrl, cancellationToken)); - - var result = await response.Content.ReadFromJsonAsync( - Json, cancellationToken); - if (result is null || string.IsNullOrWhiteSpace(result.InstanceKey)) - // A 200 with no key is not a success: the instance would believe it had registered and - // then be unable to fetch anything, with the id already claimed. - throw new SetupRegistryException( - $"The registry at {registryUrl} accepted the registration but returned no instance " - + "key. The id may now be claimed; choose a different one or contact the registry's " - + "administrator."); - return result; - } - - /// - /// Lists the packages this instance is entitled to, using the key it was just issued. - /// - /// An empty list is a legitimate answer — a plan may grant nothing — and is NOT treated as - /// a failure: the wizard says so and offers what the image itself ships. - /// - /// The registry base URL. - /// The mwi_ key, in the clear. - /// Cancels the call. - public async Task> ListPackagesAsync( - string registryUrl, string? instanceKey, CancellationToken cancellationToken = default) - { - var url = Combine(registryUrl, RegistryPackageSource.RoutePrefix); - using var request = new HttpRequestMessage(HttpMethod.Get, url); - // Per-REQUEST, never on the client: the same HttpClient may serve another registry, and a - // default header would leak this instance's key to it. - if (!string.IsNullOrWhiteSpace(instanceKey)) - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", instanceKey.Trim()); - - HttpResponseMessage response; - try - { - response = await http.SendAsync(request, cancellationToken); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) - { - throw new SetupRegistryException( - $"Could not reach the registry at {registryUrl} to list packages " - + $"({ex.GetType().Name}).", ex); - } - - if (!response.IsSuccessStatusCode) - throw new SetupRegistryException(await DescribeFailureAsync(response, registryUrl, cancellationToken)); - - var listing = await response.Content.ReadFromJsonAsync(Json, cancellationToken); - return [.. listing?.Packages ?? []]; - } - - /// The listing shape, mirroring RegistryPackageSource's private one. - private sealed record ListResponse(IReadOnlyList? Packages); - - /// - /// A refusal in the operator's terms. The status alone is not actionable on a setup form, and - /// the three that actually happen each have a different fix. - /// - private static async Task DescribeFailureAsync( - HttpResponseMessage response, string registryUrl, CancellationToken cancellationToken) - { - var body = ""; - try { body = (await response.Content.ReadAsStringAsync(cancellationToken)).Trim(); } - catch { /* the status is the message when the body cannot be read */ } - if (body.Length > 300) body = body[..300] + "…"; - - var reason = (int)response.StatusCode switch - { - 401 or 403 => - "the registry refused the key. An open registration needs no key at all — leave it " - + "blank — and a key that was minted for a plan may have been revoked or already used.", - 409 => - "that instance id is already claimed. Ids are global and are never re-issued, even " - + "after a deletion; generate a new one.", - 404 => - $"there is no registry at {registryUrl} — the address answers, but not with a " - + "registry API. Check the URL.", - _ => $"the registry answered {(int)response.StatusCode} {response.ReasonPhrase}.", - }; - return string.IsNullOrEmpty(body) ? reason : $"{reason} ({body})"; - } - - private static string Combine(string baseUrl, string route) => - (baseUrl ?? "").TrimEnd('/') + route; -} - -/// -/// A registry call the operator can act on — the message is written for the setup page, not for a -/// log. -/// -public sealed class SetupRegistryException : Exception -{ - /// Creates the exception with an operator-facing message. - /// What went wrong and what to do about it. - public SetupRegistryException(string message) : base(message) { } - - /// Creates the exception with an operator-facing message and the underlying cause. - /// What went wrong and what to do about it. - /// The transport or serialization failure beneath it. - public SetupRegistryException(string message, Exception inner) : base(message, inner) { } -} diff --git a/memex/Memex.Portal.Shared/Setup/SetupStrings.cs b/memex/Memex.Portal.Shared/Setup/SetupStrings.cs deleted file mode 100644 index ba649b7133..0000000000 --- a/memex/Memex.Portal.Shared/Setup/SetupStrings.cs +++ /dev/null @@ -1,246 +0,0 @@ -using MeshWeaver.Messaging; - -namespace Memex.Portal.Shared.Setup; - -/// -/// Every string the first-run wizard shows, resolved for one viewer's locale. -/// -/// 🚨 The wizard runs before there is a mesh, so it cannot ask -/// AccessContext.Locale — but it still must not hard-code English. -/// is a static, immutable table loaded from embedded resources -/// with no hub, no storage and no DI behind it, so it answers perfectly well pre-storage. The -/// locale comes from the request's Accept-Language header, read EXPLICITLY — which is the -/// opposite of the banned CultureInfo.CurrentUICulture resolution, not an exception to -/// it: the value is passed as an argument and never picked up ambiently from the thread. -/// -/// An instance rather than statics because the locale is per REQUEST. Two operators can be -/// setting up two instances from one host binary; a static "current language" is exactly the shape -/// of bug the ambient-culture ban exists to prevent. -/// -/// The viewer's locale, from Accept-Language. Unknown values resolve to -/// the default through Locales.Resolve. -public sealed class SetupStrings(string? locale) -{ - /// The locale these strings were resolved for. - public string? Locale { get; } = locale; - - /// Looks a key up in this instance's locale. Missing keys surface as the key itself — - /// visible in review, harmless in production. - /// The catalog key. - /// Positional arguments, applied invariantly. - public string Get(string key, params object?[] args) => - LocalizationCatalog.Get(key, Locale, args); - - // ── Page chrome ──────────────────────────────────────────────────────────────────────────── - /// The page title and heading. - public string Title => Get("setup.title"); - /// The one-line explanation under the heading. - public string Intro => Get("setup.intro"); - /// The submit button. - public string Submit => Get("setup.submit"); - /// The heading over the problems list. - public string ProblemsHeading => Get("setup.problems"); - /// The heading over the warnings list. - public string WarningsHeading => Get("setup.warnings"); - /// Shown once the manifest is written and the instance is restarting. - public string Done => Get("setup.done"); - /// Explains that the instance restarts into its configured self. - public string DoneDetail => Get("setup.done.detail"); - - // ── Step: setup token ────────────────────────────────────────────────────────────────────── - /// The access-token field label. - public string TokenLabel => Get("setup.token.label"); - /// Where to find the token. - public string TokenHelp => Get("setup.token.help"); - /// Refusal when the token is absent or wrong. - public string TokenInvalid => Get("setup.token.invalid"); - - // ── Phase one: identity ──────────────────────────────────────────────────────────────────── - /// The hello page's title. - public string HelloTitle => Get("setup.hello.title"); - /// Why the name is asked first. - public string HelloIntro => Get("setup.hello.intro"); - /// The identity section heading. - public string IdentityHeading => Get("setup.identity.heading"); - /// What the name and id are for. - public string IdentityHelp => Get("setup.identity.help"); - /// The instance-name field label. - public string InstanceNameLabel => Get("setup.identity.name"); - /// The instance-name placeholder. - public string InstanceNamePlaceholder => Get("setup.identity.name.placeholder"); - /// The generated-id field label. - public string InstanceIdLabel => Get("setup.identity.id"); - /// Why the id cannot be edited. - public string InstanceIdHelp => Get("setup.identity.id.help"); - /// The registry field label. - public string RegistryLabel => Get("setup.identity.registry"); - /// What the registry is for. - public string RegistryHelp => Get("setup.identity.registry.help"); - /// The optional registration-key field label. - public string BootstrapKeyLabel => Get("setup.identity.key"); - /// When a registration key is needed. - public string BootstrapKeyHelp => Get("setup.identity.key.help"); - /// The phase-one submit button. - public string RegisterAndContinue => Get("setup.identity.submit"); - /// The registered identity, shown in phase two. - /// The claimed instance id. - /// The registry it registered with. - public string RegisteredAs(string id, string registry) => Get("setup.registeredAs", id, registry); - /// The plan the registry enrolled this instance on. - /// The plan name. - public string PlanIs(string plan) => Get("setup.planIs", plan); - - // ── Phase one: ownership + consent ───────────────────────────────────────────────────────── - /// The ownership section heading. - public string OwnershipHeading => Get("setup.ownership.heading"); - /// Why ownership is collected before an id is claimed. - public string OwnershipHelp => Get("setup.ownership.help"); - /// The organisation field label. - public string CompanyLabel => Get("setup.ownership.company"); - /// The owner-name field label. - public string OwnerNameLabel => Get("setup.ownership.ownerName"); - /// The owner-email field label. - public string OwnerEmailLabel => Get("setup.ownership.ownerEmail"); - /// What the email is used for. - public string OwnerEmailHelp => Get("setup.ownership.ownerEmail.help"); - /// The consent checkbox label, which the document links follow. - public string ConsentLabel => Get("setup.ownership.consent"); - /// The privacy statement's link text. - public string PrivacyStatement => Get("setup.ownership.consent.privacy"); - /// The platform terms' link text. - public string PlatformTerms => Get("setup.ownership.consent.terms"); - /// What accepting means. - public string ConsentHelp => Get("setup.ownership.consent.help"); - /// The registry section heading. - public string RegistryHeading => Get("setup.registry.heading"); - - // ── Step: plugins ────────────────────────────────────────────────────────────────────────── - /// The plugins section heading. - public string PackagesHeading => Get("setup.packages.heading"); - /// What the plugin list is. - public string PackagesHelp => Get("setup.packages.help"); - /// Shown when the plan lists nothing — a legitimate answer, said out loud. - public string NoPackages => Get("setup.packages.none"); - /// Marks a package that provides a database backend. - /// The Graph:Storage:Type it provides. - public string ProvidesDatabase(string storageType) => Get("setup.packages.providesDatabase", storageType); - - // ── Step: database ───────────────────────────────────────────────────────────────────────── - /// The database step heading. - public string StorageHeading => Get("setup.storage.heading"); - /// The database step explanation. - public string StorageHelp => Get("setup.storage.help"); - /// The connection-string field label. - public string ConnectionStringLabel => Get("setup.storage.connectionString"); - /// The base-path field label. - public string BasePathLabel => Get("setup.storage.basePath"); - - // ── Step: sign-in ────────────────────────────────────────────────────────────────────────── - /// The sign-in step heading. - public string SignInHeading => Get("setup.signin.heading"); - /// The sign-in step explanation. - public string SignInHelp => Get("setup.signin.help"); - /// The developer-login switch label. - public string DevLoginLabel => Get("setup.signin.devLogin"); - /// The developer-login warning about public instances. - public string DevLoginHelp => Get("setup.signin.devLogin.help"); - /// The platform-admin ids field label. - public string DevAdminsLabel => Get("setup.signin.devAdmins"); - /// The client-id field label. - public string ClientIdLabel => Get("setup.signin.clientId"); - /// The client-secret field label. - public string ClientSecretLabel => Get("setup.signin.clientSecret"); - /// The tenant field label. - public string TenantLabel => Get("setup.signin.tenant"); - /// Marks a route the host's own configuration already answers. - public string AlreadyConfigured => Get("setup.signin.alreadyConfigured"); - - // ── Step: models ─────────────────────────────────────────────────────────────────────────── - /// The models step heading. - public string AiHeading => Get("setup.ai.heading"); - /// The models step explanation. - public string AiHelp => Get("setup.ai.help"); - /// The API-key field label. - public string ApiKeyLabel => Get("setup.ai.apiKey"); - /// The endpoint field label. - public string EndpointLabel => Get("setup.ai.endpoint"); - /// The embeddings sub-heading. - public string EmbeddingHeading => Get("setup.ai.embedding.heading"); - /// Why the embeddings endpoint matters. - public string EmbeddingHelp => Get("setup.ai.embedding.help"); - /// The embedding-model field label. - public string EmbeddingModelLabel => Get("setup.ai.embedding.model"); - - // ── Step: modules ────────────────────────────────────────────────────────────────────────── - /// The modules step heading. - public string ModulesHeading => Get("setup.modules.heading"); - /// The modules step explanation. - public string ModulesHelp => Get("setup.modules.help"); - /// The provisioned-packages field label. - public string PackagesLabel => Get("setup.modules.packages"); - - // ── Refusals ─────────────────────────────────────────────────────────────────────────────── - /// No backend was chosen. - public string ProblemNoStorage => Get("setup.problem.noStorage"); - /// The chosen backend is not one this image ships. - /// What was submitted. - /// The keys this image registered. - public string ProblemUnknownStorage(string chosen, string available) => - Get("setup.problem.unknownStorage", chosen, available); - /// The backend needs a connection string. - /// The backend's display name. - public string ProblemNoConnectionString(string backend) => - Get("setup.problem.noConnectionString", backend); - /// The backend needs a base path. - /// The backend's display name. - public string ProblemNoBasePath(string backend) => Get("setup.problem.noBasePath", backend); - /// No sign-in route at all was chosen. - public string ProblemNoSignIn => Get("setup.problem.noSignIn"); - /// A sign-in route this image cannot serve. - /// The submitted route name. - public string ProblemUnknownSignIn(string name) => Get("setup.problem.unknownSignIn", name); - /// A sign-in route was given a client id but no secret. - /// The provider's display name. - public string ProblemNoSignInSecret(string provider) => - Get("setup.problem.noSignInSecret", provider); - /// A model provider this image cannot call. - /// The submitted provider name. - public string ProblemUnknownAiProvider(string name) => - Get("setup.problem.unknownAiProvider", name); - /// A model provider that requires a key was given none. - /// The provider's display name. - public string ProblemNoApiKey(string provider) => Get("setup.problem.noApiKey", provider); - /// A module entry this image does not ship. - /// The submitted entry. - public string ProblemUnknownModule(string entry) => Get("setup.problem.unknownModule", entry); - /// A secret could not be encrypted because the install has no master key. - /// What the secret belongs to. - public string ProblemNoMasterKey(string what) => Get("setup.problem.noMasterKey", what); - - /// No instance name was given. - public string ProblemNoInstanceName => Get("setup.problem.noInstanceName"); - /// The instance id does not fit the registry's alphabet. - /// What was submitted. - public string ProblemBadInstanceId(string id) => Get("setup.problem.badInstanceId", id); - /// The registry address is not a URL. - /// What was submitted. - public string ProblemBadRegistry(string url) => Get("setup.problem.badRegistry", url); - /// Registered, but the issued key could not be persisted — and the id is now claimed. - /// The id that was claimed. - public string ProblemKeyUnstorable(string id) => Get("setup.problem.keyUnstorable", id); - - /// No organisation was given. - public string ProblemNoCompany => Get("setup.problem.noCompany"); - /// No owner name was given. - public string ProblemNoOwnerName => Get("setup.problem.noOwnerName"); - /// No usable owner email was given. - public string ProblemNoOwnerEmail => Get("setup.problem.noOwnerEmail"); - /// Consent was not accepted — nothing is sent until it is. - public string ProblemNoConsent => Get("setup.problem.noConsent"); - - // ── Warnings ─────────────────────────────────────────────────────────────────────────────── - /// The developer login is on with no platform admin named. - public string WarnDevLoginWithoutAdmins => Get("setup.warn.devLoginWithoutAdmins"); - /// No embeddings endpoint — vector search will be dark. - public string WarnNoEmbeddings => Get("setup.warn.noEmbeddings"); -} diff --git a/memex/Memex.Portal.Shared/Setup/StorageBackendCatalog.cs b/memex/Memex.Portal.Shared/Setup/StorageBackendCatalog.cs deleted file mode 100644 index 489d553ed8..0000000000 --- a/memex/Memex.Portal.Shared/Setup/StorageBackendCatalog.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Collections.Immutable; -using MeshWeaver.Mesh.Services; -using Microsoft.Extensions.DependencyInjection; - -namespace Memex.Portal.Shared.Setup; - -/// -/// Which storage backends THIS IMAGE can actually open — the keys of its registered -/// IStorageAdapterFactory services, captured while the service collection is still in hand. -/// -/// 🚨 Captured, because a key set cannot be recovered later. An -/// can resolve a keyed service you name but cannot enumerate the -/// keys, so a surface that asked at request time could only guess. This is read off the -/// during mesh configuration — after -/// InstallAssemblies, which is when a module's assembly attribute registers its factory — -/// and handed to the setup surface as data. -/// -/// This is the rule InstanceStorageSelection.Type already states: "resolved -/// against the KEYED IStorageAdapterFactory registrations this image ships. Discovered, never -/// hardcoded: an image without the Cosmos module must not be able to record Cosmos here." A -/// recorded backend the image cannot resolve is a durable answer that fails at the NEXT boot with -/// Unknown storage type — after the wizard is gone. -/// -/// The registered keys, in the order the wizard should offer them. -public sealed record StorageBackendCatalog(ImmutableList Types) -{ - /// A host that captured nothing. Renders as "this image ships no storage backend", - /// which is a real (if broken) image state and better said than shown as an empty list. - public static StorageBackendCatalog Empty { get; } = new([]); - - /// - /// The keys currently registered on . - /// - /// Keys are string-keyed by convention (FileSystem, PostgreSql, - /// Sqlite, Cosmos, Snowflake); a non-string key cannot be a - /// Graph:Storage:Type value and is skipped rather than stringified into something the - /// factory lookup would then miss. KeyedService.AnyKey is skipped for the - /// same reason — it is a wildcard registration, not a backend anyone can name. - /// - /// The collection to read. Never null. - public static StorageBackendCatalog Discover(IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - var types = services - .Where(sd => sd.ServiceType == typeof(IStorageAdapterFactory) && sd.IsKeyedService) - .Select(sd => sd.ServiceKey) - .OfType() - .Where(key => !string.IsNullOrWhiteSpace(key) - && !ReferenceEquals(key, KeyedService.AnyKey)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToImmutableList(); - return new StorageBackendCatalog(types); - } - - /// Whether this image can open . The gate the setup surface - /// applies before it writes a manifest — a refusal here is a form error the operator can fix, - /// which is the only place the mistake is still cheap. - /// The candidate Graph:Storage:Type. - public bool Offers(string? type) => - !string.IsNullOrWhiteSpace(type) - && Types.Any(t => string.Equals(t, type.Trim(), StringComparison.OrdinalIgnoreCase)); -} diff --git a/memex/Memex.Portal.Shared/ShippedReleaseSeed.cs b/memex/Memex.Portal.Shared/ShippedReleaseSeed.cs deleted file mode 100644 index 0404cfe361..0000000000 --- a/memex/Memex.Portal.Shared/ShippedReleaseSeed.cs +++ /dev/null @@ -1,451 +0,0 @@ -using System.Collections.Immutable; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Reflection; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared; - -/// -/// Boot seed that ships compiled Releases WHEREVER we ship code NodeTypes — the -/// documentation partition AND every shipped sample/platform partition (ACME, FutuRe, -/// Northwind, Cornerstone, MeshWeaver, …). At startup it finds every code -/// NodeType in those partitions that has no usable build yet and triggers a release for it — under -/// the System identity, exactly like the per-NodeType first-build kickoff. -/// -/// Why: a shipped partition's NodeTypes otherwise compile ON-DEMAND the first time a -/// user navigates to them — and that on-demand compile path (its _Activity/compile-* writes) -/// is exactly what storm-failed on prod 2026-06-18 when the activity write ran without a writer -/// identity. Pre-building the releases at provision/deploy time as System means the runtime path is -/// always a cache hit: no on-demand compile, no phantom _Activity/compile-* subscribe storm. -/// Several shipped partitions (Doc) are read-only — only a System-credentialed compile can -/// fill their cache and write the Release node, which is precisely the credential split this -/// workflow establishes. -/// -/// Each release runs entirely as System (no RequestedReleaseBy), so the compile fills -/// the cache and the Release node is created even on a read-only partition. Idempotent: a -/// NodeType that already has a usable build is skipped, so re-boots are cheap. Fire-and-forget off -/// the thread pool — never blocks host startup. -/// -/// Opt-in per deployment (ShippedRelease:PreseedEnabled, default off): even the -/// "skip already-built" pass stream-reads every code NodeType in every shipped partition — a boot -/// subscribe storm on a loaded mesh. By default only the partitions a deployment names in -/// ShippedRelease:ExtraPreseedPartitions are seeded; everything else compiles lazily on -/// first access. -/// -public static class ShippedReleaseSeed -{ - /// - /// The partitions whose code NodeTypes ship pre-built releases. The platform documentation plus - /// the bundled sample spaces — i.e. every partition we ship NodeTypes in. User/Space partitions - /// created at runtime are NOT here: those NodeTypes are authored by users and released by them - /// (the -gated "Create Release" button). - /// - public static readonly IReadOnlyList ShippedPartitions = - [ - "Doc", "ACME", "FutuRe", "Northwind", "Cornerstone", "MeshWeaver" - ]; - - // ═════════════════════════════════════════════════════════════════════ - // Platform-startup anchor (Admin partition) — see AccessControl.md → "The - // Admin partition" and ActivityNodeGuard. Platform-startup activities must - // hang off a REAL node in the Admin partition (a node holding the installed - // platform version), never the top-level mesh hub (a bare _Activity/{id} has - // no partition hub to route to → the RoutingGrain NotFound-storms). - // ═════════════════════════════════════════════════════════════════════ - - /// The Admin partition that holds platform-level data (schema admin, migration-provisioned). - public const string AdminPartition = "Admin"; - - /// Id of the platform-version node within the Admin partition. - public const string PlatformVersionId = "PlatformVersion"; - - /// Full path of the platform-version node: the canonical Admin-partition anchor for startup activities. - public const string PlatformVersionNodePath = $"{AdminPartition}/{PlatformVersionId}"; - - /// - /// The installed platform version. The FULL run-numbered version (3.0.0-ci.N) is no - /// longer compiled into any assembly — CI builds are commit-deterministic so the bake identity - /// holds across CI jobs (#1660 WS3) — so this reads the - /// the - /// container publish injects FIRST (the self-updater's version comparison against registry - /// tags depends on the run number), then falls back to the build assembly's - /// (PlatformVersion+<sha> on a - /// CI build), the numeric assembly version, then "unknown". - /// - /// 🚨 Build assembly, not entry assembly. Reading - /// here reported 1.0.0 on the deployed portal for - /// a week: the portal executable moved to MeshWeaver.Plugins (2026-08-25), which defines no - /// Version, so the host stopped being part of this build. Because - /// VersionSelect.IsNewer compares registry tags against THIS value, every tag then looked - /// newer forever — the install could never reach "up to date" and re-rolled on every check - /// floor. owns the selection so this page and - /// /api/version cannot answer differently. - /// - public static string InstalledPlatformVersion => MeshWeaver.Mesh.PlatformBuildInfo.PlatformVersion; - - /// The GitHub repository the platform is built from. - public const string RepositoryUrl = "https://github.com/Systemorph/MeshWeaver"; - - /// - /// The git commit SHA this build was produced from — baked in as AssemblyMetadata("CommitHash") - /// by the AddCommitHashMetadata target in Directory.Build.props (from the SDK's - /// SourceRevisionId or the CI GITHUB_SHA). Null when the build carried no source-control - /// info (a git-less source drop) — the About page then falls back to the version string alone. - /// Read off the same build assembly the version comes from, so "which build" and "which commit" - /// can never name two different builds. - /// - public static string? CommitHash => MeshWeaver.Mesh.PlatformBuildInfo.CommitHash; - - /// Link to the GitHub commit this build was produced from, or null when the SHA is unknown. - public static string? CommitUrl => CommitHash is { } sha ? $"{RepositoryUrl}/commit/{sha}" : null; - - /// - /// When THIS build started serving, in UTC — the process start time. - /// - /// This is the deployment moment as a user experiences it. A version answers "which - /// build", never "since when", and those are different questions: after a roll the version may - /// be unchanged (a re-deploy of the same image) or have changed minutes ago, and only a - /// timestamp separates "this has been live for a week" from "this landed while I was reading". - /// It is the question that goes unanswered every time someone asks whether a portal actually - /// rolled forward. - /// - /// Process start rather than a build/compile timestamp on purpose: a pod runs the image - /// it was given, and a viewer wants to know when the thing answering them began answering. - /// Under Kubernetes a rollout replaces the pod, so process start IS the roll. Read once and - /// cached — it cannot change within a process, and re-reading per render would be a syscall - /// for a constant. - /// - /// UTC by contract; every surface renders it in the VIEWER's zone via - /// DisplayTimeExtensions, never as a bare UTC string. - /// - public static DateTimeOffset StartedAtUtc { get; } = ReadProcessStartUtc(); - - private static DateTimeOffset ReadProcessStartUtc() - { - // WASM has no process to ask, and no deploy of its own to report — the build it runs is the - // one the server handed it. Guarded rather than caught: CA1416 is right that this is not a - // runtime accident to swallow. - if (OperatingSystem.IsBrowser()) return default; - - try - { - return new DateTimeOffset( - System.Diagnostics.Process.GetCurrentProcess().StartTime.ToUniversalTime(), - TimeSpan.Zero); - } - catch - { - // A host that refuses process introspection must not take the About page with it — the - // rest of the build identity is still worth rendering. Falling back to "now" would be a - // lie that reads as a fresh deploy on every render, so callers get default and suppress - // the line instead (AboutSettingsTab.StartedAtMarkdown). - return default; - } - } - - /// - /// THE single robust startup entry point. Runs the existing shipped-release pre-build FIRST (the - /// critical, owned per-NodeType release path), THEN ensures the Admin platform-version anchor node - /// exists and records ONE owned platform-startup Activity under it - /// (Admin/PlatformVersion/_Activity/{id}) — never the top-level mesh hub. Errors after the - /// seed propagate to the caller's onError (logged), never swallowed. Composed entirely from - /// IObservable; the version-node ensure + activity record run under one System scope each. - /// - public static IObservable RunPlatformStartup( - IMessageHub hub, IEnumerable partitions, ILogger? logger = null) - { - var version = InstalledPlatformVersion; - var started = DateTime.UtcNow; - return SeedReleases(hub, partitions, logger) - .ToList() - .SelectMany(triggered => - EnsurePlatformVersionNode(hub, version, logger) - .SelectMany(_ => RecordStartupActivity(hub, version, started, triggered, logger))); - } - - /// - /// Create-or-update the Admin-partition platform-version node (idempotent, reactive, as System). - /// Existence is read via GetQuery (empty-on-absent) — NEVER a point - /// GetMeshNodeStream(path) probe of the maybe-absent node, which would - /// NotFound-resubscribe-storm on a fresh DB. Mirrors AiSettingsNodeType.EnsureExists. The - /// node is a small Markdown page (a recognised, registered NodeType — no new type wiring) - /// recording the installed version; it is rewritten only when the version actually changes (a - /// redeploy), so re-boots at the same version are a no-op. Emits the node path. - /// - public static IObservable EnsurePlatformVersionNode( - IMessageHub hub, string version, ILogger? logger = null) - { - var meshService = hub.ServiceProvider.GetService(); - if (meshService is null) - return Observable.Return(PlatformVersionNodePath); - var accessService = hub.ServiceProvider.GetService(); - var workspace = hub.GetWorkspace(); - var body = $"# Platform version\n\nInstalled platform version: **{version}**"; - - MeshNode BuildNode() => new(PlatformVersionId, AdminPartition) - { - NodeType = "Markdown", - Name = "Platform Version", - State = MeshNodeState.Active, - Content = new MarkdownContent { Content = body }, - }; - - // 🚨 RunAsSystem, never `Observable.Using(AccessContextScope.AsSystem, …)` (#1444/#1790): - // `AsSystem(x)` IS `x.ImpersonateAsSystem()`, so the helper hides the shape rather than - // changing it. `Using` opens the AsyncLocal on the SUBSCRIBING thread — a startup hosted - // service's — and disposes it on whichever thread the create/update terminates, latching - // System onto the subscriber. RunAsSystem seals both ends inside one Subscribe; the whole - // read-then-write below stays inside the work factory, so it still runs as System. - return accessService.RunAsSystem( - () => workspace - .GetQuery($"{PlatformVersionId}|{PlatformVersionNodePath}", - $"path:{PlatformVersionNodePath} nodeType:Markdown") - .Take(1) - .SelectMany(nodes => - { - var existing = nodes.FirstOrDefault(); - if (existing is null) - { - logger?.LogInformation( - "[PlatformStartup] creating Admin platform-version node {Path} (version {Version}).", - PlatformVersionNodePath, version); - return meshService.CreateNode(BuildNode()) - .Select(_ => PlatformVersionNodePath) - // Idempotent: a concurrent first-writer (other replica) won the create race. - .Catch(ex => IsAlreadyExists(ex) - ? Observable.Return(PlatformVersionNodePath) - : Observable.Throw(ex)); - } - var currentBody = (existing.Content as MarkdownContent)?.Content; - if (string.Equals(currentBody, body, StringComparison.Ordinal)) - return Observable.Return(PlatformVersionNodePath); // up to date — no-op - logger?.LogInformation( - "[PlatformStartup] updating Admin platform-version node {Path} to version {Version}.", - PlatformVersionNodePath, version); - return workspace.GetMeshNodeStream(PlatformVersionNodePath) - .Update(n => n with { Content = new MarkdownContent { Content = body } }) - .Select(_ => PlatformVersionNodePath); - })); - } - - /// - /// Records ONE owned PlatformStartup activity at - /// {PlatformVersionNodePath}/_Activity/{id} (created as System, terminal Status). The - /// activity is anchored under the real Admin platform-version node — never a bare top-level - /// _Activity/{id} — so it routes through the Admin partition and the create-boundary - /// ownerless guard admits it. - /// - private static IObservable RecordStartupActivity( - IMessageHub hub, string version, DateTime started, IList triggered, ILogger? logger) - { - var meshService = hub.ServiceProvider.GetService(); - if (meshService is null) - return Observable.Return(Unit.Default); - var accessService = hub.ServiceProvider.GetService(); - var actId = $"startup-{Guid.NewGuid().ToString("N")[..8]}"; - var node = new MeshNode(actId, $"{PlatformVersionNodePath}/_Activity") - { - NodeType = "Activity", - Name = "Platform startup", - State = MeshNodeState.Active, - MainNode = PlatformVersionNodePath, - Content = new ActivityLog("PlatformStartup") - { - Id = actId, - HubPath = PlatformVersionNodePath, - Start = started, - End = DateTime.UtcNow, - Status = ActivityStatus.Succeeded, - Messages = ImmutableList.Create( - new LogMessage($"Installed platform version: {version}", LogLevel.Information), - new LogMessage( - $"Pre-built shipped releases triggered for {triggered.Count} NodeType(s).", - LogLevel.Information)), - AffectedPaths = triggered.ToImmutableList(), - } - }; - logger?.LogInformation( - "[PlatformStartup] recording boot activity at {Path} (version {Version}, {Count} release(s)).", - node.Path, version, triggered.Count); - // RunAsSystem, never `Observable.Using(AccessContextScope.AsSystem, …)` — see above (#1790). - return accessService.RunAsSystem( - () => meshService.CreateNode(node).Select(_ => Unit.Default)); - } - - /// True if the exception (or any inner) reports an "already exists" outcome — the idempotent-create success signal. - private static bool IsAlreadyExists(Exception ex) - { - for (var e = ex; e is not null; e = e.InnerException) - if (e.Message?.Contains("already exists", StringComparison.OrdinalIgnoreCase) == true) - return true; - return false; - } - - /// - /// Trigger a System release for every un-built code NodeType under each of - /// . Returns the path of each NodeType a release was triggered for - /// (one OnNext per trigger). Composed entirely from IObservable — no - /// await/FromAsync; every partition's work runs under one System scope. - /// - public static IObservable SeedReleases( - IMessageHub hub, IEnumerable partitions, ILogger? logger = null) - { - var meshService = hub.ServiceProvider.GetService(); - if (meshService is null) - return Observable.Empty(); - var accessService = hub.ServiceProvider.GetService(); - var workspace = hub.GetWorkspace(); - - // RunAsSystem, never `Observable.Using(AccessContextScope.AsSystem, …)` — see - // SeedPlatformVersionNode above (#1790). Every partition's work is composed inside the work - // factory, so it still runs under ONE System scope; only the boundary is sealed. - return accessService.RunAsSystem( - () => partitions - .ToObservable() - .SelectMany(partition => SeedPartition(meshService, workspace, partition, logger))); - } - - private static IObservable SeedPartition( - IMeshService meshService, IWorkspace workspace, string partitionNamespace, ILogger? logger) => - meshService - // List the NodeType nodes in the partition. Query is fine here — we only need the - // PATHS; the authoritative per-node content is re-read off the live stream below - // (query rows carry stale Content by design). - .Query(MeshQueryRequest.FromQuery( - $"namespace:{partitionNamespace} nodeType:{MeshNode.NodeTypePath} scope:subtree")) - .Take(1) - .Timeout(TimeSpan.FromSeconds(30)) - .SelectMany(result => result.Items) - .Select(n => n.Path) - .Distinct() - .SelectMany(path => workspace.GetMeshNodeStream(path) - .Where(node => node?.Content is NodeTypeDefinition) - .Take(1) - .Timeout(TimeSpan.FromSeconds(10)) - .Where(node => - { - // Skip NodeTypes that already have a usable, current build — the runtime path - // is already a cache hit for them. Trigger only the un-built ones. - var def = (NodeTypeDefinition)node!.Content!; - return def.CompilationStatus != CompilationStatus.Ok - || string.IsNullOrEmpty(def.LatestReleasePath); - }) - .SelectMany(node => - { - var nodePath = node!.Path; - logger?.LogInformation( - "[ShippedReleaseSeed] triggering System release for un-built shipped NodeType {Path}", - nodePath); - // Canonical request-via-stream-update trigger, as System — RequestedReleaseBy - // stays null so the Release node is created under System (a read-only shipped - // partition like Doc admits no user write). - return workspace.GetMeshNodeStream(nodePath) - .Update(curr => - { - if (curr?.Content is not NodeTypeDefinition def) return curr!; - return curr with - { - Content = def with - { - RequestedReleaseAt = DateTimeOffset.UtcNow, - RequestedReleaseForce = false, - RequestedReleaseBy = null - } - }; - }) - .Select(_ => nodePath); - }) - // A single un-buildable NodeType (or a partition that isn't present in this - // deployment) must not abort the whole seed. - .Catch(ex => - { - logger?.LogWarning(ex, - "[ShippedReleaseSeed] release trigger failed for {Path} (skipped)", path); - return Observable.Empty(); - })) - .Catch(ex => - { - logger?.LogWarning(ex, - "[ShippedReleaseSeed] partition {Partition} not present / not queryable (skipped)", - partitionNamespace); - return Observable.Empty(); - }); -} - -/// -/// The single platform-startup boot hook. Runs -/// once the mesh is up: pre-builds the shipped code-NodeType releases, ensures the Admin -/// platform-version anchor node, and records the boot activity UNDER that node -/// (Admin/PlatformVersion/_Activity/{id}) — never the top-level mesh hub. Reactive, -/// fire-and-forget, SubscribeOn the thread pool so it never re-enters the hub schedulers on -/// the startup thread (mirrors StaticRepoImportHostedService). -/// -public sealed class ShippedReleaseSeedHostedService( - IMessageHub hub, - IConfiguration configuration, - ILogger? logger = null) : IHostedService -{ - private IDisposable? _subscription; - - public Task StartAsync(CancellationToken cancellationToken) - { - // Boot pre-seed is OPT-IN (ShippedRelease:PreseedEnabled, default false): enumerating + - // stream-reading every code NodeType in every shipped partition is a boot-time subscribe - // storm on a loaded mesh, and the lazy compile-on-first-access path already covers - // correctness. When disabled, only the explicitly configured extra partitions - // (ShippedRelease:ExtraPreseedPartitions) are seeded — a deployment naming a partition - // there has asked for exactly that pre-build (e.g. prod: AgenticPension; set - // PER-DEPLOYMENT, never hardcode a customer's space name into framework source). The - // platform-version anchor + boot activity below run regardless — they touch one Admin - // node, not the mesh. Pre-build is async (never blocks host startup). - var extra = configuration.GetSection("ShippedRelease:ExtraPreseedPartitions").Get() - ?? []; - var preseedShipped = configuration.GetValue("ShippedRelease:PreseedEnabled", false); - var partitions = (preseedShipped ? ShippedReleaseSeed.ShippedPartitions.Concat(extra) : extra) - .Where(p => !string.IsNullOrWhiteSpace(p)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - logger?.LogInformation( - "[PlatformStartup] version={Version}; pre-seed {Mode}; pre-building shipped code-NodeType releases as System for " - + "[{Partitions}] and recording the boot activity under {VersionNode}.", - ShippedReleaseSeed.InstalledPlatformVersion, - preseedShipped ? "ENABLED (ShippedRelease:PreseedEnabled)" : "disabled (config extras only)", - string.Join(", ", partitions), - ShippedReleaseSeed.PlatformVersionNodePath); - _subscription = ShippedReleaseSeed - .RunPlatformStartup(hub, partitions, logger) - .SubscribeOn(System.Reactive.Concurrency.TaskPoolScheduler.Default) - .Subscribe( - _ => { }, - ex => logger?.LogWarning(ex, "[PlatformStartup] startup failed."), - // The "fully warm" deploy-timing marker (seeding completes after - // ApplicationStarted/PortalReady): elapsed since process start, greppable in Loki. -#pragma warning disable CA1416 - () => logger?.LogInformation( - "[PlatformStartup] startup complete in {ElapsedMs} ms since process start.", - (long)(DateTime.UtcNow - - System.Diagnostics.Process.GetCurrentProcess().StartTime.ToUniversalTime()) - .TotalMilliseconds)); -#pragma warning restore CA1416 - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _subscription?.Dispose(); - return Task.CompletedTask; - } -} diff --git a/memex/Memex.Portal.Shared/Social/GitHubAppManifestEndpoints.cs b/memex/Memex.Portal.Shared/Social/GitHubAppManifestEndpoints.cs deleted file mode 100644 index 3394075eee..0000000000 --- a/memex/Memex.Portal.Shared/Social/GitHubAppManifestEndpoints.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System; -using System.Net; -using System.Reactive.Linq; -using System.Text; -using System.Threading.Tasks; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.AspNetCore.Portal; // PortalApplication -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -using Memex.Portal.Shared.Authentication; - -namespace Memex.Portal.Shared.Social; - -/// -/// The browser half of the instance-owned GitHub App's manifest flow -/// (Doc/Architecture/InstanceRepositoryAccess): -/// -/// GET /admin/github-app/connect?organization={org}&returnPath={path} -/// — a global administrator starts the flow: records the -/// pending state, the state goes into a cookie, and the answer is a one-button form that -/// POSTs the manifest to GitHub (the manifest flow is a form POST by GitHub's design). -/// GET /admin/github-app/callback?code=…&state=… -/// — GitHub's redirect back: checks cookie, state, -/// expiry and admin, exchanges the one-time code, and stores the App's credentials as -/// Instance Secrets. The admin lands back on the page they started from. -/// -/// Every decision lives in ; this file only carries the -/// browser's cookie and the redirects. Nothing here ever logs or echoes a credential. -/// -public static class GitHubAppManifestEndpoints -{ - /// Maps the two endpoints. Called from . - public static IEndpointRouteBuilder MapGitHubAppManifest(this IEndpointRouteBuilder endpoints) - { - endpoints.MapGet(InstanceGitHubApp.StartPath, async ( - HttpContext http, - [Microsoft.AspNetCore.Mvc.FromQuery] string? organization, - [Microsoft.AspNetCore.Mvc.FromQuery] string? returnPath, - ILoggerFactory loggers) => - { - if (!http.User.Identity?.IsAuthenticated ?? true) - return Results.Challenge(new AuthenticationProperties { RedirectUri = http.Request.Path + http.Request.QueryString }); - var logger = loggers.CreateLogger("GitHubAppManifest"); - var back = ReturnUrlPolicy.Sanitize(returnPath); - var hub = Hub(http); - var locale = Locale(hub); - return await InstanceGitHubApp.Start(hub, BaseUrl(http), organization) - .Select(flow => - { - http.Response.Cookies.Append(InstanceGitHubApp.StateCookieName, - WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes($"{flow.State}|{back}")), - new CookieOptions - { - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Lax, - Path = InstanceGitHubApp.CallbackPath, - MaxAge = InstanceGitHubApp.StateLifetime, - }); - return Results.Content(FormPage(flow, locale), "text/html; charset=utf-8"); - }) - .Catch((Exception ex) => - { - logger.LogWarning("GitHub App manifest flow refused at start: {Reason}", ex.Message); - return Observable.Return(Results.Redirect(Back(back, "error", GitSyncRefusalException.Localize(ex, locale)))); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger.LogWarning(ex, "GitHub App manifest start faulted after its answer was sent"), - http.RequestAborted); - }).RequireAuthorization(); - - endpoints.MapGet(InstanceGitHubApp.CallbackPath, async ( - HttpContext http, - [Microsoft.AspNetCore.Mvc.FromQuery] string? code, - [Microsoft.AspNetCore.Mvc.FromQuery] string? state, - GitHubAppManifestClient client, - ILoggerFactory loggers) => - { - var logger = loggers.CreateLogger("GitHubAppManifest"); - string? cookieState = null; - var back = "/"; - if (http.Request.Cookies.TryGetValue(InstanceGitHubApp.StateCookieName, out var cookie) && !string.IsNullOrEmpty(cookie)) - { - var malformed = false; - try - { - var parts = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(cookie)).Split('|', 2); - cookieState = parts[0]; - back = ReturnUrlPolicy.Sanitize(parts.Length > 1 ? parts[1] : "/"); - } - catch (FormatException) - { - // A malformed cookie is no state at all: Complete refuses it by name below. - malformed = true; - } - // Only THIS callback's cookie is cleared: the cookie belongs to the newest flow this - // browser started, and a stale or forged callback for another flow must not take it away. - if (malformed || string.Equals(cookieState, state, StringComparison.Ordinal)) - http.Response.Cookies.Delete(InstanceGitHubApp.StateCookieName, - new CookieOptions { Path = InstanceGitHubApp.CallbackPath, Secure = true, HttpOnly = true }); - } - var hub = Hub(http); - var locale = Locale(hub); - return await InstanceGitHubApp.Complete(hub, code, state, cookieState, client.Convert) - .Select(app => Results.Redirect(Back(back, "connected", null))) - .Catch((Exception ex) => - { - // The refusal's English text names no credential (GitSyncRefusal templates carry - // names and statuses only), so it is safe to log and to show. - logger.LogWarning("GitHub App manifest flow refused at callback: {Reason}", ex.Message); - return Observable.Return(Results.Redirect(Back(back, "error", GitSyncRefusalException.Localize(ex, locale)))); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger.LogWarning(ex, "GitHub App manifest callback faulted after its answer was sent"), - http.RequestAborted); - }).RequireAuthorization(); - - return endpoints; - } - - private static IMessageHub Hub(HttpContext http) => - http.RequestServices.GetRequiredService().Hub; - - private static string? Locale(IMessageHub hub) => - hub.ServiceProvider.GetService()?.Context?.Locale; - - /// The instance's origin as the browser reached it — the host the state cookie is set - /// on, so GitHub's redirect returns to the same host and the cookie travels with it. - private static string BaseUrl(HttpContext http) => $"{http.Request.Scheme}://{http.Request.Host}"; - - private static string Back(string returnPath, string status, string? reason) - { - var separator = returnPath.Contains('?') ? "&" : "?"; - var url = $"{returnPath}{separator}githubApp={status}"; - return reason is { Length: > 0 } ? url + "&reason=" + Uri.EscapeDataString(reason) : url; - } - - /// - /// The one-button page that POSTs the manifest to GitHub. GitHub's manifest flow is a form POST - /// from the browser by design, so this is an HTTP interstitial rather than a mesh view; it runs - /// no script (the portal's CSP allows form-action https:), and every value is HTML-encoded. - /// - private static string FormPage(InstanceGitHubAppFlow flow, string? locale) - { - string T(string key) => WebUtility.HtmlEncode(MeshWeaver.Messaging.LocalizationCatalog.Get(key, locale)); - return $""" - - - {T("instanceApp.page.title")} - -

{T("instanceApp.page.title")}

-

{T("instanceApp.page.explain")}

-
- - -
- - """; - } -} diff --git a/memex/Memex.Portal.Shared/Social/GitHubConnectEndpoints.cs b/memex/Memex.Portal.Shared/Social/GitHubConnectEndpoints.cs deleted file mode 100644 index b1dfca52a9..0000000000 --- a/memex/Memex.Portal.Shared/Social/GitHubConnectEndpoints.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; -using MeshWeaver.Hosting.AspNetCore.Portal; // PortalApplication -using MeshWeaver.GitSync; -using MeshWeaver.Messaging; // AccessService -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -using Memex.Portal.Shared.Authentication; - -namespace Memex.Portal.Shared.Social; - -/// -/// GitHub OAuth2 authorization-code flow for connecting a user's GitHub identity for -/// GitHub Sync. Mirrors -/// MeshWeaver.Social.LinkedInConnectEndpoints (contributed by the Social -/// module) — the deployed surface is just the parts that need browser cookies + a -/// whitelisted callback URL: -/// -/// GET /connect/github/me — redirect into the flow for the signed-in user -/// GET /connect/github?returnPath={path} — start (CSRF cookie, redirect to GitHub authorize) -/// GET /connect/github/callback?code=… — exchange code → token, store credential, redirect back -/// -/// The token is stored encrypted at {userId}/_Provider/GitHub via -/// , keyed by the authenticated user's name -/// (which is the email = AccessContext.ObjectId in this portal). -/// -public static class GitHubConnectEndpoints -{ - public const string StateCookieName = "gh_connect_state"; - private const string CallbackPath = "/connect/github/callback"; - - public static IEndpointRouteBuilder MapGitHubConnect(this IEndpointRouteBuilder endpoints) - { - endpoints.MapGet("/connect/github/me", (HttpContext http) => - { - if (!http.User.Identity?.IsAuthenticated ?? true) - return Results.Challenge(new AuthenticationProperties { RedirectUri = "/connect/github/me" }); - return Results.Redirect("/connect/github?returnPath=/"); - }).RequireAuthorization(); - - endpoints.MapGet("/connect/github", ( - HttpContext http, - [Microsoft.AspNetCore.Mvc.FromQuery] string? returnPath, - GitHubOAuthService oauth) => - { - if (!http.User.Identity?.IsAuthenticated ?? true) - return Results.Challenge(new AuthenticationProperties { RedirectUri = http.Request.Path + http.Request.QueryString }); - if (!oauth.IsConfigured) - return Results.Problem("GitHub OAuth is not configured (GitHub:OAuth:ClientId + ClientSecret).", statusCode: 500); - - var state = GenerateState(); - var rp = string.IsNullOrWhiteSpace(returnPath) ? "/" : returnPath!; - http.Response.Cookies.Append(StateCookieName, - WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes($"{state}|{rp}")), - new CookieOptions - { - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Lax, - MaxAge = TimeSpan.FromMinutes(10), - }); - - return Results.Redirect(oauth.BuildAuthorizeUrl(BuildRedirectUri(http), state)); - }).RequireAuthorization(); - - endpoints.MapGet(CallbackPath, async ( - HttpContext http, - [Microsoft.AspNetCore.Mvc.FromQuery] string? code, - [Microsoft.AspNetCore.Mvc.FromQuery] string? state, - [Microsoft.AspNetCore.Mvc.FromQuery] string? error, - GitHubOAuthService oauth, - GitHubCredentialService creds, - ILoggerFactory loggers) => - { - var logger = loggers.CreateLogger("GitHubConnect"); - - // Recover the originating page (and CSRF state) from the cookie FIRST, so every failure - // below redirects the user BACK to the GitHub Sync tab WITH a visible reason — never a - // silent bounce to the home page. (Errors are also logged at Warning so they surface in - // Loki, not just the GUI.) - string cookieState = "", returnPath = "/"; - if (http.Request.Cookies.TryGetValue(StateCookieName, out var cookie) && !string.IsNullOrEmpty(cookie)) - { - http.Response.Cookies.Delete(StateCookieName); - try - { - var parts = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(cookie)).Split('|', 2); - cookieState = parts[0]; - returnPath = parts.Length > 1 ? parts[1] : "/"; - } - catch { /* malformed cookie — fall through to the state check below */ } - } - - IResult Fail(string reason) - { - logger.LogWarning("GitHub connect failed: {Reason} (user {User})", reason, http.User.Identity?.Name); - return Results.Redirect(SafeReturn(returnPath, "github-error", reason)); - } - - if (!string.IsNullOrEmpty(error)) - return Fail(error!); - if (string.IsNullOrEmpty(cookieState)) - return Fail("missing or bad connect-state cookie (CSRF)"); - if (!string.Equals(cookieState, state, StringComparison.Ordinal)) - return Fail("connect-state mismatch (CSRF)"); - if (string.IsNullOrEmpty(code)) - return Fail("no authorization code returned by GitHub"); - - // The credential MUST be keyed by the mesh User.Id (e.g. "rbuergi") — the SAME identifier - // the GitHub Sync tab and Sync read it under (AccessContext.ObjectId). Using - // http.User.Identity.Name (the display name "Roland Buergi") saved it under the wrong key, - // so the tab never found it ("nothing happens" after ?connect=github-ok). Mirror - // OAuthConnectController.ResolveMeshUserId: prefer the resolved AccessContext.ObjectId, - // fall back to the preferred_username/email local part. - var userId = ResolveMeshUserId(http); - if (string.IsNullOrEmpty(userId)) - return Fail("could not resolve your mesh user id (retry after a normal browser login)"); - - var redirectUri = BuildRedirectUri(http); - // Reactive end-to-end; bridge to Task ONCE at the HTTP boundary via - // FirstAsync().ObserveCompletion(…) — the sanctioned edge pattern (see - // OAuthConnectController.ExchangeToken). NEVER Rx's .ToTask(), which resumes the awaiter - // INLINE on the signalling thread, so the rest of this request would run on a mesh hub's - // action block (forbidden since 2026-08-30). NO hand-woven TaskCompletionSource/Subscribe. - // The credential write's AccessContext is carried through the framework's - // .Subscribe / IoPool boundary from the request context the middleware set. - return await oauth.ExchangeCode(code!, redirectUri) - .SelectMany(token => oauth.GetLogin(token.AccessToken) - .Catch(_ => Observable.Return(null)) - .SelectMany(login => creds.Save(userId!, token, login))) - .Select(_ => - { - logger.LogInformation("Stored GitHub credential for {User}", userId); - return (IResult)Results.Redirect(SafeReturn(returnPath, "github-ok", null)); - }) - .Catch((Exception ex) => - { - // Surface the REAL reason (token exchange / GetLogin / credential write) — never swallow. - logger.LogWarning(ex, "GitHub connect failed for {User}", userId); - return Observable.Return((IResult)Results.Redirect(SafeReturn(returnPath, "github-error", ex.Message))); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger.LogWarning(ex, - "GitHub connect for {User} faulted after the redirect had already been sent", - userId), - http.RequestAborted); - }).RequireAuthorization(); - - // The instance-owned GitHub App's manifest flow rides the same registration, so every - // portal that offers GitHub connect also offers it (Doc/Architecture/InstanceRepositoryAccess). - endpoints.MapGitHubAppManifest(); - - return endpoints; - } - - private static string SafeReturn(string returnPath, string status, string? reason) - { - // 🚨 Sanitize, do not re-check. This tested only for a leading "/", so "//evil.example" - // and "/\evil.example" both passed and became protocol-relative EXTERNAL redirects — a - // LIVE open redirect, and the weakest of the five copies of this rule the assembly had - // grown (#2302). It was missed by the first pass of that issue AND by the first version of - // the guard below, twice over: the sink is named returnPath rather than returnUrl, and the - // helper is named SafeReturn — so a name-trusting check certified it. - var rp = ReturnUrlPolicy.Sanitize(returnPath); - var sep = rp.Contains('?') ? "&" : "?"; - var url = $"{rp}{sep}connect={status}"; - if (!string.IsNullOrEmpty(reason)) - url += $"&reason={Uri.EscapeDataString(reason!)}"; - return url; - } - - private static string GenerateState() - { - Span buf = stackalloc byte[24]; - RandomNumberGenerator.Fill(buf); - return WebEncoders.Base64UrlEncode(buf); - } - - private static string BuildRedirectUri(HttpContext http) => - $"{http.Request.Scheme}://{http.Request.Host}{CallbackPath}"; - - /// - /// Resolves the mesh User.Id (e.g. rbuergi) to key the credential under — the SAME - /// identifier the GitHub Sync tab + Sync read it under (AccessContext.ObjectId), NEVER the - /// display Name claim. Mirrors OAuthConnectController.ResolveMeshUserId: prefer the - /// resolved AccessContext.ObjectId (email→User.Id, stamped by UserContextMiddleware), fall - /// back to the preferred_username/email local part when no context is present. - /// - private static string? ResolveMeshUserId(HttpContext http) - { - var ctx = http.RequestServices.GetService()? - .Hub.ServiceProvider.GetService()?.Context; - var resolved = ctx?.ObjectId; - if (!string.IsNullOrEmpty(resolved) && !resolved.Contains('@')) - return resolved; - var claim = http.User.FindFirstValue("preferred_username") ?? http.User.FindFirstValue(ClaimTypes.Email); - return UsernameFromEmail(claim); - } - - /// Email-shaped identifier → its local part (the username / mesh partition key, - /// e.g. rbuergi@systemorph.com → rbuergi); unchanged when there's no @. - private static string? UsernameFromEmail(string? value) - { - if (string.IsNullOrEmpty(value)) return null; - var at = value.IndexOf('@'); - return at > 0 ? value[..at] : value; - } -} diff --git a/memex/Memex.Portal.Shared/Social/GitHubLoginEndpoints.cs b/memex/Memex.Portal.Shared/Social/GitHubLoginEndpoints.cs deleted file mode 100644 index 2f18987737..0000000000 --- a/memex/Memex.Portal.Shared/Social/GitHubLoginEndpoints.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using MeshWeaver.GitSync; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Logging; - -using Memex.Portal.Shared.Authentication; - -namespace Memex.Portal.Shared.Social; - -/// -/// "Sign in with GitHub" — a first-class AUTHENTICATION provider (distinct from -/// , which only CONNECTS a GitHub identity for repo sync -/// without signing anyone in). It runs the same GitHub OAuth authorization-code flow -/// (, reusing the already-configured GitHub:OAuth creds), -/// then issues the portal cookie with the IDENTICAL claim shape Entra login produces via -/// ExternalAuthController — so a GitHub login resolves, through -/// UserContextMiddleware, to the SAME mesh User.Id as an Entra login with the same -/// verified email. That is how the user's decision "GitHub users get the same access as Entra -/// users" is realized: no special-case access logic, just the same identity by email. -/// -/// GET /login/github?returnUrl=… — start (CSRF state cookie, redirect to GitHub authorize) -/// GET /login/github/callback?code=&state= — exchange code → token, fetch verified email, sign in -/// -/// GitHub App note. The configured credential is a GitHub App (client id Ov23…), -/// which supports this user-authorization flow on the same endpoints. Resolving the user's email -/// needs the App's "Email addresses: Read" account permission; without it the login FAILS -/// gracefully (never a fabricated identity). The App must whitelist the callback URL -/// {portal}/login/github/callback. -/// -public static class GitHubLoginEndpoints -{ - public const string StateCookieName = "gh_login_state"; - private const string CallbackPath = "/login/github/callback"; - - /// - /// The single gate deciding WHO may sign in with GitHub. Today it admits everyone (the user's - /// explicit choice: "any GitHub account = same access as Entra users"). To restrict later — - /// e.g. to Systemorph org members or an allowlist — this is the ONE line to change (add the org - /// check here and thread the token through; nothing else needs to move). - /// - internal static bool IsGitHubUserAllowed(string? login, string email) => true; - - public static IEndpointRouteBuilder MapGitHubLogin(this IEndpointRouteBuilder endpoints) - { - // START — NOT RequireAuthorization: this IS the sign-in. - endpoints.MapGet("/login/github", ( - HttpContext http, - [Microsoft.AspNetCore.Mvc.FromQuery] string? returnUrl, - GitHubOAuthService oauth) => - { - if (!oauth.IsConfigured) - return Results.Redirect("/login?error=" + Uri.EscapeDataString("GitHub sign-in is not configured on this server.")); - - var state = GenerateState(); - var ru = SafeLocal(returnUrl); - http.Response.Cookies.Append(StateCookieName, - WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes($"{state}|{ru}")), - new CookieOptions - { - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Lax, - MaxAge = TimeSpan.FromMinutes(10), - }); - - return Results.Redirect(oauth.BuildAuthorizeUrl(BuildRedirectUri(http), state)); - }); - - // CALLBACK — exchange code, fetch the verified email, sign the user in with the Entra claim shape. - endpoints.MapGet(CallbackPath, async ( - HttpContext http, - [Microsoft.AspNetCore.Mvc.FromQuery] string? code, - [Microsoft.AspNetCore.Mvc.FromQuery] string? state, - [Microsoft.AspNetCore.Mvc.FromQuery] string? error, - GitHubOAuthService oauth, - ILoggerFactory loggers) => - { - var logger = loggers.CreateLogger("GitHubLogin"); - - string cookieState = "", returnUrl = "/"; - if (http.Request.Cookies.TryGetValue(StateCookieName, out var cookie) && !string.IsNullOrEmpty(cookie)) - { - http.Response.Cookies.Delete(StateCookieName); - try - { - var parts = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(cookie)).Split('|', 2); - cookieState = parts[0]; - returnUrl = parts.Length > 1 ? parts[1] : "/"; - } - catch { /* malformed cookie — fails the state check below */ } - } - - IResult Fail(string reason) - { - logger.LogWarning("GitHub sign-in failed: {Reason}", reason); - return Results.Redirect("/login?error=" + Uri.EscapeDataString(reason)); - } - - if (!string.IsNullOrEmpty(error)) return Fail(error!); - if (string.IsNullOrEmpty(cookieState)) return Fail("missing or bad sign-in state cookie (CSRF)"); - if (!string.Equals(cookieState, state, StringComparison.Ordinal)) return Fail("sign-in state mismatch (CSRF)"); - if (string.IsNullOrEmpty(code)) return Fail("no authorization code returned by GitHub"); - - var redirectUri = BuildRedirectUri(http); - // Reactive end-to-end; bridge to Task ONCE at the HTTP boundary via ObserveCompletion - // (the sanctioned edge pattern, as in GitHubConnectEndpoints / OAuthConnectController) — - // never Rx's .ToTask(), which resumes the awaiter inline on the signalling thread - // (forbidden since 2026-08-30). Exchange the code, then fetch login + primary verified - // email together. - return await oauth.ExchangeCode(code!, redirectUri) - .SelectMany(token => oauth.GetLogin(token.AccessToken) - .Catch(_ => Observable.Return(null)) - .SelectMany(login => oauth.GetPrimaryEmail(token.AccessToken) - .Catch(_ => Observable.Return(null)) - .Select(mail => (login, email: mail)))) - .SelectMany(async t => - { - var (login, email) = t; - if (string.IsNullOrEmpty(email)) - return Fail("GitHub sign-in needs a verified email — ensure the GitHub App has " - + "'Email addresses: Read' account permission and your GitHub email is verified."); - if (!IsGitHubUserAllowed(login, email)) - return Fail("This GitHub account is not permitted to sign in."); - - // The SAME cookie identity ExternalAuthController builds for Entra: objectId = email, - // preferred_username = email, name + email claims. UserContextMiddleware maps the email - // to the mesh User.Id downstream → same user, same access as an Entra login. - var name = login ?? email; - var claims = new List - { - new(ClaimTypes.NameIdentifier, email), - new(ClaimTypes.Name, name), - new("name", name), - new("preferred_username", email), - new(ClaimTypes.Email, email), - new("email", email), - }; - // The step-up ladder reads the provider off the session (Doc/Architecture/ApprovalStepUp). - claims.AddRange(Memex.Portal.Shared.Authentication.StepUpClaims.ForSession( - "GitHub", Array.Empty(), DateTimeOffset.UtcNow)); - var principal = new ClaimsPrincipal( - new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)); - await http.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - principal, - new AuthenticationProperties - { - IsPersistent = true, - ExpiresUtc = DateTimeOffset.UtcNow.AddDays(14), - }); - logger.LogInformation("GitHub sign-in for {Email} (login {Login})", email, login); - return (IResult)Results.Redirect(SafeLocal(returnUrl)); - }) - .Catch((Exception ex) => - { - logger.LogWarning(ex, "GitHub sign-in failed during token exchange / profile fetch"); - return Observable.Return(Fail(ex.Message)); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger.LogWarning(ex, - "GitHub sign-in faulted after the response had already been sent"), - http.RequestAborted); - }); - - return endpoints; - } - - /// - /// Only ever redirect to a local path — never an attacker-supplied absolute URL. - /// - /// 🚨 Delegates to rather than re-implementing the - /// check. The hand-written version here rejected //host but ACCEPTED /\host, and - /// browsers normalise a backslash to a slash in special-scheme URLs — so a crafted target still - /// became a protocol-relative external redirect (#2302). Five sinks in this assembly each had - /// their own copy of this rule and three of them were wrong; there is now one. - /// - internal static string SafeLocal(string? returnUrl) => ReturnUrlPolicy.Sanitize(returnUrl); - - private static string GenerateState() - { - Span buf = stackalloc byte[24]; - RandomNumberGenerator.Fill(buf); - return WebEncoders.Base64UrlEncode(buf); - } - - private static string BuildRedirectUri(HttpContext http) => - $"{http.Request.Scheme}://{http.Request.Host}{CallbackPath}"; -} diff --git a/memex/Memex.Portal.Shared/Social/GitHubWebhookEndpoints.cs b/memex/Memex.Portal.Shared/Social/GitHubWebhookEndpoints.cs deleted file mode 100644 index 8c0daf1051..0000000000 --- a/memex/Memex.Portal.Shared/Social/GitHubWebhookEndpoints.cs +++ /dev/null @@ -1,227 +0,0 @@ -using System; -using System.IO; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using MeshWeaver.GitSync; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Social; - -/// -/// The GitHub webhook receiver: POST /webhooks/github. GitHub calls this (unauthenticated -/// by browser session — it is authenticated by the HMAC signature), so it verifies -/// X-Hub-Signature-256 against the shared secret GitHub:Webhook:Secret before doing -/// any work, then hands the event to which refreshes the -/// synced {space}/_Issue/{number} nodes of every Space that syncs the event's repository. -/// -/// Register ONE webhook per repo in GitHub → Settings → Webhooks, pointing at -/// https://{host}/webhooks/github, content-type application/json, with the same -/// secret, subscribed to the Issues + Issue comments + Pushes events — -/// a push triggers the headless "Update to latest" for every Space sync source matching the -/// pushed repo/branch/subdirectory, so GitSync'd Spaces stay current without polling. The -/// async here is the sanctioned HTTP-boundary bridge (mirrors -/// ); the processing itself is reactive. -/// -public static class GitHubWebhookEndpoints -{ - /// - /// Hard ceiling on the request body this anonymous endpoint will buffer before the HMAC can be - /// checked. 25 MiB is GitHub's own documented maximum payload, so it can never refuse a genuine - /// delivery — it only bounds what a forged one costs. - /// - public const long MaxWebhookBodyBytes = 25L * 1024 * 1024; - - private const string WebhookPath = "/webhooks/github"; - - /// Which secret authenticated a delivery — open string constants. - internal static class Signer - { - /// The deployment's shared webhook secret (GitHub:Webhook:Secret). - public const string Deployment = "deployment"; - - /// The instance's OWN GitHub App's webhook secret (Doc/Architecture/InstanceRepositoryAccess). - public const string InstanceApp = "instance-app"; - } - - /// - /// The secret that signed , or null when neither did. The deployment's - /// secret is tried first, so a delivery it signs keeps the deployment's full event set even - /// when both secrets happen to be equal. - /// - internal static string? VerifiedSigner(string? deploymentSecret, string? instanceAppSecret, byte[] body, string signature) - => !string.IsNullOrEmpty(deploymentSecret) && GitHubWebhookProcessor.VerifySignature(deploymentSecret, body, signature) - ? Signer.Deployment - : !string.IsNullOrEmpty(instanceAppSecret) && GitHubWebhookProcessor.VerifySignature(instanceAppSecret, body, signature) - ? Signer.InstanceApp - : null; - - /// - /// 🚨 Whether a delivery signed by may carry . - /// The instance's own App subscribes to push only, and its secret is held by the - /// instance's administrators — a platform role that grants no data rights. So an - /// instance-App-signed delivery reaches the push trigger (which READS the named repository at - /// the named commit with this instance's own credentials, so it injects no content) and - /// nothing else: issues/issue_comment write the payload's text into Spaces as - /// System, and workflow_run records build completions — those stay behind the - /// deployment's signer. - /// - internal static bool SignerMayDeliver(string signer, string eventType) - => signer == Signer.Deployment - || string.Equals(eventType, "push", StringComparison.OrdinalIgnoreCase) - || string.Equals(eventType, "ping", StringComparison.OrdinalIgnoreCase); - - public static IEndpointRouteBuilder MapGitHubWebhook(this IEndpointRouteBuilder endpoints) - { - endpoints.MapPost(WebhookPath, async ( - HttpContext http, - GitHubWebhookProcessor processor, - IConfiguration config, - ILoggerFactory loggers) => - { - var logger = loggers.CreateLogger("GitHubWebhook"); - - // Two secrets may sign a delivery: the deployment's shared webhook secret, and the - // instance's OWN GitHub App's (Doc/Architecture/InstanceRepositoryAccess), which GitHub - // generated when the App was created and signs the App's push deliveries with. Either may - // be absent; with neither there is nothing to verify against. - var secret = config["GitHub:Webhook:Secret"]; - // The App's secret is read from catalogs that load lazily: on a cold replica their first - // read is empty, so wait for their first full listing — bounded, and with the request's - // cancellation — or the first delivery after a start is refused although the secret is stored. - var meshServices = http.RequestServices.GetService()? - .Hub.ServiceProvider; - if (meshServices is not null) - await InstanceGitHubApp.WhenReadable(meshServices) - .Timeout(TimeSpan.FromSeconds(10)) - .Catch((Exception ex) => - { - logger.LogWarning(ex, - "GitHub webhook: the instance's GitHub App could not be read in time; verifying " - + "against the deployment's secret only."); - return Observable.Return(System.Reactive.Unit.Default); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger.LogWarning(ex, "GitHub webhook: waiting for the App's stores faulted"), - http.RequestAborted); - var appSecret = meshServices is null ? null : InstanceGitHubApp.WebhookSecret(meshServices); - if (string.IsNullOrEmpty(secret) && string.IsNullOrEmpty(appSecret)) - { - logger.LogWarning( - "GitHub webhook received but no secret is configured (GitHub:Webhook:Secret) and no " - + "instance GitHub App is connected."); - return Results.StatusCode(503); - } - - // Read the raw body (needed byte-exact for the HMAC) — UNDER A CAP. - // - // 🚨 This endpoint is anonymous: the HMAC below is the ONLY thing authenticating it, and - // it cannot run until the body is in memory. A plain CopyToAsync therefore let an - // unauthenticated caller make the server buffer an arbitrarily large request before a - // single byte was checked, and a chunked request carries no Content-Length to reject it - // on (#2302). The cap bounds that to one GitHub-sized delivery per request. - // - // 25 MiB is GitHub's own documented maximum payload, so this can never refuse a genuine - // delivery — it is strictly a ceiling on what a forged one can cost. - // - // 🚨 A DELIVERY THAT STOPS SHORT IS A 400, NOT AN UNHANDLED FAULT (#4860). GitHub's - // delivery can time out, and an abandoned probe or a reset connection does the same: - // Kestrel raises "Unexpected end of request content" from the body read. That escaped to - // ExceptionHandlerMiddleware and was logged at `fail` with a stack trace — six times in - // 19 days across five pods — for an ordinary network condition nobody can act on. - // Caught as its own outcome, never folded into the `null` below: that one means OVER - // THE CAP and answers 413, and a dropped connection is not that. - byte[]? body; - try - { - body = await BoundedBody.ReadBytesAsync( - http.Request.Body, MaxWebhookBodyBytes, http.RequestAborted); - } - catch (BadHttpRequestException ex) - { - // 🚨 ANSWER THE EXCEPTION'S OWN STATUS, never a fixed 400. Kestrel raises this same - // type with 413 when its MaxRequestBodySize is exceeded — so hard-coding 400 here - // would turn a server-limit breach into a bad-request, and the identical oversized - // delivery would then report 413 or 400 depending only on which limit noticed it - // first (this endpoint's own cap answers 413 a few lines below). That is precisely - // the conflation this change exists to remove, so the catch must not reintroduce it. - logger.LogDebug(ex, - "GitHub webhook body read failed with {Status}: {Reason}. At 400 the delivery " - + "ended before the declared body arrived — nothing to verify, and GitHub " - + "retries its own failed deliveries.", - ex.StatusCode, ex.Message); - return Results.StatusCode(ex.StatusCode); - } - - if (body is null) - { - logger.LogWarning( - "GitHub webhook body exceeded {MaxBytes} bytes — refused before signature verification.", - MaxWebhookBodyBytes); - return Results.StatusCode(StatusCodes.Status413PayloadTooLarge); - } - - var signature = http.Request.Headers["X-Hub-Signature-256"].ToString(); - var signer = VerifiedSigner(secret, appSecret, body, signature); - if (signer is null) - { - logger.LogWarning("GitHub webhook signature verification failed ({Bytes} bytes).", body.Length); - return Results.Unauthorized(); - } - - var eventType = http.Request.Headers["X-GitHub-Event"].ToString(); - if (!SignerMayDeliver(signer, eventType)) - { - logger.LogWarning( - "GitHub webhook event '{Event}' was signed with the instance GitHub App's secret, which " - + "authenticates push deliveries only — refused.", eventType); - return Results.StatusCode(StatusCodes.Status403Forbidden); - } - if (string.Equals(eventType, "ping", StringComparison.OrdinalIgnoreCase)) - return Results.Ok(new { ok = true, pong = true }); - - using var doc = ParseOrNull(body); - if (doc is null) - { - logger.LogWarning("GitHub webhook body was not valid JSON."); - return Results.BadRequest(); - } - - // Process() reads the payload synchronously into materialized records before returning - // its observable, so the JsonDocument may be disposed once this handler returns. Bridge - // to Task ONCE here at the HTTP boundary, through ObserveCompletion rather than Rx's - // .ToTask() (which resumes inline on the signalling thread — forbidden since - // 2026-08-30); a processing failure is logged, never 500-thrown. - var updated = await processor.Process(eventType, doc.RootElement) - .Catch((Exception ex) => - { - logger.LogWarning(ex, "GitHub webhook processing failed for event {Event}.", eventType); - return Observable.Return(0); - }) - .FirstAsync() - .ObserveCompletion( - ex => logger.LogWarning(ex, - "GitHub webhook processing for event {Event} faulted after the response had " - + "already been sent", eventType), - http.RequestAborted); - - return Results.Ok(new { ok = true, updated }); - }); - - return endpoints; - } - - private static JsonDocument? ParseOrNull(byte[] body) - { - try { return JsonDocument.Parse(body); } - catch (JsonException) { return null; } - } -} diff --git a/memex/Memex.Portal.Shared/StaticRepoSyncExtensions.cs b/memex/Memex.Portal.Shared/StaticRepoSyncExtensions.cs deleted file mode 100644 index bbb80dd7af..0000000000 --- a/memex/Memex.Portal.Shared/StaticRepoSyncExtensions.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.Documentation; -using MeshWeaver.Graph; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Diagnostics; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared; - -/// -/// Wires static-repo → DB synchronization for the partitions selected by -/// : registers each partition's -/// , declares its (so the PG -/// schema is provisioned), and starts the import hosted service that runs -/// on boot. The read-only in-memory static providers -/// for these partitions are skipped at their registration sites (AddAgentType / AddLanguageModelType -/// / AddModelProviderType / AddDocumentation, gated on the same set) so Postgres serves them and -/// accepts the import's writes. No-op when no partition is selected. See -/// Doc/Architecture/StaticRepoImport.md. -/// -public static class StaticRepoSyncExtensions -{ - public static TBuilder AddStaticRepoSync(this TBuilder builder, - IReadOnlySet serveFromPartition, - IReadOnlyDictionary? syncModeOverrides = null) - where TBuilder : MeshBuilder - { - if (serveFromPartition.Count == 0) - return builder; // sync disabled — in-memory serving everywhere, no import. - - // NOTE: partition SCHEMAS are provisioned reactively by the importer itself, via the standard - // IPartitionStorageProvider.EnsurePartitionProvisioned (reactive + pooled). We do NOT declare - // PartitionDefinition nodes here to force a schema — that path provisioned the wrong-case - // schema. See StaticRepoImporter.Run + Doc/Architecture/StaticRepoImport.md. - builder.ConfigureServices(services => - { - if (serveFromPartition.Contains("Doc")) - services.AddSingleton(); - - // AI content sources register themselves: the AI module reads the same - // Features:StaticRepoSync:Partitions this method does and adds its bundle when any AI - // partition is served (AiMeshModuleAttribute). The portal no longer names them (#2276). - - // The boot signal the NodeType pre-warm sweep sequences on: without it the sweep's ONE - // enumeration snapshot can be taken while this import is still landing content, and - // every type it misses is left to compile on a user's first click. See - // StaticRepoImportSettled. - // TryAdd: the AI module registers this same signal for its provider-credential seed - // (AiMeshModuleAttribute), because a module may not depend on a service its host - // happens to have wired. Both sides TryAdd so there is exactly ONE instance whichever - // runs first — two would mean the importer marks a signal nobody is waiting on. - services.TryAddSingleton(); - - // The provider-credential seed rides the AI module for the same reason — it is the - // DB-synced path of a ModelProvider node, which is AI's own business. - - // Runs after the PG schema-provisioning hosted service (registered earlier by - // AddPartitionedPostgreSqlPersistence) — hosted services start in registration order. - // A factory (not AddHostedService) so the deploy-time per-partition mode overrides - // (Features:StaticRepoSync:Modes) reach ImportAll; null/empty = every source keeps its default. - services.AddSingleton(sp => new StaticRepoImportHostedService( - sp.GetRequiredService(), - sp.GetServices(), - syncModeOverrides, - sp.GetService>(), - sp.GetRequiredService())); - return services; - }); - return builder; - } -} - -/// -/// Boot hook that runs once registered -/// s exist. Reactive + fire-and-forget: it does NOT block host -/// startup (the PG schema is already provisioned by the time this StartAsync runs, since the -/// schema hosted service is registered earlier). ImportAll impersonates System and is idempotent -/// (fingerprint short-circuit), so re-runs are cheap. -/// -internal sealed class StaticRepoImportHostedService( - IMessageHub hub, - IEnumerable sources, - IReadOnlyDictionary? syncModeOverrides = null, - ILogger? logger = null, - StaticRepoImportSettled? settled = null) : IHostedService -{ - private IDisposable? _subscription; - - public Task StartAsync(CancellationToken cancellationToken) - { - if (!sources.Any()) - { - settled?.MarkSettled(); - return Task.CompletedTask; - } - - logger?.LogInformation( - "[StaticRepoImport] starting sync-context init for {Count} source(s).", sources.Count()); - // 🚨 SubscribeOn the thread pool — NOT the host-startup thread. The import's reactive chain - // (meshService.Query → CreateNode/Overwrite) round-trips the mesh + per-node hubs; running - // the subscription on the startup thread re-enters the hub schedulers mid-init and DEADLOCKS - // (it hung the whole import). The chain is pure IObservable (no FromAsync/await), so - // SubscribeOn moves the entire subscription cleanly off the startup thread. StartAsync - // returns immediately (Task.CompletedTask) — no async/await, no blocking. See - // Doc/Architecture/AsynchronousCalls.md. - // Pass the built-in AI catalog partitions as the #354 boot-index assertion set: after the - // import, ImportAll verifies each SERVED catalog (Agent/Provider/Harness/Skill that a source - // actually backs this run) is registered in the cross-schema search index, force-re-syncing it - // and surfacing a startup-failure notification for any that materialized but stayed unindexed. - // What this import COSTS, appended to the line that already reports its outcome — no new log - // volume. Cumulative since the import started, so the last partition's line is the whole - // import's bill. The suspected driver of the memex-cloud growth (2.5 GB → 24.5 GB at - // 130–340 MB/min) is this path plus the sync-stream churn it causes, and until now nothing - // inside the process reported what one import cost — the growth was only ever inferrable from - // a container gauge hours later. See MemoryDelta. - var importMemory = MemoryDelta.Start(); - - _subscription = StaticRepoImporter - .ImportAll(hub, logger, syncModeOverrides) - .SubscribeOn(System.Reactive.Concurrency.TaskPoolScheduler.Default) - .Subscribe( - r => logger?.LogInformation( - "[StaticRepoImport] {Partition}: {Outcome} ({Count} node(s)) — {Memory} " - + "(cumulative since import start).", - r.Partition, r.Outcome, r.Count, importMemory), - ex => - { - logger?.LogWarning(ex, "[StaticRepoImport] sync-context init failed."); - // Settle on failure TOO: a faulted import must never park the NodeType bake - // forever. The sweep proceeds and bakes whatever did land. - settled?.MarkSettled(); - }, - () => settled?.MarkSettled()); - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _subscription?.Dispose(); - return Task.CompletedTask; - } -} diff --git a/memex/Memex.Portal.Shared/Storage/MemexInfraOptions.cs b/memex/Memex.Portal.Shared/Storage/MemexInfraOptions.cs deleted file mode 100644 index 02bd452347..0000000000 --- a/memex/Memex.Portal.Shared/Storage/MemexInfraOptions.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Collections.Immutable; - -namespace Memex.Portal.Shared.Storage; - -/// -/// Declarative storage topology for a Memex deployment, loaded from JSON config FILES that live -/// on the shared mounted drive (not k8s ConfigMaps / env) so an operator edits a file on the share -/// and the portal refreshes — no redeploy. Bootstrap is a single pointer: Infra:ConfigDirectory -/// (e.g. /mnt/config); every *.json there is layered as a config source, and a polling -/// reconciler (Observable.Interval re-read + diff — FileSystemWatcher does NOT fire on SMB/Azure Files) -/// applies changes live. -/// -/// This is ADDITIVE and backward-compatible: when no Infra section is present the portal -/// falls back to the existing single Storage + single Postgres wiring (the ACA path is -/// unaffected). The mesh nodes remain the actual data; this file only declares WHERE/HOW that data -/// is stored. -/// -/// Reconciliation scope: -/// -/// ContentMounts reconcile LIVE (add/remove/update content collections on each poll). -/// PgStorages apply at (re)init — the partitioned persistence layer is wired at boot, so a -/// data-source topology change is applied on a graceful re-init / rolling restart. -/// -/// -public sealed record MemexInfraOptions -{ - public const string SectionName = "Infra"; - - /// Directory on the shared mounted drive holding the JSON config files (e.g. /mnt/config). - /// Empty = config-on-drive disabled; the portal uses the legacy single-Storage/single-PG wiring. - public string? ConfigDirectory { get; init; } - - /// How often the polling reconciler re-reads the config files (SMB-safe refresh). - /// FileSystemWatcher is unreliable on Azure Files, so we poll. Default 15s. - public int RefreshSeconds { get; init; } = 15; - - /// Postgres databases exposed as mesh storage. Each is its own mesh_nodes store; the portal - /// routes partitions across them. The first enabled entry is the primary (bootstrap) store. - public ImmutableArray PgStorages { get; init; } = ImmutableArray.Empty; - - /// Content collections, each backed by a mounted file system path (a subPath of a shared - /// Azure Files share present on every pod). Maps onto the existing ContentCollectionConfig surface. - public ImmutableArray ContentMounts { get; init; } = ImmutableArray.Empty; -} - -/// A Postgres database exposed as a mesh storage / data source. -public sealed record MeshPgStorageConfig -{ - /// Logical name for the data source (used in routing / diagnostics). - public required string Name { get; init; } - - /// Connection string. May reference a secret (resolved out of the config-file value at load). - /// Auth is selected by MeshWeaver.Hosting.PostgreSql.AzurePostgres: an Azure host - /// (Host ends in .postgres.database.azure.com) with NO password ⇒ the Entra-ID - /// token path; an Azure host WITH a password, or any non-Azure host ⇒ plain Npgsql. (Never a - /// substring match on the whole string — that both false-matches and ignores the password, - /// which crashes the portal when a token provider meets a password.) - public required string ConnectionString { get; init; } - - /// Disabled entries are ignored by the reconciler (soft remove without deleting the entry). - public bool Enabled { get; init; } = true; -} - -/// A content collection backed by a mounted file-system path. Reconciled live. -public sealed record ContentMountConfig -{ - /// Collection name (e.g. "content", "attachments"). - public required string Name { get; init; } - - /// Absolute mount path on the pod (e.g. /mnt/content). The container collection BasePath is - /// MountPath joined with SubPath. - public required string MountPath { get; init; } - - /// Optional sub-path under the mount (the share is mounted once on all nodes; each collection - /// lives in its own sub-path of it). - public string? SubPath { get; init; } - - /// Content stream-provider source type. Defaults to the FileSystem provider (the mounted drive). - public string SourceType { get; init; } = "FileSystem"; - - /// Whether writes are allowed into this collection. - public bool IsEditable { get; init; } = true; - - /// Disabled entries are dropped by the reconciler. - public bool Enabled { get; init; } = true; - - /// The resolved on-disk base path the content provider uses (MountPath + SubPath). - public string ResolvedBasePath => - string.IsNullOrEmpty(SubPath) ? MountPath : System.IO.Path.Combine(MountPath, SubPath); -} diff --git a/src/MeshWeaver.Documentation/Data/Architecture/CarvingProjectsOutOfCore.md b/src/MeshWeaver.Documentation/Data/Architecture/CarvingProjectsOutOfCore.md index 1441875f2f..8d9bb8e10f 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/CarvingProjectsOutOfCore.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/CarvingProjectsOutOfCore.md @@ -73,27 +73,62 @@ could not be built: Together those convert `MeshWeaver.GitSync` and `MeshWeaver.ContentCollections.Indexing` from "cannot be a module" (their third-party closure had nowhere to live) into "can be". -🚨 **What the lane did NOT change is what pins most of the remaining projects.** They are held by +🚨 **What the lane did NOT change is what pins most of the remaining projects.** They were held by consumers inside core — above all by `memex/Memex.Portal.Shared` — and no build change can release those. Only deleting the reference can. -## The keystone: `memex/Memex.Portal.Shared` +## The keystone: `Memex.Portal.Shared` — moved to plugins 78 files, ~17.3k lines — `Api`, `Authentication`, `Email`, `Pages`, `SelfUpdate`, `Seo`, `Settings`, -`Social`, `Storage` — and it is still in **core**, while all four hosts that consume it -(`Memex.Portal.Gui`, `.Distributed`, `.Monolith`, `Memex.LocalMesh`) live in **plugins**. +`Social`, `Storage` — that sat in **core** while all four hosts that consume it +(`Memex.Portal.Gui`, `.Distributed`, `.Monolith`, `Memex.LocalMesh`) lived in **plugins**. It is now +a SOURCE move to `MeshWeaver.Plugins/src/Memex.Portal.Shared`, with its suite at +`src/Memex.Portal.Shared.Test`; the assembly name is unchanged and it still ships in the portal image. It alone holds 13 core `ProjectReference`s — but **most of them are not real**. Measured: it builds clean without `Observability.Contract`, `Maps`, `ContentCollections.Indexing` and `.Indexing.Graph`. The one candidate it genuinely couples to is **`GitSync`**, through four files — `MemexConfiguration` plus the GitHub login, connect and webhook endpoints. That is authentication, which is precisely why an earlier attempt to move Portal.Shared wholesale was split instead: doing -it exiles sign-in from the platform repo. **That contradiction is still open and is a maintainer -call, not an implementation detail.** +it exiles sign-in from the platform repo. -So the keystone is narrower than it looks: it gates `GitSync`, and it does not gate `Maps` or +**Decided (core slimming): GitHub sign-in moves WITH Portal.Shared.** `MemexConfiguration` and +`Social/GitHub*Endpoints` live in plugins now and still call core's `MeshWeaver.GitSync` library +through a `$(MeshWeaverRoot)` project reference; `GitSync` itself stays in core for now and moves in +a later step. So sign-in is no longer the platform repo's: the platform keeps the library the +endpoints call, and plugins owns the portal surface that signs a person in. + +So the keystone was narrower than it looked: it gated `GitSync`, and it never gated `Maps` or `Indexing`. +### What the move carried with it, and what it left unguarded + +- 🚨 **The `InternalsVisibleTo` grants stay in core.** `MeshWeaver.Graph`, `.Hosting`, + `.Hosting.AspNetCore`, `.Compiler`, `.Mesh.Operations`, `.PluginCatalog` and `.Testcontainers` + name `Memex.Portal.Shared` / `Memex.Portal.Shared.Test`, which are now assemblies of ANOTHER + repository. That is the #2689 shape — a refactor of one of those internals can red the plugins + trunk with no local signal — and what catches it is the required + `Dependent suites (MeshWeaver.Plugins)` verdict, which selects the moved suite by compiled reach. +- **Two guards moved with their subject.** `IdentityReadsRunAsSystemGuard` (its whole subject was + Portal.Shared's onboarding reads) and the `SeoResolver` half of `InlineSvgEmissionBackplateGuard` + now run in `Memex.Portal.Shared.Test`. The platform's svg guard keeps its platform files. +- **Three core ratchets lost inventory, not coverage they could keep.** + `ImpersonationScopeSites.allow` (16 sites in 10 files), `AwaitedMeshReadSites.allow` (its last 2) + and `LayoutAreaDataBakeSites.allow` (1) dropped their Portal.Shared lines and budgets. The plugins + repo runs the layout-area ratchet over `src/` already (core's script, through + `node-repo-validate.yml`); it has **no** compiled-code impersonation-latch or awaited-mesh-read + ratchet over `src/` (its `check-impersonation.py` scans in-mesh `Source/` only), so those 18 sites + are now guarded by nothing until one is ported. +- **Plugins' stricter `src/` gates applied on arrival.** Every one-shot `.Subscribe` in the settings + tabs gained an error arm (`check-subscribe-error-arm.py` is zero-tolerance there; core never + scanned `memex/`), and `check-register-before-post.py` learned that a lone `hub` argument is the + IMessageHub a static reader is handed, never a delivery. +- **Many of the suite's tests are about PLATFORM subjects** (PluginCatalog landing, module + activation, search refusal, token validation) and use Portal.Shared only as the host that composes + them. They moved whole, as the suite did; by the rule in *Splitting a test that spans the + boundary* below they belong where their subject lives, and splitting them back into a core suite + is the follow-up. Until then the dependent-suites verdict is what runs them on a core change. + The second-order pin behind it is `tools/MeshWeaver.PluginTester` — core-owned, shipped as the gate image — which directly references `Indexing.Graph`, `GitSync`, `Maps`, `Mesh.Operations` and `PluginCatalog`. Since it gained `--module` those references are convertible rather than fatal, but @@ -133,10 +168,10 @@ delivery event, not a refactor, and it is why these do not get done casually or | `Maps` | 116 | **yes** | in-mesh sample content (`Cornerstone/Pricing`), 5 test meshes, PluginTester | module + identity flip | | `ContentCollections.Indexing` | 1 851 | **yes** | one `using` in `Mesh.Operations`; in-mesh content | module + identity flip | | `ContentCollections.Indexing.Graph` | 2 253 | **yes** | PluginTester; in-mesh content | with the above | -| `GitSync` | 9 649 | **yes** | `PluginCatalog`, 4 real files in Portal.Shared (**GitHub login = authentication**) | module + identity flip; auth ⇒ `Modules:Required` | -| `PluginCatalog` | 21 245 | **yes** | Portal.Shared, PluginTester, ComboAssembler/Verifier | **stays** — it *is* the plugin system | +| `GitSync` | 9 649 | **yes** | `PluginCatalog` (Portal.Shared's GitHub sign-in now calls it from plugins) | module + identity flip; auth ⇒ `Modules:Required` | +| `PluginCatalog` | 21 245 | **yes** | PluginTester, ComboAssembler/Verifier | **stays** — it *is* the plugin system | | `Documentation` | 1 670 | no | the doc gate; settled decision | **stays** | -| `Mesh.Operations` | 4 726 | **yes** | Portal.Shared, LocalMesh, PluginTester | **stays** | +| `Mesh.Operations` | 4 726 | **yes** | PluginTester (Portal.Shared and LocalMesh consume it from plugins) | **stays** | Everything not listed is kernel: messaging, data, layout, the mesh contract, hosting, the compiler and NuGet resolution, packaging. diff --git a/test/AwaitedMeshReadSites.allow b/test/AwaitedMeshReadSites.allow index 09f2ffdbe1..5e988c4552 100644 --- a/test/AwaitedMeshReadSites.allow +++ b/test/AwaitedMeshReadSites.allow @@ -40,10 +40,8 @@ # partition-root User node PRE-AUTH, on the ASP.NET request thread, before any hub turn exists to # park. Migrating it means reshaping the sign-in action around a subscription; worth doing, not # part of #3433. -memex/Memex.Portal.Shared/Authentication/DevAuthController.cs 1 # # UserRoleResolver.LoadDbRolesAsync — called from OnboardingMiddleware during authentication, i.e. # the same ASP.NET request pipeline. Its own remarks already treat the read as an availability # question with a classified outcome (IdentityReadOutcome.Unavailable), which is the #3433 lesson # applied — only the Task shape is left. -memex/Memex.Portal.Shared/Authentication/UserRoleResolver.cs 1 diff --git a/test/ImpersonationScopeSites.allow b/test/ImpersonationScopeSites.allow index d37a8037b5..d21a103713 100644 --- a/test/ImpersonationScopeSites.allow +++ b/test/ImpersonationScopeSites.allow @@ -37,16 +37,6 @@ # # format: -memex/Memex.Portal.Shared/Api/SeoEndpoints.cs 1 -memex/Memex.Portal.Shared/Authentication/ApiTokenService.cs 1 -memex/Memex.Portal.Shared/Authentication/BootstrapController.cs 1 -memex/Memex.Portal.Shared/Authentication/InvitationService.cs 2 -memex/Memex.Portal.Shared/Authentication/MeshWeaverInstanceService.cs 3 -memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs 1 -memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs 3 -memex/Memex.Portal.Shared/Email/InvitationEmailSender.cs 2 -memex/Memex.Portal.Shared/Email/OutboundEmailSender.cs 1 -memex/Memex.Portal.Shared/Settings/InboxSettingsTab.cs 1 src/MeshWeaver.GitSync/GitHubSyncService.cs 2 src/MeshWeaver.GitSync/GitHubWebhookProcessor.cs 3 src/MeshWeaver.Compiler.Pipeline/CellSurfaceAssemblyProvider.cs 2 diff --git a/test/LayoutAreaDataBakeSites.allow b/test/LayoutAreaDataBakeSites.allow index 0a52fb7499..1679d0b141 100644 --- a/test/LayoutAreaDataBakeSites.allow +++ b/test/LayoutAreaDataBakeSites.allow @@ -3,7 +3,6 @@ # Convert an area to a template that BINDS its data (Doc/GUI/DataBinding → "Templates first, data # later"), then lower or delete its line and lower TotalBudget in the guard in the same change. # Never add a line: a new area is born a template. -memex/Memex.Portal.Shared/Settings/NotificationsSettingsTab.cs 1 samples/Graph/Data/ACME/Project/Source/ProjectLayoutAreas.cs 5 src/MeshWeaver.Graph/ExportLayoutArea.cs 1 src/MeshWeaver.Graph/MarkdownNotebookLayoutArea.cs 1 diff --git a/test/Memex.Portal.Shared.Test/ADeclaredProtectedSegmentSurvivesTheBootHealTest.cs b/test/Memex.Portal.Shared.Test/ADeclaredProtectedSegmentSurvivesTheBootHealTest.cs deleted file mode 100644 index 8c7bffc16c..0000000000 --- a/test/Memex.Portal.Shared.Test/ADeclaredProtectedSegmentSurvivesTheBootHealTest.cs +++ /dev/null @@ -1,351 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The declared-access step must not publish a partition over a protected segment, and its -/// legacy heal must not DELETE the protection — MeshWeaver#4716. -/// -/// The live shape this is about. The Feedback package is pre-installed and its -/// manifest declares protectedSegments: ["_Submissions"] — a submission inbox on an otherwise -/// public partition. Core could not see that declaration at all -/// (NodeRepoPackageSource.Peek dropped it, the dead-metadata defect class preInstalled, -/// publicSegments and contactEmail each had), so two things followed and both were core's: -/// the partition took the fully-public shape, under which a Public/Anonymous deny is inert on the C# -/// read path (AnonymousCannotReadAProtectedSubmissionTest measures the exposure and its fix); -/// and ContradictingDenies read the plugin machinery's own denies as pre-#902 damage, so the -/// boot repair pass would RETIRE them and rewrite the policy to PublicRead. -/// -/// Why two arms. The declaration-driven arm is the forward fix. The EVIDENCE-driven arm is -/// the one a live portal actually needs: the boot repair pass re-drives this step from the INSTALL -/// RECORD's stored manifest, and every record stamped before ProtectedSegments was read carries -/// no declaration — so a fix that only reads the manifest would leave every already-installed portal -/// republishing its inbox on the next boot. Both arms must converge on the same nodes. -/// -/// Node shapes only. What the shape ANSWERS for an anonymous reader is measured in -/// MeshWeaver.Graph.Test/AnonymousCannotReadAProtectedSubmissionTest, against a mesh that does -/// not grant Public Admin; this fixture's job is that the installer WRITES that shape and never -/// dismantles it. -/// -public class ADeclaredProtectedSegmentSurvivesTheBootHealTest(ITestOutputHelper output) - : MonolithMeshTestBase(output) -{ - private const string Declaring = "DeclaringPkg"; - private const string Stale = "StalePkg"; - private const string Closed = "ClosedPkg"; - private const string Inbox = "_Submissions"; - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - private IMeshService MeshService => Mesh.ServiceProvider.GetRequiredService(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - private ILogger Logger => Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - /// The manifest of a pre-installed package that declares its inbox protected. - private static PackageManifest Manifest(string id, bool declaresProtection) => new() - { - Id = id, - Kind = PackageKind.NodeRepo, - TargetPartition = id, - PreInstalled = true, - ProtectedSegments = declaresProtection ? [Inbox] : [], - }; - - /// Authoritative single read off storage (never the lagging index); null when absent. - private Task Read(string path) => - Mesh.ServiceProvider.GetRequiredService() - .Read(path, Mesh.JsonSerializerOptions) - .Take(1) - .DefaultIfEmpty(null) - .Timeout(60.Seconds()) - .Await(TestContext.Current.CancellationToken); - - private Task WriteAsSystem(MeshNode node) => - Access.RunAsSystem(() => MeshService.CreateOrUpdateNode(node)) - .Should().Within(60.Seconds()) - .Emit($"writing the precondition node '{node.Path}' must land", - cancellationToken: TestContext.Current.CancellationToken); - - private Task Establish(PackageManifest manifest, string partition) => - Access.RunAsSystem(() => PackageInstaller.EnsureDeclaredAccess( - Mesh, manifest, partition, Logger)) - .Should().Within(120.Seconds()) - .Emit("the declared-access step must complete", - cancellationToken: TestContext.Current.CancellationToken); - - private async Task PolicyPublicRead(string partition) - { - var policy = await Read($"{partition}/_Policy"); - return policy?.ContentAs(Mesh.JsonSerializerOptions)?.PublicRead; - } - - private static string Deny(string scope, string subject) => $"{scope}/_Access/{subject}_Access"; - - /// - /// The DECLARATION-driven arm. A pre-installed manifest naming a protected segment is published - /// through root Public/Anonymous Viewer GRANTS with a deny pair on the segment and a _Policy - /// that WITHHOLDS public read — never the blanket PublicRead policy, which would leave the - /// deny pair inert on the C# read path. - /// - [Fact(Timeout = 300_000)] - public async Task ADeclaredProtectedSegment_IsPublishedThroughGrantsAndNeverThroughAPublicReadPolicy() - { - // Referenced so the method honours its own Timeout (xUnit1069); every helper below threads - // TestContext.Current.CancellationToken through its own await. - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - - await WriteAsSystem(new MeshNode(Declaring) { NodeType = "Space", State = MeshNodeState.Active }); - await WriteAsSystem(new MeshNode("Cover", Declaring) { NodeType = "Markdown", State = MeshNodeState.Active }); - await WriteAsSystem(new MeshNode("theirs", $"{Declaring}/{Inbox}") - { NodeType = "Markdown", State = MeshNodeState.Active }); - - await Establish(Manifest(Declaring, declaresProtection: true), Declaring); - - (await PolicyPublicRead(Declaring)).Should().BeFalse( - "THE assertion: the policy must exist (it is the declared-access post-condition marker) and " - + "must WITHHOLD public read. A PublicRead policy here is the exposure — the C# evaluator ORs " - + "it in after the deny subtraction, so the deny pair below would protect nothing on that " - + "path while the SQL fold honoured it: one segment, two answers (MeshWeaver#4716)"); - - (await Read(Deny($"{Declaring}/{Inbox}", WellKnownUsers.Public))).Should().NotBeNull( - "the declared segment is gated for Public…"); - (await Read(Deny($"{Declaring}/{Inbox}", WellKnownUsers.Anonymous))).Should().NotBeNull( - "…and for Anonymous. The installer's own child walk deliberately skips `_` satellites, so " - + "without the declaration being READ nothing in core ever writes these"); - - (await Read(Deny(Declaring, WellKnownUsers.Public))).Should().NotBeNull( - "and the catalog half is still published — through a root GRANT, the one mechanism the C# " - + "evaluator and the SQL longest-prefix fold resolve identically"); - (await Read(Deny(Declaring, WellKnownUsers.Anonymous))).Should().NotBeNull( - "both well-known subjects, or a logged-out visitor loses the public half"); - - // Idempotence: the step runs on every install path AND as a boot re-assert, so a second pass - // must not undo the first. This is the shape the repair pass drives. - await Establish(Manifest(Declaring, declaresProtection: true), Declaring); - (await PolicyPublicRead(Declaring)).Should().BeFalse( - "a re-assert converges — the protection is not a one-shot that the next boot undoes"); - (await Read(Deny($"{Declaring}/{Inbox}", WellKnownUsers.Public))).Should().NotBeNull( - "and the deny pair survives its own re-assert"); - } - - /// - /// The create-only rule, which is the difference between MOVING a publication and ADDING one. A - /// pre-installed partition whose policy ALREADY withholds public read is one this step leaves - /// closed, so a declaration must gate the segment and open nothing. - /// - /// That is not hypothetical: on the control instance memex.systemorph.com, read 2026-09-21, - /// Feedback/_Policy carries no publicRead and the partition has no - /// _Submissions at all. Publishing it from the declaration would have handed an anonymous - /// reader a partition somebody had closed — a widening introduced by the fix for an exposure, - /// which is the worst shape a security change can take. - /// - [Fact(Timeout = 300_000)] - public async Task ADeclaredProtectedSegment_OnAPartitionThatWithholdsPublicRead_IsGatedButNotOpened() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - - await WriteAsSystem(new MeshNode(Closed) { NodeType = "Space", State = MeshNodeState.Active }); - await WriteAsSystem(new MeshNode("Cover", Closed) { NodeType = "Markdown", State = MeshNodeState.Active }); - await WriteAsSystem(new MeshNode("theirs", $"{Closed}/{Inbox}") - { NodeType = "Markdown", State = MeshNodeState.Active }); - await WriteAsSystem(new MeshNode("_Policy", Closed) - { - NodeType = "PartitionAccessPolicy", - Name = "Access Policy", - State = MeshNodeState.Active, - Content = new PartitionAccessPolicy { PublicRead = false }, - }); - - await Establish(Manifest(Closed, declaresProtection: true), Closed); - - (await Read(Deny($"{Closed}/{Inbox}", WellKnownUsers.Public))).Should().NotBeNull( - "the gate is written either way — a deny can only ever NARROW, so establishing it needs no " - + "permission to widen anything"); - (await Read(Deny($"{Closed}/{Inbox}", WellKnownUsers.Anonymous))).Should().NotBeNull( - "both halves of the pair"); - - (await Read(Deny(Closed, WellKnownUsers.Public))).Should().BeNull( - "THE assertion: no root grant. This step would not have opened this partition with a policy " - + "either (create-only leaves a policy that withholds public read alone), so it must not " - + "open it with grants — the shape exists to MOVE a publication off the policy, never to " - + "add one (MeshWeaver#4716)"); - (await Read(Deny(Closed, WellKnownUsers.Anonymous))).Should().BeNull( - "and the same for Anonymous"); - (await PolicyPublicRead(Closed)).Should().BeFalse( - "the policy is untouched — it already said what the shape needs it to say"); - } - - /// - /// 🚨 THE CASE THAT WOULD HAVE CAUGHT IT. The EVIDENCE-driven arm: the partition is in the - /// protected shape and the manifest driving the re-assert declares NOTHING — the state of every - /// portal whose install record was stamped before ProtectedSegments was read. The step must - /// leave the denies alone and must not write PublicRead. - /// - /// On main both halves fail: ContradictingDenies matches the two satellite denies - /// as the pre-#902 gate, Retire DELETES them, and the policy is healed to - /// PublicRead = true — a submission inbox republished by a boot pass, logged at Information - /// as a legacy migration. - /// - [Fact(Timeout = 300_000)] - public async Task AnUndeclaredButProtectedSegment_IsNotRetiredAndNotRepublishedByTheHeal() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - - await WriteAsSystem(new MeshNode(Stale) { NodeType = "Space", State = MeshNodeState.Active }); - await WriteAsSystem(new MeshNode("Cover", Stale) { NodeType = "Markdown", State = MeshNodeState.Active }); - await WriteAsSystem(new MeshNode("theirs", $"{Stale}/{Inbox}") - { NodeType = "Markdown", State = MeshNodeState.Active }); - - // The policy the partition already carries, WITHHOLDING public read — the live shape on - // memex.meshweaver.cloud (`Feedback/_Policy`, read 2026-09-21: content with no `publicRead`, - // written by `system-security` 1.6 s before the deny pair below). Without it `current` is null - // here, the heal's retire arm is never reached, and the assertion on the denies surviving - // passes for the wrong reason. - await WriteAsSystem(new MeshNode("_Policy", Stale) - { - NodeType = "PartitionAccessPolicy", - Name = "Access Policy", - State = MeshNodeState.Active, - Content = new PartitionAccessPolicy { PublicRead = false }, - }); - - // The protection another component put there — the plugin machinery's ProtectedSegments arm. - await WriteAsSystem(PackageInstaller.ViewerAssignment( - $"{Stale}/{Inbox}", WellKnownUsers.Public, denied: true)); - await WriteAsSystem(PackageInstaller.ViewerAssignment( - $"{Stale}/{Inbox}", WellKnownUsers.Anonymous, denied: true)); - - // …and one deny the installer's OWN scoped shape could have written: pre-#902 damage on an - // ordinary child. The heal must still retire THAT — a control on each side of the split, so the - // fix cannot be "stop healing" wearing the right answer's clothes. - await WriteAsSystem(new MeshNode("Legacy", Stale) { NodeType = "Markdown", State = MeshNodeState.Active }); - await WriteAsSystem(PackageInstaller.ViewerAssignment( - $"{Stale}/Legacy", WellKnownUsers.Public, denied: true)); - - await Establish(Manifest(Stale, declaresProtection: false), Stale); - - (await Read(Deny($"{Stale}/{Inbox}", WellKnownUsers.Public))).Should().NotBeNull( - "THE assertion: a deny on an `_` satellite is outside the legacy fingerprint BY " - + "CONSTRUCTION — the installer's own child walk has never written one — so it can only be " - + "a live protection, and retiring it deletes security state a boot pass had no business " - + "touching (MeshWeaver#4716)"); - (await Read(Deny($"{Stale}/{Inbox}", WellKnownUsers.Anonymous))).Should().NotBeNull( - "both halves of the pair, or a logged-out visitor reads the inbox through the surviving gap"); - - (await PolicyPublicRead(Stale)).Should().BeFalse( - "and the OTHER half of the same defect: writing PublicRead here needs no delete at all to " - + "republish the segment, because under that policy the surviving denies withhold nothing on " - + "the C# read path. So the step must decline the blanket policy purely on the evidence, " - + "without any manifest telling it to"); - - (await Read(Deny($"{Stale}/Legacy", WellKnownUsers.Public))).Should().BeNull( - "the control on the other side of the split: a deny on an ORDINARY child IS the pre-#902 " - + "fingerprint (#902's own incident — 136 legacy denies over 8 pre-installed partitions), " - + "and the heal still retires it. Without this the narrowing could have been a blanket " - + "'never retire anything' and read as a pass"); - } -} - -/// -/// The pure discriminators, pinnable without a mesh — what separates a live protection from the -/// pre-#902 fingerprint, and what a manifest's declaration resolves to (MeshWeaver#4716). -/// -public class ProtectedSegmentDiscriminatorTest -{ - private const string Partition = "Feedback"; - - [Theory] - // The live shape on memex.meshweaver.cloud, read 2026-09-21: the deny pair the plugin machinery - // wrote for Feedback's declared `_Submissions`. Never the installer's own — it skips `_` segments. - [InlineData("Feedback/_Submissions/_Access/Public_Access", true)] - [InlineData("Feedback/_Submissions/_Access/Anonymous_Access", true)] - // Deeper still inside a satellite — the same conclusion. - [InlineData("Feedback/_Submissions/Triaged/_Access/Public_Access", true)] - // The partition's OWN _Access container is not a satellite scope in this sense: a deny there gates - // the partition root, which IS the shape the heal exists to undo. - [InlineData("Feedback/_Access/Public_Access", false)] - // An ordinary child — the pre-#902 fingerprint proper. - [InlineData("Feedback/Issues/_Access/Public_Access", false)] - [InlineData("Feedback/Issues/Deep/_Access/Anonymous_Access", false)] - // Outside the partition entirely. - [InlineData("Other/_Submissions/_Access/Public_Access", false)] - public void ASatelliteScopedDeny_IsRecognisedExactlyWhereTheInstallerCouldNotHaveWrittenIt( - string path, bool expected) => - PackageInstaller.IsSatelliteScopedDeny(path, Partition).Should().Be(expected, - "the split decides whether a boot pass DELETES the node, so it has to be the constructive " - + "one: may this sweep's own past self have written it?"); - - [Fact] - public void ADeclaredProtectedSegment_ResolvesToAFullPathUnderThePartition() => - PackageInstaller.DeclaredProtectedPaths( - new PackageManifest { Id = Partition, ProtectedSegments = ["_Submissions"] }, Partition) - .Should().Equal(["Feedback/_Submissions"], - "the `_` prefix is NOT stripped and NOT skipped — a submission inbox lives on a " - + "satellite, and skipping it is what left the declaration unhonoured"); - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("..")] - [InlineData(".")] - [InlineData("_Sub/missions")] - public void AMalformedDeclaration_IsDroppedRatherThanEscapingThePartition(string segment) => - PackageInstaller.DeclaredProtectedPaths( - new PackageManifest { Id = Partition, ProtectedSegments = [segment] }, Partition) - .Should().BeEmpty( - "one segment, inside this partition — never a traversal and never a foreign path"); - - [Fact] - public void ADeclaringPreInstalledManifest_KeepsThePolicyAsItsDeclaredAccessMarker() => - PackageInstaller.DeclaredAccessMarker( - new PackageManifest { Id = Partition, PreInstalled = true, ProtectedSegments = ["_Submissions"] }, - Partition) - .Should().Be("Feedback/_Policy", - "the open-with-protected-segments shape writes a _Policy too — one that withholds " - + "PublicRead — precisely so the post-condition marker keeps holding. A marker naming a " - + "node the chosen shape does not write turns the post-condition into an error on a " - + "correct install"); - - [Fact] - public void ABothDeclaringPreInstalledManifest_TakesTheGrantMarker() => - PackageInstaller.DeclaredAccessMarker( - new PackageManifest - { - Id = Partition, - PreInstalled = true, - PublicSegments = ["Issues"], - ProtectedSegments = ["_Submissions"], - }, - Partition) - .Should().Be("Feedback/_Access/Public_Access", - "a protected segment forces the SCOPED shape even for a pre-installed manifest that also " - + "declares public segments, and that shape writes root grants and NO policy — so a " - + "marker naming _Policy would report a CORRECT install as a failure. The marker must " - + "mirror the branch predicate, never restate it (found in review on MeshWeaver#4716)"); - - [Fact] - public void ADeclarationOfNothing_IsNotADeclarationOfProtection() => - PackageInstaller.DeclaredProtectedPaths(new PackageManifest { Id = Partition }, Partition) - .Should().BeEmpty( - "the default must never route a package onto the grant-based shape by accident — " - + "every partition that does not ask for an exception keeps the fully-public policy"); -} diff --git a/test/Memex.Portal.Shared.Test/ADefaultAppDeclarationSurvivesDiscoveryTest.cs b/test/Memex.Portal.Shared.Test/ADefaultAppDeclarationSurvivesDiscoveryTest.cs deleted file mode 100644 index 2ca840f670..0000000000 --- a/test/Memex.Portal.Shared.Test/ADefaultAppDeclarationSurvivesDiscoveryTest.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The defaultApp declaration has to survive DISCOVERY: NodeRepoPackageSource reading -/// content.defaultApp off a package's index.json is the hop that feeds the default-apps -/// lane, and a field present in the authored manifest but dropped here is the silent -/// "authored, never read" defect (the same class as preInstalled and protectedSegments). -/// Only a literal true opts in. -/// -public class ADefaultAppDeclarationSurvivesDiscoveryTest -{ - private static string Index(string id, string? extra) => $$""" - { - "$type": "MeshNode", - "id": "{{id}}", - "namespace": "", - "path": "{{id}}", - "name": "{{id}}", - "nodeType": "Store/Plugin", - "state": "Active", - "content": { - "$type": "PluginContent", - "app": true, - "preInstalled": true, - "tier": "free"{{extra}} - } - } - """; - - private static Task> Listed() => - new NodeRepoPackageSource( - fetch: (_, _, _, _) => Observable.Return(new RepoSnapshot("deadbeef", new List - { - new("Threads/index.json", Index("Threads", ",\n \"defaultApp\": true")), - new("Silent/index.json", Index("Silent", null)), - new("Stringly/index.json", Index("Stringly", ",\n \"defaultApp\": \"true\"")), - new("Off/index.json", Index("Off", ",\n \"defaultApp\": false")), - })), - repoUrl: "https://github.com/Systemorph/MeshWeaver.Plugins", - tokenProvider: () => Observable.Return(string.Empty)) - .ListPackages("HEAD").FirstAsync().Timeout(TestTimeouts.Quick).Await(TestContext.Current.CancellationToken); - - [Fact(Timeout = 60_000)] - public async Task OnlyALiteralTrue_MarksTheManifestAsADefaultApp() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - - var listed = await Listed(); - - listed.Single(m => m.Id == "Threads").DefaultApp.Should().BeTrue("the authored declaration reaches the manifest"); - listed.Single(m => m.Id == "Threads").PreInstalled.Should().BeTrue("the control on the same read"); - listed.Single(m => m.Id == "Silent").DefaultApp.Should().BeFalse("a root declaring nothing is no default app"); - listed.Single(m => m.Id == "Stringly").DefaultApp.Should().BeFalse("a malformed value never opts a package in"); - listed.Single(m => m.Id == "Off").DefaultApp.Should().BeFalse(); - } -} diff --git a/test/Memex.Portal.Shared.Test/ADeploymentNamedCommercialPackageInstallsTest.cs b/test/Memex.Portal.Shared.Test/ADeploymentNamedCommercialPackageInstallsTest.cs deleted file mode 100644 index 9a8c81009e..0000000000 --- a/test/Memex.Portal.Shared.Test/ADeploymentNamedCommercialPackageInstallsTest.cs +++ /dev/null @@ -1,404 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A commercial package the DEPLOYMENT CONFIGURATION names exactly is INSTALLED at boot -/// — policy deployment-config-authorizes-named-commercial. -/// -/// The deployment record (PluginCatalog:InstallByDefault) changes only by a reviewed, -/// App-merged pull request, so it is the authorizing principal — -/// deployment-config:{instance} () — for a package it -/// names EXACTLY and for what that package requires (commercial requirements included). A -/// whole-source pattern names nothing: a commercial package it merely covers is still skipped. -/// The ledger still seeds once, and records the principal under authorized. -/// -public class ADeploymentNamedCommercialPackageInstallsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string SourceName = "registered-0"; - private const string Instance = "client-a-test"; - private const string Principal = PackageEntitlement.DeploymentConfigPrefix + Instance; - private const string LedgerPath = PackageInstaller.InstalledPartition + "/_DefaultInstallLedger"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(_ => new NodeRepoPackageSource( - (_, _, _, _) => Observable.Return(new RepoSnapshot("commit-deployment-named", Repo)), - "https://github.com/acme/plugins")) - // The ClientA shape: the whole source as a seed AND one commercial module named - // exactly — the named one is the decision, the wildcard is not. - .AddSingleton(new PluginCatalogOptions - { - InstanceId = Instance, - InstallByDefault = [$"{SourceName}/*", $"{SourceName}/Claims"], - InstallPreInstalledPackages = false, - })); - - private static RepoFile Index(string id, string extraContent) => new( - $"{id}/index.json", - $$"""{"$type":"MeshNode","id":"{{id}}","namespace":"","path":"{{id}}","mainNode":"{{id}}","name":"{{id}}","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"{{id}}.","minMeshVersion":"1.0.0"{{extraContent}} } }"""); - - private static RepoFile Page(string id) => new( - $"{id}/Page.json", - $$"""{"$type":"MeshNode","id":"Page","namespace":"{{id}}","path":"{{id}}/Page","mainNode":"{{id}}/Page","name":"Page","nodeType":"Markdown","state":"Active","content":"# {{id}}"}"""); - - /// - /// Free installs anyway; Claims (priced) is NAMED and requires ClaimsCore (contact-sales, so - /// commercial without a price); Other (priced) is covered only by the wildcard. - /// - private static readonly ImmutableList Repo = - [ - Index("Free", ""), Page("Free"), - Index("Claims", ",\"price\":900,\"currency\":\"CHF\",\"requires\":[\"ClaimsCore\"]"), Page("Claims"), - Index("ClaimsCore", ",\"contactEmail\":\"sales@acme.test\""), Page("ClaimsCore"), - Index("Other", ",\"price\":490,\"currency\":\"CHF\""), Page("Other"), - ]; - - [Fact(Timeout = 240_000)] - public async Task ANamedCommercialPackage_AndItsCommercialRequirement_InstallAtBoot_AWildcardOnlyOneIsSkipped() - { - var ct = TestContext.Current.CancellationToken; - var installer = Mesh.ServiceProvider.GetRequiredService(); - - // PASS 1 — the first boot of a fresh instance. - var first = await installer.Completed - .FirstAsync().Timeout(TimeSpan.FromSeconds(180)).Await(ct); - Output.WriteLine($"pass 1: {first}"); - - first.Failed.Should().Be(0); - first.Delivered.Should().Contain("Free"); - first.Delivered.Should().Contain("Claims", - "a commercial package the deployment configuration names exactly is authorized by it"); - first.Delivered.Should().Contain("ClaimsCore", - "a commercial REQUIREMENT of a named package is authorized with it — the named module " - + "does not work without it"); - first.Skipped.Select(s => s.Package).Should().Equal(["Other"], - "a whole-source pattern names nothing: a commercial package it merely covers stays skipped"); - first.Skipped.Single().Reason.Should().Contain("InstallByDefault", - "the skip reason names the remedy — naming the package exactly"); - first.Authorized.Select(a => a.Package).OrderBy(x => x).Should().Equal("Claims", "ClaimsCore"); - - // THE INSTALL RECORD names the principal — the unattended update re-verifies this one. - var record = await Mesh.ServiceProvider.GetRequiredService() - .Read($"{PackageInstaller.InstalledPartition}/Claims", Mesh.JsonSerializerOptions) - .Take(1).Timeout(TestTimeouts.Convergence).Await(ct); - record.Should().NotBeNull("the named commercial package landed with its record"); - record!.ContentAs(Mesh.JsonSerializerOptions)!.AuthorizedBy - .Should().Be(Principal, "the record says WHO authorized the install: the deployment configuration"); - - // INSTALLING GRANTS NOBODY ACCESS — a commercial partition lands gated, no public read. - (await Mesh.ServiceProvider.GetRequiredService() - .Read(PackageInstaller.CoverGrantPath("Claims"), Mesh.JsonSerializerOptions) - .Take(1).Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().BeNull("who reads a commercial package stays the plan's and the subscriptions' decision"); - - // THE LEDGER records it — principal and reason — beside the seeded list. - var ledger = await ReadLedger().WaitAsync(ct); - ledger.Should().NotBeNull(); - ledger!.Seeded.Should().Contain("Claims").And.Contain("ClaimsCore").And.Contain("Free"); - ledger.Skipped.Select(s => s.Package).Should().Equal(["Other"]); - var claims = ledger.Authorized.Single(a => a.Package == "Claims"); - claims.InstalledBy.Should().Be(Principal); - claims.Reason.Should().Contain($"'{SourceName}/Claims'", "the reason quotes the entry that named it"); - ledger.Authorized.Single(a => a.Package == "ClaimsCore").Reason.Should().Contain("required by 'Claims'"); - - // THE PACKAGE IS GONE — the seed must not bring it back. The record is removed straight - // from storage ON PURPOSE: the uninstall lifecycle (PackageUninstallExecutor) also writes an - // "uninstalled here" marker that blocks every unattended lane by itself, which would make - // this leg pass without the LEDGER — the thing under test — doing anything. Nothing below - // reads the record through a hub or a cache: the seeded package is dropped before any read. - await Mesh.ServiceProvider.GetRequiredService() - .Delete($"{PackageInstaller.InstalledPartition}/Claims") - .Take(1).Timeout(TestTimeouts.Convergence).Await(ct); - - var second = await installer.RunDefaultInstall() - .FirstAsync().Timeout(TimeSpan.FromSeconds(180)).Await(ct); - Output.WriteLine($"pass 2: {second}"); - second.Packages.Should().NotContain("Claims", - "the ledger records Claims as seeded: a named commercial package is seeded ONCE, and an " - + "operator's removal is not undone by the next restart"); - second.Skipped.Select(s => s.Package).Should().Equal(["Other"], - "the wildcard-only commercial package is still re-classified, not installed"); - - var after = await ReadLedger().WaitAsync(ct); - after!.Authorized.Single(a => a.Package == "Claims").InstalledAt.Should().Be(claims.InstalledAt, - "the authorization trail is cumulative — the original decision stays on record"); - } - - /// The gate verifies the principal against the CURRENT configuration — never its string. - [Fact(Timeout = 120_000)] - public async Task TheGate_AdmitsOnlyWhatTheConfigurationNames_OnThisInstance() - { - var ct = TestContext.Current.CancellationToken; - var claims = new PackageManifest { Id = "Claims", Source = SourceName, Price = 900m, Currency = "CHF" }; - var other = new PackageManifest { Id = "Other", Source = SourceName, Price = 490m, Currency = "CHF" }; - - await PackageEntitlement.Authorize(Mesh, claims, Principal) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - var wildcardOnly = async () => await PackageEntitlement.Authorize(Mesh, other, Principal) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - (await wildcardOnly.Should().ThrowAsync()) - .Which.Message.Should().Contain("whole-source pattern"); - - var anotherInstance = async () => await PackageEntitlement - .Authorize(Mesh, claims, PackageEntitlement.DeploymentConfigPrefix + "someone-else") - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - (await anotherInstance.Should().ThrowAsync()) - .Which.Message.Should().Contain("another instance"); - } - - /// - /// A manifest that names NO source (one listed straight off a watched repository) is refused - /// even for an installed, named package: a package id alone never authorizes, because another - /// source may list the same id and would inherit the configured source's authorization. - /// - [Fact(Timeout = 240_000)] - public async Task AManifestWithoutASource_IsRefused_EvenForAnInstalledNamedPackage() - { - var ct = TestContext.Current.CancellationToken; - await Mesh.ServiceProvider.GetRequiredService().Completed - .FirstAsync().Timeout(TimeSpan.FromSeconds(180)).Await(ct); - - var sourceless = async () => await PackageEntitlement - .Authorize(Mesh, new PackageManifest { Id = "Claims", Price = 900m, Currency = "CHF" }, Principal) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - (await sourceless.Should().ThrowAsync()) - .Which.Message.Should().Contain("without a source"); - // …and a REQUIREMENT of a named package is no exception: ClaimsCore is admitted only - // under its own source, never by id from a source-less (or other-source) listing. - var sourcelessRequirement = async () => await PackageEntitlement - .Authorize(Mesh, new PackageManifest { Id = "ClaimsCore", ContactEmail = "sales@acme.test" }, Principal) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - await sourcelessRequirement.Should().ThrowAsync(); - // …and a source-less manifest that OMITS the commercial fields (reads as free) is still - // verified under this principal: the free fast path never applies to it. - var sourcelessFree = async () => await PackageEntitlement - .Authorize(Mesh, new PackageManifest { Id = "Claims" }, Principal) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - await sourcelessFree.Should().ThrowAsync(); - // A source-STAMPED manifest that has genuinely become free takes the free path — even for a - // package the configuration does not name. - await PackageEntitlement - .Authorize(Mesh, new PackageManifest { Id = "Other", Source = SourceName, Price = 0m }, Principal) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - // …and a source-less price-0 refusal does not call it "price 0". - var zero = async () => await PackageEntitlement - .Authorize(Mesh, new PackageManifest { Id = "Claims", Price = 0m }, Principal) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - (await zero.Should().ThrowAsync()) - .Which.Message.Should().NotContain("commercial (price 0)"); - var otherSourceRequirement = async () => await PackageEntitlement - .Authorize(Mesh, new PackageManifest { Id = "ClaimsCore", Source = "elsewhere", ContactEmail = "sales@acme.test" }, Principal) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - await otherSourceRequirement.Should().ThrowAsync(); - await PackageEntitlement - .Authorize(Mesh, new PackageManifest { Id = "ClaimsCore", Source = SourceName, ContactEmail = "sales@acme.test" }, Principal) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - } - - [Fact] - public void ThePrincipal_IsRecognisedByShapeOnly() - { - PackageEntitlement.IsDeploymentConfigPrincipal(Principal).Should().BeTrue(); - PackageEntitlement.IsDeploymentConfigPrincipal(" " + Principal + " ").Should().BeTrue(); - PackageEntitlement.IsDeploymentConfigPrincipal(PackageEntitlement.DeploymentConfigPrefix).Should().BeFalse(); - PackageEntitlement.IsDeploymentConfigPrincipal("rbuergi").Should().BeFalse(); - PackageEntitlement.IsDeploymentConfigPrincipal("Governance/Activities/x").Should().BeFalse(); - PackageEntitlement.IsGovernedActivityPrincipal(Principal).Should().BeFalse(); - PackageEntitlement.DeploymentConfigPrincipal(" client-a ").Should().Be("deployment-config:client-a"); - } - - [Fact] - public void OnlyAnExactEntry_NamesAPackage() - { - var claims = new PackageManifest { Id = "Claims", Source = "ClientA" }; - DeploymentConfigAuthority.NamingEntry(["ClientA/*"], claims).Should().BeNull( - "a whole-source pattern names nothing"); - DeploymentConfigAuthority.NamingEntry(["Plugins/Claims"], claims).Should().BeNull( - "another source's package of the same id is not this one"); - DeploymentConfigAuthority.NamingEntry(["ClientA/*", "ClientA/Claims"], claims) - .Should().Be("ClientA/Claims"); - DeploymentConfigAuthority.NamingEntry(null, claims).Should().BeNull(); - } - - [Fact] - public void TheClassifier_LetsAnAuthorizedCommercialPackageThrough_ButNotAPlanRefusal() - { - var paid = new PackageManifest { Id = "P", Price = 490m, Currency = "CHF" }; - InstanceAutoRegistrationService.TerminalSkipReason(paid, configAuthorized: true).Should().BeNull( - "the deployment configuration authorizes it"); - InstanceAutoRegistrationService.TerminalSkipReason(paid, configAuthorized: false) - .Should().Contain("Global Admin").And.Contain("InstallByDefault"); - InstanceAutoRegistrationService.TerminalSkipReason(paid).Should().NotBeNull( - "the one-argument form keeps the unauthorized meaning"); - } - - [Fact] - public void TheRequirementClosure_FollowsCommercialEdges_FromNamedRootsOnly() - { - PackageManifest M(string id, params string[] requires) => new() - { - Id = id, Source = SourceName, Requires = requires.ToImmutableList(), - }; - ImmutableList catalog = - [ - M("Claims", "ClaimsCore@^1.0.0"), - M("ClaimsCore", "Pricing") with { ContactEmail = "sales@acme.test" }, - M("Pricing") with { Price = 10m }, - M("Course", "PaidCourse"), - M("PaidCourse") with { Price = 49m }, - ]; - - var closure = InstanceAutoRegistrationService.ConfigAuthorizedRequirements(catalog, [catalog[0]]); - - closure.Keys.OrderBy(x => x).Should().Equal("ClaimsCore", "Pricing"); - closure["Pricing"].Should().Be("Claims", "the transitive requirement is traced to the named root"); - - // The closure decides what DependencyClosure may pull in: Claims' commercial chain, never - // the commercial requirement of a package nothing named. - var authorized = closure.Keys.Append("Claims").ToImmutableHashSet(StringComparer.Ordinal); - var withDependencies = InstanceAutoRegistrationService.DependencyClosure( - catalog, [catalog[0], catalog[3]], null, authorized); - withDependencies.Select(p => p.Id).Should().Equal("Claims", "ClaimsCore", "Course", "Pricing"); - } - - [Fact] - public void TheAuthorizationTrail_KeepsTheOriginalDecision_AndRecordsAChangedOne() - { - var at = DateTimeOffset.Parse("2026-10-11T08:00:00Z"); - var original = new DefaultInstallAuthorization("Claims", Principal, "named", at); - var reasserted = original with { InstalledAt = at.AddDays(1) }; - var other = new DefaultInstallAuthorization("Pricing", Principal, "required by 'Claims'", at.AddDays(1)); - - InstanceAutoRegistrationService.MergeAuthorizations([original], [reasserted, other]) - .Should().Equal(original, other); - InstanceAutoRegistrationService.MergeAuthorizations(null, [reasserted with { Reason = "changed" }]) - .Single().Reason.Should().Be("changed"); - } - - private Task ReadLedger() => - Mesh.ServiceProvider.GetRequiredService() - .Read(LedgerPath, Mesh.JsonSerializerOptions) - .Select(n => n?.ContentAs(Mesh.JsonSerializerOptions)) - .Take(1).Timeout(TestTimeouts.Convergence).Await(); -} - -/// -/// An INCOMPLETE commercial package the deployment configuration installed is REPAIRED under that -/// same authority by the boot repair pass — not classified as unauthorized and skipped on every -/// boot. Without the authority on the repair's candidates the lost node below never comes back. -/// -public class ADeploymentNamedCommercialPackageIsRepairedTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Principal = PackageEntitlement.DeploymentConfigPrefix + "acme-test"; - - private static PackageManifest Commercial() => - ReassertFixture.Candidate() with { Price = 900m, Currency = "CHF" }; - - private sealed class CommercialSource : IPackageSource - { - public IObservable> ListPackages(string gitRef) => - Observable.Return>(ImmutableList.Create(Commercial())); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) => - Observable.Return(ReassertFixture.Files()); - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(_ => new CommercialSource()) - .AddSingleton(new PluginCatalogOptions - { - InstanceId = "acme-test", - InstallByDefault = [$"registered-0/{ReassertFixture.Package}"], - InstallPreInstalledPackages = false, - })); - - [Fact(Timeout = 300_000)] - public async Task ALostNode_ComesBack_UnderTheDeploymentPrincipal() - { - var ct = TestContext.Current.CancellationToken; - var first = await Mesh.ServiceProvider.GetRequiredService().Completed - .FirstAsync().Timeout(TimeSpan.FromSeconds(180)).Await(ct); - first.Delivered.Should().Contain(ReassertFixture.Package, "the named commercial package installs at boot"); - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.GuidePath, present: true)).Should().BeTrue(); - - await Mesh.ServiceProvider.GetRequiredService().DeleteNode(ReassertFixture.GuidePath) - .Should().Within(TestTimeouts.Convergence).Emit("the loss is the precondition", cancellationToken: ct); - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.GuidePath, present: false)).Should().BeTrue(); - - await Mesh.ServiceProvider.GetRequiredService() - .RunRepair().Timeout(TimeSpan.FromSeconds(240)).Await(ct); - - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.GuidePath, present: true)).Should().BeTrue( - "the repair re-asserts a config-authorized commercial package instead of skipping it"); - (await ReassertFixture.ReadRecord(Mesh))!.AuthorizedBy.Should().Be(Principal, - "the repair runs under the same deployment-config principal"); - } -} - -/// -/// A directly read repository cannot claim another source's Source/Package scoping by -/// AUTHORING a source in its manifest. The boot lane keeps the authored value (selection is -/// unchanged), but the fail-closed provenance check withholds the deployment configuration's -/// authority from any candidate whose source is neither a registry's stamp nor the name its source -/// was configured under — so this package is selected, classified commercial-without-authority, and -/// skipped. -/// -public class AnAuthoredSourceClaimsNoAuthorityTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// A direct source whose listing carries an AUTHORED source — what - /// GitPackageSource / GitHubPackageSource preserve from a package.json. - private sealed class AuthoringSource : IPackageSource - { - public IObservable> ListPackages(string gitRef) => - Observable.Return>(ImmutableList.Create( - ReassertFixture.Candidate() with { Price = 900m, Currency = "CHF", Source = "Trusted" })); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) => - Observable.Return(ReassertFixture.Files()); - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(_ => new AuthoringSource()) - .AddSingleton(new PluginCatalogOptions - { - InstanceId = "acme-test", - InstallByDefault = [$"Trusted/{ReassertFixture.Package}"], - InstallPreInstalledPackages = false, - })); - - [Fact(Timeout = 240_000)] - public async Task APackageWritingTheNamedSourcesName_IsNotAuthorized() - { - var ct = TestContext.Current.CancellationToken; - var first = await Mesh.ServiceProvider.GetRequiredService().Completed - .FirstAsync().Timeout(TimeSpan.FromSeconds(180)).Await(ct); - Output.WriteLine($"pass 1: {first}"); - first.Delivered.Should().NotContain(ReassertFixture.Package, - "the manifest's authored source is not provenance — it was served by 'registered-0', " - + "which the configuration does not name"); - first.Authorized.Should().BeEmpty(); - } -} diff --git a/test/Memex.Portal.Shared.Test/AFailedDefaultInstallIsRepairedWhenTheFeedAnswersTest.cs b/test/Memex.Portal.Shared.Test/AFailedDefaultInstallIsRepairedWhenTheFeedAnswersTest.cs deleted file mode 100644 index 2658137e11..0000000000 --- a/test/Memex.Portal.Shared.Test/AFailedDefaultInstallIsRepairedWhenTheFeedAnswersTest.cs +++ /dev/null @@ -1,286 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Net.Sockets; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#5825 — a default package the boot could not install is installed when a registry -/// next answers, not at the next boot. -/// -/// Measured on the control instance, 2026-09-27: the boot's fetch of Anthropic stalled -/// on an HTTPS read, the package was recorded as FAILED, and the only repair was "the next boot -/// re-attempts it". On an instance that restarts when its image rolls, that is however long the -/// next roll takes. -/// -/// The fixture is the real path end to end: the boot default-install pass with one package -/// whose file fetch fails the way production saw it, a registry this installation is a consumer of -/// (the fake HTTP registry of ), and -/// — the same pass a safety-net tick runs — as -/// the feed read that prompts the repair. Nothing in the test calls the installer. -/// -public class AFailedDefaultInstallIsRepairedWhenTheFeedAnswersTest(ITestOutputHelper output) - : ModuleReloadScenario(output) -{ - private const string SourceName = "registered-0"; - private const string LedgerPath = "Plugins/_DefaultInstallLedger"; - - /// Whether Flaky's fetch fails. Flipped by the test; read on a pool thread. - private volatile bool flakyFails = true; - - /// Whether the source's LISTING fails — the source does not answer at all. Flipped by - /// the test; read on a pool thread. - private volatile bool listingFails; - - private int listings; - - /// How often the default-install source was LISTED — one per pass. - private int Listings => Volatile.Read(ref listings); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .ConfigureServices(services => services - .AddSingleton(_ => new FlakyCatalog( - new NodeRepoPackageSource( - (_, _, _, _) => Observable.Return(new RepoSnapshot("commit-repair", Repo)), - "https://github.com/acme/plugins"), - () => flakyFails, - () => listingFails, - () => Interlocked.Increment(ref listings))) - // Registered AFTER the scenario's own options, so this is the instance resolved: the - // same registry, plus a default-install pattern over the flaky source. - .AddSingleton(new PluginCatalogOptions - { - Registries = [new PluginRegistryReference { Name = "test", Url = RegistryUrl, Token = Token }], - InstallByDefault = [$"{SourceName}/*"], - InstallPreInstalledPackages = false, - // The safety net's own timer is out of the test: ReconcileNow IS its pass. - ReconcileSafetyNetInterval = TimeSpan.Zero, - })); - - private static readonly ImmutableArray Repo = - [ - new("Good/index.json", - """{"$type":"MeshNode","id":"Good","namespace":"","path":"Good","mainNode":"Good","name":"Good","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"Installs fine.","minMeshVersion":"1.0.0"}}"""), - new("Good/Page.json", - """{"$type":"MeshNode","id":"Page","namespace":"Good","path":"Good/Page","mainNode":"Good/Page","name":"Page","nodeType":"Markdown","state":"Active","content":"# Good"}"""), - new("Flaky/index.json", - """{"$type":"MeshNode","id":"Flaky","namespace":"","path":"Flaky","mainNode":"Flaky","name":"Flaky","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"Lands on the repair.","minMeshVersion":"1.0.0"}}"""), - new("Flaky/Page.json", - """{"$type":"MeshNode","id":"Page","namespace":"Flaky","path":"Flaky/Page","mainNode":"Flaky/Page","name":"Page","nodeType":"Markdown","state":"Active","content":"# Flaky"}"""), - ]; - - /// The production shape of the failure: a pipeline timeout wrapping the stalled socket read. - private static Exception RegistryStall() => - new TimeoutException( - "The operation didn't complete within the allowed timeout of '00:01:30'.", - new System.IO.IOException( - "Unable to read data from the transport connection: Operation canceled.", - new SocketException((int)SocketError.OperationAborted))); - - private sealed class FlakyCatalog( - NodeRepoPackageSource inner, Func fails, Func listingFails, Action listed) : IPackageSource - { - public IObservable> ListPackages(string gitRef) - => Observable.Defer(() => - { - listed(); - return listingFails() - ? Observable.Throw>(RegistryStall()) - : inner.ListPackages(gitRef); - }); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) - => package.Id == "Flaky" && fails() - ? Observable.Throw>(RegistryStall()) - : inner.FetchPackageFiles(package, gitRef); - } - - private InstanceAutoRegistrationService Installer => - Mesh.ServiceProvider.GetRequiredService(); - - private RegistryUpdateReconciler Reconciler => - Mesh.ServiceProvider.GetRequiredService(); - - /// The boot pass has failed Flaky and the boot reconcile is over. - private async Task BootedWithFlakyFailed(CancellationToken ct) - { - var boot = await Installer.Completed.FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - Output.WriteLine($"boot: {boot}"); - boot.Failures.Should().Equal(["Flaky"], "the premise: the boot could not install it"); - boot.Delivered.Should().Contain("Good"); - (await Ledger(l => l.Failed.Contains("Flaky"), ct)).Seeded.Should().NotContain("Flaky"); - await Reconciler.BootReconciled.DefaultIfEmpty().Timeout(TestTimeouts.Convergence).Await(ct); - // 🚨 The boot reconcile itself must NOT repair: it runs straight after the pass that failed, - // against the same registry state. Read here, before any baseline is taken — a repair pass - // inside the boot would otherwise be absorbed into "afterBoot" and every later +1 would hold. - Listings.Should().Be(1, "the source is listed by the boot pass alone; the boot reconcile runs no repair pass"); - return boot; - } - - [Fact(Timeout = 240_000)] - public async Task TheNextReconcileAgainstAnAnsweringRegistry_InstallsWhatTheBootCouldNot() - { - var ct = TestContext.Current.CancellationToken; - await BootedWithFlakyFailed(ct); - var afterBoot = Listings; - - // ── the registry answers, the package source still stalls: re-attempted, still recorded ── - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - Listings.Should().Be(afterBoot + 1, "a reconcile after a failed pass runs the default install again, once"); - (await Ledger(l => l.Failed.Contains("Flaky"), ct)).Seeded.Should().NotContain("Flaky", - "a package that fails again stays recorded as failed, never as delivered"); - - // ── the source answers too: the same reconcile installs it, with no boot in between ────── - flakyFails = false; - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - var healed = await Ledger(l => l.Seeded.Contains("Flaky"), ct); - healed.Failed.Should().NotContain("Flaky"); - healed.FailureCauses.Should().BeEmpty(); - var storage = Mesh.ServiceProvider.GetRequiredService(); - (await storage.Read("Flaky/Page", Mesh.JsonSerializerOptions).Take(1) - .Timeout(TestTimeouts.Convergence).Await(ct)).Should().NotBeNull("the package's content is in the mesh"); - var afterRepair = Listings; - afterRepair.Should().Be(afterBoot + 2); - - // ── nothing left undone: a further reconcile does not run the default install at all ───── - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - Listings.Should().Be(afterRepair, "a pass that left nothing undone is not repeated"); - } - - /// The negative control of the repair: a boot that installed everything is followed by - /// no second pass, however often the registry is read. - [Fact(Timeout = 240_000)] - public async Task ACleanBoot_IsNeverFollowedByARepairPass() - { - var ct = TestContext.Current.CancellationToken; - flakyFails = false; - var boot = await Installer.Completed.FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - boot.Failures.Should().BeEmpty(); - await Reconciler.BootReconciled.DefaultIfEmpty().Timeout(TestTimeouts.Convergence).Await(ct); - var afterBoot = Listings; - - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - - Listings.Should().Be(afterBoot); - } - - /// - /// A source that did not ANSWER its listing at boot is owed a repair: nothing failed (no package - /// was ever named), and the next reconcile lists it again all the same. - /// - [Fact(Timeout = 240_000)] - public async Task ASourceThatDidNotAnswerAtBoot_IsListedAgainByTheNextReconcile() - { - var ct = TestContext.Current.CancellationToken; - listingFails = true; - flakyFails = false; - var boot = await Installer.Completed.FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - Output.WriteLine($"boot: {boot}"); - boot.Failures.Should().BeEmpty("the premise: no package failed — the listing named none"); - boot.ListingIncomplete.Should().BeTrue(); - boot.SourceUnreachable.Should().BeTrue("the source did not answer; it is not held"); - await Reconciler.BootReconciled.DefaultIfEmpty().Timeout(TestTimeouts.Convergence).Await(ct); - var afterBoot = Listings; - afterBoot.Should().Be(1); - - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - Listings.Should().Be(afterBoot + 1, "an unanswered listing is repaired by listing the source again"); - - listingFails = false; - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - (await Ledger(l => l.Seeded.Contains("Flaky") && l.Seeded.Contains("Good"), ct)).Failed.Should().BeEmpty(); - var afterRepair = Listings; - afterRepair.Should().Be(afterBoot + 2); - - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - Listings.Should().Be(afterRepair, "a pass that left nothing undone is not repeated"); - } - - /// - /// 🚨 A source that is HELD stays held until the next process start: a repair pass — owed here - /// for another reason, the failed package — does not list it again, however often a registry - /// answers. The counterpart of the test above, which lists an unanswered source again. - /// - /// The hold is declared through the installer's own hold path - /// (ListingFor → ListAtProvenRef(configured, proven) with a seal answer that holds), the one place a - /// hold is declared in production. The seal reader produces no holding answer on a healthy - /// share, so the fixture cannot stage one on disk at boot. - /// - [Fact(Timeout = 240_000)] - public async Task AHeldSource_IsNotListedAgainByARepairPass() - { - var ct = TestContext.Current.CancellationToken; - await BootedWithFlakyFailed(ct); - var afterBoot = Listings; - - var configured = new ConfiguredPackageSource( - Mesh.ServiceProvider.GetRequiredService(), "HEAD", SourceName); - var held = await Installer - .ListingFor(configured, new InstanceAutoRegistrationService.ProvenSource( - configured, "the seal is torn", "the seal is torn")) - .Timeout(TestTimeouts.Convergence).Await(ct); - held.Failed.Should().BeTrue(); - held.Held.Should().BeTrue("a hold is typed apart from a source that did not answer"); - held.Listed.Should().Be(0); - Listings.Should().Be(afterBoot, "declaring a hold lists nothing"); - - // The repair is owed (Flaky failed at boot) and the source would now install it. - flakyFails = false; - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - Listings.Should().Be(afterBoot, "a held source is not listed again before the next process start"); - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - Listings.Should().Be(afterBoot); - (await Ledger(_ => true, ct)).Seeded.Should().NotContain("Flaky", - "nothing of a held source is installed mid-process"); - } - - /// What counts as "left undone". Pure. - [Fact] - public void LeftUndone_NamesFailuresAndAnUnlistedSource_AndIsNullForACleanPass() - { - InstanceAutoRegistrationService.LeftUndone(DefaultInstallSummary.Empty).Should().BeNull(); - InstanceAutoRegistrationService.LeftUndone(new DefaultInstallSummary(3, 1, 0, ["A"])).Should().BeNull(); - InstanceAutoRegistrationService.LeftUndone( - new DefaultInstallSummary(0, 0, 1, ["A"]) { Failures = ["Anthropic"] }) - .Should().Contain("1 package(s) failed").And.Contain("Anthropic"); - InstanceAutoRegistrationService.LeftUndone( - DefaultInstallSummary.Empty with { ListingIncomplete = true, SourceUnreachable = true }) - .Should().Be("a source could not be listed", - "a package a failed listing never named is neither failed nor installed, and is owed all the same"); - // 🚨 A HELD source makes the listing incomplete and is NOT owed a repair: the hold releases - // at the next process start, so re-running the pass could only revisit a source it must not. - InstanceAutoRegistrationService.LeftUndone(DefaultInstallSummary.Empty with { ListingIncomplete = true }) - .Should().BeNull("a held source does not make a later reconcile re-run the default install"); - InstanceAutoRegistrationService.LeftUndone( - new DefaultInstallSummary(0, 0, 1, ["A"]) { Failures = ["Anthropic"], ListingIncomplete = true }) - .Should().Contain("1 package(s) failed").And.NotContain("could not be listed"); - } - - private Task Ledger(Func condition, CancellationToken ct) - { - var storage = Mesh.ServiceProvider.GetRequiredService(); - return Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .SelectMany(_ => storage.Read(LedgerPath, Mesh.JsonSerializerOptions).Take(1)) - .Select(node => node?.ContentAs(Mesh.JsonSerializerOptions)) - .OfType() - .Where(condition) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - } -} diff --git a/test/Memex.Portal.Shared.Test/AFailedPackageIsRetriedNotHeldTest.cs b/test/Memex.Portal.Shared.Test/AFailedPackageIsRetriedNotHeldTest.cs deleted file mode 100644 index 4894b4b01c..0000000000 --- a/test/Memex.Portal.Shared.Test/AFailedPackageIsRetriedNotHeldTest.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The governed-provision hold () -/// must not swallow the ledger's RETRY. A package whose install FAILED is kept off the seeded list -/// on purpose, so the next pass re-attempts it (#2254). The hold asked only "was it seeded?", so on -/// the second pass that package read as NEWLY LISTED and was held: the retry the ledger exists for -/// never ran, and a package the ledger had already classified as skipped (a paid tier, sales only) -/// stopped being re-classified. MeshWeaver.Plugins' DefaultInstallFailureLedgerTest and -/// DefaultInstallCommercialSkipTest went red on the first sealed set that carried the hold. -/// -/// A package the ledger has RECORDED — seeded, failed, or skipped with a standing reason — is -/// known to this instance, and the hold is for packages it has never seen. -/// -public class AFailedPackageIsRetriedNotHeldTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string SourceName = "registered-0"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(_ => Source()) - // The SEED lane under a whole-source pattern — exactly what the hold keys on. - .AddSingleton(new PluginCatalogOptions - { - InstallByDefault = [$"{SourceName}/*"], - InstallPreInstalledPackages = false, - })); - - private static readonly IReadOnlyList Repo = - [ - new("Good/index.json", - """{"$type":"MeshNode","id":"Good","namespace":"","path":"Good","mainNode":"Good","name":"Good","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"Installs fine.","minMeshVersion":"1.0.0"}}"""), - new("Good/Page.json", - """{"$type":"MeshNode","id":"Page","namespace":"Good","path":"Good/Page","mainNode":"Good/Page","name":"Page","nodeType":"Markdown","state":"Active","content":"# Good"}"""), - new("Bad/index.json", - """{"$type":"MeshNode","id":"Bad","namespace":"","path":"Bad","mainNode":"Bad","name":"Bad","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"Never lands.","minMeshVersion":"1.0.0"}}"""), - new("Bad/Page.json", - """{"$type":"MeshNode","id":"Page","namespace":"Bad","path":"Bad/Page","mainNode":"Bad/Page","name":"Page","nodeType":"Markdown","state":"Active","content":"# Bad"}"""), - ]; - - /// Lists both packages and fails to deliver Bad — a delivery failure, not a malformed package. - private sealed class HalfBrokenCatalog(NodeRepoPackageSource inner) : IPackageSource - { - public IObservable> ListPackages(string gitRef) - => inner.ListPackages(gitRef); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) - => package.Id == "Bad" - ? Observable.Throw>(new TimeoutException("NodeOps did not answer")) - : inner.FetchPackageFiles(package, gitRef); - } - - private static IPackageSource Source() - { - Func> fetch = - (_, _, _, _) => Observable.Return(new RepoSnapshot("commit-hold", Repo)); - return new HalfBrokenCatalog(new NodeRepoPackageSource(fetch, "https://github.com/acme/plugins")); - } - - [Fact(Timeout = 180_000)] - public async Task APackageThatFailed_IsReattemptedOnTheNextPass_NotHeldAsNewlyListed() - { - var installer = Mesh.ServiceProvider.GetRequiredService(); - - // PASS 1 — a FRESH instance, so the hold does not apply: Good lands, Bad fails. - var first = await installer.Completed - .FirstAsync().Timeout(TimeSpan.FromSeconds(120)).Await(TestContext.Current.CancellationToken); - Output.WriteLine($"pass 1: {first}"); - first.Failures.Should().Contain("Bad"); - first.Delivered.Should().Contain("Good", - "without a seeded package the second pass would still be FRESH and the hold could not fire"); - - // PASS 2 — no longer fresh. Bad is covered only by the wildcard and was never seeded, which - // is the hold's exact shape; the ledger's FAILED record is what makes it known. - var second = await installer.RunDefaultInstall() - .FirstAsync().Timeout(TimeSpan.FromSeconds(120)).Await(TestContext.Current.CancellationToken); - Output.WriteLine($"pass 2: {second}"); - second.Packages.Should().Contain("Bad", - "a package the ledger recorded as FAILED is known to this instance and must be re-attempted, " - + "not held as newly listed"); - second.Packages.Should().NotContain("Good", "a seeded package is still left alone"); - } - - /// The set the hold consults: seeded, failed and skipped are all KNOWN. Pure. - [Fact] - public void TheLedgersFailedAndSkippedEntries_AreKnown() - { - var ledger = new DefaultInstallLedger - { - Seeded = ["Good"], - Failed = ["Bad"], - Skipped = [new DefaultInstallSkip("Paid", "tier not purchasable unattended")], - }; - - var known = InstanceAutoRegistrationService.KnownToTheLedger(ledger); - - known.Should().Contain("Good"); - known.Should().Contain("Bad"); - known.Should().Contain("Paid"); - known.Should().NotContain("Fresh", "a package the ledger never recorded is the one the hold is for"); - } - - /// - /// A persisted ledger can carry an explicit null for any of its lists (an older - /// record, or a writer that serialised a null). The hold runs inside the boot install, so a - /// null list must read as empty — never throw and abort the pass. Pure. - /// - [Fact] - public void ALedgerWithNullLists_ReadsAsEmpty() - { - var ledger = System.Text.Json.JsonSerializer.Deserialize( - """{"seeded":null,"failed":null,"skipped":null}""", - new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)) - ?? throw new InvalidOperationException("the ledger JSON deserialised to null"); - ledger.Failed.Should().BeNull("the precondition: the record really carries a null list"); - - var known = InstanceAutoRegistrationService.KnownToTheLedger(ledger); - - known.Should().BeEmpty("a null list is an empty list, not a fault in the boot install"); - } -} diff --git a/test/Memex.Portal.Shared.Test/AFailedPackageNamesItsCauseTest.cs b/test/Memex.Portal.Shared.Test/AFailedPackageNamesItsCauseTest.cs deleted file mode 100644 index eb770a59e8..0000000000 --- a/test/Memex.Portal.Shared.Test/AFailedPackageNamesItsCauseTest.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Net.Sockets; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A default package the boot could not deliver is NAMED WITH ITS CAUSE — on the pass's summary -/// line and on Plugins/_DefaultInstallLedger — and the cause leaves the ledger the moment a -/// later pass delivers the package (MeshWeaver#5826). -/// -/// Measured on memex.systemorph.com, 2026-09-27/28: the boot summary said -/// FAILED: [Anthropic], then FAILED: [AppleIntelligence], and the ledger said -/// failed: ["Anthropic"]. The cause — a registry file fetch whose three 30 s attempts all -/// expired waiting for response headers — lived only in a separate per-package log line, so the -/// triage of the summary could not say why, and the ticket read as "persistently fails" when each -/// boot had failed a different package for a reason it could have named. -/// -/// The failure is injected the way production saw it: a Polly-style timeout WRAPPING the -/// socket cancellation, so the assertion also pins that the innermost cause is carried. -/// -public class AFailedPackageNamesItsCauseTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string SourceName = "registered-0"; - private const string LedgerPath = "Plugins/_DefaultInstallLedger"; - - /// Whether Flaky's fetch fails. Flipped by the test between passes; volatile - /// because the install reads it on a pool thread. - private volatile bool flakyFails = true; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(_ => new FlakyCatalog( - new NodeRepoPackageSource( - (_, _, _, _) => Observable.Return(new RepoSnapshot("commit-cause", Repo)), - "https://github.com/acme/plugins"), - () => flakyFails)) - .AddSingleton(new PluginCatalogOptions - { - InstallByDefault = [$"{SourceName}/*"], - InstallPreInstalledPackages = false, - })); - - private static readonly IReadOnlyList Repo = - [ - new("Good/index.json", - """{"$type":"MeshNode","id":"Good","namespace":"","path":"Good","mainNode":"Good","name":"Good","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"Installs fine.","minMeshVersion":"1.0.0"}}"""), - new("Good/Page.json", - """{"$type":"MeshNode","id":"Page","namespace":"Good","path":"Good/Page","mainNode":"Good/Page","name":"Page","nodeType":"Markdown","state":"Active","content":"# Good"}"""), - new("Flaky/index.json", - """{"$type":"MeshNode","id":"Flaky","namespace":"","path":"Flaky","mainNode":"Flaky","name":"Flaky","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"Lands on the second pass.","minMeshVersion":"1.0.0"}}"""), - new("Flaky/Page.json", - """{"$type":"MeshNode","id":"Page","namespace":"Flaky","path":"Flaky/Page","mainNode":"Flaky/Page","name":"Page","nodeType":"Markdown","state":"Active","content":"# Flaky"}"""), - ]; - - /// The production shape of the failure: a pipeline timeout wrapping the stalled socket read. - private static Exception RegistryStall() => - new TimeoutException( - "The operation didn't complete within the allowed timeout of '00:01:30'.", - new System.IO.IOException( - "Unable to read data from the transport connection: Operation canceled.", - new SocketException((int)SocketError.OperationAborted))); - - /// Lists both packages; fails Flaky's file fetch while says so. - private sealed class FlakyCatalog(NodeRepoPackageSource inner, Func fails) : IPackageSource - { - public IObservable> ListPackages(string gitRef) - => inner.ListPackages(gitRef); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) - => package.Id == "Flaky" && fails() - ? Observable.Throw>(RegistryStall()) - : inner.FetchPackageFiles(package, gitRef); - } - - [Fact(Timeout = 180_000)] - public async Task AFailedPackage_IsNamedWithItsCause_AndTheCauseLeavesWhenItLands() - { - var installer = Mesh.ServiceProvider.GetRequiredService(); - - // PASS 1 — Flaky's fetch stalls. - var first = await installer.Completed - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - Output.WriteLine($"pass 1: {first}"); - - first.Failures.Should().Equal("Flaky"); - var cause = first.FailureCauses.Should().ContainSingle( - "every failed package carries exactly one cause").Which; - cause.Package.Should().Be("Flaky"); - cause.Cause.Should().Contain("TimeoutException") - .And.Contain("00:01:30", "the outer cause names the budget that expired") - .And.Contain("SocketException", "the innermost cause names what actually stalled"); - first.ToString().Should().Contain("CAUSES: [Flaky: TimeoutException", - "the summary line a reader triages from must say WHY, not only WHICH"); - - var ledger = await Ledger(l => l.Failed.Contains("Flaky")); - ledger.FailureCauses.Should().ContainSingle() - .Which.Should().Be(cause, "the ledger records the cause the pass saw, verbatim"); - - // PASS 2 — the registry answers now. The failed package is retried (it is known to the - // ledger, so the governed-provision hold does not take it) and lands; its cause goes. - flakyFails = false; - var second = await installer.RunDefaultInstall() - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - Output.WriteLine($"pass 2: {second}"); - - second.Delivered.Should().Contain("Flaky", "the next pass is the retry, and this time it delivers"); - second.FailureCauses.Should().BeEmpty(); - var healed = await Ledger(l => !l.Failed.Contains("Flaky")); - healed.FailureCauses.Should().BeEmpty( - "a cause is a snapshot of what is missing NOW — a delivered package must not stay advertised as failing"); - healed.Seeded.Should().Contain("Flaky"); - } - - /// The cause is bounded and keeps the innermost exception. Pure. - [Fact] - public void ACause_KeepsTheInnermostException_AndIsBounded() - { - var cause = DefaultInstallFailure.Of("Anthropic", RegistryStall()); - cause.Cause.Should().StartWith("TimeoutException: The operation didn't complete"); - cause.Cause.Should().Contain("← SocketException"); - - var huge = DefaultInstallFailure.Of("Big", new InvalidOperationException(new string('x', 5_000))); - huge.Cause.Length.Should().BeLessThanOrEqualTo(DefaultInstallFailure.MaxCauseLength + 1); - huge.Cause.Should().EndWith("…"); - - var flat = DefaultInstallFailure.Of("Flat", new InvalidOperationException("no inner")); - flat.Cause.Should().Be("InvalidOperationException: no inner", "no inner exception, no arrow"); - } - - /// - /// 🚨 A failed package ALWAYS has a cause on the ledger and the summary line, even when the site - /// that recorded the failure supplied none: the gap is an entry that says so, never an id with - /// nothing beside it — and a cause for a package that did not fail is not advertised. Pure. - /// - [Fact] - public void AFailureRecordedWithoutACause_SaysSo_AndACauseWithoutAFailureIsDropped() - { - var summary = new DefaultInstallSummary(0, 0, 2, ["Named", "Silent", "Fine"]) - { - Failures = ["Silent", "Named", "Named"], - FailureCauses = - [ - new DefaultInstallFailure("Named", "first"), - new DefaultInstallFailure("Named", "second"), - new DefaultInstallFailure("Fine", "did not fail"), - ], - }; - - summary.CausePerFailure().Should().Equal( - new DefaultInstallFailure("Named", "first"), - DefaultInstallFailure.Unrecorded("Silent")); - summary.ToString().Should().Contain($"Silent: {DefaultInstallFailure.UnrecordedCause}", - "the summary line must show a failure nobody explained, not hide it") - .And.NotContain("did not fail"); - DefaultInstallSummary.Empty.CausePerFailure().Should().BeEmpty(); - } - - /// - /// The ledger as stored, once it satisfies — read off storage (never - /// the lagging index) and re-read until the condition holds, since the write may still be in - /// flight when the pass's summary is emitted. - /// - private Task Ledger(Func condition) - { - var storage = Mesh.ServiceProvider.GetRequiredService(); - return Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .SelectMany(_ => storage.Read(LedgerPath, Mesh.JsonSerializerOptions).Take(1)) - .Select(node => node?.ContentAs(Mesh.JsonSerializerOptions)) - .OfType() - .Where(condition) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - } -} diff --git a/test/Memex.Portal.Shared.Test/AProtectedSegmentDeclarationSurvivesDiscoveryTest.cs b/test/Memex.Portal.Shared.Test/AProtectedSegmentDeclarationSurvivesDiscoveryTest.cs deleted file mode 100644 index b2e4aa4d08..0000000000 --- a/test/Memex.Portal.Shared.Test/AProtectedSegmentDeclarationSurvivesDiscoveryTest.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The declaration has to survive DISCOVERY, and nothing else in the change set looks at that -/// hop — MeshWeaver#4716, found in review on its pull request. -/// -/// Every other test of the protected-segment shape builds a -/// in-process, so the projection that actually feeds production — NodeRepoPackageSource reading -/// content.protectedSegments out of a package's index.json — is covered by none of them. -/// That is the EXACT shape of the defect being fixed: the field was present in the authored manifest and -/// core dropped it on the way in, silently, so every installed node-repo package took the blanket -/// PublicRead path. A regression on this hop restores the exposure and reds nothing. -/// -/// No network and no git: the source takes its fetch as a delegate, so the whole listing runs off -/// an in-memory — which also means this test exercises the real -/// ListPackages pipeline rather than a re-implementation of it. -/// -public class AProtectedSegmentDeclarationSurvivesDiscoveryTest -{ - private const string Declaring = "Feedback"; - private const string Silent = "Chess"; - - /// A plugin root as the plugins repo authors it — the shape live `Feedback/index.json` has. - private static string Index(string id, string? extra) => $$""" - { - "$type": "MeshNode", - "id": "{{id}}", - "namespace": "", - "path": "{{id}}", - "name": "{{id}}", - "nodeType": "Store/Plugin", - "state": "Active", - "content": { - "$type": "PluginContent", - "description": "A plugin.", - "preInstalled": true, - "tier": "free"{{extra}} - } - } - """; - - private static NodeRepoPackageSource Source() => new( - fetch: (_, _, _, _) => Observable.Return(new RepoSnapshot("deadbeef", new List - { - // The declaring package: `protectedSegments` beside `preInstalled`, exactly as authored. - new($"{Declaring}/index.json", Index(Declaring, ",\n \"protectedSegments\": [\"_Submissions\"]")), - // The control: an otherwise identical root that declares nothing. - new($"{Silent}/index.json", Index(Silent, null)), - })), - repoUrl: "https://github.com/Systemorph/MeshWeaver.Plugins", - tokenProvider: () => Observable.Return(string.Empty)); - - private static Task> Listed() => - Source().ListPackages("HEAD") - .FirstAsync() - .Timeout(TestTimeouts.Quick) - .Await(TestContext.Current.CancellationToken); - - /// - /// THE assertion: the authored declaration reaches the manifest the installer's access step reads. - /// - [Fact(Timeout = 60_000)] - public async Task TheAuthoredProtectedSegments_ReachTheManifest() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - - var listed = await Listed(); - var declaring = listed.Single(m => m.Id == Declaring); - - declaring.PreInstalled.Should().BeTrue( - "the control on the READ itself: if the projection were broken wholesale, `preInstalled` " - + "would be false too and the assertion below would be about the wrong thing"); - declaring.ProtectedSegments.Should().Equal(["_Submissions"], - "THE assertion: `content.protectedSegments` must survive discovery. It did not, and that is " - + "the whole first half of the defect — the field was authored, core dropped it, and the " - + "partition took the blanket PublicRead shape with the Store's protection sitting under it " - + "doing nothing (MeshWeaver#4716)"); - - PackageInstaller.DeclaredProtectedPaths(declaring, Declaring).Should().Equal(["Feedback/_Submissions"], - "and it resolves to the path the access step gates — the two halves joined, so a regression " - + "in either the read or the resolution is visible here"); - } - - /// - /// The other side: a manifest that declares nothing comes back EMPTY, never defaulted to something. - /// A field that quietly acquires a value would route every package onto the grant-based shape. - /// - [Fact(Timeout = 60_000)] - public async Task AManifestWithoutTheKey_ComesBackEmpty() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - - var listed = await Listed(); - listed.Single(m => m.Id == Silent).ProtectedSegments.Should().BeEmpty( - "absent means absent. A default that carried a segment would gate a child of every package " - + "that never asked for it, and the shape decision keys on Count > 0"); - } -} diff --git a/test/Memex.Portal.Shared.Test/ARecordUnderAnOlderComparisonRuleIsNoBaselineTest.cs b/test/Memex.Portal.Shared.Test/ARecordUnderAnOlderComparisonRuleIsNoBaselineTest.cs deleted file mode 100644 index fdeb941d5a..0000000000 --- a/test/Memex.Portal.Shared.Test/ARecordUnderAnOlderComparisonRuleIsNoBaselineTest.cs +++ /dev/null @@ -1,202 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A HASH OF THE SOURCE IS NO EVIDENCE OF WHAT THE MESH STORES — MeshWeaver.Plugins#2786. -/// -/// Measured on a deployed portal: Governance/NameCheck declares -/// "keepsHistory": false in its file and the stored definition read true. MeshWeaver#6479 -/// widened the per-node comparison so it sees that member, and the package was then REINSTALLED on a -/// platform carrying #6479 — and the stored node did not move. The comparison was never reached: the -/// install funnel decides from the install record first, and both of its shortcuts read hashes of the -/// SOURCE (the module hash for the up-to-date exit, the per-file map for the incremental diff). The -/// file's hash had not moved since the install whose comparison could not see the difference — that -/// install skipped the write and stamped the hash anyway — so every later install trusted the hash and -/// never looked at the node again. -/// -/// The fixture re-enacts that state with the real funnel: install the package, make one stored -/// node differ from what the package ships while its file hash stays the same, and stamp the record -/// the way every installer before the comparison rule was versioned did (no -/// ). The reinstall must rewrite the node. The control -/// is the identical sequence under a record stamped by the CURRENT rule: there the up-to-date exit is -/// trusted and nothing is written — which is what proves the rule on the record, and nothing else in -/// the sequence, is what makes the reinstall compare. -/// -public class ARecordUnderAnOlderComparisonRuleIsNoBaselineTest(ITestOutputHelper output) - : MonolithMeshTestBase(output) -{ - private const string Packaged = "# Guide\n\nThe node the tests lose."; - private const string Drifted = "# Guide\n\nWhat an installer that could not see the difference left behind."; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(_ => new ReassertFixture.Source(ReassertFixture.Files())) - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false })); - - [Fact(Timeout = 300_000)] - public async Task AStoredNodeThatDiffersUnderAnUnchangedHash_IsRewritten_WhenTheRecordPredatesTheRule() - { - var result = await ReinstallOverADriftedNode(restampEpoch: true, recordedEpoch: null, - TestContext.Current.CancellationToken); - - result.WrittenPaths.Should().Contain(ReassertFixture.GuidePath, - "THE assertion: the record was stamped before the comparison rule was versioned, so its " - + "equal module hash is no evidence the stored node matches the package — the reinstall " - + "must compare it and write it. On main the up-to-date exit returns before any node is " - + "compared, which is how Governance/NameCheck kept keepsHistory:true (Plugins#2786)"); - (await WaitForGuideBody(Packaged)).Should().BeTrue( - "the stored node must carry what the package ships once the reinstall has run"); - - var after = await ReassertFixture.ReadRecord(Mesh); - after!.ComparisonEpoch.Should().Be(PackageInstaller.InstallComparisonEpoch, - "the full install compared every node, so it may stamp the current rule — otherwise every " - + "later install would repeat the full compare forever"); - after.ModuleVersion.Should().Be(ReassertFixture.ModuleHash, - "a re-compare at the recorded version is never an update"); - } - - /// - /// The mixed-image roll: a pod on a LATER image stamped a NEWER rule onto the shared record. That - /// record is no baseline for this installer either — under an "at least" test the up-to-date exit - /// would trust it, and an incremental update would compare under this installer's older rule - /// while carrying the newer value forward, leaving a record that falsely claims the newer - /// comparison ran. The installer must compare in full and stamp its OWN rule. - /// - [Fact(Timeout = 300_000)] - public async Task ARecordStampedByANewerRule_IsNoBaselineEither_AndTheReinstallComparesInFull() - { - var result = await ReinstallOverADriftedNode(restampEpoch: true, - recordedEpoch: PackageInstaller.InstallComparisonEpoch + 1, TestContext.Current.CancellationToken); - - result.WrittenPaths.Should().Contain(ReassertFixture.GuidePath, - "a record stamped by a newer rule than this installer applies says nothing about what THIS " - + "installer's comparison would decide, so the reinstall must compare the drifted node and write it"); - (await WaitForGuideBody(Packaged)).Should().BeTrue( - "the stored node must carry what the package ships once the reinstall has run"); - var after = await ReassertFixture.ReadRecord(Mesh); - after!.ComparisonEpoch.Should().Be(PackageInstaller.InstallComparisonEpoch, - "the full install stamps THIS installer's rule, never carries the newer one forward"); - } - - /// The predicate itself: only an exact match is a baseline. - [Theory] - [InlineData(null, true)] - [InlineData(PackageInstaller.InstallComparisonEpoch - 1, true)] - [InlineData(PackageInstaller.InstallComparisonEpoch, false)] - [InlineData(PackageInstaller.InstallComparisonEpoch + 1, true)] - public void OnlyARecordStampedByExactlyThisRule_IsABaseline(int? recordedEpoch, bool expectedNoBaseline) - => PackageInstaller.RecordIsNotABaselineUnderThisRule( - new PackageManifest { Id = "p", ComparisonEpoch = recordedEpoch }) - .Should().Be(expectedNoBaseline, - "a record is a baseline only under exactly the rule this installer applies — " - + "older AND newer rules both force a full compare"); - - [Fact(Timeout = 300_000)] - public async Task Control_UnderARecordStampedByTheCurrentRule_TheUpToDateExitIsTrusted() - { - var result = await ReinstallOverADriftedNode(restampEpoch: false, recordedEpoch: null, - TestContext.Current.CancellationToken); - - result.Written.Should().Be(0, - "the control: under a record stamped by the current rule the equal module hash is trusted " - + "and nothing is compared or written — so the rewrite in the subject test comes from the " - + "rule on the record, not from anything else in the sequence"); - var guide = await ReassertFixture.Read(Mesh, ReassertFixture.GuidePath); - guide!.ContentAs(Mesh.JsonSerializerOptions)!.Content.Should().Be(Drifted, - "nothing rewrote the drifted node"); - } - - /// - /// Install, drift one node under an unchanged file hash, optionally re-stamp the record's - /// comparison rule to , reinstall. - /// - private async Task ReinstallOverADriftedNode( - bool restampEpoch, int? recordedEpoch, System.Threading.CancellationToken ct) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - await Mesh.ServiceProvider.GetRequiredService() - .Completed.FirstAsync().Timeout(180.Seconds()).Await(ct); - - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, new ReassertFixture.Source(ReassertFixture.Files()), "HEAD", - ReassertFixture.Candidate(), logger) - .Should().Within(180.Seconds()) - .Emit("the first install must land before a drift can be planted", cancellationToken: ct); - (await WaitForGuideBody(Packaged)).Should().BeTrue("the first install wrote the packaged node"); - - var record = await ReassertFixture.ReadRecord(Mesh); - record!.ComparisonEpoch.Should().Be(PackageInstaller.InstallComparisonEpoch, - "a full install stamps the rule it compared under"); - - // ── The drift: the stored node differs from the package, the package's file hash does not move. - // Through the live stream surface, transforming the CURRENT node, never a whole earlier snapshot. - var options = Mesh.JsonSerializerOptions; - await access.RunAsSystem(() => Mesh.GetMeshNodeStream(ReassertFixture.GuidePath).Update(current => current with - { - Content = current.ContentAs(options)! with { Content = Drifted }, - })) - .Should().Within(60.Seconds()).Emit("the drift is the precondition", cancellationToken: ct); - (await WaitForGuideBody(Drifted)).Should().BeTrue("the drift must be stored before the reinstall"); - - if (restampEpoch) - { - // ── The record as another installer stamped it: before the rule was versioned (null), or - // a pod on a later image mid-roll (current + 1). Computed from the LIVE manifest. - await access.RunAsSystem(() => Mesh.GetMeshNodeStream(ReassertFixture.RecordPath).Update(current => current with - { - Content = current.ContentAs(options)! with { ComparisonEpoch = recordedEpoch }, - })) - .Should().Within(60.Seconds()).Emit("the re-stamped record is the precondition", cancellationToken: ct); - (await WaitFor(ReassertFixture.RecordPath, n => - n.ContentAs(options)?.ComparisonEpoch == recordedEpoch)) - .Should().BeTrue("the re-stamped record must be stored before the reinstall"); - } - - // ── The reinstall, at the SAME module hash — the 19:07Z shape. - return await CatalogLayoutAreas - .InstallOrUpdate(Mesh, new ReassertFixture.Source(ReassertFixture.Files()), "HEAD", - ReassertFixture.Candidate(), logger) - .Should().Within(180.Seconds()) - .Emit("the reinstall must complete", cancellationToken: ct); - } - - private Task WaitForGuideBody(string body) => - WaitFor(ReassertFixture.GuidePath, - n => n.ContentAs(Mesh.JsonSerializerOptions)?.Content == body); - - /// Polls the authoritative store until holds; false on timeout. - private Task WaitFor(string path, Func predicate) => - Observable.Interval(TimeSpan.FromMilliseconds(100)).StartWith(0L) - .SelectMany(_ => Mesh.ServiceProvider.GetRequiredService() - .Read(path, Mesh.JsonSerializerOptions) - .Take(1) - .DefaultIfEmpty(null)) - .Where(n => n is not null && predicate(n)) - .Select(_ => true) - .FirstAsync() - .Timeout(60.Seconds()) - .Catch(_ => Observable.Return(false)) - .Await(TestContext.Current.CancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/ARepairBindsToATrustedSourceTest.cs b/test/Memex.Portal.Shared.Test/ARepairBindsToATrustedSourceTest.cs deleted file mode 100644 index 06314894af..0000000000 --- a/test/Memex.Portal.Shared.Test/ARepairBindsToATrustedSourceTest.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The repair lane re-asserts an installed package only from a source TRUSTED to be the one -/// its record names — never from a repository that merely AUTHORS that name (MeshWeaver#6512). -/// -/// Two directly read sources list the same id. The honest one (configured as -/// registered-1) served the install, so the record names registered-1. The impostor -/// (configured as registered-0, listed FIRST) authors "source": "registered-1" in its -/// manifest and serves different files at the same module version. Before #6512 the lane preferred -/// the first listing whose Source equals the record's — the impostor — and its files landed -/// over the installed package. -/// -/// SHOULD-FAIL-IF the lane trusts an authored source again: the lost node comes back -/// with the impostor's body. -/// -public class ARepairBindsToATrustedSourceTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string HonestSource = "registered-1"; - private const string ImpostorMarker = "IMPOSTOR body"; - - private readonly ImpostorSource impostor = new(); - - /// A direct source whose manifest AUTHORS the honest source's name — what a package.json - /// may carry — and serves its own files. Lists nothing until the install has landed, so the - /// install itself is unambiguous and only the repair meets the impostor. - private sealed class ImpostorSource : IPackageSource - { - private volatile bool listing; - - public void StartListing() => listing = true; - - public IObservable> ListPackages(string gitRef) => - Observable.Return>(listing - ? [ReassertFixture.Candidate() with { Source = HonestSource }] - : []); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) => - Observable.Return>(ReassertFixture.Files() - .Select(f => f.RelativePath.EndsWith("/Guide.md", StringComparison.Ordinal) - ? new PackageFile(f.RelativePath, $"# Guide\n\n{ImpostorMarker}.") - : f) - .ToList()); - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - // Order is the configured name: the impostor is registered-0 and listed FIRST. - .AddSingleton(_ => impostor) - .AddSingleton(_ => new ReassertFixture.Source(ReassertFixture.Files())) - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false })); - - [Fact(Timeout = 300_000)] - public async Task ARecordNamingItsSource_IsRepairedFromThatSource_NotFromOneThatAuthorsItsName() - { - var ct = TestContext.Current.CancellationToken; - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - await Mesh.ServiceProvider.GetRequiredService().Completed - .FirstAsync().Timeout(180.Seconds()).Await(ct); - - // Installed from the honest source, the record stamped with its configured name. - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, new ReassertFixture.Source(ReassertFixture.Files()), "HEAD", - ReassertFixture.Candidate() with { Source = HonestSource }, logger) - .Should().Within(180.Seconds()).Emit("the install must land first", cancellationToken: ct); - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.GuidePath, present: true)).Should().BeTrue(); - (await ReassertFixture.ReadRecord(Mesh))!.Source.Should().Be(HonestSource, - "CONTROL — the record names the source the repair must bind to"); - - impostor.StartListing(); - await Mesh.ServiceProvider.GetRequiredService().DeleteNode(ReassertFixture.GuidePath) - .Should().Within(TestTimeouts.Convergence).Emit("the loss is the precondition", cancellationToken: ct); - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.GuidePath, present: false)).Should().BeTrue(); - - await Mesh.ServiceProvider.GetRequiredService() - .RunRepair().Timeout(240.Seconds()).Await(ct); - - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.GuidePath, present: true)).Should().BeTrue( - "the honest source still lists the package, so the repair re-asserts it"); - var guide = await ReassertFixture.Read(Mesh, ReassertFixture.GuidePath); - var body = JsonSerializer.Serialize(guide!.Content, Mesh.JsonSerializerOptions); - body.Should().NotContain(ImpostorMarker, - "a repair must never land the files of a repository that merely AUTHORS the recorded source"); - body.Should().Contain("The node the tests lose", - "the node comes back from the source the record names"); - } -} diff --git a/test/Memex.Portal.Shared.Test/AReservedIdIsNeverASignInTest.cs b/test/Memex.Portal.Shared.Test/AReservedIdIsNeverASignInTest.cs deleted file mode 100644 index 3791e2ba8e..0000000000 --- a/test/Memex.Portal.Shared.Test/AReservedIdIsNeverASignInTest.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Security.Claims; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Mesh.Security; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A sign-in can never BE the System identity, or one of the audience pseudo-users. -/// -/// Ingress audit for Plugins#2601. A session's object id is the local part of the -/// provider's preferred_username (or email). The portal accepts multi-tenant sign-in, so an -/// account system-security@attacker.onmicrosoft.com arrived as ObjectId -/// system-security: the System identity, which PermissionEvaluator grants every -/// permission on every partition. The same holds for Public, whose grants every signed-in -/// user inherits, and for a hub-shaped principal. -/// -public class AReservedIdIsNeverASignInTest -{ - private static ClaimsPrincipal SignIn(string username) => - new(new ClaimsIdentity( - [ - new Claim("preferred_username", username), - new Claim(ClaimTypes.Email, username), - new Claim(ClaimTypes.Name, username), - ], - authenticationType: "OpenIdConnect")); - - [Theory] - [InlineData("system-security@attacker.onmicrosoft.com")] - [InlineData("SYSTEM-SECURITY@attacker.onmicrosoft.com")] - [InlineData("public@attacker.onmicrosoft.com")] - [InlineData("anonymous@attacker.onmicrosoft.com")] - public void AReservedLocalPart_ResolvesToAnonymous(string username) - { - UserContextMiddleware.ExtractUserContext(SignIn(username))?.ObjectId - .Should().NotBeNull("the premise: the claims alone do yield the reserved local part"); - - var caller = UserContextMiddleware.ResolveHttpCaller(SignIn(username), new ServiceCollection().BuildServiceProvider()); - - caller.ObjectId.Should().Be(WellKnownUsers.Anonymous, - "a reserved id is never a person's; the sign-in is refused to least privilege, never adopted"); - } - - [Fact] - public void AnOrdinaryLocalPart_IsTheUser() - { - var caller = UserContextMiddleware.ResolveHttpCaller( - SignIn("jdoe@example.org"), new ServiceCollection().BuildServiceProvider()); - - caller.ObjectId.Should().Be("jdoe", "the control: an ordinary sign-in keeps its own id"); - } - - [Theory] - [InlineData("system-security", true)] - [InlineData("Public", true)] - [InlineData("Anonymous", true)] - [InlineData("mesh/abc", true)] - [InlineData("node/x", true)] - [InlineData("jdoe", false)] - [InlineData(null, false)] - public void TheReservedIds(string? id, bool reserved) => - RequestIdentity.IsReservedPrincipal(id).Should().Be(reserved); -} diff --git a/test/Memex.Portal.Shared.Test/AStaleRecordDoesNotUndoTheGateTest.cs b/test/Memex.Portal.Shared.Test/AStaleRecordDoesNotUndoTheGateTest.cs deleted file mode 100644 index 2d0ebcfdee..0000000000 --- a/test/Memex.Portal.Shared.Test/AStaleRecordDoesNotUndoTheGateTest.cs +++ /dev/null @@ -1,227 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Text.Json.Nodes; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The declared-access step decides "pre-installed" from the partition's LIVE ROOT, never from a -/// stale manifest — MeshWeaver#5297 / #5578. -/// -/// The live shape. Hosting on memex-cloud stopped being pre-installed -/// (MeshWeaver.Plugins#1959). Its root node said so; its install record kept -/// preInstalled: true until a re-install three days later. The Store's gating reconcile reads -/// the root and gates the partition (a policy withholding public read, a Public/Anonymous deny on every -/// child); the boot repair pass hands this step the record, whose legacy heal read those very denies as -/// pre-#902 damage, retired them and wrote publicRead: true over the gate's policy. Neither -/// write stuck: Hosting/_Policy reached version 238, and the gate logged, truthfully, that its -/// shape was "NOT STAYING". -/// -public class AStaleRecordDoesNotUndoTheGateTest(ITestOutputHelper output) - : MonolithMeshTestBase(output) -{ - /// - /// The plugin root's NodeType. Store/Plugin and its PluginContent are defined in - /// MeshWeaver.Plugins, so this mesh declares a stand-in NodeType at the same path with NO content - /// type: the root's content stays the raw "$type":"PluginContent" JSON that - /// PackageInstaller.PreInstalledOnRoot recognises by its discriminator — exactly what a - /// core host that never compiled the plugins' types reads. - /// - private const string PluginNodeType = "Store/Plugin"; - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .AddMeshNodes(new MeshNode("Plugin", "Store") - { - Name = "Plugin", - NodeType = MeshNode.NodeTypePath, - IsSatelliteType = false, - }); - - private IMeshService MeshService => Mesh.ServiceProvider.GetRequiredService(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - private ILogger Logger => Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - /// The install record's stored manifest: stamped while the package WAS pre-installed. - private static PackageManifest StaleRecord(string id) => new() - { - Id = id, - Kind = PackageKind.NodeRepo, - TargetPartition = id, - PreInstalled = true, - }; - - private Task Read(string path) => - Mesh.ServiceProvider.GetRequiredService() - .Read(path, Mesh.JsonSerializerOptions) - .Take(1) - .DefaultIfEmpty(null) - .Timeout(60.Seconds()) - .Await(TestContext.Current.CancellationToken); - - private Task WriteAsSystem(MeshNode node) => - Access.RunAsSystem(() => MeshService.CreateOrUpdateNode(node)) - .Should().Within(60.Seconds()) - .Emit($"writing the precondition node '{node.Path}' must land", - cancellationToken: TestContext.Current.CancellationToken); - - private Task Establish(PackageManifest manifest, string partition) => - Access.RunAsSystem(() => PackageInstaller.ReassertDeclaredAccess( - Mesh, manifest, partition, Logger)) - .Should().Within(120.Seconds()) - .Emit("the declared-access step must complete", - cancellationToken: TestContext.Current.CancellationToken); - - private async Task PolicyPublicRead(string partition) - { - var policy = await Read($"{partition}/_Policy"); - return policy?.ContentAs(Mesh.JsonSerializerOptions)?.PublicRead; - } - - private static string Deny(string scope, string subject) => $"{scope}/_Access/{subject}_Access"; - - /// - /// Arranges the gate's shape on a plugin partition whose root declares - /// : the policy the gate writes (public read withheld, the - /// paywall redirect) and the Public/Anonymous deny pair on an ordinary child. - /// - private async Task Arrange(string partition, bool rootPreInstalled) - { - var content = new JsonObject { ["$type"] = "PluginContent" }; - if (rootPreInstalled) - content["preInstalled"] = true; - // The production shape of a plugin root: NodeType Store/Plugin carrying PluginContent (both - // defined in MeshWeaver.Plugins, so neither is registered on this mesh). A `Space` carrying - // `"$type":"PluginContent"` is a shape nothing writes — the content names a type its NodeType - // does not bind — and the write boundary refuses it (ContentSchemaValidator, #6231). - await WriteAsSystem(new MeshNode(partition) - { - NodeType = PluginNodeType, - State = MeshNodeState.Active, - Content = content, - }); - await WriteAsSystem(new MeshNode("Guide", partition) { NodeType = "Markdown", State = MeshNodeState.Active }); - await WriteAsSystem(new MeshNode("_Policy", partition) - { - NodeType = "PartitionAccessPolicy", - Name = "Access Policy", - State = MeshNodeState.Active, - Content = new PartitionAccessPolicy { PublicRead = false, RedirectOnDenied = $"{partition}/Subscribe" }, - }); - await WriteAsSystem(PackageInstaller.ViewerAssignment( - $"{partition}/Guide", WellKnownUsers.Public, denied: true)); - await WriteAsSystem(PackageInstaller.ViewerAssignment( - $"{partition}/Guide", WellKnownUsers.Anonymous, denied: true)); - } - - /// - /// The root says GATED, the record says pre-installed: the step must leave the gate's shape alone. - /// - [Fact(Timeout = 300_000)] - public async Task ARootThatIsNoLongerPreInstalled_KeepsTheGatesDeniesAndPolicy() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - const string partition = "DemotedPkg"; - await Arrange(partition, rootPreInstalled: false); - - await Establish(StaleRecord(partition), partition); - - (await Read(Deny($"{partition}/Guide", WellKnownUsers.Public))).Should().NotBeNull( - "THE assertion: the root says this package is gated, so the Public deny the gate wrote on " - + "its child is the live access model, not pre-#902 damage — retiring it is the write the " - + "gate's next pass finds missing and reports as NOT STAYING (MeshWeaver#5297)"); - (await Read(Deny($"{partition}/Guide", WellKnownUsers.Anonymous))).Should().NotBeNull( - "both halves of the pair"); - (await PolicyPublicRead(partition)).Should().BeFalse( - "and the policy keeps withholding public read — a PublicRead policy here publishes every " - + "gated page on the C# read path and is the other half of the ping-pong"); - } - - /// - /// The control on the other side: a root that really IS pre-installed is still healed from the - /// legacy gate, so the fix is "read the root", not "stop healing". - /// - [Fact(Timeout = 300_000)] - public async Task ARootThatIsPreInstalled_IsStillHealed() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - const string partition = "BaselinePkg"; - await Arrange(partition, rootPreInstalled: true); - - await Establish(StaleRecord(partition), partition); - - (await Read(Deny($"{partition}/Guide", WellKnownUsers.Public))).Should().BeNull( - "a pre-installed root with a gated child is the pre-#902 fingerprint the heal exists for, " - + "and it must still be retired"); - (await PolicyPublicRead(partition)).Should().BeTrue( - "and the pre-installed partition is published"); - } - - /// The pure reader: which roots carry the declaration, and what an absent flag means. - [Fact] - public void PreInstalledOnRoot_ReadsOnlyAPluginRoot_AndAnAbsentFlagIsFalse() - { - var options = Mesh.JsonSerializerOptions; - PackageInstaller.PreInstalledOnRoot(null, options).Should().BeNull("no root, no statement"); - PackageInstaller.PreInstalledOnRoot(new MeshNode("X") { NodeType = "Space" }, options) - .Should().BeNull("a root without content says nothing"); - PackageInstaller.PreInstalledOnRoot( - new MeshNode("X") { Content = new JsonObject { ["$type"] = "Space" } }, options) - .Should().BeNull("a non-plugin root leaves the manifest to decide"); - PackageInstaller.PreInstalledOnRoot( - new MeshNode("X") { Content = new JsonObject { ["$type"] = "PluginContent" } }, options) - .Should().BeFalse("the serializer omits a false bool, so a stored gated root has no flag at all"); - PackageInstaller.PreInstalledOnRoot( - new MeshNode("X") - { - Content = new JsonObject { ["$type"] = "PluginContent", ["preInstalled"] = true }, - }, options) - .Should().BeTrue(); - PackageInstaller.PreInstalledOnRoot( - new MeshNode("X") { Content = new JsonObject { ["$type"] = "Store.Plugin.PluginContent" } }, - options) - .Should().BeFalse( - "a hub that had not registered the dynamic type writes a NAMESPACED discriminator, which " - + "the framework reads as the same short name — missing it would fall back to the stale " - + "manifest and re-arm the fight"); - } - - /// - /// The install path keeps its manifest: an install (or a delta) re-asserts access BEFORE the root - /// it carries lands, so reading the store there would decide on the PREVIOUS declaration. Only the - /// re-assert from a stored manifest consults the root. - /// - [Fact(Timeout = 300_000)] - public async Task TheInstallPath_StillDecidesOnTheManifestItCarries() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - const string partition = "PromotedPkg"; - await Arrange(partition, rootPreInstalled: false); - - await Access.RunAsSystem(() => PackageInstaller.EnsureDeclaredAccess( - Mesh, StaleRecord(partition), partition, Logger)) - .Should().Within(120.Seconds()) - .Emit("the declared-access step must complete", - cancellationToken: TestContext.Current.CancellationToken); - - (await PolicyPublicRead(partition)).Should().BeTrue( - "an install carrying preInstalled:true (a gated package being PROMOTED) publishes the " - + "partition even though the root it is about to overwrite still says gated"); - } -} diff --git a/test/Memex.Portal.Shared.Test/AboutSettingsTabTest.cs b/test/Memex.Portal.Shared.Test/AboutSettingsTabTest.cs deleted file mode 100644 index bf9e62ea11..0000000000 --- a/test/Memex.Portal.Shared.Test/AboutSettingsTabTest.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using Memex.Portal.Shared.SelfUpdate; -using Memex.Portal.Shared.Settings; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the About tab's installed-plugin inventory: the manifest→row mapping the grid binds -/// (name fallback, shortened module hash, no "null" cells), and that every localization key the -/// tab reads actually exists in the shipped catalog — a typo between host.Localize("…") -/// and strings.en.json renders the raw key to every user and no compiler catches it. -/// -public class AboutSettingsTabTest -{ - // Localizer echoes the key, so assertions pin WHICH key the line reads. - private static string Echo(string key) => key; - - /// - /// The About page states WHEN this build started serving, in the viewer's own zone. - /// - /// A version answers "which build" and never "since when". Those are different questions, - /// and the second is the one asked whenever somebody needs to know whether a portal actually - /// rolled forward — a re-deploy of the same image leaves the version identical, so only a - /// timestamp distinguishes it. - /// - /// 🚨 Rendered in the VIEWER's zone, never as bare UTC. The stored value is UTC by - /// contract; a viewer in Zurich reading "13:35" for a 13:35 UTC deploy would be an hour wrong - /// in summer, and silently so. - /// - [Fact] - public void StartedAt_is_rendered_in_the_viewers_zone_not_utc() - { - // 13:35 UTC on a summer date → 15:35 in Zurich (CEST, UTC+2). - var utc = new DateTimeOffset(2026, 8, 18, 13, 35, 0, TimeSpan.Zero); - - var markdown = AboutSettingsTab.StartedAtMarkdown(utc, "Europe/Zurich", Echo); - - markdown.Should().NotBeNull(); - markdown!.Should().Contain("15:35", "Zurich is UTC+2 in August — the viewer's clock, not the server's"); - markdown.Should().NotContain("13:35", "a bare UTC time is exactly the thing that misleads"); - markdown.Should().Contain("about.lastDeployed", "the label must come from the localization catalog"); - } - - /// - /// An unavailable start time renders NOTHING rather than a fabricated one. The fallback for a - /// host that refuses process introspection is default, and printing 01/01/0001 — or - /// worse, "now" — would read as a fresh deploy on every render. - /// - [Fact] - public void StartedAt_renders_nothing_when_the_start_time_is_unknown() - => AboutSettingsTab.StartedAtMarkdown(default, "Europe/Zurich", Echo).Should().BeNull(); - - [Fact] - public void ToRows_maps_manifests_to_display_rows() - { - var rows = AboutSettingsTab.ToRows(new[] - { - new PackageManifest - { - Id = "edu", Name = "Education", Version = "1.0.6", - ModuleVersion = "9148498b9487955bdeadbeef", Kind = PackageKind.Content, TargetPartition = "Edu" - }, - // Name missing → the id is the display name; nullable fields render EMPTY, never "null". - new PackageManifest { Id = "bare-plugin" } - }); - - rows.Should().HaveCount(2); - rows[0].Name.Should().Be("Education"); - rows[0].Version.Should().Be("1.0.6"); - rows[0].Module.Should().Be("9148498b9487", "the module hash is shortened to 12 chars for display"); - rows[0].Kind.Should().Be("Content"); - rows[0].Partition.Should().Be("Edu"); - - rows[1].Name.Should().Be("bare-plugin"); - rows[1].Version.Should().Be(""); - rows[1].Module.Should().Be(""); - rows[1].Partition.Should().Be(""); - } - - /// - /// The update line renders for both verdicts and stays SILENT on Unknown — an install with - /// no self-update wiring (or with checks switched off) must not be told it is "up to date" on the - /// strength of a check that never ran. - /// - [Fact] - public void UpdateStatusMarkdown_renders_both_verdicts_and_nothing_when_unknown() - { - // Localizer echoes the key, so the assertions pin WHICH key each verdict reads. - static string Echo(string key) => key; - - var available = AboutSettingsTab.UpdateStatusMarkdown( - new PlatformUpdateStatus(PlatformUpdateAvailability.UpdateAvailable, "3.0.0-ci.2400"), Echo); - available.Should().Contain("about.updateAvailable").And.Contain("3.0.0-ci.2400"); - - var current = AboutSettingsTab.UpdateStatusMarkdown( - new PlatformUpdateStatus(PlatformUpdateAvailability.UpToDate, null), Echo); - current.Should().Contain("about.upToDate"); - // The verdict is a two-state answer — "up to date" must never name a version. - current.Should().NotContain("3.0.0"); - - AboutSettingsTab.UpdateStatusMarkdown(PlatformUpdateStatus.Unknown, Echo).Should().BeNull(); - } - - [Fact] - public void Every_localization_key_the_about_tab_reads_exists_in_the_catalog() - { - // The keys BuildContent / BuildInfoMarkdown / PluginsGrid resolve. A key missing from the - // catalog does not fail anywhere at compile time — it just renders as the raw key. - string[] used = - [ - "ui.aboutMeshWeaver", "about.intro", "about.version", "about.buildCommit", - "about.buildCommitMissing", "about.repository", "about.runtime", - "about.updateStatus", "about.upToDate", "about.updateAvailable", - "about.plugins.title", "about.plugins.none", - "about.plugins.column.name", "about.plugins.column.version", "about.plugins.column.module", - "about.plugins.column.kind", "about.plugins.column.partition" - ]; - - var catalogPath = Path.Combine(FindRepoRoot(), - "src", "MeshWeaver.Messaging.Hub", "Localization", "strings.en.json"); - var catalog = JsonSerializer.Deserialize>(File.ReadAllText(catalogPath))!; - - var missing = used.Where(k => !catalog.ContainsKey(k)).ToList(); - missing.Should().BeEmpty($"the About tab renders these keys raw when absent: {string.Join(", ", missing)}"); - } - - private static string FindRepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); - } -} diff --git a/test/Memex.Portal.Shared.Test/AbsentUpdatePolicyFailsClosedTest.cs b/test/Memex.Portal.Shared.Test/AbsentUpdatePolicyFailsClosedTest.cs deleted file mode 100644 index 7c38b5606d..0000000000 --- a/test/Memex.Portal.Shared.Test/AbsentUpdatePolicyFailsClosedTest.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Text.Json; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A policy record that has LOST its policy field must read as -/// — never as "auto-update enabled" (#3542, proposal 3). -/// -/// The defect this pins is a two-part mechanism, and neither part is visible on its own. The -/// hub serializer sets DefaultIgnoreCondition = WhenWritingDefault, so whichever enum member -/// is ZERO is omitted from the persisted record; and an omitted field deserializes back to that same -/// member. Continuous IS the enum's zero — it still is, deliberately (reordering the enum -/// would make an explicit None unwritable, which is the safety-critical direction) — so a -/// record that lost its policy under its own bookkeeping writes read back as the MOST PERMISSIVE -/// state, reached purely by losing information. That is how memex-cloud rolled onto a withdrawn -/// 3.1.0-ci line "on a policy record that lost its own policy". The cure is the NULLABLE -/// backing field: null is the default that gets dropped, and it reads as None. -/// -/// 🚨 The two assertions below are a pair on purpose. Fail-closed alone would be satisfied by -/// an enum nobody can express Continuous in; round-tripping alone would be satisfied by the old -/// shape. Together they say: an ABSENT policy is None, and an EXPLICIT Continuous survives — which -/// is the distinction the old shape could not make. -/// -/// This pins what an absent field MEANS. What stops the field from GOING absent is -/// UnreadablePolicyRecordIsNotClobberedTest: a bookkeeping write that could not read the -/// record refuses instead of writing a default over it. -/// -public class AbsentUpdatePolicyFailsClosedTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - [Fact] - public void AnAbsentPolicyFieldReadsAsNone() - { - // A record persisted without a `policy` field — exactly what the bookkeeping write leaves - // behind when the policy is at the serializer's default. - const string withoutPolicy = """{"latestAvailableTag":"3.0.0-ci.8009"}"""; - - var content = JsonSerializer.Deserialize( - withoutPolicy, Mesh.JsonSerializerOptions); - - Assert.NotNull(content); - Assert.Equal(UpdatePolicyKind.None, content!.Policy); - } - - [Fact] - public void AnExplicitContinuousSurvivesTheRoundTrip() - { - // The other half: the DECLARED policy is nullable, so its default is null and every named - // member — Continuous included — is non-default and must be WRITTEN OUT. Otherwise an - // admin's explicit choice would decay into the absent case and be silently downgraded to - // None on the next read. - var chosen = new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous }; - - var json = JsonSerializer.Serialize(chosen, Mesh.JsonSerializerOptions); - Assert.Contains("Continuous", json); - - var readBack = JsonSerializer.Deserialize(json, Mesh.JsonSerializerOptions); - Assert.Equal(UpdatePolicyKind.Continuous, readBack!.Policy); - } -} diff --git a/test/Memex.Portal.Shared.Test/ActivationReadIsNotQueuedBehindALandingTest.cs b/test/Memex.Portal.Shared.Test/ActivationReadIsNotQueuedBehindALandingTest.cs deleted file mode 100644 index 098e37162e..0000000000 --- a/test/Memex.Portal.Shared.Test/ActivationReadIsNotQueuedBehindALandingTest.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System; -using System.IO; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Reactive.Assertions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#4963 / #4804 — a read of the module-activation record must never queue behind a -/// LANDING in flight. -/// -/// The registry answers every /api/plugins/bundles/index.json with two reads of the -/// activation record and every bundle download with one, and those reads ran on the same cap-1 -/// pool that serialises — the publish landing, one -/// SMB round trip per file on the shared volume. So a consumer's authenticated index request waited, -/// with nothing written to it, for every landing in flight and every other request's read, one at a -/// time, process-wide: #4963's "0 bytes received" for a whole client budget, from a host -/// answering every other probe in ~0.1 s. -/// -/// Deterministic, not timed: the landing is PARKED inside its recording window (after its -/// bytes are on the volume, before anything is recorded — the #4026 seam), which holds the landing -/// lane for as long as the test wants. The read is then asked for with a bound far shorter than the -/// park. On the old lane the read cannot run until the park ends, so it fails; on its own lane it -/// answers at once. -/// -public class ActivationReadIsNotQueuedBehindALandingTest : IDisposable -{ - private const string Module = "MeshWeaver.Test.ReadBesideALanding"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-read-beside-landing-" + Guid.NewGuid().ToString("N")); - - /// Creates the landing root. - public ActivationReadIsNotQueuedBehindALandingTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - /// - /// A landing parked in its recording window holds the landing lane; the activation read still - /// answers — and answers what is TRUE at that moment (nothing recorded yet), then, once the - /// landing finishes, the landed head. - /// - [Fact] - public async Task AReadAnswersWhileALandingHoldsTheLandingLane() - { - var ct = TestContext.Current.CancellationToken; - var released = 0; - var parked = new AsyncSubject(); - using var landing = new ModuleLandingService(null, root, beforeRecording: _ => - { - parked.OnNext(Unit.Default); - parked.OnCompleted(); - // The deliberate park — the subject of this test. Bounded, and released in the finally - // below, so a failing assertion can never strand the landing's thread. - SpinWait.SpinUntil(() => Volatile.Read(ref released) == 1, TestTimeouts.Convergence); - }); - - var landed = new ReplaySubject(); - using var landingSubscription = landing.ShelveModule( - Module, - [(Module + ".dll", File.ReadAllBytes(typeof(BundleReader).Assembly.Location))], - frameworkMvid: "test-build", packagePath: "Plugins/ReadBesideALanding", version: "1.0.0", - staticAssets: [("wwwroot/build.txt", Encoding.UTF8.GetBytes("1.0.0"))]) - .Subscribe(landed); - try - { - await parked.Should().Within(TestTimeouts.Convergence) - .Emit("the landing must reach its recording window, or nothing below is measured", - cancellationToken: ct); - - // 🚨 THE ASSERTION. The landing lane is held until `released` flips; the read must not - // need it. Quick is a third of the park's bound, so on the old lane this cannot pass. - var during = await landing.GetActivation().Should().Within(TestTimeouts.Quick) - .Emit("a read of the activation record must not wait for a landing in flight — " - + "the registry's bundle index reads it on every request (#4963)", - cancellationToken: ct); - - // And the read is not a torn one: the parked landing has put bytes on the volume but - // recorded nothing, so the module is not yet an entry. - Assert.DoesNotContain(during.Entries, - e => string.Equals(e.Name, Module, StringComparison.OrdinalIgnoreCase)); - } - finally - { - Volatile.Write(ref released, 1); - } - - // Positive control: the landing itself completes, and a read after it sees the head. - await landed.Should().Within(TestTimeouts.Convergence) - .Emit("the parked landing must complete once released", cancellationToken: ct); - var after = await landing.GetActivation().Should().Within(TestTimeouts.Convergence) - .Emit("the activation read must answer after the landing", cancellationToken: ct); - var head = Assert.Single(after.Entries, - e => string.Equals(e.Name, Module, StringComparison.OrdinalIgnoreCase)); - Assert.Equal("1.0.0", head.Version); - } -} diff --git a/test/Memex.Portal.Shared.Test/ActivationRecordPublicationTest.cs b/test/Memex.Portal.Shared.Test/ActivationRecordPublicationTest.cs deleted file mode 100644 index cb9f8aefd6..0000000000 --- a/test/Memex.Portal.Shared.Test/ActivationRecordPublicationTest.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The WRITER/READER protocol for a module's activation records (MeshWeaver#2190): a record's name -/// appears holding the whole record, or it does not appear — and a publication that cannot be made -/// that way publishes NOTHING rather than something a reader can trip over. -/// -/// Why the reader has no second option. A record it cannot read costs the module WHOLE -/// (#4026's fail-closed rule: an unreadable tombstone skipped would load an uninstalled module, an -/// unreadable head record would promote an older generation). At BOOT that is the process's answer -/// for its lifetime — the pod runs without the module until it is restarted. So "the reader copes" -/// is not available; the name must never be observable mid-write. -/// -/// That is exactly what memex logged on 2026-09-16 for -/// MeshWeaver.AI.Anthropic@bd2a4b60e93636cb…verdict: "could not be read (IOException: The -/// process cannot access the file … because it is being used by another process)", followed by -/// "Module 'MeshWeaver.AI.Anthropic' is NOT loaded from this read". The first test here -/// reproduces that pairing from the reader's side; the second pins the writer's half. -/// -public class ActivationRecordPublicationTest : IDisposable -{ - private const string Module = "MeshWeaver.Test.RecordPublication"; - private const string Generation = Module + "@0123456789abcdef"; - - private readonly string root = Path.Combine( - Path.GetTempPath(), "mw-record-publication-" + Guid.NewGuid().ToString("N")); - - private string? elsewhere; - - /// Creates the deployment root the records live under. - public ActivationRecordPublicationTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - GC.SuppressFinalize(this); - try { Directory.Delete(root, recursive: true); } catch { /* temp cleanup is never a failure */ } - try { if (elsewhere is not null) Directory.Delete(elsewhere, recursive: true); } catch { /* same */ } - } - - private static ModuleLandingRecord Landing() => new() - { - Name = Module, - Source = ModuleActivationSources.Store, - Directory = Generation, - Version = "1.0.0", - }; - - private string RecordsDirectory => ModuleActivationSidecar.LandingRecordsDirectory(root, Module); - - /// - /// 🚨 The production failure, from the reader's side: while ONE of a module's records is held - /// open exclusively — which is precisely what File.Move(…, overwrite: false)'s copy - /// fallback does to the final name, with FileShare.None, for the whole copy — the module - /// is dropped from the read. The read is fail-closed by design, so this test is the REASON the - /// writer may never create a final name any way but by renaming a complete file. - /// - [Fact] - public void AWriterHoldingARecordsFinalNameExclusively_CostsTheWholeModule() - { - Assert.True(ModuleActivationSidecar.WriteLanding(root, Landing())); - Assert.True(ModuleActivationSidecar.WriteVerdict(root, Module, Generation, "test-platform", linkable: true)); - - var faults = new List(); - var before = ModuleActivationSidecar.Read(root, faults.Add); - Assert.Empty(faults); - Assert.Contains(before.Entries, e => string.Equals(e.Name, Module, StringComparison.Ordinal)); - - var verdict = Directory.GetFiles(RecordsDirectory, "*" + ModuleActivationSidecar.VerdictSuffix).Single(); - using (new FileStream(verdict, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) - { - var during = ModuleActivationSidecar.Read(root, faults.Add); - - Assert.Contains(faults, f => f.Contains(Path.GetFileName(verdict), StringComparison.Ordinal)); - Assert.Contains(faults, f => f.Contains("NOT loaded from this read", StringComparison.Ordinal)); - Assert.DoesNotContain(during.Entries, e => string.Equals(e.Name, Module, StringComparison.Ordinal)); - } - - // Released: the module is back, from the same files. Nothing about the records changed — which - // is what makes the drop above a property of the LOCK and not of anything written. - faults.Clear(); - var after = ModuleActivationSidecar.Read(root, faults.Add); - Assert.Empty(faults); - Assert.Contains(after.Entries, e => string.Equals(e.Name, Module, StringComparison.Ordinal)); - } - - /// - /// The writer's half: where the record cannot be RENAMED into place — here because its directory - /// is on another file system, which is the deterministic stand-in for the Azure Files rename the - /// portals' volume refuses — the write is refused and the record directory stays empty. Before - /// #2190 this call published a record by COPYING into the final name, which is the state the test - /// above shows costs a module. - /// - [Fact] - public void ARecordThatCannotBeRenamedIntoPlace_IsNotPublishedAtAll() - { - elsewhere = DirectoryOnAnotherFileSystem(); - Assert.SkipWhen(elsewhere is null, - "this host exposes no second file system, so a refused rename cannot be staged here; the " - + "primitive's own refusal rows are pinned in MeshWeaver.Hosting.Test's NoReplaceMoveTest."); - - Directory.CreateDirectory(ModuleActivationSidecar.EntriesDirectory(root)); - Directory.CreateSymbolicLink(RecordsDirectory, elsewhere!); - - var refusal = Assert.Throws(() => - ModuleActivationSidecar.WriteVerdict(root, Module, Generation, "test-platform", linkable: true)); - - Assert.Contains("NOT published", refusal.Message, StringComparison.Ordinal); - Assert.Empty(Directory.GetFiles(elsewhere!)); - var faults = new List(); - Assert.Empty(ModuleActivationSidecar.Read(root, faults.Add).Entries); - Assert.Empty(faults); - } - - /// A writable directory on another file system, or null on a host that has none (macOS); - /// the caller SKIPS rather than passing when this answers null. - private static string? DirectoryOnAnotherFileSystem() - { - foreach (var candidate in new[] { "/dev/shm", "/run/shm" }) - { - if (!Directory.Exists(candidate)) - continue; - var directory = Path.Combine(candidate, "mw-record-publication-" + Guid.NewGuid().ToString("N")); - try - { - Directory.CreateDirectory(directory); - return directory; - } - catch (Exception e) when (e is IOException or UnauthorizedAccessException) - { - // Not writable here; try the next candidate. - } - } - return null; - } -} diff --git a/test/Memex.Portal.Shared.Test/AdminAppFirstFrameTest.cs b/test/Memex.Portal.Shared.Test/AdminAppFirstFrameTest.cs deleted file mode 100644 index da33b8cc38..0000000000 --- a/test/Memex.Portal.Shared.Test/AdminAppFirstFrameTest.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Client; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The Admin app as a reader that takes ONE frame sees it — an MCP get @Admin/area/Settings -/// (MeshOperations.RenderArea takes the first materialised frame and closes), a first paint. Measured on a live -/// instance: that frame listed only the compiled Admin-app tabs — the six seeded ones (Invitations, -/// Privacy, Published to the web, Updates, Control lane, Inbox) were missing from the nav while the -/// same seeds passed their gates in $Menu:NodeSettings — and the Overview's installed-plugin -/// section said "No plugins are installed on this instance" on an instance running dozens. -/// -/// Both were a first frame painted from a SEED rather than an answer. The seeded tabs' lane -/// opened its admin verdict with a synthetic false, so the page painted before the verdict -/// the rest of the page already waited for; the plugin section opened its install-registry query -/// with an empty list and rendered that as "none installed". A person watching the page saw the -/// tabs and the grid arrive a moment later; anything that reads one frame — the MCP read, a -/// screenshot, a server-side first paint — saw the seed as the answer. -/// -/// The platform admin's grant is created at RUNTIME, as it is on an instance (Admin/_Access -/// rows written by the setup claim or another admin) — a static grant would answer the verdict on -/// the evaluator's synchronous fast path and hide the ordering this pins. -/// -public class AdminAppFirstFrameTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string PlatformAdmin = "adminframe-admin"; - private static TimeSpan Budget => TestTimeouts.Convergence; - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddPluginCatalog() - .AddPlatformSettingsTabContributions() - .AddMeshNodes(new MeshNode(PlatformAdmin) - { - NodeType = UserNodeType.NodeType, - Name = PlatformAdmin, - State = MeshNodeState.Active, - Content = new User { FullName = PlatformAdmin, Email = $"{PlatformAdmin}@meshweaver.io" }, - }) - .ConfigureDefaultNodeHub(config => config - .AddPlatformSettingsTabAreas() - .AddAdminAppOverviewTab()) - // Both instances the defect was measured on close themselves to logged-out callers: - // there, a read that lost its viewer is ANONYMOUS and holds nothing — not even the - // world-readable install records. - .ConfigureServices(services => services.AddSingleton( - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [AnonymousAccess.DenyAnonymousConfigKey] = "true", - }).Build())); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - /// Grants the platform admin at runtime and installs one package, as System. - private async Task Arrange() - { - var ct = TestContext.Current.CancellationToken; - var mesh = Mesh.ServiceProvider.GetRequiredService(); - await Access.RunAsSystem(() => mesh.CreateNode( - AssignmentNodeFactory.UserRole(PlatformAdmin, "Admin", AdminAppNodeType.Path))) - .FirstAsync().Timeout(Budget).Await(ct); - var manifest = new PackageManifest - { - Id = "FirstFramePlugin", Name = "First frame plugin", Version = "1.0.0", - Kind = PackageKind.Content, TargetPartition = "FirstFramePlugin", SourceFolder = "FirstFramePlugin", - }; - await PackageInstaller.Install(Mesh, manifest, - [new PackageFile("FirstFramePlugin/Doc.md", "# First frame plugin")], "HEAD") - .FirstAsync().Timeout(Budget).Await(ct); - } - - /// - /// Every frame of the Admin app's settings page as the platform admin sees it, the identity - /// carried the way a multi-user server carries it: on the REQUEST only. The test host's - /// process-wide identity (TestUsers.DevLogin) is cleared first, because a portal never has - /// one — there, every render continuation that runs off the viewer's delivery finds no identity - /// at all, and a test host that falls back to one hides exactly that. - /// - private IObservable Frames() - { - var viewer = new AccessContext - { ObjectId = PlatformAdmin, Name = PlatformAdmin, Email = $"{PlatformAdmin}@meshweaver.io" }; - var client = GetClient(); - var access = client.ServiceProvider.GetRequiredService(); - Access.ClearHostIdentity(); - access.ClearHostIdentity(); - // The subscribe request is stamped with the identity ambient at stream creation. - using (access.SwitchAccessContext(viewer)) - return client.GetWorkspace() - .GetRemoteStream( - new Address(AdminAppNodeType.Path), new LayoutAreaReference(MeshNodeLayoutAreas.SettingsArea)) - .Select(change => change.Value); - } - - /// - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) - => base.ConfigureClient(configuration).AddLayoutClient(); - - private static bool IsType(JsonElement control, string type) - => control.TryGetProperty("$type", out var t) && t.GetString() == type; - - /// Every NavLink title in the frame, each with the title of the NavGroup it sits in. - private static (string Tab, string? Section)[] NavTabs(JsonElement frame) - { - if (!frame.TryGetProperty("areas", out var areas)) - return []; - var groups = areas.EnumerateObject() - .Where(a => IsType(a.Value, "NavGroupControl")) - .Select(a => (Id: JsonSerializer.Deserialize(a.Name)!, Title: a.Value.GetProperty("title").GetString())) - .ToArray(); - return areas.EnumerateObject() - .Where(a => IsType(a.Value, "NavLinkControl")) - .Select(a => - { - var id = JsonSerializer.Deserialize(a.Name)!; - var section = groups.FirstOrDefault(g => id.StartsWith(g.Id + "/", StringComparison.Ordinal)).Title; - return (a.Value.GetProperty("title").GetString()!, section); - }) - .ToArray(); - } - - /// - /// The seeded instance tabs are in the Admin app's nav the FIRST time the nav renders, each in - /// the section its seed names: Invitations, Privacy and Published to the web under People & - /// sign-in; Updates, Control lane and Inbox under Operations. "First time the nav renders" is - /// the first frame carrying the compiled Administrators tab — a reader that takes that frame - /// (an MCP read, a first paint) must not find the seeded tabs missing. - /// - [Fact(Timeout = 90000)] - public async Task TheFirstRenderedNav_CarriesTheSeededInstanceTabs_InTheirSections() - { - await Arrange(); - var tabs = await Frames() - .Select(NavTabs) - .Where(t => t.Any(x => x.Tab == "Administrators")) - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - Output.WriteLine(string.Join("\n", tabs.Select(t => $"{t.Section} / {t.Tab}"))); - - tabs.Should().Contain(("Invitations", AdminAppNodeType.PeopleGroup)); - tabs.Should().Contain(("Privacy", AdminAppNodeType.PeopleGroup)); - tabs.Should().Contain(("Published to the web", AdminAppNodeType.PeopleGroup)); - tabs.Should().Contain(("Updates", AdminAppNodeType.OperationsGroup)); - tabs.Should().Contain(("Control lane", AdminAppNodeType.OperationsGroup)); - tabs.Should().Contain(("Inbox", AdminAppNodeType.OperationsGroup)); - } - - /// - /// The Overview's installed-plugin section, the first time it says anything, lists what IS - /// installed — never the "No plugins are installed" line painted before the registry answered. - /// - [Fact(Timeout = 90000)] - public async Task TheInstalledPluginSection_FirstSaysWhatIsInstalled() - { - await Arrange(); - var names = await Frames() - .Select(frame => (Text: frame.GetRawText(), Frame: frame)) - .Where(f => f.Text.Contains("No plugins are installed on this instance", StringComparison.Ordinal) - || f.Text.Contains("aboutPlugins", StringComparison.Ordinal)) - .Select(f => f.Frame.GetProperty("data").EnumerateObject() - .Where(d => JsonSerializer.Deserialize(d.Name) == "aboutPlugins") - .SelectMany(d => d.Value.EnumerateArray()) - .Select(row => row.GetProperty("name").GetString()) - .ToArray()) - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - - names.Should().Contain("First frame plugin", - "a package is installed — the section's first word is the registry's answer, never an empty seed"); - } -} diff --git a/test/Memex.Portal.Shared.Test/AdminChosenPolicyReachesTheRecordTest.cs b/test/Memex.Portal.Shared.Test/AdminChosenPolicyReachesTheRecordTest.cs deleted file mode 100644 index 553666db5e..0000000000 --- a/test/Memex.Portal.Shared.Test/AdminChosenPolicyReachesTheRecordTest.cs +++ /dev/null @@ -1,349 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 THE ADMIN'S CHOICE MUST REACH THE RECORD — #3542, the half that outlived both earlier -/// fixes. -/// -/// What #3607 and #3619 each settled, and what neither touched. #3607 made an ABSENT -/// policy field read as rather than as "enabled", by -/// renaming UpdatePolicyContent.Policy to DeclaredPolicy and giving it -/// [JsonPropertyName("policy")]. #3619 stopped a bookkeeping write that could not READ the -/// record from overwriting it with a default. Between them, an install that has lost its policy -/// fails closed and stops losing it again — and the ONE surface built to put the policy back, the -/// Updates settings tab, silently stopped working in the very commit that renamed the property. -/// -/// The mechanism. derived each editor -/// field's JSON Key from the CLR PROPERTY NAME (p.Name.ToCamelCase()), ignoring -/// [JsonPropertyName]. MeshNodeContentEditorView uses that key verbatim, on both -/// sides: LoadValues reads obj[f.Key] and Persist writes obj[f.Key]. So -/// after the rename the "Update strategy" dropdown bound to declaredPolicy — a key the -/// record neither writes nor reads: -/// -/// an install WITH a policy renders the dropdown EMPTY (the read misses); -/// an admin who then picks one persists declaredPolicy, which -/// deserializes into nothing — Policy stays . -/// 🚨 Measured here: the value does not even reach storage. The owning hub materialises the -/// content as UpdatePolicyContent and re-serialises it, so the unknown key is dropped on -/// that round trip — spends its whole budget without ever -/// seeing Stable on the record. The control keeps the choice in its own field state, so -/// the tab shows it until the next emission and then silently reverts to unset. -/// -/// -/// 🚨 That is what makes the fault self-latching rather than merely wrong. Under -/// None the poller records "updates are disabled on this install" and evaluates nothing -/// (#3795), so no check, hold or verdict ever contradicts the tab. The single repair surface is a -/// no-op that looks like a success, and nothing else on the install disagrees with it. -/// -/// Measured on memex.meshweaver.cloud, 2026-09-18: Admin/UpdatePolicy carries no -/// policy field, its lastCheckVerdict reads "updates are disabled on this install -/// (Admin/UpdatePolicy = None); the registry was not listed", and its heldAt has been frozen -/// at 2026-09-11T08:57Z ever since — the timestamp of the last evaluation that ran while a policy -/// was still on the record. -/// -/// 🚨 The writes here are the view's, byte for byte — the same -/// obj[key] = value read-modify-write through GetMeshNodeStream(path).Update(...) that -/// MeshNodeContentEditorView.Persist posts (the shape -/// MeshWeaver.GitSync.Test.MeshNodeContentEditorTest already pins for the sync config). The -/// Blazor view lives in MeshWeaver.Plugins and is unchanged by this fix: it uses -/// exactly as declared, and the key is what was wrong. -/// -public class AdminChosenPolicyReachesTheRecordTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// An install in the state memex-cloud is in: updates disabled, real bookkeeping on the - /// record. The admin's repair is to pick a strategy on the Updates tab. - private const string DisabledRecord = - """{"$type":"UpdatePolicyContent","policy":"None","requireCiGreen":true,"latestAvailableTag":"3.0.0-ci.8009"}"""; - - /// 🚨 , never a literal: a hand-written 30 s is - /// both a guess about machine speed AND the framework's own write bound (#2819). - private static TimeSpan Budget => TestTimeouts.Convergence; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => - base.ConfigureMesh(builder).AddUpdatePolicyType(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - /// - /// The dropdown an admin actually reaches for: the field carrying - /// UpdatePolicyContent.DeclaredPolicy's own [Description] — the only one of the - /// two the tab rendered with a label (and a German [Translation]) that says what it is. - /// Selected by that label rather than by key ON PURPOSE: the key is the subject under test, so - /// naming it here would make the assertion circular. - /// - private static MeshNodeEditorField StrategyField() => - MeshNodeEditorField.FromType(typeof(UpdatePolicyContent)) - .Single(f => f.Label == "Update strategy"); - - /// - /// 🚨 The SECOND half of the same reflection defect: [JsonIgnore] was not a reason to - /// skip a property, so the computed UpdatePolicyContent.Policy — a read-side convenience - /// that exists only to fold an absent declaration to — was - /// offered as a second, unlabelled strategy dropdown. Two dropdowns over the same enum, one of - /// which is read and one of which is not, and nothing on the tab says which. - /// - [Fact] - public void TheUpdatesTabOffersExactlyOneStrategyDropdown() - { - var strategy = MeshNodeEditorField.FromType(typeof(UpdatePolicyContent)) - .Where(f => f.Kind == MeshNodeEditorFieldKind.Enum - && f.Options.SequenceEqual(Enum.GetNames())) - .Select(f => $"{f.Key} ('{f.Label}')") - .ToList(); - - Output.WriteLine($"strategy dropdowns: {string.Join(", ", strategy)}"); - - strategy.Should().HaveCount(1, - "a [JsonIgnore] property is one the record does not persist, so offering it as an " - + "editable field puts a second control over the same value on the tab — and an admin " - + "has no way to tell which of the two is the one that is read (#3542)"); - } - - /// - /// 🚨 THE REGRESSION. An admin picks Stable in the Updates tab's strategy dropdown; the - /// install must then FOLLOW Stable. Before the fix the write landed under - /// declaredPolicy, nothing read it, and Policy stayed None — the update - /// stayed disabled while the tab showed the choice as applied. - /// - [Fact(Timeout = 180000)] - public async Task AnAdminWhoPicksAStrategyOnTheUpdatesTabChangesThePolicy() - { - await Seed(DisabledRecord, TestContext.Current.CancellationToken); - - var field = StrategyField(); - Output.WriteLine($"strategy field key = '{field.Key}', label = '{field.Label}'"); - - await PersistAsTheEditorDoes(field.Key, JsonValue.Create(nameof(UpdatePolicyKind.Stable))); - - var content = await WaitForWrittenValue(nameof(UpdatePolicyKind.Stable)); - - content.Policy.Should().Be(UpdatePolicyKind.Stable, - "the strategy an admin picks on the Updates tab is the ONLY surface built to re-enable " - + "an install whose policy was lost — a write that lands under a key nothing reads " - + "leaves updates disabled while the tab reports success, and under None nothing ever " - + "evaluates to contradict it (#3542)"); - - content.LatestAvailableTag.Should().Be("3.0.0-ci.8009", - "a per-field editor write must not replace the record it patched"); - } - - /// - /// 🚨 The READ half of the same key. An install that HAS a policy must render it in the - /// dropdown; a key that misses reads as unset, which is what invites the admin to "set" a - /// policy that is already there and then discard it. - /// - [Fact(Timeout = 180000)] - public async Task TheStrategyDropdownShowsThePolicyTheRecordCarries() - { - await Seed(DisabledRecord, TestContext.Current.CancellationToken); - - var field = StrategyField(); - - // MeshNodeContentEditorView.LoadValues, byte for byte: obj[f.Key] off the node stream. - var shown = (await AsEditorJsonObject())?[field.Key]?.ToString(); - Output.WriteLine($"dropdown would show: '{shown ?? ""}'"); - - shown.Should().Be(nameof(UpdatePolicyKind.None), - "the Updates tab reads the record through this key; when it misses, an install with an " - + "explicit policy renders as if it had none"); - } - - /// - /// 🚨 THE POSITIVE CONTROL. A field with neither trap — its CLR name already equals its JSON - /// name, and its value is not a CLR default — must land through the very same write path, and - /// must do so BEFORE the fix as well as after. Without it, "the edit did not stick" would be - /// satisfied by an editor write that simply does not work, and by a harness that never wrote - /// anything. (Measured before the fix: this arm is the one that passed.) - /// - [Fact(Timeout = 180000)] - public async Task AFieldWithNeitherTrapLands() - { - await Seed(DisabledRecord, TestContext.Current.CancellationToken); - - var pattern = MeshNodeEditorField.FromType(typeof(UpdatePolicyContent)) - .Single(f => f.Key == "pattern"); - - await PersistAsTheEditorDoes(pattern.Key, JsonValue.Create("3.0.1-ci*")); - - var content = await WaitForContent(c => c.Pattern is not null); - - content.Pattern.Should().Be("3.0.1-ci*"); - content.LatestAvailableTag.Should().Be("3.0.0-ci.8009", - "a per-field editor write leaves the bookkeeping the poller owns alone"); - } - - /// - /// 🚨 THE THIRD WAY THE SAME TAB DISCARDS A CHOICE, and an independent defect from the key: - /// defaults to true through a property - /// INITIALIZER, while the hub serializer omits WhenWritingDefault — and false IS - /// the CLR default for a bool. So unticking "Only update to CI-verified (green) builds" wrote a - /// value the serializer dropped, and the next read re-applied the initializer's true. - /// - /// The cure is the one NotificationSettings and GitHubSyncConfig already - /// document on every default-true bool they own: [JsonIgnore(Condition = Never)]. This - /// was the ONLY editable property across the five editor-bound content types in the tree whose - /// initializer differs from its CLR default and which lacked the attribute — every - /// HomeConfig and GitHubSyncConfig enum defaults to its own zero member, so their - /// round trip was already lossless. - /// - [Fact(Timeout = 180000)] - public async Task UntickingOnlyUpdateToCiVerifiedBuildsSticks() - { - await Seed(DisabledRecord, TestContext.Current.CancellationToken); - - var green = MeshNodeEditorField.FromType(typeof(UpdatePolicyContent)) - .Single(f => f.Kind == MeshNodeEditorFieldKind.Bool); - green.Key.Should().Be("requireCiGreen"); - - await PersistAsTheEditorDoes(green.Key, JsonValue.Create(false)); - - var content = await WaitForContent(c => !c.RequireCiGreen); - - content.RequireCiGreen.Should().BeFalse( - "a checkbox an admin can untick and that never stays unticked is a setting the platform " - + "silently overrules (#3542)"); - content.Policy.Should().Be(UpdatePolicyKind.None, - "a per-field editor write touches only its own field"); - } - - /// - /// MeshNodeContentEditorView.Persist, reproduced exactly: read the node's content as a - /// , set ONE key, write the whole object back through the node stream. - /// Nothing here is a stand-in — the key is what the backend declared and the write is the one - /// the GUI posts. - /// - private Task PersistAsTheEditorDoes(string key, JsonNode? value) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Update(node => - { - var obj = ToJsonObject(node.Content) ?? new JsonObject(); - obj[key] = value is null ? null : JsonNode.Parse(value.ToJsonString()); - return node with - { - Content = JsonSerializer.SerializeToElement( - obj, Mesh.JsonSerializerOptions), - }; - }) - .Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - /// The node content in the shape the editor view reads its values from. - private Task AsEditorJsonObject() => - RawContent() - .Select(ToJsonObject) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - private JsonObject? ToJsonObject(object? content) => - content switch - { - null => null, - JsonElement je => JsonNode.Parse(je.GetRawText()) as JsonObject, - _ => JsonSerializer.SerializeToNode(content, Mesh.JsonSerializerOptions) as JsonObject, - }; - - /// - /// Waits for the editor's write to be OBSERVABLE on the record — the VALUE it wrote, under - /// whatever key it wrote it — and only then parses and asserts. - /// - /// 🚨 The predicate may NOT be DeclaredPolicy is not null, which is what this test - /// asked for first. The seeded record already carries an explicit policy: "None", so that - /// is TRUE of the PRE-WRITE snapshot: the wait returned the state the write had not touched yet - /// and the assertion ran against something it never observed. It passed locally and failed on - /// CI (shard 5, run 13126) — a real ordering race, not a flake, and the same trap - /// documents on its own positive - /// controls. - /// - /// Filtering on the written VALUE is what makes the test decisive, and it turned out to be - /// a SHARPER statement of the defect than intended: before the fix this wait spends its entire - /// budget and times out, because Stable never appears on the record under ANY key. The - /// owning hub materialises the content as UpdatePolicyContent and re-serialises it, so - /// declaredPolicy is dropped on that round trip. The admin's choice is not merely read by - /// nobody — it is never stored. A timeout is the honest verdict for "the write had no effect - /// this test could observe", and it is the same shape (and cost) as the negative arm of - /// . - /// - private Task WaitForWrittenValue(string jsonValue) => - RawContent() - .Where(c => RawJson(c).Contains(jsonValue, StringComparison.Ordinal)) - .Select(c => UpdatePolicyNodeType.ParseContent(c, Mesh.JsonSerializerOptions)) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - /// The stored bytes, not a parse of them — "the write is on the record" is a fact about - /// what is ON the node, and the parser fails closed to None for a record that has no - /// policy at all. - private string RawJson(object? content) => content switch - { - null => string.Empty, - JsonElement je => je.GetRawText(), - _ => JsonSerializer.Serialize(content, Mesh.JsonSerializerOptions), - }; - - private Task WaitForContent(Func settled) => - RawContent() - .Select(c => UpdatePolicyNodeType.ParseContent(c, Mesh.JsonSerializerOptions)) - .Where(settled) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - private IObservable RawContent() => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(n => n is not null) - .Select(n => (object?)n.Content) - .Subscribe(observer); - }); - - private Task Seed(string json, CancellationToken cancellationToken) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = JsonDocument.Parse(json).RootElement.Clone(), - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } -} diff --git a/test/Memex.Portal.Shared.Test/AdminTabsUseStandardControlsTest.cs b/test/Memex.Portal.Shared.Test/AdminTabsUseStandardControlsTest.cs deleted file mode 100644 index b92c4a5264..0000000000 --- a/test/Memex.Portal.Shared.Test/AdminTabsUseStandardControlsTest.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; -using Memex.Portal.Shared.Authentication; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Client; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using static Memex.Portal.Shared.Test.SettingsUseStandardControlsTest; - -namespace Memex.Portal.Shared.Test; - -/// -/// The Admin app's Invitations and Inbox tabs are composed from the platform's controls (maintainer, -/// 2026-10-18: hand-built HTML replaced fleet-wide). Read end to end: the admin-gated areas -/// (, ) -/// rendered on a booted mesh as a platform admin, through the layout client. A frame is judged once -/// it has SETTLED on the tab's content, and the Invite form is driven like a person drives it: a -/// blank submit, then a real one — each outcome must arrive as a -/// with the right intent and text, never as markup. -/// -public class AdminTabsUseStandardControlsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string PlatformAdmin = "admintabs-admin"; - private const string Invitee = "new.teammate@example.com"; - private static TimeSpan Budget => TestTimeouts.Convergence; - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .ConfigureServices(services => services.AddSingleton()) - .ConfigureDefaultNodeHub(config => config.AddPlatformSettingsTabAreas()) - .AddMeshNodes( - new MeshNode(PlatformAdmin) - { - NodeType = UserNodeType.NodeType, - Name = "Admin Tabs Admin", - State = MeshNodeState.Active, - Content = new User { FullName = "Admin Tabs Admin", Email = $"{PlatformAdmin}@meshweaver.io" }, - }, - AssignmentNodeFactory.UserRole(PlatformAdmin, "Admin", PlatformAdmin), - // The platform-admin grant the areas' AdminGated check reads. - AssignmentNodeFactory.UserRole(PlatformAdmin, "Admin", AdminAppNodeType.Path)); - - /// - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) - => base.ConfigureClient(configuration).AddLayoutClient(); - - private ISynchronizationStream Open(string area) - { - TestUsers.DevLogin(Mesh, new AccessContext - { ObjectId = PlatformAdmin, Name = "Admin Tabs Admin", Email = $"{PlatformAdmin}@meshweaver.io" }); - return GetClient().GetWorkspace() - .GetRemoteStream(new Address(PlatformAdmin), new LayoutAreaReference(area)); - } - - private static string? TypeOf(JsonElement control) - => control.TryGetProperty("$type", out var t) ? t.GetString() : null; - - private static string? Text(JsonElement control, string property = "data") - => control.TryGetProperty(property, out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null; - - /// The message bars of a frame, as (intent, body). - private static (string? Intent, string? Body)[] Notices(JsonElement frame) - => [.. Areas(frame).Where(a => TypeOf(a.Control) == nameof(MessageBarControl)) - .Select(a => (Text(a.Control, "intent"), Text(a.Control, "body")))]; - - private static bool HasLabel(JsonElement frame, string text) - => Areas(frame).Any(a => TypeOf(a.Control) == nameof(LabelControl) && Text(a.Control) == text); - - private static bool HasMarkdown(JsonElement frame, string fragment) - => Areas(frame).Any(a => TypeOf(a.Control) == nameof(MarkdownControl) - && (Text(a.Control, "markdown") ?? Text(a.Control) ?? "").Contains(fragment, StringComparison.Ordinal)); - - private static string? ButtonArea(JsonElement frame, string title) - => Areas(frame).FirstOrDefault(a => TypeOf(a.Control) == nameof(ButtonControl) && Text(a.Control) == title).Id; - - [Fact(Timeout = 120000)] - public async Task Invitations_RendersStandardControls_AndTheInviteOutcomesAreMessageBars() - { - var ct = TestContext.Current.CancellationToken; - var stream = Open(PlatformSettingsTabAreas.InvitationsArea); - - var initial = await stream.Select(c => c.Value).Should().Within(Budget) - .Match(f => HasLabel(f, "Invitations") && ButtonArea(f, "Invite") is not null, - "the Invitations tab renders its heading and the Invite form", ct); - - NonStandard(initial).Should().BeEmpty("the Invitations tab holds no hand-built markup"); - HasMarkdown(initial, "`Features:Onboarding:InvitationOnly`").Should() - .BeTrue("the config key is code formatting in Markdown, not a tag"); - Notices(initial).Should().BeEmpty("no outcome is shown before a click"); - - // Blank email → the Error banner. - var invite = ButtonArea(initial, "Invite")!; - stream.SubmitUserAction(new ClickedEvent(invite, stream.StreamId), actingUser: null, onRefused: null, onAccepted: null); - var refused = await stream.Select(c => c.Value).Should().Within(Budget) - .Match(f => Notices(f).Contains((MessageBarIntent.Error, "Enter a valid email address.")), - "a blank invite shows the Error banner", ct); - NonStandard(refused).Should().BeEmpty(); - - // A real address → the Success banner, and the invitation is created. - stream.UpdatePointer(JsonValue.Create(Invitee), - LayoutAreaReference.GetDataPointer("invitationForm"), new JsonPointerReference("email")); - stream.SubmitUserAction(new ClickedEvent(invite, stream.StreamId), actingUser: null, onRefused: null, onAccepted: null); - var invited = await stream.Select(c => c.Value).Should().Within(Budget) - .Match(f => Notices(f).Any(n => n.Intent == MessageBarIntent.Success - && (n.Body ?? "").StartsWith($"Invited {Invitee}", StringComparison.Ordinal)), - "a valid invite shows the Success banner naming the address", ct); - NonStandard(invited).Should().BeEmpty(); - Notices(invited).Should().NotContain(n => n.Intent == MessageBarIntent.Error, - "the Success banner REPLACES the earlier error — one slot, the latest outcome"); - } - - [Fact(Timeout = 120000)] - public async Task Inbox_RendersStandardControls() - { - var frame = await Open(PlatformSettingsTabAreas.InboxArea).Select(c => c.Value).Should().Within(Budget) - .Match(f => HasMarkdown(f, "**not**"), "the Inbox tab renders its explanation", - TestContext.Current.CancellationToken); - - NonStandard(frame).Should().BeEmpty("the Inbox tab holds no hand-built markup"); - } -} diff --git a/test/Memex.Portal.Shared.Test/AdminVerdictFailsClosedTest.cs b/test/Memex.Portal.Shared.Test/AdminVerdictFailsClosedTest.cs deleted file mode 100644 index 36dd2b370e..0000000000 --- a/test/Memex.Portal.Shared.Test/AdminVerdictFailsClosedTest.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Messaging; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 THE PLATFORM-ADMIN VERDICT FAILS CLOSED. -/// -/// The token mint now carries the server's hub.IsGlobalAdmin() answer so a browser -/// client can decide whether to issue the platform notification leg at all — today -/// clients/portal-next omits it, which is why no React viewer has ever seen a platform -/// notification (Systemorph/MeshWeaver.Plugins#1295). -/// -/// The asymmetry is the whole design. An admin who briefly sees no platform -/// notifications is a nuisance; a non-admin who sees them is a disclosure. So every way of NOT -/// knowing — a fault, an empty source — answers false. -/// -/// 🚨 The control arm is not optional here. A reduction that answered false -/// unconditionally would pass every fault case and be catastrophically wrong: no admin would ever -/// get the platform leg. The `true` row is what makes the others mean anything. -/// -public class AdminVerdictFailsClosedTest -{ - /// - /// A fault that reached the observer AFTER the verdict settled. - /// catches into false and completes, so this must stay null on every row — a late fault here - /// would mean the catch arm let something through behind the answer. - /// - private Exception? lateFault; - - /// - /// 🚨 NOT .ToTask(). That bridge resumes the awaiter INLINE on the signalling thread and - /// discards a fault arriving after the task settles — banned repo-wide, and the guard - /// ObservableToTaskBridgeGuard fails the build on a new site. - /// is the sanctioned bridge: it settles on the first notification without blocking, keeps the - /// error arm attached, and hands a late fault to rather than dropping it. - /// - private async Task Resolve(IObservable source) - { - var verdict = await AdminVerdict - .FailClosed(source, "rbuergi", NullLogger.Instance) - .ObserveCompletion(ex => lateFault = ex, TestContext.Current.CancellationToken); - Assert.Null(lateFault); - return verdict; - } - - /// THE CONTROL ARM — a real admin still gets true, or the fail-closed rows prove nothing. - [Fact] - public async Task AnAdmin_IsAnsweredTrue() - => Assert.True(await Resolve(Observable.Return(true))); - - [Fact] - public async Task ANonAdmin_IsAnsweredFalse() - => Assert.False(await Resolve(Observable.Return(false))); - - /// A FAULTED lookup is "we do not know", which must read as not-admin. - [Fact] - public async Task AFaultedLookup_IsAnsweredFalse() - => Assert.False(await Resolve( - Observable.Throw(new InvalidOperationException("permission evaluator unavailable")))); - - /// - /// A source that completes WITHOUT emitting is the same answer as one that faulted — and - /// getting this wrong is worse than a wrong verdict: without the empty guard the mint's - /// SelectMany would drop the response entirely, so the token request would never return. - /// - [Fact] - public async Task AnEmptyLookup_IsAnsweredFalse_RatherThanNeverAnswering() - => Assert.False(await Resolve(Observable.Empty())); - - /// Only the FIRST answer counts — a stream that later flips to true must not - /// retroactively grant the platform view for a token already minted. - [Fact] - public async Task OnlyTheFirstAnswerCounts() - => Assert.False(await Resolve(new[] { false, true }.ToObservable())); - - /// The response record's own default is not-admin: an unset field is never a grant. - [Fact] - public void TheResponseDefaultsToNotAdmin() - => Assert.False(new CreateTokenResponse().IsGlobalAdmin); -} diff --git a/test/Memex.Portal.Shared.Test/AdoptedInventoryReadsDeclarationLessNodeTypesTest.cs b/test/Memex.Portal.Shared.Test/AdoptedInventoryReadsDeclarationLessNodeTypesTest.cs deleted file mode 100644 index 81255b97c1..0000000000 --- a/test/Memex.Portal.Shared.Test/AdoptedInventoryReadsDeclarationLessNodeTypesTest.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Graph.ControlLane; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A statically-registered NodeType that declares no -/// (ControlLaneRecord — every portal registers it) is code in the image: it adopts no -/// framework, and it must not make the adopted-artifact inventory INCOMPLETE. On 2026-10-06 it did, -/// on memex, memex-cloud and control alike: every report carried -/// AdoptedFrameworkInventoryComplete=false after a Warning naming -/// NodeType adoption record ControlLaneRecord could not be read, so the control instance's -/// retention refused every report. Content that is present but unreadable stays the loud case. -/// -/// The xUnit output sink. -public class AdoptedInventoryReadsDeclarationLessNodeTypesTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private static readonly TimeSpan Budget = TimeSpan.FromSeconds(60); - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddPluginCatalog() - // The production registration that carries the declaration-less static NodeType. - .AddControlLane() - .AddMeshNodes(new MeshNode(DeploymentReportService.InventoryNodeType) - { - Name = "Module Inventory", - IsSatelliteType = false, - ExcludeFromContext = new HashSet { "search", "create", "content" }, - }) - .ConfigureServices(services => services - .AddSingleton(new ConfigurationBuilder().AddInMemoryCollection( - new Dictionary { [DeploymentReportService.DeploymentKey] = "unit-dep" }).Build()) - .AddSingleton(new PluginCatalogOptions - { - InstallPreInstalledPackages = false, - InstanceId = "instance-1", - HomeUrl = "https://unit.example", - })); - - /// - /// The production registration of the content-less ControlLaneRecord NodeType leaves the - /// deployment report's adopted-framework inventory complete, with no "incomplete" warning. - /// - [Fact(Timeout = 180_000)] - public async Task ADeclarationLessStaticNodeType_LeavesTheAdoptedInventoryComplete() - { - var outcome = await Mesh.ServiceProvider.GetRequiredService() - .Report().Timeout(Budget).Await(TestContext.Current.CancellationToken); - - var report = outcome.Report ?? throw new InvalidOperationException( - $"the report carried no DeploymentReport: {outcome.Delivery} - {outcome.Detail}"); - report.AdoptedFrameworkInventoryComplete.Should().Be(true, - $"'{ControlLaneExtensions.RecordNodeType}' is a static NodeType with no definition — it adopts " - + "nothing, and reading it as 'unreadable' made every instance's inventory incomplete"); - report.Warnings.Should().NotContain(w => w.Contains("adopted artifact inventory is incomplete")); - } - - /// - /// keeps its three states apart: - /// no content is null, a readable definition is its stamp, and unreadable content throws with - /// the established could not be read fingerprint. - /// - [Fact] - public void TheStamp_TellsNoContentFromAStampFromUnreadableContent() - { - var options = Mesh.JsonSerializerOptions; - - NodeTypeAdoptionStamp.CompiledFrameworkVersionOf( - new MeshNode(ControlLaneExtensions.RecordNodeType) { NodeType = MeshNode.NodeTypePath }, options) - .Should().BeNull("no content declares nothing — it is an answer, not an unreadable record"); - - NodeTypeAdoptionStamp.CompiledFrameworkVersionOf( - new MeshNode("Stamped") { NodeType = MeshNode.NodeTypePath, Content = new NodeTypeDefinition { CompiledFrameworkVersion = "fw-1" } }, - options) - .Should().Be("fw-1"); - - var unreadable = new MeshNode("Unreadable") - { - NodeType = MeshNode.NodeTypePath, - // A foreign CLR value: ContentAs answers null for it exactly as it does for no content, - // which is the fold the readers made. - Content = "not a NodeTypeDefinition", - }; - Action read = () => NodeTypeAdoptionStamp.CompiledFrameworkVersionOf(unreadable, options); - read.Should().Throw( - "present-but-unreadable content means the stamp is UNKNOWN — a reference set that drops it " - + "lets retention delete a bundle someone adopted") - .WithMessage("NodeType adoption record Unreadable could not be read*String*"); - } - - /// - /// The retention side, at the pass level: an unreadable adoption record makes the reference - /// fold THROW (it is never dropped), that error ends the pass before the sweep is invoked — so - /// nothing is deleted against an incomplete reference set — and the pass guard records the - /// fault and completes, so the schedule survives to plan again on the next tick. - /// - [Fact] - public void AnUnreadableRecord_AbortsTheRetentionPass_BeforeTheSweep_AndTheScheduleSurvives() - { - var options = Mesh.JsonSerializerOptions; - var records = new[] - { - new MeshNode(ControlLaneExtensions.RecordNodeType) { NodeType = MeshNode.NodeTypePath }, - new MeshNode("Stamped") { NodeType = MeshNode.NodeTypePath, Content = new NodeTypeDefinition { CompiledFrameworkVersion = "fw-1" } }, - }; - PrebuiltBundleRetentionHostedService.StampedIdentitiesOf(records, options, null) - .Should().ContainSingle("a declaration-less record contributes nothing, a stamped one its stamp") - .Which.Should().Be("fw-1"); - Action empty = () => PrebuiltBundleRetentionHostedService.StampedIdentitiesOf([], options, null); - empty.Should().Throw( - "a store holding bundles and a mesh answering zero NodeType records contradict each other — " - + "an empty read is incomplete, and sweeping against it would make every unpinned bundle deletable") - .WithMessage("the mesh-wide NodeType read returned no records*"); - - var withUnreadable = records.Append(new MeshNode("Unreadable") - { - NodeType = MeshNode.NodeTypePath, - Content = "not a NodeTypeDefinition", - }).ToArray(); - var stamps = Observable.Defer(() => Observable.Return( - PrebuiltBundleRetentionHostedService.StampedIdentitiesOf(withUnreadable, options, null))); - - var swept = 0; - var recorded = 0; - Exception? faulted = null; - var passes = 0; - Exception? scheduleDied = null; - PrebuiltBundleRetentionHostedService.GuardedPass( - PrebuiltBundleRetentionHostedService.PassOver( - stamps, - Observable.Return(System.Collections.Immutable.ImmutableList.Empty), - (_, _) => - { - swept++; - return Observable.Return(1); - }), - _ => recorded++, - ex => faulted = ex, - ex => faulted = ex) - .Subscribe(_ => passes++, ex => scheduleDied = ex); - - swept.Should().Be(0, "a reference set that could not be read must never reach the sweep"); - recorded.Should().Be(0); - faulted.Should().BeOfType() - .Which.Message.Should().StartWith("NodeType adoption record Unreadable could not be read"); - passes.Should().Be(1, "the faulted pass completes as one pass, so the schedule's next tick runs"); - scheduleDied.Should().BeNull("a pass's fault is bounded to that pass, never the schedule"); - } -} diff --git a/test/Memex.Portal.Shared.Test/AnInstallHoldsItsRootTest.cs b/test/Memex.Portal.Shared.Test/AnInstallHoldsItsRootTest.cs deleted file mode 100644 index f3f81a4acb..0000000000 --- a/test/Memex.Portal.Shared.Test/AnInstallHoldsItsRootTest.cs +++ /dev/null @@ -1,165 +0,0 @@ -#pragma warning disable CS1591 - -using System.Reactive.Linq; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 #3510 — an install HOLDS its package root for as long as it runs, and releases it on every -/// way that run can end. -/// -/// The defect. The Hosting root hub was disposed while Hosting's own -/// 145-file install was in flight. Its per-node children went with it, the writes those children -/// owed acks for were stranded (ADVANCE_WITHOUT_HANDOFF … the owner never acknowledged this -/// write), the nodeops handler that owed its reply to one of those acks never replied, -/// and the install ran out the gate's ten-minute bound. Six occurrences, four lost bake seals. -/// The install is the writer that should own the root's lifetime. -/// -/// The deferral itself — a recycle waiting on that lease, and an ordinary recycle proceeding -/// at once — is pinned in MeshWeaver.Graph.Test. What THIS file pins is the half that would -/// otherwise be a guard checking nothing: that the installer actually TAKES the lease, and that it -/// gives it back. A registry nobody holds is a deferral that never defers; a lease nobody releases -/// is strictly worse than the bug. -/// -/// 🚨 It lives here rather than beside the installer for the same reason -/// RetypedRootRecycleNeedsAJobTest does: this is the only test project in THIS repository -/// that both references MeshWeaver.PluginCatalog and appears in its -/// InternalsVisibleTo list. -/// -public class AnInstallHoldsItsRootTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Package = "LeaseHeldPkg"; - - /// - /// The catalog's own registration — without it the install's LAST step - /// (WriteInstalledRecord) is refused with NodeType 'Package' is not registered, - /// so the run would fault for a reason that has nothing to do with the lease. - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - private static PackageManifest Manifest(string id) => new() - { - Id = id, - Name = id, - Kind = PackageKind.Content, - TargetPartition = id, - SourceFolder = id, - Version = "1.0.0", - }; - - /// - /// 🚨 THE WIRING PIN, in both tenses. The hold is observed on the install's own emission — the - /// lease is taken by Observable.Using at subscribe and released on the terminal, so an - /// OnNext is the one moment at which "the install is running" is an observable fact - /// rather than a timing guess. Remove HoldRootDuringInstall from - /// PackageInstaller.Install and the middle assertion goes red. - /// - [Fact] - public async Task TheRootIsHeldWhileTheInstallRuns_AndReleasedWhenItFinishes() - { - var leases = Mesh.ServiceProvider.GetRequiredService(); - - leases.IsHeld(Package).Should().BeFalse("nothing has started yet"); - - var heldDuringInstall = false; - var holderDuringInstall = (string?)null; - var install = PackageInstaller - .Install( - Mesh, - Manifest(Package), - [new PackageFile("Readme.md", $"# {Package}")], - "HEAD") - .Do(_ => - { - // Composed OUTSIDE the observable Install returns, so this runs while - // Observable.Using's resource is still alive — the terminal that disposes it has - // not been delivered yet. - heldDuringInstall = leases.IsHeld(Package); - holderDuringInstall = leases.HeldBy(Package); - }); - - var result = await install.Should().Within(TestTimeouts.CrossSilo) - .Emit("the install must complete before anything about the lease can be read"); - result.Written.Should().BeGreaterThan(0, "the install wrote its one node"); - - heldDuringInstall.Should().BeTrue( - "the install holds its own package root for as long as it runs — that is the whole " - + "point of #3510's remedy, and nothing else can hold it on its behalf"); - holderDuringInstall.Should().Be( - PackageInstaller.InstallLeaseHolder(Manifest(Package)), - "a deferred recycle prints the holder, so the holder must be NAMED — an anonymous " - + "lease reads to the next person as no lease at all"); - - leases.IsHeld(Package).Should().BeFalse( - "the install has completed, so the root is released and any recycle waiting on it runs"); - } - - /// - /// 🚨 The lease must name the root the install actually WRITES under, and the three package - /// kinds do not agree on how that is derived. InstallCode falls back to the shared - /// type partition when the manifest declares no target, while the install RECORD's rule - /// (TargetPartitionOf) falls back to the package id. A lease keyed on the record's rule - /// would hold <id> for a blank-target Code package whose writes land in - /// type/<id> — a lease on a root nothing is writing to, which reads in the log and - /// in every other test exactly like a lease that is working, while the root that IS being - /// written stays freely recyclable. Pure, so the rule is pinned without a mesh. - /// - [Fact] - public void TheLeaseNamesTheRootTheInstallWritesUnder_ForEveryKind() - { - // A declared target always wins, whatever the kind. - PackageInstaller.InstallRootOf(Manifest(Package)).Should().Be(Package); - PackageInstaller.InstallRootOf(Manifest(Package) with { Kind = PackageKind.Code }) - .Should().Be(Package); - - // 🚨 THE REGRESSION CASE: no declared target, Code kind — the install writes under `type`. - PackageInstaller.InstallRootOf( - Manifest(Package) with { Kind = PackageKind.Code, TargetPartition = null }) - .Should().Be(PackageInstaller.CodeDefaultPartition, - "InstallCode writes its NodeType into the shared `type` partition when the manifest " - + "declares no target, so that is the root whose teardown would strand its writes"); - - // …and the control: for the other kinds a blank target still means the package's own id, - // so a change that pointed every lease at `type` would fail here. - PackageInstaller.InstallRootOf( - Manifest(Package) with { Kind = PackageKind.NodeRepo, TargetPartition = null }) - .Should().Be(Package); - PackageInstaller.InstallRootOf( - Manifest(Package) with { Kind = PackageKind.Content, TargetPartition = null }) - .Should().Be(Package); - } - - /// - /// 🚨 THE CONTROL THAT MATTERS MOST. "Defer" is only allowed to mean wait for a state that - /// always arrives. An install that FAILS is exactly when a recycle behind it must not be - /// stranded — and a lease held by a dead install would be a permanently un-recyclable root, - /// which is worse than the bug this fixes. The failure is genuine (a package with no - /// installable content file), not simulated. - /// - [Fact] - public async Task AnInstallThatFAILS_StillReleasesItsRoot() - { - var leases = Mesh.ServiceProvider.GetRequiredService(); - var failing = $"{Package}Failing"; - - var faults = PackageInstaller - .Install(Mesh, Manifest(failing), [], "HEAD") - .Materialize() - .Where(n => n.Kind == System.Reactive.NotificationKind.OnError); - - await faults.Should().Within(TestTimeouts.CrossSilo) - .Emit("a package with no installable content file is refused"); - - leases.IsHeld(failing).Should().BeFalse( - "an install that faulted has released its root — otherwise every recycle deferred " - + "behind it waits for ever, and there is deliberately no timer to force it"); - } -} diff --git a/test/Memex.Portal.Shared.Test/AnInstalledPackageDeclaresItsActionHandlerRuleNamespaceTest.cs b/test/Memex.Portal.Shared.Test/AnInstalledPackageDeclaresItsActionHandlerRuleNamespaceTest.cs deleted file mode 100644 index be0800dc78..0000000000 --- a/test/Memex.Portal.Shared.Test/AnInstalledPackageDeclaresItsActionHandlerRuleNamespaceTest.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.ActionLinks; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// MeshWeaver#4692, shape 3 — action-handler rules count only from DECLARED namespaces, and an -/// installed package's own partition is declared by its install record, read LIVE. -/// -/// What is observed. A real monolith mesh with the plugin catalog. A rule is written -/// into a package partition BEFORE the package is installed: it is refused as -/// and named in the unclaimed detail. Then the -/// install record (Plugins/{id}, the installer's own declaration of the partition) is -/// written while the portal runs, and the SAME rule resolves on the next chain emission — no -/// recycle, no restart. -/// -public class AnInstalledPackageDeclaresItsActionHandlerRuleNamespaceTest(ITestOutputHelper output) - : MonolithMeshTestBase(output) -{ - /// A partition-root NodeType standing in for an installed package's root. - private const string PluginLikeNodeType = "TestStore/InstalledPlugin"; - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .AddMeshNodes(new MeshNode(PluginLikeNodeType) - { - Name = "Installed plugin root", - NodeType = MeshNode.NodeTypePath, - State = MeshNodeState.Active, - Content = new NodeTypeDefinition { DefaultNamespace = "", RestrictedToNamespaces = [""] }, - }); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - private async Task CreateAsSystem(MeshNode node) - { - var response = await Access - .RunAsSystem(() => ObserveNodeOperation(new CreateNodeRequest(node))) - .FirstAsync() - .Select(d => d.Message) - .Timeout(TestTimeouts.Convergence) - .Await(); - response.Success.Should().BeTrue($"the fixture write '{node.Path}' must land: {response.Error}"); - } - - [Fact] - public async Task ANamespaceDeclaredAfterStart_ByInstallingItsPackage_CountsLive() - { - var packageId = "rulepkg" + Guid.NewGuid().ToString("N")[..8]; - var key = "test.action-" + Guid.NewGuid().ToString("N")[..8]; - var rulePath = $"{packageId}/ActionHandlers/coupon"; - - await CreateAsSystem(new MeshNode(packageId) - { - Name = packageId, NodeType = PluginLikeNodeType, State = MeshNodeState.Active, - }); - await CreateAsSystem(new MeshNode("coupon", $"{packageId}/ActionHandlers") - { - Name = "coupon rule", - NodeType = ActionHandlerRuleNodeType.NodeType, - State = MeshNodeState.Active, - Content = new ActionHandlerRule { Id = "coupon", Claims = [key], Handler = $"{packageId}/Coupons/Review" }, - }); - - // Before the install: the rule is in the snapshot and refused by name. - await Mesh.ActionHandlerChain() - .Where(chain => chain.Rejected.Contains( - new RejectedActionHandlerRule(rulePath, ActionHandlerRules.UndeclaredNamespace))) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - var before = await Mesh.ResolveActionHandler(new ActionInvocation { HandlerKey = key, HandlerVersion = 1 }) - .Timeout(TestTimeouts.Convergence).Await(); - before.Outcome.Status.Should().Be(ActionOutcomeStatus.UnknownHandler, - "a partition nobody declared does not count, however the node got there"); - before.Outcome.Detail.Should().Contain($"{rulePath} ({ActionHandlerRules.UndeclaredNamespace})"); - - // The install record IS the declaration — written while the portal runs. - await CreateAsSystem(MeshNode.FromPath($"{PackageInstaller.InstalledPartition}/{packageId}") with - { - NodeType = PackageInstaller.PackageNodeType, - Name = packageId, - State = MeshNodeState.Active, - Content = new PackageManifest { Id = packageId, Name = packageId, Version = "1.0.0", TargetPartition = packageId }, - }); - - var chain = await Mesh.ActionHandlerChain() - .Where(c => c.Handlers.Any(h => h.Capability.HandlerKey == key)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - chain.DeclaredNamespaces.Should().Contain(packageId); - chain.Rejected.Should().NotContain(r => r.Path == rulePath); - - var after = await Mesh.ResolveActionHandler(new ActionInvocation { HandlerKey = key, HandlerVersion = 1 }) - .Timeout(TestTimeouts.Convergence).Await(); - after.Outcome.Status.Should().Be(ActionOutcomeStatus.Ready, - "a newly installed package's rules count on the next resolution — no recycle"); - } -} diff --git a/test/Memex.Portal.Shared.Test/AnInstanceReportsWhatItRunsTest.cs b/test/Memex.Portal.Shared.Test/AnInstanceReportsWhatItRunsTest.cs deleted file mode 100644 index e6c75e793a..0000000000 --- a/test/Memex.Portal.Shared.Test/AnInstanceReportsWhatItRunsTest.cs +++ /dev/null @@ -1,322 +0,0 @@ -#pragma warning disable CS1591 -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Memex.Portal.Shared.SelfUpdate; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The fleet inventory is fed by the instances themselves: a named instance composes the report the -/// control instance's inbox understands — platform build, commit, framework identity, update policy -/// and one row per module in BOTH recorded shapes — and, being its own control instance here (no -/// Hosting:ReportTo), lands it where a reported one would land, with the discriminator stamped. -/// -public class AnInstanceReportsWhatItRunsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Deployment = "unit-dep"; - private static readonly TimeSpan Budget = TimeSpan.FromSeconds(60); - private const string Sha = "0123456789abcdef0123456789abcdef01234567"; - - private static IConfiguration Environment() => - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [DeploymentReportService.DeploymentKey] = Deployment, - }).Build(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddPluginCatalog() - .AddGitHubSyncTypes() - .AddUpdatePolicyType() - // The control instance carries the Hosting package, whose ModuleInventory type owns the - // record a local write lands in; this mesh stands in for that one declaration. - .AddMeshNodes(new MeshNode(DeploymentReportService.InventoryNodeType) - { - Name = "Module Inventory", - IsSatelliteType = false, - ExcludeFromContext = new HashSet { "search", "create", "content" }, - }) - .ConfigureServices(services => services - .AddSingleton(Environment()) - .AddSingleton(new PluginCatalogOptions - { - InstallPreInstalledPackages = false, - InstanceId = "instance-1", - HomeUrl = "https://unit.example", - })); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - private IMeshService MeshService => Mesh.ServiceProvider.GetRequiredService(); - private DeploymentReportService Reporter => Mesh.ServiceProvider.GetRequiredService(); - - [Fact(Timeout = 180_000)] - public async Task TheReport_CarriesBothModuleShapesAndThePlatform_AndLandsWhereAReportedOneWould() - { - await Seed(new MeshNode(GitHubSyncService.ConfigId, "Course") - { - NodeType = GitHubSyncService.ConfigNodeType, - Name = "Course sync", - State = MeshNodeState.Active, - Content = new GitHubSyncConfig - { - RepositoryUrl = "https://github.com/Systemorph/MeshWeaver.Education", - Branch = "main", - Subdirectory = "Course", - LastSyncCommitSha = Sha, - LastSyncedAt = new DateTimeOffset(2026, 9, 6, 8, 0, 0, TimeSpan.Zero), - }, - }); - await Seed(new MeshNode("Sample", PackageInstaller.InstalledPartition) - { - NodeType = PackageInstaller.PackageNodeType, - Name = "Sample", - State = MeshNodeState.Active, - Content = new PackageManifest { Id = "Sample", Name = "Sample", ModuleVersion = "1.2.3" }, - }); - await Seed(new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = UpdatePolicyKind.Stable }, - }); - - var outcome = await Reporter.Report().Timeout(Budget).Await(TestContext.Current.CancellationToken); - - outcome.Delivery.Should().Be(DeploymentReportDelivery.Written, - "without Hosting:ReportTo this instance IS the control instance and files the record itself"); - var report = outcome.Report!; - report.Event.Should().Be(DeploymentReportService.EventName); - report.Deployment.Should().Be(Deployment); - report.InstanceId.Should().Be("instance-1"); - report.Host.Should().Be("https://unit.example"); - report.PlatformVersion.Should().NotBeNullOrWhiteSpace("the standard platform version, never a bespoke key"); - report.FrameworkIdentity.Should().NotBeNullOrWhiteSpace("bundles are keyed on it, so the fleet must see it"); - report.AdoptedFrameworkInventoryComplete.Should().Be(true); - report.AdoptedFrameworkIdentities.Should().NotBeNull(); - report.UpdatePolicy.Should().Be(nameof(UpdatePolicyKind.Stable)); - report.SampledAt.Should().EndWith("Z"); - - report.Modules.Select(m => m.Id).Should().Equal(new[] { "Course", "Sample" }, - "BOTH shapes, always — an inventory read off one of them looks healthy and reports almost nothing"); - var course = report.Modules.Single(m => m.Id == "Course"); - course.Origin.Should().Be("GitSync"); - course.Repository.Should().Be("https://github.com/Systemorph/MeshWeaver.Education"); - course.Ref.Should().Be("main"); - course.Subdirectory.Should().Be("Course"); - course.CommitSha.Should().Be(Sha); - course.LastSyncedAt.Should().Be("2026-09-06T08:00:00Z"); - var sample = report.Modules.Single(m => m.Id == "Sample"); - sample.Origin.Should().Be("Package"); - sample.ModuleVersion.Should().Be("1.2.3", "a package-only module reports the installer's version, never an unknown build"); - - // The landed node: the same partition a reported one is filed under, and its content - // MATERIALISES — which is the assertion this test was always reaching for. - var node = await Read($"{DeploymentReportService.DefaultOperationalSpace}/Modules/{Deployment}"); - node.Should().NotBeNull(); - node!.NodeType.Should().Be(DeploymentReportService.InventoryNodeType); - - // 🚨 READ IT BACK AS THE RECORD, not as raw JSON (#3625). This assertion used to be - // `content.GetProperty("$type") == InventoryContentType` — a check that the discriminator - // STRING was stamped, with the reason given as "content without the discriminator is stored - // perfectly and materialises as NOTHING". The string was stamped and the content - // materialised as nothing anyway: `"ModuleInventoryContent"` named no CLR type in the - // fleet, so the reading hub could not resolve it and the value degraded back to a raw - // JsonElement. The old assertion passed BECAUSE of the defect — raw JSON is the only shape - // in which a `$type` property is there to be read — which is why it never noticed. Asserting - // the materialised record instead cannot pass while the type is unregistered. - // 🚨 The RUNTIME TYPE, not ContentAs. ContentAs is the bad-data-TOLERANT accessor: handed - // a raw JsonElement it deserialises it anyway, so it answers a DeploymentReport whether or - // not the discriminator resolved — it cannot fail here, and an assertion that cannot fail is - // not an assertion. What the defect actually breaks is MATERIALISATION at the read seam: - // `node.Content is DeploymentReport`, which is what every ordinary reader does and what the - // stream cache's degradation warning is about. - node.Content.Should().BeOfType( - "the inventory node's content must MATERIALISE — an unresolvable discriminator leaves it " - + "a raw JsonElement, and every reader doing `Content is DeploymentReport` sees nothing"); - var landed = (DeploymentReport)node.Content!; - landed.Deployment.Should().Be(Deployment); - landed.Modules.Count.Should().Be(2); - } - - /// - /// The shape this test's first CI run failed on: the service's own boot report read one seeded - /// module, an explicit report read two, and the boot report's write landed LAST. Every report - /// now goes through one channel in request order, so the later request — which read the later - /// state — is what the record says. Under overlapping reports this assertion can fail; under - /// the channel it cannot. - /// - [Fact(Timeout = 180_000)] - public async Task TwoOverlappingRequests_LandInRequestOrder_SoTheRecordSaysWhatTheInstanceCarriesNow() - { - await Seed(new MeshNode(GitHubSyncService.ConfigId, "Course") - { - NodeType = GitHubSyncService.ConfigNodeType, - Name = "Course sync", - State = MeshNodeState.Active, - Content = new GitHubSyncConfig { RepositoryUrl = "https://github.com/Systemorph/MeshWeaver.Education", Branch = "main" }, - }); - // Request A is enqueued NOW, before the second seed; it may read one module or two. - var first = Reporter.Report().Timeout(Budget).Await(TestContext.Current.CancellationToken); - await Seed(new MeshNode("Sample", PackageInstaller.InstalledPartition) - { - NodeType = PackageInstaller.PackageNodeType, - Name = "Sample", - State = MeshNodeState.Active, - Content = new PackageManifest { Id = "Sample", Name = "Sample", ModuleVersion = "1.2.3" }, - }); - // Request B is enqueued after both seeds, so it must read both and its write must land last. - var second = await Reporter.Report().Timeout(Budget).Await(TestContext.Current.CancellationToken); - var earlier = await first; - - earlier.Delivery.Should().Be(DeploymentReportDelivery.Written); - second.Delivery.Should().Be(DeploymentReportDelivery.Written); - second.Report!.Modules.Select(m => m.Id).Should().Equal(new[] { "Course", "Sample" }); - - var node = await Read($"{DeploymentReportService.DefaultOperationalSpace}/Modules/{Deployment}"); - Element(node!.Content!).GetProperty("modules").GetArrayLength().Should().Be(2, - "the record is the LATER request's write — an earlier read landing last would say what the instance carried a moment ago"); - } - - [Fact(Timeout = 180_000)] - public async Task TheReport_PreservesAnOlderAdoptedBuild_AlongsideItsRunningPlatform() - { - const string adopted = "older-adopted-framework"; - await Seed(new MeshNode("OldModuleType", "Sample") - { - NodeType = MeshNode.NodeTypePath, - Name = "Old module type", - Content = new NodeTypeDefinition { CompiledFrameworkVersion = adopted }, - }); - - var outcome = await Reporter.Report().Timeout(Budget).Await(TestContext.Current.CancellationToken); - - outcome.Delivery.Should().Be(DeploymentReportDelivery.Written); - outcome.Report!.FrameworkIdentity.Should().NotBe(adopted); - outcome.Report.AdoptedFrameworkInventoryComplete.Should().Be(true); - outcome.Report.AdoptedFrameworkIdentities!.Should().Contain(adopted, - "the running platform must not replace the identity of an older build a module still uses"); - var node = await Read($"{DeploymentReportService.DefaultOperationalSpace}/Modules/{Deployment}"); - var landed = Assert.IsType(node!.Content); - landed.AdoptedFrameworkIdentities!.Should().Contain(adopted); - landed.AdoptedFrameworkInventoryComplete.Should().Be(true); - } - - /// - /// #6989 — a STORE files the report a consumer delivered under the instance id the store - /// AUTHENTICATED, whatever the body says, and its retention then finds it for the record that - /// mounts the store. - /// - [Fact(Timeout = 60_000)] - public async Task AStoreFilesAConsumersReport_UnderTheAuthenticatedInstance_NotTheOneTheBodyClaims() - { - var delivered = new DeploymentReport - { - Deployment = "someone-else", - RegisteredInstance = "someone-else", - PlatformVersion = "3.1.100", - FrameworkIdentity = "s-consumer", - AdoptedFrameworkIdentities = ["s-consumer-older"], - AdoptedFrameworkInventoryComplete = true, - SampledAt = DeploymentReportService.Stamp(DateTimeOffset.UtcNow), - }; - - var path = await DeploymentReportService.FileStoreReport(Mesh, "consumer", delivered) - .Timeout(Budget).Await(TestContext.Current.CancellationToken); - - path.Should().Be($"{DeploymentReportService.DefaultOperationalSpace}/{DeploymentReportService.StoreReportsSegment}/consumer"); - var node = await Read(path); - var filed = Assert.IsType(node!.Content); - filed.RegisteredInstance.Should().Be("consumer", "the store stamps the identity it authenticated"); - filed.Deployment.Should().Be("someone-else", "the body is kept as sent; it is not the key"); - - var record = new MeshNode("consumer", "Deployments") - { - NodeType = DeploymentPinnedReferences.DeploymentNodeType, - Content = System.Text.Json.Nodes.JsonNode.Parse( - """{ "host": "consumer.example", "pluginRepos": [ { "url": "https://store.example" } ] }"""), - }; - DeploymentPinnedReferences.Resolve([record], [node!], Mesh.JsonSerializerOptions, DateTimeOffset.UtcNow, - DeploymentPinnedReferences.StaleAfter, new ConsumerScope("store", ["store.example"], false)) - .Select(r => r.Identity).Should().Contain(["s-consumer", "s-consumer-older"]); - } - - [Fact] - public void TheSignature_IsGitHubsWebhookShape() - { - // RFC 4231-style vector for HMAC-SHA256("key", "The quick brown fox jumps over the lazy dog"). - DeploymentReportService.Sign("The quick brown fox jumps over the lazy dog", "key") - .Should().Be("sha256=f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"); - } - - [Fact] - public void TheReporter_ReadsThePolicyOffTheNodeMemexDeclares() - { - // MeshWeaver.PluginCatalog cannot reference Memex.Portal.Shared, so it carries the node's - // type and partition as constants; this is what keeps them from drifting apart. - DeploymentReportService.UpdatePolicyNodeType.Should().Be(UpdatePolicyNodeType.NodeType); - DeploymentReportService.UpdatePolicyPartition.Should().Be(UpdatePolicyNodeType.AdminPartition); - } - - private Task Seed(MeshNode node) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return MeshService.CreateOrUpdateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - private Task Read(string path) => - Mesh.ServiceProvider.GetRequiredService() - .Read(path, Mesh.JsonSerializerOptions) - .Take(1).Timeout(Budget).Await(TestContext.Current.CancellationToken); - - private JsonElement Element(object content) => - content is JsonElement je - ? je - : JsonSerializer.SerializeToElement(content, content.GetType(), Mesh.JsonSerializerOptions); -} - -/// -/// An instance nobody named reports nothing: a report filed under a guessed deployment id would -/// overwrite another instance's inventory, and the fleet page would render that as fact. -/// -public class AnUnnamedInstanceReportsNothingTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new ConfigurationBuilder().Build()) - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false })); - - [Fact(Timeout = 60_000)] - public async Task WithoutADeploymentId_TheReportIsSkipped_AndSaysWhichKeyNamesTheInstance() - { - var outcome = await Mesh.ServiceProvider.GetRequiredService() - .Report().Timeout(TimeSpan.FromSeconds(30)).Await(TestContext.Current.CancellationToken); - - outcome.Delivery.Should().Be(DeploymentReportDelivery.Skipped); - outcome.Detail.Should().Contain(DeploymentReportService.DeploymentKey); - outcome.Report.Should().BeNull("nothing was composed, so nothing could have been written or sent"); - } -} diff --git a/test/Memex.Portal.Shared.Test/ApiTokenBasicAuthTest.cs b/test/Memex.Portal.Shared.Test/ApiTokenBasicAuthTest.cs deleted file mode 100644 index 786cc40862..0000000000 --- a/test/Memex.Portal.Shared.Test/ApiTokenBasicAuthTest.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Text; -using Memex.Portal.Shared.Authentication; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins how an API token is read out of an Authorization header. -/// -/// Why Basic is accepted at all. A NuGet client cannot send Bearer — -/// nuget.config's packageSourceCredentials speaks Basic, and the only alternative is -/// shipping a credential-provider plugin. Accepting both lets dotnet restore read the -/// access-controlled /api/content route with the SAME personal token every other API caller -/// uses, so the route's per-node Read check remains the authorization gate rather than a second -/// scheme growing its own rules. -/// -/// This runs on an UNAUTHENTICATED request path, so the parse must never throw: a malformed -/// header is an anonymous caller, not a 500 anyone can trigger. -/// -public class ApiTokenBasicAuthTest -{ - private static string Basic(string user, string password) => - "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{password}")); - - [Theory] - [InlineData("Bearer mw_abc", "mw_abc")] - [InlineData("bearer mw_abc", "mw_abc")] // scheme is case-insensitive - [InlineData(" Bearer mw_abc ", "mw_abc")] // stray whitespace tolerated - public void BearerStillWorks(string header, string expected) - => Assert.Equal(expected, ApiTokenAuthenticationHandler.ExtractToken(header)); - - [Theory] - [InlineData("nuget")] - [InlineData("anything")] - [InlineData("")] - public void BasicYieldsThePasswordHalf(string username) - // The username is ignored — the token is the whole secret — but NuGet requires one to be - // present, so any value must work. - => Assert.Equal("mw_abc", ApiTokenAuthenticationHandler.ExtractToken(Basic(username, "mw_abc"))); - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("mw_abc")] // no scheme - [InlineData("Bearer ")] - [InlineData("Basic ")] - [InlineData("Basic !!!not-base64!!!")] - [InlineData("Negotiate abc")] // unrelated scheme - public void MalformedHeadersYieldNoTokenAndDoNotThrow(string? header) - => Assert.Null(ApiTokenAuthenticationHandler.ExtractToken(header)); - - [Fact] - public void BasicWithoutAColonYieldsNoToken() - // No colon means no password half; reading the whole blob would accept a bare username. - => Assert.Null(ApiTokenAuthenticationHandler.ExtractToken( - "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("mw_abc")))); - - [Fact] - public void OverlongBasicPayloadYieldsNoToken() - // Unauthenticated, attacker-controlled input: the reject path must not decode an unbounded - // blob, and must not throw doing it. - => Assert.Null(ApiTokenAuthenticationHandler.ExtractToken("Basic " + new string('A', 5000))); - - [Fact] - public void ATokenContainingAColonSurvives() - { - // Only the FIRST colon separates user from password, per RFC 7617 — a token that itself - // contains one must come back whole, or such tokens would silently fail to authenticate. - var header = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:mw_a:b:c")); - - Assert.Equal("mw_a:b:c", ApiTokenAuthenticationHandler.ExtractToken(header)); - } -} diff --git a/test/Memex.Portal.Shared.Test/AreaRouteIdWithColonTest.cs b/test/Memex.Portal.Shared.Test/AreaRouteIdWithColonTest.cs deleted file mode 100644 index e79067c624..0000000000 --- a/test/Memex.Portal.Shared.Test/AreaRouteIdWithColonTest.cs +++ /dev/null @@ -1,91 +0,0 @@ -#pragma warning disable CS1591 - -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #6815: the MCP area route {node}/area/{Name}/{Id} must carry an id that contains a colon. -/// Reference ids are free text — the OgCard gallery embeds url=https://github.com/… — and -/// MeshOperations.TryParseAreaRoute used to refuse ANY path with a ':' (to leave the -/// legacy {path}/area:Name form to the unified resolver), so the read fell through to that -/// resolver, which split at the colon and answered "No node found at '{node}/area/{Name}'". -/// The route now wins whenever its area segment lies wholly before the first colon; the -/// legacy colon form still resolves through the unified path. -/// -public class AreaRouteIdWithColonTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Node = "AreaRouteColonProbe"; - private const string Area = "Echo"; - private const string ColonId = "url=https://github.com/Systemorph/MeshWeaver"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddMeshNodes(new MeshNode(Node) { Name = "Probe" }) - .ConfigureDefaultNodeHub(config => config.AddLayout(layout => layout - .WithView(Area, (LayoutAreaHost host, RenderingContext _) => - Controls.Markdown($"ECHO[{host.Reference.Id}]")))); - - [Fact] - public void Parse_IdWithColon_KeepsTheAreaRoute() - { - MeshOperations.TryParseAreaRoute($"{Node}/area/{Area}/{ColonId}", out var nodePath, out var area, out var id) - .Should().BeTrue("the area segment precedes the first colon, so the colon belongs to the id"); - nodePath.Should().Be(Node); - area.Should().Be(Area); - id.Should().Be(ColonId); - } - - [Fact] - public void Parse_InteriorAreaSegment_WithColonId_SplitsAtTheDeepestMarker() - { - MeshOperations.TryParseAreaRoute($"Root/area/Child/area/{Area}/{ColonId}", out var nodePath, out var area, out var id) - .Should().BeTrue("a colon in a free-text id does not turn the route into the legacy form"); - nodePath.Should().Be("Root/area/Child"); - area.Should().Be(Area); - id.Should().Be(ColonId); - } - - [Theory] - [InlineData(Node + "/area:" + Area)] - [InlineData(Node + "/area:" + Area + "/legacy-id")] - [InlineData(Node + "/area:content:logo.svg")] - [InlineData(Node + "/data:Collection/area/x")] - // An INTERIOR `area` segment of the node path is not the marker when a legacy keyword follows. - [InlineData("Root/area/Child/area:" + Area + "/legacy-id")] - [InlineData("Root/area/Child/area:" + Area)] - [InlineData("Root/area/Child/data:Collection")] - [InlineData("Root/area/Child/content:logo.svg")] - public void Parse_LegacyColonForm_IsLeftToTheUnifiedResolver(string path) - => MeshOperations.TryParseAreaRoute(path, out _, out _, out _) - .Should().BeFalse("a colon at or before the area marker is the legacy colon form"); - - [HubFact] - public async Task Get_AreaRoute_WithColonInId_RendersThatId() - { - var answer = await new MeshOperations(Mesh).Get($"@{Node}/area/{Area}/{ColonId}") - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - - answer.Should().Contain($"ECHO[{ColonId}]", - "the route must render area '{0}' of node '{1}' with the full colon-carrying id; got: {2}", Area, Node, answer); - } - - [HubFact] - public async Task Get_LegacyColonForm_StillResolves() - { - var answer = await new MeshOperations(Mesh).Get($"@{Node}/area:{Area}/legacy-id") - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - - answer.Should().NotStartWith("Error", "the legacy colon form keeps resolving through the unified path; got: {0}", answer); - answer.Should().NotStartWith("Not found", "got: {0}", answer); - } -} diff --git a/test/Memex.Portal.Shared.Test/AuthHostRedirectTest.cs b/test/Memex.Portal.Shared.Test/AuthHostRedirectTest.cs deleted file mode 100644 index 6fc0872665..0000000000 --- a/test/Memex.Portal.Shared.Test/AuthHostRedirectTest.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Net; -using Memex.Portal.Shared.Seo; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the sign-in host rule (): a sign-in that STARTS -/// on a brand host is sent to the host that owns sign-in, so the OAuth redirect_uri always -/// names the host the identity providers were registered with. -/// -/// The defect this prevents, measured 2026-09-23 on www.meshweaver.cloud: all three -/// providers were handed their own brand host — redirect_uri=https://www.meshweaver.cloud/signin-microsoft -/// and the -google / -linkedin equivalents — while the registered value was the app -/// host, so LinkedIn answered "The redirect_uri does not match the registered value" and Google -/// "Access blocked: This app's request is invalid". -/// -public class AuthHostRedirectTest -{ - private const string Auth = "memex.example.test"; - private const string Brand = "www.example.test"; - - private static IConfiguration Config(string? authHost) - => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [PublicSite.AuthHostKey] = authHost, - }).Build(); - - private static HttpRequest RequestOn(string host, string path, string query = "") - { - var http = new DefaultHttpContext(); - http.Request.Scheme = "https"; - http.Request.Host = new HostString(host); - http.Request.Path = path; - if (query.Length > 0) http.Request.QueryString = new QueryString(query); - return http.Request; - } - - // ───────────────────────────── the pure rule ───────────────────────────── - - [Fact] - public void SignInStartingOnABrandHost_IsOnTheWrongHost() - => Assert.True(PublicSite.StartsSignInOnWrongHost( - Config(Auth), RequestOn(Brand, "/auth/login", "?provider=Microsoft"))); - - [Fact] - public void SignInStartingOnTheAuthHost_IsNotRedirected() - => Assert.False(PublicSite.StartsSignInOnWrongHost( - Config(Auth), RequestOn(Auth, "/auth/login", "?provider=Microsoft"))); - - /// - /// 🚨 The CALLBACK must never move hosts: it carries the correlation and nonce cookies the - /// challenge set, which are scoped to the issuing host. - /// - [Theory] - [InlineData("/signin-microsoft")] - [InlineData("/signin-google")] - [InlineData("/signin-linkedin")] - [InlineData("/signin-apple")] - public void ACallbackIsNeverRedirected_EvenOnABrandHost(string callback) - => Assert.False(PublicSite.StartsSignInOnWrongHost(Config(Auth), RequestOn(Brand, callback))); - - /// A deployment with one sign-in host is untouched — memex.systemorph.com's shape. - [Fact] - public void WithNoAuthHostConfigured_NothingIsRedirected() - => Assert.False(PublicSite.StartsSignInOnWrongHost( - Config(null), RequestOn(Brand, "/auth/login", "?provider=Microsoft"))); - - [Fact] - public void AnOrdinaryPageOnABrandHost_IsNotASignIn() - => Assert.False(PublicSite.StartsSignInOnWrongHost(Config(Auth), RequestOn(Brand, "/Doc/Architecture"))); - - // ───────────────────────────── the middleware ───────────────────────────── - - /// - /// The pipeline under test: the auth-host redirect over a TestServer, with one ordinary route - /// behind it so "not redirected" is proved against something that actually answers. Built the - /// way PublicSiteTest builds its host, so both two-host rules are pinned the same way. - /// - private static WebApplication Host(string? authHost) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [PublicSite.AuthHostKey] = authHost, - }); - var app = builder.Build(); - app.UseAuthHostRedirect(); - app.MapGet("/{**path}", () => Results.Text("endpoint reached", "text/plain")); - app.Start(); - return app; - } - - private static HttpRequestMessage On(string host, string pathAndQuery) - { - var request = new HttpRequestMessage(HttpMethod.Get, pathAndQuery); - request.Headers.Host = host; - return request; - } - - private static async Task Send(string? authHost, string host, string pathAndQuery) - { - using var app = Host(authHost); - return await app.GetTestClient().SendAsync(On(host, pathAndQuery)); - } - - [Fact] - public async Task ABrandHostSignIn_IsSentToTheAuthHost_WithItsQueryIntact() - { - var response = await Send(Auth, Brand, "/auth/login?provider=Microsoft&returnUrl=%2FDoc"); - - Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); - Assert.Equal( - $"https://{Auth}/auth/login?provider=Microsoft&returnUrl=%2FDoc", - response.Headers.Location!.ToString()); - } - - /// - /// 🚨 302, never 301. The sign-in host is configuration and may move; a browser caches a - /// permanent redirect past the deploy that changes it. - /// - [Fact] - public async Task TheRedirectIsTemporary_SoTheAuthHostCanMove() - { - var response = await Send(Auth, Brand, "/auth/login?provider=Google"); - - Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); - Assert.NotEqual(HttpStatusCode.MovedPermanently, response.StatusCode); - } - - [Fact] - public async Task OnTheAuthHost_TheRequestReachesTheEndpoint() - { - var response = await Send(Auth, Auth, "/auth/login?provider=Microsoft"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("endpoint reached", await response.Content.ReadAsStringAsync()); - } - - /// The NEGATIVE CONTROL: with no auth host, the same brand-host request is served. - [Fact] - public async Task WithNoAuthHost_TheBrandHostServesSignInItself() - { - var response = await Send(null, Brand, "/auth/login?provider=Microsoft"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("endpoint reached", await response.Content.ReadAsStringAsync()); - } -} diff --git a/test/Memex.Portal.Shared.Test/AuthoredShareImageTest.cs b/test/Memex.Portal.Shared.Test/AuthoredShareImageTest.cs deleted file mode 100644 index 15ff2cf0f2..0000000000 --- a/test/Memex.Portal.Shared.Test/AuthoredShareImageTest.cs +++ /dev/null @@ -1,385 +0,0 @@ -using System; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Mesh; -using MeshWeaver.Reactive.Assertions; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using SkiaSharp; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A shared post unfurled in WhatsApp as an EMPTY large card (2026-10-07): title, description -/// and domain present, the picture box reserved and blank. The head declared the post's -/// mediaUrl — a PROGRESSIVE 1264×848 JPEG on a third-party Supabase host — raw, with no -/// og:image:type and no size, while twitter:card=summary_large_image committed the -/// unfurler to the large layout. The drawn card, which is a declared 1200×630 image on our own -/// origin, was never affected. -/// -/// The fix re-serves an authored foreign picture from this origin as -/// /api/og/{node}.jpg: a baseline 1200×630 JPEG within the byte budget, declared as such. -/// These pin both halves — what the head is told to declare, and that the route then actually -/// answers with exactly that — through the endpoint's OWN decision -/// () over a real -/// , whose HTTP leg is an in-process handler: no network. -/// -/// The foreign host is a literal TEST-NET-3 address (203.0.113.0/24): public by the SSRF -/// guard's rules, so the production guard runs unmodified, and it never resolves through DNS. -/// -public class AuthoredShareImageTest -{ - private const string MediaUrl = "https://203.0.113.10/storage/v1/object/public/post-images/master.jpg"; - - /// A 316×212 PROGRESSIVE JPEG (cjpeg -progressive): left half red, right half blue. - /// The progressive encoding is the property under test; the two halves let a pixel read tell the - /// authored picture from the drawn card. - private static readonly byte[] ProgressiveFixture = Convert.FromBase64String( - "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEY" - + "Gh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4e" - + "Hh4eHh4eHh4eHh4eHh7/wgARCADUATwDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAMEBv/EABcBAQEBAQAAAAAA" - + "AAAAAAAAAAAHBAX/2gAMAwEAAhADEAAAAeeHFpgAAAAAAACdJ7MWUVKUgAAAAAAAAbhIbGAAAAAAAAnSezFlFSlIAAAAAAAA" - + "G4SGxgAAAAAAAJ0nsxZRUpSAAAAAAAABuEhsYAAAAAAACdJ7MWUVKUgAAAAAAAAbhIbGAAAAAAAAnSezFlFSlIAAAAAAAAG4" - + "SGxgAAAAAAAJ0nsxZRUpSAAAAAAAABuEhsYAAAAAAACdJ7MWUVKUgAAAAAAAAbhIbGAAAAAAAAnSezFlFSlIAAAAAAAAG4SG" - + "xgAAAAAAAJ0nsxZRUpSAAAAAAAABuEhsYAAAAAAACdJ7MWUVKUgAAAAAAAAbhIbGAAAAAAAAnSezFlFSlIAAAAAAAAG4SGxg" - + "AAAAAAAJ0nsxZRUpSAAAAAAAABuEhsYAAAAAAACdJ7MWUVKUgAAAAAAAAbhIbGAAAAAAAAmbMWUVKUgAAAAAAAAf/8QAFxAA" - + "AwEAAAAAAAAAAAAAAAAAAjJQQP/aAAgBAQABBQLGaxzWOaxzWOaxzWOaxzWOaxzWOaxzWOaxzWOaxzWOaxzWOaxzWOaxzWOa" - + "xzWOaxzWOa5P/8QAGxEAAAcBAAAAAAAAAAAAAAAAAQMFNEBysTD/2gAIAQMBAT8B4qLQ2o5EUWhtRyIotDajkRRaG1HIii0N" - + "qORFFobUciKLQ2o5EUWhtRyIotDajkRRaG1HIii0NqORFFobUciKLQ2o5EUWhtRzn//EABsRAAAHAQAAAAAAAAAAAAAAAAED" - + "BTRAcrEw/9oACAECAQE/AeKe7KsGxE92VYNiJ7sqwbET3ZVg2InuyrBsRPdlWDYie7KsGxE92VYNiJ7sqwbET3ZVg2InuyrB" - + "sRPdlWDYie7KsGxE92VYN5//xAAUEAEAAAAAAAAAAAAAAAAAAACg/9oACAEBAAY/Agcf/8QAFhABAQEAAAAAAAAAAAAAAAAA" - + "MVBA/9oACAEBAAE/IcbyHkPIeQ8h5DyHkPIeQ8h5DyHkPIeQ8h5DyHkPIeQ8h5DyHkPl/9oADAMBAAIAAwAAABAIIIIIIIIJ" - + "UEEEEEEEEEEIIIIIIIIJUEEEEEEEEEEIIIIIIIIJUEEEEEEEEEEIIIIIIIIJUEEEEEEEEEEIIIIIIIIJUEEEEEEEEEEIIIII" - + "IIIJUEEEEEEEEEEIIIIIIIIJUEEEEEEEEEEIIIIIIIIJUEEEEEEEEEEIIIIIIIIJUEEEEEEEEEEIIIIIIIIJUEEEEEEEEEEI" - + "IIIIIIIJUEEEEEEEEEEIIIIIIIIJUEEEEEEEEEEIIIIIIIIJUEEEEEEEEEEIIIIIIIIJ0EEEEEEEEEH/xAAXEQADAQAAAAAA" - + "AAAAAAAAAABAUfAw/9oACAEDAQE/EMZLCSWEksJJYSSwklhJLCSWEksJJYSSwklhJLCSWz//xAAXEQADAQAAAAAAAAAAAAAA" - + "AABAUfAw/9oACAECAQE/EMYKCQUEgoJBQSCgkFBIKCQUEgoJBQSCgkFBIKCQUz//xAAXEAEBAQEAAAAAAAAAAAAAAACxAVBA" - + "/9oACAEBAAE/EOM7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7Mg7OX" - + "/9k=" ); - - private static MeshNode Post(object? content) => - new("CrossingTheChasmPlaybook", "Posts") { NodeType = "SocialMedia/Post", Name = "Crossing The Chasm Playbook", Content = content }; - - private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value); - - // ── What the head is told to declare ───────────────────────────────────────────────────────── - - /// - /// The head names OUR route for a foreign authored picture, and that route is one the head can - /// declare a type and a size for. Before the fix returned - /// the Supabase URL itself and said false for it — the - /// "no type, no size" head the reporter's card was built from. - /// - [Fact] - public void AForeignMediaUrl_IsDeclaredAsTheReServedCard_WithItsTypeAndSize() - { - var post = Post(Json(new { text = "post", mediaUrl = MediaUrl })); - - var image = SeoResolver.ShareImage(post); - - Assert.Equal("/api/og/Posts/CrossingTheChasmPlaybook.jpg", image); - Assert.True(SeoResolver.IsGeneratedCard(image), "the head declares 1200×630 only for a portal-served card"); - Assert.Equal("image/jpeg", SeoResolver.CardMediaType(image)); - // The route still reads the ORIGINAL — the authored field is not rewritten. - Assert.Equal(MediaUrl, SeoResolver.ExtractImage(post)); - } - - /// - /// NEGATIVE CONTROLS on the same instrument: a picture on this origin's own content route is - /// declared unchanged with no invented type, and a page that authored nothing keeps the drawn - /// PNG — so the rule above fires on "foreign", not on "authored" or on everything. - /// - [Fact] - public void AnOwnOriginPicture_AndNoPicture_AreUntouched() - { - var own = Post(Json(new { ogImage = "/api/content/Posts/content/og.png" })); - Assert.Equal("/api/content/Posts/content/og.png", SeoResolver.ShareImage(own)); - Assert.Null(SeoResolver.CardMediaType(SeoResolver.ShareImage(own))); - - var none = Post(Json(new { text = "post" })); - Assert.Equal("/api/og/Posts/CrossingTheChasmPlaybook.png", SeoResolver.ShareImage(none)); - Assert.Equal("image/png", SeoResolver.CardMediaType(SeoResolver.ShareImage(none))); - Assert.Null(SeoResolver.CardMediaType("https://cdn.example/api/og/banner.jpg")); - } - - // ── What the route then answers ────────────────────────────────────────────────────────────── - - /// - /// The precondition, measured rather than assumed: the fixture really IS progressive. Without - /// this, the baseline assertion below could pass on a fixture that was baseline to begin with. - /// - [Fact] - public void TheFixtureIsAProgressiveJpeg() - { - var (progressive, width, height) = JpegFrame(ProgressiveFixture); - Assert.True(progressive); - Assert.Equal((316, 212), (width, height)); - } - - /// - /// 🚨 THE FIX, end to end through the route's own decision: the foreign progressive JPEG comes - /// back as exactly what the head declares — a BASELINE JPEG of exactly 1200×630, inside the byte - /// budget, shared-cacheable — and it is the AUTHORED picture (red left of centre, blue right), - /// fitted whole rather than cropped. - /// - [Fact] - public async Task AForeignProgressiveJpeg_IsServedAsTheDeclaredBaseline1200x630Jpeg() - { - var foreign = new ForeignHost(_ => Image(ProgressiveFixture)); - using var renderer = new OgCardRenderer("Memex"); - - var jpeg = await Serve(foreign, renderer, "the authored picture must be re-served"); - - var (progressive, width, height) = JpegFrame(jpeg.Bytes); - Assert.False(progressive, "a progressive JPEG is one of the encodings an unfurler may refuse"); - Assert.Equal((OgCardRenderer.Width, OgCardRenderer.Height), (width, height)); - Assert.Equal("image/jpeg", jpeg.ContentType); - Assert.True(jpeg.Bytes.Length <= OgCardRenderer.MaxShareImageBytes, $"{jpeg.Bytes.Length} bytes is over the budget"); - Assert.Equal("public, max-age=86400", jpeg.CacheControl); - Assert.Equal(Hue.Red, HueAt(jpeg.Bytes, 400, 315)); - Assert.Equal(Hue.Blue, HueAt(jpeg.Bytes, 800, 315)); - Assert.Equal("image/*", foreign.LastAccept); - } - - /// - /// A foreign host that does not hand over a picture — down, or answering an HTML challenge page - /// with 200 — still gets the unfurler what the head promised: the node's DRAWN card, as a - /// 1200×630 baseline JPEG. And the failure is not cached: the next request fetches again and, - /// the host having recovered, serves the authored picture. (NEGATIVE CONTROL for the pixel read - /// above: the drawn card is not red at the same spot.) - /// - [Theory] - [InlineData(HttpStatusCode.ServiceUnavailable, "text/plain")] - [InlineData(HttpStatusCode.OK, "text/html")] - public async Task APictureThatCannotBeHad_ServesTheDrawnCardAsJpeg_AndTheNextRequestTriesAgain( - HttpStatusCode status, string mediaType) - { - var recovered = 0; - var foreign = new ForeignHost(_ => Volatile.Read(ref recovered) == 0 - ? new HttpResponseMessage(status) { Content = new StringContent("challenge", System.Text.Encoding.UTF8, mediaType) } - : Image(ProgressiveFixture)); - using var renderer = new OgCardRenderer("Memex"); - - var fallback = await Serve(foreign, renderer, "an unavailable picture must still answer"); - var (progressive, width, height) = JpegFrame(fallback.Bytes); - Assert.False(progressive); - Assert.Equal((OgCardRenderer.Width, OgCardRenderer.Height), (width, height)); - Assert.NotEqual(Hue.Red, HueAt(fallback.Bytes, 400, 315)); - - Volatile.Write(ref recovered, 1); - var authored = await Serve(foreign, renderer, "the retry must reach the recovered host"); - Assert.Equal(Hue.Red, HueAt(authored.Bytes, 400, 315)); - Assert.Equal(2, foreign.Requests); - } - - /// - /// Nothing is cached in the process — an unbounded per-URL cache would grow with every public - /// node and edit, and go stale when an author replaces the bytes behind one URL. Each request - /// re-reads the source; the response's strong ETag and day-long shared cacheability are what - /// spare the foreign host. Same bytes in, same bytes out, so the ETag holds across requests. - /// - [Fact] - public async Task EachRequestReReadsTheSource_AndYieldsTheSameBytes() - { - var foreign = new ForeignHost(_ => Image(ProgressiveFixture)); - using var renderer = new OgCardRenderer("Memex"); - - var first = await Serve(foreign, renderer, "first request"); - var second = await Serve(foreign, renderer, "second request"); - - Assert.Equal(first.Bytes, second.Bytes); - Assert.Equal(2, foreign.Requests); - } - - /// - /// A phone photo stored sideways is served UPRIGHT. The fixture with an EXIF Orientation of 6 - /// (stored rotated; display turns it 90° clockwise) puts its red half on TOP; read without the - /// orientation it would sit on the left, so top and bottom of the centre column would agree. - /// - [Fact] - public async Task AnExifRotatedPicture_IsServedUpright() - { - var foreign = new ForeignHost(_ => Image(WithExifOrientation(ProgressiveFixture, 6))); - using var renderer = new OgCardRenderer("Memex"); - - var jpeg = await Serve(foreign, renderer, "the rotated picture must be re-served"); - - Assert.Equal(Hue.Red, HueAt(jpeg.Bytes, 600, 120)); - Assert.Equal(Hue.Blue, HueAt(jpeg.Bytes, 600, 510)); - } - - /// - /// 🚨 A decompression bomb is refused BEFORE allocation: a few-hundred-byte PNG declaring - /// 60000×60000 pixels would be a 14 GB bitmap. It never reaches a decode — the declared size is - /// over — and the route serves the drawn card. - /// - [Fact] - public async Task APictureDeclaringHugeDimensions_IsRefusedAndTheDrawnCardServed() - { - var bomb = PngDeclaring(60_000, 60_000); - using var renderer = new OgCardRenderer("Memex"); - Assert.Null(renderer.NormaliseAuthored(bomb)); - - var foreign = new ForeignHost(_ => Image(bomb, "image/png")); - var served = await Serve(foreign, renderer, "a refused picture must still answer"); - var (_, width, height) = JpegFrame(served.Bytes); - Assert.Equal((OgCardRenderer.Width, OgCardRenderer.Height), (width, height)); - Assert.NotEqual(Hue.Red, HueAt(served.Bytes, 400, 315)); - } - - /// - /// A SCHEME-RELATIVE authored URL (//host/…) starts with / but names another host, - /// so it is re-served like an absolute one — declared as the portal card, fetched as https — - /// rather than slipping through as a raw, undeclared foreign picture. - /// - [Fact] - public async Task ASchemeRelativeMediaUrl_IsTreatedAsForeign() - { - const string networkPath = "//203.0.113.10/storage/v1/object/public/post-images/master.jpg"; - var post = Post(Json(new { text = "post", mediaUrl = networkPath })); - Assert.Equal("/api/og/Posts/CrossingTheChasmPlaybook.jpg", SeoResolver.ShareImage(post)); - - Uri? asked = null; - var foreign = new ForeignHost(request => { asked = request.RequestUri; return Image(ProgressiveFixture); }); - using var renderer = new OgCardRenderer("Memex"); - var served = await Serve(foreign, renderer, "the network-path picture must be re-served", networkPath); - - Assert.Equal("https://203.0.113.10/storage/v1/object/public/post-images/master.jpg", asked?.ToString()); - Assert.Equal(Hue.Red, HueAt(served.Bytes, 400, 315)); - } - - // ── Harness ────────────────────────────────────────────────────────────────────────────────── - - private sealed record Served(byte[] Bytes, string? ContentType, string CacheControl); - - private static async Task Serve( - ForeignHost foreign, OgCardRenderer renderer, string because, string mediaUrl = MediaUrl) - { - var http = new DefaultHttpContext(); - var result = await SeoEndpoints.AuthoredCardResult( - foreign.Fetcher, NullLogger.Instance, http, renderer, - Post(Json(new { text = "post", mediaUrl })), sharedCacheable: true) - .Should().Within(TestTimeouts.Convergence).Emit(because); - var file = Assert.IsType(result); - return new Served(file.FileContents.ToArray(), file.ContentType, http.Response.Headers.CacheControl.ToString()); - } - - private static HttpResponseMessage Image(byte[] bytes, string mediaType = "image/jpeg") - { - var content = new ByteArrayContent(bytes); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(mediaType); - return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; - } - - /// The JPEG with an APP1 Exif segment carrying one Orientation tag, spliced in after SOI. - private static byte[] WithExifOrientation(byte[] jpeg, ushort orientation) - { - byte[] tiff = - [ - (byte)'M', (byte)'M', 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08, // big-endian header, IFD0 at 8 - 0x00, 0x01, // one entry - 0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, // Orientation, SHORT, count 1 - (byte)(orientation >> 8), (byte)orientation, 0x00, 0x00, // value - 0x00, 0x00, 0x00, 0x00, // no next IFD - ]; - byte[] exifHeader = [(byte)'E', (byte)'x', (byte)'i', (byte)'f', 0x00, 0x00]; - var length = 2 + exifHeader.Length + tiff.Length; - byte[] app1 = [0xFF, 0xE1, (byte)(length >> 8), (byte)length, .. exifHeader, .. tiff]; - return [.. jpeg.Take(2), .. app1, .. jpeg.Skip(2)]; - } - - /// A structurally valid PNG whose IHDR declares × - /// and whose image data is a single empty deflate stream — tiny on the wire, huge if decoded. - private static byte[] PngDeclaring(int width, int height) - { - static byte[] Chunk(string type, byte[] data) - { - var typed = System.Text.Encoding.ASCII.GetBytes(type).Concat(data).ToArray(); - var crc = Crc32(typed); - return [.. BigEndian(data.Length), .. typed, .. BigEndian((int)crc)]; - } - static byte[] BigEndian(int v) => [(byte)(v >> 24), (byte)(v >> 16), (byte)(v >> 8), (byte)v]; - static uint Crc32(byte[] bytes) - { - var crc = 0xFFFFFFFFu; - foreach (var b in bytes) - { - crc ^= b; - for (var k = 0; k < 8; k++) - crc = (crc & 1) != 0 ? 0xEDB88320u ^ (crc >> 1) : crc >> 1; - } - return ~crc; - } - byte[] ihdr = [.. BigEndian(width), .. BigEndian(height), 8, 2, 0, 0, 0]; // 8-bit RGB - byte[] idat = [0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]; // empty zlib stream - return [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, - .. Chunk("IHDR", ihdr), .. Chunk("IDAT", idat), .. Chunk("IEND", [])]; - } - - /// The foreign picture host: a real whose client - /// is backed by an in-process handler, counting what it is asked. - private sealed class ForeignHost : HttpMessageHandler, IHttpClientFactory - { - private readonly Func answer; - private int requests; - - public ForeignHost(Func answer) - { - this.answer = answer; - Fetcher = new OpenGraphPreviewService( - new ServiceCollection().AddSingleton(this).BuildServiceProvider()); - } - - public OpenGraphPreviewService Fetcher { get; } - public int Requests => Volatile.Read(ref requests); - public string? LastAccept { get; private set; } - - public HttpClient CreateClient(string name) => new(this, disposeHandler: false); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - Interlocked.Increment(ref requests); - LastAccept = request.Headers.Accept.ToString(); - return Task.FromResult(answer(request)); - } - } - - /// The frame header of a JPEG: SOF2 is progressive, SOF0/SOF1 baseline. - private static (bool Progressive, int Width, int Height) JpegFrame(byte[] jpeg) - { - Assert.Equal(new byte[] { 0xFF, 0xD8 }, jpeg.Take(2).ToArray()); - var i = 2; - while (i + 9 < jpeg.Length) - { - Assert.Equal(0xFF, jpeg[i]); - var marker = jpeg[i + 1]; - var length = (jpeg[i + 2] << 8) | jpeg[i + 3]; - if (marker is 0xC0 or 0xC1 or 0xC2) - return (marker == 0xC2, (jpeg[i + 7] << 8) | jpeg[i + 8], (jpeg[i + 5] << 8) | jpeg[i + 6]); - i += 2 + length; - } - throw new Xunit.Sdk.XunitException("no frame header found — not a JPEG"); - } - - private enum Hue { Red, Blue, Other } - - private static Hue HueAt(byte[] jpeg, int x, int y) - { - using var bitmap = SKBitmap.Decode(jpeg); - var c = bitmap.GetPixel(x, y); - return c.Red > 150 && c.Blue < 90 ? Hue.Red - : c.Blue > 150 && c.Red < 90 ? Hue.Blue - : Hue.Other; - } -} diff --git a/test/Memex.Portal.Shared.Test/BootInstallLandsOnTheSealedCommitTest.cs b/test/Memex.Portal.Shared.Test/BootInstallLandsOnTheSealedCommitTest.cs deleted file mode 100644 index 5a674de0cb..0000000000 --- a/test/Memex.Portal.Shared.Test/BootInstallLandsOnTheSealedCommitTest.cs +++ /dev/null @@ -1,580 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 Policy module-sync-per-manifest-hash: the seal no longer pins the boot default -/// install's ref (the #4259 design below). Every boot lists the configured ref, and a partition a -/// sync source keeps current is written by that source alone (#4588) — which is what keeps the two -/// unattended writers of one partition from landing two trees now that the sync source follows -/// green builds rather than the seal. The history is kept because it is why #4588's hold exists. -/// -/// 🚨 The boot default install was the last unattended branch-tip import, and it was writing -/// partitions a sync entry already held at the sealed commit (Systemorph/MeshWeaver#4259). -/// -/// The Sync-Ref Contract says an UNATTENDED import reads a commit a build proved; only a -/// person clicking "Update to latest" reads a branch. Every GitSync path honours it and -/// ModuleDiscoveryService.FirstImport was pinned to the seal for exactly that reason -/// (MeshWeaver#3845). still listed -/// PluginCatalog:Sources:N:Ref — main — resolved at fetch time on every boot, and -/// stamped installedFromRef: main. Measured on memex.meshweaver.cloud 2026-09-13: the boot -/// install put a 2026-09-13 tree of Hosting into a partition whose Hosting/_GitSync -/// is held to the 2026-09-12 commit sealed for the running framework; on the next boot the sealed -/// reconciler re-imported the partition at the seal and pruned the eleven nodes only the newer -/// tree has; the boot install then re-applied main as a diff against its own record and -/// left them absent. Eight NodeTypes sat at compilationStatus: Error. -/// -/// What is measured here, on the production decision end to end. A real -/// over a real mesh, a configured URL source whose -/// transport answers a DIFFERENT tree per commitish — the branch tip carries a node the sealed -/// commit does not — and a real published-bundle root carrying the markers -/// publish-bake-bundles.sh writes. With no seal the lane keeps today's behaviour (the -/// residue the contract names: a repository this instance runs no publication of). Once the -/// repository is sealed for this identity, the lane lists and installs at that commit, never asks -/// the transport for the branch, and its record says so. A torn seal HOLDS — nothing is installed -/// at the branch instead, the pass reports its listing incomplete — and a seal of ANOTHER -/// repository is not this lane's business. -/// -public class BootInstallLandsOnTheSealedCommitTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string RepoFullName = "test/boot-seal"; - private const string RepoUrl = $"https://github.com/{RepoFullName}"; - private const string OtherRepoFullName = "test/some-other-repo"; - - /// The bake source directory the seal lives under — the name the Plugins lane uses. - private const string SealedSourceName = "plugins"; - - /// The commit the seal covers — an OLDER tree than the branch tip, as on memex-cloud. - private const string SealedSha = "627fb3cd2f1bc7142226a2ae881c81f9d83cc430"; - - /// - /// The commit the configured ref (main) resolves to at the transport. Since the decision - /// "Record commit on install" (MeshWeaver#3848) the install record stores the commit the source - /// SERVED, not the moving ref it was asked for — so a record listed at the configured ref reads - /// THIS sha, and a record stamped from the seal would read instead. - /// - private static readonly string MainTipSha = "f00d" + new string('0', 36); - - private const string Package = "Course"; - private const string LessonPath = $"{Package}/Lesson"; - private const string QuizPath = $"{Package}/Quiz"; - private const string RecordPath = $"{PackageInstaller.InstalledPartition}/{Package}"; - - // Written BEFORE the mesh is built: a derived class's field initializers run ahead of the base - // constructor, which is what calls ConfigureMesh. - private readonly string publishedRoot = Path.Combine( - Path.GetTempPath(), "mw-boot-seal-" + Guid.NewGuid().ToString("N")[..12]); - - private readonly TwoTreeRepoClient repoClient = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddPluginCatalog() - // The tracking seam, REAL: the second test's gate is answered by the shipped GitHub - // provider over a real {partition}/_GitSync node, not by a stand-in. With no such node - // — every arm of the first test — it answers "nothing tracks this partition", which is - // the pre-#4588 behaviour and is what keeps that test measuring what it always did. - .AddGitHubSyncTypes() - .ConfigureServices(services => services - .AddGitHubSyncServices()) - .ConfigureServices(services => services - .AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - // The registry's OWN shape, exactly as the fleet configures it: a URL - // source at `main`, in node-repo format. - ["PluginCatalog:Sources:0:Name"] = "Plugins", - ["PluginCatalog:Sources:0:RepoPath"] = RepoUrl, - ["PluginCatalog:Sources:0:Ref"] = "main", - ["PluginCatalog:Sources:0:Format"] = "node-repo", - // Where this pod reads what was sealed for its own framework identity. - [ShippedPrebuiltBundles.PublishedRootConfigKey] = publishedRoot, - }) - .Build()) - .AddSingleton(repoClient) - // The platform baseline: the package declares `preInstalled: true`, so it is a - // RECONCILED candidate that re-asserts on every boot — the lane under test. - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = true })); - - public override async ValueTask DisposeAsync() - { - try { await base.DisposeAsync(); } - finally - { - try { Directory.Delete(publishedRoot, recursive: true); } catch { /* best effort */ } - } - } - - private InstanceAutoRegistrationService Installer => - Mesh.ServiceProvider.GetRequiredService(); - - private IMeshService MeshService => Mesh.ServiceProvider.GetRequiredService(); - - /// - /// 🚨 Policy module-sync-per-manifest-hash re-expresses this case: the seal no longer pins - /// (or holds) the boot install's ref — the same answer the GitSync first import takes — so every - /// boot lists the CONFIGURED ref whatever is sealed: nothing sealed, sealed at an older commit, a - /// torn seal, another repository's seal. Against the #4259 code boot 2 fetched the sealed commit - /// and boot 3 held; here every boot asks the transport for main only. - /// - [Fact(Timeout = 300_000)] - public async Task TheBootInstall_ListsTheConfiguredRef_WhateverIsSealed() - { - var first = await Installer.Completed.FirstAsync().Timeout(TimeSpan.FromSeconds(180)).Await(TestContext.Current.CancellationToken); - first.Packages.Should().Equal(new[] { Package }); - first.Failed.Should().Be(0); - (await Record())!.InstalledFromRef.Should().Be(MainTipSha, - "the record stores the commit the configured ref served (MeshWeaver#3848)"); - (await Lesson()).Should().Be("# Lesson, as of main"); - (await Read(QuizPath)).Should().NotBeNull("the branch tip carries a node the sealed commit does not"); - repoClient.FetchedRefs.Should().NotBeEmpty().And.OnlyContain(r => r == "main"); - - foreach (var (repository, complete, what) in new[] - { - (RepoFullName, true, "sealed at an OLDER commit for this identity"), - (RepoFullName, false, "a TORN seal"), - (OtherRepoFullName, true, "another repository's seal"), - }) - { - var fetchesBefore = repoClient.FetchedRefs.Count; - StageSeal(repository, SealedSha, complete); - var pass = await Installer.RunDefaultInstall().Timeout(TimeSpan.FromSeconds(120)).Await(TestContext.Current.CancellationToken); - pass.Packages.Should().Equal(new[] { Package }, $"{what}: the package is still installed"); - pass.Failed.Should().Be(0); - pass.ListingIncomplete.Should().BeFalse($"{what}: nothing is held, so the listing is whole"); - (await Record())!.InstalledFromRef.Should().Be(MainTipSha, - $"{what}: the seal does not choose the ref (policy module-sync-per-manifest-hash) — " - + "the record names the commit the CONFIGURED ref served, never the sealed one"); - (await Read(QuizPath)).Should().NotBeNull($"{what}: the configured ref's tree stays"); - repoClient.FetchedRefs.Skip(fetchesBefore).Should().NotContain(SealedSha, - $"{what}: the sealed commit is never asked for"); - } - } - - /// - /// 🚨 THE RESIDUE, and what it may not do — Systemorph/MeshWeaver#4588. - /// - /// The test above removes the branch-tip install wherever a seal can be attributed to the - /// source. It cannot be attributed to a REGISTRY source — - /// answers "no repository to attribute a seal to" for a source with no RepoPath — and - /// that residue is what the control instance runs. Measured on memex.systemorph.com - /// 2026-09-17: Plugins/Hosting was installed at 14:34:30Z with - /// installedFromRef: HEAD into Hosting, whose Hosting/_GitSync re-imports - /// the same subdirectory at the commit sealed for the running framework (then 061976bc, - /// which does not carry Hosting/Issue/Source/FleetWatchCadence.cs). Thirteen minutes - /// later the record claimed that file and the node was gone; three Hosting NodeTypes could not - /// compile and the roll onto the 3.0.0 candidate could not converge. - /// - /// Fails on main. There the residue writes the branch tip into a partition another - /// writer keeps current, whatever that writer is held to. The witness is the transport's own - /// request log — what a lane ASKED FOR is the one thing it cannot fake. - /// - /// Since policy module-sync-per-manifest-hash the seal proves no ref, so the - /// arms that used to install at the sealed commit (a partition synced from its own repository) - /// defer to the partition's sync source as well: it follows green builds per module manifest hash - /// and is the partition's one writer. A partition nothing syncs still installs (boot 1 here, and - /// every boot of the case above), so this is not "the boot install stopped installing". - /// - [Fact(Timeout = 300_000)] - public async Task TheBootInstall_HoldsAnUnprovenRef_WhereAnotherWriterKeepsThePartitionCurrent() - { - // ── Boot 1: nothing sealed, and nothing syncs Course. The residue installs the branch - // tip — today's behaviour, and the precondition that makes the next pass a measurement. - var first = await Installer.Completed.FirstAsync().Timeout(TimeSpan.FromSeconds(180)) - .Await(TestContext.Current.CancellationToken); - first.Packages.Should().Equal(new[] { Package }); - first.Held.Should().BeEmpty( - "nothing else writes this partition, so there is nothing to hold — a hold here would " - + "make every assertion below pass for the wrong reason"); - (await Record())!.InstalledFromRef.Should().Be(MainTipSha, - "the record stores the commit the configured ref served (MeshWeaver#3848)"); - (await Lesson()).Should().Be("# Lesson, as of main"); - - // ── The one fact that changes: a sync source now keeps Course current. ────────────────── - await TrackPartition(); - - // ── And it does what a sealed reconcile does: it PRUNES the node its tree does not carry. - // This is the shape that loops on main — the installer's completeness check sees a - // declared node absent, installs in FULL to repair it, the partition's own writer - // removes it again at the next import, and the next boot detects the same shortfall. - // "A detection that REPEATS at an unchanged module version is a repair that did not - // hold" (CatalogLayoutAreas.SkipOrHeal), measured on memex.meshweaver.cloud over ELEVEN - // boots for Feedback/Feedback/Source/FeedbackHandover. - // As SYSTEM: the partition's own writer is the platform (the import), and a one-way synced - // partition's content is written by System alone (#5140). - using (Mesh.ServiceProvider.GetRequiredService().ImpersonateAsSystem()) - await MeshService.DeleteNode(QuizPath) - .Should().Within(TestTimeouts.WriteConvergence) - .Emit("the prune is the precondition of the repair this gate must not attempt", - cancellationToken: TestContext.Current.CancellationToken); - (await Read(QuizPath)).Should().BeNull("the prune must have landed, or nothing is measured"); - var fetchesBeforeTheHold = repoClient.FetchedRefs.Count; - - var second = await Installer.RunDefaultInstall().Timeout(TimeSpan.FromSeconds(120)) - .Await(TestContext.Current.CancellationToken); - - // ── THE assertion. ───────────────────────────────────────────────────────────────────── - (await Read(QuizPath)).Should().BeNull( - "THE assertion: an unattended install must not write a ref no publication sealed here " - + "names back into a partition another writer keeps current — not even to repair a node " - + "that writer deliberately does not carry. On main it writes it back every boot, which " - + "is how Hosting/Issue/Source/FleetWatchCadence came to be claimed by a 13-minute-old " - + "install and absent from the mesh (MeshWeaver#4588)"); - repoClient.FetchedRefs.Count.Should().Be(fetchesBeforeTheHold, - "and it did not even ASK the transport — what a lane requested is the one thing it " - + "cannot fake"); - second.Packages.Should().BeEmpty("a held package is not a package this pass landed"); - second.Failed.Should().Be(0, - "a hold is not a failure — no retry can prove a ref, and advertising a defect where " - + "there is a standing decision is the #2536 mistake one lane over"); - var held = second.Held.Should().ContainSingle(h => h.Package == Package, - "the hold is RECORDED, once, with its reason — an install that will never land here is " - + "exactly the quiet 'my plugin never updates' this lane must not become").Subject; - second.Skipped.Should().BeEmpty( - "and it is NOT spelt as a terminal authorization skip: the summary renders those " - + "'authorization, not retried', while this one lifts by itself the moment a seal names " - + "the source or the partition stops being written by anything else"); - held.Reason.Should().Contain(Package, - "the reason names the partition it is about, or nobody can act on it"); - held.Reason.Should().Contain("MeshWeaver#4588"); - (await Record())!.InstalledFromRef.Should().Be(MainTipSha, - "and the record was NOT re-stamped — a hold that moved the record would leave the next " - + "delta computed against a claim nothing wrote"); - - // ── ARMS 2 and 3, under policy module-sync-per-manifest-hash. ────────────────────────── - // The seal no longer PROVES the install's ref, so a seal of this repository changes nothing - // here: whether the partition syncs from ANOTHER repository (arm 2, which #4625 held on the - // repository mismatch) or from its OWN (arm 3, which used to install at the sealed commit), - // the partition's content is kept current by its sync source — its ONE writer, following - // green builds per module manifest hash — and the installer defers to it (#4588). Installing - // the sealed tree here would put an OLDER tree into a partition that writer has moved past: - // the two-writer mix #4355 describes, in the other direction. - StageSeal(RepoFullName, SealedSha, complete: true); - var mismatched = await Installer.RunDefaultInstall().Timeout(TimeSpan.FromSeconds(120)) - .Await(TestContext.Current.CancellationToken); - mismatched.Held.Should().ContainSingle(h => h.Package == Package, - "a partition another writer keeps current is not written by the boot install").Subject - .Reason.Should().Contain("MeshWeaver#4588"); - mismatched.Packages.Should().BeEmpty("a held package is not a package this pass landed"); - (await Record())!.InstalledFromRef.Should().Be(MainTipSha, "and the record is still not re-stamped"); - - await PointTheSyncAt($"https://github.com/{RepoFullName}", Package); - var third = await Installer.RunDefaultInstall().Timeout(TimeSpan.FromSeconds(120)) - .Await(TestContext.Current.CancellationToken); - third.Held.Should().ContainSingle(h => h.Package == Package, - "a partition synced from its OWN repository has one writer too — the sync source"); - third.Packages.Should().BeEmpty(); - repoClient.FetchedRefs.Should().NotContain(SealedSha, "the sealed commit is never asked for"); - (await Record())!.InstalledFromRef.Should().Be(MainTipSha); - } - - /// - /// Re-points the partition's _GitSync at a repository and subdirectory — the one thing - /// that differs between the #4625 hold and the control that must install. - /// - /// The repository the partition's own writer imports from. - /// The subdirectory within it. - private async Task PointTheSyncAt(string repositoryUrl, string? subdirectory) - { - var path = $"{Package}/{GitHubSyncService.ConfigId}"; - await Mesh.GetWorkspace().GetMeshNodeStream(path) - .Update(node => node with - { - Content = new GitHubSyncConfig - { - RepositoryUrl = repositoryUrl, - Branch = "main", - Subdirectory = subdirectory, - }, - }) - .Should().Within(TestTimeouts.WriteConvergence) - .Emit("the arm below turns on this edit, so it has to have landed", - cancellationToken: TestContext.Current.CancellationToken); - - // 🚨 Read it back THROUGH the decision under test, never off the node: a write that landed - // and a seam that re-read it are different facts, and the arm below is only meaningful once - // the gate itself can see the new repository. Waiting on the gate is also what makes a - // failure here read as "the seam never saw the edit" rather than as the arm's own verdict. - await Observable.Interval(TestTimeouts.Quick / 20).StartWith(0L) - .SelectMany(_ => PartitionContentOwnership.ImportedFromAnotherRepository( - Mesh, Package, RepoFullName, Package)) - .Where(mismatch => mismatch is null) - .FirstAsync() - .Timeout(TestTimeouts.CrossSilo) - .Await(TestContext.Current.CancellationToken); - } - - /// - /// The gate's decision, pinned offline so every arm stays separate — including the two that are - /// not passes. Folding into "proceed" would - /// make a seam that faulted read exactly like a partition nothing syncs, and the lane would - /// then write a branch tip into a partition it has no evidence about. - /// - [Fact] - public void UnprovenRefHold_HoldsEverythingButARealNegative() - { - InstanceAutoRegistrationService.UnprovenRefHold( - PartitionContentOwnership.Decide(Package, tracked: false, providerCount: 1), "main") - .Should().BeNull( - "a mesh WITH a sync layer that says nothing tracks this partition is a real " - + "negative — the installer is its only writer and the residue is unchanged"); - - InstanceAutoRegistrationService.UnprovenRefHold( - PartitionContentOwnership.Decide(Package, tracked: null, providerCount: 0), "main") - .Should().BeNull( - "a mesh that registers NO tracking provider has no second writer at all — a local " - + "mesh, a CI mesh, the bake host — and must keep the pre-#4588 behaviour"); - - var tracked = InstanceAutoRegistrationService.UnprovenRefHold( - PartitionContentOwnership.Decide(Package, tracked: true, providerCount: 1), "main"); - tracked.Should().NotBeNull().And.Contain(Package).And.Contain("main", - "the hold names the partition AND the ref it refused to land there"); - - InstanceAutoRegistrationService.UnprovenRefHold( - PartitionContentOwnership.Decide(Package, tracked: null, providerCount: 2), "main") - .Should().NotBeNull( - "THE arm: a seam that did not answer was NOT checked, and 'I could not tell' is " - + "never 'clear to write' — a hold that was wrong is lifted by the next boot, an " - + "install that was wrong cannot be taken back"); - } - - /// A real {partition}/_GitSync naming a repository — what the settings tab - /// writes when a Space is connected — read back through the very decision under test, so a - /// false negative cannot be mistaken for a working gate. The repository is deliberately NOT - /// this package's: the transport refuses it, exactly as a real client with no credential for it - /// would, so the sync writes nothing and the arm measures the INSTALL alone. - private async Task TrackPartition() - { - await MeshService - .CreateNode(new MeshNode(GitHubSyncService.ConfigId, Package) - { - Name = "GitHub Sync", - NodeType = GitHubSyncService.ConfigNodeType, - State = MeshNodeState.Active, - Content = new GitHubSyncConfig - { - RepositoryUrl = "https://github.com/Systemorph/Example", - Branch = "main", - }, - }) - .Should().Within(TestTimeouts.Convergence) - .Emit("connecting the partition to a repository is the precondition of the hold", - cancellationToken: TestContext.Current.CancellationToken); - - var verdict = await Observable.Interval(TestTimeouts.Quick / 20).StartWith(0L) - .SelectMany(_ => PartitionContentOwnership.Observe(Mesh, Package)) - .Where(v => v.Owner == PartitionContentOwner.SyncSource) - .FirstAsync() - .Timeout(TestTimeouts.CrossSilo) - .Await(TestContext.Current.CancellationToken); - verdict.InstallerOwnsTheContent.Should().BeFalse( - "the seam must actually see the sync entry, or the pass below would be held by nothing"); - } - - /// - /// The pure half, pinned offline so the three answers stay three: a plan naming a commit - /// re-refs the source; a branch-tip plan returns the SAME instance (so "pinned" and "as - /// configured" are told apart without comparing refs); a hold carries its reason. - /// - [Fact] - public void ApplyPlan_KeepsTheThreeAnswersApart() - { - var configured = new ConfiguredPackageSource(repoClient.AsSource(), "main", "Plugins") { RepoPath = RepoUrl }; - - var pinned = InstanceAutoRegistrationService.ApplyPlan(configured, - new SealedSyncGate.FirstImportPlan(SealedSha, null, "sealed")); - pinned.HoldReason.Should().BeNull(); - pinned.Source.GitRef.Should().Be(SealedSha); - pinned.Source.Name.Should().Be("Plugins"); - pinned.Source.RepoPath.Should().Be(RepoUrl, "everything but the ref is carried"); - - var tip = InstanceAutoRegistrationService.ApplyPlan(configured, - new SealedSyncGate.FirstImportPlan(null, null, "nothing sealed")); - tip.HoldReason.Should().BeNull(); - tip.Source.Should().BeSameAs(configured); - - var held = InstanceAutoRegistrationService.ApplyPlan(configured, - new SealedSyncGate.FirstImportPlan(null, "torn", "torn")); - held.HoldReason.Should().Be("torn"); - } - - /// - /// <root>/<this process's identity>/plugins/ with the markers a sealed - /// publication carries; false leaves out the completion sentinel — - /// the torn shape. Re-callable, because re-sealing is what the publishing lane does. - /// - private void StageSeal(string repository, string commit, bool complete) - { - var sourceDirectory = Path.Combine( - publishedRoot, PrebuiltAssemblySeeder.LiveFrameworkMvid, SealedSourceName); - Directory.CreateDirectory(sourceDirectory); - File.WriteAllText( - Path.Combine(sourceDirectory, SealedPublicationIndex.RepositoryMarkerFileName), repository); - File.WriteAllText( - Path.Combine(sourceDirectory, SealedPublicationIndex.SourceCommitMarkerFileName), commit); - var sentinel = Path.Combine(sourceDirectory, ShippedPrebuiltBundles.CompletionSentinelFileName); - if (complete) - File.WriteAllText(sentinel, string.Empty); - else if (File.Exists(sentinel)) - File.Delete(sentinel); - } - - private async Task Record() => - (await Read(RecordPath))?.ContentAs(Mesh.JsonSerializerOptions); - - /// The lesson's markdown, as the markdown parser lands it (a ). - private async Task Lesson() => - (await Read(LessonPath))?.ContentAs(Mesh.JsonSerializerOptions)?.Content?.Trim(); - - /// Authoritative single-node read straight off storage (never the lagging index). - private Task Read(string path) => - Mesh.ServiceProvider.GetRequiredService() - .Read(path, Mesh.JsonSerializerOptions) - .Take(1).Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - /// - /// The transport, answering a DIFFERENT tree per commitish and recording what it was asked for: - /// the branch carries a lesson "as of main" plus a Quiz node; the sealed commit carries the - /// lesson "as sealed" and no Quiz. Every other commitish is a fault — an unattended install - /// asking for anything else is the bug. - /// - private sealed class TwoTreeRepoClient : IGitHubRepoClient - { - private readonly List fetched = []; - - public IReadOnlyList FetchedRefs - { - get { lock (fetched) return fetched.ToList(); } - } - - /// The same transport as a package source, for the offline pin. - public IPackageSource AsSource() => new NodeRepoPackageSource(Fetch, RepoUrl); - - public IObservable Fetch( - string repositoryUrl, string commitish, string? subdirectory, string accessToken) - { - // Another repository is not this transport's business, and saying so BEFORE recording - // keeps the witness clean: the second test connects the partition to a different - // repository, and a sync attempt of THAT one must neither appear in FetchedRefs nor - // import anything over the package's content. A real client without a credential for it - // fails in exactly this place. - if (!string.Equals(repositoryUrl, RepoUrl, StringComparison.Ordinal)) - return Observable.Throw(new NotSupportedException( - $"This transport answers only for '{RepoUrl}'; '{repositoryUrl}' is another repository.")); - lock (fetched) - fetched.Add(commitish); - return commitish switch - { - "main" => Observable.Return(new RepoSnapshot(MainTipSha, Tree( - lesson: "# Lesson, as of main", quiz: "# Quiz, only on main", - moduleVersion: "tip-0000000000001", lessonHash: "L-main", quizHash: "Q-main"))), - SealedSha => Observable.Return(new RepoSnapshot(SealedSha, Tree( - lesson: "# Lesson, as sealed", quiz: null, - moduleVersion: "seal-000000000001", lessonHash: "L-seal", quizHash: null))), - _ => Observable.Throw(new InvalidOperationException( - $"The boot install asked the transport for '{commitish}', which is neither the branch nor the sealed commit.")), - }; - } - - private static IReadOnlyList Tree( - string lesson, string? quiz, string moduleVersion, string lessonHash, string? quizHash) - { - var files = new List - { - new($"{Package}/index.json", $$""" - { - "$type": "MeshNode", "id": "{{Package}}", "namespace": "", "path": "{{Package}}", - "mainNode": "{{Package}}", "name": "{{Package}}", "nodeType": "Space", "state": "Active", - "content": { - "$type": "PluginManifest", "description": "A course.", "minMeshVersion": "1.0.0", - "preInstalled": true - } - } - """), - new($"{Package}/Lesson.md", lesson), - }; - if (quiz is not null) - files.Add(new RepoFile($"{Package}/Quiz.md", quiz)); - // The CI-maintained sidecar: its hashes are compared manifest-to-manifest only, so any - // distinct strings do; what matters is that the sealed tree DECLARES no Quiz, which is - // what makes the lane prune it when it lands on the seal. - var quizEntry = quizHash is null ? "" : $", \"{Package}/Quiz.md\": \"{quizHash}\""; - files.Add(new RepoFile($"{Package}/{ModuleManifest.FileName}", $$""" - { - "module": "{{Package}}", "moduleVersion": "{{moduleVersion}}", "version": "1.0.0", - "files": { - "{{Package}}/index.json": "i-1", "{{Package}}/Lesson.md": "{{lessonHash}}"{{quizEntry}} - } - } - """)); - return files; - } - - private static IObservable NotUsed() => Observable.Throw( - new NotSupportedException("BootInstallLandsOnTheSealedCommitTest's repo client answers only Fetch.")); - - public IObservable Push(GitHubPushRequest request) => NotUsed(); - - public IObservable CreateBranch(GitHubCreateBranchRequest request) - => NotUsed(); - - public IObservable OpenPullRequest(GitHubOpenPullRequestRequest request) - => NotUsed(); - - public IObservable GetPullRequestStatus( - string repositoryUrl, int number, string accessToken) => NotUsed(); - - public IObservable> ListIssues( - string repositoryUrl, GitHubIssueState? state, string accessToken) - => NotUsed>(); - - public IObservable GetIssue(string repositoryUrl, int number, string accessToken) - => NotUsed(); - - public IObservable CreateIssue(GitHubCreateIssueRequest request) => NotUsed(); - - public IObservable CommentIssue( - string repositoryUrl, int number, string body, string accessToken) - => NotUsed(); - - public IObservable SetIssueState( - string repositoryUrl, int number, GitHubIssueState state, string accessToken) - => NotUsed(); - - public IObservable> ListPullRequests( - string repositoryUrl, PullRequestStatus? state, string accessToken) - => NotUsed>(); - - public IObservable GetPullRequestDetail( - string repositoryUrl, int number, string accessToken) => NotUsed(); - - public IObservable CommentPullRequest( - string repositoryUrl, int number, string body, string accessToken) - => NotUsed(); - - public IObservable MergePullRequest(GitHubMergePullRequestRequest request) - => NotUsed(); - } -} diff --git a/test/Memex.Portal.Shared.Test/BootstrapSecretGateTest.cs b/test/Memex.Portal.Shared.Test/BootstrapSecretGateTest.cs deleted file mode 100644 index fce193724c..0000000000 --- a/test/Memex.Portal.Shared.Test/BootstrapSecretGateTest.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Memex.Portal.Shared.Authentication; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The Bootstrap:Secret door, in both directions. -/// -/// This endpoint is anonymous-reachable by design — there is no administrator yet to -/// authorise it — and what it grants is platform admin on an instance nobody administers. So the -/// two properties worth pinning are that it stays SHUT (absent, wrong, or not configured at all) -/// and that the one accepted case is the configured secret, presented in either form. -/// -public class BootstrapSecretGateTest -{ - [Fact] - public void WithNoSecretConfiguredTheEndpointDoesNotExist() - { - // Disabled must not be confused with Missing: the controller answers 404 for the first and - // 401 for the second, so an unconfigured instance never admits that the door is there. - Assert.Equal(BootstrapAuthResult.Disabled, BootstrapSecretGate.Decide(null, "anything")); - Assert.Equal(BootstrapAuthResult.Disabled, BootstrapSecretGate.Decide("", "anything")); - Assert.Equal(BootstrapAuthResult.Disabled, BootstrapSecretGate.Decide(" ", "anything")); - } - - [Fact] - public void AConfiguredSecretAdmitsOnlyItself() - { - Assert.Equal(BootstrapAuthResult.Accepted, BootstrapSecretGate.Decide("s3cret", "s3cret")); - Assert.Equal(BootstrapAuthResult.Invalid, BootstrapSecretGate.Decide("s3cret", "s3crey")); - Assert.Equal(BootstrapAuthResult.Invalid, BootstrapSecretGate.Decide("s3cret", "s3cret ")); - Assert.Equal(BootstrapAuthResult.Invalid, BootstrapSecretGate.Decide("s3cret", "S3CRET")); - Assert.Equal(BootstrapAuthResult.Missing, BootstrapSecretGate.Decide("s3cret", null)); - Assert.Equal(BootstrapAuthResult.Missing, BootstrapSecretGate.Decide("s3cret", "")); - } - - [Fact] - public void ALengthMismatchIsRefusedRatherThanThrowing() - { - // FixedTimeEquals over byte arrays of different lengths must answer false, not throw: - // the prefix of a correct secret is the most likely wrong answer an attacker sends. - Assert.Equal(BootstrapAuthResult.Invalid, BootstrapSecretGate.Decide("s3cret", "s3")); - Assert.Equal(BootstrapAuthResult.Invalid, BootstrapSecretGate.Decide("s3cret", "s3cretatlength")); - Assert.False(BootstrapSecretGate.FixedTimeEquals("s3cret", "s3")); - Assert.True(BootstrapSecretGate.FixedTimeEquals("s3cret", "s3cret")); - } - - [Fact] - public void TheHeaderWinsAndOnlyTheQueryFormIsReportedAsSuch() - { - // The warning is about the transport, not the value — a caller that has moved to the header - // must not be scolded because a stale script still appends the query parameter. - Assert.Equal(("hdr", false), BootstrapSecretGate.Present("hdr", "qry")); - Assert.Equal(("qry", true), BootstrapSecretGate.Present(null, "qry")); - Assert.Equal(("qry", true), BootstrapSecretGate.Present("", "qry")); - Assert.Equal(("hdr", false), BootstrapSecretGate.Present("hdr", null)); - - // Nothing presented at all is not "from the query": there is no transport to complain about. - Assert.Equal((null, false), BootstrapSecretGate.Present(null, null)); - } - - [Fact] - public void AUnicodeSecretComparesByItsBytes() - { - // The gate encodes UTF-8 before comparing, so a non-ASCII secret is neither truncated nor - // silently equal to a different string with the same length in chars. - Assert.Equal(BootstrapAuthResult.Accepted, BootstrapSecretGate.Decide("pässwörd", "pässwörd")); - Assert.Equal(BootstrapAuthResult.Invalid, BootstrapSecretGate.Decide("pässwörd", "passwoerd")); - } -} diff --git a/test/Memex.Portal.Shared.Test/BoundedBodyTest.cs b/test/Memex.Portal.Shared.Test/BoundedBodyTest.cs deleted file mode 100644 index fe1aae54e4..0000000000 --- a/test/Memex.Portal.Shared.Test/BoundedBodyTest.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System.Text; -using Memex.Portal.Shared.Api; -using Microsoft.AspNetCore.Http; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -public class BoundedBodyTest -{ - [Fact] - public async Task Body_within_the_cap_is_returned_intact() - { - var text = new string('x', 1000); - using var s = new MemoryStream(Encoding.UTF8.GetBytes(text)); - Assert.Equal(text, await BoundedBody.ReadAsync(s, maxBytes: 1000, CancellationToken.None)); - } - - [Fact] - public async Task Body_over_the_cap_is_refused_not_buffered() - { - // No Content-Length is involved at all — this is the chunked-request shape. - using var s = new MemoryStream(Encoding.UTF8.GetBytes(new string('x', 1001))); - Assert.Null(await BoundedBody.ReadAsync(s, maxBytes: 1000, CancellationToken.None)); - } - - /// The contract is on bytes READ, not bytes buffered: an oversized body must be refused - /// after at most max+1 bytes have been pulled from the stream. Only a byte counter can tell an - /// enforced cap from a promised one — null comes back either way. - [Fact] - public async Task Never_reads_more_than_max_plus_one_bytes_from_the_stream() - { - var counting = new CountingStream(new MemoryStream(Encoding.UTF8.GetBytes(new string('x', 100_000)))); - Assert.Null(await BoundedBody.ReadAsync(counting, maxBytes: 1000, CancellationToken.None)); - Assert.True(counting.BytesRead <= 1001, $"read {counting.BytesRead} bytes; the contract is at most 1001"); - } - - private sealed class CountingStream(Stream inner) : Stream - { - public long BytesRead { get; private set; } - public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) - { var n = await inner.ReadAsync(buffer, ct); BytesRead += n; return n; } - public override int Read(byte[] buffer, int offset, int count) - { var n = inner.Read(buffer, offset, count); BytesRead += n; return n; } - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => throw new NotSupportedException(); - public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } - public override void Flush() { } - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - } - - [Fact] - public async Task Empty_body_is_empty_string_not_null() - { - using var s = new MemoryStream(); - Assert.Equal("", await BoundedBody.ReadAsync(s, maxBytes: 10, CancellationToken.None)); - } - - /// - /// The bytes form must be BYTE-EXACT. A signature is computed over what the sender sent, so a - /// body that is not valid UTF-8 must survive the read unchanged — reading it as a string and - /// re-encoding turns every invalid sequence into U+FFFD, and the HMAC would then be computed - /// over bytes nobody signed. 0xFF 0xFE is not valid UTF-8, which is exactly why it is the probe. - /// - [Fact] - public async Task ReadBytes_is_byte_exact_for_input_that_is_not_valid_utf8() - { - byte[] raw = [0x7B, 0xFF, 0xFE, 0x00, 0x7D]; - var got = await BoundedBody.ReadBytesAsync(new MemoryStream(raw), 1024, TestContext.Current.CancellationToken); - Assert.Equal(raw, got); - - // And the contrast that makes the point: the string form cannot round-trip these bytes. - var viaString = await BoundedBody.ReadAsync(new MemoryStream(raw), 1024, TestContext.Current.CancellationToken); - Assert.NotEqual(raw, System.Text.Encoding.UTF8.GetBytes(viaString!)); - } - - [Fact] - public async Task ReadBytes_over_the_cap_is_refused() - { - var over = new byte[65]; - var got = await BoundedBody.ReadBytesAsync(new MemoryStream(over), 64, TestContext.Current.CancellationToken); - Assert.Null(got); - } - - [Fact] - public async Task ReadBytes_at_exactly_the_cap_is_returned() - { - var exact = new byte[64]; - for (var i = 0; i < exact.Length; i++) exact[i] = (byte)i; - var got = await BoundedBody.ReadBytesAsync(new MemoryStream(exact), 64, TestContext.Current.CancellationToken); - Assert.Equal(exact, got); - } - - /// - /// 🚨 A TRUNCATED body is not an OVERSIZED one (#4860). When a client aborts mid-delivery, - /// Kestrel raises "Unexpected end of request content" from the body read. Swallowing that into - /// the null that means "over the cap" would answer 413 to a caller whose connection - /// dropped, and log a cap breach for a body far under the cap. The two outcomes must stay - /// distinguishable, so the exception propagates and the endpoints turn it into a 400. - /// - [Fact] - public async Task A_client_disconnect_mid_body_is_not_reported_as_over_the_cap() - { - // 8 bytes arrive, then the connection drops — far below a 1000-byte cap, so if this came - // back as null the caller could not tell it from a payload that was genuinely too large. - var truncated = new TruncatedStream(deliver: 8); - - await Assert.ThrowsAsync( - () => BoundedBody.ReadBytesAsync(truncated, maxBytes: 1000, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task The_string_form_keeps_the_same_distinction() - => await Assert.ThrowsAsync( - () => BoundedBody.ReadAsync(new TruncatedStream(deliver: 8), maxBytes: 1000, - TestContext.Current.CancellationToken)); - - /// Delivers bytes, then fails the way Kestrel does when the - /// peer goes away before the declared Content-Length has arrived. - private sealed class TruncatedStream(int deliver) : Stream - { - private int _remaining = deliver; - - public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) - { - if (_remaining <= 0) - throw new BadHttpRequestException("Unexpected end of request content.", 400); - var n = Math.Min(_remaining, buffer.Length); - buffer.Span[..n].Fill((byte)'x'); - _remaining -= n; - return ValueTask.FromResult(n); - } - - // Plain synchronous fill — never a blocking bridge over the async form - // (BlockingBridgeInTestRatchetGuard holds test/ at zero, and bridging the async form - // here would trip it while changing what the test measures). - public override int Read(byte[] buffer, int offset, int count) - { - if (_remaining <= 0) - throw new BadHttpRequestException("Unexpected end of request content.", 400); - var n = Math.Min(_remaining, count); - Array.Fill(buffer, (byte)'x', offset, n); - _remaining -= n; - return n; - } - - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => throw new NotSupportedException(); - public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } - public override void Flush() { } - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - } -} diff --git a/test/Memex.Portal.Shared.Test/BuildPrincipalAuthenticationTest.cs b/test/Memex.Portal.Shared.Test/BuildPrincipalAuthenticationTest.cs deleted file mode 100644 index a2fdffc109..0000000000 --- a/test/Memex.Portal.Shared.Test/BuildPrincipalAuthenticationTest.cs +++ /dev/null @@ -1,662 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.IO.Compression; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Hosting.SelfUpdate; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The build principal end to end (#2483): a GitHub Actions OIDC token, verified against a JWKS this -/// test controls, resolved to a BuildPrincipal node in the Admin partition, and finally -/// decided at the prebuilt-publication routes. -/// -/// 🚨 The refusal matrix is the point. Every negative below starts from a token that -/// WOULD be accepted and moves exactly one thing — the repository, the audience, the expiry, the -/// node, the scope — so a passing assertion can only mean that one thing was checked. A verifier -/// that checked the signature and stopped would authenticate every workflow run on GitHub. -/// -/// 🚨 And the third state. An unreadable JWKS answers 503, never 401: -/// "I could not find out" is not a denial and certainly not an admission (core #2901). The test -/// asserts the STATUS, because a build told "your identity is unknown" goes looking for a -/// credential that was never the problem. -/// -public class BuildPrincipalAuthenticationTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Audience = "https://registry.build-principal.test"; - private const string Repository = "Systemorph/MeshWeaver.SocialMedia"; - private const string DeclaredRepository = "Systemorph/MeshWeaver.Declared"; - private const string Identity = "s0123456789abcdef0123456789abcdef"; - private const string Source = "plugins"; - - private readonly GitHubTokenFactory tokens = new(); - - /// - public override async ValueTask DisposeAsync() - { - tokens.Dispose(); - await base.DisposeAsync(); - } - - /// What the JWKS leaf answers next. A test swaps it to stage a rotation or an outage. - /// Instance state, so it dies with the test class — never a static. - private volatile Func jwks = () => ""; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .AddUpdatePolicyType() - .ConfigureServices(services => services - // The audience is the one deployment knob the build-principal leg has, and an - // unconfigured one refuses everything — so a test that wants the leg live has to - // name it, exactly as an operator does. - .AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [BuildPrincipalConfiguration.AudienceConfigKey] = Audience, - // One repository this deployment DECLARES instead of holding a node for — - // in the key shape a portal's environment produces. The ids are the ones - // GitHubTokenFactory mints. - [$"{DeclaredBuildPrincipals.ConfigSection}:0:Repository"] = DeclaredRepository, - [$"{DeclaredBuildPrincipals.ConfigSection}:0:RepositoryId"] = "123456789", - [$"{DeclaredBuildPrincipals.ConfigSection}:0:RepositoryOwnerId"] = "9999", - [$"{DeclaredBuildPrincipals.ConfigSection}:0:Events:workflow_run:0"] = BuildVerbs.Verify, - [$"{DeclaredBuildPrincipals.ConfigSection}:0:EventRefs:workflow_run:0"] = "refs/heads/main", - [$"{DeclaredBuildPrincipals.ConfigSection}:0:Scopes:0"] = "verify:combo", - }) - .Build()) - // 🚨 TRANSIENT here, singleton in production. The service caches a key set for an - // hour on purpose; a per-request instance lets each fact stage its own JWKS (a - // rotation, an outage) without a Clear() for test isolation, which would be the tell - // of an unfixed root cause. The cache and refresh-floor rules are pinned separately - // below, against one instance driven directly. - .AddTransient(sp => new GitHubOidcKeyService( - sp.GetRequiredService(), - sp.GetRequiredService>()) - { - FetchOverride = _ => Task.FromResult(jwks()), - })); - - private InstanceRegistryAuthenticator Authenticator() => new( - Mesh, Mesh.ServiceProvider.GetRequiredService>()); - - private Task Authenticate(string token, CancellationToken cancellationToken = default) => - Authenticator().AuthenticateOutcome($"Bearer {token}") - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(cancellationToken); - - /// - /// The answer once it satisfies . The authenticator reads the principal - /// from the process-wide node mirror, which the change feed keeps current: an UPDATE of a node - /// the mirror already holds is seen when the feed delivers it, not when the write returns. So a - /// read that follows an update waits on the answer it is about, never on the write. - /// - private Task AuthenticateUntil( - string token, Func seen, CancellationToken cancellationToken = default) => - Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .Select(_ => Authenticator().AuthenticateOutcome($"Bearer {token}").FirstAsync()) - .Concat() - .Where(seen) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(cancellationToken); - - /// Writes a build principal into the Admin partition — the create a global admin - /// makes. As System, because the Admin partition is exactly what an ordinary identity cannot - /// write. - private Task Grant(BuildPrincipal principal, string? repository = null, - CancellationToken cancellationToken = default) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var id = BuildPrincipal.NodeId(repository ?? principal.Repository); - var node = new MeshNode(id, BuildPrincipal.Namespace) - { - Name = $"Build principal: {principal.Repository}", - NodeType = MeshWeaverInstanceNodeType.BuildPrincipalNodeType, - State = MeshNodeState.Active, - Content = principal, - }; - return access.RunAsSystem(() => meshService.CreateOrUpdateNode(node)) - .Timeout(TestTimeouts.Convergence).Await(cancellationToken); - } - - private static BuildPrincipal Principal(string repository = Repository) => new() - { - Repository = repository, - Scopes = [$"{BuildVerbs.Fetch}:{Source}", $"{BuildVerbs.Publish}:socialmedia"], - Events = new Dictionary> - { - ["push"] = [BuildVerbs.Fetch, BuildVerbs.Publish], - ["pull_request"] = [BuildVerbs.Fetch], - }, - IssuedBy = "admin", - IssuedAt = DateTimeOffset.UtcNow.AddDays(-1), - }; - - // ── the authenticator ──────────────────────────────────────────────────── - - [Fact(Timeout = 240_000)] - public async Task AVerifiedToken_ResolvesToItsPrincipal_AndOnlyWhenOneExists() - { - jwks = () => tokens.Jwks(); - var repository = "Systemorph/MeshWeaver.NoPrincipalYet"; - - // 1. No node for the repository → a DEFINITIVE negative. A verified signature is not an - // authorization: this is the "no node = 401" the design names. - var stranger = await Authenticate(tokens.Mint(Audience, repository: repository), TestContext.Current.CancellationToken); - Assert.False(stranger.IsUnavailable); - Assert.Null(stranger.Instance); - Assert.Null(stranger.Build); - - // 2. Same token, once a global admin has written the rule → authenticated. - await Grant(Principal(repository)); - var trusted = await Authenticate(tokens.Mint(Audience, repository: repository)); - Assert.False(trusted.IsUnavailable); - Assert.NotNull(trusted.Build); - Assert.Equal(repository, trusted.Build!.Repository); - // 🚨 …and it is NOT an instance. Every surface that needs an installation keeps refusing it. - Assert.Null(trusted.Instance); - } - - [Fact(Timeout = 240_000)] - public async Task ANodeThatNamesAnotherRepository_DoesNotAuthenticateTheToken() - { - // The path is a routing hint; the RECORD is the authority. A node hand-edited (or migrated) - // so its path and its declared repository disagree must authenticate nobody. - jwks = () => tokens.Jwks(); - var repository = "Systemorph/MeshWeaver.PathDrift"; - await Grant(Principal("Systemorph/SomethingElse"), repository, TestContext.Current.CancellationToken); - - var result = await Authenticate(tokens.Mint(Audience, repository: repository)); - - Assert.False(result.IsUnavailable); - Assert.Null(result.Build); - } - - [Fact(Timeout = 240_000)] - public async Task ARevokedPrincipal_StopsAuthenticatingAtOnce() - { - jwks = () => tokens.Jwks(); - var repository = "Systemorph/MeshWeaver.Revoked"; - await Grant(Principal(repository), cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull((await Authenticate(tokens.Mint(Audience, repository: repository))).Build); - - // The control-plane verb — no watcher stands between writing it and the refusal. - await Grant(Principal(repository) with { RequestedAction = BuildPrincipalActions.Revoke }); - - var result = await Authenticate(tokens.Mint(Audience, repository: repository)); - Assert.False(result.IsUnavailable); - Assert.Null(result.Build); - } - - [Fact(Timeout = 240_000)] - public async Task AWrongAudienceOrAnExpiredToken_IsRefused_NotUnavailable() - { - jwks = () => tokens.Jwks(); - await Grant(Principal(), cancellationToken: TestContext.Current.CancellationToken); - - foreach (var token in new[] - { - tokens.Mint("api://AzureADTokenExchange"), - tokens.Mint(Audience, issuedAt: DateTimeOffset.UtcNow.AddHours(-2)), - tokens.Mint(Audience, issuer: "https://evil.example"), - }) - { - var result = await Authenticate(token); - Assert.False(result.IsUnavailable, "a bad token is a VERDICT, not an outage"); - Assert.Null(result.Build); - } - } - - [Fact(Timeout = 240_000)] - public async Task AnUnreachableKeySet_IsUNDETERMINED_NeverUnknownToken() - { - // 🚨 Fail closed AND distinguishable. The build is told "retry", not "your identity is - // unknown" — the latter sends an operator hunting a credential that was never the problem - // (the #2695 shape, on the leg that did not exist yet). - jwks = () => throw new HttpRequestException("JWKS unreachable"); - await Grant(Principal(), cancellationToken: TestContext.Current.CancellationToken); - - var result = await Authenticate(tokens.Mint(Audience)); - - Assert.True(result.IsUnavailable); - Assert.Null(result.Build); - Assert.Null(result.Instance); - } - - [Fact(Timeout = 240_000)] - public async Task AKeySetThatPublishesNothingUsable_IsUNDETERMINED_NotAnAdmission() - { - // An empty key set would refuse everything, which is correct — but it must not be REMEMBERED - // for an hour as if it were a valid answer, so the read throws and stays retryable. - jwks = () => """{"keys":[]}"""; - await Grant(Principal(), cancellationToken: TestContext.Current.CancellationToken); - - var result = await Authenticate(tokens.Mint(Audience)); - - Assert.True(result.IsUnavailable); - Assert.Null(result.Build); - } - - // ── the key service's own cache ────────────────────────────────────────── - - [Fact(Timeout = 240_000)] - public async Task TheKeySetIsReadOnce_AFaultIsNeverCached_AndTheRefreshFloorBoundsRe_Reads() - { - var reads = 0; - var document = tokens.Jwks(); - var fail = true; - var service = new GitHubOidcKeyService( - Mesh, Mesh.ServiceProvider.GetRequiredService>()) - { - FetchOverride = _ => - { - Interlocked.Increment(ref reads); - return fail - ? Task.FromException(new HttpRequestException("down")) - : Task.FromResult(document); - }, - }; - var now = DateTimeOffset.UtcNow; - - // A failure propagates AND is not cached — the next caller starts a genuinely new attempt - // rather than replaying a latched OnError (the ReplaySubject trap, #1369). - await Assert.ThrowsAnyAsync(() => - service.Keys(now).FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken)); - await Assert.ThrowsAnyAsync(() => - service.Keys(now).FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken)); - Assert.Equal(2, reads); - - // 🚨 A ROTATION ARRIVING WHILE THE CACHE IS EMPTY. The fault above evicted the promise, so - // this refresh has nothing to replace — and must still start a real read rather than letting - // a null observable escape into the authenticator's SelectMany, where the failure would - // surface far from its cause (Copilot review, PR #2988). - fail = false; - var afterFault = await service - .Refresh(new GitHubSigningKeys( - new Dictionary(), now - TimeSpan.FromHours(1)), now) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - Assert.Single(afterFault.ByKeyId); - Assert.Equal(3, reads); - - var first = await service.Keys(now).FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - Assert.Single(first.ByKeyId); - // …and it was SHARED with the refresh above rather than starting a round trip of its own. - Assert.Equal(3, reads); - - // A success is shared: a second caller inside the window costs no round trip. - await service.Keys(now).FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - Assert.Equal(3, reads); - - // 🚨 The refresh floor. An unknown kid may force ONE early re-read; inside the floor it - // returns the set unchanged, so a caller inventing key ids cannot amplify into a fetch per - // request. - var suppressed = await service.Refresh(first, now).FirstAsync() - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - Assert.Equal(3, reads); - Assert.Equal(first.FetchedAt, suppressed.FetchedAt); - - // Past the floor it really does re-read — a rotation is recoverable without a restart. - await service.Refresh(first, now + GitHubOidcKeyService.MinimumRefreshInterval + TimeSpan.FromSeconds(1)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - Assert.Equal(4, reads); - - // And a stale set is re-read on the ordinary path too. - await service.Keys(now + GitHubOidcKeyService.CacheDuration + TimeSpan.FromMinutes(1)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - Assert.Equal(5, reads); - } - - // ── the endpoint ───────────────────────────────────────────────────────── - - [Fact(Timeout = 240_000)] - public async Task ThePrebuiltRoutesServeATrustedBuild_AndRefuseEveryoneElse() - { - jwks = () => tokens.Jwks(); - var root = Path.Combine(Path.GetTempPath(), "mw-buildprincipal-" + Guid.NewGuid().ToString("N")); - var dir = Path.Combine(root, Identity, Source); - Directory.CreateDirectory(dir); - try - { - WriteBundle(Path.Combine(dir, "Store.zip")); - File.WriteAllText( - Path.Combine(dir, ShippedPrebuiltBundles.CompletionSentinelFileName), "Store.zip\n"); - - await Grant(Principal(), cancellationToken: TestContext.Current.CancellationToken); - await using var app = await StartHost(root); - var route = $"/api/plugins/bundles/prebuilt/{Identity}/{Source}"; - - // 1. the designed case — a push from the trusted repo, holding fetch:plugins - var ok = await Get(app, route, tokens.Mint(Audience)); - Assert.Equal(HttpStatusCode.OK, ok.StatusCode); - Assert.Contains("Store.zip", await ok.Content.ReadAsStringAsync()); - var bytes = await Get(app, route + "/Store.zip", tokens.Mint(Audience)); - Assert.Equal(HttpStatusCode.OK, bytes.StatusCode); - - // 2. 🚨 a valid signature from ANOTHER repository — 401, because no rule names it - var other = await Get(app, route, tokens.Mint(Audience, repository: "Systemorph/Evil")); - Assert.Equal(HttpStatusCode.Unauthorized, other.StatusCode); - - // 3. the right repository on an event its rule does not carry → 403, never the bytes - var wrongEvent = await Get(app, route, tokens.Mint(Audience, eventName: "workflow_dispatch")); - Assert.Equal(HttpStatusCode.Forbidden, wrongEvent.StatusCode); - - // 4. a source the principal holds no fetch scope for → 403 - var otherSource = await Get( - app, $"/api/plugins/bundles/prebuilt/{Identity}/education", tokens.Mint(Audience)); - Assert.Equal(HttpStatusCode.Forbidden, otherSource.StatusCode); - - // 5. 🚨 a build principal is NOT an installation: every other bundle route still 401s, - // because it decides per package against a grant and a plan a build does not have. - var index = await Get(app, "/api/plugins/bundles/index.json", tokens.Mint(Audience)); - Assert.Equal(HttpStatusCode.Unauthorized, index.StatusCode); - - // 6. an unreadable key set is 503 + Retry-After, never the 401 a bad token gets - jwks = () => throw new HttpRequestException("JWKS unreachable"); - var unavailable = await Get(app, route, tokens.Mint(Audience)); - Assert.Equal(HttpStatusCode.ServiceUnavailable, unavailable.StatusCode); - } - finally - { - try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } - } - } - - [Fact(Timeout = 240_000)] - public async Task ComboVerification_BuildCanReadAndRecord_WithoutChangingDisabledUpdates() - { - jwks = () => tokens.Jwks(); - var principal = VerificationPrincipal(); - await Grant(principal); - var access = Mesh.ServiceProvider.GetRequiredService(); - await access.RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService() - .CreateOrUpdateNode(new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - Name = "Update policy", NodeType = UpdatePolicyNodeType.NodeType, - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = UpdatePolicyKind.None, - LatestAvailableTag = "3.0.0-ci.123" }, - })).Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - await using var app = await StartHost(Path.GetTempPath()); - var token = tokens.Mint(Audience, eventName: "workflow_run"); - using var combo = await Get(app, ReleaseGateEndpoints.ComboRoute, token); - Assert.Equal(HttpStatusCode.OK, combo.StatusCode); - Assert.Contains("isComplete", await combo.Content.ReadAsStringAsync()); - - var verdict = new ComboVerification - { - CandidateTag = "3.0.0-ci.123", VerifiedAt = DateTimeOffset.UtcNow, - Verdict = ComboVerdictKind.NotVerifiable, Caveats = ["Test could not execute the candidate"], - }; - using var recorded = await PostVerdict(app, token, - JsonSerializer.Serialize(verdict, InstanceComboAssembler.Json)); - Assert.Equal(HttpStatusCode.OK, recorded.StatusCode); - Assert.Contains("\"recorded\":true", await recorded.Content.ReadAsStringAsync()); - var policy = await access.RunAsSystem(() => Mesh.GetWorkspace() - .GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Select(n => UpdatePolicyNodeType.Parse(n, Mesh.JsonSerializerOptions))) - .Where(c => c.VerificationFor(verdict.CandidateTag)?.VerifiedAt == verdict.VerifiedAt) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - Assert.Equal(UpdatePolicyKind.None, policy.Policy); - Assert.Equal("3.0.0-ci.123", policy.LatestAvailableTag); - Assert.Equal(ComboVerdictKind.NotVerifiable, policy.VerificationFor(verdict.CandidateTag)!.Verdict); - - // Revocation is observed on the very next request, including the write route. - await Grant(principal with { RequestedAction = BuildPrincipalActions.Revoke }); - using var revoked = await PostVerdict(app, token, "{}"); - Assert.Equal(HttpStatusCode.Unauthorized, revoked.StatusCode); - } - - [Theory(Timeout = 240_000)] - [InlineData("scope")] - [InlineData("event")] - [InlineData("ref")] - [InlineData("repository")] - [InlineData("repositoryId")] - [InlineData("ownerId")] - [InlineData("audience")] - [InlineData("expired")] - public async Task ComboVerification_RefusesAChangedTrustInput(string changed) - { - jwks = () => tokens.Jwks(); - var principal = VerificationPrincipal(); - if (changed == "scope") principal = principal with { Scopes = ["fetch:plugins"] }; - if (changed == "ownerId") principal = principal with { RepositoryOwnerId = "wrong-owner" }; - await Grant(principal, cancellationToken: TestContext.Current.CancellationToken); - var token = tokens.Mint(changed == "audience" ? "https://another.portal.test" : Audience, - repository: changed == "repository" ? "Systemorph/AnotherRepo" : Repository, - repositoryId: changed == "repositoryId" ? "wrong-id" : "123456789", - eventName: changed == "event" ? "pull_request" : "workflow_dispatch", - gitRef: changed == "ref" ? "refs/heads/feature" : "refs/heads/main", - issuedAt: changed == "expired" ? DateTimeOffset.UtcNow.AddHours(-1) : null); - await using var app = await StartHost(Path.GetTempPath()); - foreach (var route in new[] { ReleaseGateEndpoints.ComboRoute, - ReleaseGateEndpoints.SelectRoute, ReleaseGateEndpoints.Route }) - { - using var denied = await Get(app, route, token); - Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode); - } - // Even malformed input must be refused as unauthorized, before the body is touched. - using var write = await PostVerdict(app, token, "not JSON"); - Assert.Equal(HttpStatusCode.Unauthorized, write.StatusCode); - } - - [Theory(Timeout = 240_000)] - [InlineData("null")] - [InlineData("{}")] - [InlineData("not JSON")] - [InlineData("{\"candidateTag\":\"3.0.0-ci.1\",\"verifiedAt\":\"2026-09-10T01:00:00Z\",\"verdict\":\"Green\"}")] - public async Task ComboVerification_RefusesAnInvalidOrEmptyGreenVerdict(string body) - { - jwks = () => tokens.Jwks(); - await Grant(VerificationPrincipal(), cancellationToken: TestContext.Current.CancellationToken); - await using var app = await StartHost(Path.GetTempPath()); - using var response = await PostVerdict(app, - tokens.Mint(Audience, eventName: "workflow_run"), body); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - /// - /// 🚨 A retired module (#3848, policy record-served-commit-on-install) is reported APART - /// from the verified ones: a verdict that lists the same module as verified AND as retired is - /// internally inconsistent and the recording route refuses it (400), while the same verdict - /// with a disjoint retired list is recorded — the control that the 400 is about the overlap, - /// and about nothing else in the body. - /// - [Fact(Timeout = 240_000)] - public async Task ComboVerification_RefusesAModuleBothVerifiedAndRetired_AndRecordsADisjointOne() - { - jwks = () => tokens.Jwks(); - await Grant(VerificationPrincipal(), cancellationToken: TestContext.Current.CancellationToken); - var access = Mesh.ServiceProvider.GetRequiredService(); - await access.RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService() - .CreateOrUpdateNode(new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - Name = "Update policy", NodeType = UpdatePolicyNodeType.NodeType, - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = UpdatePolicyKind.None }, - })).Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - await using var app = await StartHost(Path.GetTempPath()); - var token = tokens.Mint(Audience, eventName: "workflow_run"); - - ComboVerification Verdict(string retiredId) => new() - { - CandidateTag = "3.0.0-ci.456", VerifiedAt = DateTimeOffset.UtcNow, - Verdict = ComboVerdictKind.NotVerifiable, Caveats = ["Test could not execute the candidate"], - Modules = - [ - new ModuleVerification - { - ModuleId = "Widget", Outcome = ModuleVerificationOutcome.NotVerified, - Failures = ["the gate did not run"], - }, - ], - RetiredModules = [new RetiredModule { ModuleId = retiredId, Reason = "gone at HEAD" }], - }; - - using var overlapping = await PostVerdict(app, token, - JsonSerializer.Serialize(Verdict("widget"), InstanceComboAssembler.Json)); - Assert.Equal(HttpStatusCode.BadRequest, overlapping.StatusCode); - - using var disjoint = await PostVerdict(app, token, - JsonSerializer.Serialize(Verdict("PlatformUI"), InstanceComboAssembler.Json)); - Assert.Equal(HttpStatusCode.OK, disjoint.StatusCode); - Assert.Contains("\"recorded\":true", await disjoint.Content.ReadAsStringAsync()); - } - - // ── a principal DECLARED in configuration (policy declared-not-derived) ─── - - [Fact(Timeout = 240_000)] - public async Task ADeclaredPrincipal_ReachesTheComboRoutes_WithNoNode_AndOnlyAsDeclared() - { - // 🚨 No Grant(...) anywhere in this fact: nothing under Admin/_BuildPrincipal names the - // repository. The only thing that trusts it is the deployment's configuration. - jwks = () => tokens.Jwks(); - var access = Mesh.ServiceProvider.GetRequiredService(); - await access.RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService() - .CreateOrUpdateNode(new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - Name = "Update policy", NodeType = UpdatePolicyNodeType.NodeType, - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = UpdatePolicyKind.None }, - })).Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - var designed = tokens.Mint(Audience, repository: DeclaredRepository, eventName: "workflow_run"); - var authenticated = await Authenticate(designed); - Assert.False(authenticated.IsUnavailable); - Assert.NotNull(authenticated.Build); - Assert.Null(authenticated.Instance); - Assert.Equal($"{DeclaredBuildPrincipals.ConfigSection}:0", authenticated.Build!.PrincipalPath); - Assert.True(authenticated.Build.Allows(BuildVerbs.Verify, "combo")); - Assert.False(authenticated.Build.Allows(BuildVerbs.Fetch, Source)); - - await using var app = await StartHost(Path.GetTempPath()); - using var combo = await Get(app, ReleaseGateEndpoints.ComboRoute, designed); - Assert.Equal(HttpStatusCode.OK, combo.StatusCode); - - // The refusal matrix, one input moved at a time from the token that was just admitted. - foreach (var refused in new[] - { - tokens.Mint(Audience, repository: DeclaredRepository, eventName: "push"), - tokens.Mint(Audience, repository: DeclaredRepository, eventName: "workflow_run", - gitRef: "refs/heads/feature"), - tokens.Mint(Audience, repository: DeclaredRepository, eventName: "workflow_run", - repositoryId: "1"), - tokens.Mint(Audience, repository: DeclaredRepository + ".Evil", eventName: "workflow_run"), - tokens.Mint("https://another.portal.test", repository: DeclaredRepository, - eventName: "workflow_run"), - }) - { - using var denied = await Get(app, ReleaseGateEndpoints.ComboRoute, refused); - Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode); - } - - // A declaration grants verify:combo and nothing else — the publication routes stay shut. - // 403, exactly: the declared principal authenticated and was refused for the scope it lacks. - // Anything else (a 401, a 404, a 500) would mean the request never reached that decision. - using var fetch = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}", designed); - Assert.Equal(HttpStatusCode.Forbidden, fetch.StatusCode); - } - - [Fact(Timeout = 240_000)] - public async Task ANode_OverridesItsDeclaration_SoARevokeTakesEffectWithoutARoll() - { - jwks = () => tokens.Jwks(); - var token = tokens.Mint(Audience, repository: DeclaredRepository, eventName: "workflow_run"); - Assert.NotNull((await Authenticate(token, TestContext.Current.CancellationToken)).Build); - - // A node narrower than the declaration: the NODE decides, so the declared scope is gone. - await Grant(VerificationPrincipal() with { Repository = DeclaredRepository, Scopes = ["fetch:plugins"] }); - var narrowed = await Authenticate(token); - Assert.NotNull(narrowed.Build); - Assert.Equal(BuildPrincipal.PathFor(DeclaredRepository), narrowed.Build!.PrincipalPath); - Assert.False(narrowed.Build.Allows(BuildVerbs.Verify, "combo")); - - // …and a revoking node stops the declared principal with no roll: as soon as the mirror the - // authenticator reads has the update, the token is refused — by a verdict, not by a read - // that could not be made. - await Grant(VerificationPrincipal() with - { - Repository = DeclaredRepository, RequestedAction = BuildPrincipalActions.Revoke, - }); - var revoked = await AuthenticateUntil(token, r => r.Build is null, TestContext.Current.CancellationToken); - Assert.False(revoked.IsUnavailable); - } - - private static BuildPrincipal VerificationPrincipal() => Principal() with - { - RepositoryId = "123456789", RepositoryOwnerId = "9999", Scopes = ["verify:combo"], - Events = new Dictionary> - { - ["workflow_run"] = [BuildVerbs.Verify], ["workflow_dispatch"] = [BuildVerbs.Verify], - }, - EventRefs = new Dictionary> - { - ["workflow_run"] = ["refs/heads/main"], ["workflow_dispatch"] = ["refs/heads/main"], - }, - }; - - private static async Task PostVerdict(WebApplication app, string token, string body) - { - using var request = new HttpRequestMessage(HttpMethod.Post, ReleaseGateEndpoints.VerificationRoute); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}"); - request.Content = new StringContent(body, Encoding.UTF8, "application/json"); - return await app.GetTestClient().SendAsync(request); - } - - private static void WriteBundle(string path) - { - using var zip = ZipFile.Open(path, ZipArchiveMode.Create); - using var writer = new StreamWriter(zip.CreateEntry("meshweaver/manifest.json").Open()); - writer.Write("""{"plugin":"Store"}"""); - } - - private async Task StartHost(string publishedRoot) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [PublishedBundleCatalogue.PublishedRootConfigKey] = publishedRoot, - }); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(new InstanceRegistryAuthenticator( - Mesh, Mesh.ServiceProvider.GetRequiredService>())); - builder.Services.AddSingleton(new InstanceComboReader(Mesh)); - var app = builder.Build(); - app.MapPluginBundles(); - app.MapReleaseGate(); - await app.StartAsync(); - return app; - } - - private static async Task Get(WebApplication app, string route, string token) - { - using var request = new HttpRequestMessage(HttpMethod.Get, route); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}"); - return await app.GetTestClient().SendAsync(request); - } -} diff --git a/test/Memex.Portal.Shared.Test/BuildPrincipalDecisionTest.cs b/test/Memex.Portal.Shared.Test/BuildPrincipalDecisionTest.cs deleted file mode 100644 index 5ff63c0e49..0000000000 --- a/test/Memex.Portal.Shared.Test/BuildPrincipalDecisionTest.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System; -using System.Collections.Generic; -using MeshWeaver.Mesh.Security; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The AUTHORIZATION half of the build principal (#2483) — the rule a verified GitHub Actions token -/// is decided against, once the signature has established only WHICH repository asked. -/// -/// The three steps the design names, pinned one at a time: repository claim → node, -/// event_name claim → allowed verbs, requested verb:source ∈ scopes. Every -/// refusal below starts from the allowed case and moves exactly one thing, so a passing assertion -/// can only mean that one thing was checked. -/// -public class BuildPrincipalDecisionTest -{ - private static readonly DateTimeOffset Now = new(2026, 9, 1, 12, 0, 0, TimeSpan.Zero); - private const string Repository = "Systemorph/MeshWeaver.SocialMedia"; - - private static BuildPrincipal Principal( - string repository = Repository, - string[]? scopes = null, - IReadOnlyDictionary>? events = null, - IReadOnlyDictionary>? eventRefs = null) => - new() - { - Repository = repository, - Scopes = scopes ?? ["publish:socialmedia", "fetch:plugins"], - Events = events ?? new Dictionary> - { - ["push"] = ["publish", "fetch"], - ["pull_request"] = ["fetch"], - }, - EventRefs = eventRefs ?? new Dictionary>(), - IssuedBy = "admin", - IssuedAt = Now.AddDays(-1), - }; - - private static GitHubBuildClaims Claims( - string repository = Repository, - string eventName = "push", - string gitRef = "refs/heads/main", - string? repositoryId = "123456789") => - new() - { - Repository = repository, - RepositoryId = repositoryId, - RepositoryOwnerId = "9999", - EventName = eventName, - Ref = gitRef, - }; - - [Fact] - public void TheDesignedCase_Allows() - { - Assert.True(Principal().Allows(Claims(), BuildVerbs.Fetch, "plugins", Now)); - Assert.True(Principal().Allows(Claims(), BuildVerbs.Publish, "socialmedia", Now)); - } - - [Fact] - public void AnotherRepositorysToken_IsRefused() - { - // 🚨 THE assertion this whole file exists for. Every workflow on GitHub holds a token with a - // valid signature; only the repository claim tells them apart. A prefix or wildcard match - // here would authorize any repository whose name merely starts the same way. - Assert.False(Principal().Allows(Claims("Systemorph/MeshWeaver.Evil"), BuildVerbs.Fetch, "plugins", Now)); - Assert.False(Principal().Allows(Claims("Evil/MeshWeaver.SocialMedia"), BuildVerbs.Fetch, "plugins", Now)); - Assert.Contains("repository", Principal().Refuse( - Claims("Systemorph/MeshWeaver.Evil"), BuildVerbs.Fetch, "plugins", Now)!); - } - - [Fact] - public void TheImmutableClaimFormatStillMatchesAClassicallyWrittenPrincipal() - { - // The migration hazard: GitHub moving the org to immutable ids must not silently stop every - // build principal in the fleet from authenticating. - Assert.True(Principal().Allows( - Claims("Systemorph@12345/MeshWeaver.SocialMedia@67890"), BuildVerbs.Fetch, "plugins", Now)); - } - - [Fact] - public void AnUnlistedEvent_IsRefused() - { - Assert.False(Principal().Allows(Claims(eventName: "workflow_dispatch"), BuildVerbs.Fetch, "plugins", Now)); - Assert.False(Principal().Allows(Claims(eventName: ""), BuildVerbs.Fetch, "plugins", Now)); - } - - [Fact] - public void AVerbTheEventDoesNotCarry_IsRefused() - { - // The design's whole point: `push` on main may publish; a pull request may only fetch. - Assert.True(Principal().Allows(Claims(eventName: "pull_request"), BuildVerbs.Fetch, "plugins", Now)); - Assert.False(Principal().Allows( - Claims(eventName: "pull_request"), BuildVerbs.Publish, "socialmedia", Now)); - } - - [Fact] - public void AScopeForAnotherSource_IsRefused_AndThereIsNoWildcard() - { - Assert.False(Principal().Allows(Claims(), BuildVerbs.Fetch, "education", Now)); - // A principal cannot buy itself every source by writing a star: `fetch:*` is a literal - // scope for a source literally named "*", which no registry has. - Assert.False(Principal(scopes: ["fetch:*"]).Allows(Claims(), BuildVerbs.Fetch, "plugins", Now)); - // …and publishing is not implied by fetching. - Assert.False(Principal(scopes: ["fetch:plugins"]).Allows(Claims(), BuildVerbs.Publish, "plugins", Now)); - } - - [Fact] - public void AScopeIsCaseInsensitiveOnBothHalves() - { - // Source names are operator-typed config values, matched case-insensitively everywhere else - // in the grant model (PluginGrantEntry.Source) — this must not be the one place they are not. - Assert.True(Principal(scopes: ["Fetch:Plugins"]).Allows(Claims(), BuildVerbs.Fetch, "plugins", Now)); - Assert.True(Principal().Allows(Claims(), "FETCH", "PLUGINS", Now)); - } - - [Fact] - public void ARefPin_AppliesToTheEventThatDeclaresIt_AndOnlyThat() - { - var pinned = Principal(eventRefs: new Dictionary> - { - ["push"] = ["refs/heads/main"], - }); - - Assert.True(pinned.Allows(Claims(), BuildVerbs.Publish, "socialmedia", Now)); - // A push to somebody's feature branch is not a push to main. - Assert.False(pinned.Allows( - Claims(gitRef: "refs/heads/feat/anything"), BuildVerbs.Publish, "socialmedia", Now)); - // The pull_request event declares no pin — its ref is refs/pull//merge and cannot be - // enumerated in advance, so it must stay unconstrained rather than silently refuse. - Assert.True(pinned.Allows( - Claims(eventName: "pull_request", gitRef: "refs/pull/17/merge"), BuildVerbs.Fetch, "plugins", Now)); - } - - [Fact] - public void RevocationTakesEffectImmediately_OnEitherForm() - { - // 🚨 No watcher stands between writing the revoke and the refusal. A security stop that - // waits for a reactor is a security stop with a window. - Assert.False((Principal() with { RequestedAction = BuildPrincipalActions.Revoke }) - .Allows(Claims(), BuildVerbs.Fetch, "plugins", Now)); - Assert.False((Principal() with { RequestedAction = "revoke" }) - .Allows(Claims(), BuildVerbs.Fetch, "plugins", Now)); - Assert.False((Principal() with { IsRevoked = true }) - .Allows(Claims(), BuildVerbs.Fetch, "plugins", Now)); - } - - [Fact] - public void AnEndedTerm_IsRefused() - { - Assert.False((Principal() with { ExpiresAt = Now.AddMinutes(-1) }) - .Allows(Claims(), BuildVerbs.Fetch, "plugins", Now)); - Assert.True((Principal() with { ExpiresAt = Now.AddMinutes(1) }) - .Allows(Claims(), BuildVerbs.Fetch, "plugins", Now)); - } - - [Fact] - public void AnImmutableIdPin_MustMatchExactly_WhenSet() - { - var pinned = Principal() with { RepositoryId = "123456789" }; - - Assert.True(pinned.Allows(Claims(), BuildVerbs.Fetch, "plugins", Now)); - // A repository renamed away and its name re-registered by someone else carries a different - // immutable id — which is the entire reason the pin exists. - Assert.False(pinned.Allows(Claims(repositoryId: "987654321"), BuildVerbs.Fetch, "plugins", Now)); - Assert.False(pinned.Allows(Claims(repositoryId: null), BuildVerbs.Fetch, "plugins", Now)); - // Unpinned stays unpinned — the ordinary case must not start demanding an id. - Assert.True(Principal().Allows(Claims(repositoryId: null), BuildVerbs.Fetch, "plugins", Now)); - } - - [Fact] - public void AnEmptyPrincipal_GrantsNothing() - { - // The default-constructed shape — what a half-written node deserializes to — must authorize - // nothing at all rather than everything. - var empty = new BuildPrincipal { Repository = Repository }; - - Assert.False(empty.Allows(Claims(), BuildVerbs.Fetch, "plugins", Now)); - Assert.False(empty.Allows(null, BuildVerbs.Fetch, "plugins", Now)); - Assert.False(Principal().Allows(Claims(), "", "plugins", Now)); - Assert.False(Principal().Allows(Claims(), BuildVerbs.Fetch, "", Now)); - } -} diff --git a/test/Memex.Portal.Shared.Test/BundleServeRaceIsTransientTest.cs b/test/Memex.Portal.Shared.Test/BundleServeRaceIsTransientTest.cs deleted file mode 100644 index abedbf2a64..0000000000 --- a/test/Memex.Portal.Shared.Test/BundleServeRaceIsTransientTest.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.IO; -using System.Reactive; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using MeshWeaver.Fixture; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.Reactive.Assertions; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#3876 (reopened 2026-09-26) — a bundle whose resolved bytes are removed before the -/// archive opens them answers 503 + Retry-After, never an unhandled 500. -/// -/// The production shape, measured on memex-cloud (pod -/// memex-portal-deployment-6c7669df84-9b4rz, six occurrences): the bundle route resolved -/// Collaboration_Review's assembly to /data/assembly-cache/…/v1172-….dll, the assembly -/// store's eviction (newest three versions kept per type) removed it, and -/// NuGetPackageWriter.Write → File.OpenRead threw FileNotFoundException past -/// every handler. Reproduced here byte for byte: a real archive write whose entry opens a real file -/// that was deleted after it was resolved. -/// -public class BundleServeRaceIsTransientTest : IDisposable -{ - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-bundle-serve-race-" + Guid.NewGuid().ToString("N")); - - /// Creates the temp root. - public BundleServeRaceIsTransientTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - /// The removed file is a 503 with Retry-After. - [Theory] - [InlineData(false)] // the file is gone - [InlineData(true)] // its whole directory is gone - public async Task BytesRemovedBetweenResolveAndOpen_AreTransient(bool directoryGone) - { - var ct = TestContext.Current.CancellationToken; - var http = new DefaultHttpContext(); - - var result = await ArchiveOfAResolvedPath(directoryGone) - .TransientWhenServedBytesMoved(http, "Collaboration", "1.2.3", logger: null) - .Should().Within(TestTimeouts.Quick) - .Emit("the removal must become an answer, not an escaping fault", cancellationToken: ct); - - var status = Assert.IsAssignableFrom(result); - Assert.Equal(StatusCodes.Status503ServiceUnavailable, status.StatusCode); - Assert.Equal( - PluginBundleEndpoints.TransientRetryAfterSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture), - http.Response.Headers.RetryAfter.ToString()); - } - - /// - /// The unmapped shape — what production ran: the removal escapes the route as the raw - /// the exception middleware turned into a 500. Pins that - /// the archive write really does fault on this input, so the mapping above is not vacuous. - /// - [Fact] - public async Task WithoutTheMapping_TheRemovalEscapesAsAFault() - { - var ct = TestContext.Current.CancellationToken; - - var notification = await ArchiveOfAResolvedPath(directoryGone: false) - .Materialize() - .Should().Within(TestTimeouts.Quick) - .Emit("the archive write must terminate", cancellationToken: ct); - - Assert.Equal(NotificationKind.OnError, notification.Kind); - Assert.IsType(notification.Exception); - } - - /// Any OTHER I/O fault is a real defect and passes through unchanged. - [Fact] - public async Task AnyOtherIoFault_PassesThrough() - { - var ct = TestContext.Current.CancellationToken; - var http = new DefaultHttpContext(); - - var notification = await Observable.Throw(new IOException("sharing violation")) - .TransientWhenServedBytesMoved(http, "Collaboration", "1.2.3", logger: null) - .Materialize() - .Should().Within(TestTimeouts.Quick) - .Emit("the fault must reach the caller", cancellationToken: ct); - - Assert.Equal(NotificationKind.OnError, notification.Kind); - Assert.Equal("sharing violation", notification.Exception!.Message); - Assert.Equal(0, http.Response.Headers.RetryAfter.Count); - } - - /// - /// The route's own write, in miniature: resolve a path that exists, then lose it, then write - /// the archive whose entry opens it — exactly the order the bundle route runs in. - /// - private IObservable ArchiveOfAResolvedPath(bool directoryGone) - { - var typeDirectory = Path.Combine(root, "Collaboration_Review"); - Directory.CreateDirectory(typeDirectory); - var resolved = Path.Combine(typeDirectory, "v1172-c003e001-bea6e0b4ca5e.dll"); - File.WriteAllBytes(resolved, [1, 2, 3]); - - // The store's eviction, between the resolve and the open. - if (directoryGone) - Directory.Delete(typeDirectory, recursive: true); - else - File.Delete(resolved); - - return Observable.Defer(() => - { - var buffer = new MemoryStream(); - NuGetPackageWriter.Write( - buffer, - new PluginManifest("Collaboration", "Collaboration", "1.2.3", "Collaboration", null, - ImmutableArray.Empty), - "3.0.0", - [new NuGetPackageWriter.Entry("lib/Collaboration_Review.dll", () => File.OpenRead(resolved))], - "{}"); - return Observable.Return(Results.File(buffer, "application/octet-stream")); - }); - } -} diff --git a/test/Memex.Portal.Shared.Test/BundleServeRaceOnTheRouteTest.cs b/test/Memex.Portal.Shared.Test/BundleServeRaceOnTheRouteTest.cs deleted file mode 100644 index 75dc0269e0..0000000000 --- a/test/Memex.Portal.Shared.Test/BundleServeRaceOnTheRouteTest.cs +++ /dev/null @@ -1,144 +0,0 @@ -#pragma warning disable CS1591 - -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#3876 on the WIRE — the real authenticated per-package bundle route answers a file -/// it resolved and then could not open with 503 + Retry-After: 30, never a 500. -/// -/// The route lists a module's files when it resolves the bundle and opens them only when the -/// archive is written. "Listed, then gone at the open" is produced here without any seam or timing: -/// a DANGLING symbolic link in the landed generation's wwwroot. The directory walk lists it -/// (it is an entry of the directory) and the open fails with FileNotFoundException — the -/// same exception, from the same frame (NuGetPackageWriter.Write → the entry's open), as the -/// assembly the store evicted in production (Collaboration_Review/v1172-….dll). -/// -public class BundleServeRaceOnTheRouteTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Plugin = "ServeRace"; - private const string Module = "MeshWeaver.ServeRace"; - private const string Source = "Plugins"; - private const string Instance = "serve-race-consumer"; - private const string Version = "1.0.0"; - - private readonly string landingRoot = Path.Combine( - Path.GetTempPath(), "mw-serve-race-" + Guid.NewGuid().ToString("N")); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - Directory.CreateDirectory(landingRoot); - return base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new ModuleLandingService(baseDirectory: landingRoot))); - } - - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - try - { - if (Directory.Exists(landingRoot)) - Directory.Delete(landingRoot, recursive: true); - } - catch - { - // Best-effort temp cleanup must never turn a passing assertion red. - } - } - - private Task RegisterInstance(CancellationToken cancellationToken) => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [$"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:0"] = $"{Source}/*", - }) - .Build()) - .Register("race-owner", "Race Owner", "owner@test.com", Instance, Instance) - .Select(result => result.RawKey) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(cancellationToken); - - private async Task StartBundleHost(CancellationToken cancellationToken) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton( - Mesh.ServiceProvider.GetRequiredService()); - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(cancellationToken); - return app; - } - - private static async Task Get(WebApplication app, string route, string key) - { - using var request = new HttpRequestMessage(HttpMethod.Get, route); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - return await app.GetTestClient().SendAsync(request); - } - - [Fact(Timeout = 300_000)] - public async Task AFileListedThenGoneAtTheOpen_Is503WithRetryAfter() - { - var ct = TestContext.Current.CancellationToken; - await Mesh.ServiceProvider.GetRequiredService() - .ShelveModule( - Module, - [(Module + ".dll", File.ReadAllBytes(typeof(BundleReader).Assembly.Location))], - frameworkMvid: "framework-" + Version, - packagePath: $"{Source}/{Plugin}", - version: Version, - staticAssets: [("wwwroot/app.css", Encoding.UTF8.GetBytes("body{}"))]) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - var key = await RegisterInstance(ct); - await using var app = await StartBundleHost(ct); - var route = $"{PluginBundleEndpoints.RoutePrefix}/{Plugin}/{Version}"; - - // Positive control: the healthy bundle is served — the route, the grant and the module - // resolution all work, so the 503 below can only come from the removed file. - using (var healthy = await Get(app, route, key)) - Assert.Equal(HttpStatusCode.OK, healthy.StatusCode); - - // A file the walk will LIST and the archive cannot OPEN. - var generation = Assert.Single(ModuleActivationSidecar.Read(landingRoot).Entries).Directory; - Assert.False(string.IsNullOrWhiteSpace(generation)); - File.CreateSymbolicLink( - Path.Combine(landingRoot, "modules", generation!, "wwwroot", "evicted.css"), - Path.Combine(landingRoot, "no-such-target-" + Guid.NewGuid().ToString("N"))); - - using var response = await Get(app, route, key); - Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); - Assert.Equal( - TimeSpan.FromSeconds(PluginBundleEndpoints.TransientRetryAfterSeconds), - response.Headers.RetryAfter?.Delta); - } -} diff --git a/test/Memex.Portal.Shared.Test/BundleTransferFailsOnSilenceTest.cs b/test/Memex.Portal.Shared.Test/BundleTransferFailsOnSilenceTest.cs deleted file mode 100644 index e865db842d..0000000000 --- a/test/Memex.Portal.Shared.Test/BundleTransferFailsOnSilenceTest.cs +++ /dev/null @@ -1,446 +0,0 @@ -using System; -using System.Diagnostics; -using System.Linq; -using System.Net; -using System.Reactive; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A registry transfer FAILS ON SILENCE, at a NAMED stage, against a real socket (#4528). -/// -/// What was measured. With #4549's streaming shape live on every replica of both -/// production deployments (2026-09-19, five pods, ~10.5 h on the image), the 120 s attempt budget -/// on plugin-registry-bundles was still exceeded roughly 1.5× a minute, at the reconciler's -/// 180 s per-package period to within 5 ms — and nothing in the fleet could say WHICH stage timed -/// out, because the transport pipeline's attempt timeout retries inside a 3-minute outer bound -/// that cannot hold the retry, so every occurrence surfaced as a bare "The operation has timed -/// out". A second consumer (#4963, a plain curl from a CI runner) then showed the shape from -/// outside the mesh: 180 s, 0 bytes received — the registry accepting the connection and -/// never beginning a response. -/// -/// What this pins. The client owns the clock at every stage: a registry that never -/// begins a response is refused after ONE stall budget as ; -/// one that sends headers and then goes quiet is refused as -/// with the byte count it reached; one that -/// streams slowly but continuously for longer than the budget SUCCEEDS (the bound is on silence, -/// never on total duration); one that is larger than the client accepts is refused as -/// — before a byte when the length is declared, at the -/// bound when it is not — which re-establishes the bound the buffering read used to carry and -/// streaming had removed. And the index is read ONCE per client: it used to be re-sent per package, -/// so a stalled registry cost every package of a pass the whole stall. -/// -/// Negative controls. Every stall case runs under an outer .Timeout of three -/// stall budgets: without the client's own bound the request hangs until the transport's 100 s -/// clock, the outer timeout fires first, and the assertion fails with Rx's -/// instead of the named refusal — a hang reproduces as a red test, -/// never as a pass. The server is Kestrel on a loopback port, so what is exercised is the real -/// HttpClient against a real TCP connection, not a handler double. -/// -public class BundleTransferFailsOnSilenceTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// The silence budget under test — small, and scaled with the CI factor. - private static TimeSpan StallBudget => TestTimeouts.Quick / 3; - - /// Three stall budgets: the bound that turns a hang into a red test. - private static TimeSpan Outer => TestTimeouts.Quick; - - private const long ByteBound = 4096; - private const string Plugin = "StallProbe"; - private const string Version = "1.0.0"; - - private StallingRegistry registry = null!; - - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - public override async ValueTask InitializeAsync() - { - await base.InitializeAsync(); - registry = await StallingRegistry.StartAsync(chunkGap: StallBudget / 2, byteBound: ByteBound); - } - - // Async teardown because Kestrel stops asynchronously; a blocking bridge here is what - // BlockingBridgeInTestRatchetGuard exists to prevent. - public override async ValueTask DisposeAsync() - { - await registry.DisposeAsync(); - await base.DisposeAsync(); - } - - private PluginBundleClient Client() => - new(Mesh, registry.BaseUrl, "mwi_probe") { StallBudget = StallBudget, MaxTransferBytes = ByteBound }; - - private static Task Refusal(IObservable transfer) => - Assert.ThrowsAsync(() => transfer.Timeout(Outer).Await(Ct)); - - private static void AssertWaitedTheBudget(BundleTransferException refusal) => - Assert.True( - refusal.Elapsed >= StallBudget * 0.9, - $"the refusal came after {refusal.Elapsed.TotalMilliseconds:0} ms, before the " - + $"{StallBudget.TotalMilliseconds:0} ms silence budget — it did not fall on the silence bound"); - - /// The #4963 shape, on the INDEX: the connection is accepted and nothing is ever - /// written. Refused after one stall budget, naming the stage, the registry and zero bytes. - [Fact] - public async Task AnIndexThatNeverBeginsAResponse_IsRefusedAsNoResponse_AfterOneStallBudget() - { - registry.Mode = StallingRegistry.Behaviour.NeverAnswer; - - var refusal = await Refusal(Client().FetchIndex().Select(i => (object)i)); - - Assert.Equal(BundleTransferStage.NoResponse, refusal.Stage); - Assert.Equal(0, refusal.Received); - Assert.Null(refusal.Declared); - Assert.Equal((long)StallBudget.TotalSeconds, refusal.Bound); - Assert.Contains("did not begin a response", refusal.Message, StringComparison.Ordinal); - Assert.Contains(registry.BaseUrl.TrimEnd('/'), refusal.Message, StringComparison.Ordinal); - AssertWaitedTheBudget(refusal); - } - - /// The same shape on the BUNDLE route. - [Fact] - public async Task ABundleThatNeverBeginsAResponse_IsRefusedAsNoResponse() - { - registry.Mode = StallingRegistry.Behaviour.NeverAnswer; - - var refusal = await Refusal(Client().DownloadOverHttp(Plugin, Version).Select(r => (object)r)); - - Assert.Equal(BundleTransferStage.NoResponse, refusal.Stage); - Assert.Equal(0, refusal.Received); - Assert.Contains($"Bundle for {Plugin}@{Version}", refusal.Message, StringComparison.Ordinal); - AssertWaitedTheBudget(refusal); - } - - /// Headers and a prefix arrive, then nothing: refused as a MID-BODY stall carrying how - /// far it got — the number that separates "the registry stopped" from "the transport did". - [Fact] - public async Task ABodyThatGoesQuiet_IsRefusedAsStalledMidBody_WithTheBytesItReached() - { - registry.Mode = StallingRegistry.Behaviour.HeadersThenSilence; - registry.PrefixBytes = 100; - - var refusal = await Refusal(Client().DownloadOverHttp(Plugin, Version).Select(r => (object)r)); - - Assert.Equal(BundleTransferStage.StalledMidBody, refusal.Stage); - Assert.Equal(100, refusal.Received); - Assert.Null(refusal.Declared); - Assert.Contains("sent no data for", refusal.Message, StringComparison.Ordinal); - Assert.Contains("100 of an undeclared number of byte(s)", refusal.Message, StringComparison.Ordinal); - AssertWaitedTheBudget(refusal); - } - - /// - /// 🚨 THE POSITIVE CONTROL: a body that streams slowly but continuously for longer than the - /// stall budget SUCCEEDS. This is the property #4549 bought and the one a total-duration bound - /// — the transport's, or the buffering default's — would break: the transfer takes about - /// 2.5 stall budgets end to end and is never cut, because no single gap reaches the bound. - /// - [Fact] - public async Task ABodyThatTricklesLongerThanTheBudget_StillCompletes() - { - registry.Mode = StallingRegistry.Behaviour.SlowButAlive; - var clock = Stopwatch.StartNew(); - - var result = await Client().DownloadOverHttp(Plugin, Version) - .Timeout(Outer + Outer) - .Await(Ct); - - Assert.Equal(BundleAdoptionKind.Adopted, result.Kind); - Assert.NotNull(result.Bytes); - Assert.Equal(StallingRegistry.ChunkCount * StallingRegistry.ChunkBytes, result.Bytes!.Length); - Assert.True( - clock.Elapsed > StallBudget, - $"the trickle completed in {clock.Elapsed.TotalMilliseconds:0} ms, inside one stall budget " - + $"({StallBudget.TotalMilliseconds:0} ms) — it did not exercise the silence-not-duration property"); - } - - /// A declared length over the bound is refused BEFORE a byte is read. - [Fact] - public async Task ADeclaredOversizedBody_IsRefusedBeforeAByteIsRead() - { - registry.Mode = StallingRegistry.Behaviour.DeclaredOversize; - - var refusal = await Refusal(Client().DownloadOverHttp(Plugin, Version).Select(r => (object)r)); - - Assert.Equal(BundleTransferStage.OverSize, refusal.Stage); - Assert.Equal(0, refusal.Received); - Assert.Equal(ByteBound + 1, refusal.Declared); - Assert.Equal(ByteBound, refusal.Bound); - Assert.Contains("larger than the 4096 byte(s) this client accepts", refusal.Message, StringComparison.Ordinal); - } - - /// An undeclared (chunked) body is refused the moment it crosses the bound. - [Fact] - public async Task AnUndeclaredOversizedBody_IsRefusedAtTheBound() - { - registry.Mode = StallingRegistry.Behaviour.UndeclaredOversize; - - var refusal = await Refusal(Client().DownloadOverHttp(Plugin, Version).Select(r => (object)r)); - - Assert.Equal(BundleTransferStage.OverSize, refusal.Stage); - Assert.True(refusal.Received > ByteBound, $"refused at {refusal.Received} byte(s), which is not over the bound"); - Assert.Null(refusal.Declared); - } - - /// - /// 🚨 ONE index read per client. The slot used to cache a COLD observable, so every package's - /// Adopt re-sent the index request — and a stalled registry was paid for by every - /// package of the pass, which is the 180 s per-package period the incident showed. - /// - [Fact] - public async Task TheIndex_IsReadOnce_ForEveryPackageOfOneClient() - { - registry.Mode = StallingRegistry.Behaviour.Healthy; - var client = Client(); - - await client.Adopt(Plugin).Timeout(Outer).Await(Ct); - await client.Adopt("AnotherPackage").Timeout(Outer).Await(Ct); - Assert.Equal(1, registry.IndexRequests); - - // A second CLIENT is a second pass and asks again: the sharing is per client, not global. - await Client().Adopt(Plugin).Timeout(Outer).Await(Ct); - Assert.Equal(2, registry.IndexRequests); - } - - /// A refused index read is EVICTED, so the next package asks again rather than - /// replaying the refusal for the rest of the pass (#1369). - [Fact] - public async Task ARefusedIndexRead_IsEvicted_SoTheNextPackageAsksAgain() - { - registry.Mode = StallingRegistry.Behaviour.NeverAnswer; - var client = Client(); - await Refusal(client.Adopt(Plugin).Select(n => (object)n)); - Assert.Equal(1, registry.IndexRequests); - - registry.Mode = StallingRegistry.Behaviour.Healthy; - await client.Adopt(Plugin).Timeout(Outer).Await(Ct); - Assert.Equal(2, registry.IndexRequests); - } - - /// - /// The transport fact the design leans on: the standard resilience handler the hosts register - /// for plugin-registry-bundles leaves INFINITE, so the - /// client's stage clocks are the only clocks over the header stage — and the fallback client - /// without a factory says the same. A 100 s transport clock in front of a 120 s stall budget - /// would cut every stall one stage earlier under a message that names no stage. - /// - [Fact] - public void TheStandardResilienceHandler_LeavesTheTransportClockInfinite() - { - var services = new ServiceCollection(); - services.AddHttpClient(InstanceRegistrationClient.BundleHttpClientName) - .AddStandardResilienceHandler(options => - { - options.AttemptTimeout.Timeout = PluginBundleClient.TransferStallBudget; - options.TotalRequestTimeout.Timeout = PluginBundleClient.TransferStallBudget * 2.5; - options.CircuitBreaker.SamplingDuration = PluginBundleClient.TransferStallBudget * 2; - }); - using var provider = services.BuildServiceProvider(); - - var client = provider.GetRequiredService() - .CreateClient(InstanceRegistrationClient.BundleHttpClientName); - - Assert.Equal(Timeout.InfiniteTimeSpan, client.Timeout); - } - - /// The reconciler's outer bound is DERIVED from the client's stall budget and sits - /// above it, so a stalled stage is always refused — and named — before the outer bound cuts - /// the package. Pinned because two independently authored numbers is how the inversion began. - [Fact] - public void TheReconcilersPerPackageBudget_IsDerivedFromTheStallBudget_AndExceedsIt() - { - var headroom = RegistryUpdateReconciler.PerPackageAdoptBudget - PluginBundleClient.TransferStallBudget; - Assert.True(headroom > TimeSpan.Zero, "the outer bound must fire AFTER the client's own refusal"); - // Headroom for the read, the decision and the landing — less than a second stall budget, - // because a second stalled stage is the client's to refuse, not the reconciler's to wait for. - Assert.True(headroom < PluginBundleClient.TransferStallBudget); - } - - /// - /// A loopback registry that misbehaves on request: Kestrel bound to port 0 (never - /// HttpListener, whose port reservation has a check-then-use window — #2436), serving the - /// two bundle routes with one selectable behaviour, and counting index requests. - /// - private sealed class StallingRegistry : IAsyncDisposable - { - public enum Behaviour - { - /// Accept the connection, write nothing, until the client hangs up. - NeverAnswer, - /// Send 200 + headers (+ ), then write nothing. - HeadersThenSilence, - /// chunks, each followed by a gap of half a stall budget. - SlowButAlive, - /// Declare Content-Length one over the bound, then send it. - DeclaredOversize, - /// Chunked, one byte over the bound. - UndeclaredOversize, - /// A real index advertising for this lane; 404 for bundles. - Healthy, - } - - // Five chunks of 500 bytes: well inside the byte bound the tests inject (4096), so the - // trickle exercises the SILENCE bound alone — its first draft sent 5,000 bytes and was - // refused as OverSize, which was the size bound doing its job on the wrong test. - public const int ChunkCount = 5; - public const int ChunkBytes = 500; - - private WebApplication app = null!; - private TimeSpan chunkGap; - private long byteBound; - private int indexRequests; - - public string BaseUrl { get; private set; } = string.Empty; - public volatile Behaviour Mode = Behaviour.Healthy; - public volatile int PrefixBytes; - public int IndexRequests => Volatile.Read(ref indexRequests); - - private StallingRegistry() - { - } - - public static async Task StartAsync(TimeSpan chunkGap, long byteBound) - { - var server = new StallingRegistry { chunkGap = chunkGap, byteBound = byteBound }; - - var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); - builder.WebHost.UseKestrelCore().ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); - builder.Services.AddRoutingCore(); - builder.Logging.ClearProviders(); - - var app = builder.Build(); - app.Run(server.Serve); - await app.StartAsync().ConfigureAwait(false); - - var address = app.Services.GetRequiredService() - .Features.Get()! - .Addresses.First(); - server.app = app; - server.BaseUrl = address.EndsWith('/') ? address : address + "/"; - return server; - } - - private async Task Serve(HttpContext context) - { - var isIndex = context.Request.Path.Value!.EndsWith("/index.json", StringComparison.Ordinal); - if (isIndex) - Interlocked.Increment(ref indexRequests); - var aborted = context.RequestAborted; - var response = context.Response; - try - { - switch (Mode) - { - case Behaviour.NeverAnswer: - await Park(aborted).ConfigureAwait(false); - return; - - case Behaviour.HeadersThenSilence: - response.StatusCode = StatusCodes.Status200OK; - response.ContentType = "application/octet-stream"; - await response.StartAsync(aborted).ConfigureAwait(false); - if (PrefixBytes > 0) - { - await response.Body.WriteAsync(new byte[PrefixBytes], aborted).ConfigureAwait(false); - await response.Body.FlushAsync(aborted).ConfigureAwait(false); - } - await Park(aborted).ConfigureAwait(false); - return; - - case Behaviour.SlowButAlive: - response.StatusCode = StatusCodes.Status200OK; - response.ContentType = "application/octet-stream"; - await response.StartAsync(aborted).ConfigureAwait(false); - for (var i = 0; i < ChunkCount; i++) - { - await response.Body.WriteAsync(new byte[ChunkBytes], aborted).ConfigureAwait(false); - await response.Body.FlushAsync(aborted).ConfigureAwait(false); - if (i < ChunkCount - 1) - // The stub's pacing — the thing under test is that this gap, being - // shorter than the budget, is NOT a stall. - await Observable.Timer(chunkGap).Await(aborted).ConfigureAwait(false); - } - return; - - case Behaviour.DeclaredOversize: - response.StatusCode = StatusCodes.Status200OK; - response.ContentType = "application/octet-stream"; - response.ContentLength = byteBound + 1; - await response.Body.WriteAsync(new byte[byteBound + 1], aborted).ConfigureAwait(false); - return; - - case Behaviour.UndeclaredOversize: - response.StatusCode = StatusCodes.Status200OK; - response.ContentType = "application/octet-stream"; - await response.StartAsync(aborted).ConfigureAwait(false); - await response.Body.WriteAsync(new byte[byteBound], aborted).ConfigureAwait(false); - await response.Body.FlushAsync(aborted).ConfigureAwait(false); - await response.Body.WriteAsync(new byte[1], aborted).ConfigureAwait(false); - return; - - case Behaviour.Healthy: - if (isIndex) - { - response.StatusCode = StatusCodes.Status200OK; - response.ContentType = "application/json"; - await response.WriteAsync(IndexJson(), Encoding.UTF8, aborted).ConfigureAwait(false); - } - else - { - response.StatusCode = StatusCodes.Status404NotFound; - } - return; - } - } - catch (OperationCanceledException) - { - // The client gave up on this request, which is exactly what the stall cases - // assert on the client side; nothing to record here. - } - } - - /// The stall under test: hold the request open, writing nothing, until the client - /// hangs up. Awaited through the one sanctioned bridge; the abort token ends the wait. - private static async Task Park(CancellationToken aborted) - { - try - { - await Observable.Never().Await(aborted).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // The client hung up — the park is over. - } - } - - private string IndexJson() => JsonSerializer.Serialize(new - { - frameworkMvid = PrebuiltAssemblySeeder.LiveFrameworkMvid, - architecture = ReleaseArchitecture.Live, - bundles = new[] - { - new { plugin = Plugin, version = Version, url = $"{BaseUrl}api/plugins/bundles/{Plugin}/{Version}" }, - }, - }); - - public async ValueTask DisposeAsync() => await app.DisposeAsync().ConfigureAwait(false); - } -} diff --git a/test/Memex.Portal.Shared.Test/BundleTransferIsStreamedAndMeasuredTest.cs b/test/Memex.Portal.Shared.Test/BundleTransferIsStreamedAndMeasuredTest.cs deleted file mode 100644 index 3bcfdce51e..0000000000 --- a/test/Memex.Portal.Shared.Test/BundleTransferIsStreamedAndMeasuredTest.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System; -using System.IO; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A registry transfer reads HEADERS FIRST, is bounded on SILENCE at every stage, and records -/// what it moved (#4528). The SHAPE guards, pinned on the source so a refactor that restores the -/// buffering default or drops the per-chunk reset cannot pass by still returning the right bytes; -/// the BEHAVIOUR — each refusal against a real socket — is . -/// -/// What was measured. On memex.systemorph.com, eighteen module adopts failed across -/// 2026-09-15 and 2026-09-16 — fourteen in one pass (19:58–20:54Z), four more the next afternoon — -/// and every one of them failed at exactly 180 s, the reconciler's -/// PerPackageAdoptBudget, with the same cause: "The operation has timed out." Eleven -/// attempt timeouts fired on the 120 s transfer pipeline in the same window, and not one -/// Bundle fetch for line exists beside them: the HTTP layer never got to report, so nothing -/// in the fleet recorded a byte count or an elapsed time. "Is 120 s too short?" is really "how many -/// bytes, at what throughput?", and that question could not be asked at all. -/// -/// The shape. DownloadOverHttp sent with the buffering default, so the whole -/// archive was downloaded INSIDE SendAsync and the per-attempt budget measured -/// (size ÷ throughput) instead of "is the registry answering?". #4549 streamed the bundle; the -/// INDEX kept the buffering call until the roll showed the remaining stall was the index itself. -/// Both now go through ONE receive path, bounded per stage. -/// -public class BundleTransferIsStreamedAndMeasuredTest -{ - private static DirectoryInfo RepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - Assert.NotNull(dir); - return dir!; - } - - private static string BundleClientSource() => - File.ReadAllText(Path.Combine( - RepoRoot().FullName, "src", "MeshWeaver.PluginCatalog", "PluginBundleClient.cs")); - - /// The body of one method of the client, from its signature to the next member. - private static string MemberBody(string signature) - { - var source = BundleClientSource(); - var start = source.IndexOf(signature, StringComparison.Ordinal); - Assert.True(start >= 0, $"'{signature}' is no longer in PluginBundleClient — this guard has lost its subject"); - var next = source.IndexOf("\n /// ", start + 1, StringComparison.Ordinal); - return next < 0 ? source[start..] : source[start..next]; - } - - /// - /// EVERY registry transfer reads headers first — the index and the bundle alike, through the - /// one receive path. The buffering overload would put the body back inside a budget that - /// measures size, which is the defect itself and is invisible in any test that only checks - /// the bytes come out right. - /// - [Fact] - public void EveryTransfer_ReadsHeadersFirst_ThroughTheOneReceivePath() - { - var source = BundleClientSource(); - var begin = MemberBody("Task BeginResponse("); - - Assert.Contains("HttpCompletionOption.ResponseHeadersRead", begin, StringComparison.Ordinal); - // The buffering call shape: `SendAsync(request, ct)` with no completion option downloads - // the whole body before it returns. Nowhere in the file — the index used to read that way. - Assert.DoesNotContain("_http.SendAsync(request, ct)", source, StringComparison.Ordinal); - Assert.DoesNotContain("ReadAsByteArrayAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("ReadAsStringAsync", source, StringComparison.Ordinal); - // And both callers go through Receive, whose body streams through the stall-bounded copy. - Assert.Contains("await Receive(request, \"Bundle index\"", source, StringComparison.Ordinal); - Assert.Contains("await Receive(request, $\"Bundle for {pluginId}@{version}\"", source, StringComparison.Ordinal); - Assert.Contains("CopyStallBounded", MemberBody("Task Receive("), StringComparison.Ordinal); - } - - /// - /// 🚨 THE HANG GUARD (#4549 review). Reading headers first takes the body outside the transport's - /// attempt policy — and two callers have no operation deadline of their own - /// (CatalogLayoutAreas.InstallPackage, InstanceAutoRegistrationService), so a - /// registry that sends headers and then stops would hang them indefinitely. A hang is worse - /// than a failure, so trading the timeout for one would be no fix at all. - /// - /// The bound is on SILENCE, not on total duration: every chunk that arrives resets the - /// deadline, so a large transfer still making progress is never cut off — which is the defect - /// being removed — while a dead transfer fails and says so. Both `CancelAfter` calls matter: - /// the first arms it, the second is the reset, and losing the reset would silently restore a - /// total-duration bound. The response start has its own clock of the same length. - /// - [Fact] - public void EveryStage_IsBoundedBySILENCE_NotByTotalDuration() - { - var source = BundleClientSource(); - - Assert.Contains("TransferStallBudget", source, StringComparison.Ordinal); - // One linked source per stage: the response start, and the body. - Assert.Equal(2, CountOccurrences(source, "CancellationTokenSource.CreateLinkedTokenSource(ct)")); - Assert.Contains("start.CancelAfter(StallBudget)", source, StringComparison.Ordinal); - // Armed once, then reset on every chunk that arrived. - Assert.Equal(2, CountOccurrences(source, "stall.CancelAfter(StallBudget)")); - // A stall is reported as a stall, never as somebody else's cancellation — the filter reads - // the stall token, not the exception's type (the transport pipeline may throw its own). - Assert.Contains("when (start.IsCancellationRequested && !ct.IsCancellationRequested)", source, StringComparison.Ordinal); - Assert.Contains("when (stall.IsCancellationRequested && !ct.IsCancellationRequested)", source, StringComparison.Ordinal); - // The transport's own total-duration clock is switched off on the fallback client: the - // stages above are the only clocks, and a second unnamed cut at 100 s would undo them. - Assert.Contains("Timeout = Timeout.InfiniteTimeSpan", source, StringComparison.Ordinal); - } - - /// - /// 🚨 The measurement must cover the HEADER stage, where nothing has arrived yet (#4549 review). - /// A clock started after the response begins cannot see "the registry never answered" — - /// which is exactly the diagnosis this change exists to make possible — so the timestamp is - /// taken BEFORE the response is begun, and the whole transfer is inside the try. - /// - [Fact] - public void TheMeasurement_StartsBeforeTheRequest_SoAZeroByteFailureIsStillReported() - { - var receive = MemberBody("Task Receive("); - - var timing = receive.IndexOf("Stopwatch.GetTimestamp()", StringComparison.Ordinal); - var send = receive.IndexOf("await BeginResponse(", StringComparison.Ordinal); - Assert.True(timing >= 0, "the transfer no longer takes a timestamp"); - Assert.True(send >= 0, "the transfer no longer begins a response"); - Assert.True( - timing < send, - "the clock must start BEFORE the request, or a header-stage timeout reports nothing — " - + "the exact evidence gap #4528 is about"); - } - - private static int CountOccurrences(string haystack, string needle) - { - var count = 0; - for (var i = haystack.IndexOf(needle, StringComparison.Ordinal); - i >= 0; - i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) - count++; - return count; - } - - /// - /// A completed transfer states its size AND its rate, and a transfer that did not complete - /// states how much had arrived — the three numbers that separate "a large bundle" from "a slow - /// registry". Without the incomplete case, the interesting occurrence is still silent. The - /// index has its own completed line now: it was the stage that stalled after the roll. - /// - [Fact] - public void TheTransfer_StatesWhatItMoved_CompletedOrNot() - { - var source = BundleClientSource(); - - Assert.Contains("byte(s) over HTTP from {Registry}", source, StringComparison.Ordinal); - Assert.Contains("{Throughput} KiB/s", source, StringComparison.Ordinal); - Assert.Contains("Bundle index from {Registry}: {Bytes} byte(s) in {Elapsed} ms", source, StringComparison.Ordinal); - Assert.Contains("did NOT complete after", source, StringComparison.Ordinal); - Assert.Contains("{Received} of {Declared} byte(s) had arrived", source, StringComparison.Ordinal); - // 🚨 The incomplete line names the REGISTRY too (#4549 review): an instance may have several - // configured, and a byte count that cannot be attributed to an endpoint does not say which - // one needs fixing. - Assert.Contains( - "over HTTP from {Registry} did NOT complete", source, StringComparison.Ordinal); - } - - /// The three refusals each carry their own sentence, and the sentence names the stage - /// by what a reader would do about it. - [Fact] - public void EachStage_IsDescribedByWhatToDoAboutIt() - { - var elapsed = TimeSpan.Zero; - - var none = BundleTransferException.Describe( - BundleTransferStage.NoResponse, "Bundle index", "https://r", elapsed, 0, null, 120); - Assert.Contains("did not begin a response within 120 s", none, StringComparison.Ordinal); - // Only what was measured: nothing before the first byte can be told apart by the transport. - Assert.Contains("before the first byte", none, StringComparison.Ordinal); - Assert.DoesNotContain("The registry is stalled", none, StringComparison.Ordinal); - - var stalled = BundleTransferException.Describe( - BundleTransferStage.StalledMidBody, "Bundle for X@1", "https://r", elapsed, 4096, 8192, 120); - Assert.Contains("sent no data for 120 s", stalled, StringComparison.Ordinal); - Assert.Contains("4096 of 8192 byte(s) had arrived", stalled, StringComparison.Ordinal); - - var large = BundleTransferException.Describe( - BundleTransferStage.OverSize, "Bundle for X@1", "https://r", elapsed, 0, 9000, 8192); - Assert.Contains("larger than the 8192 byte(s) this client accepts", large, StringComparison.Ordinal); - Assert.Contains("never a larger bound", large, StringComparison.Ordinal); - } - - /// A rate over a real interval is the bytes divided by the seconds. - [Theory] - [InlineData(1024L, 1.0, 1L)] - [InlineData(10L * 1024 * 1024, 10.0, 1024L)] - [InlineData(0L, 5.0, 0L)] - public void Throughput_IsBytesOverElapsed(long bytes, double seconds, long expectedKib) - => Assert.Equal( - expectedKib, - PluginBundleClient.ThroughputKibPerSecond(bytes, TimeSpan.FromSeconds(seconds))); - - /// - /// 🚨 A near-zero elapsed time reports NO rate rather than an enormous one. A transfer that - /// completes from a warm buffer would otherwise publish a throughput figure of millions of - /// KiB/s, and a fabricated number in the one log line this exists to make trustworthy is worse - /// than no number at all. - /// - [Fact] - public void Throughput_RefusesToDivideByANearZeroInterval() - { - Assert.Equal(0, PluginBundleClient.ThroughputKibPerSecond(5_000_000, TimeSpan.Zero)); - Assert.Equal(0, PluginBundleClient.ThroughputKibPerSecond(5_000_000, TimeSpan.FromTicks(1))); - } -} diff --git a/test/Memex.Portal.Shared.Test/CallerFacingReadsDecideForTheCallerTest.cs b/test/Memex.Portal.Shared.Test/CallerFacingReadsDecideForTheCallerTest.cs deleted file mode 100644 index 91b1b072f3..0000000000 --- a/test/Memex.Portal.Shared.Test/CallerFacingReadsDecideForTheCallerTest.cs +++ /dev/null @@ -1,314 +0,0 @@ -#pragma warning disable CS1591 - -using System.Reactive.Linq; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Data.Completion; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 Every caller-facing read decides access for the CALLER captured at its entry, whichever -/// thread it is finally subscribed on (MeshWeaver#6570, the sweep beyond get). -/// -/// #6570 found get re-reading "the caller" from the ambient AccessContext -/// inside the continuation of an earlier response, where the identity was the delivering hub's, -/// and the read gates pass hub principals through. This class runs the same probe against the -/// other read surfaces the MCP tools and /api/mesh/* expose: each read is BUILT under an -/// ungranted caller and SUBSCRIBED under a hub principal (the continuation-on-another-identity -/// shape). The ungranted caller must learn nothing a reader learns; the grant holder, under the -/// same split, must still read (the positive control — without it a denial proves nothing). -/// -/// Probed here: autocomplete, search, render-area, the content listing, upload's -/// Update pre-flight and collection lookup, and recycle's Update pre-flight. get has its own class -/// (UngrantedGetIsNotFoundTest). Patch's pre-flight is the same CheckUpdateOutcome -/// recycle's test drives. run_tests and visit are NOT probed: the first renders the -/// Tests area inside an activity that outlives the call, the second copies only a node's name into -/// the caller's own activity log — their captures are held by -/// CallerCapturedAtEntryGuard, not by a behavioural probe. -/// -public class CallerFacingReadsDecideForTheCallerTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Granted = "ReadGateGranted"; - private const string Hidden = "ReadGateHidden"; - private const string ViewerUser = "readgate-viewer"; - private const string HolderUser = "readgate-holder"; - private const string EditorUser = "readgate-editor"; - - /// A name no other node in the mesh carries, so finding it anywhere is the leak. - private const string SecretName = "Zephyrinequartz"; - private const string SecretPath = Hidden + "/" + SecretName; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - // 🚨 ConfigureMeshBase, not base.ConfigureMesh: the latter grants Public the Admin role in every - // default partition, under which the viewer would read everything and the denial would be vacuous. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddMeshNodes( - new MeshNode(Granted) { Name = "Granted", NodeType = "Markdown" }, - new MeshNode(Hidden) { Name = "Hidden", NodeType = "Markdown" }, - AssignmentNodeFactory.UserRole(ViewerUser, "Viewer", Granted), - AssignmentNodeFactory.UserRole(HolderUser, "Viewer", Hidden), - AssignmentNodeFactory.UserRole(EditorUser, "Editor", Hidden)); - - /// - /// 🚨 The secret node is WRITTEN, not declared with AddMeshNodes: a declared node is a - /// configuration node, and the static query provider serves those without the row filter (they - /// are type definitions and built-ins). A probe on one would measure that provider, not the - /// identity a read is decided for. - /// - public override async ValueTask InitializeAsync() - { - await base.InitializeAsync(); - await Mesh.ServiceProvider.GetRequiredService() - .RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService() - .CreateNode(new MeshNode(SecretName, Hidden) { Name = SecretName, NodeType = "Markdown" })) - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - } - - private IMessageHub SessionHub() => SessionHubFactory.Resolve( - Mesh, "mcp", "readgate", - Mesh.ServiceProvider.GetRequiredService().CreateLogger(nameof(CallerFacingReadsDecideForTheCallerTest))); - - /// - /// Builds the read as , then subscribes it while the ambient identity - /// is a hub principal — the continuation-on-another-identity shape #6570 measured. - /// - private Task BuiltAsSubscribedAsHub(string userId, Func> build) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - access.SetContext(new AccessContext { ObjectId = userId, Name = userId }); - var read = build(); - using (access.ImpersonateAsHub(Mesh)) - return read.Timeout(Budget).Await(TestContext.Current.CancellationToken); - } - - private Task Autocomplete(string userId, string context) - => BuiltAsSubscribedAsHub(userId, () => - Mesh.ServiceProvider.GetRequiredService() - .GetCompletions("@" + SecretName[..6], context) - .SelectMany(batch => batch.Items.Select(item => $"{batch.Category}:{item.Category}|{item.Path}|{item.InsertText}|{item.Label}")) - .ToList() - .Select(items => string.Join("\n", items))); - - [Fact] - public async Task Autocomplete_ForAnUngrantedCaller_NamesNothingUnderTheHiddenSubtree() - { - var denied = await Autocomplete(ViewerUser, Hidden); - Output.WriteLine($"viewer: {denied}"); - - denied.Should().NotContain(SecretName, - "the viewer holds no Read under the subtree — a completion naming a node there discloses it"); - } - - [Fact] - public async Task Autocomplete_TheGrantHolder_StillCompletesIt_UnderTheSameSplit() - { - var answer = await Autocomplete(HolderUser, Hidden); - Output.WriteLine($"holder: {answer}"); - - answer.Should().Contain(SecretName, - "the holder's grant covers the subtree — if this fails, the denial above proves nothing"); - } - - private Task Search(string userId, string query) - => BuiltAsSubscribedAsHub(userId, () => new MeshOperations(SessionHub()).Search(query, Hidden)); - - [Theory] - [InlineData("Zephyrine")] - [InlineData("name:Zephyrinequartz")] - public async Task Search_ForAnUngrantedCaller_FindsNothingUnderTheHiddenSubtree(string query) - { - var denied = await Search(ViewerUser, query); - Output.WriteLine($"viewer: {denied}"); - - denied.Should().NotContain(SecretName, - "the viewer holds no Read under the subtree — a search hit there discloses the node"); - } - - [Theory] - [InlineData("Zephyrine")] - [InlineData("name:Zephyrinequartz")] - public async Task Search_TheGrantHolder_StillFindsIt_UnderTheSameSplit(string query) - { - var answer = await Search(HolderUser, query); - Output.WriteLine($"holder: {answer}"); - - answer.Should().Contain(SecretName, - "the holder's grant covers the subtree — if this fails, the denial above proves nothing"); - } - - /// - /// The node's OWN providers answer an AutocompleteRequest (its layout areas, data ids, - /// files, indexed chunks), so the request is a read of that node. Asked with the caller's real - /// identity — no thread split at all — an ungranted caller must get nothing from it. - /// - private Task AutocompleteInsideTheNode(string userId) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - access.SetContext(new AccessContext { ObjectId = userId, Name = userId }); - return Mesh.ServiceProvider.GetRequiredService() - .GetCompletions("@Ov", SecretPath) - .SelectMany(batch => batch.Items) - .Select(item => $"{item.Path}|{item.InsertText}|{item.Label}") - .ToList() - .Select(items => string.Join("\n", items)) - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Autocomplete_InsideANodeTheCallerCannotRead_AnswersNothingFromIt() - { - var denied = await AutocompleteInsideTheNode(ViewerUser); - Output.WriteLine($"viewer: {denied}"); - - denied.Should().NotContain(SecretPath, - "the node's providers must not answer a caller holding no Read on it"); - } - - [Fact] - public async Task Autocomplete_InsideANodeTheHolderCanRead_IsAnsweredByIt() - { - var answer = await AutocompleteInsideTheNode(HolderUser); - Output.WriteLine($"holder: {answer}"); - - answer.Should().Contain(SecretPath, - "the node's own layout areas complete for a reader — if this fails, the denial above proves nothing"); - } - - /// Inside the test's own budget, so a render the owner refuses answers as a fault the - /// probe can read instead of racing the outer bound. - private const int RenderBudgetSeconds = 10; - - private Task RenderArea(string userId, string path) - => BuiltAsSubscribedAsHub(userId, () => - new MeshOperations(SessionHub()).RenderArea(path, timeoutSeconds: RenderBudgetSeconds) - .Catch((Exception ex) => Observable.Return($"Fault: {ex.GetType().Name}: {ex.Message}"))); - - [Fact] - public async Task RenderArea_ForAnUngrantedCaller_RendersNothingOfTheNode() - { - var denied = await RenderArea(ViewerUser, SecretPath); - Output.WriteLine($"viewer: {denied}"); - - denied.Should().NotContain("\"areas\"", - "the viewer holds no Read on the node — its rendered area must not reach them"); - } - - [Fact] - public async Task RenderArea_TheGrantHolder_StillRendersIt_UnderTheSameSplit() - { - var answer = await RenderArea(HolderUser, SecretPath); - Output.WriteLine($"holder: {answer}"); - - answer.Should().Contain(SecretName, - "the holder's grant covers the node — if this fails, the denial above proves nothing"); - } - - /// The answer only a caller the owner hub served as a READER gets: the plain test mesh - /// configures no content collection, so a served lookup says the collection is missing. - private const string ServedAsReader = "collection 'content' not found"; - - private Task ContentList(string userId, string path) - => BuiltAsSubscribedAsHub(userId, () => new MeshOperations(SessionHub()).ContentList(path)); - - [Fact] - public async Task ContentList_ForAnUngrantedCaller_IsNotServedByTheOwner() - { - var denied = await ContentList(ViewerUser, SecretPath + "/content"); - Output.WriteLine($"viewer: {denied}"); - - denied.Should().NotContain(ServedAsReader, - "the owner hub must refuse the collection lookup of a caller holding no Read on the node"); - } - - [Fact] - public async Task ContentList_TheGrantHolder_IsServed_UnderTheSameSplit() - { - var answer = await ContentList(HolderUser, SecretPath + "/content"); - Output.WriteLine($"holder: {answer}"); - - answer.Should().Contain(ServedAsReader, - "the holder's grant covers the node — if this fails, the denial above proves nothing"); - } - - private Task Upload(string userId) - => BuiltAsSubscribedAsHub(userId, () => - new MeshOperations(SessionHub()).Upload(SecretPath + "/content/probe.txt", [1, 2, 3])); - - /// - /// Upload is a WRITE (#6585): a caller who may not read the node gets the absent answer, a reader - /// is told Update is missing, and only an editor reaches the owner's collection lookup. Built as - /// the caller and subscribed as a hub, each answer must be the one decided for the CALLER — the - /// subscribing hub would have been served. - /// - [Fact] - public async Task Upload_ForAnUngrantedCaller_IsNotServedByTheOwner() - { - var denied = await Upload(ViewerUser); - Output.WriteLine($"viewer: {denied}"); - - denied.Should().NotContain(ServedAsReader, - "the owner hub must refuse the collection lookup of a caller holding no Read on the node"); - denied.Should().Contain("no matching node for path", - "a caller who may not read the node gets the answer an absent path gets — decided for the " - + "caller, never for the subscribing hub"); - } - - [Fact] - public async Task Upload_AReader_IsToldUpdateIsMissing_UnderTheSameSplit() - { - var denied = await Upload(HolderUser); - Output.WriteLine($"reader: {denied}"); - - denied.Should().NotContain(ServedAsReader, "a reader holds no Update, so nothing is looked up for a write"); - denied.Should().Contain(HolderUser, "the refusal is decided for the caller, never for the subscribing hub") - .And.Contain("Update"); - } - - [Fact] - public async Task Upload_TheEditor_IsServed_UnderTheSameSplit() - { - var answer = await Upload(EditorUser); - Output.WriteLine($"editor: {answer}"); - - answer.Should().Contain(ServedAsReader, - "the editor's grant covers the node — if this fails, the refusals above prove nothing"); - } - - private Task Recycle(string userId) - => BuiltAsSubscribedAsHub(userId, () => - new MeshOperations(SessionHub()).Recycle(SecretPath, "test: caller captured at entry")); - - [Fact] - public async Task Recycle_ForACallerWithoutUpdate_IsRefused() - { - var denied = await Recycle(HolderUser); - Output.WriteLine($"reader: {denied}"); - - denied.Should().Contain("\"status\":\"Error\"", - "a reader holds no Update on the node, so the pre-flight refuses the recycle"); - } - - [Fact] - public async Task Recycle_TheEditor_RecyclesIt_UnderTheSameSplit() - { - var answer = await Recycle(EditorUser); - Output.WriteLine($"editor: {answer}"); - - answer.Should().Contain("\"status\":\"Recycled\"", - "the editor holds Update — the pre-flight decides for HER, not for the hub that subscribed; " - + "if this fails, the refusal above proves nothing"); - } -} diff --git a/test/Memex.Portal.Shared.Test/CapturingLogger.cs b/test/Memex.Portal.Shared.Test/CapturingLogger.cs deleted file mode 100644 index b85a595148..0000000000 --- a/test/Memex.Portal.Shared.Test/CapturingLogger.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Extensions.Logging; - -namespace Memex.Portal.Shared.Test; - -/// -/// An that keeps every formatted line, for tests whose -/// SUBJECT is a log line — the stage a validation failure is logged under, the rows a supersede -/// names. Thread-safe: the services under test log from Rx continuations, never from the test's -/// thread. -/// -/// The category the logger is injected as. -internal sealed class CapturingLogger : ILogger -{ - private readonly ConcurrentQueue<(LogLevel Level, string Text)> entries = new(); - - /// Every line logged so far, in arrival order. - public IReadOnlyList<(LogLevel Level, string Text)> Entries => entries.ToArray(); - - /// The text of every line at . - public IReadOnlyList Lines(LogLevel level) => - entries.Where(e => e.Level == level).Select(e => e.Text).ToArray(); - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) => - entries.Enqueue((logLevel, formatter(state, exception))); -} diff --git a/test/Memex.Portal.Shared.Test/CatalogAdminActionsNeedTheAdministratorTest.cs b/test/Memex.Portal.Shared.Test/CatalogAdminActionsNeedTheAdministratorTest.cs deleted file mode 100644 index 71199750c0..0000000000 --- a/test/Memex.Portal.Shared.Test/CatalogAdminActionsNeedTheAdministratorTest.cs +++ /dev/null @@ -1,279 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The catalog's two administrator actions — the orphaned record's Remove and the card's -/// update-policy choice — run as System (the partition policy denies them to every user identity), -/// so nothing downstream of the click can refuse a non-administrator. The gate is the ACTION: -/// it acts only on what the server's current page offers (an offer set that is empty for a viewer -/// who is not a global administrator) and only when the acting identity is that administrator. -/// -/// This mesh grants NO public admin (ConfigureMeshBase): the DevLogin identity is the -/// one global administrator, and a second identity holds Viewer on the catalog node and nothing -/// else. Each refusal is followed by the same click from the administrator, which acts — so the -/// silence before it is the gate and not a click that could never have worked. -/// -public class CatalogAdminActionsNeedTheAdministratorTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string CatalogNode = "AdminGateCatalog"; - private const string Area = CatalogLayoutAreas.CatalogArea; - - private static readonly AccessContext Viewer = new() - { - ObjectId = "catalog-viewer", Name = "Catalog Viewer", Email = "catalog-viewer@meshweaver.io", - }; - - private static string RemoveArea => $"{Area}/orphans/{ItemTemplateControl.ViewArea}/remove"; - - private static string PinArea => $"{Area}/cards/{ItemTemplateControl.ViewArea}/updatePolicy/choices/none"; - - private static readonly ImmutableList Listed = [Package("GateListed", "Gate Listed")]; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder).AddPluginCatalog().AddMeshNodes( - new MeshNode("AdminGateCatalogType") - { - HubConfiguration = config => config.AddDefaultLayoutAreas().AddLayout(layout => - layout.WithView(Area, (host, _) => - CatalogLayoutAreas.RenderFromSource(host, new FixtureSource(), "HEAD", null, "fixture"))) - }, - new MeshNode(CatalogNode) { NodeType = "AdminGateCatalogType" }, - // The viewer may OPEN the catalog and nothing more: not a global administrator. - new MeshNode($"{Viewer.ObjectId}_Access", $"{CatalogNode}/_Access") - { - NodeType = "AccessAssignment", - Name = "Catalog viewer", - Content = new AccessAssignment - { - AccessObject = Viewer.ObjectId, - DisplayName = Viewer.Name, - Roles = [new RoleAssignment { Role = "Viewer" }], - }, - MainNode = CatalogNode, - }); - - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) - => base.ConfigureClient(configuration).AddLayoutClient(); - - /// - /// A viewer who is not a global administrator opens the ALL page and clicks Remove on a real - /// orphan row and the pin ("None" — installs are Auto by default) on a real card row. Neither acts. The administrator's same clicks then - /// remove the record and set the policy. - /// - [Fact(Timeout = 120000)] - public async Task ANonAdministrator_CannotRemoveAnInstallOrSetAnUpdatePolicy() - { - var orphan = Package("GateOrphan", "Gate Orphan"); - await Install(orphan); - await Install(Listed[0]); - var records = Records(); - await records.Where(items => HasPolicyOtherThanNone(items, Listed[0].Id) && items.Any(i => i.Id == orphan.Id)) - .Should().Within(TestTimeouts.Convergence).Emit(); - - ActAs(TestUsers.Admin); - var adminStream = OpenAll(); - var orphanRow = RowOf(await Rows(adminStream, CatalogLayoutAreas.OrphansDataId, 1), - CatalogLayoutAreas.OrphansDataId, orphan.Id); - var cardRow = RowOf(await Rows(adminStream, CatalogLayoutAreas.CardsDataId, Listed.Count), - CatalogLayoutAreas.CardsDataId, Listed[0].Id); - - ActAs(Viewer); - var viewerStream = OpenAll(); - await Rows(viewerStream, CatalogLayoutAreas.CardsDataId, Listed.Count); - - await Click(viewerStream, RemoveArea, orphanRow, Viewer); - await Click(viewerStream, PinArea, cardRow, Viewer); - await records.Where(items => items.All(i => i.Id != orphan.Id) || !HasPolicyOtherThanNone(items, Listed[0].Id)) - .Should().NotEmit(1.Seconds(), - "a viewer who is not a global administrator is offered neither action, so the " - + "click must remove nothing and re-pin nothing", - TestContext.Current.CancellationToken); - - // The control: the administrator's same two clicks act. - ActAs(TestUsers.Admin); - await Click(adminStream, RemoveArea, orphanRow, TestUsers.Admin); - await Click(adminStream, PinArea, cardRow, TestUsers.Admin); - await records.Where(items => items.All(i => i.Id != orphan.Id) && !HasPolicyOtherThanNone(items, Listed[0].Id)) - .Should().Within(TestTimeouts.Convergence).Emit( - "the administrator's clicks remove the orphaned record and set the policy", - TestContext.Current.CancellationToken); - } - - /// - /// On a page whose offers were computed for the administrator, a click made under another - /// identity acts on nothing — the page's stream accepts input only from its subscriber and - /// answers the click with a refusal — and the administrator's own click acts. - /// - [Fact(Timeout = 120000)] - public async Task TheAdministratorActions_ActOnlyForTheAdministrator() - { - var orphan = Package("GateBorrowedOrphan", "Borrowed Orphan"); - await Install(orphan); - await Install(Listed[0]); - var records = Records(); - await records.Where(items => HasPolicyOtherThanNone(items, Listed[0].Id) && items.Any(i => i.Id == orphan.Id)) - .Should().Within(TestTimeouts.Convergence).Emit(); - - ActAs(TestUsers.Admin); - var adminStream = OpenAll(); - var orphanRow = RowOf(await Rows(adminStream, CatalogLayoutAreas.OrphansDataId, 1), - CatalogLayoutAreas.OrphansDataId, orphan.Id); - var cardRow = RowOf(await Rows(adminStream, CatalogLayoutAreas.CardsDataId, Listed.Count), - CatalogLayoutAreas.CardsDataId, Listed[0].Id); - - (await Answer(adminStream, RemoveArea, orphanRow, Viewer)).Should().NotBeNull( - "the page was opened by the administrator, so a click under another identity is refused"); - (await Answer(adminStream, PinArea, cardRow, Viewer)).Should().NotBeNull( - "the page was opened by the administrator, so a click under another identity is refused"); - await records.Where(items => items.All(i => i.Id != orphan.Id) || !HasPolicyOtherThanNone(items, Listed[0].Id)) - .Should().NotEmit(1.Seconds(), - "the acting identity is not the administrator, so neither action runs", - TestContext.Current.CancellationToken); - - // The control: the same clicks, on the same stream, by the administrator. - await Click(adminStream, RemoveArea, orphanRow, TestUsers.Admin); - await Click(adminStream, PinArea, cardRow, TestUsers.Admin); - await records.Where(items => items.All(i => i.Id != orphan.Id) && !HasPolicyOtherThanNone(items, Listed[0].Id)) - .Should().Within(TestTimeouts.Convergence).Emit( - "the administrator's own clicks act", TestContext.Current.CancellationToken); - } - - // ── Harness ───────────────────────────────────────────────────────────────────────────────── - - private static PackageManifest Package(string id, string name) => new() - { - Id = id, Name = name, Version = "1.0.0", Kind = PackageKind.Content, - Category = "Fixtures", TargetPartition = id, SourceFolder = id, - }; - - private static bool HasPolicyOtherThanNone(IReadOnlyList items, string id) - => items.Any(i => i.Id == id && i.EffectiveUpdatePolicy != PackageUpdatePolicy.None); - - /// - /// Makes the one the NEXT subscription is opened as: the host - /// identity, and the flow's delivery-scoped context — which the installs above leave on the - /// System identity they ran as, and a stream opened in that state would be System's page. - /// - private void ActAs(AccessContext identity) - { - TestUsers.DevLogin(Mesh, identity); - Mesh.ServiceProvider.GetRequiredService().SetContext(identity); - } - - private Task Install(PackageManifest manifest) - => PackageInstaller.Install(Mesh, manifest, - [new PackageFile($"{manifest.Id}/Doc.md", $"# {manifest.Name}")], "HEAD") - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - private ISynchronizationStream OpenAll() - => GetClient().GetWorkspace().GetRemoteStream( - new Address(CatalogNode), - new LayoutAreaReference(Area) { Id = $"{Area}?{CatalogLayoutAreas.AllParam}=true" }); - - // The install registry as the catalog reads it, live, read as the mesh itself. - private IObservable> Records() - { - var records = Mesh.ServiceProvider.GetRequiredService() - .Query(MeshQueryRequest.FromQuery(CatalogLayoutAreas.AllInstalledQuery)) - .Scan(ImmutableDictionary.Empty, (map, change) => - { - if (change.ChangeType is QueryChangeType.Initial or QueryChangeType.Reset) - return change.Items.ToImmutableDictionary(n => n.Path); - foreach (var item in change.Items) - map = change.ChangeType == QueryChangeType.Removed ? map.Remove(item.Path) : map.SetItem(item.Path, item); - return map; - }) - .Select(map => (IReadOnlyList)map.Values - .Select(n => n.ContentAs(Mesh.JsonSerializerOptions)) - .OfType() - .ToList()) - .Replay(1); - records.Connect(); - return records; - } - - private static async Task> Rows( - ISynchronizationStream stream, string dataId, int count) - { - var rows = await stream.GetDataStream(new JsonPointerReference(LayoutAreaReference.GetDataPointer(dataId))) - .Should().Within(TestTimeouts.Convergence).Match( - r => r.ValueKind == JsonValueKind.Array && r.GetArrayLength() == count, - $"the client mirror holds the {count} rows of {dataId}", - TestContext.Current.CancellationToken); - return [.. rows.EnumerateArray().Select(r => r.Clone())]; - } - - private static RowContext RowOf(ImmutableArray rows, string dataId, string id) - { - var k = rows.Select((r, i) => (r, i)).Single(x => x.r.GetProperty("id").GetString() == id).i; - return new RowContext - { - Pointer = $"{LayoutAreaReference.GetDataPointer(dataId)}/{k}", - Index = k, - Value = rows[k].Clone(), - }; - } - - /// - /// Sends the click as and waits for the owner's receipt — the - /// click action has RUN by then (it answers when the action is done), so the silence asserted - /// after it is the action's decision and not a click still in flight. - /// - private static async Task Click( - ISynchronizationStream stream, string area, RowContext row, AccessContext actingUser) - => (await Answer(stream, area, row, actingUser)).Should().BeNull( - "the owner accepts a click made by the identity the page was opened for"); - - /// - /// Sends the click as and returns the owner's answer: - /// null when the click was accepted (its action has run by then), the refusal sentence - /// otherwise. The click is sent only once the control is on this stream's page: the rows can - /// reach the client before the item template's buttons, and a click on an area that holds no - /// control is refused for that reason alone (RefusingALostUserAction) — which would answer a - /// question this test does not ask. - /// - private static async Task Answer( - ISynchronizationStream stream, string area, RowContext row, AccessContext actingUser) - { - await stream.GetControlStream(area).Should().Within(TestTimeouts.Convergence).Match( - control => control is not null, - $"the control at {area} must be rendered before it is clicked", - cancellationToken: TestContext.Current.CancellationToken); - var answer = new ReplaySubject(1); - stream.SubmitUserAction(new ClickedEvent(area, stream.StreamId) { Row = row }, actingUser, - onRefused: answer.OnNext, - onAccepted: () => answer.OnNext(null)); - return await answer.Should().Within(TestTimeouts.Convergence).Emit( - "the owner answers every click", TestContext.Current.CancellationToken); - } - - private sealed class FixtureSource : IPackageSource - { - public IObservable> ListPackages(string gitRef) - => Observable.Return>(Listed); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) - => Observable.Throw>(new InvalidOperationException("These actions must never fetch package files.")); - } -} diff --git a/test/Memex.Portal.Shared.Test/CatalogCategoryFirstTest.cs b/test/Memex.Portal.Shared.Test/CatalogCategoryFirstTest.cs deleted file mode 100644 index 36d4bcdd73..0000000000 --- a/test/Memex.Portal.Shared.Test/CatalogCategoryFirstTest.cs +++ /dev/null @@ -1,326 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Layout; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The plugin catalog opens on its categories and loads packages per category (maintainer, -/// 2026-09-03: "the store must not load the full thing — only categories first"). -/// -/// Pinned on the REAL render path — a mesh node whose catalog area is -/// over a stub , -/// rendered through a client workspace exactly as the portal renders it — plus the pure seams the -/// composition branches on. What is asserted: -/// -/// the LANDING renders one tile per category, with counts, off ONE listing of the source and -/// renders no package card — while an install record exists on the mesh, so a landing that -/// joined the registry would have had something to show; -/// a CATEGORY page renders exactly that category's cards, and the card whose package is -/// installed reads as installed — the join ran, for the members and nothing else; -/// an EMPTY catalog renders the localized empty state, not a blank page and not a -/// progress bar that never ends. -/// -/// -public class CatalogCategoryFirstTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string CatalogNode = "CategoryFirstCatalog"; - private const string StubType = "StubPluginCatalog"; - - private static TimeSpan RenderBudget => TestTimeouts.Convergence; - - - /// - /// 🚨 Assert on ASCII-only fragments of a rendered label — the frame travels as JSON, and - /// System.Text.Json escapes non-ASCII, so the card's ✓ Installed v1.0.0 appears in - /// frame.Value.ToString() as ✓ Installed v1.0.0 and the back link's ← - /// as ←. Waiting on the label verbatim is a predicate that can NEVER become true, - /// and it fails as a 36-second TIMEOUT rather than an assertion — indistinguishable from a view - /// that does not render. (The same rule is why the Plugins repo's own catalog test picks a - /// fragment "that reads identically in the rendered HTML and in the JSON the store travels as".) - /// - private const string InstalledFragment = "Installed v1.0.0"; - - private readonly StubSource source = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .AddMeshNodes( - // The same registration shape the real PluginCatalog type uses, over a source the - // test controls — everything below RenderFromSource is the production pipeline. - new MeshNode(StubType) - { - Name = "Stub Plugin Catalog", - HubConfiguration = config => config - .AddDefaultLayoutAreas() - .AddLayout(layout => layout.WithView( - CatalogLayoutAreas.CatalogArea, - (host, _) => CatalogLayoutAreas.RenderFromSource(host, source, "HEAD", null, "stub"))), - }, - new MeshNode(CatalogNode) { Name = "Catalog", NodeType = StubType }); - - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) - => base.ConfigureClient(configuration).AddLayoutClient(); - - // ————————————————————————————————————————————— the pure seams - - [Fact] - public void Categories_GroupCaseInsensitively_Alphabetically_UncategorizedLast() - { - var categories = CatalogLayoutAreas.Categories( - [ - Package("b1", "Insurance"), Package("a1", "Education"), Package("a2", "education "), - Package("u1", null), Package("u2", " "), - ]); - - categories.Select(c => (c.Key, c.Count)).Should().Equal( - ("Education", 2), ("Insurance", 1), (CatalogLayoutAreas.Uncategorized, 2)); - } - - [Fact] - public void Plan_LandingReadsNoRecord_CategoryReadsOnlyItsMembers_AllReadsEverything() - { - IReadOnlyList available = - [Package("b1", "Insurance"), Package("a1", "Education"), Package("a2", "Education")]; - - var landing = CatalogLayoutAreas.Plan(null, null, available); - landing.Kind.Should().Be(CatalogLayoutAreas.CatalogPage.Landing); - landing.Packages.Should().BeEmpty("the landing composes no install-record read at all"); - landing.Categories.Select(c => c.Key).Should().Equal("Education", "Insurance"); - landing.Total.Should().Be(3); - - // A stale or mistyped category falls back to the tiles, never a blank page. - CatalogLayoutAreas.Plan("NoSuch", null, available).Kind - .Should().Be(CatalogLayoutAreas.CatalogPage.Landing); - - var category = CatalogLayoutAreas.Plan("education", null, available); - category.Kind.Should().Be(CatalogLayoutAreas.CatalogPage.Category); - category.Category.Should().Be("Education", "matched case-insensitively to the source's spelling"); - category.Packages.Select(p => p.Id).Should().Equal("a1", "a2"); - category.Available.Should().HaveCount(3, "a click still resolves dependencies against the whole listing"); - - var all = CatalogLayoutAreas.Plan("Education", "true", available); - all.Kind.Should().Be(CatalogLayoutAreas.CatalogPage.All); - all.Packages.Should().HaveCount(3); - CatalogLayoutAreas.Plan(null, "yes", available).Kind - .Should().Be(CatalogLayoutAreas.CatalogPage.Landing, "only a true-ish value asks for the flat list"); - } - - [Fact] - public void InstalledRecordQueries_AreOneExactPathReadPerMember() - { - CatalogLayoutAreas.InstalledRecordQueries(["b1", " a1 ", "", "b1"]).Should().Equal( - "path:Plugins/a1 nodeType:Package", - "path:Plugins/b1 nodeType:Package"); - CatalogLayoutAreas.InstalledRecordQueries([]).Should().BeEmpty(); - CatalogLayoutAreas.AllInstalledQuery.Should().Be("path:Plugins scope:children nodeType:Package"); - CatalogLayoutAreas.InstalledIdsQuery.Should().StartWith(CatalogLayoutAreas.AllInstalledQuery) - .And.Contain("select:") - .And.NotContain("content", "the id listing must never load the records' installed-file baselines"); - } - - [Fact] - public void CategoryHref_CarriesTheEncodedCategory_AndRoundTripsThroughTheReference() - { - var href = CatalogLayoutAreas.CategoryHref("Plugins/Catalog", "Maps & Places"); - href.Should().Contain(CatalogLayoutAreas.CatalogArea); - href.Should().Contain($"{CatalogLayoutAreas.CategoryParam}=Maps%20%26%20Places", href); - - var reference = new LayoutAreaReference(CatalogLayoutAreas.CatalogArea) - { - Id = $"{CatalogLayoutAreas.CatalogArea}?{CatalogLayoutAreas.CategoryParam}=Maps%20%26%20Places", - }; - reference.GetParameterValue(CatalogLayoutAreas.CategoryParam).Should().Be("Maps & Places"); - - CatalogLayoutAreas.AllHref("Plugins/Catalog").Should().Contain($"{CatalogLayoutAreas.AllParam}=true"); - } - - [Fact] - public void EveryLocalizationKeyTheCatalogReads_IsInBothCatalogs() - { - string[] keys = - [ - "ui.pluginCatalog", "ui.mdLoadingCatalog", "ui.mdNoPackages", "ui.catalogAllPackages", - "ui.catalogUncategorized", "ui.catalogBackToCategories", "ui.catalogNoSource", - "ui.catalogSourceSummary", "ui.catalogRegistry", "ui.catalogInstall", "ui.catalogUpdateTo", - "ui.catalogInstalledVersion", "ui.orphanedInstallRecords", "ui.mdOrphanedInstallRecords", - ]; - foreach (var locale in new[] { "en", "de" }) - { - foreach (var key in keys) - LocalizationCatalog.Get(key, locale).Should().NotBe(key, - $"'{key}' renders as its raw key to a {locale} viewer when the catalog lacks it"); - LocalizationCatalog.Plural("plural.package", 1, locale).Should().NotContain("plural.package"); - LocalizationCatalog.Plural("plural.package", 2, locale).Should().NotContain("plural.package"); - } - LocalizationCatalog.Plural("plural.package", 2, "de").Should().Be("2 Pakete"); - } - - // ————————————————————————————————————————————— the real render path - - [Fact(Timeout = 120_000)] - public async Task Landing_RendersTheCategoriesOffOneListing_AndNoCard() - { - source.Packages = - [ - Package("CfAlpha", "Education", "Alpha Course"), - Package("CfBeta", "Education", "Beta Course"), - Package("CfGamma", "Insurance", "Gamma Cover"), - Package("CfDelta", null, "Delta Tool"), - ]; - // A real install record on the mesh: a landing that joined the registry would have had a - // card to flip to "Installed"; the tiles must render without ever asking. - await Install(source.Packages[2]); - - var stream = Render(null); - var tiles = await Data(stream, CatalogLayoutAreas.TilesDataId, t => t.GetArrayLength() == 4, TestContext.Current.CancellationToken); - var page = await Data(stream, CatalogLayoutAreas.PageDataId, p => Str(p, "tilesStyle") != CatalogLayoutAreas.Hidden, TestContext.Current.CancellationToken); - - tiles.EnumerateArray().Select(t => Str(t, "label")).Should().Equal( - "Education", "Insurance", LocalizationCatalog.Get("ui.catalogUncategorized", "en"), - LocalizationCatalog.Get("ui.catalogAllPackages", "en")); - tiles.EnumerateArray().Select(t => Str(t, "count")).Should().Equal( - LocalizationCatalog.Plural("plural.package", 2, "en"), LocalizationCatalog.Plural("plural.package", 1, "en"), - LocalizationCatalog.Plural("plural.package", 1, "en"), LocalizationCatalog.Plural("plural.package", 4, "en")); - Str(page, "sourceLine").Should().Contain(LocalizationCatalog.Plural("plural.package", 4, "en")); - Str(page, "backStyle").Should().Be(CatalogLayoutAreas.Hidden, "the landing has no way back — it IS the way in"); - - var cards = await Data(stream, CatalogLayoutAreas.CardsDataId, _ => true, TestContext.Current.CancellationToken); - cards.GetArrayLength().Should().Be(0, "the landing renders tiles, never a package card"); - cards.ToString().Should().NotContain("Gamma Cover", "no card, so no package name on the landing"); - - source.Listings.Should().BeGreaterThan(0, "the landing IS built from the source's listing"); - source.Fetches.Should().Be(0, "nothing is fetched until somebody clicks Install"); - } - - [Fact(Timeout = 120_000)] - public async Task Category_RendersExactlyItsCards_JoinedWithItsOwnRecords() - { - source.Packages = - [ - Package("CfAlpha", "Education", "Alpha Course"), - Package("CfBeta", "Education", "Beta Course"), - Package("CfGamma", "Insurance", "Gamma Cover"), - ]; - await Install(source.Packages[2]); - - // Lower-case on purpose: the request is matched to the source's own spelling. - var insurance = Render($"{CatalogLayoutAreas.CatalogArea}?{CatalogLayoutAreas.CategoryParam}=insurance"); - var cards = await Data(insurance, CatalogLayoutAreas.CardsDataId, - c => c.ToString().Contains(InstalledFragment, StringComparison.Ordinal), TestContext.Current.CancellationToken); - cards.EnumerateArray().Select(c => Str(c, "id")).Should().Equal(["CfGamma"], "the category's sole card"); - Str(cards[0], "name").Should().Be("Gamma Cover"); - Str(cards[0], "installStyle").Should().Be(CatalogLayoutAreas.Hidden, "an up-to-date package offers no Install"); - var page = await Data(insurance, CatalogLayoutAreas.PageDataId, p => Str(p, "heading") == "Insurance", TestContext.Current.CancellationToken); - Str(page, "backStyle").Should().NotBe(CatalogLayoutAreas.Hidden); - Str(page, "tilesStyle").Should().Be(CatalogLayoutAreas.Hidden, "a category page shows cards, not tiles"); - - var education = Render($"{CatalogLayoutAreas.CatalogArea}?{CatalogLayoutAreas.CategoryParam}=Education"); - var educationCards = await Data(education, CatalogLayoutAreas.CardsDataId, c => c.GetArrayLength() == 2, TestContext.Current.CancellationToken); - educationCards.EnumerateArray().Select(c => Str(c, "name")).Should().Equal("Alpha Course", "Beta Course"); - educationCards.EnumerateArray().Should().AllSatisfy(c => - { - Str(c, "installLabel").Should().Be(LocalizationCatalog.Get("ui.catalogInstall", "en"), "neither course is installed"); - Str(c, "installStyle").Should().NotBe(CatalogLayoutAreas.Hidden); - }); - educationCards.ToString().Should().NotContain("Gamma Cover", "the other category's card never renders here") - .And.NotContain(InstalledFragment, "neither course has an install record"); - } - - [Fact(Timeout = 120_000)] - public async Task EmptyCatalog_RendersTheLocalizedEmptyState() - { - source.Packages = []; - LocalizationCatalog.Get("ui.mdNoPackages", "en").Should().NotBe("ui.mdNoPackages"); - - var stream = Render(null); - var page = await Data(stream, CatalogLayoutAreas.PageDataId, p => Str(p, "emptyStyle") != CatalogLayoutAreas.Hidden, TestContext.Current.CancellationToken); - Str(page, "tilesStyle").Should().Be(CatalogLayoutAreas.Hidden); - Str(page, "loadingStyle").Should().Be(CatalogLayoutAreas.Hidden, "an empty answer is an answer, not a page that loads forever"); - } - - // ————————————————————————————————————————————— helpers - - private static PackageManifest Package(string id, string? category, string? name = null) => new() - { - Id = id, - Name = name ?? id, - Category = category, - Kind = PackageKind.Content, - TargetPartition = id, - SourceFolder = id, - Version = "1.0.0", - }; - - // The real installer writes the record — the same node the catalog joins against in production. - // - // 🚨 Through ObservableAwait.Await(), never `await` on the observable itself and never - // .ToTask(): Rx's own awaiter resumes the continuation INLINE on the signalling thread, still - // inside the trampoline, so the rest of the test would run on whatever thread the install - // happened to complete on. Await() queues the continuation instead. The .Timeout stays — Await - // bounds nothing by itself. - private Task Install(PackageManifest manifest) => - PackageInstaller.Install(Mesh, manifest, [new PackageFile($"{manifest.Id}/Doc.md", $"# {manifest.Id}")], "HEAD") - .FirstAsync() - .Timeout(RenderBudget) - .Await(TestContext.Current.CancellationToken); - - private ISynchronizationStream Render(string? id) => - GetClient().GetWorkspace().GetRemoteStream( - new Address(CatalogNode), - new LayoutAreaReference(CatalogLayoutAreas.CatalogArea) { Id = id }); - - /// The template's bound data as the client mirror holds it, once - /// holds — the page is a template, so what it shows is its DATA. - private static async Task Data( - ISynchronizationStream stream, string id, Func predicate, CancellationToken ct) - => (await stream.GetDataStream(new JsonPointerReference(LayoutAreaReference.GetDataPointer(id))) - .Where(v => v.ValueKind is JsonValueKind.Object or JsonValueKind.Array && predicate(v)) - .Select(v => v.Clone()) - .FirstAsync().Timeout(RenderBudget).Await(ct)); - - private static string? Str(JsonElement e, string property) - => e.TryGetProperty(property, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; - - /// A package source that lists exactly what it is told to and counts what is asked - /// of it — standing in for the registry's catalog read. - private sealed class StubSource : IPackageSource - { - private int listings; - private int fetches; - - public IReadOnlyList Packages { get; set; } = []; - - public int Listings => Volatile.Read(ref listings); - - public int Fetches => Volatile.Read(ref fetches); - - public IObservable> ListPackages(string gitRef) => - Observable.Defer(() => - { - Interlocked.Increment(ref listings); - return Observable.Return(Packages); - }); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) => - Observable.Defer(() => - { - Interlocked.Increment(ref fetches); - return Observable.Return>([]); - }); - } -} diff --git a/test/Memex.Portal.Shared.Test/CatalogOrphanActionIdentityTest.cs b/test/Memex.Portal.Shared.Test/CatalogOrphanActionIdentityTest.cs deleted file mode 100644 index 6eb13e2832..0000000000 --- a/test/Memex.Portal.Shared.Test/CatalogOrphanActionIdentityTest.cs +++ /dev/null @@ -1,252 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The catalog's ALL page against real install records in the isolated test mesh: the orphaned -/// record's Remove and the card's update-policy choices are ROW-SCOPED actions (Doc/GUI/DataBinding → -/// "Row-scoped actions") — each declared ONCE in its row template, and the click carries the row it -/// was clicked in. Clicking in row k acts on row k, also after the rows changed underneath; a click -/// with no row acts on nothing. Package files are local fixture content only. -/// -public class CatalogOrphanActionIdentityTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string CatalogNode = "OrphanIdentityCatalog"; - private const string Area = CatalogLayoutAreas.CatalogArea; - - private static string RemoveArea => $"{Area}/orphans/{ItemTemplateControl.ViewArea}/remove"; - - private static string PinArea => $"{Area}/cards/{ItemTemplateControl.ViewArea}/updatePolicy/choices/none"; - - private static readonly ImmutableList Listed = [Package("ListedA", "Listed A"), Package("ListedB", "Listed B")]; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog().AddMeshNodes( - new MeshNode("OrphanIdentityCatalogType") - { - HubConfiguration = config => config.AddDefaultLayoutAreas().AddLayout(layout => - layout.WithView(Area, (host, _) => - CatalogLayoutAreas.RenderFromSource(host, new FixtureSource(), "HEAD", null, "fixture"))) - }, - new MeshNode(CatalogNode) { NodeType = "OrphanIdentityCatalogType" }); - - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) - => base.ConfigureClient(configuration).AddLayoutClient(); - - private static PackageManifest Package(string id, string name) => new() - { - Id = id, Name = name, Version = "1.0.0", Kind = PackageKind.Content, - Category = "Fixtures", TargetPartition = id, SourceFolder = id, - }; - - private Task Install(PackageManifest manifest) - => PackageInstaller.Install(Mesh, manifest, - [new PackageFile($"{manifest.Id}/Doc.md", $"# {manifest.Name}")], "HEAD") - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - /// - /// Three orphaned records; the person clicks Remove on B. Before the click a neighbour is - /// inserted above (or removed), so B's slot changes — and the click still removes B, and only B. - /// - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task TheRemoveClickedInRowK_RemovesThatOrphan_AlsoAfterANeighbourChanged(bool insert) - { - var first = Package("OrphanA", "Package A"); - var intended = Package("OrphanB", "Package B"); - var last = Package("OrphanC", "Package C"); - await Install(intended); - await Install(last); - if (!insert) - await Install(first); - - var stream = OpenAll(); - var before = await Offered(stream, CatalogLayoutAreas.OrphansDataId, insert ? 2 : 3, RemoveStyle, RemoveArea); - var clicked = RowOf(before, CatalogLayoutAreas.OrphansDataId, intended.Id); - - if (insert) - await Install(first); - else - Assert.True(await PackageInstaller.RemoveInstalledRecord(Mesh, first.Id) - .Should().Within(TestTimeouts.Convergence).Emit()); - var after = await Offered(stream, CatalogLayoutAreas.OrphansDataId, insert ? 3 : 2, RemoveStyle, RemoveArea); - Assert.NotEqual(clicked.Index, RowOf(after, CatalogLayoutAreas.OrphansDataId, intended.Id).Index); - - var records = Records(); - Submit(stream, RemoveArea, clicked); - var remaining = await records.Where(items => items.All(item => item.Id != intended.Id)) - .Should().Within(TestTimeouts.Convergence).Emit(); - Assert.Contains(remaining, item => item.Id == last.Id); - if (insert) - Assert.Contains(remaining, item => item.Id == first.Id); - // Removing the record must leave the package's installed content in place. - Assert.NotNull(await Mesh.GetMeshNode(intended.Id + "/Doc") - .Should().Within(TestTimeouts.Convergence).Emit()); - } - - /// NEGATIVE CONTROL: the Remove button clicked with no row removes nothing. - [Fact] - public async Task ARemoveClickWithNoRow_RemovesNothing() - { - var orphan = Package("OrphanSolo", "Solo"); - await Install(orphan); - var stream = OpenAll(); - // The button must be ON OFFER before the row-less click: otherwise the click lands on a - // control the page has not rendered yet, nothing runs, and the "removes nothing" below - // passes having tested nothing. - await Offered(stream, CatalogLayoutAreas.OrphansDataId, 1, RemoveStyle, RemoveArea); - - var records = Records(); - Submit(stream, RemoveArea, row: null); - await records.Where(items => items.All(item => item.Id != orphan.Id)) - .Should().NotEmit(1.Seconds(), "with no row the action cannot know which record, and must not guess", - TestContext.Current.CancellationToken); - } - - /// - /// The update-policy choices are row-scoped too: "None" (the pin) clicked on card k pins package k — installs are Auto by default (policy packages-auto-update), for - /// every k, and leaves the other card's policy as it was. - /// - [Fact] - public async Task ThePinChoiceInRowK_SetsPackageKsPolicy() - { - foreach (var package in Listed) - await Install(package); - var stream = OpenAll(); - var cards = await Offered(stream, CatalogLayoutAreas.CardsDataId, Listed.Count, PolicyStyle, PinArea); - var records = Records(); - await records.Where(items => Listed.All(p => items.Any(i => i.Id == p.Id - && i.EffectiveUpdatePolicy != PackageUpdatePolicy.None))) - .Should().Within(TestTimeouts.Convergence).Emit(); - - for (var k = 0; k < Listed.Count; k++) - { - var id = Listed[k].Id; - Submit(stream, PinArea, RowOf(cards, CatalogLayoutAreas.CardsDataId, id)); - var now = await records.Where(items => items.Any(i => i.Id == id && i.EffectiveUpdatePolicy == PackageUpdatePolicy.None)) - .Should().Within(TestTimeouts.Convergence).Emit(); - Assert.NotNull(now); - foreach (var other in Listed.Skip(k + 1)) - Assert.NotEqual(PackageUpdatePolicy.None, now.Single(i => i.Id == other.Id).EffectiveUpdatePolicy); - } - } - - // ── Harness ───────────────────────────────────────────────────────────────────────────────── - - private ISynchronizationStream OpenAll() - { - var stream = GetClient().GetWorkspace().GetRemoteStream( - new Address(CatalogNode), - new LayoutAreaReference(Area) { Id = $"{Area}?{CatalogLayoutAreas.AllParam}=true" }); - return stream; - } - - // The install registry as the catalog reads it, live; the Removed / Updated changes are its own signal. - private IObservable> Records() - { - var records = Mesh.ServiceProvider.GetRequiredService().Query(MeshQueryRequest.FromQuery(CatalogLayoutAreas.AllInstalledQuery)) - .Scan(ImmutableDictionary.Empty, (map, change) => - { - if (change.ChangeType is QueryChangeType.Initial or QueryChangeType.Reset) - return change.Items.ToImmutableDictionary(n => n.Path); - foreach (var item in change.Items) - map = change.ChangeType == QueryChangeType.Removed ? map.Remove(item.Path) : map.SetItem(item.Path, item); - return map; - }) - .Select(map => (IReadOnlyList)map.Values - .Select(n => n.ContentAs(Mesh.JsonSerializerOptions)) - .OfType() - .ToList()) - .Replay(1); - records.Connect(); - return records; - } - - private const string PolicyStyle = "policyStyle"; - private const string RemoveStyle = "removeStyle"; - - /// - /// The rows of once a person could CLICK in - /// every one of them — the condition a click needs, never merely the rows existing. Two things - /// arrive separately and each, missing, swallows a click without a trace: - /// - /// The rows render off the source's listing BEFORE the viewer's global-admin flag is - /// confirmed (it starts false), and in that render the administrator's controls are hidden - /// ( is ): the server's - /// page offers no policy / Remove on any row, so the action correctly does nothing. - /// The row values can reach the client before the template's controls do (measured on a - /// cold first render: the bound rows ~100 ms ahead of the controls). A click on an area the - /// stream holds no control for runs nothing — a person cannot make that click, since there is - /// no button on their screen yet. - /// - /// - private static async Task> Offered( - ISynchronizationStream stream, string dataId, int count, string styleProperty, string area) - { - var rows = await Rows(stream, dataId, count, - row => row.GetProperty(styleProperty).GetString() != CatalogLayoutAreas.Hidden, - $"every row shows {styleProperty}"); - await stream.GetControlStream(area).Should().Within(TestTimeouts.Convergence).Match( - control => control is ButtonControl, - $"the client holds the control at {area}", TestContext.Current.CancellationToken); - return rows; - } - - private static async Task> Rows( - ISynchronizationStream stream, string dataId, int count, - Func? eachRow = null, string? rowCondition = null) - { - var rows = await stream.GetDataStream(new JsonPointerReference(LayoutAreaReference.GetDataPointer(dataId))) - .Should().Within(TestTimeouts.Convergence).Match( - r => r.ValueKind == JsonValueKind.Array && r.GetArrayLength() == count - && (eachRow is null || r.EnumerateArray().All(eachRow)), - $"the client mirror holds the {count} rows of {dataId}" - + (rowCondition is null ? "" : $", and {rowCondition}"), - TestContext.Current.CancellationToken); - return [.. rows.EnumerateArray().Select(r => r.Clone())]; - } - - /// The row the client renders for : its pointer, index and value. - private static RowContext RowOf(ImmutableArray rows, string dataId, string id) - { - var k = rows.Select((r, i) => (r, i)).Single(x => x.r.GetProperty("id").GetString() == id).i; - return new RowContext - { - Pointer = $"{LayoutAreaReference.GetDataPointer(dataId)}/{k}", - Index = k, - Value = rows[k].Clone(), - }; - } - - private static void Submit(ISynchronizationStream stream, string area, RowContext? row) - => stream.SubmitUserAction(new ClickedEvent(area, stream.StreamId) { Row = row }, - actingUser: null, onRefused: null, onAccepted: null); - - private sealed class FixtureSource : IPackageSource - { - public IObservable> ListPackages(string gitRef) - => Observable.Return>(Listed); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) - => Observable.Throw>(new InvalidOperationException("These actions must never fetch package files.")); - } -} diff --git a/test/Memex.Portal.Shared.Test/ClosedTypeSetBundleAdoptionIsNotAMissTest.cs b/test/Memex.Portal.Shared.Test/ClosedTypeSetBundleAdoptionIsNotAMissTest.cs deleted file mode 100644 index 488bdcb79c..0000000000 --- a/test/Memex.Portal.Shared.Test/ClosedTypeSetBundleAdoptionIsNotAMissTest.cs +++ /dev/null @@ -1,129 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A closed type set has nothing to adopt, and that is not a miss. Measured on the -/// control instance (control.systemorph.com /health, 2026-10-09): -/// bundle_adoption: Degraded — 27 adoption attempt(s), 0 assembly/assemblies adopted …, 14 -/// MISS(es) — … Edu: adopted only 0/12 — the rest compile here; Essentials: adopted only 0/5 …. -/// Every one of the 0/N lines was the seeder answering NotSeeded on purpose — the pod's log -/// carries Prebuilt assembly for Edu/Quiz NOT SEEDED: Mesh:ClosedTypeSet=true for each of -/// them — and nothing was compiled there in their place: a closed type set serves only the image's -/// types (Doc/Architecture/ClosedTypeSet). So the instrument read Degraded for ever on a -/// portal with nothing wrong with it. -/// -/// Pinned: on a closed type set asks the registry -/// NOTHING (the bytes could only be declined), returns 0, and records -/// — so bundle_adoption reads "no misses". -/// The control () is the same client on an open -/// mesh against the same registry: it DOES fetch the index and DOES record the miss, so the closed -/// arm's silence is a verdict and not a client that asks nothing anywhere. -/// -public class ClosedTypeSetBundleAdoptionIsNotAMissTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - internal const string RegistryHost = "registry.closedset.test"; - internal const string RegistryUrl = "https://" + RegistryHost; - internal const string Plugin = "Edu"; - - private readonly CountingRegistry registry = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => - base.ConfigureMesh(builder) - .WithClosedTypeSet() - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new HostRoutingClientFactory(registry))); - - [Fact(Timeout = 60_000)] - public async Task OnAClosedTypeSet_NothingIsFetched_AndTheAttemptIsNotAMiss() - { - var ct = TestContext.Current.CancellationToken; - - var adopted = await new PluginBundleClient(Mesh, RegistryUrl, "mwi_closed").Adopt(Plugin) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - adopted.Should().Be(0); - registry.Requests.Should().BeEmpty( - "a closed type set adopts no database NodeType, so the bundle could only be declined"); - var ledger = Mesh.ServiceProvider.GetRequiredService(); - var outcome = ledger.Outcomes.Should().ContainSingle(o => o.PluginId == Plugin).Subject; - outcome.Kind.Should().Be(BundleAdoptionKind.NotApplicable, - "nothing was fetched, so nothing may be claimed about what the package carries"); - outcome.Reason.Should().Contain(ClosedTypeSet.ConfigKey); - ledger.Misses.Should().BeEmpty("nothing is compiled here in place of the bundle"); - ledger.Describe().Should().Contain("no misses").And.Contain("1 not attempted") - .And.NotContain("carried no NodeTypes", "the package was never read"); - } -} - -/// The open-mesh control for . -public class OpenTypeSetBundleAdoptionControlTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private readonly CountingRegistry registry = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => - base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new HostRoutingClientFactory(registry))); - - [Fact(Timeout = 60_000)] - public async Task OnAnOpenMesh_TheSameAttemptFetchesTheIndex_AndRecordsTheMiss() - { - var ct = TestContext.Current.CancellationToken; - - var adopted = await new PluginBundleClient( - Mesh, ClosedTypeSetBundleAdoptionIsNotAMissTest.RegistryUrl, "mwi_open") - .Adopt(ClosedTypeSetBundleAdoptionIsNotAMissTest.Plugin) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - adopted.Should().Be(0); - registry.Requests.Should().NotBeEmpty("an open mesh asks the registry for its index"); - var ledger = Mesh.ServiceProvider.GetRequiredService(); - ledger.Misses.Should().ContainSingle(o => o.PluginId == ClosedTypeSetBundleAdoptionIsNotAMissTest.Plugin) - .Which.Kind.Should().Be(BundleAdoptionKind.FrameworkDeclined, - "the index is baked for a framework this process does not run"); - } -} - -/// A registry whose index is baked for another framework, recording every request. -internal sealed class CountingRegistry : HttpMessageHandler, IHostHandler -{ - private ImmutableList requests = ImmutableList.Empty; - - public ImmutableList Requests => requests; - - public bool Serves(string host) => - string.Equals(host, ClosedTypeSetBundleAdoptionIsNotAMissTest.RegistryHost, StringComparison.OrdinalIgnoreCase); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - ImmutableInterlocked.Update(ref requests, r => r.Add(request.RequestUri!.AbsolutePath)); - var body = JsonSerializer.Serialize(new - { - frameworkMvid = "s00000000000000000000000000000000", - bundles = Array.Empty(), - }); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }); - } -} diff --git a/test/Memex.Portal.Shared.Test/ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.cs b/test/Memex.Portal.Shared.Test/ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.cs deleted file mode 100644 index 856f212978..0000000000 --- a/test/Memex.Portal.Shared.Test/ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.cs +++ /dev/null @@ -1,624 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.Persistence.Parsers; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 On a closed type set the package installer holds no NodeType definition rows (#6627). -/// -/// Measured. On the control instance (Mesh:ClosedTypeSet=true) the pre-prod -/// NodeType sweep returned 73 of 126 NodeTypes at Error, every one carrying the closed-set -/// refusal and no compile defect: package installs wrote nodeType: NodeType rows a closed set -/// can never serve. The decision (option a): the installer writes none, removes the ones earlier -/// installs left, and installs everything else unchanged. -/// -/// The arms. -/// is the install lane; is the host whose -/// packages never move. is the same install on an open -/// mesh, which DOES write the row — so the closed arm's absence is a verdict and not an install that -/// writes nothing. -/// -public class ClosedTypeSetInstallHoldsNoNodeTypeRowsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - internal const string Package = "ClosedPkg"; - internal const string TypePath = $"{Package}/Widget"; - internal const string TypeFile = $"{Package}/Widget.json"; - internal const string InstancePath = $"{Package}/Item"; - internal const string GuidePath = $"{Package}/Guide"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => - base.ConfigureMesh(builder).WithClosedTypeSet().AddPluginCatalog(); - - private const string ManifestLock = $$""" - { - "module": "{{Package}}", - "moduleVersion": "0123456789abcdef", - "version": "1.0.0", - "files": { - "{{Package}}/index.json": "aaa", - "{{TypeFile}}": "bbb", - "{{Package}}/Item.json": "ccc", - "{{Package}}/Guide.md": "ddd" - } - } - """; - - internal static PackageManifest Candidate() => new() - { - Id = Package, - Name = Package, - Kind = PackageKind.NodeRepo, - TargetPartition = Package, - SourceFolder = Package, - Version = "1.0.0", - ModuleVersion = "0123456789abcdef", - }; - - internal static IReadOnlyList Files() => - [ - new PackageFile($"{Package}/{ModuleManifest.FileName}", ManifestLock), - new PackageFile($"{Package}/index.json", $$""" - { "id": "{{Package}}", "path": "{{Package}}", "nodeType": "Space", "name": "Closed", "state": "Active" } - """), - new PackageFile(TypeFile, TypeNodeJson), - new PackageFile($"{Package}/Item.json", $$""" - { "id": "Item", "namespace": "{{Package}}", "path": "{{InstancePath}}", "nodeType": "{{TypePath}}", "name": "Item", "state": "Active" } - """), - new PackageFile($"{Package}/Guide.md", "# Guide"), - ]; - - /// The package's NodeType, authored as a repo authors one. - internal const string TypeNodeJson = $$""" - { - "id": "Widget", - "namespace": "{{Package}}", - "path": "{{TypePath}}", - "nodeType": "NodeType", - "name": "Widget", - "state": "Active", - "content": { "configuration": "config => config" } - } - """; - - /// A NodeType row as an EARLIER install on this host left it. - private Task SeedLeftRow(System.Threading.CancellationToken ct) => - Mesh.ServiceProvider.GetRequiredService() - .Write(new MeshNode("Widget", Package) - { - NodeType = MeshNode.NodeTypePath, - Name = "Widget", - State = MeshNodeState.Active, - }, Mesh.JsonSerializerOptions) - .Should().Within(TestTimeouts.WriteConvergence) - .Emit("the left-behind row must be stored before the install can be asked to remove it", ct); - - internal static Task Install(MeshWeaver.Messaging.IMessageHub mesh, ILogger logger, System.Threading.CancellationToken ct) => - PackageInstaller.Install(mesh, Candidate(), Files(), "HEAD", logger) - .Should().Within(180.Seconds()) - .Emit("the install must complete before anything about its writes can be read", - ct); - - internal static Task Stored(MeshWeaver.Messaging.IMessageHub mesh, string path, System.Threading.CancellationToken ct) => - mesh.ServiceProvider.GetRequiredService() - .Read(path, mesh.JsonSerializerOptions) - .Take(1) - .DefaultIfEmpty(null) - .Select(n => n is not null) - .Should().Within(TestTimeouts.Convergence) - .Emit($"storage must answer whether '{path}' is stored", ct); - - internal static async Task Record(MeshWeaver.Messaging.IMessageHub mesh, System.Threading.CancellationToken ct) - { - var node = await mesh.ServiceProvider.GetRequiredService() - .Read($"{PackageInstaller.InstalledPartition}/{Package}", mesh.JsonSerializerOptions) - .Take(1) - .DefaultIfEmpty(null) - .Should().Within(TestTimeouts.Convergence) - .Emit("storage must answer for the install record", ct); - return node?.ContentAs(mesh.JsonSerializerOptions); - } - - private ILogger Logger() => Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - [Fact(Timeout = 300_000)] - public async Task AnInstall_WritesNoNodeTypeRow_RemovesTheLeftOne_AndInstallsTheRest() - { - var ct = TestContext.Current.CancellationToken; - await SeedLeftRow(ct); - (await Stored(Mesh, TypePath, ct)).Should().BeTrue( - "the left-behind row is the subject — if seeding failed, its absence would prove nothing"); - - await Install(Mesh, Logger(), ct); - - (await Stored(Mesh, TypePath, ct)).Should().BeFalse( - "a closed type set holds no NodeType definition row: the install writes none and removes " - + "the one an earlier install left"); - (await Stored(Mesh, InstancePath, ct)).Should().BeTrue( - "an instance typed by the withheld type is still a record and still installs"); - (await Stored(Mesh, GuidePath, ct)).Should().BeTrue("ordinary content still installs"); - (await Stored(Mesh, Package, ct)).Should().BeTrue("the package root still installs"); - - var record = await Record(Mesh, ct); - record.Should().NotBeNull("the install record still installs"); - record!.WithheldFiles.Should().NotBeNull(); - record.WithheldFiles!.Should().Equal([TypeFile], - "the record names the withheld row so the completeness sweep does not count it as owed"); - var parsers = new FileFormatParserRegistry( - Mesh.JsonSerializerOptions, Mesh.ServiceProvider.GetServices()); - InstallCompleteness.DeclaredNodePaths(record, parsers).Should().NotContain(TypePath) - .And.Contain(InstancePath); - } - - /// - /// The incremental lane: a delta that touches NO type file still removes a row an earlier - /// install left, because the reconcile reads every node the NEW manifest declares — not only - /// the changed ones. Narrowing the candidates back to the delta reddens this test. - /// - [Fact(Timeout = 300_000)] - public async Task ADeltaTouchingNoTypeFile_StillRemovesTheRowLeftBehind() - { - var ct = TestContext.Current.CancellationToken; - await Install(Mesh, Logger(), ct); - // A row written before this host withheld them; the update below changes only the guide. - await SeedLeftRow(ct); - (await Stored(Mesh, TypePath, ct)).Should().BeTrue("the left-behind row is the subject"); - // A record UNDER the type's path — what a leaf-shaped type's instance looks like. The row - // is removed alone (DeleteNodeRequest.KeepDescendants); this must survive it. - await Mesh.ServiceProvider.GetRequiredService() - .Write(new MeshNode("Note", TypePath) - { - NodeType = "Markdown", - Name = "Note", - State = MeshNodeState.Active, - }, Mesh.JsonSerializerOptions) - .Should().Within(TestTimeouts.WriteConvergence) - .Emit("the record under the type's path must be stored before the update", ct); - - var newManifest = new ModuleManifest - { - Module = Package, - ModuleVersion = "fedcba9876543210", - Version = "1.0.1", - Files = ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, - [ - new KeyValuePair($"{Package}/index.json", "aaa"), - new KeyValuePair(TypeFile, "bbb"), - new KeyValuePair($"{Package}/Item.json", "ccc"), - new KeyValuePair($"{Package}/Guide.md", "eee"), - ]), - }; - await PackageInstaller.InstallNodeRepoDelta( - Mesh, Candidate(), newManifest, - [new PackageFile($"{Package}/Guide.md", "# Guide, revised")], - Array.Empty(), "HEAD", Logger()) - .Should().Within(180.Seconds()) - .Emit("the incremental update must complete before its effect can be read", ct); - - (await Stored(Mesh, TypePath, ct)).Should().BeFalse( - "a delta that changed no type file must still remove the NodeType row a closed type set " - + "does not hold — the candidates are every node the new manifest declares"); - (await Stored(Mesh, $"{TypePath}/Note", ct)).Should().BeTrue( - "only the row is removed — a record under the type's path is never taken with it"); - (await Stored(Mesh, InstancePath, ct)).Should().BeTrue( - "the instance typed by the withheld type stays"); - var record = await Record(Mesh, ct); - record!.WithheldFiles.Should().Equal([TypeFile], - "the incremental record still names the withheld row for the completeness sweep"); - } - - /// - /// The CONTENT-package lane records what it withheld, exactly as the node-repo lanes do — - /// without it the record's WithheldFiles stays null, the completeness sweep reads the - /// withheld row as ABSENT, and the package is handed to the heal on every pass. - /// - [Fact(Timeout = 300_000)] - public async Task AContentPackage_RecordsTheRowItWithheld() - { - var ct = TestContext.Current.CancellationToken; - const string pkg = "ClosedContent"; - var candidate = new PackageManifest - { - Id = pkg, - Name = pkg, - Kind = PackageKind.Content, - TargetPartition = pkg, - SourceFolder = pkg, - Version = "1.0.0", - InstalledFiles = ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, - [ - new KeyValuePair($"{pkg}/Widget.json", "bbb"), - new KeyValuePair($"{pkg}/Guide.md", "ddd"), - ]), - }; - PackageFile[] files = - [ - new($"{pkg}/Widget.json", $$""" - { "id": "Widget", "nodeType": "NodeType", "name": "Widget", "state": "Active", "content": { "configuration": "config => config" } } - """), - new($"{pkg}/Guide.md", "# Guide"), - ]; - - await PackageInstaller.Install(Mesh, candidate, files, "HEAD", Logger()) - .Should().Within(180.Seconds()) - .Emit("the content install must complete before its record can be read", ct); - - (await Stored(Mesh, $"{pkg}/Widget", ct)).Should().BeFalse( - "a closed type set holds no NodeType row, in a content package either"); - (await Stored(Mesh, $"{pkg}/Guide", ct)).Should().BeTrue("ordinary content still installs"); - var node = await Mesh.ServiceProvider.GetRequiredService() - .Read($"{PackageInstaller.InstalledPartition}/{pkg}", Mesh.JsonSerializerOptions) - .Take(1) - .DefaultIfEmpty(null) - .Should().Within(TestTimeouts.Convergence) - .Emit("storage must answer for the content package's install record", ct); - var record = node?.ContentAs(Mesh.JsonSerializerOptions); - record.Should().NotBeNull("the install record still installs"); - record!.WithheldFiles.Should().Equal([$"{pkg}/Widget.json"], - "the content lane must record the withheld row, or the sweep counts it as owed"); - } - - /// - /// A content package whose and - /// DIFFER: Library/Widget.json installs at - /// Installed/Widget. The reconcile deletes the NodeType rows it finds at the candidate - /// paths as System, so mapping the file the node-repo way (Library/Widget) would delete an - /// UNRELATED type in the source partition and miss the installed row. The falsifier is the - /// unrelated row: under the node-repo mapping it is gone after the install. - /// - [Fact(Timeout = 300_000)] - public async Task AContentPackageRebasedUnderAnotherPartition_ReconcilesTheInstalledPath_NotTheSourcePath() - { - var ct = TestContext.Current.CancellationToken; - const string source = "ClosedLib"; - const string target = "ClosedLibInstalled"; - var candidate = new PackageManifest - { - Id = source, - Name = source, - Kind = PackageKind.Content, - TargetPartition = target, - SourceFolder = source, - Version = "1.0.0", - InstalledFiles = ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, - [ - new KeyValuePair($"{source}/Widget.json", "bbb"), - new KeyValuePair($"{source}/Item.json", "ccc"), - new KeyValuePair($"{source}/Guide.md", "ddd"), - ]), - }; - var parsers = new FileFormatParserRegistry( - Mesh.JsonSerializerOptions, Mesh.ServiceProvider.GetServices()); - PackageInstaller.InstalledNodePathOf(candidate, $"{source}/Widget.json", parsers) - .Should().Be($"{target}/Widget", "the content lane rebases under its target partition"); - - var storage = Mesh.ServiceProvider.GetRequiredService(); - // An UNRELATED, unclaimed NodeType row at the path the node-repo mapping would name. - await storage.Write(new MeshNode("Widget", source) - { - NodeType = MeshNode.NodeTypePath, - Name = "Unrelated", - State = MeshNodeState.Active, - }, Mesh.JsonSerializerOptions) - .Should().Within(TestTimeouts.WriteConvergence) - .Emit("the unrelated row must be stored before the install", ct); - (await Stored(Mesh, $"{source}/Widget", ct)).Should().BeTrue("the unrelated row is the subject"); - - PackageFile[] files = - [ - new($"{source}/Widget.json", $$""" - { "id": "Widget", "nodeType": "NodeType", "name": "Widget", "state": "Active", "content": { "configuration": "config => config" } } - """), - new($"{source}/Item.json", $$""" - { "id": "Item", "nodeType": "{{target}}/Widget", "name": "Item", "state": "Active" } - """), - new($"{source}/Guide.md", "# Guide"), - ]; - await PackageInstaller.Install(Mesh, candidate, files, "HEAD", Logger()) - .Should().Within(180.Seconds()) - .Emit("the content install must complete", ct); - - (await Stored(Mesh, $"{source}/Widget", ct)).Should().BeTrue( - "a NodeType row the package never wrote is not the package's to remove"); - (await Stored(Mesh, $"{target}/Widget", ct)).Should().BeFalse( - "the package's own row is withheld at the path the install would have written"); - (await Stored(Mesh, $"{target}/Item", ct)).Should().BeTrue( - "a content instance typed by the withheld row still installs (#6627) — the per-node create " - + "would refuse it as InvalidNodeType, because the row it reads for the type is withheld"); - var node = await storage.Read($"{PackageInstaller.InstalledPartition}/{source}", Mesh.JsonSerializerOptions) - .Take(1).DefaultIfEmpty(null) - .Should().Within(TestTimeouts.Convergence) - .Emit("storage must answer for the install record", ct); - node!.ContentAs(Mesh.JsonSerializerOptions)!.WithheldFiles - .Should().Equal([$"{source}/Widget.json"], "the withheld FILE is recorded through the same mapping"); - } - - /// - /// A delta that ADDS an instance typed by a row this closed host withholds. The per-node create - /// checks the type against the store, where the row is by design absent, so without the bulk - /// route the delta faults with InvalidNodeType — a package update that can never land. - /// - [Fact(Timeout = 300_000)] - public async Task ADeltaAddingAnInstanceOfAWithheldType_InstallsIt() - { - var ct = TestContext.Current.CancellationToken; - await Install(Mesh, Logger(), ct); - (await Record(Mesh, ct))!.WithheldFiles.Should().Equal([TypeFile], - "the premise: the earlier install recorded the withheld row"); - - const string added = $"{Package}/Item2"; - var newManifest = new ModuleManifest - { - Module = Package, - ModuleVersion = "fedcba9876543210", - Version = "1.0.1", - Files = ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, - [ - new KeyValuePair($"{Package}/index.json", "aaa"), - new KeyValuePair(TypeFile, "bbb"), - new KeyValuePair($"{Package}/Item.json", "ccc"), - new KeyValuePair($"{Package}/Item2.json", "fff"), - new KeyValuePair($"{Package}/Guide.md", "ddd"), - ]), - }; - await PackageInstaller.InstallNodeRepoDelta( - Mesh, Candidate(), newManifest, - [new PackageFile($"{Package}/Item2.json", $$""" - { "id": "Item2", "namespace": "{{Package}}", "path": "{{added}}", "nodeType": "{{TypePath}}", "name": "Item2", "state": "Active" } - """)], - Array.Empty(), "HEAD", Logger()) - .Should().Within(180.Seconds()) - .Emit("the incremental update adding a typed instance must complete", ct); - - (await Stored(Mesh, added, ct)).Should().BeTrue("the new instance of the withheld type installs"); - (await Stored(Mesh, TypePath, ct)).Should().BeFalse("the row stays withheld"); - } - - /// - /// A delta that changes only a type's Source/ file. Its release target is DERIVED from the - /// written path (the owning type), which on a closed host is the withheld row — so no release - /// may be requested there: the flip is a point write to an absent node. The row must stay absent - /// and the source must land. - /// - [Fact(Timeout = 300_000)] - public async Task ASourceOnlyDelta_RequestsNoReleaseOfTheWithheldRow() - { - var ct = TestContext.Current.CancellationToken; - await Install(Mesh, Logger(), ct); - const string sourceFile = $"{TypePath}/Source/Thing.cs"; - var newManifest = new ModuleManifest - { - Module = Package, - ModuleVersion = "fedcba9876543211", - Version = "1.0.2", - Files = ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, - [ - new KeyValuePair($"{Package}/index.json", "aaa"), - new KeyValuePair(TypeFile, "bbb"), - new KeyValuePair($"{Package}/Item.json", "ccc"), - new KeyValuePair($"{Package}/Guide.md", "ddd"), - new KeyValuePair(sourceFile, "sss"), - ]), - }; - await PackageInstaller.InstallNodeRepoDelta( - Mesh, Candidate(), newManifest, - [new PackageFile(sourceFile, "public class Thing { }")], - Array.Empty(), "HEAD", Logger()) - .Should().Within(180.Seconds()) - .Emit("the source-only update must complete", ct); - - (await Stored(Mesh, $"{TypePath}/Source/Thing", ct)).Should().BeTrue("the source still installs"); - (await Stored(Mesh, TypePath, ct)).Should().BeFalse( - "no release flip may write the withheld row back"); - } - - [Fact(Timeout = 300_000)] - public async Task TheBootRepairPass_RemovesARowLeftBehind() - { - var ct = TestContext.Current.CancellationToken; - await Install(Mesh, Logger(), ct); - // A row written before this host withheld them — the case where nothing ever updates. - await SeedLeftRow(ct); - (await Stored(Mesh, TypePath, ct)).Should().BeTrue("the left-behind row is the subject"); - - await new InstalledPackageRepairService(Mesh).RunRepair() - .Should().Within(180.Seconds()) - .Emit("the boot repair pass must complete", ct); - - (await Stored(Mesh, TypePath, ct)).Should().BeFalse( - "the boot repair pass removes a NodeType row a closed type set does not hold"); - (await Stored(Mesh, InstancePath, ct)).Should().BeTrue("records are never touched"); - } - - /// - /// #6751 — two packages installed into the SAME target partition. The per-partition passes walk - /// one record per partition, but each package declares its own NodeType rows, so the row - /// reconciliation must walk EVERY record. Narrowing it back to one record per partition leaves - /// one of the two rows (whichever record the grouping drops) and reddens this test. - /// - [Fact(Timeout = 300_000)] - public async Task TheBootRepairPass_ReconcilesEveryRecord_InASharedPartition() - { - var ct = TestContext.Current.CancellationToken; - await Install(Mesh, Logger(), ct); - const string second = "ClosedPkgSecond"; - const string gadgetFile = $"{Package}/Gadget.json"; - const string gadgetPath = $"{Package}/Gadget"; - var candidate = new PackageManifest - { - Id = second, - Name = second, - Kind = PackageKind.Content, - TargetPartition = Package, - SourceFolder = Package, - Version = "1.0.0", - InstalledFiles = ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, - [ - new KeyValuePair(gadgetFile, "ggg"), - new KeyValuePair($"{Package}/Manual.md", "mmm"), - ]), - }; - PackageFile[] files = - [ - new(gadgetFile, $$""" - { "id": "Gadget", "nodeType": "NodeType", "name": "Gadget", "state": "Active", "content": { "configuration": "config => config" } } - """), - new($"{Package}/Manual.md", "# Manual"), - ]; - await PackageInstaller.Install(Mesh, candidate, files, "HEAD", Logger()) - .Should().Within(180.Seconds()) - .Emit("the second package's install must complete before the repair pass reads its record", ct); - - // Both packages' rows, as an earlier install on this host left them. - await SeedLeftRow(ct); - await Mesh.ServiceProvider.GetRequiredService() - .Write(new MeshNode("Gadget", Package) - { - NodeType = MeshNode.NodeTypePath, - Name = "Gadget", - State = MeshNodeState.Active, - }, Mesh.JsonSerializerOptions) - .Should().Within(TestTimeouts.WriteConvergence) - .Emit("the second package's left-behind row must be stored before the repair pass", ct); - (await Stored(Mesh, TypePath, ct)).Should().BeTrue("the first package's left row is a subject"); - (await Stored(Mesh, gadgetPath, ct)).Should().BeTrue("the second package's left row is a subject"); - - await new InstalledPackageRepairService(Mesh).RunRepair() - .Should().Within(180.Seconds()) - .Emit("the boot repair pass must complete", ct); - - (await Stored(Mesh, TypePath, ct)).Should().BeFalse( - "the first package's NodeType row is removed by the boot repair pass"); - (await Stored(Mesh, gadgetPath, ct)).Should().BeFalse( - "the second package in the same partition is reconciled too — every record, not the " - + "first one per partition"); - } - - /// - /// #6751 — a CODE package's NodeType row is synthesized by the install, so no installed file - /// maps to it. The boot repair pass derives it from the record; deriving the candidates from the - /// declared files alone leaves the row and reddens this test. - /// - [Fact(Timeout = 300_000)] - public async Task TheBootRepairPass_RemovesACodePackagesSynthesizedRowLeftBehind() - { - var ct = TestContext.Current.CancellationToken; - const string code = "ClosedCode"; - var candidate = new PackageManifest - { - Id = code, - Name = code, - Kind = PackageKind.Code, - TargetPartition = code, - SourceFolder = code, - Version = "1.0.0", - NodeTypeConfiguration = "config => config", - }; - PackageFile[] files = [new($"{code}/Thing.cs", "public class Thing { }")]; - await PackageInstaller.Install(Mesh, candidate, files, "HEAD", Logger()) - .Should().Within(180.Seconds()) - .Emit("the code install must complete before the repair pass reads its record", ct); - var synthesized = PackageInstaller.SynthesizedNodeTypePathOf(candidate); - synthesized.Should().Be($"{code}/{code}", "the row InstallCode synthesizes"); - (await Stored(Mesh, synthesized!, ct)).Should().BeFalse( - "the premise: a closed type set's code install writes no synthesized row"); - - // The synthesized row, as an install before this host withheld them left it. - await Mesh.ServiceProvider.GetRequiredService() - .Write(new MeshNode(code, code) - { - NodeType = MeshNode.NodeTypePath, - Name = code, - State = MeshNodeState.Active, - }, Mesh.JsonSerializerOptions) - .Should().Within(TestTimeouts.WriteConvergence) - .Emit("the left-behind synthesized row must be stored before the repair pass", ct); - (await Stored(Mesh, synthesized!, ct)).Should().BeTrue("the left-behind row is the subject"); - - await new InstalledPackageRepairService(Mesh).RunRepair() - .Should().Within(180.Seconds()) - .Emit("the boot repair pass must complete", ct); - - (await Stored(Mesh, synthesized!, ct)).Should().BeFalse( - "the boot repair pass derives a code package's synthesized NodeType row and removes it"); - } -} - -/// The open-mesh control for . -public class OpenTypeSetInstallControlTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => - base.ConfigureMesh(builder).AddPluginCatalog(); - - [Fact(Timeout = 300_000)] - public async Task OnAnOpenMesh_TheSameInstallWritesTheNodeTypeRow() - { - var ct = TestContext.Current.CancellationToken; - await ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.Install( - Mesh, Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(), ct); - - (await ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.Stored( - Mesh, ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.TypePath, ct)).Should().BeTrue( - "an open mesh serves in-mesh types, so the package's NodeType row is written"); - var record = await ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.Record(Mesh, ct); - record.Should().NotBeNull(); - record!.WithheldFiles.Should().BeNull("nothing is withheld on an open mesh"); - } - - /// - /// A deployment that ran a CLOSED type set and is now OPEN: the record still carries the - /// withheld set from the closed run. The boot repair pass clears it — left in place it would keep - /// subtracting the NodeType files from what the package owes, and a host whose package never - /// updates could never restore the definitions it now serves. - /// - [Fact(Timeout = 300_000)] - public async Task OnAnOpenMesh_TheBootRepairPass_ClearsAWithheldSetAClosedRunLeft() - { - var ct = TestContext.Current.CancellationToken; - await ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.Install( - Mesh, Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(), ct); - - // The record as a closed run left it. - var storage = Mesh.ServiceProvider.GetRequiredService(); - var recordPath = - $"{PackageInstaller.InstalledPartition}/{ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.Package}"; - var stored = await storage.Read(recordPath, Mesh.JsonSerializerOptions) - .Take(1) - .Should().Within(TestTimeouts.Convergence) - .Emit("the install record must be readable before it can be aged", ct); - var aged = stored!.ContentAs(Mesh.JsonSerializerOptions)! with - { - WithheldFiles = ImmutableSortedSet.Create(StringComparer.Ordinal, - ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.TypeFile), - }; - await storage.Write(stored! with { Content = aged }, Mesh.JsonSerializerOptions) - .Should().Within(TestTimeouts.WriteConvergence) - .Emit("the aged record must be stored before the repair pass reads it", ct); - (await ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.Record(Mesh, ct))!.WithheldFiles - .Should().NotBeNull("the aged withheld set is the subject — if it did not land, its " - + "absence afterwards would prove nothing"); - - await new InstalledPackageRepairService(Mesh).RunRepair() - .Should().Within(180.Seconds()) - .Emit("the boot repair pass must complete", ct); - - (await ClosedTypeSetInstallHoldsNoNodeTypeRowsTest.Record(Mesh, ct))!.WithheldFiles - .Should().BeNull("an open host withholds nothing, so a set a closed run recorded is cleared"); - } -} diff --git a/test/Memex.Portal.Shared.Test/ComboClearanceTest.cs b/test/Memex.Portal.Shared.Test/ComboClearanceTest.cs deleted file mode 100644 index 9d5c795b6b..0000000000 --- a/test/Memex.Portal.Shared.Test/ComboClearanceTest.cs +++ /dev/null @@ -1,264 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The three verdicts, and the fourth state that is the absence of one — never conflated. -/// -/// This is the fold the whole combo gate (#2274) rests on, and it is deliberately pure so the -/// distinctions are pinned here rather than inferred from an integration run. The property that -/// matters most is negative and is asserted from every direction: only a -/// can produce . -/// A missing verdict, a missing gate, and a verdict all -/// land somewhere that grants nothing — which is what makes it impossible for a configuration key, -/// an unregistered service or a caught exception to manufacture a clearance. -/// -/// The second property is symmetric and just as load-bearing: none of those three REFUSES -/// either. Refusing on absent evidence would freeze every instance in the fleet the day this -/// shipped, since producing a verdict needs docker a portal pod does not have — the fail-closed rule -/// drawn one state too wide that already records the cost -/// of. -/// -public class ComboClearanceTest -{ - private const string Tag = "3.0.0-ci.777"; - - // ── Green: the ONLY thing that clears ── - - [Fact] - public void AGreenVerdict_Clears_AndNamesWhatItRan() - { - var clearance = ComboClearance.For(Tag, Green()); - - clearance.Kind.Should().Be(ComboClearanceKind.Cleared); - clearance.IsCleared.Should().BeTrue(); - clearance.Refuses.Should().BeFalse(); - clearance.Reason.Should().Contain("GREEN").And.Contain(Tag); - clearance.Reason.Should().Contain("linux/amd64", - "a verdict is about ONE architecture and has to say which — the amd64 and arm64 " - + "variants of one tag carry different bytes"); - } - - /// - /// 🚨 A Green with caveats is not an unqualified pass. - /// documents them as mandatory-to-surface: a run over a MOVING pin verified the content it - /// happened to fetch, and a later run can resolve differently. - /// - [Fact] - public void AGreenWithCaveats_StillCarriesThem_ItIsNeverAnUnqualifiedPass() - { - var clearance = ComboClearance.For(Tag, Green() with - { - Caveats = ["'Widget' was materialised from a MOVING ref"], - }); - - clearance.Kind.Should().Be(ComboClearanceKind.Cleared); - clearance.Reason.Should().Contain("MOVING"); - } - - // ── Red: the refusal ── - - [Fact] - public void ARedVerdict_Refuses_AndNamesEveryFailingModule() - { - var clearance = ComboClearance.For(Tag, Red( - ("Widget", "Widget/Thing: compile failed — no overload for 'AddTracking'"), - ("Gadget", "install: the package could not be installed"))); - - clearance.Kind.Should().Be(ComboClearanceKind.Refused); - clearance.Refuses.Should().BeTrue(); - clearance.IsCleared.Should().BeFalse(); - // Breadth-complete: one broken module must never hide another. - clearance.Reason.Should().Contain("Widget").And.Contain("AddTracking"); - clearance.Reason.Should().Contain("Gadget").And.Contain("could not be installed"); - } - - // ── NotVerifiable: NEITHER ── - - /// - /// 🚨 The state the whole issue turns on. "We could not find out" is not "all clear" (treating - /// it as Green reproduces the outage this gate exists to prevent) and it is not "broken" - /// (treating it as Red bricks self-update the first time evidence is missing). It grants nothing - /// and refuses nothing, and it says WHY. - /// - [Fact] - public void ANotVerifiableVerdict_NeitherClearsNorRefuses_AndSaysWhyItCouldNotAnswer() - { - var clearance = ComboClearance.For(Tag, NotVerifiable( - "the gate could not run: docker is not available on this host")); - - clearance.Kind.Should().Be(ComboClearanceKind.Unverifiable); - clearance.IsCleared.Should().BeFalse("'we could not find out' is not a pass"); - clearance.Refuses.Should().BeFalse( - "refusing on missing evidence would freeze every instance that has no producer"); - clearance.Reason.Should().Contain("could NOT answer"); - clearance.Reason.Should().Contain("docker is not available", - "the caveats name every reason the question went unanswered"); - } - - [Fact] - public void ANotVerifiableVerdict_NamesTheModulesItCouldNotEvaluate() - { - var clearance = ComboClearance.For(Tag, NotVerifiable("the gate was not run") with - { - Modules = - [ - new ModuleVerification - { - ModuleId = "Widget", - Outcome = ModuleVerificationOutcome.NotVerified, - Failures = ["materialised without an index.json root"], - }, - ], - }); - - clearance.Kind.Should().Be(ComboClearanceKind.Unverifiable); - clearance.Reason.Should().Contain("Widget").And.Contain("index.json"); - } - - // ── no verdict at all: NEITHER, and DISTINCT from NotVerifiable ── - - /// - /// 🚨 "The gate ran and could not answer" and "nothing has ever run the gate" are different - /// incidents with different fixes, and an operator has to be able to tell them apart from the - /// recorded sentence alone. Both grant nothing; only the wording separates them. - /// - [Fact] - public void NoVerdictAtAll_IsItsOwnState_NotAPassAndNotARefusal() - { - var clearance = ComboClearance.For(Tag, verdict: null); - - clearance.Kind.Should().Be(ComboClearanceKind.NotVerified); - clearance.IsCleared.Should().BeFalse(); - clearance.Refuses.Should().BeFalse(); - clearance.Verdict.Should().BeNull(); - clearance.Reason.Should().Contain("no combo verification has been recorded"); - clearance.Reason.Should().Contain("mw-combo-verify", - "an unactionable absence is how a gate stays unwired for months"); - } - - [Fact] - public void NoVerdict_CarriesWhyThisHostHasNone() - { - var clearance = ComboClearance.For( - Tag, verdict: null, absence: "no combo-gate runner is registered on this host"); - - clearance.Kind.Should().Be(ComboClearanceKind.NotVerified); - clearance.Reason.Should().Contain("no combo-gate runner is registered"); - } - - /// - /// An unregistered gate has not ANSWERED the question, it has failed to ask it — so it lands on - /// the same no-clearance state, naming the wiring rather than the release. - /// - [Fact] - public void AnUnregisteredGate_GrantsNothing_AndNamesTheWiring() - { - var clearance = ComboVerificationGate.NotRegistered(Tag); - - clearance.Kind.Should().Be(ComboClearanceKind.NotVerified); - clearance.IsCleared.Should().BeFalse(); - clearance.Refuses.Should().BeFalse(); - clearance.Reason.Should().Contain(nameof(ComboVerificationGate)); - clearance.Reason.Should().Contain("AddSelfUpdate"); - } - - /// - /// 🚨 The exhaustiveness pin. switches on - /// with Green and Red as EXPLICIT arms and everything else - /// falling to Unverifiable — never the other way round. A member appended to - /// tomorrow must land on the state that grants nothing, and an - /// `is not Red` test (the tempting shape) would have cleared it instead. - /// - [Fact] - public void AnUnknownVerdictKind_FallsToTheStateThatGrantsNothing_NeverToCleared() - { - var clearance = ComboClearance.For(Tag, Green() with { Verdict = (ComboVerdictKind)999 }); - - clearance.Kind.Should().Be(ComboClearanceKind.Unverifiable); - clearance.IsCleared.Should().BeFalse(); - } - - // ── Retired modules (#3848, policy record-served-commit-on-install) ── - - /// - /// A retired module is NAMED on the clearance sentence of every verdict and changes none of - /// them: Green still clears, Red still refuses, NotVerifiable still grants nothing. - /// - [Fact] - public void RetiredModules_AreNamed_AndChangeNoClearanceKind() - { - ImmutableList retired = - [ - new RetiredModule { ModuleId = "PlatformUI" }, - new RetiredModule { ModuleId = "DeepSign" }, - ]; - - var green = ComboClearance.For(Tag, Green() with { RetiredModules = retired }); - var red = ComboClearance.For(Tag, Red(("Widget", "compile failed")) with { RetiredModules = retired }); - var unverifiable = ComboClearance.For(Tag, NotVerifiable("no report") with { RetiredModules = retired }); - - green.Kind.Should().Be(ComboClearanceKind.Cleared, "a retired module neither fails nor holds a Green"); - red.Kind.Should().Be(ComboClearanceKind.Refused); - unverifiable.Kind.Should().Be(ComboClearanceKind.Unverifiable); - foreach (var clearance in new[] { green, red, unverifiable }) - clearance.Reason.Should().Contain("Retired, not verifiable: 'PlatformUI', 'DeepSign'.", - "every verdict still names the modules it could not verify because they are gone"); - red.Reason.Should().Contain("Widget").And.Contain("compile failed", - "and the failing module is still named apart"); - } - - /// NEGATIVE CONTROL — no retired modules, no retired sentence. - [Fact] - public void NoRetiredModules_NoRetiredSentence() - { - ComboClearance.For(Tag, Green()).Reason.Should().NotContain("Retired"); - } - - // ── helpers ── - - private static ComboVerification Green() => new() - { - CandidateTag = Tag, - ImageRef = $"meshweaver.azurecr.io/memex-portal-ai:{Tag}", - ImageDigest = "sha256:4a63eda", - VerifiedPlatform = "linux/amd64", - VerifiedAt = DateTimeOffset.UtcNow, - Verdict = ComboVerdictKind.Green, - Modules = - [ - new ModuleVerification - { - ModuleId = "Widget", - Outcome = ModuleVerificationOutcome.Passed, - }, - ], - }; - - private static ComboVerification Red(params (string Module, string Failure)[] failures) => - Green() with - { - Verdict = ComboVerdictKind.Red, - Modules = failures - .Select(f => new ModuleVerification - { - ModuleId = f.Module, - Outcome = ModuleVerificationOutcome.Failed, - Failures = [f.Failure], - }) - .ToImmutableList(), - }; - - private static ComboVerification NotVerifiable(string caveat) => Green() with - { - Verdict = ComboVerdictKind.NotVerifiable, - Modules = [], - Caveats = [caveat], - }; -} diff --git a/test/Memex.Portal.Shared.Test/ComboGateRollTest.cs b/test/Memex.Portal.Shared.Test/ComboGateRollTest.cs deleted file mode 100644 index c9f95f3b03..0000000000 --- a/test/Memex.Portal.Shared.Test/ComboGateRollTest.cs +++ /dev/null @@ -1,655 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The combo gate (#2274) as the POLLER experiences it, against a real monolith mesh. -/// -/// The gate was fully built, documented and tested — and called by NOTHING. Its verdict type, -/// its recorder, its reader and the settings tab that renders it all existed while -/// comboVerifications stayed [] on every instance and the poller rolled regardless. -/// The acceptance criterion for closing that gap is not "the fold is correct" (that is -/// ) — it is that a RED verdict actually STOPS a roll, and that the -/// verifier is genuinely RUN and its verdict RECORDED where an admin looks. -/// -/// 🚨 Under policy platform-rolls-independently-of-packages a Red is an ADVISORY, -/// not a hold. The assertions stay positive and -/// observable: a Red is asserted as a roll that DID land PLUS the failing module named in the -/// advisories on Admin/UpdatePolicy and on the check verdict (never as "a roll happened", -/// which a poller that ignores the gate also produces). Delete the wiring in -/// SelfUpdateHostedService.ComboThenApply and -/// plus -/// both go red. -/// -/// Only the documented seams are injected: the availability gate (so the OTHER gate never -/// decides anything here), the ACR list and the k8s PATCH. The hub, the workspace, the policy node, -/// the live stream, every stream.Update, the combo assembler and -/// itself are real. -/// -public class ComboGateRollTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string CandidateTag = "9999.0.0-ci.1"; - - /// 🚨 , never a literal: a hand-written 30 s is - /// both a guess about machine speed AND the framework's own write bound, so a test that waits - /// it gives up one second before the mesh can explain itself (#2819). - private static TimeSpan Budget => TestTimeouts.Convergence; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - // AddGitHubSyncTypes registers the BuildCompletion satellite the self-update watch reacts - // to — types only, the same production registration rather than a duplicate declaration. - => base.ConfigureMesh(builder).AddUpdatePolicyType().AddGitHubSyncTypes(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - // ══════════════════════════════════════════════════════════════════════════ - // Red — the refusal, and the whole point of the issue - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 A Red is an ADVISORY under policy platform-rolls-independently-of-packages. - /// It used to hold the roll; a module that does not - /// survive the candidate is a statement about a PACKAGE, so the platform rolls and the module - /// keeps its previous generation and catches up on its own. What must survive is the - /// visibility: the failing module is NAMED where an admin looks (the advisories on - /// Admin/UpdatePolicy) and on the check verdict — never a silent roll. - /// - [Fact(Timeout = 240_000)] - public async Task ARedVerdict_IsAnAdvisory_TheRollLands_AndTheModuleIsNamed() - { - await Seed(TestContext.Current.CancellationToken); - await Record(Red()); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, ComboGate()); - - updater.Tags.Should().Contain(CandidateTag, - "the platform rolls independently of packages — a combo Red names modules, it does not hold"); - content.IsHeld(CandidateTag).Should().BeFalse("nothing package-shaped holds the platform any more"); - content.HasAdvisoriesFor(CandidateTag).Should().BeTrue( - "a roll over a Red that leaves no trace is the silent state this gate must never become"); - content.Advisories.Should().Contain("Widget", - "an unnamed advisory is unactionable — the operator has to know WHICH module does not follow"); - content.Advisories.Should().Contain("AddTracking"); - content.LastCheckVerdict.Should().Contain("PACKAGE ADVISORY"); - content.LastCheckVerdict.Should().Contain("the combo gate recorded RED"); - } - - /// - /// 🚨 The refusal is a hold, not a freeze. It is re-decided from scratch on every check, - /// so a candidate that has since been re-verified clears with nothing to un-stick by hand — - /// which is the property that makes refusing safe to do at all. An instance that quietly stops - /// updating for weeks is its own outage, worse than the roll it prevented. - /// - /// The stale hold is SEEDED rather than produced by an earlier check, so what is under - /// test is the clearing itself and not the sequencing of two pollers. - /// - [Fact(Timeout = 240_000)] - public async Task AReVerifiedCandidate_ClearsAStaleHold_AndRolls() - { - await Seed(TestContext.Current.CancellationToken, held: true); - await Record(Green()); - var updater = new RecordingUpdater(); - - var cleared = await RunOneCheck(updater, ComboGate(), c => !c.IsHeld(CandidateTag)); - - updater.Tags.Should().Contain(CandidateTag); - cleared.HeldReason.Should().BeNull( - "a resolved hold must disappear from the admin tab, not linger as a stale scare"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Green — and the two states that are NEITHER - // ══════════════════════════════════════════════════════════════════════════ - - [Fact(Timeout = 240_000)] - public async Task AGreenVerdict_Clears_AndTheRollLands() - { - await Seed(TestContext.Current.CancellationToken); - await Record(Green()); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, ComboGate()); - - updater.Tags.Should().Contain(CandidateTag, "a verified candidate must not be held"); - content.IsHeld(CandidateTag).Should().BeFalse(); - content.LastCheckVerdict.Should().NotContain("UNVERIFIED", - "a Green IS the clearance — qualifying it would make the qualification meaningless"); - } - - /// - /// 🚨 NotVerifiable is NEITHER. It does not clear — treating "we could not find out" as - /// "all clear" is the outage this gate exists to prevent — and it does not refuse, because - /// refusing the first time evidence is missing would freeze every instance in the fleet. The - /// roll rests on the other gates, and the state is DURABLE on the policy node so an operator can - /// see that an unverified roll was taken. - /// - [Fact(Timeout = 240_000)] - public async Task ANotVerifiableVerdict_NeitherClearsNorRefuses_AndTheUnverifiedRollIsRecorded() - { - await Seed(TestContext.Current.CancellationToken); - await Record(NotVerifiable()); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, ComboGate()); - - updater.Tags.Should().Contain(CandidateTag, - "refusing on 'we could not find out' would brick self-update on every install whose " - + "producer cannot run"); - content.IsHeld(CandidateTag).Should().BeFalse("nothing refused this build"); - content.LastCheckVerdict.Should().Contain("UNVERIFIED", - "an unverified roll that leaves no durable trace is indistinguishable from a verified " - + "one — and a log line depends on a level a deployment may never have set"); - content.LastCheckVerdict.Should().Contain("could NOT answer"); - content.LastCheckVerdict.Should().Contain("docker is not available", - "the caveats naming WHY it could not answer are what make the state actionable"); - } - - /// - /// The state every install in the fleet is in today: no verdict at all. Also neither — and its - /// recorded sentence is DIFFERENT from NotVerifiable's, because "nothing has asked" and "we - /// asked and could not find out" are different incidents with different fixes. - /// - [Fact(Timeout = 240_000)] - public async Task NoVerdictAtAll_DoesNotClearAndDoesNotRefuse_AndSaysWhichItIs() - { - await Seed(TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, ComboGate()); - - updater.Tags.Should().Contain(CandidateTag); - content.IsHeld(CandidateTag).Should().BeFalse(); - content.LastCheckVerdict.Should().Contain("UNVERIFIED"); - content.LastCheckVerdict.Should().Contain("no combo verification has been recorded"); - content.LastCheckVerdict.Should().Contain("mw-combo-verify", - "an absence nobody can act on is how a gate stays unwired for months"); - } - - /// - /// 🚨 A verdict that lands AFTER this pod started has to be honoured — and that is the only - /// shape production ever has. - /// - /// Every other test here records the verdict BEFORE the poller starts, so all of them pass - /// against a service that reads the policy node exactly once and then decides off that frozen - /// snapshot forever. Production is the other way round: a portal pod runs for days, and - /// mw-combo-verify lands its verdict on Admin/UpdatePolicy whenever a candidate is - /// published — always after startup, never before it. A gate that cannot see such a verdict is - /// not a gate; it is #2274's original "built, documented, tested, and called by nothing" with - /// one extra step. - /// - /// It WAS frozen: CreatePolicySource ended in - /// DistinctUntilChanged(c => (c.Policy, c.RequireCiGreen)), a leftover from the - /// Switch-based shape it outlived. With the watch moved out of the policy stream that - /// operator no longer prevented a resubscribe — it filtered the CONTENT, and the same stream is - /// what the poller reads at decision time. Recording a verdict changes neither of those two - /// fields, so the emission carrying it was dropped and every check kept deciding on the content - /// as it stood at startup. - /// - /// The check that must honour it is driven by the SAFETY NET, deliberately: a policy - /// change would refresh the content even with the defect present (it changes the very field the - /// filter keyed on), so a test that triggered one would pass either way. - /// - [Fact(Timeout = 240_000)] - public async Task AVerdictRecordedAfterTheFirstCheck_IsHonouredByTheNextCheck() - { - var ct = TestContext.Current.CancellationToken; - await Seed(TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(); - var service = new GatedSelfUpdateService( - Mesh, new FakeAcrTagLister(), updater, FastPollWithSafetyNet(), - Mesh.ServiceProvider.GetService>(), - new AlwaysAvailable(Mesh, new ConfigurationBuilder().Build()), ComboGate()); - - await service.StartAsync(CancellationToken.None); - try - { - // Wait for the service to have EVALUATED, never for a state to appear inside a bound — - // the same rule RunOneCheck follows and for the same reason. - await service.Evaluations.FirstAsync().Timeout(Budget).Await(ct); - updater.Tags.Should().Contain(CandidateTag, - "nothing is recorded yet, so the first check rolls UNVERIFIED — which is what makes " - + "the verdict arriving next the thing under test"); - - // The mw-combo-verify moment: the verdict lands while the poller is already running. - await Record(Red()); - - // The check's own verdict stamp is the LAST write of a check, so a content carrying it - // carries every earlier write of that check — the advisories included. - var advised = await WaitForContent( - c => c.LastCheckVerdict?.Contains("the combo gate recorded RED") == true); - - // 🚨 Under policy platform-rolls-independently-of-packages the honoured verdict - // is an ADVISORY, not a hold — but it must still be - // SEEN by the running poller, naming the module, exactly as when it was there from the - // start. - advised.IsHeld(CandidateTag).Should().BeFalse(); - advised.HasAdvisoriesFor(CandidateTag).Should().BeTrue( - "the verdict arrived while the poller was running, and an advisory that leaves no " - + "trace is the silent state this gate must never become"); - advised.Advisories.Should().Contain("Widget", - "the advisory must name the module that does not follow, exactly as it does when the " - + "verdict was there from the start"); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - } - - // ══════════════════════════════════════════════════════════════════════════ - // The wiring pin: InstanceComboVerifier is actually RUN - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The defect this issue is about was that nothing CALLED the verifier. This drives the - /// real pipeline on a host that can produce: this instance's combo → - /// materialises the module → - /// runs the gate inside the candidate and folds the evidence - /// → lands the verdict where the Updates - /// tab reads it → the poller refuses the roll. - /// - /// Only the two things a portal pod genuinely cannot have are faked — docker and the - /// module repository (). The assembler, the verifier, the fold, the - /// node write and the roll decision are all real, so removing ANY link of that chain fails this - /// test: no recorded verdict, or a roll that lands. - /// - [Fact(Timeout = 240_000)] - public async Task TheVerifierIsRun_ItsRedVerdictIsRecorded_AndBecomesAnAdvisory() - { - await Seed(TestContext.Current.CancellationToken); - var workRoot = Path.Combine( - Path.GetTempPath(), $"combo-gate-test-{Guid.NewGuid():N}"[..38]); - var updater = new RecordingUpdater(); - var runner = new FakeGateRunner(workRoot, ModuleFailsToInstall()); - - try - { - var content = await RunOneCheck( - updater, - new ProducingComboGate(Mesh, runner, OneModuleCombo()), - c => c.VerificationFor(CandidateTag) is not null - && c.LastCheckVerdict?.Contains("the combo gate recorded RED") == true); - - // 1. The verifier RAN — a verdict exists that nothing but the fold could have produced. - var verdict = content.VerificationFor(CandidateTag); - verdict.Should().NotBeNull( - "the whole defect was that InstanceComboVerifier had no caller — a recorded verdict " - + "is the only proof it now has one"); - verdict!.Verdict.Should().Be(ComboVerdictKind.Red); - verdict.VerifiedPlatform.Should().Be(DockerPlatform, - "a verdict is about ONE architecture and must say which"); - var module = verdict.Modules.Single(); - module.ModuleId.Should().Be("Widget"); - module.Outcome.Should().Be(ModuleVerificationOutcome.Failed); - module.ResolvedCommit.Should().Be(Sha, - "the verdict names its exact input — these files, at this commit"); - module.Failures.Should().ContainSingle().Which.Should().Contain("AddTracking"); - - // 2. And that verdict is an ADVISORY on the roll (policy - // platform-rolls-independently-of-packages): the platform rolled, the - // module is named. - updater.Tags.Should().Contain(CandidateTag); - content.IsHeld(CandidateTag).Should().BeFalse(); - content.Advisories.Should().Contain("Widget"); - } - finally - { - if (Directory.Exists(workRoot)) - Directory.Delete(workRoot, recursive: true); - } - } - - // ══════════════════════════════════════════════════════════════════════════ - // Harness - // ══════════════════════════════════════════════════════════════════════════ - - private const string DockerPlatform = "linux/amd64"; - private static readonly string Sha = new('a', 40); - - /// The gate the poller consults when nothing on this host can produce — the portal-pod - /// shape, and the one every instance in the fleet runs today. - private ComboVerificationGate ComboGate() => new(Mesh); - - /// - /// Starts the poller, waits for ONE check to have been evaluated AND reported, then reads the - /// policy content the check produced. - /// - /// 🚨 Waits for the service to have EVALUATED, never for a state to appear inside a bound: - /// this service is event-driven, so "the state is not there yet" and "the first check has not - /// run yet" are indistinguishable from outside, and on a loaded shard the second is what - /// actually happens — reported as if it were a wrong verdict. - /// - private async Task RunOneCheck( - RecordingUpdater updater, - ComboVerificationGate combo, - Func? settled = null) - { - var ct = TestContext.Current.CancellationToken; - var service = new GatedSelfUpdateService( - Mesh, new FakeAcrTagLister(), updater, FastPoll(), - Mesh.ServiceProvider.GetService>(), - new AlwaysAvailable(Mesh, new ConfigurationBuilder().Build()), combo); - - await service.StartAsync(CancellationToken.None); - try - { - await service.Evaluations.FirstAsync().Timeout(Budget).Await(ct); - // The check's own verdict stamp is the last write of a check, so a content carrying it - // carries every earlier write of that check too. - return await WaitForContent( - settled ?? (c => c.LastCheckVerdict is not null)); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - } - - /// The same poller, plus a fast SAFETY NET so a SECOND check runs without an admin - /// touching the policy. The safety net is the only trigger that re-decides on unchanged policy - /// — which is exactly the shape a verdict landing mid-life has to be honoured through. - private static SelfUpdateOptions FastPollWithSafetyNet() => FastPoll() with - { - SafetyNetCheckInterval = TimeSpan.FromMilliseconds(250), - }; - - private static SelfUpdateOptions FastPoll() => new() - { - RetryInterval = TimeSpan.FromMilliseconds(500), - EventCoalesceWindow = TimeSpan.FromMilliseconds(50), - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - }; - - /// Fake registry (the documented IO seam): one build newer than anything installed. - private sealed class FakeAcrTagLister : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult>([CandidateTag]); - } - - /// Fake k8s patcher (the documented IO seam) — records every applied tag. - private sealed class RecordingUpdater : IDeploymentUpdater - { - private ImmutableList tags = ImmutableList.Empty; - - public ImmutableList Tags => tags; - public bool CanPatch => true; - - public Task LastRolledAtAsync(CancellationToken ct) => - Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) - { - ImmutableInterlocked.Update(ref tags, current => current.Add(versionTag)); - return Task.CompletedTask; - } - } - - /// - /// The availability gate, pinned to "nothing to enforce here". The two gates are INDEPENDENT and - /// this suite is about the combo one — leaving the availability gate free to decide would make - /// every assertion here ambiguous about which gate produced the outcome. - /// - private sealed class AlwaysAvailable(IMessageHub hub, IConfiguration configuration) - : ReleaseAvailabilityService(hub, configuration) - { - public override IObservable IsUpdatable(string? targetVersion) => - Observable.Return(UpdatabilityVerdict.NotEnforced( - "this test host consumes no CI bakes")); - } - - /// A host that CAN produce a verdict: docker and the module repository, faked — the two - /// things a portal pod genuinely does not have. Everything between them is real. - private sealed class FakeGateRunner(string workRoot, GateRunReport report) : IComboGateRunner - { - public string WorkRoot => workRoot; - - public ComboAssemblyOptions Options { get; } = new(); - - public IObservable Run(string imageRef, string root) => - Observable.Return(new CandidateGateRun - { - ExitCode = report.Packages.All(p => p.Success) ? 0 : 1, - ImageDigest = "sha256:4a63eda", - Platform = DockerPlatform, - Report = report, - LogTail = "", - }); - - public IObservable Fetch( - string repositoryUrl, string gitRef, string? subdirectory, string accessToken) => - Observable.Return(new RepoSnapshot(Sha, - [ - // index.json is what makes the folder DISCOVERABLE to the gate — without it the - // verifier refuses to run the gate at all and folds to NotVerifiable. - new RepoFile("index.json", "{\"id\":\"Widget\",\"name\":\"Widget\"}"), - new RepoFile("Thing.json", "{\"nodeType\":\"NodeType\"}"), - ])); - } - - /// The gate on such a host. Only the two seams are overridden; the assembler, the - /// verifier, the fold and the verdict write are the production ones. - private sealed class ProducingComboGate( - IMessageHub hub, IComboGateRunner runner, InstanceCombo combo) - : ComboVerificationGate(hub) - { - protected override IComboGateRunner? ResolveRunner() => runner; - - protected override IObservable? ReadCombo() => Observable.Return(combo); - } - - /// One module, pinned to an EXACT commit — the production majority shape, and the only - /// one the assembler materialises without an explicit allow-moving opt-in. - private static InstanceCombo OneModuleCombo() => new() - { - ReadAt = DateTimeOffset.UtcNow, - Modules = - [ - new ModuleCoordinate - { - ModuleId = "Widget", - Name = "Widget", - GitSync = new GitSyncCoordinate - { - ConfigPath = "Widget/_GitSync", - RepositoryUrl = "https://github.com/acme/widgets", - Branch = "main", - LastSyncCommitSha = Sha, - LastSyncedAt = DateTimeOffset.UtcNow, - }, - }, - ], - }; - - /// The gate report of a module that cannot bind against the candidate — the shape the - /// memex.systemorph.com trap actually took. - private static GateRunReport ModuleFailsToInstall() => new() - { - Packages = - [ - new GateRunPackage - { - Id = "Widget", - NodeCount = 1, - InstallError = - "MissingMethodException: no overload for 'AddTracking' taking 3 arguments", - }, - ], - }; - - /// The poller with both gates supplied directly. Production resolves them from the - /// mesh's service provider; injecting keeps the test from rebuilding the mesh to register two - /// singletons, without changing which code path decides. - private sealed class GatedSelfUpdateService( - IMessageHub hub, - IAcrTagLister acr, - IDeploymentUpdater updater, - SelfUpdateOptions options, - ILogger? logger, - ReleaseAvailabilityService? gate, - ComboVerificationGate? combo) - : SelfUpdateHostedService(hub, acr, updater, options, logger) - { - /// Surfaces the base class's per-check completion. The base member is - /// protected internal and this assembly is not in its friend list, so a derived-class - /// forward is the local way to reach it. - public IObservable Evaluations => ChecksReported; - - protected override ReleaseAvailabilityService? ResolveAvailabilityGate() => gate; - - protected override ComboVerificationGate? ResolveComboGate() => combo; - } - - // ── verdicts ── - - private static ComboVerification Base() => new() - { - CandidateTag = CandidateTag, - ImageRef = $"meshweaver.azurecr.io/memex-portal-ai:{CandidateTag}", - ImageDigest = "sha256:4a63eda", - VerifiedPlatform = DockerPlatform, - VerifiedAt = DateTimeOffset.UtcNow, - }; - - private static ComboVerification Red() => Base() with - { - Verdict = ComboVerdictKind.Red, - Modules = - [ - new ModuleVerification - { - ModuleId = "Widget", - Outcome = ModuleVerificationOutcome.Failed, - ResolvedCommit = Sha, - Failures = ["Widget/Thing: compile failed — no overload for 'AddTracking'"], - }, - ], - }; - - private static ComboVerification Green() => Base() with - { - VerifiedAt = DateTimeOffset.UtcNow.AddMinutes(1), - Verdict = ComboVerdictKind.Green, - Modules = - [ - new ModuleVerification - { - ModuleId = "Widget", - Outcome = ModuleVerificationOutcome.Passed, - ResolvedCommit = Sha, - }, - ], - }; - - private static ComboVerification NotVerifiable() => Base() with - { - Verdict = ComboVerdictKind.NotVerifiable, - Caveats = ["the gate could not run: docker is not available on this host"], - }; - - // ── mesh helpers (the ComboVerdictRecordingTest shapes) ── - - private Task Seed(CancellationToken cancellationToken, bool held = false) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - // 2026-09-08: the candidate is a ci build, eligible only under a pattern that admits it. - var content = new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, Pattern = "*-ci*" }; - if (held) - content = content with - { - HeldTag = CandidateTag, - HeldReason = "'Widget' does not compile against it", - HeldAt = DateTimeOffset.UtcNow.AddHours(-1), - }; - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = content, - }; - // System scope opened/closed SYNCHRONOUSLY around the subscribe — impersonation is an - // AsyncLocal, so Observable.Using would restore it on the wrong thread. - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } - - /// - /// Records a verdict and waits until it is READABLE — not merely until the write's own - /// observable emitted. - /// - /// 🚨 The gate under test reads the policy through its own stream, and - /// stream.Update completing says the patch was accepted, not that every reader can see - /// it. When the check ran before the verdict was visible, the gate honestly reported the state - /// it could see — "no combo verification has been recorded for this candidate" — and the - /// assertion then failed on text that was never about a defect. Measured twice on 2026-09-04 - /// (runs 33863349195 and the shard-5 red on #3319, a PR whose whole diff is two YAML files and - /// two Markdown docs and therefore cannot reach this test at all). - /// - /// So the record is followed by a read-back on the SAME predicate a real reader uses: the - /// verification for this candidate tag is present in the content. That is the condition the - /// test actually depends on, and waiting on it removes the race without widening any bound — - /// the same cure as the other timing assertions fixed in this repo today. - /// - private async Task Record(ComboVerification verdict) - { - await UpdatePolicyNodeType.RecordVerification(Mesh, verdict) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - await WaitForContent(c => c.ComboVerifications.Any( - v => string.Equals(v.CandidateTag, verdict.CandidateTag, StringComparison.OrdinalIgnoreCase))); - } - - /// The first reconciled content matching — the - /// wait-on-the-condition read, never a bare first emission (which can predate the write). - private Task WaitForContent(Func predicate) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace() - .GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .Subscribe(observer); - }) - .Where(predicate) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/ComboVerdictRecordingTest.cs b/test/Memex.Portal.Shared.Test/ComboVerdictRecordingTest.cs deleted file mode 100644 index 20d9272d4f..0000000000 --- a/test/Memex.Portal.Shared.Test/ComboVerdictRecordingTest.cs +++ /dev/null @@ -1,405 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Data; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// The verdict record on Admin/UpdatePolicy — where the Candidate Release Protocol's -/// instance gate lands "cannot update to X" so an admin actually sees it. Against a REAL mesh: -/// writes through the node stream (the only -/// mutation API), the content round-trips the hub serializer (nested records + enums — the shape -/// an operator's combo-verdict.json patch must survive), upsert-by-tag never duplicates, -/// and the list is bounded. The rendering tests are pure: the three verdicts and the join against -/// LatestAvailableTag. -/// -public class ComboVerdictRecordingTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Tag = "3.0.0-ci.99"; - private static readonly TimeSpan Budget = TimeSpan.FromSeconds(30); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddUpdatePolicyType(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - // ── recording ── - - [Fact(Timeout = 60000)] - public async Task RecordVerification_RoundTripsTheWholeVerdict_ThroughTheHubSerializer() - { - await Seed(TestContext.Current.CancellationToken); - var verdict = RedVerdict(Tag) with - { - Caveats = ["'Store' was materialised from a MOVING ref"], - }; - - await Record(verdict); - - // A cross-hub Update completes optimistically — wait for the owner's reconciled state to - // CARRY the verdict, never a bare first emission (which can be the pre-write snapshot). - var content = await WaitForContent(c => c.VerificationFor(Tag) is not null); - var read = content.VerificationFor(Tag); - read.Should().NotBeNull(); - read!.Verdict.Should().Be(ComboVerdictKind.Red); - read.ImageRef.Should().Be(verdict.ImageRef); - read.ImageDigest.Should().Be(verdict.ImageDigest); - read.Caveats.Should().ContainSingle().Which.Should().Contain("MOVING"); - var module = read.Modules.Single(m => m.ModuleId == "Widget"); - module.Outcome.Should().Be(ModuleVerificationOutcome.Failed); - module.ResolvedCommit.Should().Be("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); - module.Failures.Should().ContainSingle().Which.Should().Contain("AddTracking"); - content.Policy.Should().Be(UpdatePolicyKind.Stable, "the admin-chosen policy is preserved"); - } - - [Fact(Timeout = 60000)] - public async Task RecordVerification_UpsertsByTag_ARerunReplacesRatherThanDuplicates() - { - await Seed(TestContext.Current.CancellationToken); - await Record(RedVerdict(Tag)); - await Record(GreenVerdict(Tag) with { VerifiedAt = DateTimeOffset.UtcNow.AddMinutes(1) }); - - var content = await WaitForContent(c => c.VerificationFor(Tag)?.Verdict == ComboVerdictKind.Green); - content.ComboVerifications.Should().ContainSingle( - v => string.Equals(v.CandidateTag, Tag, StringComparison.OrdinalIgnoreCase), - "a re-verification of the same candidate replaces its verdict"); - content.VerificationFor(Tag)!.Verdict.Should().Be(ComboVerdictKind.Green); - } - - [Fact(Timeout = 60000)] - public async Task RecordVerification_IsBounded_NewestKept_TheNodeNeverGrowsWithoutLimit() - { - await Seed(TestContext.Current.CancellationToken); - var baseTime = DateTimeOffset.UtcNow; - for (var i = 0; i < UpdatePolicyNodeType.MaxRecordedVerifications + 2; i++) - await Record(GreenVerdict($"3.0.0-ci.{i}") with { VerifiedAt = baseTime.AddMinutes(i) }); - - // Wait for the LAST write to be reconciled — the tell of the read-after-write race is the - // newest verdict missing from a bare-first-emission read. - var lastTag = $"3.0.0-ci.{UpdatePolicyNodeType.MaxRecordedVerifications + 1}"; - var content = await WaitForContent(c => c.VerificationFor(lastTag) is not null); - content.ComboVerifications.Should().HaveCount(UpdatePolicyNodeType.MaxRecordedVerifications); - content.ComboVerifications[0].CandidateTag.Should().Be( - $"3.0.0-ci.{UpdatePolicyNodeType.MaxRecordedVerifications + 1}", "newest first"); - content.VerificationFor("3.0.0-ci.0").Should().BeNull("the oldest fell off"); - content.VerificationFor("3.0.0-ci.1").Should().BeNull(); - } - - // ── rendering: the join an admin actually sees ── - - [Fact] - public void StatusMarkdown_RedVerdict_IsAnAdvisoryBesideUpdateAvailable_NamesEveryFailingModule() - { - var content = new UpdatePolicyContent - { - LatestAvailableTag = Tag, - CheckedAt = DateTimeOffset.UtcNow, - ComboVerifications = - [ - RedVerdict(Tag) with - { - Modules = - [ - FailedModule("Widget", "Widget/Thing: compile failed — no 'AddTracking'"), - FailedModule("Store", "install: import faulted"), - PassedModule("Edu"), - ], - Caveats = ["'Store' has diverged from its install record"], - }, - ], - }; - - var markdown = UpdatePolicySettingsTab.StatusMarkdown(content, EchoLocalizer); - - // 🚨 Under policy platform-rolls-independently-of-packages a Red is an ADVISORY: the - // build is still what this install rolls to, so the - // availability line stays, and the failing modules are named beneath it — never silently. - markdown.Should().Contain($"ui.updateBlocked[{Tag}]", - "a Red must not read as a plain 'update available' — the modules that do not follow are named"); - markdown.Should().Contain("ui.updateLatestAvailable", - "the platform rolls independently of packages — a Red does not replace the release line"); - markdown.Should().Contain("**Widget**").And.Contain("AddTracking"); - markdown.Should().Contain("**Store**").And.Contain("import faulted"); - markdown.Should().NotContain("**Edu**", "only failing modules are listed"); - markdown.Should().Contain("ui.updateVerifiedAtLine"); - markdown.Should().Contain("ui.updateCaveats").And.Contain("diverged", - "caveats are mandatory-to-surface on EVERY verdict, red included"); - } - - [Fact] - public void StatusMarkdown_GreenWithCaveats_NeverRendersAsAnUnqualifiedPass() - { - var content = new UpdatePolicyContent - { - LatestAvailableTag = Tag, - ComboVerifications = - [ - GreenVerdict(Tag) with - { - Caveats = ["'Widget' was materialised from a MOVING ref"], - }, - ], - }; - - var markdown = UpdatePolicySettingsTab.StatusMarkdown(content, EchoLocalizer); - - markdown.Should().Contain("ui.updateVerifiedGreen[1]"); - markdown.Should().Contain("ui.updateCaveats").And.Contain("MOVING", - "a green over a moving pin must not read as reproducible evidence"); - } - - [Fact] - public void StatusMarkdown_GreenVerdict_SaysVerified_KeepsTheAvailableLine() - { - var content = new UpdatePolicyContent - { - LatestAvailableTag = Tag, - ComboVerifications = [GreenVerdict(Tag)], - }; - - var markdown = UpdatePolicySettingsTab.StatusMarkdown(content, EchoLocalizer); - - markdown.Should().Contain($"ui.updateLatestAvailable[{Tag}]"); - markdown.Should().Contain("ui.updateVerifiedGreen[1]", "one module verified"); - } - - [Fact] - public void StatusMarkdown_NotVerifiable_SurfacesTheCaveats() - { - var content = new UpdatePolicyContent - { - LatestAvailableTag = Tag, - ComboVerifications = - [ - GreenVerdict(Tag) with - { - Verdict = ComboVerdictKind.NotVerifiable, - Caveats = ["the tester produced no structured report (exit 2)"], - Modules = - [ - PassedModule("Widget") with - { - Outcome = ModuleVerificationOutcome.NotVerified, - Failures = ["Refused: pinned only to a moving ref"], - }, - ], - }, - ], - }; - - var markdown = UpdatePolicySettingsTab.StatusMarkdown(content, EchoLocalizer); - - markdown.Should().Contain($"ui.updateNotVerifiable[{Tag}]"); - markdown.Should().Contain("no structured report", - "a partial answer must never read as a healthy one"); - markdown.Should().Contain("**Widget**").And.Contain("moving ref", - "the per-module reason names WHICH module prevented verification"); - } - - /// - /// Retired modules (#3848, policy record-served-commit-on-install) render under their - /// own heading on EVERY verdict branch, and apart from the failed and not-verified lists: a - /// retired module is named, and never reads as a failure. - /// - [Theory] - [InlineData(ComboVerdictKind.Green)] - [InlineData(ComboVerdictKind.Red)] - [InlineData(ComboVerdictKind.NotVerifiable)] - public void StatusMarkdown_RetiredModules_AreNamedUnderTheirOwnHeading_OnEveryVerdict(ComboVerdictKind kind) - { - var verdict = (kind == ComboVerdictKind.Red ? RedVerdict(Tag) : GreenVerdict(Tag)) with - { - Verdict = kind, - RetiredModules = - [ - new RetiredModule { ModuleId = "PlatformUI", Reason = "retired, not verifiable: gone at HEAD" }, - new RetiredModule { ModuleId = "DeepSign", Reason = "retired, not verifiable: gone at HEAD" }, - ], - }; - if (kind == ComboVerdictKind.NotVerifiable) - verdict = verdict with - { - Modules = - [ - PassedModule("Widget") with - { - Outcome = ModuleVerificationOutcome.NotVerified, - Failures = ["Refused: pinned only to a moving ref"], - }, - ], - }; - var content = new UpdatePolicyContent { LatestAvailableTag = Tag, ComboVerifications = [verdict] }; - - var markdown = UpdatePolicySettingsTab.StatusMarkdown(content, EchoLocalizer); - - markdown.Should().Contain("ui.updateRetiredModules[2]", - "the retired modules carry their own heading, with their count"); - var retiredBlock = markdown[markdown.IndexOf("ui.updateRetiredModules[2]", StringComparison.Ordinal)..]; - retiredBlock.Should().Contain("- **PlatformUI**").And.Contain("- **DeepSign**", - "every retired module is named beneath that heading"); - markdown[..markdown.IndexOf("ui.updateRetiredModules[2]", StringComparison.Ordinal)] - .Should().NotContain("PlatformUI", "a retired module is never listed as failed or not verified") - .And.NotContain("DeepSign"); - if (kind == ComboVerdictKind.Red) - markdown.Should().Contain("**Widget**", "the failing module is still listed apart"); - } - - /// NEGATIVE CONTROL — a verdict with no retired modules renders no retired heading. - [Fact] - public void StatusMarkdown_NoRetiredModules_RendersNoRetiredHeading() - { - var content = new UpdatePolicyContent { LatestAvailableTag = Tag, ComboVerifications = [GreenVerdict(Tag)] }; - - UpdatePolicySettingsTab.StatusMarkdown(content, EchoLocalizer) - .Should().NotContain("ui.updateRetiredModules"); - } - - [Fact] - public void StatusMarkdown_NoVerdictForTheTag_RendersThePlainAvailableLine() - { - var content = new UpdatePolicyContent - { - LatestAvailableTag = Tag, - // A verdict for a DIFFERENT tag must not decorate this one. - ComboVerifications = [RedVerdict("2.0.0-ci.1")], - }; - - var markdown = UpdatePolicySettingsTab.StatusMarkdown(content, EchoLocalizer); - - markdown.Should().Contain($"ui.updateLatestAvailable[{Tag}]"); - markdown.Should().NotContain("ui.updateBlocked"); - } - - /// - /// 🚨 #2553. This test used to assert that an empty LatestAvailableTag renders "no newer - /// version detected" — pinning a CLAIM the surface has no evidence for. An install that checked - /// and found nothing and an install whose checker never ran both reach this branch, and the - /// second is the one that matters: it is how memex sat three builds behind for 7 h while this - /// line reassured whoever read it. Without a completed check there is nothing to report but the - /// absence of a check. - /// - [Fact] - public void StatusMarkdown_NoTagAndNoCheckEverCompleted_SaysNoCheckHasRun() - { - UpdatePolicySettingsTab.StatusMarkdown(new UpdatePolicyContent(), EchoLocalizer) - .Should().Be("ui.updateNeverChecked[]"); - } - - /// The other half of the same branch, and the reason it may not be collapsed back into - /// one sentence: a check that RAN and found nothing is genuine good news, and it says when. - [Fact] - public void StatusMarkdown_NoTagButACheckDidRun_SaysNothingNewerAndWhen() - { - var content = new UpdatePolicyContent - { - LastCheckedAt = new DateTimeOffset(2026, 8, 29, 7, 30, 0, TimeSpan.Zero), - LastCheckVerdict = "no newer release: 12 tag(s) listed, none newer than the installed 3.0.0.", - }; - - var markdown = UpdatePolicySettingsTab.StatusMarkdown(content, EchoLocalizer); - - markdown.Should().Contain("ui.updateNoNewerVersion[]"); - markdown.Should().Contain("ui.updateCheckedAt[2026-08-29 07:30]"); - } - - // ── helpers ── - - /// A localizer echoing key + args, so assertions pin WHICH key renders (the real - /// catalog is guarded by LocalizationTest). - private static string EchoLocalizer(string key, object?[] args) => - $"{key}[{string.Join(',', args)}]"; - - private Task Seed(CancellationToken cancellationToken) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = UpdatePolicyKind.Stable }, - }; - // System scope opened/closed SYNCHRONOUSLY around the subscribe — the - // NonAdminUpdateStatusTest shape, for the same AsyncLocal-leak reason. - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } - - private Task Record(ComboVerification verdict) => - UpdatePolicyNodeType.RecordVerification(Mesh, verdict) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - /// The first reconciled content matching — the - /// wait-on-the-condition read (never a bare first emission, which can predate the write). - private Task WaitForContent(Func predicate) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .Subscribe(observer); - }) - .Where(predicate) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - private static ComboVerification GreenVerdict(string tag) => new() - { - CandidateTag = tag, - ImageRef = $"meshweaver.azurecr.io/memex-portal-ai:{tag}", - ImageDigest = "meshweaver.azurecr.io/memex-portal-ai@sha256:feedc0de", - VerifiedAt = DateTimeOffset.UtcNow, - ComboReadAt = DateTimeOffset.UtcNow, - Verdict = ComboVerdictKind.Green, - Modules = [PassedModule("Widget")], - }; - - private static ComboVerification RedVerdict(string tag) => GreenVerdict(tag) with - { - Verdict = ComboVerdictKind.Red, - Modules = [FailedModule("Widget", "Widget/Thing: compile failed — no 'AddTracking'")], - }; - - private static ModuleVerification PassedModule(string id) => new() - { - ModuleId = id, - Outcome = ModuleVerificationOutcome.Passed, - ResolvedCommit = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ContentHash = new string('1', 64), - Pin = MaterializationPin.ExactCommit, - }; - - private static ModuleVerification FailedModule(string id, string failure) => - PassedModule(id) with - { - Outcome = ModuleVerificationOutcome.Failed, - Failures = [failure], - }; -} diff --git a/test/Memex.Portal.Shared.Test/ComboVerificationDeadlineTest.cs b/test/Memex.Portal.Shared.Test/ComboVerificationDeadlineTest.cs deleted file mode 100644 index d85a4a29a0..0000000000 --- a/test/Memex.Portal.Shared.Test/ComboVerificationDeadlineTest.cs +++ /dev/null @@ -1,122 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using System.Reactive; -using System.Reactive.Linq; -using Memex.Portal.Shared.Api; -using Microsoft.AspNetCore.Http; -using Microsoft.Reactive.Testing; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// MeshWeaver#6790 — POST /api/plugins/combo-verification let its 30 s deadline escape as a -/// bare , which faulted the endpoint's task into an unhandled 500 -/// naming neither the route nor which wait had stalled. Measured on the incident: the CI verifier's -/// POST answered 500 after 31 s and the node's version history shows the verdict was not written. -/// -/// These drive the endpoint's own composition () -/// on VIRTUAL time, so the deadline expires deterministically and without a wall-clock wait: a -/// write that never completes answers 504 with phase write; a write that completes but is never -/// confirmed answers 504 with phase confirm. Controls: a confirmed write answers its own result -/// before the deadline; a fault that is NOT a deadline still propagates; and a -/// raised UPSTREAM (not this route's own timer) also still propagates — -/// the fix turns one designed outcome into a response, it does not swallow faults. -/// -public class ComboVerificationDeadlineTest -{ - private static readonly TimeSpan Deadline = ReleaseGateEndpoints.VerificationDeadline; - - private static (ImmutableList Results, Exception? Error, bool Completed) Run( - TestScheduler scheduler, IObservable write, Func> confirmed, TimeSpan advance) - { - var results = ImmutableList.Empty; - Exception? error = null; - var completed = false; - using var _ = ReleaseGateEndpoints.RecordAndConfirm( - write, confirmed, Deadline, scheduler, - phase => Results.Json(new { phase }, - statusCode: StatusCodes.Status504GatewayTimeout)) - .Subscribe(r => results = results.Add(r), ex => error = ex, () => completed = true); - scheduler.AdvanceBy(advance.Ticks); - return (results, error, completed); - } - - private static string? PhaseOf(IResult result) => - result is IValueHttpResult { Value: { } value } - ? value.GetType().GetProperty("phase")?.GetValue(value) as string - : null; - - private static int? StatusOf(IResult result) => - (result as IStatusCodeHttpResult)?.StatusCode; - - [Fact] - public void A_write_that_never_completes_answers_504_naming_the_write_phase() - { - var scheduler = new TestScheduler(); - var (results, error, completed) = Run(scheduler, Observable.Never(), - () => Observable.Never(), Deadline + TimeSpan.FromSeconds(1)); - - error.Should().BeNull("an expired deadline is a designed outcome, answered — never an unhandled fault (#6790)"); - completed.Should().BeTrue(); - results.Should().ContainSingle(); - StatusOf(results[0]).Should().Be(StatusCodes.Status504GatewayTimeout); - PhaseOf(results[0]).Should().Be(ReleaseGateEndpoints.WritePhase, - "the write had not completed when the deadline expired, so the answer names that phase " - + "(the verdict's outcome is unknown — a queued write may still land)"); - } - - [Fact] - public void A_write_that_completes_but_is_never_confirmed_answers_504_naming_the_confirm_phase() - { - var scheduler = new TestScheduler(); - var (results, error, _) = Run(scheduler, Observable.Return(Unit.Default), - () => Observable.Never(), Deadline + TimeSpan.FromSeconds(1)); - - error.Should().BeNull(); - results.Should().ContainSingle(); - StatusOf(results[0]).Should().Be(StatusCodes.Status504GatewayTimeout); - PhaseOf(results[0]).Should().Be(ReleaseGateEndpoints.ConfirmPhase); - } - - [Fact] - public void Control_a_confirmed_write_answers_its_own_result_before_the_deadline() - { - var scheduler = new TestScheduler(); - var (results, error, completed) = Run(scheduler, Observable.Return(Unit.Default), - () => Observable.Return(Results.Ok(new { recorded = true })), TimeSpan.FromSeconds(1)); - - error.Should().BeNull(); - completed.Should().BeTrue(); - results.Should().ContainSingle(); - StatusOf(results[0]).Should().Be(StatusCodes.Status200OK); - } - - [Fact] - public void Control_a_fault_that_is_not_a_deadline_still_propagates() - { - var scheduler = new TestScheduler(); - var (results, error, _) = Run(scheduler, - Observable.Throw(new InvalidOperationException("owner refused the write")), - () => Observable.Never(), TimeSpan.FromSeconds(1)); - - results.Should().BeEmpty(); - error.Should().BeOfType( - "only the deadline is answered; any other fault is a defect and reaches the exception handler"); - } - - [Fact] - public void Control_an_upstream_TimeoutException_is_not_mistaken_for_this_routes_deadline() - { - var scheduler = new TestScheduler(); - var (results, error, _) = Run(scheduler, - Observable.Throw(new TimeoutException("the write's own initial-state bound expired")), - () => Observable.Never(), TimeSpan.FromSeconds(1)); - - results.Should().BeEmpty( - "only this route's own timer yields the 504; an upstream TimeoutException is a fault"); - error.Should().BeOfType(); - } -} diff --git a/test/Memex.Portal.Shared.Test/ComboVerifiesTheRecordedCommitTest.cs b/test/Memex.Portal.Shared.Test/ComboVerifiesTheRecordedCommitTest.cs deleted file mode 100644 index 091cafc0aa..0000000000 --- a/test/Memex.Portal.Shared.Test/ComboVerifiesTheRecordedCommitTest.cs +++ /dev/null @@ -1,367 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#3848 / #4413 — policy record-served-commit-on-install: the package -/// integrity verifier (the combo assembler under ) checks an -/// installed package against the commit its install RECORD names, never against the source -/// repository's moving HEAD; and a package whose source folder was retired is reported as -/// retired, not verifiable without failing the verification. -/// -/// Measured before the decision on the public instance (combo-verify run 38416709107): -/// NotVerifiable — 86 module(s), 0 passed, because 17 records were checked at a HEAD -/// that had moved past their install and four installed packages (ContainerRegistry, -/// PlatformUI, Skill, DeepSign) had left the source repository — any one of -/// which refused the whole root. The fixture reproduces both shapes over an in-memory repository -/// whose HEAD has moved, with no network and no docker. -/// -public class ComboVerifiesTheRecordedCommitTest : IDisposable -{ - private const string Repo = "https://github.com/Systemorph/ComboFixture"; - private static readonly string InstalledCommit = new('1', 40); - private static readonly string HeadCommit = new('2', 40); - private const string InstalledVersion = "aaaaaaaaaaaaaaaa"; - private const string MovedVersion = "bbbbbbbbbbbbbbbb"; - - private readonly string workRoot = Path.Combine( - Path.GetTempPath(), $"combo-commit-test-{Guid.NewGuid():N}"[..40]); - - public void Dispose() - { - if (Directory.Exists(workRoot)) - Directory.Delete(workRoot, recursive: true); - } - - // ── The recorded commit is the reference ───────────────────────────────────────────────────── - - /// A package recorded at its install commit verifies there, although HEAD has - /// moved on and carries another module version. - [Fact] - public async Task APackageRecordedAtItsCommit_VerifiesAtThatCommit_ThoughHeadMoved() - { - var run = await Verify(Combo(Package("Widget", InstalledCommit, InstalledVersion)), Repository( - At(InstalledCommit, Module("Widget", InstalledVersion)), - At(HeadCommit, Module("Widget", MovedVersion)))); - - var assembled = run.Assembly.Modules.Single(); - assembled.Status.Should().Be(ModuleAssemblyStatus.Materialized, assembled.Error ?? ""); - assembled.Pin.Should().Be(MaterializationPin.ExactCommit); - assembled.ResolvedCommit.Should().Be(InstalledCommit, - "the content is checked at the commit the install recorded, never at HEAD"); - run.Verdict.Verdict.Should().Be(ComboVerdictKind.Green); - } - - /// NEGATIVE CONTROL — content that diverges from its recorded commit still FAILS: - /// the tree at the recorded commit does not carry the module version the record claims. - /// Before the decision an exact install commit was never proved, so this materialised. - [Fact] - public async Task ContentThatDivergesFromItsRecordedCommit_StillFails() - { - var run = await Verify(Combo(Package("Widget", InstalledCommit, InstalledVersion)), Repository( - At(InstalledCommit, Module("Widget", MovedVersion)), - At(HeadCommit, Module("Widget", MovedVersion)))); - - var assembled = run.Assembly.Modules.Single(); - assembled.Status.Should().Be(ModuleAssemblyStatus.Failed); - assembled.Error.Should().Contain("diverges from the commit it was recorded at"); - run.Verdict.Caveats.Should().NotContain(c => c.Contains("synced past the install"), - "a commit does not move, so a later sync cannot explain a divergence AT the recorded " - + "commit — that caveat would contradict the failure's own diagnosis"); - run.Verdict.Verdict.Should().Be(ComboVerdictKind.NotVerifiable); - } - - /// NEGATIVE CONTROL — an existing install with NO recorded commit behaves exactly as - /// before: checked at HEAD, and refused when HEAD moved past it. Nothing is - /// backfilled; such a record restamps on its next update. - [Fact] - public async Task ARecordWithNoCommit_IsStillCheckedAtHead_AsBefore() - { - var run = await Verify(Combo(Package("Widget", "main", InstalledVersion)), Repository( - At("main", Module("Widget", MovedVersion)), - At("HEAD", Module("Widget", MovedVersion)))); - - var assembled = run.Assembly.Modules.Single(); - assembled.Status.Should().Be(ModuleAssemblyStatus.Failed); - assembled.Error.Should().Contain("moduleVersion mismatch"); - run.Verdict.Caveats.Should().Contain(c => c.Contains("synced past the install"), - "a divergence read at a moving ref IS the sync-past-the-install case"); - run.Verdict.RetiredModules.Should().BeEmpty("the package still exists in its source"); - run.Verdict.Verdict.Should().Be(ComboVerdictKind.NotVerifiable); - } - - // ── Retired, not verifiable ────────────────────────────────────────────────────────────────── - - /// - /// 🚨 A package whose source folder left the repository is reported as RETIRED and does not - /// fail the verification: the live module verifies Green beside it. Before the decision the - /// retired module was a FAILED assembly entry, the gate never ran, and the verdict was - /// NotVerifiable for every candidate, forever. - /// - [Fact] - public async Task ARetiredPackage_IsReportedRetired_AndDoesNotFailTheVerification() - { - var run = await Verify( - Combo( - Package("Widget", InstalledCommit, InstalledVersion), - Package("PlatformUI", null, InstalledVersion)), - Repository( - At(InstalledCommit, Module("Widget", InstalledVersion)), - At("HEAD", Module("Widget", MovedVersion)))); - - var retired = run.Assembly.Modules.Single(m => m.ModuleId == "PlatformUI"); - retired.Status.Should().Be(ModuleAssemblyStatus.Retired, retired.Error ?? ""); - retired.Error.Should().StartWith("retired, not verifiable"); - run.Assembly.Success.Should().BeTrue("a retired module does not fail the assembly"); - - run.Verdict.Verdict.Should().Be(ComboVerdictKind.Green); - run.Verdict.Modules.Select(m => m.ModuleId).Should().Equal("Widget"); - var reported = run.Verdict.RetiredModules.Should().ContainSingle().Which; - reported.ModuleId.Should().Be("PlatformUI"); - reported.Status.Should().Be(ModuleAssemblyStatus.Retired); - } - - /// - /// The CI summary names the retired module on its own line and counts it apart: never as a - /// failure and never as not verified — the reporting contract the verdict's own list carries. - /// - [Fact] - public async Task TheSummary_NamesARetiredModule_AndCountsItApart() - { - var run = await Verify( - Combo( - Package("Widget", InstalledCommit, InstalledVersion), - Package("PlatformUI", null, InstalledVersion)), - Repository( - At(InstalledCommit, Module("Widget", InstalledVersion)), - At("HEAD", Module("Widget", MovedVersion)))); - - using var output = new StringWriter(); - run.WriteSummary(output); - var summary = output.ToString(); - - summary.Should().Contain($" -- PlatformUI [{ModuleAssemblyStatus.Retired}] retired, not verifiable", - "the retired module is named on its own line, with its reason"); - summary.Should().Contain( - "verdict: Green — 1 module(s), 1 passed, 0 failed, 0 not verified, 1 retired (not verifiable, not failing).", - "it is counted as retired, and neither as failed nor as not verified"); - } - - /// NEGATIVE CONTROL — with nothing retired the summary counts zero retired and names - /// no retired line. - [Fact] - public async Task TheSummary_WithNothingRetired_CountsZeroRetired() - { - var run = await Verify(Combo(Package("Widget", InstalledCommit, InstalledVersion)), Repository( - At(InstalledCommit, Module("Widget", InstalledVersion)))); - - using var output = new StringWriter(); - run.WriteSummary(output); - var summary = output.ToString(); - - summary.Should().NotContain(" -- "); - summary.Should().Contain(", 0 retired (not verifiable, not failing)."); - } - - /// The DeepSign shape: recorded at a commit whose tree no longer carries the - /// folder, and gone from HEAD too — retired. - [Fact] - public async Task APackageGoneAtItsRecordedCommitAndAtHead_IsRetired() - { - var run = await Verify( - Combo( - Package("Widget", InstalledCommit, InstalledVersion), - Package("DeepSign", InstalledCommit, InstalledVersion)), - Repository( - At(InstalledCommit, Module("Widget", InstalledVersion)), - At("HEAD", Module("Widget", InstalledVersion)))); - - run.Assembly.Modules.Single(m => m.ModuleId == "DeepSign").Status - .Should().Be(ModuleAssemblyStatus.Retired); - run.Verdict.Verdict.Should().Be(ComboVerdictKind.Green); - } - - /// NEGATIVE CONTROL — a folder absent at the recorded commit but PRESENT at - /// HEAD is not retired: the record is wrong, the package is not gone, and it fails. - [Fact] - public async Task AFolderMissingAtItsCommitButPresentAtHead_IsNotRetired_AndFails() - { - var run = await Verify(Combo(Package("Widget", InstalledCommit, InstalledVersion)), Repository( - At(InstalledCommit), - At("HEAD", Module("Widget", InstalledVersion)))); - - var assembled = run.Assembly.Modules.Single(); - assembled.Status.Should().Be(ModuleAssemblyStatus.Failed); - assembled.Error.Should().Contain("not retired"); - run.Verdict.RetiredModules.Should().BeEmpty(); - run.Verdict.Verdict.Should().Be(ComboVerdictKind.NotVerifiable); - } - - /// NEGATIVE CONTROL — a combo whose EVERY module is retired is not a success: the - /// gate would run over an empty root and pass vacuously. - [Fact] - public async Task AComboOfOnlyRetiredPackages_IsNotVerifiable_NeverGreen() - { - var run = await Verify(Combo(Package("PlatformUI", null, InstalledVersion)), Repository( - At("HEAD", Module("Widget", InstalledVersion)))); - - run.Assembly.Modules.Single().Status.Should().Be(ModuleAssemblyStatus.Retired); - run.Assembly.Success.Should().BeFalse(); - run.Verdict.Verdict.Should().Be(ComboVerdictKind.NotVerifiable); - } - - // ── A truncated listing proves no absence (MeshWeaver#6742) ───────────────────────────────── - - /// - /// 🚨 A folder missing from a TRUNCATED tree at the recorded commit is INDETERMINATE, not - /// absent — even when a complete HEAD lacks it too. The package stays failed, is never - /// reported retired, and the verdict cannot go Green on a partial listing. - /// - [Fact] - public async Task AFolderMissingFromATruncatedTreeAtItsCommit_IsNotRetired() - { - var run = await Verify( - Combo( - Package("Widget", InstalledCommit, InstalledVersion), - Package("DeepSign", InstalledCommit, InstalledVersion)), - Repository( - TruncatedAt(InstalledCommit, Module("Widget", InstalledVersion)), - At("HEAD", Module("Widget", InstalledVersion)))); - - var deepSign = run.Assembly.Modules.Single(m => m.ModuleId == "DeepSign"); - deepSign.Status.Should().Be(ModuleAssemblyStatus.Failed, deepSign.Error ?? ""); - deepSign.Error.Should().Contain("indeterminate listing"); - run.Verdict.RetiredModules.Should().BeEmpty(); - run.Verdict.Verdict.Should().Be(ComboVerdictKind.NotVerifiable); - } - - /// 🚨 Absent at a complete recorded commit, but the HEAD read that would - /// confirm the retirement was TRUNCATED: whether the package left its source is unknown, so - /// it stays failed and says so. - [Fact] - public async Task AFolderMissingAtItsCommit_AndFromATruncatedHead_IsNotRetired() - { - var run = await Verify( - Combo( - Package("Widget", InstalledCommit, InstalledVersion), - Package("DeepSign", InstalledCommit, InstalledVersion)), - Repository( - At(InstalledCommit, Module("Widget", InstalledVersion)), - TruncatedAt("HEAD", Module("Widget", InstalledVersion)))); - - var deepSign = run.Assembly.Modules.Single(m => m.ModuleId == "DeepSign"); - deepSign.Status.Should().Be(ModuleAssemblyStatus.Failed, deepSign.Error ?? ""); - deepSign.Error.Should().Contain("could not be checked"); - deepSign.Error.Should().Contain("indeterminate listing"); - run.Verdict.RetiredModules.Should().BeEmpty(); - run.Verdict.Verdict.Should().Be(ComboVerdictKind.NotVerifiable); - } - - /// 🚨 A record with no commit is fetched at HEAD, where the absence used to - /// BE the retirement fact with no second read. A truncated HEAD proves no absence. - [Fact] - public async Task ARecordReadAtATruncatedHead_IsNotRetired() - { - var run = await Verify( - Combo( - Package("Widget", InstalledCommit, InstalledVersion), - Package("PlatformUI", null, InstalledVersion)), - Repository( - At(InstalledCommit, Module("Widget", InstalledVersion)), - TruncatedAt("HEAD", Module("Widget", MovedVersion)))); - - var platformUi = run.Assembly.Modules.Single(m => m.ModuleId == "PlatformUI"); - platformUi.Status.Should().Be(ModuleAssemblyStatus.Failed, platformUi.Error ?? ""); - platformUi.Error.Should().Contain("indeterminate listing"); - run.Verdict.RetiredModules.Should().BeEmpty(); - run.Verdict.Verdict.Should().Be(ComboVerdictKind.NotVerifiable); - } - - // ── Harness ────────────────────────────────────────────────────────────────────────────────── - - private Task Verify(InstanceCombo combo, Func repository) - { - var assembler = new InstanceComboAssembler( - (_, gitRef, _, _) => Observable.Return(repository(gitRef)), - IoPool.Unbounded, - new ComboAssemblyOptions { DefaultSourceRepository = Repo }); - // The gate seam: every module the assembly MATERIALISED passes — so the verdict below is - // decided by the assembly alone, which is what this test is about. - var verifier = new InstanceComboVerifier(assembler, (_, root) => Observable.Return(new CandidateGateRun - { - ExitCode = 0, - ImageDigest = "sha256:fixture", - Platform = "linux/amd64", - Report = new GateRunReport - { - Packages = [.. Directory.GetDirectories(root) - .Select(dir => new GateRunPackage { Id = Path.GetFileName(dir) })], - }, - })); - return verifier.Verify(combo, "registry.example/portal:1.0.0", workRoot) - .Timeout(TimeSpan.FromSeconds(60)) - .Await(TestContext.Current.CancellationToken); - } - - private static InstanceCombo Combo(params ModuleCoordinate[] modules) => new() - { - ReadAt = DateTimeOffset.UtcNow, - Modules = [.. modules], - }; - - private static ModuleCoordinate Package(string id, string? installedFromRef, string moduleVersion) => new() - { - ModuleId = id, - Package = new PackageCoordinate - { - RecordPath = $"Plugins/{id}", - PackageId = id, - SourceName = "Plugins", - ModuleVersion = moduleVersion, - InstalledFromRef = installedFromRef, - }, - }; - - private static IReadOnlyList Module(string id, string moduleVersion) => - [ - new RepoFile($"{id}/index.json", $$"""{ "id": "{{id}}", "nodeType": "Space", "name": "{{id}}" }"""), - new RepoFile($"{id}/{ModuleManifest.FileName}", $$""" - { "module": "{{id}}", "moduleVersion": "{{moduleVersion}}", "version": "1.0.0", "files": {} } - """), - ]; - - private static (string Ref, IReadOnlyList Files, bool Complete) At( - string gitRef, params IReadOnlyList[] modules) => - (gitRef, [.. modules.SelectMany(m => m)], true); - - /// A tree whose listing was TRUNCATED (GitHub's truncated: true on a recursive - /// tree read): it names only the files given, and omits others it never lists. - private static (string Ref, IReadOnlyList Files, bool Complete) TruncatedAt( - string gitRef, params IReadOnlyList[] modules) => - (gitRef, [.. modules.SelectMany(m => m)], false); - - /// An in-memory repository: one tree per ref. A ref the fixture does not name is an - /// empty, COMPLETE tree — the repository simply does not carry anything there. - private static Func Repository( - params (string Ref, IReadOnlyList Files, bool Complete)[] trees) => - gitRef => - { - var tree = trees.FirstOrDefault(t => string.Equals(t.Ref, gitRef, StringComparison.Ordinal)); - var commit = gitRef.Length == 40 ? gitRef : HeadCommit; - return new RepoSnapshot(commit, (tree.Files ?? []).ToImmutableList()) - { - ListingIsComplete = tree.Ref is null || tree.Complete, - }; - }; -} diff --git a/test/Memex.Portal.Shared.Test/ConcurrentModuleLandingTest.cs b/test/Memex.Portal.Shared.Test/ConcurrentModuleLandingTest.cs deleted file mode 100644 index bc846805da..0000000000 --- a/test/Memex.Portal.Shared.Test/ConcurrentModuleLandingTest.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 #4026 — two REPLICAS landing the same module concurrently. Every portal replica shares one -/// /data volume, and a landing used to read the module's activation entry at its start, -/// decide against what it read, and REPLACE the entry by an unconditional rename seconds later. Two -/// publishes of one module reaching two replicas inside that window were last-writer-wins, so the -/// version rule of #3996 ("an older upload never displaces a newer head whose bytes are present") -/// held per PROCESS only: an older build finishing on replica B after replica A landed the newer one -/// put the head back. -/// -/// The interleaving is made DETERMINISTIC, not timed: each "replica" is its own -/// over one landing root (its own cap-1 pool — the thing that -/// serialises landings within ONE process and never across two), and the interrupted replica's -/// recording window runs the other replica's whole landing on the same thread. No sleep, no gate, -/// no timer — the window is exactly the one the defect lived in: after the bytes are on the volume, -/// before anything is recorded. -/// -public class ConcurrentModuleLandingTest : IDisposable -{ - private const string Module = "MeshWeaver.Test.ConcurrentShelf"; - private const string MarkerAsset = "wwwroot/build.txt"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-concurrent-landing-" + Guid.NewGuid().ToString("N")); - - /// Creates the landing root both replicas share. - public ConcurrentModuleLandingTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - /// A module whose bytes LINK here (a real platform assembly) and whose marker asset - /// makes each version a distinct content address — without it two versions would resolve to - /// ONE generation directory and every head claim below would be vacuous. - private static (IReadOnlyList<(string, byte[])> Assemblies, IReadOnlyList<(string, byte[])> Assets) Bundle(string content) => - ([(Module + ".dll", File.ReadAllBytes(typeof(BundleReader).Assembly.Location))], - [(MarkerAsset, Encoding.UTF8.GetBytes(content))]); - - /// A replica's landing through its public publish-route entry point. - private static async Task Shelve(ModuleLandingService replica, string? version, string content) - { - var (assemblies, assets) = Bundle(content); - return await replica.ShelveModule( - Module, assemblies, frameworkMvid: "test-build", packagePath: "Plugins/ConcurrentShelf", - version: version, staticAssets: assets) - .Timeout(TestTimeouts.Convergence).Await(); - } - - /// A replica's WHOLE shelf landing, run synchronously — what the other replica's - /// recording window executes. Another process's landing does not queue on this one's pool. - private static ModuleLandingOutcome ShelveNow(ModuleLandingService replica, string version, string content) - { - var (assemblies, assets) = Bundle(content); - return replica.LandCore( - Module, assemblies, "test-build", "Plugins/ConcurrentShelf", version, minMeshVersion: null, - assets, sourceCommit: null, nativeAssets: null, holdUnloadable: true, keepNewerHead: true); - } - - /// WHICH bytes a generation holds, read off the disk rather than inferred from the - /// version the entry claims. - private string MarkerIn(ModuleActivationEntry entry) => - File.ReadAllText(Path.Combine( - ModuleLandingService.ModuleDirectoryFor(root, Module, entry), - MarkerAsset.Replace('/', Path.DirectorySeparatorChar))); - - private void CollectGarbage() => - ModuleLandingService.CollectGarbage(root, minAge: TimeSpan.Zero, nowUtc: DateTime.UtcNow.AddHours(1)); - - /// - /// 🚨 THE DEFECT, both ways round. Mail 1.7.0 and 1.6.1 reached one registry eleven minutes - /// apart on 2026-09-11 (#3996); this is the same pair reaching TWO replicas inside one landing's - /// window. Whichever replica is interrupted, the deployment must converge on the newer head, - /// with the older build retained as its fallback — and the report of the landing that recorded - /// last must say so, because a publisher reading "head" off the older build would be reading - /// the lost update. - /// - /// The older-interrupted case is the one that regressed: replica B read an empty - /// record, decided 1.6.1 was the head, and wrote that decision over the 1.7.0 entry replica A - /// had written in between. The newer-interrupted case is its positive control — a rule - /// that simply kept the FIRST landing would pass the other case and fail this one. - /// - [Theory] - [InlineData("older-interrupted")] - [InlineData("newer-interrupted")] - public async Task TwoReplicasLandingDifferentVersionsInOneWindow_ConvergeOnTheNewerHead(string interleaving) - { - var olderInterrupted = interleaving == "older-interrupted"; - ModuleLandingOutcome? inside = null; - using var replicaA = new ModuleLandingService(null, root, beforeRecording: null); - using var replicaB = new ModuleLandingService(null, root, beforeRecording: _ => - { - // The other replica's landing runs to completion INSIDE this one's window, once. - if (inside is null) - inside = olderInterrupted - ? ShelveNow(replicaA, "1.7.0", "1.7.0") - : ShelveNow(replicaA, "1.6.1", "1.6.1"); - }); - - var outer = olderInterrupted - ? await Shelve(replicaB, "1.6.1", "1.6.1") - : await Shelve(replicaB, "1.7.0", "1.7.0"); - Assert.NotNull(inside); - - // ── the head did NOT regress, whatever order the two writes landed in ──────────────── - var list = ModuleActivationSidecar.Read(root); - var head = Assert.Single(list.Entries); - Assert.Equal("1.7.0", head.Version); - Assert.Equal("1.7.0", MarkerIn(head)); - - // ── and the older build is retained as the fallback, not lost ──────────────────────── - Assert.Equal("1.6.1", head.PreviousVersion); - var fallback = ModuleActivationBoot.PreviousGeneration(head); - Assert.NotNull(fallback); - Assert.Equal("1.6.1", MarkerIn(fallback!)); - - // ── the landing that recorded LAST reports the converged head ───────────────────────── - // (The one inside the window recorded first, alone, and reported what was true then.) - Assert.Equal("1.7.0", outer.HeadVersion); - if (olderInterrupted) - { - Assert.True(outer.ShelfOnly, - "the older build must not report itself as the head — that report WAS the lost update"); - Assert.True(outer.RetainedAsFallback); - } - else - { - Assert.False(outer.ShelfOnly, "the newer build IS the head"); - } - - // ── the stored entry an OLDER image boots from says the same ────────────────────────── - // The later of the two landings derived over BOTH records, so the projection it wrote for - // images that predate the records is the converged answer too. - var stored = JsonSerializer.Deserialize( - File.ReadAllText(ModuleActivationSidecar.EntryPath(root, Module)), - new JsonSerializerOptions(JsonSerializerDefaults.Web)); - Assert.NotNull(stored!.ProjectionOf); - Assert.Equal(head with { UnloadableFrameworkMvid = null }, stored with { ProjectionOf = null }); - - // ── and both generations survive the modules GC ─────────────────────────────────────── - CollectGarbage(); - Assert.True(ModuleActivationBoot.LandedModuleDllExists(root, head)); - Assert.True(ModuleActivationBoot.LandedModuleDllExists(root, fallback!)); - Assert.Equal(head, Assert.Single(ModuleActivationSidecar.Read(root).Entries)); - } -} diff --git a/test/Memex.Portal.Shared.Test/ConcurrentUninstallTest.cs b/test/Memex.Portal.Shared.Test/ConcurrentUninstallTest.cs deleted file mode 100644 index 61ad66ce26..0000000000 --- a/test/Memex.Portal.Shared.Test/ConcurrentUninstallTest.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 #4026, Copilot's review of #4427: an UNINSTALL and a LANDING of one module on two replicas at -/// once. Both end by writing the per-module file (activation.d/<Name>.json) that images -/// predating the landing records read — and a write of that file is a projection DERIVED earlier, -/// so either one can land after the other and state a decision the other already overtook. -/// Re-reading before writing is not a compare-and-swap, so the ORDER between the two has to live -/// somewhere nothing replaces: a landing record and an uninstall tombstone, each immutable, each -/// ordered by its own arrival. -/// -/// Deterministic, like : two -/// replicas over one root, and one replica's whole operation runs -/// inside the other's projection window — after it decided, before it wrote the per-module file. -/// -public class ConcurrentUninstallTest : IDisposable -{ - private const string Module = "MeshWeaver.Test.ConcurrentUninstall"; - private const string MarkerAsset = "wwwroot/build.txt"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-concurrent-uninstall-" + Guid.NewGuid().ToString("N")); - - /// Creates the landing root both replicas share. - public ConcurrentUninstallTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - private static (IReadOnlyList<(string, byte[])> Assemblies, IReadOnlyList<(string, byte[])> Assets) Bundle(string content) => - ([(Module + ".dll", File.ReadAllBytes(typeof(BundleReader).Assembly.Location))], - [(MarkerAsset, Encoding.UTF8.GetBytes(content))]); - - private static async Task Shelve(ModuleLandingService replica, string version) - { - var (assemblies, assets) = Bundle(version); - await replica.ShelveModule( - Module, assemblies, frameworkMvid: "test-build", packagePath: "Plugins/ConcurrentUninstall", - version: version, staticAssets: assets) - .Timeout(TestTimeouts.Convergence).Await(); - } - - private static void ShelveNow(ModuleLandingService replica, string version) - { - var (assemblies, assets) = Bundle(version); - replica.LandCore( - Module, assemblies, "test-build", "Plugins/ConcurrentUninstall", version, minMeshVersion: null, - assets, sourceCommit: null, nativeAssets: null, holdUnloadable: true, keepNewerHead: true); - } - - /// What an image that predates the landing records reads: the per-module file, and - /// nothing else. - private ModuleActivationEntry OlderImageView() => - JsonSerializer.Deserialize( - File.ReadAllText(ModuleActivationSidecar.EntryPath(root, Module)), - new JsonSerializerOptions(JsonSerializerDefaults.Web))!; - - /// - /// 🚨 Copilot's interleaving, verbatim: replica A lands and DERIVES its projection; replica B - /// uninstalls — its decision and its disabled per-module file both complete; THEN A writes the - /// projection it derived before the uninstall. The uninstall came after the landing, so the - /// module is uninstalled — and a current image must say so whatever the per-module file says. - /// - /// The per-module file itself ends as A's stale projection, because it is last-writer-wins - /// and nothing can make it otherwise without a compare-and-swap the store does not have. That is - /// what an IMAGE THAT PREDATES THE RECORDS reads, and it is asserted here so the residue is a - /// measured statement rather than a hope: such an image sees the module enabled until the next - /// current-image write of this module's file — exactly what every image saw on main for the - /// same interleaving. - /// - [Fact] - public async Task AnUninstallInsideALandingsProjectionWindow_IsNotUndoneByTheStaleProjection() - { - var uninstalled = false; - using var replicaB = new ModuleLandingService(null, root, beforeRecording: null); - using var replicaA = new ModuleLandingService(null, root, beforeRecording: null, beforeProjecting: _ => - { - if (!uninstalled) - { - uninstalled = true; - replicaB.RemoveCore(Module); - } - }); - - await Shelve(replicaA, "1.7.0"); - Assert.True(uninstalled); - - var entry = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.False(entry.Enabled, "the uninstall came after the landing; a stale projection must not undo it"); - Assert.Null(entry.Directory); - var seenByB = Assert.Single((await replicaB.GetActivation().Timeout(TestTimeouts.Convergence).Await()).Entries); - Assert.False(seenByB.Enabled); - - // The residue, stated: an older image reads only the per-module file, which A wrote last. - Assert.True(OlderImageView().Enabled, - "the per-module file is last-writer-wins — an image that predates the records reads A's " - + "stale projection until the next current-image write of it, as it did on main"); - - // …and the NEXT landing, which comes after the uninstall, is what brings the module back. - await Shelve(replicaB, "1.6.0"); - var back = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.True(back.Enabled); - Assert.Equal("1.6.0", back.Version); - Assert.True(OlderImageView().Enabled); - Assert.Equal("1.6.0", OlderImageView().Version); - } - - /// - /// The mirror image, and the control that stops "uninstall always wins" from passing the test - /// above: replica B decides an uninstall; replica A lands a new build inside B's projection - /// window; THEN B writes its disabled per-module file. The landing came after the uninstall, so - /// the module is installed, at the new build. - /// - /// Here the per-module file ends DISABLED (B wrote it last), which is what an image that - /// predates the records reads until the next current-image write of it — the other direction - /// of the same last-writer-wins residue. - /// - [Fact] - public async Task ALandingInsideAnUninstallsProjectionWindow_IsNotUndoneByTheStaleDisabledEntry() - { - using var replicaA = new ModuleLandingService(null, root, beforeRecording: null); - var landed = false; - using var replicaB = new ModuleLandingService(null, root, beforeRecording: null, beforeProjecting: _ => - { - if (!landed) - { - landed = true; - ShelveNow(replicaA, "1.7.1"); - } - }); - await Shelve(replicaA, "1.7.0"); - - replicaB.RemoveCore(Module); - Assert.True(landed); - - var entry = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.True(entry.Enabled, "the landing came after the uninstall; a stale disabled entry must not undo it"); - Assert.Equal("1.7.1", entry.Version); - Assert.False(OlderImageView().Enabled, - "the per-module file is last-writer-wins — an image that predates the records reads B's " - + "stale disabled entry until the next current-image write of it"); - } -} diff --git a/test/Memex.Portal.Shared.Test/ConfigurationHandOverTest.cs b/test/Memex.Portal.Shared.Test/ConfigurationHandOverTest.cs deleted file mode 100644 index 85bfd10750..0000000000 --- a/test/Memex.Portal.Shared.Test/ConfigurationHandOverTest.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Memex.Portal.Shared; -using Memex.Portal.Shared.Test; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -// The probe module: this TEST assembly is itself a loadable module (ResolveModulePath passes an -// absolute path straight through), so pointing Modules:Assemblies at it makes the REAL fold — -// ConfigureMemexMesh → InstallAssemblies → attribute BuilderConfigurations — observable. -[assembly: ConfigurationProbeModule] - -namespace Memex.Portal.Shared.Test; - -/// -/// #2507, pinned: the portal boot path must hand the host configuration to the builder BEFORE -/// modules install. A module attribute's BuilderConfigurations run inside -/// InstallAssemblies and read builder.Configuration for the deployment's answers — -/// on both prods that read null (ConfigureMemexMesh had every answer in its parameter and -/// never passed it on), so AiMeshModuleAttribute.ServeFromPartitions(null) put the whole -/// AI catalog on the in-memory path and the AI content sources were never registered: no -/// [StaticRepoImport] Provider on any pod, no provider schema in either database, -/// while the deployed config plainly listed the partitions. The tester/LocalMesh path goes -/// through InstallConfiguredModules, which hands it over — exactly why the defect was -/// portal-specific and invisible to every gate. -/// -/// 🚨 The assertion is on what the probe module OBSERVED AT FOLD TIME, not merely on -/// builder.Configuration after the call — a hand-over moved to AFTER -/// InstallAssemblies would satisfy the weaker assertion while re-breaking every module, -/// which is precisely the #2507 shape (Copilot review). -/// -public class ConfigurationHandOverTest -{ - [Theory] - [InlineData(InstanceSetupState.AwaitingStorage)] - [InlineData(InstanceSetupState.AwaitingModules)] - [InlineData(InstanceSetupState.Unreadable)] - public void AnIncompleteManifest_LeavesTheInstanceAwaitingSetup_NeverPosesAsConfiguredStorage( - InstanceSetupState state) - { - // 🚨 Copilot review on #2552. A manifest that EXISTS is not a manifest that ANSWERS: the - // wizard's own starting point (InstanceSetupDefaults) is AwaitingStorage with a type - // already pre-filled, and an Unreadable one answers nothing at all. Accepting either as - // configured storage boots past setup and fails later and deeper — on an unknown backend - // or a missing connection string — which is both a worse failure and further from its - // cause than simply serving the setup surface. - var root = Directory.CreateTempSubdirectory("mw-2552-").FullName; - try - { - new InstanceManifest - { - State = state, - Storage = new InstanceStorageSelection { Type = "PostgreSql" }, - }.Write(root); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { ["Modules:Root"] = root }) - .Build(); - var builder = new MeshBuilder(configure => configure(new ServiceCollection()), - new Address("mesh", "test")); - - builder.ConfigureMemexMesh(configuration); - - Assert.True(builder.IsAwaitingSetup, - $"a manifest in state {state} does not answer the storage question, so the " - + "instance must stay in setup rather than boot on it"); - } - finally - { - try { Directory.Delete(root, recursive: true); } catch { /* OS problem, not a failure */ } - } - } - - [Fact] - public void ConfigureMemexMesh_HandsTheHostConfigurationToTheBuilder_BeforeModulesFold() - { - var temp = Directory.CreateTempSubdirectory("mw-2507-").FullName; - try - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Graph:Storage:Type"] = "FileSystem", - ["Graph:Storage:BasePath"] = temp, - // This test assembly IS the module: its ConfigurationProbeModuleAttribute - // records builder.Configuration the moment the fold runs. - ["Modules:Assemblies:0"] = typeof(ConfigurationHandOverTest).Assembly.Location, - }) - .Build(); - var serviceConfigurations = new List>(); - var builder = new MeshBuilder( - configure => serviceConfigurations.Add(configure), new Address("mesh", "test")); - - builder.ConfigureMemexMesh(configuration); - - Assert.Same(configuration, builder.Configuration); - - var services = serviceConfigurations.Aggregate( - (IServiceCollection)new ServiceCollection(), - (collection, configure) => configure(collection)); - var observed = services - .Select(d => d.ImplementationInstance) - .OfType() - .ToList(); - var observation = Assert.Single(observed); - Assert.Same(configuration, observation.Value); - } - finally - { - try { Directory.Delete(temp, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - } -} - -/// What the probe saw as builder.Configuration when its fold ran — null means the -/// fold ran BEFORE the host handed its configuration over, the #2507 defect. -public sealed record ObservedModuleFoldConfiguration(IConfiguration? Value); - -/// The probe module attribute — records the fold-time builder.Configuration into -/// the mesh service collection, where the test can read it back. -[AttributeUsage(AttributeTargets.Assembly)] -public sealed class ConfigurationProbeModuleAttribute : MeshNodeProviderAttribute -{ - /// - public override IEnumerable> BuilderConfigurations => - [ - builder => builder.ConfigureServices(services => - services.AddSingleton(new ObservedModuleFoldConfiguration(builder.Configuration))) - ]; -} diff --git a/test/Memex.Portal.Shared.Test/ConfiguredSourceListingEvictionTest.cs b/test/Memex.Portal.Shared.Test/ConfiguredSourceListingEvictionTest.cs deleted file mode 100644 index ba6e30b11e..0000000000 --- a/test/Memex.Portal.Shared.Test/ConfiguredSourceListingEvictionTest.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.GitSync; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The listing cache has to be invalidated for the sources /api/plugins actually -/// SERVES, and those are a DIFFERENT list from the one PluginUpdateWatcher historically -/// watched (MeshWeaver#4222, found by review). -/// -/// WatchCatalog walks PluginCatalog NODES (SourceRepoPath); the registry -/// endpoint reads PluginCatalog:Sources:N:RepoPath from CONFIGURATION — which is how the -/// fleet registry is wired. Evicting only on the first would have left the freshness window as the -/// ONLY invalidation for the sources that matter, silently and with the documentation claiming -/// otherwise. This test drives the real Start() over a real mesh whose CONFIG names a source -/// and asserts the watch is opened on that repository's build node. -/// -public class ConfiguredSourceListingEvictionTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Repo = "https://github.com/Systemorph/MeshWeaver.Plugins"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - // The registry's OWN shape: sources declared in configuration, no catalog node. - .AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["PluginCatalog:Sources:0:RepoPath"] = Repo, - ["PluginCatalog:Sources:0:Format"] = "node-repo", - ["PluginCatalog:Sources:0:Ref"] = "main", - ["PluginCatalog:Sources:0:Name"] = "Plugins", - }) - .Build()) - .AddSingleton(new SilentRepoClient())); - - [Fact] - public void AConfiguredSource_OpensAnEvictionWatchOnItsBuildNode() - { - var lines = new List(); - var watcher = new PluginUpdateWatcher(Mesh, new CollectingLogger(lines)); - using (watcher) - { - watcher.Start(); - } - - var expected = BuildCompletion.PathFor("Systemorph", "MeshWeaver.Plugins"); - Assert.True( - lines.Any(l => l.Contains(expected, StringComparison.Ordinal) - && l.Contains("invalidate the listing cache", StringComparison.Ordinal)), - "the watcher opened no eviction watch for the CONFIGURED source. /api/plugins serves " - + "PluginCatalog:Sources:N:RepoPath, not the PluginCatalog node list, so without this " - + "watch a green build never invalidates the listing the registry actually serves " - + $"(#4222). Expected a line naming '{expected}'. Lines were:\n " - + string.Join("\n ", lines)); - } - - /// - /// 🚨 The NEGATIVE half, so the assertion above cannot pass on a watcher that opens a watch for - /// everything it can name: a repository that is NOT configured gets none. The watch list is the - /// CONFIGURED source list, read through PackageSources.FromConfiguration — the same - /// reader the endpoint uses — not an enumeration of whatever build nodes happen to exist. - /// - [Fact] - public void ARepositoryThatIsNotConfigured_GetsNoEvictionWatch() - { - var lines = new List(); - var watcher = new PluginUpdateWatcher(Mesh, new CollectingLogger(lines)); - using (watcher) - { - watcher.Start(); - } - - var unconfigured = BuildCompletion.PathFor("Systemorph", "MeshWeaver.Education"); - Assert.DoesNotContain(lines, l => l.Contains(unconfigured, StringComparison.Ordinal)); - } - - private sealed class CollectingLogger(List sink) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - lock (sink) - sink.Add(formatter(state, exception)); - } - } - - /// Answers nothing — the watcher must open its subscriptions without fetching. - private sealed class SilentRepoClient : IGitHubRepoClient - { - private static IObservable Never() => Observable.Never(); - - public IObservable Fetch( - string repositoryUrl, string commitish, string? subdirectory, string accessToken) => Never(); - - public IObservable Push(GitHubPushRequest request) => Never(); - - public IObservable CreateBranch(GitHubCreateBranchRequest request) => Never(); - - public IObservable OpenPullRequest(GitHubOpenPullRequestRequest request) => Never(); - - public IObservable GetPullRequestStatus( - string repositoryUrl, int number, string accessToken) => Never(); - - public IObservable> ListIssues( - string repositoryUrl, GitHubIssueState? state, string accessToken) => Never>(); - - public IObservable GetIssue(string repositoryUrl, int number, string accessToken) => Never(); - - public IObservable CreateIssue(GitHubCreateIssueRequest request) => Never(); - - public IObservable CommentIssue( - string repositoryUrl, int number, string body, string accessToken) => Never(); - - public IObservable SetIssueState( - string repositoryUrl, int number, GitHubIssueState state, string accessToken) => Never(); - - public IObservable> ListPullRequests( - string repositoryUrl, PullRequestStatus? state, string accessToken) => Never>(); - - public IObservable GetPullRequestDetail( - string repositoryUrl, int number, string accessToken) => Never(); - - public IObservable CommentPullRequest( - string repositoryUrl, int number, string body, string accessToken) => Never(); - - public IObservable MergePullRequest(GitHubMergePullRequestRequest request) => Never(); - } -} diff --git a/test/Memex.Portal.Shared.Test/ConfiguredSourceReconcilesOnGreenBuildTest.cs b/test/Memex.Portal.Shared.Test/ConfiguredSourceReconcilesOnGreenBuildTest.cs deleted file mode 100644 index e71b76bd20..0000000000 --- a/test/Memex.Portal.Shared.Test/ConfiguredSourceReconcilesOnGreenBuildTest.cs +++ /dev/null @@ -1,318 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Threading; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A package installed from a CONFIGURED GitHub source updates itself on a green build of that -/// source — not only one installed from a PluginCatalog node's repository. -/// -/// The measured defect (an enterprise client's test instance, 2026-10-13/14): the instance -/// mounts a GitHub repository as a package source (PluginCatalog:Sources:N:RepoPath, no -/// catalog node), the repository's green-build fact arrived at Admin/_Build/{owner}.{repo}, -/// and the Auto install record stayed on its old commit for 14+ hours. -/// reconciled installed packages only for catalog NODES; for a -/// configured source it subscribed to the very same build node and only evicted the listing cache. -/// reconciles only against HTTP registries, so nothing ever -/// looked again. -/// -/// This drives the real hosted watcher over a mesh whose CONFIGURATION names the source and -/// whose GitHub client serves two commits of a node repository: the Auto record installs the new -/// build, the Notify record gets a reminder and keeps its installed content. -/// -public class ConfiguredSourceReconcilesOnGreenBuildTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string RepoUrl = "https://github.com/acme/client-packages"; - private const string Origin = "github.com/acme/client-packages"; - private const string OldSha = "1111111111111111111111111111111111111111"; - private const string NewSha = "2222222222222222222222222222222222222222"; - private const string AutoPkg = "ConfiguredAutoPkg"; - private const string NotifyPkg = "ConfiguredNotifyPkg"; - private const string LocklessPkg = "ConfiguredLocklessPkg"; - - private System.Text.Json.JsonSerializerOptions Json => Mesh.JsonSerializerOptions; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - // The BuildCompletion node type, as a portal with GitHub sync registers it. - .AddGitHubSyncTypes() - .ConfigureServices(services => services - // The measured shape: a GitHub repository mounted as a configured source, no node. - .AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["PluginCatalog:Sources:0:RepoPath"] = RepoUrl, - ["PluginCatalog:Sources:0:Format"] = "node-repo", - ["PluginCatalog:Sources:0:Ref"] = "main", - ["PluginCatalog:Sources:0:Name"] = "Client", - }) - .Build()) - .AddSingleton(new TwoCommitRepoClient()) - // Nothing is installed at boot: the records under test are seeded as they stand on - // the instance — installed at the OLD commit. - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false })); - - [Fact(Timeout = 300_000)] - public async Task AGreenBuildOfAConfiguredSource_InstallsTheAutoRecord_AndRemindsTheNotifyRecord() - { - var ct = TestContext.Current.CancellationToken; - await Seed(AutoPkg, PackageUpdatePolicy.Auto, ct); - await Seed(NotifyPkg, PackageUpdatePolicy.Notify, ct); - - // The green-build fact, exactly as GitHubWebhookProcessor writes it. - await WriteBuild(NewSha, ct); - - var auto = await AwaitRecord(AutoPkg, r => r.ModuleVersion == ModuleVersion(AutoPkg, NewSha), 240, ct); - auto.Version.Should().Be("2.0.0", "the Auto record now names the new build's released version"); - auto.Origin.Should().Be(Origin, "the update stays bound to the configured source's origin"); - - var notify = await AwaitRecord(NotifyPkg, r => r.NotifiedModuleVersion == ModuleVersion(NotifyPkg, NewSha), 60, ct); - notify.ModuleVersion.Should().Be(ModuleVersion(NotifyPkg, OldSha), - "a Notify record is REMINDED about the new build; its installed content does not move"); - } - - /// - /// 🚨 A lost reaction self-heals: the safety net re-reads the DURABLE build fact and runs the - /// same reconcile. The record is seeded only after the live stream has already seen this sha - /// (and the stream never re-emits the same sha), so only the safety-net pass can reach it. - /// - [Fact(Timeout = 300_000)] - public async Task TheSafetyNet_ReconcilesFromTheStoredBuildFact_WhenTheLiveReactionWasMissed() - { - var ct = TestContext.Current.CancellationToken; - var watcher = Mesh.ServiceProvider.GetRequiredService(); - await WriteBuild(NewSha, ct); - // Drain whatever the live stream enqueued for this sha (the lane is serialized). - await watcher.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - - await Seed(NotifyPkg, PackageUpdatePolicy.Notify, ct); - await watcher.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - - var notify = await AwaitRecord(NotifyPkg, r => r.NotifiedModuleVersion == ModuleVersion(NotifyPkg, NewSha), 60, ct); - notify.Origin.Should().Be(Origin); - } - - /// - /// The NEGATIVE control: a record installed from ANOTHER repository is not touched by this - /// source's build — the configured source reconciles bound to its own origin, never by id alone. - /// - [Fact(Timeout = 300_000)] - public async Task ARecordFromAnotherOrigin_IsNotUpdatedByTheConfiguredSourcesBuild() - { - var ct = TestContext.Current.CancellationToken; - var watcher = Mesh.ServiceProvider.GetRequiredService(); - await Seed(NotifyPkg, PackageUpdatePolicy.Notify, ct, origin: "github.com/someone-else/packages"); - await Seed(AutoPkg, PackageUpdatePolicy.Notify, ct); - await WriteBuild(NewSha, ct); - - // Positive signal that the pass ran: the same-origin control record is reminded. - await AwaitRecord(AutoPkg, r => r.NotifiedModuleVersion == ModuleVersion(AutoPkg, NewSha), 60, ct); - await watcher.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - - var foreign = await AwaitRecord(NotifyPkg, _ => true, 30, ct); - foreign.NotifiedModuleVersion.Should().BeNull("a candidate from a repository the package was not installed from is ignored"); - foreign.ModuleVersion.Should().Be(ModuleVersion(NotifyPkg, OldSha)); - } - - /// - /// 🚨 A record installed BEFORE its repository emitted a manifest.lock is not stuck - /// (2026-10-15). Its install record carries no moduleVersion while the listing now states one: - /// the record cannot claim to equal the listed content, so the listed identity is a change — - /// an Auto record installs (and is stamped, so the next pass compares normally), a Notify - /// record is reminded. Measured on an enterprise client's test instance: three packages logged - /// "the install record's moduleVersion is empty" on every green build and never moved. - /// - [Fact(Timeout = 300_000)] - public async Task ARecordWithoutModuleVersion_IsUpdatedOnceTheListingCarriesOne() - { - var ct = TestContext.Current.CancellationToken; - await Seed(AutoPkg, PackageUpdatePolicy.Auto, ct, withModuleVersion: false); - await Seed(NotifyPkg, PackageUpdatePolicy.Notify, ct, withModuleVersion: false); - - await WriteBuild(NewSha, ct); - - var auto = await AwaitRecord(AutoPkg, r => r.ModuleVersion == ModuleVersion(AutoPkg, NewSha), 240, ct); - auto.Version.Should().Be("2.0.0", "the Auto record installed the listed build and is now stamped with its identity"); - - var notify = await AwaitRecord(NotifyPkg, r => r.NotifiedModuleVersion == ModuleVersion(NotifyPkg, NewSha), 60, ct); - notify.ModuleVersion.Should().BeNull("a Notify record is reminded; its installed content does not move"); - } - - /// - /// The case that IS still refused: the LISTING carries no identity (its repository commits no - /// manifest.lock). Acting on the event there would re-install on every green build. The - /// locked Auto package in the same pass is the positive signal that the pass ran. - /// - [Fact(Timeout = 300_000)] - public async Task AListingWithoutModuleVersion_IsStillRefused() - { - var ct = TestContext.Current.CancellationToken; - var watcher = Mesh.ServiceProvider.GetRequiredService(); - await Seed(LocklessPkg, PackageUpdatePolicy.Auto, ct, withModuleVersion: false); - await Seed(AutoPkg, PackageUpdatePolicy.Notify, ct); - - await WriteBuild(NewSha, ct); - await AwaitRecord(AutoPkg, r => r.NotifiedModuleVersion == ModuleVersion(AutoPkg, NewSha), 60, ct); - await watcher.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - - var lockless = await AwaitRecord(LocklessPkg, _ => true, 30, ct); - lockless.Version.Should().Be("1.0.0", "a listing with no content identity is refused, never installed on the event"); - lockless.ModuleVersion.Should().BeNull(); - lockless.NotifiedModuleVersion.Should().BeNull(); - } - - private Task WriteBuild(string sha, CancellationToken ct) - { - var path = BuildCompletion.PathFor("acme", "client-packages"); - var slash = path.LastIndexOf('/'); - var node = new MeshNode(path[(slash + 1)..], path[..slash]) - { - NodeType = BuildCompletion.NodeType, - Name = "acme/client-packages build", - State = MeshNodeState.Active, - Content = new BuildCompletion - { - RepositoryUrl = RepoUrl, - Branch = "main", - HeadSha = sha, - WorkflowName = "CI", - WorkflowPath = ".github/workflows/ci.yml", - RunNumber = 7, - Conclusion = "success", - }, - }; - return Mesh.ServiceProvider.GetRequiredService() - .RunAsSystem(() => NodeFactory.CreateOrUpdateNode(node)) - .Timeout(TestTimeouts.Convergence).Await(ct); - } - - private async Task Seed(string id, PackageUpdatePolicy policy, CancellationToken ct, string origin = Origin, - bool withModuleVersion = true) - { - var auto = policy == PackageUpdatePolicy.Auto; - var manifest = new PackageManifest - { - Id = id, - Name = id, - Kind = PackageKind.NodeRepo, - TargetPartition = id, - SourceFolder = id, - Version = "1.0.0", - // A record installed before its repository emitted manifest.lock carries none. - ModuleVersion = withModuleVersion ? ModuleVersion(id, OldSha) : null, - Source = "Client", - Origin = origin, - InstalledFromRef = "main", - AutoUpdate = auto, - UpdatePolicy = policy, - UpdatePolicySetAt = DateTimeOffset.UtcNow, - }; - var record = MeshNode.FromPath($"{PackageInstaller.InstalledPartition}/{id}") with - { - NodeType = PackageInstaller.PackageNodeType, - Name = id, - State = MeshNodeState.Active, - Content = manifest, - }; - await Mesh.ServiceProvider.GetRequiredService() - .RunAsSystem(() => NodeFactory.CreateOrUpdateNode(record)) - .Timeout(TestTimeouts.Convergence).Await(ct); - } - - private async Task AwaitRecord(string id, Func predicate, int seconds, CancellationToken ct) => - await Mesh.GetMeshNodeStream($"{PackageInstaller.InstalledPartition}/{id}") - .Select(n => n?.ContentAs(Json)) - .Where(r => r is not null && predicate(r)) - .Select(r => r!) - .FirstAsync().Timeout(TimeSpan.FromSeconds(seconds)).Await(ct); - - private static string ModuleVersion(string id, string sha) => $"mv-{id}-{sha[..4]}"; - - /// One package at one commit: a Space root, a page, and the CI-maintained manifest.lock - /// carrying the content identity the reconcile compares. - private static IEnumerable Package(string id, string sha, string version) => - [ - new RepoFile($"{id}/index.json", - $$"""{"$type":"MeshNode","id":"{{id}}","namespace":"","path":"{{id}}","mainNode":"{{id}}","name":"{{id}}","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"{{id}}."} }"""), - new RepoFile($"{id}/Page.json", - $$"""{"$type":"MeshNode","id":"Page","namespace":"{{id}}","path":"{{id}}/Page","mainNode":"{{id}}/Page","name":"Page","nodeType":"Markdown","state":"Active","content":"# {{id}} at {{sha}}"}"""), - new RepoFile($"{id}/{ModuleManifest.FileName}", $$""" - { - "module": "{{id}}", - "moduleVersion": "{{ModuleVersion(id, sha)}}", - "version": "{{version}}", - "files": { - "{{id}}/index.json": "{{sha[..8]}}a", - "{{id}}/Page.json": "{{sha[..8]}}b" - } - } - """), - ]; - - /// Serves the repository at two commits; any other ref (the branch name) is the new one. - private sealed class TwoCommitRepoClient : IGitHubRepoClient - { - private static RepoSnapshot At(string sha) => - new(sha, [.. Package(AutoPkg, sha, sha == OldSha ? "1.0.0" : "2.0.0"), - .. Package(NotifyPkg, sha, sha == OldSha ? "1.0.0" : "2.0.0"), - // A package whose repository commits NO manifest.lock: no listed identity. - .. Package(LocklessPkg, sha, "2.0.0").Where(f => !f.Path.EndsWith(ModuleManifest.FileName, StringComparison.Ordinal))]); - - private static IObservable Never() => Observable.Never(); - - public IObservable Fetch( - string repositoryUrl, string commitish, string? subdirectory, string accessToken) => - Observable.Return(At(commitish == OldSha ? OldSha : NewSha)); - - public IObservable Push(GitHubPushRequest request) => Never(); - - public IObservable CreateBranch(GitHubCreateBranchRequest request) => Never(); - - public IObservable OpenPullRequest(GitHubOpenPullRequestRequest request) => Never(); - - public IObservable GetPullRequestStatus( - string repositoryUrl, int number, string accessToken) => Never(); - - public IObservable> ListIssues( - string repositoryUrl, GitHubIssueState? state, string accessToken) => Never>(); - - public IObservable GetIssue(string repositoryUrl, int number, string accessToken) => Never(); - - public IObservable CreateIssue(GitHubCreateIssueRequest request) => Never(); - - public IObservable CommentIssue( - string repositoryUrl, int number, string body, string accessToken) => Never(); - - public IObservable SetIssueState( - string repositoryUrl, int number, GitHubIssueState state, string accessToken) => Never(); - - public IObservable> ListPullRequests( - string repositoryUrl, PullRequestStatus? state, string accessToken) => Never>(); - - public IObservable GetPullRequestDetail( - string repositoryUrl, int number, string accessToken) => Never(); - - public IObservable CommentPullRequest( - string repositoryUrl, int number, string body, string accessToken) => Never(); - - public IObservable MergePullRequest(GitHubMergePullRequestRequest request) => Never(); - } -} diff --git a/test/Memex.Portal.Shared.Test/ControlLaneKeyRoundTripTest.cs b/test/Memex.Portal.Shared.Test/ControlLaneKeyRoundTripTest.cs deleted file mode 100644 index 662987cb45..0000000000 --- a/test/Memex.Portal.Shared.Test/ControlLaneKeyRoundTripTest.cs +++ /dev/null @@ -1,259 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.SelfUpdate; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.Reactive.Assertions; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The whole announcement-key round trip (Doc/Architecture/SelfUpdateAnnouncementKey), with no -/// operator and no restart: -/// -/// the CONTROL instance issues a key for fabrikam (a portal-set per-sender key); -/// fabrikam's administrator SAVES the same key in Settings ▸ Control lane; -/// the two fingerprints match; -/// Test connection sends a signed, verify-only test, and the control inbox answers -/// "verified as fabrikam" and stores nothing; -/// a mistyped key answers a MISMATCH; -/// no log line carries the key. -/// -/// Both roles share one mesh: the sender signs with Hosting:ControlInbox:Secret, and the -/// inbox verifies with Hosting:PlatformWebhookSecret:fabrikam. The HTTP hop is an in-process -/// handler that answers exactly as does. -/// -public class ControlLaneKeyRoundTripTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string AdminPartition = "Admin"; - private const string PlatformAdmin = "platform-boss"; - private const string Deployment = "fabrikam"; - private const string InboxUrl = "https://control.test" + SelfUpdateHandover.InboxRoute; - private const string FleetSecret = "the-fleet-wide-secret"; - - private static readonly string MasterKey = Convert.ToBase64String(Enumerable.Range(7, 32).Select(i => (byte)i).ToArray()); - - private readonly CapturingLoggerProvider logs = new(); - - private static TimeSpan Budget => TestTimeouts.Convergence; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddMeshNodes( - new MeshNode(AdminPartition) { Name = "Admin", NodeType = "Markdown" }, - new MeshNode("Hosting") { Name = "Hosting", NodeType = "Markdown" }, - new MeshNode(SelfUpdateHandover.InboxTarget) { Name = "Platform builds", NodeType = "Markdown", Content = new MarkdownContent { Content = "#\n" } }, - AssignmentNodeFactory.UserRole(PlatformAdmin, "Admin", AdminPartition)) - .AddWebhookInbox() - .AddInstanceSecretSlot(SelfUpdateHandover.SecretKey) - .AddInstanceSecretSlot(SelfUpdateHandover.LocalSecretKey + ":*") - .ConfigureServices(services => - { - services.AddSingleton(logs); - return services.AddSingleton( - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - ["Ai:KeyProtection:MasterKey"] = MasterKey, - [SelfUpdateHandover.DeploymentKey] = Deployment, - [SelfUpdateHandover.UrlKey] = InboxUrl, - [SelfUpdateHandover.LocalSecretKey] = FleetSecret, - }).Build()); - }); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - private InstanceSecretCatalog Catalog => Mesh.ServiceProvider.GetRequiredService(); - - private Task SaveAsAdmin(string key, string value) - { - using (Access.SwitchAccessContext(new AccessContext { ObjectId = PlatformAdmin, Name = PlatformAdmin })) - return InstanceSecrets.Set(Mesh, key, value).FirstAsync().Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - } - - private Task Live(string key, string value) => - Catalog.Changes.Where(_ => Catalog.Entry(key)?.Current == value) - .Should().Within(Budget).Emit($"{key} is live without a restart"); - - private SelfUpdateHandover Handover() => - new(Mesh, http: new HttpClient(new InProcessInbox(Mesh))); - - [Fact] - public async Task Issue_save_and_test_round_trip_matches_and_a_mistyped_key_reads_as_a_mismatch() - { - var issued = SecretFingerprint.Generate(); - var controlKey = $"{SelfUpdateHandover.LocalSecretKey}:{Deployment}"; - - // 1. The control instance issues fabrikam's key. - var controlStatus = await SaveAsAdmin(controlKey, issued); - // 2. Fabrikam's administrator pastes it and saves. - var fabrikamStatus = await SaveAsAdmin(SelfUpdateHandover.SecretKey, issued); - await Live(controlKey, issued); - await Live(SelfUpdateHandover.SecretKey, issued); - - // 3. The pair: the two fingerprints are the same, and neither is the key. - Assert.Equal(controlStatus.Secret.Fingerprint, fabrikamStatus.Secret.Fingerprint); - Assert.DoesNotContain(issued, fabrikamStatus.Secret.Fingerprint!); - - // The self-updater now has a route to the control instance — no restart, no mount. - Assert.Equal(SelfUpdateHandover.Route.Post, SelfUpdateHandover.RouteFor(Handover().ReadSettings())); - - // 4. Test connection: verified as fabrikam. - var outcome = await Handover().Test().FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - Assert.True(outcome.Accepted, outcome.Detail); - Assert.Equal(Deployment, outcome.Sender); - Assert.Contains("Match", ControlLaneSettingsTab.TestMarkdown(outcome, (k, a) => k == "ui.controlLaneTestMatch" ? "Match " + a[0] : k, "en")); - - // The test is recorded on BOTH ends: fabrikam's last use, and the control's last verification. - await Catalog.Changes - .Where(_ => Catalog.Entry(SelfUpdateHandover.SecretKey)?.Content.LastUseOk == true - && Catalog.Entry(controlKey)?.Content.LastUseOk == true) - .Should().Within(Budget).Emit("a verified test is recorded as each key's last use"); - - // 5. A mistyped key: refused, and the verdict says to re-enter it. - await SaveAsAdmin(SelfUpdateHandover.SecretKey, issued[..^1] + (issued[^1] == '0' ? '1' : '0')); - await Catalog.Changes.Where(_ => Catalog.Entry(SelfUpdateHandover.SecretKey)?.Current != issued) - .Should().Within(Budget).Emit("the re-entered key is live"); - var mismatch = await Handover().Test().FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - Assert.False(mismatch.Accepted); - Assert.Contains("401", mismatch.Detail); - // The verdict renders in the viewer's language: the German reads the German catalog entry. - Assert.Contains("abgelehnt", ControlLaneSettingsTab.TestMarkdown(mismatch, (k, a) => k + " " + string.Join(" ", a), "de")); - - // Nothing was stored by either test: a key test is verify-only. - var stored = await Access.RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService() - .Query(MeshQueryRequest.FromQuery($"path:{SelfUpdateHandover.InboxTarget}/{WebhookInbox.InboxContainer} scope:children")).Take(1)) - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - Assert.Empty(stored.Items); - - // 6. The key never reached a log line. - Assert.DoesNotContain(logs.Messages, m => m.Contains(issued[..16], StringComparison.Ordinal)); - } - - /// - /// The REVERSED flow the fabrikam rollout uses: the deployment GENERATES its key (shown once, with - /// its fingerprint), Systemorph registers that value on the control instance, the fingerprints - /// agree, and Test connection answers a match — with nothing shown again afterwards. - /// - [Fact] - public async Task The_deployment_generates_the_key_and_the_control_instance_registers_it() - { - var controlKey = $"{SelfUpdateHandover.LocalSecretKey}:{Deployment}"; - InstanceSecrets.Generated generated; - using (Access.SwitchAccessContext(new AccessContext { ObjectId = PlatformAdmin, Name = PlatformAdmin })) - generated = await InstanceSecrets.Generate(Mesh, SelfUpdateHandover.SecretKey).FirstAsync().Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - Assert.Equal(SecretSources.Generate, generated.Status.Secret.Source); - - // Before the control instance registers it, the test reads as a mismatch. - await Live(SelfUpdateHandover.SecretKey, generated.Value); - var before = await Handover().Test().FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - Assert.False(before.Accepted); - - // Systemorph pastes what the deployment's administrator sent over the secure channel. - var registered = await SaveAsAdmin(controlKey, generated.Value); - Assert.Equal(generated.Status.Secret.Fingerprint, registered.Secret.Fingerprint); - await Live(controlKey, generated.Value); - - var after = await Handover().Test().FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - Assert.True(after.Accepted, after.Detail); - Assert.Equal(Deployment, after.Sender); - Assert.DoesNotContain(logs.Messages, m => m.Contains(generated.Value[..16], StringComparison.Ordinal)); - } - - [Fact] - public void The_status_block_shows_the_fingerprint_and_never_a_value() - { - var key = SecretFingerprint.Generate(); - var status = InstanceSecrets.StatusOf(SelfUpdateHandover.SecretKey, null, key); - var markdown = WriteOnlySecretSection.StatusMarkdown(status.Secret, (k, a) => k + " " + string.Join(" | ", a)) - + ControlLaneSettingsTab.DetailMarkdown(status, (k, a) => k + " " + string.Join(" | ", a), "en"); - Assert.Contains(SecretFingerprint.Of(key)!, markdown); - Assert.DoesNotContain(key, markdown); - Assert.DoesNotContain(key[..16], markdown); - } - - [Theory] - [InlineData(200, """{"status":"verified","signature":"verified","sender":"fabrikam"}""", true, "fabrikam")] - [InlineData(200, """{"status":"verified","signature":"verified","sender":null}""", true, null)] - [InlineData(401, "", false, null)] - // An older control instance that does not know the verify-only header STORES the test instead. - [InlineData(200, """{"status":"accepted","signature":"verified"}""", false, null)] - [InlineData(200, """{"status":"verified","signature":"not-required"}""", false, null)] - [InlineData(500, "", false, null)] - public void A_test_answer_is_accepted_only_when_verified(int code, string body, bool accepted, string? sender) - { - var outcome = SelfUpdateHandover.TestOutcomeOf(InboxUrl, code, body); - Assert.Equal(accepted, outcome.Accepted); - Assert.Equal(sender, outcome.Sender); - } - - /// The control inbox, in process: delivers into THIS mesh and answers as the endpoint does. - private sealed class InProcessInbox(IMessageHub mesh) : HttpMessageHandler - { - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var body = await request.Content!.ReadAsStringAsync(cancellationToken); - var headers = request.Headers.Select(h => new KeyValuePair(h.Key, string.Join(", ", h.Value))).ToList(); - var targets = new[] { new WebhookInbox.WebhookTarget(SelfUpdateHandover.InboxTarget, SelfUpdateHandover.LocalSecretKey) }; - var result = await WebhookInbox.Deliver(mesh, targets, SelfUpdateHandover.InboxTarget, "application/json", headers, body) - .FirstAsync().Await(cancellationToken); - return result.Status switch - { - WebhookInbox.DeliveryStatus.Accepted => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(JsonSerializer.Serialize(WebhookInboxEndpoints.AcceptedAnswer(result)), Encoding.UTF8, "application/json"), - }, - WebhookInbox.DeliveryStatus.SignatureInvalid => new HttpResponseMessage(HttpStatusCode.Unauthorized), - _ => new HttpResponseMessage(HttpStatusCode.InternalServerError), - }; - } - } - - private sealed class CapturingLoggerProvider : ILoggerProvider - { - private readonly System.Collections.Concurrent.ConcurrentQueue messages = new(); - - public IEnumerable Messages => messages.ToArray(); - - public ILogger CreateLogger(string categoryName) => new Capture(messages); - - public void Dispose() { } - - private sealed class Capture(System.Collections.Concurrent.ConcurrentQueue sink) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - sink.Enqueue(formatter(state, exception)); - if (exception is not null) - sink.Enqueue(exception.ToString()); - } - } - } -} diff --git a/test/Memex.Portal.Shared.Test/DataVolumeHealthCheckTest.cs b/test/Memex.Portal.Shared.Test/DataVolumeHealthCheckTest.cs deleted file mode 100644 index c14631f9b8..0000000000 --- a/test/Memex.Portal.Shared.Test/DataVolumeHealthCheckTest.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Memex.Portal.ServiceDefaults; -using MeshWeaver.Hosting; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The data_volume_free_space health check as the host registers it: the paths come from -/// the store-root keys, the threshold from DataVolume:MinimumFreeBytes, the probe is a -/// seam. Degraded — never Unhealthy — names the path, used and total. -/// -public class DataVolumeHealthCheckTest -{ - private const long MiB = 1024L * 1024L; - - private static IConfiguration Configuration(params (string Key, string Value)[] entries) => - new ConfigurationBuilder() - .AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value)) - .Build(); - - [Fact] - public async Task AFullShare_ReportsDegraded_NamingPathUsedAndTotal() - { - var check = new DataVolumeHealthCheck( - Configuration((ShippedPrebuiltBundles.PublishedRootConfigKey, "/data/prebuilt-bundles")), - path => new DataVolumeReading(path, "/data", 3 * MiB, 16384 * MiB, null)); - - var result = await check.CheckHealthAsync(new HealthCheckContext()); - - result.Status.Should().Be(HealthStatus.Degraded); - result.Description.Should().Contain("/data/prebuilt-bundles"); - result.Description.Should().Contain("16,381 MiB used"); - result.Description.Should().Contain("16,384 MiB"); - result.Data.Keys.Should().Contain("/data/prebuilt-bundles"); - } - - [Fact] - public async Task ARoomyShare_IsHealthy() - { - var check = new DataVolumeHealthCheck( - Configuration( - (ShippedPrebuiltBundles.PublishedRootConfigKey, "/data/prebuilt-bundles"), - (ModuleRoot.ConfigKey, "/data/modules")), - path => new DataVolumeReading(path, "/data", 9000 * MiB, 16384 * MiB, null)); - - var result = await check.CheckHealthAsync(new HealthCheckContext()); - - result.Status.Should().Be(HealthStatus.Healthy); - result.Data.Keys.Should().Contain("/data/modules"); - } - - [Fact] - public async Task NothingConfigured_IsHealthy_NotVacuouslyDegraded() - { - var check = new DataVolumeHealthCheck(Configuration(), path => throw new System.IO.IOException("never called")); - - var result = await check.CheckHealthAsync(new HealthCheckContext()); - - result.Status.Should().Be(HealthStatus.Healthy); - result.Description.Should().Contain("no data volume configured"); - } - - [Fact] - public async Task TheThresholdComesFromConfiguration() - { - var check = new DataVolumeHealthCheck( - Configuration( - (ShippedPrebuiltBundles.PublishedRootConfigKey, "/data/prebuilt-bundles"), - (DataVolumeFreeSpace.MinimumFreeBytesConfigKey, (2 * MiB).ToString())), - path => new DataVolumeReading(path, "/data", 3 * MiB, 16384 * MiB, null)); - - (await check.CheckHealthAsync(new HealthCheckContext())).Status.Should().Be(HealthStatus.Healthy); - } - - [Fact] - public void AdditionalPaths_AreWatchedToo() - { - var configuration = Configuration( - ($"{DataVolumeFreeSpace.PathsConfigKey}:0", "/data/assembly-cache"), - ($"{DataVolumeFreeSpace.PathsConfigKey}:1", "/data/dataprotection-keys")); - - DataVolumeHealthCheck.PathsOf(configuration).Should().Equal("/data/assembly-cache", "/data/dataprotection-keys"); - } - - /// - /// The framework cannot reference PluginCatalog's , so it - /// mirrors the string. This assembly sees both; a drift here would silently stop the check - /// watching the modules root. - /// - [Fact] - public void TheModulesRootKey_IsTheOnePluginCatalogReads() - { - DataVolumeFreeSpace.ModulesRootConfigKey.Should().Be(ModuleRoot.ConfigKey); - DataVolumeFreeSpace.PathConfigKeys.Should().Contain(ModuleRoot.ConfigKey); - } -} diff --git a/test/Memex.Portal.Shared.Test/DataVolumeUsageHealthCheckTest.cs b/test/Memex.Portal.Shared.Test/DataVolumeUsageHealthCheckTest.cs deleted file mode 100644 index defbeddfa9..0000000000 --- a/test/Memex.Portal.Shared.Test/DataVolumeUsageHealthCheckTest.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.Threading.Tasks; -using Memex.Portal.ServiceDefaults; -using MeshWeaver.Hosting; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The data_volume_usage census as /health publishes it (MeshWeaver#6983): the last -/// walk's per-directory reading, "not measured yet" as its own sentence, Degraded only when the -/// instrument itself is missing or a walk faulted. -/// -public class DataVolumeUsageHealthCheckTest -{ - private static IServiceProvider With(DataVolumeUsageCensus? census) - { - var services = new ServiceCollection(); - if (census is not null) - services.AddSingleton(census); - return services.BuildServiceProvider(); - } - - [Fact] - public async Task A_host_without_the_census_is_Degraded_never_a_silent_Healthy() - { - var result = await new DataVolumeUsageHealthCheck(With(null)).CheckHealthAsync(new HealthCheckContext()); - - result.Status.Should().Be(HealthStatus.Degraded); - result.Description.Should().Contain("not registered"); - } - - [Fact] - public async Task A_reading_prints_per_directory_and_is_Healthy() - { - var census = new DataVolumeUsageCensus(); - census.SetRoots(["/data"]); - census.Record(new DataVolumeUsageReading( - "/data", DateTimeOffset.UnixEpoch, TimeSpan.FromMinutes(3), - [ - new DataVolumeUsageEntry("prebuilt-bundles", 90L << 30, 1000, 811, null, 0, false, - [new DataVolumeUsageEntry("c003e001", 40L << 30, 400, 3, null, 0, false, [])]), - new DataVolumeUsageEntry("dumps", 0, 0, 0, null, 0, true, []), - ], - 0, 0, null)); - - var result = await new DataVolumeUsageHealthCheck(With(census)).CheckHealthAsync(new HealthCheckContext()); - - result.Status.Should().Be(HealthStatus.Healthy); - result.Description.Should().Contain("prebuilt-bundles/ 92,160 MiB (100%)"); - result.Description.Should().Contain("811 entr(ies)"); - result.Description.Should().Contain("[largest: c003e001 40,960 MiB]"); - result.Description.Should().Contain("dumps/ is another volume's mount point"); - } - - [Fact] - public async Task Before_the_first_walk_it_says_not_measured_yet() - { - var census = new DataVolumeUsageCensus(); - census.SetRoots(["/data"]); - - var result = await new DataVolumeUsageHealthCheck(With(census)).CheckHealthAsync(new HealthCheckContext()); - - result.Status.Should().Be(HealthStatus.Healthy); - result.Description.Should().Contain("NOT MEASURED YET"); - } -} diff --git a/test/Memex.Portal.Shared.Test/DeclaredAccessAssignmentIsRowFilteredTest.cs b/test/Memex.Portal.Shared.Test/DeclaredAccessAssignmentIsRowFilteredTest.cs deleted file mode 100644 index c23a2ea9f5..0000000000 --- a/test/Memex.Portal.Shared.Test/DeclaredAccessAssignmentIsRowFilteredTest.cs +++ /dev/null @@ -1,136 +0,0 @@ -#pragma warning disable CS1591 - -using System.Reactive.Linq; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.Persistence.Query; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 An access grant DECLARED in configuration is row-filtered like a stored one. -/// -/// The static query provider serves configuration-declared nodes (AddMeshNodes, -/// ) from memory, and it served them to -/// every caller without the per-user row filter the storage providers apply. Almost everything -/// declared there is public by nature — type definitions, built-in roles, partition definitions, -/// documentation — but one shipped declaration is not: the platform-admin seed -/// (Auth:GlobalAdmins → GlobalAdminSeed) declares an AccessAssignment per -/// listed user under Admin/_Access, so any authenticated caller could list who holds -/// platform admin. -/// -/// The seed is reproduced here with the same shape (an Admin grant at scope Admin). -/// A caller with no grant must not find it through query-nodes, search or an -/// autocomplete drill-down under Admin/_Access; the seeded admin, who may read -/// Admin/_Access, still does — the positive control. -/// -public class DeclaredAccessAssignmentIsRowFilteredTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string SeededAdmin = "declaredgrant-admin"; - private const string Stranger = "declaredgrant-stranger"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - // 🚨 ConfigureMeshBase, not base.ConfigureMesh: the latter grants Public the Admin role in every - // default partition, under which every caller could read Admin/_Access and the filter would be vacuous. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddMeshNodes( - AssignmentNodeFactory.UserRole(SeededAdmin, "Admin", "Admin"), - new MeshNode(SatelliteNote, "Admin/_Access") { NodeType = "Markdown", Name = SatelliteNote }); - - private IMessageHub SessionHub() => SessionHubFactory.Resolve( - Mesh, "mcp", "declaredgrant", - Mesh.ServiceProvider.GetRequiredService().CreateLogger(nameof(DeclaredAccessAssignmentIsRowFilteredTest))); - - private Task As(string userId, Func> read) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - access.SetContext(new AccessContext { ObjectId = userId, Name = userId }); - return read(new MeshOperations(SessionHub())).Timeout(Budget).Await(TestContext.Current.CancellationToken); - } - - private const string Grants = "namespace:Admin/_Access nodeType:AccessAssignment"; - - [Fact] - public async Task QueryNodes_AStranger_DoesNotListTheDeclaredGrant() - { - var answer = await As(Stranger, ops => ops.QueryNodes(Grants)); - Output.WriteLine($"stranger: {answer}"); - - answer.Should().NotContain(SeededAdmin, - "the stranger may not read Admin/_Access — a declared grant must not name who holds platform admin"); - } - - [Fact] - public async Task Search_AStranger_DoesNotListTheDeclaredGrant() - { - var answer = await As(Stranger, ops => ops.Search(Grants)); - Output.WriteLine($"stranger: {answer}"); - - answer.Should().NotContain(SeededAdmin, - "the stranger may not read Admin/_Access — a declared grant must not name who holds platform admin"); - } - - [Fact] - public async Task QueryNodes_TheSeededAdmin_StillListsIt() - { - var answer = await As(SeededAdmin, ops => ops.QueryNodes(Grants)); - Output.WriteLine($"admin: {answer}"); - - answer.Should().Contain(SeededAdmin, - "the seeded admin may read Admin/_Access — if this fails, the filter above proves nothing"); - } - /// - /// A declared node on the same satellite path whose type is NOT excluded from autocomplete. - /// AccessAssignment itself is (AutocompleteExcludedNodeTypes), so a drill-down - /// never suggested the seed above — a probe of it would pass with or without the row filter. - /// This row is what makes the filter observable on the autocomplete surface. - /// - private const string SatelliteNote = "declaredgrant-note"; - - /// - /// The paths the declared-node provider suggests to for an autocomplete - /// drill-down under Admin/_Access — the provider itself, the leaf every - /// MeshQuery.Autocomplete fan-out (and so the chat drill-down) reaches. One snapshot, then - /// complete: the catalog is in memory. - /// - private Task AutocompleteAs(string userId) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - access.SetContext(new AccessContext { ObjectId = userId, Name = userId }); - return Mesh.ServiceProvider.GetRequiredService() - .Autocomplete("Admin/_Access", "", Mesh.JsonSerializerOptions, limit: 50) - .Select(rows => string.Join("\n", rows.Select(r => r.Path))) - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Autocomplete_AStranger_IsNotSuggestedADeclaredSatelliteRow() - { - var answer = await AutocompleteAs(Stranger); - Output.WriteLine($"stranger: {answer}"); - - answer.Should().NotContain(SatelliteNote, - "an autocomplete drill-down under Admin/_Access is the same read as search — a declared row there is filtered for the stranger exactly as a query of it is"); - } - - [Fact] - public async Task Autocomplete_TheSeededAdmin_IsStillSuggestedIt() - { - var answer = await AutocompleteAs(SeededAdmin); - Output.WriteLine($"admin: {answer}"); - - answer.Should().Contain(SatelliteNote, - "the seeded admin may read Admin/_Access — if this fails, the stranger's missing suggestion proves nothing"); - } -} diff --git a/test/Memex.Portal.Shared.Test/DeclaredBreakRollHoldTest.cs b/test/Memex.Portal.Shared.Test/DeclaredBreakRollHoldTest.cs deleted file mode 100644 index 4d457212b5..0000000000 --- a/test/Memex.Portal.Shared.Test/DeclaredBreakRollHoldTest.cs +++ /dev/null @@ -1,223 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The ladder's roll rule (policy platform-backwards-compatibility, -/// Doc/Architecture/DeployingAcrossPlatformVersions): a normal platform build rolls with NO -/// seal for the new build — the install keeps the plugin bytes it runs — and only behind a DECLARED -/// break (another compatibility key, or a ceiling the installed build declares) does a platform -/// roll wait for a replacement sealed for the target. Then the hold NAMES the plugin and both -/// versions. -/// -/// Each case is the pure gate () -/// on one package — Plugins/Store, content-bearing — against one target. The negative -/// controls are the two HOLD cases: the rule that lets the ordinary roll through must still stop -/// a declared break, or the "open ceiling rolls" case would be passing on a gate that holds -/// nothing. -/// -public class DeclaredBreakRollHoldTest -{ - private const string Key1 = "c003e001"; - private const string Key2 = "c003e002"; - private const string Running = "3.0.0-ci.9321"; - private const string Target = "3.0.0-ci.9400"; - - private static RequiredPackage Store(string? installedKey = Key1, string? ceiling = null) => - new("Plugins/Store", "Store", HasContent: true) - { - InstalledKey = installedKey, - InstalledCeiling = ceiling, - }; - - /// Nothing sealed for the target at all — the state right after a platform build. - private static ReleaseArtifacts NothingSealed() => ReleaseArtifacts.Of([]); - - /// The package's bundle sealed for the target, with the given declared range. - private static ReleaseArtifacts Replacement(string? floor, string? ceiling = null) => - ReleaseArtifacts.Of(["Store.zip"]) with - { - BundleRanges = ImmutableDictionary.Empty - .WithComparers(StringComparer.OrdinalIgnoreCase) - .Add("Store", new BundlePlatformRange(floor, ceiling)), - }; - - private static UpdatabilityVerdict Gate(ReleaseTarget target, RequiredPackage package, ReleaseArtifacts artifacts) => - ReleaseAvailability.IsUpdatable(target, [package], artifacts, ReleaseGatePolicy.Default); - - // ─────────────────────────────── the ordinary path: no seal, ever - - /// - /// 🚨 RATCHET: a platform roll inside one compatibility key, with an open ceiling, is updatable - /// with NOTHING sealed for the new build. A missing bake is the boot-compile COST it always was - /// (an advisory), never a seal requirement. Re-introducing "a platform roll waits for a plugin - /// seal for the new build" reds exactly this. - /// - [Fact] - public void AnOpenCeiling_SameKey_RollsWithoutAnySeal() - { - var verdict = Gate(new ReleaseTarget(Target, Key1), Store(), NothingSealed()); - - verdict.IsUpdatable.Should().BeTrue( - "a normal platform build changes nothing for plugins — no seal is on a platform roll's path"); - verdict.Packages.Should().ContainSingle().Which.Kind.Should().Be(PackageAvailabilityKind.ContentBakeMissing, - "the missing bake is still REPORTED as the boot-compile cost"); - verdict.BootCompiles.Should().Contain("Plugins/Store"); - } - - /// - /// A target whose key carries the package sealed by an EARLIER build of the same key: the roll - /// ADOPTS the older compatible set — said as such, never as "would recompile at boot". - /// - [Fact] - public void ASetSealedByAnEarlierBuildOfTheKey_ReadsAsAdopted_NotRecompiled() - { - var verdict = Gate(new ReleaseTarget(Target, Key1), Store(), Replacement(floor: "3.0.0-ci.9321")); - - verdict.IsUpdatable.Should().BeTrue(); - verdict.BootCompiles.Should().BeEmpty("the key's publication is adopted — nothing recompiles"); - verdict.Advisories.Should().Contain(a => a.Contains("adopts the older compatible set") - && a.Contains("Plugins/Store @ 3.0.0-ci.9321")); - } - - /// A legacy installed identity states no epoch, so it declares no range — the first - /// roll onto the keyed platform is an ordinary roll, not a held one. - [Fact] - public void ALegacyInstalledIdentity_IsNeverHeldByTheRange() - { - var verdict = Gate(new ReleaseTarget(Target, Key1), Store(installedKey: "s47313cc0000000000000000000000000"), NothingSealed()); - - verdict.IsUpdatable.Should().BeTrue(); - verdict.Packages.Should().NotContain(p => p.Kind == PackageAvailabilityKind.PlatformRangeExceeded); - } - - // ─────────────────────────────── behind a declared break - - /// - /// Another compatibility key (an epoch bump) with no replacement sealed for it: HELD, and the - /// reason names the plugin, the installed key, the target version and the target key. - /// - [Fact] - public void AnEpochBump_WithNoReplacement_IsHeld_AndNamesThePluginAndBothVersions() - { - var verdict = Gate(new ReleaseTarget(Target, Key2), Store(), NothingSealed()); - - verdict.IsUpdatable.Should().BeFalse(); - var hold = verdict.Blockers.Should().ContainSingle().Which; - hold.Kind.Should().Be(PackageAvailabilityKind.PlatformRangeExceeded); - hold.Package.Should().Be("Plugins/Store"); - hold.Reason.Should().Contain(Key1).And.Contain(Target).And.Contain(Key2) - .And.Contain("no replacement is sealed"); - verdict.HoldReason.Should().StartWith("Plugins/Store:"); - } - - /// A ceiling the installed build declares, below the target, on the SAME key: held. - [Fact] - public void ACeilingBelowTheTarget_WithNoReplacement_IsHeld() - { - var verdict = Gate(new ReleaseTarget(Target, Key1), Store(ceiling: "3.0.0-ci.9350"), NothingSealed()); - - verdict.IsUpdatable.Should().BeFalse(); - verdict.Blockers.Should().ContainSingle().Which.Reason.Should() - .Contain("3.0.0-ci.9350").And.Contain(Target); - } - - /// …and a ceiling AT or above the target does not hold. - [Fact] - public void ACeilingAtTheTarget_DoesNotHold() - { - Gate(new ReleaseTarget(Target, Key1), Store(ceiling: Target), NothingSealed()) - .Packages.Should().NotContain(p => p.Kind == PackageAvailabilityKind.PlatformRangeExceeded); - } - - /// - /// A replacement sealed under the target's key whose floor is at or below the target: the roll - /// proceeds — the break is covered. - /// - [Fact] - public void AnEpochBump_WithASealedReplacementWhoseFloorIsBelowTheTarget_Rolls() - { - var verdict = Gate(new ReleaseTarget(Target, Key2), Store(), Replacement(floor: "3.0.0-ci.9390")); - - verdict.IsUpdatable.Should().BeTrue(); - verdict.Packages.Should().ContainSingle().Which.Kind.Should().Be(PackageAvailabilityKind.Available); - } - - /// A replacement built by a platform NEWER than the target does not cover it (the - /// forbidden rung, platform 1 + plugin 2): still held, naming why. - [Fact] - public void AReplacementWhoseFloorIsAboveTheTarget_StillHolds() - { - var verdict = Gate(new ReleaseTarget(Target, Key2), Store(), Replacement(floor: "3.0.0-ci.9410")); - - verdict.IsUpdatable.Should().BeFalse(); - verdict.Blockers.Should().ContainSingle().Which.Reason.Should() - .Contain("3.0.0-ci.9410").And.Contain("does not admit the target"); - } - - /// An unreadable replacement manifest is never an open range: behind a break it holds, - /// as indeterminate (could not look), not as cleared. - [Fact] - public void AnUnreadableReplacementRange_HoldsAsIndeterminate() - { - var artifacts = ReleaseArtifacts.Of(["Store.zip"]) with - { - BundleRanges = ImmutableDictionary.Empty - .WithComparers(StringComparer.OrdinalIgnoreCase) - .Add("Store", new BundlePlatformRange(null, null) { Unreadable = "InvalidDataException" }), - }; - - var verdict = Gate(new ReleaseTarget(Target, Key2), Store(), artifacts); - - verdict.IsUpdatable.Should().BeFalse(); - verdict.IsIndeterminate.Should().BeTrue(); - } - - /// An installed range nobody could read is never an open ceiling. - [Fact] - public void AnUnreadableInstalledRange_HoldsAsIndeterminate_ButALegacyIdentityIsNotAffected() - { - var unreadable = Store() with { InstalledRangeUnreadable = "IOException: share unreachable" }; - Gate(new ReleaseTarget(Target, Key1), unreadable, NothingSealed()).IsIndeterminate.Should().BeTrue(); - - var legacy = Store(installedKey: "s47313cc0000000000000000000000000") with { InstalledRangeUnreadable = "IOException" }; - Gate(new ReleaseTarget(Target, Key1), legacy, NothingSealed()).IsUpdatable.Should().BeTrue(); - } - - // ─────────────────────────────── the walk: a held newest release is walked past - - /// - /// 🚨 A declared break is a statement about a PACKAGE, so under policy - /// platform-rolls-independently-of-packages it no longer walks the - /// platform back to the last build of the old epoch. The newest admitted release is selected, the plugin - /// that does not cover it is NAMED on the advisories, and it catches up through its own update - /// policy. The predicate itself still refuses (the tests above) — the waiver is the roll's. - /// - [Fact] - public async Task TheWalk_SelectsTheNewest_AndNamesThePluginThatDoesNotCoverIt() - { - const string LastOfEpoch1 = "3.0.0-ci.9399"; - var inputs = new RollSelectionInputs( - "memex", - Running, - [Target, LastOfEpoch1], - PluginInventory.Of([Store()], "test")); - - var outcome = await RollSelection.Select(inputs, version => Observable.Return(version == Target - ? Gate(new ReleaseTarget(Target, Key2), Store(), NothingSealed()) - : Gate(new ReleaseTarget(LastOfEpoch1, Key1), Store(), NothingSealed()))) - .Should().Within(TestTimeouts.Convergence).Emit(); - - outcome.SelectedVersion.Should().Be(Target); - outcome.Declined.Should().BeEmpty(); - outcome.Advisories.Should().NotBeEmpty(); - outcome.Advisories[0].Should().Contain("independently of packages").And.Contain("Plugins/Store").And.Contain(Key2); - } -} diff --git a/test/Memex.Portal.Shared.Test/DeclaredBuildPrincipalTest.cs b/test/Memex.Portal.Shared.Test/DeclaredBuildPrincipalTest.cs deleted file mode 100644 index de66d4a545..0000000000 --- a/test/Memex.Portal.Shared.Test/DeclaredBuildPrincipalTest.cs +++ /dev/null @@ -1,277 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Memex.Portal.ServiceDefaults; -using MeshWeaver.Mesh.Security; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A build principal DECLARED in configuration (policy declared-not-derived): the rules a -/// declaration must satisfy, and what /health says about it. -/// -/// 🚨 A declaration is stricter than a node. Every refusal below starts from the -/// declaration that IS accepted and moves exactly one thing, so a passing assertion can only mean -/// that one rule was checked — and each refused declaration is asserted to be trusted for nothing, -/// never narrowed into something that passes. -/// -public class DeclaredBuildPrincipalTest -{ - private const string Repository = "Systemorph/MeshWeaver"; - private const string Audience = "https://instance.declared.test"; - private static readonly DateTimeOffset Now = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero); - - /// The designed declaration, in the key shape a portal's environment produces - /// (__ already folded to :). - private static Dictionary Designed(int index = 0, string repository = Repository) - { - var prefix = $"{DeclaredBuildPrincipals.ConfigSection}:{index}"; - return new Dictionary - { - [$"{prefix}:Repository"] = repository, - [$"{prefix}:RepositoryId"] = "743606761", - [$"{prefix}:RepositoryOwnerId"] = "77832550", - [$"{prefix}:Events:workflow_run:0"] = "verify", - [$"{prefix}:Events:workflow_dispatch:0"] = "verify", - [$"{prefix}:EventRefs:workflow_run:0"] = "refs/heads/main", - [$"{prefix}:EventRefs:workflow_dispatch:0"] = "refs/heads/main", - [$"{prefix}:Scopes:0"] = "verify:combo", - }; - } - - private static IConfiguration Config(Dictionary values, bool audience = true) - { - var all = new Dictionary(values); - if (audience) - all[DeclaredBuildPrincipals.AudienceConfigKey] = Audience; - return new ConfigurationBuilder().AddInMemoryCollection(all).Build(); - } - - private static Dictionary With( - Dictionary values, string suffix, string? value) - { - var copy = new Dictionary(values); - var key = $"{DeclaredBuildPrincipals.ConfigSection}:0:{suffix}"; - if (value is null) - copy.Remove(key); - else - copy[key] = value; - return copy; - } - - private static GitHubBuildClaims Claims( - string repository = Repository, - string eventName = "workflow_run", - string gitRef = "refs/heads/main", - string repositoryId = "743606761", - string ownerId = "77832550") => - new() - { - Repository = repository, - RepositoryId = repositoryId, - RepositoryOwnerId = ownerId, - EventName = eventName, - Ref = gitRef, - }; - - [Fact] - public void TheDesignedDeclaration_IsAccepted_AndAllowsExactlyWhatItDeclares() - { - var declarations = DeclaredBuildPrincipals.Read(Config(Designed())); - - Assert.Empty(declarations.Refused); - var declared = Assert.Single(declarations.Accepted); - Assert.Equal($"{DeclaredBuildPrincipals.ConfigSection}:0", declared.ConfigPath); - var principal = declared.Principal; - - // The designed case, on both declared events. - Assert.True(principal.Allows(Claims(), BuildVerbs.Verify, "combo", Now)); - Assert.True(principal.Allows(Claims(eventName: "workflow_dispatch"), BuildVerbs.Verify, "combo", Now)); - - // …and one thing moved at a time. - Assert.False(principal.Allows(Claims(eventName: "push"), BuildVerbs.Verify, "combo", Now)); - Assert.False(principal.Allows(Claims(gitRef: "refs/heads/feature"), BuildVerbs.Verify, "combo", Now)); - Assert.False(principal.Allows(Claims(repositoryId: "1"), BuildVerbs.Verify, "combo", Now)); - Assert.False(principal.Allows(Claims(ownerId: "1"), BuildVerbs.Verify, "combo", Now)); - Assert.False(principal.Allows(Claims(repository: "Systemorph/MeshWeaver.Evil"), BuildVerbs.Verify, "combo", Now)); - Assert.False(principal.Allows(Claims(), BuildVerbs.Fetch, "plugins", Now)); - Assert.False(principal.Allows(Claims(), BuildVerbs.Publish, "combo", Now)); - } - - [Fact] - public void Find_MatchesTheRepositoryExactly_NeverAsAPrefix() - { - var declarations = DeclaredBuildPrincipals.Read(Config(Designed())); - - Assert.NotNull(declarations.Find("systemorph/meshweaver")); - Assert.Null(declarations.Find("Systemorph/MeshWeaver.Plugins")); - Assert.Null(declarations.Find("Systemorph/Mesh")); - Assert.Null(declarations.Find(null)); - } - - public static TheoryData Refusals => new() - { - { "Repository", "NotARepository", "owner/name" }, - { "Repository", null, "owner/name" }, - { "RepositoryId", null, "RepositoryId" }, - { "RepositoryId", "not-a-number", "RepositoryId" }, - { "RepositoryOwnerId", null, "RepositoryOwnerId" }, - { "Scopes:0", null, "Scopes is empty" }, - { "Scopes:0", "fetch:plugins", "cannot be declared in configuration" }, - { "Scopes:1", "publish:plugins", "cannot be declared in configuration" }, - { "Events:workflow_run:0", "publish", "no declared scope carries" }, - { "Events:push:0", "verify", "carries no EventRefs allow-list" }, - { "EventRefs:workflow_run:0", null, "carries no EventRefs allow-list" }, - { "EventRefs:workflow_run:0", "main", "not a full git ref" }, - { "EventRefs:pull_request:0", "refs/heads/main", "that Events does not list" }, - { "IsRevoked", "false", "unknown key" }, - { "Scope", "verify:combo", "unknown key" }, - // An unknown key at ANY depth refuses the declaration, not only an immediate child. - { "RepositoryId:Typo", "1", "'RepositoryId' is a single value" }, - { "Repository:Alias", "Systemorph/Other", "'Repository' is a single value" }, - { "Notes:Extra", "x", "'Notes' is a single value" }, - { "Scopes:0:Extra", "fetch:plugins", "'Scopes' is a list of values" }, - { "Events", "verify", "carries a value of its own" }, - { "Events:workflow_run:0:Extra", "publish", "'Events' is a map of event → list" }, - { "EventRefs:workflow_run:0:Extra", "refs/heads/feature", "'EventRefs' is a map of event → list" }, - }; - - [Theory] - [MemberData(nameof(Refusals))] - public void ADeclarationThatBreaksOneRule_IsRefusedWhole_AndNamed(string key, string? value, string reason) - { - var declarations = DeclaredBuildPrincipals.Read(Config(With(Designed(), key, value))); - - Assert.Empty(declarations.Accepted); - var refused = Assert.Single(declarations.Refused); - Assert.Contains(reason, refused.Reason, StringComparison.Ordinal); - Assert.Null(declarations.Find(Repository)); - } - - [Fact] - public void NoEventsAtAll_IsRefused() - { - var values = Designed().Where(p => !p.Key.Contains(":Events:", StringComparison.Ordinal)) - .ToDictionary(p => p.Key, p => p.Value); - - var declarations = DeclaredBuildPrincipals.Read(Config(values)); - - Assert.Empty(declarations.Accepted); - Assert.Contains("Events is empty", Assert.Single(declarations.Refused).Reason, StringComparison.Ordinal); - } - - [Fact] - public void ARepositoryDeclaredTwice_IsTrustedThroughNeither_AndAnotherDeclarationIsUntouched() - { - var values = Designed(0); - foreach (var pair in Designed(1, "systemorph/meshweaver")) - values[pair.Key] = pair.Value; - foreach (var pair in Designed(2, "Systemorph/Other")) - values[pair.Key] = pair.Value; - - var declarations = DeclaredBuildPrincipals.Read(Config(values)); - - Assert.Equal("Systemorph/Other", Assert.Single(declarations.Accepted).Principal.Repository); - Assert.Equal(2, declarations.Refused.Count); - Assert.All(declarations.Refused, r => Assert.Contains("more than once", r.Reason, StringComparison.Ordinal)); - Assert.Null(declarations.Find(Repository)); - } - - /// - /// 🚨 A refused declaration still names its repository. One valid and one malformed declaration - /// for the same repository is trusted through neither: which of the two the estate meant is not - /// for the reader to guess, and the malformed one may be the narrower. - /// - [Fact] - public void ARepositoryDeclaredOnceValidAndOnceMalformed_IsTrustedThroughNeither() - { - var values = Designed(0); - foreach (var pair in Designed(1, "systemorph/meshweaver")) - values[pair.Key] = pair.Value; - values.Remove($"{DeclaredBuildPrincipals.ConfigSection}:1:RepositoryId"); - foreach (var pair in Designed(2, "Systemorph/Other")) - values[pair.Key] = pair.Value; - - var declarations = DeclaredBuildPrincipals.Read(Config(values)); - - Assert.Equal("Systemorph/Other", Assert.Single(declarations.Accepted).Principal.Repository); - Assert.Null(declarations.Find(Repository)); - Assert.Equal(2, declarations.Refused.Count); - Assert.Contains(declarations.Refused, r => r.Reason.Contains("RepositoryId", StringComparison.Ordinal)); - Assert.Contains(declarations.Refused, r => r.Reason.Contains("more than once", StringComparison.Ordinal)); - - // The control: the same malformed declaration naming ANOTHER repository refuses only itself. - values[$"{DeclaredBuildPrincipals.ConfigSection}:1:Repository"] = "Systemorph/Third"; - var apart = DeclaredBuildPrincipals.Read(Config(values)); - Assert.NotNull(apart.Find(Repository)); - Assert.Single(apart.Refused); - } - - // ── /health ────────────────────────────────────────────────────────────── - - [Fact] - public void NothingDeclared_IsHealthy_AndSaysSo() - { - var entry = BuildPrincipalHealthCheck.Evaluate(Config([])); - - Assert.Equal(HealthStatus.Healthy, entry.Status); - Assert.Contains("no build principal is DECLARED", entry.Description, StringComparison.Ordinal); - } - - [Fact] - public void ADeclaredPrincipal_IsNamedInFullOnHealth() - { - var entry = BuildPrincipalHealthCheck.Evaluate(Config(Designed())); - - Assert.Equal(HealthStatus.Healthy, entry.Status); - foreach (var fact in new[] - { - "1 build principal(s) DECLARED", Repository, "743606761", "77832550", - "workflow_run@refs/heads/main", "workflow_dispatch@refs/heads/main", "verify:combo", - }) - Assert.Contains(fact, entry.Description, StringComparison.Ordinal); - Assert.DoesNotContain("\n", entry.Description, StringComparison.Ordinal); - } - - [Fact] - public void ARefusedDeclaration_IsDegraded_AndNamesTheRule() - { - var entry = BuildPrincipalHealthCheck.Evaluate(Config(With(Designed(), "Scopes:0", "fetch:plugins"))); - - Assert.Equal(HealthStatus.Degraded, entry.Status); - Assert.Contains("REFUSED", entry.Description, StringComparison.Ordinal); - Assert.Contains("cannot be declared in configuration", entry.Description, StringComparison.Ordinal); - } - - [Fact] - public void ADeclaredPrincipalWithNoAudience_IsDegraded_BecauseTheWholeLegIsOff() - { - var entry = BuildPrincipalHealthCheck.Evaluate(Config(Designed(), audience: false)); - - Assert.Equal(HealthStatus.Degraded, entry.Status); - Assert.Contains("NO audience is configured", entry.Description, StringComparison.Ordinal); - } - - [Fact] - public void TheHealthLine_PrintsAlthoughItIsHealthy() - { - var entry = BuildPrincipalHealthCheck.Evaluate(Config(Designed())); - var report = new HealthReport( - new Dictionary - { - [DeclaredBuildPrincipals.HealthCheckName] = new( - entry.Status, entry.Description, TimeSpan.Zero, null, null, [ProbeEndpoints.CensusTag]), - }, - TimeSpan.Zero); - - var line = Memex.Portal.ServiceDefaults.ServiceDefaults - .HealthBodyLines(report, Memex.Portal.ServiceDefaults.ServiceDefaults.StartupStatus(report)) - .Single(l => l.StartsWith(DeclaredBuildPrincipals.HealthCheckName + ":", StringComparison.Ordinal)); - - Assert.StartsWith("build_principals: Healthy", line, StringComparison.Ordinal); - Assert.Contains(Repository, line, StringComparison.Ordinal); - } -} diff --git a/test/Memex.Portal.Shared.Test/DeclaredModuleThatIsNotServedIsAMissTest.cs b/test/Memex.Portal.Shared.Test/DeclaredModuleThatIsNotServedIsAMissTest.cs deleted file mode 100644 index ca6da4b234..0000000000 --- a/test/Memex.Portal.Shared.Test/DeclaredModuleThatIsNotServedIsAMissTest.cs +++ /dev/null @@ -1,134 +0,0 @@ -#pragma warning disable CS1591 - -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A package that DECLARES a module the registry cannot serve at the requested version (the package -/// record is ahead of the module's own publish) used to be answered with a manifest that declared -/// nothing at all: no module section (written only when files exist), no miss. Every consumer read -/// it as an empty archive — /health bundle_adoption Degraded, "the bundle carried no -/// assemblies", an Information log and no producer warning — indistinguishable from a producer -/// that shipped an empty archive. The registry must state the decline as a -/// module '<name>': not served — … miss, and the consumer must carry it into its reason. -/// Real authenticated route, real install record, real (empty) module shelf. -/// -public class DeclaredModuleThatIsNotServedIsAMissTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Plugin = "LaggingModulePkg"; - private const string Source = "Plugins"; - private const string Version = "1.37.5"; - private const string ModuleName = "MeshWeaver.LaggingModule"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - [Fact(Timeout = 300_000)] - public async Task ADeclaredModuleTheShelfDoesNotHold_IsStatedAsAMissOnTheWire() - { - var ct = TestContext.Current.CancellationToken; - await InstallRecord(ct); - var key = await RegisterInstance(ct); - await using var app = await StartBundleHost(ct); - - var (manifest, assemblies) = BundleReader.Read(await Bundle(app, key, ct)); - - Assert.NotNull(manifest); - Assert.Empty(assemblies); - var miss = Assert.Single(manifest!.Misses ?? []); - Assert.True(BundleReader.IsModuleNotServedMiss(miss), miss); - Assert.Contains($"module '{ModuleName}'", miss); - Assert.Contains(Version, miss); - - // The consumer's rule is untouched: still NOT "nothing to adopt" — but now it has a reason. - Assert.Equal(MeshWeaver.PluginCatalog.BundleAdoptionKind.NoAssemblies, - BundleOffering.ClassifyEmpty(manifest)); - } - - [Fact] - public void ANotServedModuleMiss_IsNotNodeTypeCoverage_ButADivergenceStillIs() - { - Assert.True(BundleReader.IsModuleNotServedMiss( - $"module 'M': {BundleReader.ModuleNotServedReason}the shelf holds no generation")); - Assert.False(BundleReader.IsModuleNotServedMiss("module 'M': the assemblies were built against another module")); - Assert.False(BundleReader.IsModuleNotServedMiss("Pkg/Type: no artifact for this lane")); - Assert.False(BundleReader.IsModuleNotServedMiss(null)); - } - - private Task InstallRecord(CancellationToken ct) => - PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = Plugin, - Name = Plugin, - Kind = PackageKind.Content, - TargetPartition = Plugin, - SourceFolder = Plugin, - Version = "content-path-commit", - ReleasedVersion = Version, - Module = ModuleName, - Source = Source, - }, - [new PackageFile($"{Plugin}/Doc.md", "# Lagging module")], - "HEAD") - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - private Task RegisterInstance(CancellationToken ct) => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [$"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:0"] = $"{Source}/*", - }) - .Build()) - .Register("lag-owner", "Lag Owner", "owner@test.com", "lag-consumer", "lag-consumer") - .Select(result => result.RawKey) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - private async Task StartBundleHost(CancellationToken ct) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(Mesh.ServiceProvider.GetRequiredService()); - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(ct); - return app; - } - - private static async Task Bundle(WebApplication app, string key, CancellationToken ct) - { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{PluginBundleEndpoints.RoutePrefix}/{Plugin}/{Version}"); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - using var response = await app.GetTestClient().SendAsync(request, ct); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - return await response.Content.ReadAsByteArrayAsync(ct); - } -} diff --git a/test/Memex.Portal.Shared.Test/DeclaredModuleWithNoBinaryIsReportedTest.cs b/test/Memex.Portal.Shared.Test/DeclaredModuleWithNoBinaryIsReportedTest.cs deleted file mode 100644 index aacae3f6a4..0000000000 --- a/test/Memex.Portal.Shared.Test/DeclaredModuleWithNoBinaryIsReportedTest.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A MIXED PACKAGE WHOSE MODULE NEVER LANDS MUST SAY SO — never report a clean install -/// (Systemorph/MeshWeaver.Plugins#1597). -/// -/// A package that declares content.module arrives in two halves: its NODES come with -/// the package, its layout areas and node types come only with the compiled module. Installing the -/// first half and not the second is a SILENT and TOTAL loss of the second, and every surface reads -/// clean: the package is preInstalled, its install record names the module, the boot -/// reconcile reports it up to date, its guide page renders, and only the areas are absent. -/// -/// Measured on a memex-local self-registry install, platform 3.0.0-ci.8118: the -/// Export package was installed and Export/Guide rendered, while -/// get @Export/Guide/area/ExportPdf answered Area not found and the node menu carried -/// no Export group at all. MeshWeaver.Markdown.Export.dll was in neither /app nor -/// /app/modules, and a mounted checkout can never land a module BINARY -/// (Systemorph/MeshWeaver#2417). Walking a course to completion issues a correct certificate that -/// cannot then be exported — and nothing anywhere says why. -/// -/// does not cover this and cannot: it answers whether -/// the REGISTRY offered the package, and a self-registry install offers its own checkout perfectly -/// well. The unreported state is the next one along — offered, installed, and the binary still has -/// no lane that can produce it here. -/// -/// 🚨 This REPORTS. It never refuses, and it is not an entitlement verdict — the same -/// discipline keeps, and for the same reason: a portal that will not -/// boot cannot be given the fix for whatever is wrong with it. Which modules a deployment SHOULD -/// carry is a policy question this does not answer; making the shortfall visible is the whole -/// deliverable. -/// -public class DeclaredModuleWithNoBinaryIsReportedTest -{ - private static PackageManifest Package(string id, string? module, string? version = null) => - new() { Id = id, Name = id, Module = module, ModuleVersion = version }; - - /// Present exactly for the modules named here; absent for everything else. - private static Func Present(params string[] modules) - { - var set = modules.ToHashSet(StringComparer.OrdinalIgnoreCase); - return set.Contains; - } - - [Fact] - public void ADeclaredModuleWithNoBinaryOnThisInstallationIsNamed() - { - var absent = ModuleDelivery.BinaryAbsent( - [Package("Export", "MeshWeaver.Markdown.Export", "1.3")], - Present()); - - var only = Assert.Single(absent); - Assert.Equal("Export", only.PackageId); - Assert.Equal("MeshWeaver.Markdown.Export", only.Module); - // The claimed identity rides along, because that is what makes the shape legible: the - // record says 1.3 while there is no assembly at all. - Assert.Equal("1.3", only.InstalledModuleVersion); - Assert.Contains("MeshWeaver.Markdown.Export", only.Describe(), StringComparison.Ordinal); - } - - [Fact] - public void AModuleWhoseBinaryIsHereIsNotNamed() - { - Assert.Empty(ModuleDelivery.BinaryAbsent( - [Package("Mcp", "MeshWeaver.Mcp")], - Present("MeshWeaver.Mcp"))); - } - - /// - /// 🚨 The negative that keeps this usable. Most installed packages are content-only, and naming - /// them would bury the ones that have actually lost half of themselves — the annotation-on-every - /// -green-run failure this repo has met before. - /// - [Fact] - public void AContentOnlyPackageIsNeverNamed_AndIsNeverEvenProbed() - { - var probed = new List(); - var absent = ModuleDelivery.BinaryAbsent( - [Package("Northwind", null), Package("Chess", " ")], - module => { probed.Add(module); return false; }); - - Assert.Empty(absent); - // Probing the filesystem for a module nobody declared would put a name in the log that no - // install record ever claimed. - Assert.Empty(probed); - } - - [Fact] - public void EveryDeclaredModuleIsProbedExactlyOnce() - { - var probed = new List(); - ModuleDelivery.BinaryAbsent( - [Package("Export", "MeshWeaver.Markdown.Export"), Package("Voice", "MeshWeaver.Speech")], - module => { probed.Add(module); return false; }); - - Assert.Equal(["MeshWeaver.Markdown.Export", "MeshWeaver.Speech"], probed.Order()); - Assert.Equal(2, probed.Count); - } - - /// Ordered and case-insensitive on the package id — the same comparison every other - /// install-record lookup on this path uses, so two readings never disagree on order. - [Fact] - public void TheReportIsOrderedByPackageId() - { - var absent = ModuleDelivery.BinaryAbsent( - [Package("voice", "MeshWeaver.Speech"), Package("Export", "MeshWeaver.Markdown.Export")], - Present()); - - Assert.Equal(["Export", "voice"], absent.Select(u => u.PackageId)); - } - - /// An unreadable record's content arrives as null, and a record with no id is not a - /// record. Neither may take down a boot-time report. - [Fact] - public void NullsAndIdlessRecordsAreSkipped_AndNullInputIsEmpty() - { - Assert.Empty(ModuleDelivery.BinaryAbsent(null, Present())); - Assert.Empty(ModuleDelivery.BinaryAbsent( - [null, Package("", "MeshWeaver.Markdown.Export")], Present())); - } - - /// 🚨 A probe that cannot be consulted must not silently answer "everything is fine". - /// No probe means nothing is known, and the report says nothing rather than clearing every - /// package. - [Fact] - public void WithNoProbeNothingIsClaimed() - { - Assert.Empty(ModuleDelivery.BinaryAbsent( - [Package("Export", "MeshWeaver.Markdown.Export")], null)); - } -} diff --git a/test/Memex.Portal.Shared.Test/DeclaredNodeIsRowFilteredInQueriesTest.cs b/test/Memex.Portal.Shared.Test/DeclaredNodeIsRowFilteredInQueriesTest.cs deleted file mode 100644 index 79c4acdefd..0000000000 --- a/test/Memex.Portal.Shared.Test/DeclaredNodeIsRowFilteredInQueriesTest.cs +++ /dev/null @@ -1,232 +0,0 @@ -#pragma warning disable CS1591 - -using System.Reactive.Linq; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.Persistence.Query; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A node DECLARED in configuration is row-filtered in a query exactly as a stored one is. -/// -/// The static query provider served every declared node (AddMeshNodes, -/// ) to every viewer, filtering only satellite paths -/// (_Access, _Thread, …). A POINT read of the same node was already denied, so the two -/// read seams disagreed: a viewer with no grant on a partition listed a node there through -/// — with the viewer stamped explicitly on the request — that -/// GetMeshNodeStream refused them. -/// -/// The fixture declares one ordinary (non-satellite) node in a partition only -/// holds Read on. must get no row; the reader must — the -/// control without which an empty answer proves nothing. -/// -public class DeclaredNodeIsRowFilteredInQueriesTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Partition = "declaredrls-private"; - private const string SecretId = "declaredrls-secret"; - private const string SecretPath = Partition + "/" + SecretId; - // Declared WITHOUT a node type and without a hub configuration — ordinary content, not a type - // registration. A missing node type alone must not make a declared node vocabulary. - private const string UntypedId = "declaredrls-untyped"; - private const string UntypedPath = Partition + "/" + UntypedId; - // Untyped AND carrying a hub configuration (the shape of a type registration), but filed as - // SIBLING metadata of the partition: _Policy is a regular mesh_nodes row, not a satellite - // table, so IsSatellitePath does not see it. Also one level below it. Neither is vocabulary. - private const string SiblingPath = Partition + "/_Policy"; - private const string NestedId = "declaredrls-nested"; - private const string NestedPath = SiblingPath + "/" + NestedId; - // The SAME two shapes declared with a FULL-PATH id (Namespace null, Id = the whole path) - a - // documented registration shape that AddMeshNodes preserves. Classification must read the PATH, - // never how it happens to be split between Id and Namespace. - private const string FullPathSibling = Partition + "/fullpath/_Policy"; - private const string FullPathNested = FullPathSibling + "/declaredrls-fullpath-nested"; - private const string Reader = "declaredrls-reader"; - private const string Stranger = "declaredrls-stranger"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - // 🚨 ConfigureMeshBase, not base.ConfigureMesh: the latter grants Public the Admin role in every - // default partition, under which every viewer could read the partition and the filter would be vacuous. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddMeshNodes( - new MeshNode(SecretId, Partition) { NodeType = "Markdown", Name = SecretId }, - new MeshNode(UntypedId, Partition) { Name = UntypedId, Content = "

untyped secret body

" }, - new MeshNode("_Policy", Partition) { Name = "declaredrls-sibling", HubConfiguration = config => config }, - new MeshNode(NestedId, SiblingPath) { Name = NestedId, HubConfiguration = config => config }, - new MeshNode(FullPathSibling) { Name = "declaredrls-fullpath-sibling", HubConfiguration = config => config }, - new MeshNode(FullPathNested) { Name = "declaredrls-fullpath-nested", HubConfiguration = config => config }, - AssignmentNodeFactory.UserRole(Reader, "Viewer", Partition)); - - private Task> QueryAs(string viewer, string query) - => Mesh.ServiceProvider.GetRequiredService() - .Query(new MeshQueryRequest { Query = query, UserId = viewer }) - .FirstAsync() - .Select(change => (IReadOnlyList)change.Items.Select(n => n.Path).ToList()) - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - [Theory] - [InlineData("path:" + SecretPath)] - [InlineData("namespace:" + Partition)] - [InlineData("namespace:" + Partition + " scope:descendants nodeType:Markdown")] - public async Task AViewerWithNoGrant_GetsNoDeclaredRow(string query) - { - var rows = await QueryAs(Stranger, query); - Output.WriteLine($"stranger [{query}]: {string.Join(", ", rows)}"); - - rows.Should().NotContain(SecretPath, - "the stranger holds no grant on the partition — a point read of the node is denied, and a query must not answer differently"); - } - - [Theory] - [InlineData("path:" + SecretPath)] - [InlineData("namespace:" + Partition)] - [InlineData("namespace:" + Partition + " scope:descendants nodeType:Markdown")] - public async Task AViewerWithRead_StillGetsTheDeclaredRow(string query) - { - var rows = await QueryAs(Reader, query); - Output.WriteLine($"reader [{query}]: {string.Join(", ", rows)}"); - - rows.Should().Contain(SecretPath, - "the reader holds Viewer on the partition — if this fails, the stranger's empty answer proves nothing"); - } - - /// - /// An UNTYPED declared node with no hub configuration is content, not a type registration: the - /// stranger gets it neither from a query nor from an autocomplete drill-down; the reader gets both. - /// - [Theory] - [InlineData("path:" + UntypedPath)] - [InlineData("namespace:" + Partition)] - public async Task AnUntypedDeclaredNode_IsFilteredInAQuery(string query) - { - var stranger = await QueryAs(Stranger, query); - var reader = await QueryAs(Reader, query); - Output.WriteLine($"[{query}] stranger: {string.Join(", ", stranger)} | reader: {string.Join(", ", reader)}"); - - stranger.Should().NotContain(UntypedPath, "a missing node type does not make declared content vocabulary"); - reader.Should().Contain(UntypedPath, "the reader may read the partition — the control"); - } - - [Fact] - public async Task AnUntypedDeclaredNode_IsFilteredInAutocomplete() - { - var stranger = await AutocompleteAs(Stranger); - var reader = await AutocompleteAs(Reader); - Output.WriteLine($"stranger: {string.Join(", ", stranger)} | reader: {string.Join(", ", reader)}"); - - stranger.Should().NotContain(UntypedPath, "an autocomplete drill-down is the same read as a query"); - reader.Should().Contain(UntypedPath, "the reader may read the partition — the control"); - } - - /// - /// WhatsNew is an untyped registration WITHOUT a hub configuration, so it is not - /// vocabulary and the row filter decides it — and release notes are for everyone. Its read access - /// is DECLARED (a PublicRead policy on its partition, as Doc and _Setting - /// declare theirs): a viewer holding no grant anywhere still finds it, in the same request where - /// the untyped private node is still withheld from them — the policy opens one partition, not the - /// filter. - /// - [Fact] - public async Task WhatsNew_IsReadableByEveryone_WhilePrivateUntypedContentStaysHidden() - { - var whatsNew = await QueryAs(Stranger, "path:" + MeshWeaver.Graph.Configuration.WhatsNewNodeType.NodeType); - var privateRows = await QueryAs(Stranger, "path:" + UntypedPath); - Output.WriteLine($"stranger whatsnew: {string.Join(", ", whatsNew)} | private: {string.Join(", ", privateRows)}"); - - whatsNew.Should().Contain(MeshWeaver.Graph.Configuration.WhatsNewNodeType.NodeType, - "WhatsNew declares PublicRead on its partition — release notes are readable by every viewer"); - privateRows.Should().NotContain(UntypedPath, - "the WhatsNew policy opens its own partition only; untyped private content is still row-filtered"); - } - - /// - /// A registration-SHAPED node (untyped, with a hub configuration) filed under a sibling - /// metadata segment such as _Policy is still not vocabulary: the stranger gets it from - /// neither a query nor an autocomplete drill-down; the reader gets both. - /// - [Theory] - [InlineData("path:" + SiblingPath, SiblingPath)] - [InlineData("path:" + NestedPath, NestedPath)] - [InlineData("namespace:" + Partition + " scope:descendants", SiblingPath)] - [InlineData("namespace:" + Partition + " scope:descendants", NestedPath)] - [InlineData("path:" + FullPathSibling, FullPathSibling)] - [InlineData("path:" + FullPathNested, FullPathNested)] - [InlineData("namespace:" + Partition + " scope:descendants", FullPathSibling)] - [InlineData("namespace:" + Partition + " scope:descendants", FullPathNested)] - public async Task ARegistrationShapedNodeUnderASiblingSegment_IsFilteredInAQuery(string query, string path) - { - var stranger = await QueryAs(Stranger, query); - var reader = await QueryAs(Reader, query); - Output.WriteLine($"[{query}] stranger: {string.Join(", ", stranger)} | reader: {string.Join(", ", reader)}"); - - stranger.Should().NotContain(path, "an underscore metadata segment is never vocabulary, whatever the node's shape"); - reader.Should().Contain(path, "the reader may read the partition - the control"); - } - - [Fact] - public async Task ARegistrationShapedNodeUnderASiblingSegment_IsFilteredInAutocomplete() - { - var stranger = await AutocompleteAs(Stranger); - var reader = await AutocompleteAs(Reader); - Output.WriteLine($"stranger: {string.Join(", ", stranger)} | reader: {string.Join(", ", reader)}"); - - stranger.Should().NotContain(SiblingPath, "a sibling metadata node is never vocabulary"); - stranger.Should().NotContain(NestedPath, "nor is anything under one"); - stranger.Should().NotContain(FullPathSibling, "declared with a full-path id it is the same metadata"); - stranger.Should().NotContain(FullPathNested, "declared with a full-path id it is the same metadata"); - reader.Should().Contain(SiblingPath, "the reader may read the partition - the control"); - reader.Should().Contain(NestedPath, "the reader may read the partition - the control"); - reader.Should().Contain(FullPathSibling, "the reader may read the partition - the control"); - reader.Should().Contain(FullPathNested, "the reader may read the partition - the control"); - } - - /// The paths the declared-node provider suggests to under the partition. - private Task> AutocompleteAs(string viewer) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - access.SetContext(new AccessContext { ObjectId = viewer, Name = viewer }); - return Mesh.ServiceProvider.GetRequiredService() - .Autocomplete(Partition, "", Mesh.JsonSerializerOptions, limit: 50) - .Select(rows => (IReadOnlyList)rows.Select(r => r.Path).ToList()) - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - } - - /// - /// The definitions stay vocabulary: a viewer with no grant anywhere still resolves a built-in role - /// and a type registration — no grant is ever written on them, so filtering them would empty every - /// role and type picker for everyone but an admin. - /// - [Theory] - [InlineData("path:Role/Viewer", "Role/Viewer")] - [InlineData("path:Markdown", "Markdown")] - public async Task AViewerWithNoGrant_StillResolvesTheDefinitions(string query, string expected) - { - var rows = await QueryAs(Stranger, query); - Output.WriteLine($"stranger [{query}]: {string.Join(", ", rows)}"); - - rows.Should().Contain(expected, "a definition is platform vocabulary, served to every viewer"); - } - - /// The point read the query must agree with: the fold denies the stranger and admits the reader. - [Fact] - public async Task ThePointReadVerdictIsTheOneTheQueryFollows() - { - var ct = TestContext.Current.CancellationToken; - var stranger = await Mesh.GetEffectivePermissions(SecretPath, Stranger).FirstAsync().Timeout(Budget).Await(ct); - var reader = await Mesh.GetEffectivePermissions(SecretPath, Reader).FirstAsync().Timeout(Budget).Await(ct); - Output.WriteLine($"stranger={stranger} reader={reader}"); - - stranger.HasFlag(Permission.Read).Should().BeFalse("the stranger holds no grant on the partition"); - reader.HasFlag(Permission.Read).Should().BeTrue("the reader holds Viewer on the partition"); - } -} diff --git a/test/Memex.Portal.Shared.Test/DeleteTokenReportsWhatItRemovedTest.cs b/test/Memex.Portal.Shared.Test/DeleteTokenReportsWhatItRemovedTest.cs deleted file mode 100644 index dcc3000bc8..0000000000 --- a/test/Memex.Portal.Shared.Test/DeleteTokenReportsWhatItRemovedTest.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System.Reactive.Linq; -using Memex.Portal.Shared.Authentication; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// reports WHAT IT REMOVED, not merely that it ran. -/// -/// The defect. DeleteToken projected the delete with -/// .Select(_ => true) and recovered faults to false. That read correctly only while -/// deleting an absent path FAULTED: MeshWeaver#4668 made it a success emitting false, so the -/// projection began overwriting the one bit the new contract added and every absent path reported a -/// removal that never happened. -/// -/// Why it is worth a test rather than a one-line tidy. The damage is not the wrong -/// value, it is what the wrong value SILENCES. true from an unconditional projection is a -/// constant, so every assertion that DeleteToken returned true becomes unfailable — -/// including the stale-read regression test whose whole subject is DeleteToken resolving a -/// path the token is not at. A defect that turns another test vacuous outlives its own fix. -/// -/// SHOULD-FAIL-IF the projection comes back: -/// goes red on the second call, and it is the same path both times, so no path-shape or -/// permission story can explain the difference away. -/// is the control in the other direction — it fails if the value is hardwired to false, so -/// the pair cannot both be satisfied by a constant. -/// -public class DeleteTokenReportsWhatItRemovedTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// The user whose token this suite's validator refuses to delete. - private const string RefusedUser = "delete-refused-user"; - - private const string RefusalReason = "this token is spoken for"; - - private readonly RefuseDeletingThisUsersTokens refusing = new(RefusedUser, RefusalReason); - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).ConfigureServices(services => services - .AddSingleton(refusing)); - - private ApiTokenService GetService() => - new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService(), - Mesh.ServiceProvider.GetRequiredService>()); - - [Fact] - public async Task TheFirstDeleteOfALiveToken_RemovesIt() - { - var service = GetService(); - var created = await service.CreateToken( - "delete-reports-user", "Delete Reports", "delete-reports@example.com", "First").Should().Emit(); - - var removed = await service.DeleteToken(created.Node.Path).Should().Emit(); - - removed.Should().BeTrue( - "the token node was there and this call is what took it away — a delete that removed " - + "something must say so, or the value carries no information at all"); - } - - [Fact] - public async Task ASecondDeleteOfTheSamePath_RemovesNothing() - { - var service = GetService(); - var created = await service.CreateToken( - "delete-reports-twice", "Delete Twice", "delete-twice@example.com", "Second").Should().Emit(); - var path = created.Node.Path; - - var first = await service.DeleteToken(path).Should().Emit(); - var second = await service.DeleteToken(path).Should().Emit(); - - first.Should().BeTrue("the first call is the one that removed the token"); - second.Should().BeFalse( - "the node was already gone, so this call removed nothing. It is not an error — the " - + "postcondition already held — but reporting it as a removal is how an unconditional " - + "`.Select(_ => true)` hides both a no-op and every path-resolution bug behind it"); - } - - [Fact] - public async Task APathThatNeverHeldAToken_RemovesNothing() - { - var removed = await GetService() - .DeleteToken("delete-reports-ghost/ApiToken/never-minted").Should().Emit(); - - removed.Should().BeFalse( - "nothing was ever stored there, so nothing was removed"); - } - - /// - /// The value has to survive all the way to the screen, or the fix is invisible to the only - /// person it is for. ApiTokensSettingsTab subscribed with _ => "Deleted token" - /// and so announced a removal for a token that was already gone — the second admin on the same - /// live list, or the row clicked twice. These drive the SAME factored composition the click - /// handler subscribes to (as the revoke button's test already does), so a regression in the - /// handler cannot pass them. - /// - [Fact] - public async Task TheTabReportsARealRemovalAsRemoved() - { - var service = GetService(); - var created = await service.CreateToken( - "delete-tab-user", "Tab User", "tab@example.com", "Tab").Should().Emit(); - - var outcome = await ApiTokensSettingsTab - .Delete(service, created.Node.Path, "Tab").Should().Emit(); - - outcome.Result.Should().Be(ApiTokensSettingsTab.TokenDeleteResult.Removed); - outcome.Label.Should().Be("Tab"); - } - - [Fact] - public async Task TheTabDoesNotAnnounceARemovalForATokenThatWasAlreadyGone() - { - var service = GetService(); - var created = await service.CreateToken( - "delete-tab-twice", "Tab Twice", "tab-twice@example.com", "Twice").Should().Emit(); - - var first = await ApiTokensSettingsTab - .Delete(service, created.Node.Path, "Twice").Should().Emit(); - var second = await ApiTokensSettingsTab - .Delete(service, created.Node.Path, "Twice").Should().Emit(); - - first.Result.Should().Be(ApiTokensSettingsTab.TokenDeleteResult.Removed); - second.Result.Should().Be(ApiTokensSettingsTab.TokenDeleteResult.AlreadyGone, - "nobody here took that token away, so the screen must not say it did — and " - + "AlreadyGone is not Failed either: nothing went wrong, there was simply nothing " - + "to do"); - } - - /// - /// MeshWeaver#4707, and the reason the outcome is three-valued rather than two. A REFUSED - /// delete — a permission denial, a validator verdict — is a statement about a token that is - /// STILL THERE and still authenticates. DeleteToken used to fold every fault into - /// false, so the screen said "was already gone" about a live credential and - /// was unreachable by construction. - /// - /// The refusal is a real one — a registered , the same - /// extension point production rules use — not a stub standing in for the mesh. - /// - /// SHOULD-FAIL-IF the swallowing .Catch returns: this renders - /// AlreadyGone instead of Failed. And the assertion is written in BOTH - /// directions on purpose — a regression that answered Failed for everything would - /// satisfy this test alone, so is its - /// other half and must keep passing. - /// - [Fact] - public async Task ARefusedDeleteIsAFailure_NotAQuietAlreadyGone() - { - var service = GetService(); - var created = await service.CreateToken( - RefusedUser, "Refused", "refused@example.com", "Refused").Should().Emit(); - - refusing.Armed = true; - - var outcome = await ApiTokensSettingsTab - .Delete(service, created.Node.Path, "Refused").Should().Emit(); - - outcome.Result.Should().Be(ApiTokensSettingsTab.TokenDeleteResult.Failed, - "the delete was refused — the token is still there, so this is a failure the operator " - + "has to see, not a no-op"); - outcome.Result.Should().NotBe(ApiTokensSettingsTab.TokenDeleteResult.AlreadyGone, - "saying 'already gone' about a credential that still authenticates is the exact " - + "misreport #4707 is about"); - outcome.Message.Should().Contain(RefusalReason, - "the refusing validator's own sentence has to survive to the screen, or the operator " - + "is told something failed without being told what"); - } -} - -/// -/// Refuses the delete of one user's token nodes, once armed, and accepts everything else — the -/// registered-extension-point way to produce a REAL refusal. Instance state, never static: the mesh -/// owns this singleton for the life of this test's mesh, so nothing bleeds into another suite. -/// -/// Matched by NAMESPACE prefix rather than by exact path because a token's id is minted -/// during the test, long after the mesh is configured. -/// -internal sealed class RefuseDeletingThisUsersTokens(string userId, string reason) : INodeValidator -{ - /// Off until the token exists: a create must not be refused. - public bool Armed { get; set; } - - /// - public IReadOnlyCollection SupportedOperations { get; } = [NodeOperation.Delete]; - - /// - public IObservable Validate(NodeValidationContext context) - => Observable.Return( - Armed && context.Node.Path?.StartsWith($"{userId}/", StringComparison.OrdinalIgnoreCase) == true - ? NodeValidationResult.Invalid(reason) - : NodeValidationResult.Valid()); -} diff --git a/test/Memex.Portal.Shared.Test/DeploymentPathsSupplyStorageTest.cs b/test/Memex.Portal.Shared.Test/DeploymentPathsSupplyStorageTest.cs deleted file mode 100644 index 96533c080e..0000000000 --- a/test/Memex.Portal.Shared.Test/DeploymentPathsSupplyStorageTest.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.IO; -using System.Text.RegularExpressions; -using MeshWeaver.Deployment; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Every path that deploys a PORTAL must state its storage explicitly. -/// -/// 🚨 This is the dangerous direction of the first-run setup change, and it is the one -/// with a production outage on the other side. The deployed image deliberately no longer bakes -/// a Graph:Storage:Type default — that default made the setup wizard unreachable on -/// Kubernetes (MeshWeaver.Plugins DeployedImageDoesNotPreAnswerStorageTest). The safety of -/// removing it rests ENTIRELY on the deployment paths supplying the value themselves. If one of -/// them ever stops, a configured production portal with real data reboots into a FIRST-RUN SETUP -/// WIZARD, serving no content and offering a stranger the chance to configure it — and nothing -/// fails, because "no storage configured" is a legitimate state by design. -/// -/// So the two halves are guarded on opposite sides: the image must NOT answer, and these -/// must. Each guard lives with its subject, and neither can be satisfied by the other moving. -/// -public class DeploymentPathsSupplyStorageTest -{ - private static DirectoryInfo RepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - Assert.NotNull(dir); - return dir!; - } - - [Fact] - public void TheHelmChartStatesTheStorageType() - { - // The AKS lane. Every portal the chart deploys reads Graph__Storage__Type from its - // ConfigMap, and the ConfigMap emits the key only when the values state it. - var values = Path.Combine(RepoRoot().FullName, "deploy", "helm", "values.yaml"); - Assert.True(File.Exists(values), $"the chart's values are not at {values}"); - - var match = Regex.Match(File.ReadAllText(values), - @"^\s*Graph__Storage__Type:\s*""(?[^""]*)""", RegexOptions.Multiline); - - Assert.True(match.Success, - $"{values} no longer states Graph__Storage__Type. The deployed image does not carry a " - + "default any more, so every portal this chart deploys would boot into the first-run " - + "SETUP WIZARD instead of serving its data."); - Assert.False(string.IsNullOrWhiteSpace(match.Groups["v"].Value), - "Graph__Storage__Type is present but EMPTY — the ConfigMap omits an empty key, which is " - + "indistinguishable from not stating it at all."); - } - - [Fact] - public void TheAspireHostStatesTheStorageType() - { - // The Azure Container Apps lane, which does not use the chart at all — so the chart guard - // above says nothing about it. It was the ACA portals that made this worth checking: - // they configure the portal purely through environment variables. - // - // Since the Deployment record became the ONE input (2026-09-08), the adapter states no key - // itself: every portal key is DERIVED from the record by the contract's - // DeploymentPortalConfig.PortalConfig — the same derivation the Helm chart renders. So the - // guard has two halves, each with its subject: the adapter must still route through that - // derivation, and the derivation must state the storage type for a record that says - // NOTHING about storage (a bare AddMemex(name) is exactly that record). - var hosting = Path.Combine(RepoRoot().FullName, "memex", "aspire", - "Memex.Aspire.Hosting", "MemexHostingExtensions.cs"); - Assert.True(File.Exists(hosting), $"the Aspire hosting extensions are not at {hosting}"); - Assert.Contains("DeploymentPortalConfig.PortalConfig(", File.ReadAllText(hosting)); - - var config = DeploymentPortalConfig.PortalConfig(new DeploymentContent(), PortalConfigOptions.Aspire(mcpBaseUrl: null)); - - Assert.True(config.TryGetValue("Graph__Storage__Type", out var type), - "the record's portal-config derivation no longer emits Graph__Storage__Type for a record " - + "that states no storage layout. The deployed image carries no default, so an Aspire/ACA " - + "portal would boot into the first-run SETUP WIZARD rather than serving its data."); - Assert.False(string.IsNullOrWhiteSpace(type), - "Graph__Storage__Type derives to an EMPTY value, which reads as 'not configured'."); - } -} diff --git a/test/Memex.Portal.Shared.Test/DeploymentPinnedReferencesTest.cs b/test/Memex.Portal.Shared.Test/DeploymentPinnedReferencesTest.cs deleted file mode 100644 index f713ba28bf..0000000000 --- a/test/Memex.Portal.Shared.Test/DeploymentPinnedReferencesTest.cs +++ /dev/null @@ -1,737 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; -using MeshWeaver.Hosting; -using MeshWeaver.Mesh; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// How a control instance turns its records into s for the -/// prebuilt-bundle retention: a Hosting/Deployment record's pinnedImageTag (a mesh -/// NodeType with no CLR type here, read as JSON in whatever shape the content arrived) and an -/// instance's Hosting/ModuleInventory report ( -/// + ). -/// -public class DeploymentPinnedReferencesTest -{ - private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); - - [Fact] - public void ADeploymentRecordsPin_IsAReference_WhateverJsonShapeTheContentTook() - { - var asNode = new MeshNode("fabrikam", "Deployments") - { - NodeType = DeploymentPinnedReferences.DeploymentNodeType, - Content = JsonNode.Parse("""{ "host": "fabrikam.example", "pinnedImageTag": "memex-portal-ai:3.0.0-ci.8080" }"""), - }; - var asElement = asNode with - { - Content = JsonSerializer.Deserialize("""{ "PinnedImageTag": "3.0.0-ci.8080" }"""), - }; - - var fromNode = DeploymentPinnedReferences.DeploymentPinOf(asNode, Options)!; - fromNode.Origin.Should().Be("Deployment Deployments/fabrikam"); - fromNode.Version.Should().Be("memex-portal-ai:3.0.0-ci.8080"); - fromNode.Identity.Should().BeNull(); - PlatformVersionLine.Normalize(fromNode.Version).Should().Be("3.0.0-ci.8080"); - - DeploymentPinnedReferences.DeploymentPinOf(asElement, Options)!.Version.Should().Be("3.0.0-ci.8080"); - } - - [Fact] - public void ARecordThatPinsNothing_IsNoReference() - { - var node = new MeshNode("memex", "Deployments") - { - NodeType = DeploymentPinnedReferences.DeploymentNodeType, - Content = JsonNode.Parse("""{ "host": "memex.example", "pinnedImageTag": "" }"""), - }; - DeploymentPinnedReferences.DeploymentPinOf(node, Options).Should().BeNull(); - DeploymentPinnedReferences.DeploymentPinOf(node with { Content = null }, Options).Should().BeNull(); - } - - [Fact] - public void AnInstanceReport_CarriesBothItsVersionAndItsIdentity() - { - var report = new DeploymentReport - { - Deployment = "initech", - PlatformVersion = "3.0.0-ci.7000+deadbeef", - FrameworkIdentity = "s9c0b05d61cb34bbffde9ad7a32ab1a8b", - }; - var node = new MeshNode("initech", "Deployments/Modules") - { - NodeType = DeploymentReportService.InventoryNodeType, - Content = JsonNode.Parse(JsonSerializer.Serialize(report, Options)), - }; - - var reference = DeploymentPinnedReferences.ReportedBuildOf(node, Options)!; - reference.Origin.Should().Be("instance report Deployments/Modules/initech"); - reference.Version.Should().Be("3.0.0-ci.7000+deadbeef"); - reference.Identity.Should().Be("s9c0b05d61cb34bbffde9ad7a32ab1a8b"); - } - - [Fact] - public void AReportWithNeitherVersionNorIdentity_IsNoReference() - { - var node = new MeshNode("blank", "Deployments/Modules") - { - NodeType = DeploymentReportService.InventoryNodeType, - Content = JsonNode.Parse("""{ "deployment": "blank", "modules": [] }"""), - }; - DeploymentPinnedReferences.ReportedBuildOf(node, Options).Should().BeNull(); - } - [Fact] - public void ARemoteAdoptedFallback_IsProtectedAlongsideTheRunningFramework() - { - var node = new MeshNode("remote", "Ops/Modules") - { - Content = JsonNode.Parse("""{"frameworkIdentity":"s-current","platformVersion":"4.0.0-ci.1","adoptedFrameworkInventoryComplete":true,"adoptedFrameworkIdentities":["s-old"]}""") - }; - var references = DeploymentPinnedReferences.ReportedBuildsOf(node, Options); - references.Select(r => r.Identity).Should().Equal("s-current", "s-old"); - var now = DateTimeOffset.UtcNow; - var old = now.AddDays(-100); - PrebuiltIdentityEntry Identity(string id) => new(id, "/store/" + id, - [new PrebuiltSourceEntry("plugins", true, old, old, null)], 1024, old); - var scan = new PrebuiltStoreScan([Identity("s-current"), Identity("s-old"), Identity("s-newer")], - [new ReleaseMarkerEntry("3.0.0-ci.1", "s-old", "/markers/old", old, null), - new ReleaseMarkerEntry("3.0.0-ci.2", "s-newer", "/markers/newer", old, null)]); - var policy = new PrebuiltBundleRetention { KeepNewestPerSource = 0 }; - - var before = PrebuiltBundleStore.Plan("s-current", "4.0.0-ci.1", scan, [], [], policy, now); - before.Collectable.Select(i => i.Identity).Should().Contain("s-old"); - var protectedPlan = PrebuiltBundleStore.Plan("s-current", "4.0.0-ci.1", scan, [], references, policy, now); - protectedPlan.Collectable.Select(i => i.Identity).Should().NotContain("s-old"); - protectedPlan.Protected["s-old"].Should().Contain("adopted build reported by"); - } - - [Theory] - [InlineData("""{"frameworkIdentity":"s-current"}""")] - [InlineData("""{"frameworkIdentity":"s-current","adoptedFrameworkInventoryComplete":false,"adoptedFrameworkIdentities":[]}""")] - [InlineData("""{"frameworkIdentity":"s-current","adoptedFrameworkInventoryComplete":true}""")] - [InlineData("""{"frameworkIdentity":"s-current","adoptedFrameworkInventoryComplete":true,"adoptedFrameworkIdentities":[null]}""")] - [InlineData("""{"frameworkIdentity":"s-current","adoptedFrameworkInventoryComplete":true,"adoptedFrameworkIdentities":[""]}""")] - [InlineData("""{"adoptedFrameworkInventoryComplete":true,"adoptedFrameworkIdentities":[]}""")] - public void AnIncompleteOrMalformedConsumerInventory_CannotAuthorizeCleanup(string json) - { - var node = new MeshNode("remote", "Ops/Modules") { Content = JsonNode.Parse(json) }; - Assert.Throws(() => DeploymentPinnedReferences.ReportedBuildsOf(node, Options)); - } - - [Fact] - public void AnExplicitCompleteEmptyAdoptionInventory_IsValid_AndStillProtectsTheRunningBuild() - { - var node = new MeshNode("remote", "Ops/Modules") - { - Content = JsonSerializer.Deserialize("""{"FrameworkIdentity":"s-current","AdoptedFrameworkInventoryComplete":true,"AdoptedFrameworkIdentities":[]}""") - }; - DeploymentPinnedReferences.ReportedBuildsOf(node, Options).Select(r => r.Identity).Should().Equal("s-current"); - } - - // ── The FLEET inventory: coverage and freshness, #3438/#3858 ─────────────────────────────── - // - // The per-report signal above answers "did THIS instance answer fully". These answer the two - // questions that decide whether anything may be deleted at all: did every expected instance - // answer, and does its answer still describe it. A refusal here is the whole point — an - // instance whose report never arrived contributes exactly the same reference list as an - // instance that consumes nothing, and one of those two readings authorises deleting what it is - // running. - - private static readonly DateTimeOffset Now = new(2026, 9, 12, 12, 0, 0, TimeSpan.Zero); - - /// The store the fixtures' retention runs on: the registry every fixture record mounts. - private const string StoreHost = "registry.example"; - - private static MeshNode Deployment(string id, string? pin = null, string? extra = null, - string? mounts = "https://" + StoreHost) - { - var content = new JsonObject { ["host"] = id + ".example" }; - if (mounts is not null) - content["pluginRepos"] = new JsonArray(new JsonObject { ["name"] = "Plugins", ["url"] = mounts }); - if (pin is not null) - content["pinnedImageTag"] = pin; - if (extra is not null) - foreach (var property in JsonNode.Parse(extra)!.AsObject()) - content[property.Key] = property.Value?.DeepClone(); - return new MeshNode(id, "Deployments") - { - NodeType = DeploymentPinnedReferences.DeploymentNodeType, - Content = content, - }; - } - - private static MeshNode Report(string id, TimeSpan age, string running = "s-running", - string[]? adopted = null, bool complete = true, bool stamp = true) => - new(id, "Ops/Modules") - { - NodeType = DeploymentReportService.InventoryNodeType, - Content = JsonNode.Parse(JsonSerializer.Serialize(new DeploymentReport - { - Deployment = id, - PlatformVersion = "3.0.0-ci.8399", - FrameworkIdentity = running, - AdoptedFrameworkIdentities = [.. adopted ?? []], - AdoptedFrameworkInventoryComplete = complete, - SampledAt = stamp ? DeploymentReportService.Stamp(Now - age) : "", - }, Options)), - }; - - private static ImmutableList Resolve( - IReadOnlyList deployments, IReadOnlyList reports, - bool isControlInstance = false) => - Resolve(deployments, reports, - new ConsumerScope("registry", [StoreHost], isControlInstance)); - - private static ImmutableList Resolve( - IReadOnlyList deployments, IReadOnlyList reports, ConsumerScope scope) => - DeploymentPinnedReferences.Resolve(deployments, reports, Options, Now, - DeploymentPinnedReferences.StaleAfter, scope); - - /// A report as a STORE files it: keyed by the instance its credential authenticated as. - private static MeshNode StoreReport(string registeredInstance, TimeSpan age, string running, - string? claimedDeployment = null) - { - var report = Report(claimedDeployment ?? registeredInstance, age, running); - ((JsonObject)report.Content!)["registeredInstance"] = registeredInstance; - return report with - { - Id = registeredInstance, - Namespace = $"{DeploymentReportService.DefaultOperationalSpace}/{DeploymentReportService.StoreReportsSegment}", - }; - } - - /// The same body as , filed somewhere the platform never files one. - private static MeshNode Elsewhere(MeshNode report, string @namespace) => report with { Namespace = @namespace }; - - // ── #6989: a report is where the platform FILES one ────────────────────────────────────────── - // - // Inventories are read mesh-wide, and `Hosting/ModuleInventory` is a type anyone may create in a - // partition they own. Measured on the registry: a leftover probe under a user's partition was - // matched as the `memex` record's sink report and refused every pass, standing in for the report - // that never arrived. - - [Fact] - public void AFreshCompleteReportInAUsersPartition_DoesNotSatisfyARecord() - { - // The dangerous direction: a hand-made body that LOOKS like a fresh, complete report for the - // silent `customer` would complete the pass and authorise deleting what `customer` runs. - var refusal = Assert.Throws(() => Resolve(SyncedFleet(), - [StoreReport("company", TimeSpan.FromMinutes(30), "s-company"), - StoreReport("control", TimeSpan.FromHours(2), "s-control"), - Elsewhere(StoreReport("customer", TimeSpan.FromMinutes(1), "s-forged"), "someone/OpsProbe/ConsumerReports"), - Elsewhere(Report("customer", TimeSpan.FromMinutes(1), "s-forged-sink"), "someone/OpsProbe/Modules")], - new ConsumerScope("registry", [], IsControlInstance: false))); - - refusal.Message.Should().Contain("Deployments/customer: no Hosting/ModuleInventory report has ever arrived", - because: "a node outside the filing locations is not a report, so `customer` is still silent"); - } - - [Fact] - public void ALegacyProbeInAUsersPartition_DoesNotRefuseAPassItsRecordsRealReportCompletes() - { - // The measured shape: an incomplete legacy body under a user's partition, named after a - // record whose own authenticated report is fresh and complete. - var references = Resolve(SyncedFleet(), - [StoreReport("company", TimeSpan.FromMinutes(30), "s-company"), - StoreReport("control", TimeSpan.FromHours(2), "s-control"), - StoreReport("customer", TimeSpan.FromHours(1), "s-customer"), - Elsewhere(Report("customer", TimeSpan.FromDays(40), "s-probe", complete: false), "someone/OpsProbe/Modules")], - new ConsumerScope("registry", [], IsControlInstance: false)); - - references.Select(r => r.Identity).Should().Contain(["s-company", "s-control", "s-customer"]) - .And.NotContain("s-probe", because: "a probe is not a consumer, and its legacy body is no refusal"); - } - - [Fact] - public void AnAuthenticatedStampOutsideTheStoresFilingLocation_IsNotAnAuthenticatedReport() - { - // `registeredInstance` is stamped by the STORE, at its filing location. The same field on a - // node under the sink's location was never stamped by anybody who authenticated anything; it - // is read as a sink report of the deployment its body names (`intruder`), never as `customer`. - var refusal = Assert.Throws(() => Resolve(SyncedFleet(), - [StoreReport("company", TimeSpan.FromMinutes(30), "s-company"), - StoreReport("control", TimeSpan.FromHours(2), "s-control"), - Elsewhere(StoreReport("customer", TimeSpan.FromMinutes(1), "s-claimed", claimedDeployment: "intruder"), - $"{DeploymentReportService.DefaultOperationalSpace}/Modules")], - new ConsumerScope("registry", [], IsControlInstance: false))); - - refusal.Message.Should().Contain("Deployments/customer: no Hosting/ModuleInventory report has ever arrived"); - } - - [Fact] - public void TheFilingLocations_FollowTheConfiguredOperationalSpace() - { - var underOps = new[] - { - StoreReport("company", TimeSpan.FromMinutes(30), "s-company"), - StoreReport("control", TimeSpan.FromHours(2), "s-control"), - StoreReport("customer", TimeSpan.FromHours(1), "s-customer"), - }; - var underFleet = underOps - .Select(r => Elsewhere(r, $"Fleet/{DeploymentReportService.StoreReportsSegment}")) - .ToImmutableList(); - - Resolve(SyncedFleet(), underFleet, new ConsumerScope("registry", [], false) { OperationalSpace = "Fleet" }) - .Select(r => r.Identity).Should().Contain(["s-company", "s-control", "s-customer"]); - Assert.Throws(() => - Resolve(SyncedFleet(), underOps, new ConsumerScope("registry", [], false) { OperationalSpace = "Fleet" })); - } - - [Fact] - public void ScopeOf_ReadsTheOperationalSpace_DefaultingToOps() - { - DeploymentPinnedReferences.ScopeOf( - new ConfigurationBuilder() - .AddInMemoryCollection([new(DeploymentReportService.OperationalSpaceKey, "Fleet")]).Build(), null) - .OperationalSpace.Should().Be("Fleet"); - DeploymentPinnedReferences.ScopeOf( - new ConfigurationBuilder().Build(), null) - .OperationalSpace.Should().Be(DeploymentReportService.DefaultOperationalSpace); - } - - // ── #6989: whose consumers a store accounts for ────────────────────────────────────────────── - // - // The fleet as it is: one report sink (the control instance), one plugin registry every other - // instance mounts, and a synced copy of the whole Deployments folder on hosts that are neither. - // The denominator used to be "every record this host holds", so every host but the sink refused - // on every pass, for ever, and its shared volume was never reclaimed. - - private const string Company = "company.example"; - - private static ImmutableList SyncedFleet() => - [ - Deployment("registry", extra: $$"""{ "host": "{{StoreHost}}" }""", mounts: null), - Deployment("company", extra: $$"""{ "host": "{{Company}}" }"""), - Deployment("control", extra: """{ "host": "control.example" }"""), - Deployment("customer"), - ]; - - [Fact] - public void AHostHoldingASyncedCopyOfRecords_WhoseInstancesMountAnotherRegistry_CompletesItsPass() - { - // The company instance: it holds every record (a synced copy), it is not the report sink, and - // nobody mounts it. Its consumers are itself alone — protected by its live identity and its - // adoption stamps — so the inventory is COMPLETE. Before #6989 this was "INCOMPLETE — 4 - // refusal(s) over 4 expected instance(s)" on every tick. - var references = Resolve(SyncedFleet(), - [Report("company", TimeSpan.FromDays(12), "s-company-stale")], - new ConsumerScope("company", [], IsControlInstance: false)); - - references.Select(r => r.Identity).Should().NotContain("s-company-stale", - because: "a report describing ANOTHER store's consumer is not a consumer of this one"); - } - - [Fact] - public void TheReportSink_WhichNobodyMounts_CompletesItsPass_TooEvenWithStaleFleetReports() - { - // The control instance holds the records AND the reports, and is mounted by nobody either. - Resolve(SyncedFleet(), - [Report("customer", TimeSpan.FromDays(30), "s-customer")], - new ConsumerScope("control", [], IsControlInstance: true)) - .Should().BeEmpty(because: "no record mounts the control instance's store, so the fleet's " - + "reports describe another store's consumers — and the pass is COMPLETE, not refused"); - } - - [Fact] - public void TheRegistry_WhoseMountingConsumersAllReportedThere_CompletesAndProtectsWhatTheyRun() - { - var references = Resolve(SyncedFleet(), - [StoreReport("company", TimeSpan.FromMinutes(30), "s-company"), - StoreReport("control", TimeSpan.FromHours(2), "s-control"), - StoreReport("customer", TimeSpan.FromHours(5), "s-customer")], - new ConsumerScope("registry", [], IsControlInstance: false)); - - references.Select(r => r.Identity).Should().Contain(["s-company", "s-control", "s-customer"], - because: "the registry serves each consumer bundles keyed on what it runs; those identities are referenced"); - } - - [Fact] - public void TheRegistry_WithAMountingConsumerSilent_StillRefuses_NamingIt() - { - // The negative control: the fix narrows the denominator to the DECLARED consumers, and a - // declared consumer that has not reported still refuses the whole pass. - var refusal = Assert.Throws(() => Resolve(SyncedFleet(), - [StoreReport("company", TimeSpan.FromMinutes(30), "s-company"), - StoreReport("control", TimeSpan.FromHours(2), "s-control")], - new ConsumerScope("registry", [], IsControlInstance: false))); - - refusal.Message.Should().Contain("Deployments/customer").And.Contain("1 refusal(s) over 3 expected instance(s)") - .And.Contain("1 not mounting this store"); - } - - // ── #6989: a SUSPENDED consumer is paused, not gone ───────────────────────────────────────── - // - // Measured on the registry: one expected consumer's record was suspended (pending teardown), the - // instance stopped reporting, and a day later its last report was past the 24 h budget — every - // pass FAULTED on that one refusal and nothing was reclaimed. The record's declared status - // excuses the freshness verdict; the builds its last report names stay pinned so it can resume. - - private static ImmutableList FleetWithCustomer(string? status) => - SyncedFleet().Select(r => r.Id == "customer" - ? Deployment("customer", extra: status is null ? null : $$"""{ "status": "{{status}}" }""") - : r).ToImmutableList(); - - private static ImmutableList FleetReportsWithStaleCustomer() => - [ - StoreReport("company", TimeSpan.FromMinutes(30), "s-company"), - StoreReport("control", TimeSpan.FromHours(2), "s-control"), - StaleCustomerReport(), - ]; - - /// The suspended consumer's last report: 31 h old, running one build and holding another adopted. - private static MeshNode StaleCustomerReport() - { - var report = Report("customer", TimeSpan.FromHours(31), "s-customer-running", adopted: ["s-customer-adopted"]); - ((JsonObject)report.Content!)["registeredInstance"] = "customer"; - return report with - { - Namespace = $"{DeploymentReportService.DefaultOperationalSpace}/{DeploymentReportService.StoreReportsSegment}", - }; - } - - [Theory] - [InlineData("Suspended")] - [InlineData(" suspended ")] - public void ASuspendedConsumerWithAStaleReport_NoLongerFaultsThePass(string status) - { - var references = Resolve(FleetWithCustomer(status), FleetReportsWithStaleCustomer(), - new ConsumerScope("registry", [], IsControlInstance: false)); - - references.Select(r => r.Identity).Should().Contain(["s-company", "s-control"], - because: "the pass completes, so every other consumer's builds are what protection is computed from"); - } - - [Fact] - public void ASuspendedConsumer_KeepsEveryBuildItsLastReportNamedPinned() - { - var references = Resolve(FleetWithCustomer("Suspended"), FleetReportsWithStaleCustomer(), - new ConsumerScope("registry", [], IsControlInstance: false)); - - references.Select(r => r.Identity).Should().Contain(["s-customer-running", "s-customer-adopted"], - because: "a suspended instance can be resumed onto what it last reported, so those builds stay protected — " - + "it is excused from the freshness budget, never treated as retired"); - - // And the pins hold through the PLAN, not only in the reference list. - var old = Now.AddDays(-100); - PrebuiltIdentityEntry Identity(string id) => new(id, "/store/" + id, - [new PrebuiltSourceEntry("plugins", true, old, old, null)], 1024, old); - var scan = new PrebuiltStoreScan( - [Identity("s-registry"), Identity("s-customer-running"), Identity("s-customer-adopted"), Identity("s-unreferenced")], []); - var plan = PrebuiltBundleStore.Plan("s-registry", "3.0.0-ci.9000", scan, [], references, - new PrebuiltBundleRetention { KeepNewestPerSource = 0 }, Now); - - plan.Collectable.Select(i => i.Identity).Should().Contain("s-unreferenced", - because: "everything nobody references is reclaimed") - .And.NotContain(["s-customer-running", "s-customer-adopted"]); - } - - [Fact] - public void ASuspendedConsumerThatNeverReported_StillRefuses() - { - // What it would resume onto is unknown, so nothing may be shown to be unreferenced. - var refusal = Assert.Throws(() => Resolve(FleetWithCustomer("Suspended"), - [StoreReport("company", TimeSpan.FromMinutes(30), "s-company"), - StoreReport("control", TimeSpan.FromHours(2), "s-control")], - new ConsumerScope("registry", [], IsControlInstance: false))); - - refusal.Message.Should().Contain("Deployments/customer: no Hosting/ModuleInventory report has ever arrived"); - } - - [Theory] - [InlineData(null)] - [InlineData("Live")] - [InlineData("SomeStatusNobodyDeclaredYet")] - public void ANonSuspendedConsumerWithAStaleReport_StillRefuses(string? status) - { - // The negative control: only the record's DECLARED suspension excuses the budget; silence, - // an ordinary status or an unknown one keeps the full verdict. - var refusal = Assert.Throws(() => Resolve(FleetWithCustomer(status), - FleetReportsWithStaleCustomer(), new ConsumerScope("registry", [], IsControlInstance: false))); - - // Unchanged behaviour by design: this passes with and without the suspension rule. - refusal.Message.Should().Contain("Deployments/customer: its newest report") - .And.Contain("past the 24 h budget"); - } - - [Fact] - public void AReportCanOnlyStandForTheInstanceTheStoreAuthenticated_NotTheOneItsBodyClaims() - { - // `customer` holds a key; its body claims to be `company`. Filed under `customer`, it cannot - // make the silent `company` read as reported. - var refusal = Assert.Throws(() => Resolve(SyncedFleet(), - [StoreReport("customer", TimeSpan.FromMinutes(5), "s-customer", claimedDeployment: "company"), - StoreReport("control", TimeSpan.FromHours(2), "s-control")], - new ConsumerScope("registry", [], IsControlInstance: false))); - - refusal.Message.Should().Contain("Deployments/company"); - } - - [Fact] - public void ADeclaredRegistryInstanceId_IsWhereARecordsStoreReportIsFound() - { - var fleet = SyncedFleet().Select(r => r.Id == "customer" - ? Deployment("customer", extra: """{ "registryInstanceId": "customer-portal" }""") - : r).ToImmutableList(); - - Resolve(fleet, - [StoreReport("company", TimeSpan.FromMinutes(30), "s-company"), - StoreReport("control", TimeSpan.FromHours(2), "s-control"), - StoreReport("customer-portal", TimeSpan.FromHours(1), "s-customer")], - new ConsumerScope("registry", [], IsControlInstance: false)) - .Select(r => r.Identity).Should().Contain("s-customer"); - } - - [Fact] - public void AStoreReportFromAnAccountThatIsNotTheDeclaredRegistryIdentity_DoesNotSatisfyTheRecord() - { - // `customer` declares that it reaches this store as `customer-portal`. A fresh report from - // a DIFFERENT registry account that happens to be called `customer` is not that consumer: - // `customer-portal` is still silent, and the pass must not complete. - var fleet = SyncedFleet().Select(r => r.Id == "customer" - ? Deployment("customer", extra: """{ "registryInstanceId": "customer-portal" }""") - : r).ToImmutableList(); - - var refusal = Assert.Throws(() => Resolve(fleet, - [StoreReport("company", TimeSpan.FromMinutes(30), "s-company"), - StoreReport("control", TimeSpan.FromHours(2), "s-control"), - StoreReport("customer", TimeSpan.FromMinutes(5), "s-unrelated-account")], - new ConsumerScope("registry", [], IsControlInstance: false))); - - refusal.Message.Should().Contain("Deployments/customer"); - } - - [Fact] - public void AStoreReportOfARecordThatMovedToAnotherRegistry_StillProtectsWhatItIsServedFromHere() - { - // `customer`'s record now mounts another registry, but its instance has not applied that - // yet: a fresh, authenticated report still arrives HERE. It no longer counts as an expected - // consumer, and its builds must still be protected — they are being served from this store. - var fleet = SyncedFleet().Select(r => r.Id == "customer" - ? Deployment("customer", mounts: "https://other-registry.example") - : r).ToImmutableList(); - - var references = Resolve(fleet, - [StoreReport("company", TimeSpan.FromMinutes(30), "s-company"), - StoreReport("control", TimeSpan.FromHours(2), "s-control"), - StoreReport("customer", TimeSpan.FromMinutes(10), "s-customer-still-served")], - new ConsumerScope("registry", [], IsControlInstance: false)); - - references.Select(r => r.Identity).Should().Contain("s-customer-still-served", - because: "a report this store authenticated names a build it is serving, whatever the record now mounts"); - } - - [Fact] - public void AHostThatCannotNameItsStore_RefusesRatherThanReadingEveryMountAsSomeoneElses() - { - var refusal = Assert.Throws(() => Resolve(SyncedFleet(), [], - new ConsumerScope(null, [], IsControlInstance: false))); - - refusal.Message.Should().Contain("cannot name the store"); - } - - [Fact] - public void ARegistrySourceRepository_IsNotAMountOfARegistry() - { - var registry = Deployment("registry", extra: """ - { "pluginRepos": [ { "url": "https://github.com/example/plugins", "isRegistrySource": true }, - { "url": "https://registry.example/" } ] } - """, mounts: null); - - DeploymentPinnedReferences.MountedHostsOf(registry, Options).Should().Equal(StoreHost); - } - - [Fact] - public void ACompleteFreshFleet_ProtectsEveryRunningAndAdoptedBuildOfEveryInstance() - { - var references = Resolve( - [Deployment("memex"), Deployment("memex-cloud", pin: "3.0.0-ci.8399")], - [Report("memex", TimeSpan.FromMinutes(20), "s-memex", ["s-old-memex"]), - Report("memex-cloud", TimeSpan.FromHours(3), "s-cloud")]); - - references.Select(r => r.Identity).Where(i => i is not null).Should().Contain( - ["s-memex", "s-old-memex", "s-cloud"], - because: "a complete, fresh inventory protects what each instance runs AND the older builds its modules still adopt"); - references.Select(r => r.Version).Should().Contain("3.0.0-ci.8399", - because: "a Deployment record's pin is a reference in its own right"); - } - - [Fact] - public void AnExpectedInstanceThatHasNeverReported_RefusesTheWholePass_NamingIt() - { - var refusal = Assert.Throws(() => Resolve( - [Deployment("memex"), Deployment("fabrikam")], - [Report("memex", TimeSpan.FromMinutes(5))])); - - refusal.Message.Should().Contain("INCOMPLETE").And.Contain("Deployments/fabrikam"); - refusal.Message.Should().Contain("1 refusal(s) over 2 expected instance(s)", - because: "the denominator belongs in the refusal, not only in a log line — and a refusal " - + "COUNT is not a fraction of the expected set, because an orphan report can be " - + "the refused subject"); - } - - [Fact] - public void AReportOlderThanTheFreshnessBudget_RefusesTheWholePass_NamingTheAge() - { - var refusal = Assert.Throws(() => Resolve( - [Deployment("memex-cloud")], - [Report("memex-cloud", DeploymentPinnedReferences.StaleAfter + TimeSpan.FromHours(1))])); - - refusal.Message.Should().Contain("Deployments/memex-cloud").And.Contain("25.0 h ago"); - } - - [Fact] - public void AReportThatIsExactlyAtTheBudget_IsStillFresh() - { - Resolve([Deployment("memex-cloud")], [Report("memex-cloud", DeploymentPinnedReferences.StaleAfter)]) - .Should().NotBeEmpty(because: "the budget is a limit, and a boundary that flips is a flake"); - } - - [Fact] - public void AReportWithNoReadableSampledAt_IsAReportOfUnknownAge_AndRefuses() - { - var refusal = Assert.Throws(() => Resolve( - [Deployment("memex")], [Report("memex", TimeSpan.Zero, stamp: false)])); - - refusal.Message.Should().Contain("SampledAt"); - } - - [Fact] - public void AnExplicitlyRetiredInstance_LeavesTheExpectedSet_AndItsSilenceIsNotARefusal() - { - Resolve([Deployment("fabrikam", extra: """{ "retired": true }""")], []).Should().BeEmpty(); - Resolve([Deployment("fabrikam", extra: """{ "retiredAt": "2026-09-01T00:00:00Z" }""")], []).Should().BeEmpty(); - } - - [Fact] - public void AnInstanceThatIsMerelyUnREACHABLE_IsNotRetired() - { - // The distinction #3858 asks for: retirement is a declaration on the record, never an - // inference from silence. A portal that is down reports nothing, and so does a portal that - // was decommissioned — only one of them may have its artifacts collected. - Assert.Throws(() => Resolve([Deployment("fabrikam")], [])); - } - - [Fact] - public void AReportFromAnInstanceWithNoRecord_IsStillProtected() - { - Resolve([Deployment("memex")], - [Report("memex", TimeSpan.FromMinutes(1), "s-memex"), - Report("stranger", TimeSpan.FromDays(400), "s-stranger")]) - .Select(r => r.Identity).Should().Contain("s-stranger", - because: "an unexpected consumer is still a consumer; only the EXPECTED set decides completeness"); - } - - [Fact] - public void AHostThatIsNobodysFleet_IsAMeasuredZero_NotARefusal() - { - // This source is registered on EVERY portal, not only the control instance. An ordinary - // installation holds no Deployment records and receives no reports; refusing there would - // wedge retention on every portal in the fleet for ever. - Resolve([], []).Should().BeEmpty(); - } - - [Fact] - public void AControlInstanceHoldingReportsButNoRecords_IsRefused_RatherThanReadAsAFleetOfZero() - { - var refusal = Assert.Throws(() => Resolve( - [], [Report("memex", TimeSpan.FromMinutes(5)), Report("memex-cloud", TimeSpan.FromMinutes(5))])); - - refusal.Message.Should().Contain("control instance").And.Contain("missing denominator"); - } - - [Fact] - public void AnIncompleteAdoptionInventoryOnAnExpectedInstance_RefusesNamingTheInstance() - { - var refusal = Assert.Throws(() => Resolve( - [Deployment("memex-cloud")], - [Report("memex-cloud", TimeSpan.FromMinutes(5), complete: false)])); - - refusal.Message.Should().Contain("Deployments/memex-cloud").And.Contain("retention must not delete"); - } - - [Fact] - public void TwoReportsForOneInstance_AreJudgedByTheNEWEST_AndBothStillProtect() - { - // A control instance files its own report locally while remote ones arrive through the - // inbox, so two nodes can describe one deployment. Picking whichever came first out of the - // query would make the verdict depend on ordering — a stale duplicate beside a current - // report is not a stale instance, and a current duplicate beside a stale one is not a fresh - // one either. Both contribute references; the freshness verdict comes from the newest. - var stale = Report("memex", TimeSpan.FromDays(9), "s-was-running") with { Id = "legacy" }; - var current = Report("memex", TimeSpan.FromMinutes(20), "s-running"); - - var references = Resolve([Deployment("memex")], [stale, current]); - references.Select(r => r.Identity).Should().Contain(["s-running", "s-was-running"], - because: "the older report still names a build something may be serving"); - - // …and the order it comes back in must not decide the verdict. - Resolve([Deployment("memex")], [current, stale]).Select(r => r.Identity) - .Should().Contain(["s-running", "s-was-running"]); - - // The stale one ALONE is the refusal, which is what makes the pair above a real distinction - // rather than a guard that never fires. - Assert.Throws(() => Resolve([Deployment("memex")], [stale])); - } - - [Fact] - public void AReportStampedInTheFUTURE_IsAnUnknownAge_NotAFreshOne() - { - // A negative age passes `age > budget` trivially, so a skewed clock would make every report - // that producer files permanently fresh — protecting one identity for ever while the - // installation moves on, which is exactly what the budget exists to detect. - var refusal = Assert.Throws(() => Resolve( - [Deployment("memex")], [Report("memex", -TimeSpan.FromHours(6))])); - refusal.Message.Should().Contain("FUTURE"); - } - - [Fact] - public void AControlInstanceWhoseIndexHasNotCaughtUp_IsRefused_NotReadAsAFleetOfZero() - { - // `Records` reads an eventually-consistent index, so a control instance whose Deployment - // AND inventory queries have both not landed answers exactly as an ordinary portal does. - // Configuration is authoritative where the query is not. - Resolve([], []).Should().BeEmpty(because: "an ordinary portal owns no fleet"); - var refusal = Assert.Throws( - () => Resolve([], [], isControlInstance: true)); - refusal.Message.Should().Contain("control instance").And.Contain("returned NOTHING"); - - // …and it says nothing once the records are there. - Resolve([Deployment("memex")], [Report("memex", TimeSpan.FromMinutes(5))], isControlInstance: true) - .Should().NotBeEmpty(); - } - - [Fact] - public void TheRefusalCountsExpectedAndORPHANReportsSeparately() - { - // "1 of 0 expected instance(s)" is the shape of an operator message nobody can act on: the - // refused subject was an orphan report, not a fraction of the expected set. - var refusal = Assert.Throws(() => Resolve( - [], [Report("stranger", TimeSpan.FromMinutes(5))])); - refusal.Message.Should().Contain("1 refusal(s) over 0 expected instance(s)") - .And.Contain("1 reporting without a record"); - } - - [Fact] - public void AStaleInstance_CannotHaveItsRunningBuildCollected_EvenThoughItsOldReportNamesAnother() - { - // The end-to-end shape of the failure, in the store's own terms. The instance reported - // `s-two-rolls-ago` a week ago and has rolled twice since; without the freshness verdict the - // pass would protect `s-two-rolls-ago`, collect everything else, and take with it whatever - // the instance is running now — which nothing in this process can name. - var stale = new[] { Report("memex-cloud", TimeSpan.FromDays(7), "s-two-rolls-ago") }; - var refusal = Assert.Throws(() => Resolve([Deployment("memex-cloud")], stale)); - refusal.Message.Should().Contain("Retention must not delete"); - - // …and the identical fleet with a fresh report is NOT refused: the guard distinguishes, it - // does not simply always refuse. - Resolve([Deployment("memex-cloud")], [Report("memex-cloud", TimeSpan.FromHours(2), "s-two-rolls-ago")]) - .Select(r => r.Identity).Should().Equal("s-two-rolls-ago"); - } -} diff --git a/test/Memex.Portal.Shared.Test/DevLoginIsNotAdminTest.cs b/test/Memex.Portal.Shared.Test/DevLoginIsNotAdminTest.cs deleted file mode 100644 index efd561f4d2..0000000000 --- a/test/Memex.Portal.Shared.Test/DevLoginIsNotAdminTest.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Memex.Portal.Shared.Authentication; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the rule that a dev-login identity is an ORDINARY USER unless configuration says otherwise. -/// -/// 🚨 Why this is security-relevant and not a dev-only nicety: ProvisionDevUser used to -/// onboard every dev identity with Role: Admin. That role becomes a CLAIM, and -/// PermissionEvaluator.ComputeRoleState adds claim roles AFTER the per-scope deny -/// subtraction — so a claim-Admin cannot be removed by any gating deny. The visible effect was a -/// paywall BYPASS that read as a gating bug: a signed-in user saw the CHF 900 checkout (entitlement -/// is a record, and they had none) yet still read the gated lessons. Anonymous stayed blocked, -/// because it carries no claim role — which is precisely why it went unnoticed. -/// -/// It also made the e2e suite unable to prove the purchase funnel at all: every identity it -/// minted passed every gate, so the gating specs asserted nothing. -/// -public class DevLoginIsNotAdminTest -{ - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void NoConfiguredList_MeansNobodyIsAdmin(string? configured) - { - // The DEFAULT is the property that matters: a dev portal that hands out admin by default - // cannot test any access rule, because every identity passes every gate. - Assert.False(DevAuthController.IsDevAdmin(configured, "e2e-admin")); - Assert.False(DevAuthController.IsDevAdmin(configured, "roland")); - } - - [Fact] - public void OnlyTheNamedUsersAreAdmin() - { - const string list = "e2e-admin,ops"; - - Assert.True(DevAuthController.IsDevAdmin(list, "e2e-admin")); - Assert.True(DevAuthController.IsDevAdmin(list, "ops")); - // The locked-out fixtures the gating specs rely on MUST stay ordinary users. - Assert.False(DevAuthController.IsDevAdmin(list, "e2e-locked")); - Assert.False(DevAuthController.IsDevAdmin(list, "e2e-user")); - } - - [Fact] - public void MatchingIsCaseInsensitiveAndTolerantOfSpacing() - { - // The list is hand-written in compose/env files; whitespace must not silently drop an admin - // (which would break a harness bootstrap in a way that looks like a permissions bug). - Assert.True(DevAuthController.IsDevAdmin(" e2e-admin , ops ", "E2E-Admin")); - Assert.True(DevAuthController.IsDevAdmin("E2E-ADMIN", "e2e-admin")); - } - - [Fact] - public void APrefixOrSubstringIsNotAMatch() - { - // "e2e-admin" must not make "e2e-admin2" — or a bare "e2e" — an admin. - Assert.False(DevAuthController.IsDevAdmin("e2e-admin", "e2e-admin2")); - Assert.False(DevAuthController.IsDevAdmin("e2e-admin", "e2e")); - Assert.False(DevAuthController.IsDevAdmin("e2e", "e2e-admin")); - } - - [Fact] - public void TheRuleIsPositionIndependent_SoItCanRunOnEverySignin() - { - // Regression: the grant first lived inside ProvisionDevUser, which runs ONLY when the User - // node is missing. On any mesh whose database already held the user — a re-boot reusing the - // volume, a seeded user, the second run of a suite — the configured admin silently came back - // as an ordinary user and the e2e bootstrap hung on an admin-gated Plugin Catalog it could - // no longer read. The rule must therefore be a pure function of (list, username) with no - // dependence on whether this is the user's first signin, so it is safe to evaluate every - // time; the caller now does exactly that. - const string list = "e2e-admin"; - Assert.True(DevAuthController.IsDevAdmin(list, "e2e-admin")); - Assert.True(DevAuthController.IsDevAdmin(list, "e2e-admin")); // second signin: unchanged - Assert.False(DevAuthController.IsDevAdmin(list, "e2e-locked")); - Assert.False(DevAuthController.IsDevAdmin(list, "e2e-locked")); // and still not an admin - } - - [Fact] - public void AnEmptyUsernameIsNeverAdmin() - { - // Guards the degenerate case where a blank id would otherwise match a stray empty entry. - Assert.False(DevAuthController.IsDevAdmin("e2e-admin,,", "")); - Assert.False(DevAuthController.IsDevAdmin(",", "")); - } -} diff --git a/test/Memex.Portal.Shared.Test/DiagnosticsNameTheContentTypeAssemblyTest.cs b/test/Memex.Portal.Shared.Test/DiagnosticsNameTheContentTypeAssemblyTest.cs deleted file mode 100644 index 63e3f8d12e..0000000000 --- a/test/Memex.Portal.Shared.Test/DiagnosticsNameTheContentTypeAssemblyTest.cs +++ /dev/null @@ -1,116 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; // IMeshContentTypeRegistry -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 #4158, ask 2 — get_diagnostics names the assembly a NodeType's content type -/// actually resolved to. -/// -/// Before this, every identity on that reply was a CLAIM written by a build: mvid is -/// NodeTypeDefinition.LatestAssemblyMvid (the bytes a build produced — and the record's own -/// doc says LatestAssemblyPath "is an ADDRESS, not an identity"), and [ModuleLoad] -/// names the assembly a module LOADED. Neither is the statement the serializer and -/// /schema/<Type> act on. On 2026-09-10 that gap cost a night: a module had the newest -/// generation, the newest written= and types predating two merged PRs, and from the consumer -/// a stale ADOPTED build and a stale registry SHELF read identically. -/// -/// Both cases below FAIL on main: the reply carries no contentType member at -/// all, so TryGetProperty is false and the first assertion of each goes red. -/// -/// The second case is the negative control. Same mesh, same call, a NodeType with no -/// registered content type — it must report unresolved. If the block were a constant of the -/// reply (or derived from the node's record rather than from the live registry) both cases would -/// answer the same way and this one would fail. -/// -public class DiagnosticsNameTheContentTypeAssemblyTest(ITestOutputHelper testOutput) - : MonolithMeshTestBase(testOutput) -{ - private const string TypedNodeType = "DiagTyped"; - private const string UntypedNodeType = "DiagUntyped"; - - /// The content type the typed NodeType's diagnostics must name the assembly of. - public sealed record DiagPayload(string Value); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddMeshNodes( - new MeshNode(TypedNodeType) - { - Name = TypedNodeType, - NodeType = MeshNode.NodeTypePath, - Content = new NodeTypeDefinition { DefaultNamespace = "" }, - }, - new MeshNode(UntypedNodeType) - { - Name = UntypedNodeType, - NodeType = MeshNode.NodeTypePath, - Content = new NodeTypeDefinition { DefaultNamespace = "" }, - }); - - private async Task DiagnosticsFor(string nodeTypePath, CancellationToken cancellationToken) - { - var json = await new MeshOperations(Mesh).GetDiagnostics(nodeTypePath) - .Timeout(TestTimeouts.CrossSilo).FirstAsync().Await(cancellationToken); - Output.WriteLine($"{nodeTypePath} → {json}"); - return JsonDocument.Parse(json).RootElement; - } - - [Fact(Timeout = 120_000)] - public async Task ARegisteredContentTypeIsNamedByItsOwnAssemblyAndMvid() - { - Mesh.ServiceProvider.GetRequiredService() - .Register(typeof(DiagPayload), TypedNodeType); - - var reply = await DiagnosticsFor(TypedNodeType, TestContext.Current.CancellationToken); - - Assert.True(reply.TryGetProperty("contentType", out var block), - "the per-NodeType diagnostics must carry the assembly the content type resolved to — " - + "every other identity on this reply is a claim some build wrote (#4158 ask 2). " - + $"Got: {reply}"); - // 🚨 The LITERAL, not the constant: this is a control over the WIRE shape a consumer - // reads, and asserting against the constant would let a rename change the - // expectation silently. It also lets this file compile — and fail — on `main`. - Assert.Equal("resolved", block.GetProperty("status").GetString()); - Assert.Equal(typeof(DiagPayload).FullName, block.GetProperty("typeName").GetString()); - // The LOADED assembly's own path and module version id — properties of the bytes in this - // process, which is the only coordinate here that cannot be stale. - Assert.Equal(typeof(DiagPayload).Assembly.Location, - block.GetProperty("assembly").GetString()); - Assert.Equal( - typeof(DiagPayload).Assembly.ManifestModule.ModuleVersionId.ToString("N")[..8], - block.GetProperty("mvid").GetString()); - Assert.False(block.GetProperty("collectible").GetBoolean()); - } - - [Fact(Timeout = 120_000)] - public async Task ANodeTypeWithNoRegisteredContentTypeSaysSo_NotNothing() - { - var reply = await DiagnosticsFor(UntypedNodeType, TestContext.Current.CancellationToken); - - Assert.True(reply.TryGetProperty("contentType", out var block), - $"the block must be present whatever the answer is. Got: {reply}"); - // 🚨 The negative control. If the block were a constant of the reply, or read off the - // node's record instead of the live registry, this would say "resolved" like the case - // above — and the case above would then be measuring nothing. - Assert.Equal("unresolved", block.GetProperty("status").GetString()); - Assert.Null(block.GetProperty("typeName").GetString()); - Assert.Null(block.GetProperty("assembly").GetString()); - // The consequence, spelled out: this is the state in which the node's views render blank. - Assert.Contains("render empty", block.GetProperty("message").GetString()!, - StringComparison.Ordinal); - } -} diff --git a/test/Memex.Portal.Shared.Test/DrainEndpointTest.cs b/test/Memex.Portal.Shared.Test/DrainEndpointTest.cs deleted file mode 100644 index 7024332bc7..0000000000 --- a/test/Memex.Portal.Shared.Test/DrainEndpointTest.cs +++ /dev/null @@ -1,249 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Text.Encodings.Web; -using System.Threading.Tasks; -using Memex.Portal.ServiceDefaults; -using MeshWeaver.Hosting; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The session drain, pinned end to end (#1971 / #1342). -/// -/// What was NOT pinned. A rollout is non-disruptive only because the container's -/// preStop polls http://127.0.0.1:8080/drain until this pod's last Blazor circuit -/// closes. #2043 pinned the ARITHMETIC of that loop (DrainDeadlineGuard, a text guard over -/// the chart) and DrainProgressTest pins what each probe is worth logging — but nothing -/// pinned the thing in the middle: that the portal actually MAPS /drain, that it answers -/// 503-with-a-count while circuits are open and 200 when drained, and that it answers with no -/// session at all. Every one of those is a chart↔code agreement, and this chart already carries a -/// scar of exactly that class: preStop once probed with wget, which is absent from -/// the image, so the loop could never succeed and EVERY termination hung to the grace ceiling. It -/// was caught by reading, not by a test. -/// -/// Why a wrong answer here is silent. preStop probes with -/// curl -sf -m 5 -o /dev/null, which cannot tell a 404 from a 503 from a refused -/// connection — all three are "not drained yet, keep waiting". So an unmapped, renamed, or -/// login-gated /drain does not fail: it makes every roll sit out its whole drain window and -/// then cut every open session at the deadline, with nothing in the log that names the cause. That -/// is the same disease as the rest of this issue — silence indistinguishable from success — and -/// these cases are what make it fail loudly instead, at build time. -/// -/// Driven over a REAL HTTP pipeline through the REAL -/// composition (never a hand-mapped copy), behind -/// a deny-by-default authorization fallback so the anonymity assertion is not vacuous — the same -/// harness shape as beside it. -/// -public class DrainEndpointTest -{ - /// The route the chart's preStop hook polls. Changing it is a chart change too. - private const string DrainRoute = "/drain"; - - /// A route with no AllowAnonymous — the proof the fallback policy is real. - private const string GuardedRoute = "/guarded"; - - private const string TestScheme = "NoSession"; - - /// - /// The pipeline under test. is registered exactly as the portal - /// registers it, so the endpoint reads the same counter a live pod's circuits feed. - /// - private static WebApplication BuildApp(ActiveCircuitTracker? tracker, MeshWeaver.Mesh.HostDrainSignal? signal = null) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.AddDefaultHealthChecks(); - - if (tracker is not null) - builder.Services.AddSingleton(tracker); - if (signal is not null) - builder.Services.AddSingleton(signal); - - builder.Services.AddAuthentication(TestScheme) - .AddScheme(TestScheme, _ => { }); - - // Deny by default. AllowAnonymous on /drain is what must beat this — a preStop hook carries - // no cookie and no bearer token. - builder.Services.AddAuthorizationBuilder() - .SetFallbackPolicy(new AuthorizationPolicyBuilder() - .RequireAssertion(_ => false) - .Build()); - - var app = builder.Build(); - app.UseAuthentication(); - app.UseAuthorization(); - app.MapDefaultEndpoints(); - app.MapGet(GuardedRoute, () => "secret"); - return app; - } - - /// Authenticates nobody — the "no session" a kubelet preStop probe arrives with. - private sealed class NoSessionHandler( - IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) - : AuthenticationHandler(options, logger, encoder) - { - protected override Task HandleAuthenticateAsync() => - Task.FromResult(AuthenticateResult.NoResult()); - } - - private static async Task<(HttpResponseMessage Response, string Body)> GetAsync( - string route, ActiveCircuitTracker? tracker = null) - { - var app = BuildApp(tracker); - await using (app) - { - await app.StartAsync(TestContext.Current.CancellationToken); - using var client = app.GetTestClient(); - var response = await client.GetAsync(route, TestContext.Current.CancellationToken); - var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - return (response, body); - } - } - - /// - /// Non-vacuity guard: the pipeline really does refuse an unauthenticated caller. Without it the - /// anonymity assertion below proves nothing — every route answers when nothing is guarding. - /// - [Fact] - public async Task A_guarded_route_in_the_same_pipeline_refuses_an_anonymous_caller() - { - var (response, _) = await GetAsync(GuardedRoute); - - response.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden); - } - - /// - /// 🚨 The success signal preStop waits for. A pod with no circuits answers 200, which is the - /// ONLY thing that ends the drain loop early — every other outcome, including a 404 from an - /// unmapped route, reads to curl -sf as "still draining". - /// - [Fact] - public async Task WithNoCircuits_TheDrainRouteAnswers200_TheOnlyThingThatEndsThePreStopLoop() - { - var (response, body) = await GetAsync(DrainRoute, new ActiveCircuitTracker()); - - response.StatusCode.Should().Be(HttpStatusCode.OK, - "preStop polls until this route SUCCEEDS; a 404 or a 5xx is indistinguishable from " - + "'sessions are still open' and rides the whole drain window before cutting them off"); - body.Should().Be("drained"); - } - - /// - /// 🚨 The hold signal. A live circuit must produce a NON-success status, or a roll would tear - /// down a pod that is still serving someone — the regression #1342 fixed. - /// - [Fact] - public async Task WithALiveCircuit_TheDrainRouteAnswers503_AndNamesTheCount() - { - var tracker = new ActiveCircuitTracker(); - tracker.Opened(); - tracker.Opened(); - - var (response, body) = await GetAsync(DrainRoute, tracker); - - response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - body.Should().Contain("2", - "the count is the diagnostic — DrainProgress reports it, and an operator reading a " - + "Terminating pod has nothing else to go on"); - } - - /// - /// 🚨 Anonymous, because the caller is a kubelet exec with no cookie and no token. A portal - /// that put this route behind its login would answer 302/401 to every probe — which - /// curl -sf reads as "not drained" — so every roll would ride to the deadline and cut - /// every session. Proved against the deny-by-default fallback above, which the control case - /// shows is real. - /// - [Fact] - public async Task TheDrainRoute_AnswersWithoutASession() - { - var (response, _) = await GetAsync(DrainRoute, new ActiveCircuitTracker()); - - response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized); - response.StatusCode.Should().NotBe(HttpStatusCode.Forbidden); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - - /// - /// A host with no Blazor tracker at all (a worker, a test host) is trivially drained. Pinned - /// because the alternative — a 500 from a missing service — is read by curl -sf as - /// "keep waiting", so the pod would sit out its whole window and then be killed anyway. - /// - [Fact] - public async Task WithNoCircuitTrackerRegistered_TheRouteStillAnswersDrained() - { - var (response, body) = await GetAsync(DrainRoute, tracker: null); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - body.Should().Be("drained"); - } - - /// - /// 🚨 The first /drain probe from the pod's own preStop (loopback, unforwarded) publishes - /// "this pod has begun TERMINATING" process-wide (), so an - /// instance singleton (RelocateOnDrain) leaves the pod NOW — at preStop, not 30 minutes later at SIGTERM. - /// - [Fact] - public async Task TheFirstInPodDrainProbe_BeginsTermination_ForTheSingletonsToLeave() - { - using var signal = new MeshWeaver.Mesh.HostDrainSignal(); - var app = BuildApp(new ActiveCircuitTracker(), signal); - await using (app) - { - await app.StartAsync(TestContext.Current.CancellationToken); - signal.Begun.Should().BeFalse("nothing has probed /drain yet"); - var probe = await Probe(app, IPAddress.Loopback, forwardedFor: null); - probe.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - signal.Begun.Should().BeTrue("the first in-pod probe IS termination beginning — the singletons must leave now"); - } - } - - /// - /// 🚨 /drain is anonymous and reachable through the ingress, and beginning termination is - /// permanent — so a probe from anywhere but the pod itself only REPORTS (#6092 review). A remote peer, - /// and a loopback peer that arrived through a proxy (a forged or real X-Forwarded-For), both - /// still get the drain answer and leave the singletons where they are. - /// - [Fact] - public async Task ADrainProbeFromOutsideThePod_Reports_ButNeverBeginsTermination() - { - using var signal = new MeshWeaver.Mesh.HostDrainSignal(); - var app = BuildApp(new ActiveCircuitTracker(), signal); - await using (app) - { - await app.StartAsync(TestContext.Current.CancellationToken); - - var remote = await Probe(app, IPAddress.Parse("10.244.1.7"), forwardedFor: null); - remote.Response.StatusCode.Should().Be(StatusCodes.Status200OK, "the report itself stays open to anyone"); - signal.Begun.Should().BeFalse("a remote caller must not evict the singletons from a healthy pod"); - - var forwarded = await Probe(app, IPAddress.Loopback, forwardedFor: "127.0.0.1"); - forwarded.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - signal.Begun.Should().BeFalse("a request that came through a proxy is never the pod's own preStop, whatever peer it claims"); - - var noPeer = await Probe(app, null, forwardedFor: null); - noPeer.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - signal.Begun.Should().BeFalse("an unknown peer is not proof of an in-pod probe"); - } - } - - private static Task Probe(WebApplication app, IPAddress? peer, string? forwardedFor) => - app.GetTestServer().SendAsync(c => - { - c.Request.Method = HttpMethods.Get; - c.Request.Path = DrainRoute; - c.Connection.RemoteIpAddress = peer; - if (forwardedFor is not null) - c.Request.Headers["X-Forwarded-For"] = forwardedFor; - }, TestContext.Current.CancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/EaConnectOpenRedirectTest.cs b/test/Memex.Portal.Shared.Test/EaConnectOpenRedirectTest.cs deleted file mode 100644 index ac9430b506..0000000000 --- a/test/Memex.Portal.Shared.Test/EaConnectOpenRedirectTest.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The consent endpoints must never redirect off-site. -/// -/// Why this test exists. Both redirect targets come from the request: -/// ?returnUrl= on /auth/ea/connect, and the OAuth state on the callback — -/// which is that same value round-tripped through the identity provider. Passing either straight -/// to Redirect is an open redirect, and the already-connected fast path turns it into a -/// SINGLE hop with no dialog in between: /auth/ea/connect?returnUrl=https://phish.example -/// sends an authenticated user off-site behind our own domain. Caught in review on #2101 before -/// it merged. -/// -/// The variants below are the ones a prefix check ("does it start with '/'?") lets through: -/// //host is protocol-relative and resolves against the current scheme, and /\host -/// is normalised into an authority by some browsers. The controller implements ASP.NET's own -/// IsLocalUrl rule directly (its Url helper is null for a directly-constructed controller, -/// and a security check that NREs depending on how the type was built is worse than the hole it -/// closes) — so these cases are what make the hand-written rule trustworthy. -/// -public class EaConnectOpenRedirectTest -{ - private sealed class FakeEaGraphAuth(bool connected) : IEaGraphAuth - { - public string? LastState { get; private set; } - public bool IsConfigured => true; - public string ConnectPath => EaConsentController.ConnectPath; - - public string BuildConsentUrl(string state, string redirectUri) - { - LastState = state; - return "https://login.microsoftonline.example/consent?state=" + state; - } - - public IObservable ExchangeAndStore( - string code, string redirectUri, string userObjectId) => Observable.Return(true); - - public IObservable GetAccessToken(string userObjectId) => - Observable.Return(connected - ? EaGraphAccess.Connected("token") - : EaGraphAccess.NotConnected()); - - public IObservable GetConnection(string userObjectId) => - Observable.Return(connected - ? EaGraphAccess.Connected() - : EaGraphAccess.NotConnected()); - } - - private static EaConsentController Controller(FakeEaGraphAuth ea) - { - var access = new AccessService(); - access.SetContext(new AccessContext { ObjectId = "user-1" }); - return new EaConsentController(ea, access, NullLogger.Instance) - { - ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext - { - Request = { Scheme = "https", Host = new HostString("memex.example") }, - }, - }, - }; - } - - [Theory] - [InlineData("https://phish.example")] // absolute, the headline case - [InlineData("http://phish.example/steal")] - [InlineData("//phish.example")] // protocol-relative — a leading '/' is not enough - [InlineData("//phish.example/path?a=b")] - [InlineData("/\\phish.example")] // backslash variant some browsers normalise - [InlineData("javascript:alert(1)")] // not a navigation target at all - [InlineData("mailto:a@b.example")] - [InlineData("rbuergi")] // not rooted — ambiguous, so refused - public async Task An_offsite_returnUrl_never_reaches_the_redirect(string hostile) - { - // The already-connected path is the dangerous one: no consent dialog stands between the - // click and the redirect. - var result = await Controller(new FakeEaGraphAuth(connected: true)).Connect(returnUrl: hostile); - - result.Should().BeOfType() - .Which.Url.Should().Be("/", - $"'{hostile}' must collapse to the home page, never be redirected to"); - } - - [Fact] - public async Task An_offsite_returnUrl_is_not_smuggled_through_the_consent_state_either() - { - // The not-connected path hands returnUrl to the IdP as `state` and redirects to it on the - // way back — sanitising only the visible redirect would leave that route open. - var ea = new FakeEaGraphAuth(connected: false); - - await Controller(ea).Connect(returnUrl: "https://phish.example"); - - ea.LastState.Should().Be("%2F", - "the state carried to the identity provider is the SANITISED target, url-escaped"); - } - - [Fact] - public async Task A_hostile_state_coming_back_from_the_provider_is_refused() - { - // `state` re-enters as untrusted input: it left our process, so it is re-sanitised rather - // than trusted on the strength of having been ours once. - var result = await Controller(new FakeEaGraphAuth(connected: true)) - .Callback(code: null, state: "https%3A%2F%2Fphish.example", error: "access_denied", - ct: CancellationToken.None); - - result.Should().BeOfType().Which.Url.Should().Be("/?eaConnect=failed&reason=microsoft"); - } - - [Theory] - [InlineData("/rbuergi")] - [InlineData("/rbuergi/Chat?from=ea#top")] - [InlineData("/")] - public async Task A_same_site_returnUrl_is_preserved_exactly(string friendly) - { - // The guard must not be so blunt that it breaks the feature it protects. - var result = await Controller(new FakeEaGraphAuth(connected: true)).Connect(returnUrl: friendly); - - result.Should().BeOfType().Which.Url.Should().Be(friendly); - } -} diff --git a/test/Memex.Portal.Shared.Test/EaConnectReconsentTest.cs b/test/Memex.Portal.Shared.Test/EaConnectReconsentTest.cs deleted file mode 100644 index 3392ed5859..0000000000 --- a/test/Memex.Portal.Shared.Test/EaConnectReconsentTest.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the consent flow's idempotence: a user whose Executive-Assistant grant is already stored -/// is NOT sent back through Microsoft's consent dialog when they hit /auth/ea/connect again. -/// -/// Why this test exists. Connect used to redirect to BuildConsentUrl -/// unconditionally, and that URL then carried prompt=consent (removed in #6082). Every visit to the connect link — -/// a second click on the EA's "please connect" hint, a bookmarked URL — re-ran the full Microsoft -/// consent, and users read the repeat dialog as "my consent was never saved" (reported 2026-08-23). -/// The grant WAS stored; only the entry point ignored it. -/// -public class EaConnectReconsentTest -{ - private sealed class FakeEaGraphAuth(EaConnection state) : IEaGraphAuth - { - public FakeEaGraphAuth(bool connected) - : this(connected ? EaConnection.Connected : EaConnection.NotConnected) { } - - public bool ConsentUrlBuilt { get; private set; } - - public bool IsConfigured => true; - public string ConnectPath => EaConsentController.ConnectPath; - - public string BuildConsentUrl(string state, string redirectUri) - { - ConsentUrlBuilt = true; - return "https://login.microsoftonline.example/consent?state=" + state; - } - - public IObservable ExchangeAndStore( - string code, string redirectUri, string userObjectId) => Observable.Return(true); - - public IObservable GetAccessToken(string userObjectId) => - Observable.Return(Answer("token")); - - public IObservable GetConnection(string userObjectId) => - Observable.Return(Answer(null)); - - private EaGraphAccess Answer(string? token) => state switch - { - EaConnection.Connected => EaGraphAccess.Connected(token), - EaConnection.Undetermined => EaGraphAccess.Unknown("the credential read did not complete"), - _ => EaGraphAccess.NotConnected(), - }; - } - - private static EaConsentController Controller(FakeEaGraphAuth ea) - { - var access = new AccessService(); - access.SetContext(new AccessContext { ObjectId = "user-1" }); - var controller = new EaConsentController(ea, access, NullLogger.Instance) - { - // A real request context so the not-connected branch can compose its callback URI. - ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext - { - Request = { Scheme = "https", Host = new HostString("memex.example") }, - }, - }, - }; - return controller; - } - - [Fact] - public async Task An_already_connected_user_bounces_back_without_a_Microsoft_round_trip() - { - var ea = new FakeEaGraphAuth(connected: true); - - var result = await Controller(ea).Connect(returnUrl: "/rbuergi"); - - result.Should().BeOfType() - .Which.Url.Should().Be("/rbuergi", - "a stored grant means there is nothing to consent — the connect link must be " - + "idempotent, not a Microsoft round trip"); - ea.ConsentUrlBuilt.Should().BeFalse("the consent URL must not even be composed"); - } - - [Fact] - public async Task Force_still_runs_the_full_consent_for_a_connected_user() - { - var ea = new FakeEaGraphAuth(connected: true); - - var result = await Controller(ea).Connect(returnUrl: "/rbuergi", force: true); - - result.Should().BeOfType() - .Which.Url.Should().StartWith("https://login.microsoftonline.example/consent", - "?force=true is the deliberate re-consent escape hatch (scope additions, rotation)"); - } - - [Fact] - public async Task A_not_yet_connected_user_is_sent_to_the_Microsoft_consent() - { - var ea = new FakeEaGraphAuth(connected: false); - - var result = await Controller(ea).Connect(returnUrl: "/rbuergi"); - - result.Should().BeOfType() - .Which.Url.Should().StartWith("https://login.microsoftonline.example/consent"); - ea.ConsentUrlBuilt.Should().BeTrue(); - } - - /// - /// 🚨 #3433 at the consent endpoint. A read that did not ANSWER must not be treated as - /// "already connected" — the fast path exists to spare a connected user a pointless dialog, and - /// taking it on an unknown state would strand a user who genuinely never connected with a - /// redirect that does nothing. - /// - /// The opposite mistake — treating undetermined as "never connected" and offering the - /// link — is the one the EA tools make, and it is wrong THERE because nothing is being consented - /// there. Here the consent flow is the safe direction: re-consenting a live grant is harmless - /// (Microsoft re-issues it), so the endpoint runs it and logs the diagnostic on the way past. - /// The two callers differ because their fallbacks differ, not because the state does. - /// - [Fact] - public async Task An_undetermined_read_runs_the_consent_rather_than_guessing() - { - var ea = new FakeEaGraphAuth(EaConnection.Undetermined); - - var result = await Controller(ea).Connect(returnUrl: "/rbuergi"); - - result.Should().BeOfType() - .Which.Url.Should().StartWith("https://login.microsoftonline.example/consent", - "an unanswered credential read is not evidence of a stored grant, and the fast path " - + "is only correct when we KNOW the user is connected"); - ea.ConsentUrlBuilt.Should().BeTrue(); - } -} diff --git a/test/Memex.Portal.Shared.Test/EaConsentOutcomeTest.cs b/test/Memex.Portal.Shared.Test/EaConsentOutcomeTest.cs deleted file mode 100644 index 6d1700d409..0000000000 --- a/test/Memex.Portal.Shared.Test/EaConsentOutcomeTest.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Mesh; -using MeshWeaver.AI; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins what the EA consent flow tells Microsoft and what it tells the user (#6082). -/// -/// No forced prompt. prompt=consent made Microsoft show its consent dialog on -/// every authorize request. In a tenant where users may not consent to the mail scopes, that dialog -/// is always "request admin approval" — also after an admin granted tenant-wide consent, so the -/// user looped. Without the parameter Microsoft prompts only for consent that is actually missing. -/// -/// A visible outcome. The callback used to redirect to the return URL whatever happened, -/// so a refused consent or a failed code exchange looked exactly like success. The return URL now -/// carries eaConnect=connected or eaConnect=failed&reason=…, and Microsoft's -/// error_description reaches the log. -/// -public class EaConsentOutcomeTest -{ - private sealed class FakeEaGraphAuth(bool exchangeSucceeds) : IEaGraphAuth - { - public bool IsConfigured => true; - public string ConnectPath => EaConsentController.ConnectPath; - - public string BuildConsentUrl(string state, string redirectUri) => - "https://login.microsoftonline.example/consent?state=" + state; - - public IObservable ExchangeAndStore( - string code, string redirectUri, string userObjectId) => Observable.Return(exchangeSucceeds); - - public IObservable GetAccessToken(string userObjectId) => - Observable.Return(EaGraphAccess.NotConnected()); - - public IObservable GetConnection(string userObjectId) => - Observable.Return(EaGraphAccess.NotConnected()); - } - - private sealed class PassThroughProtector : IProviderKeyProtector - { - public string? Protect(string? plaintext) => plaintext; - public string? Unprotect(string? stored) => stored; - } - - private static EaConsentController Controller( - IEaGraphAuth ea, ILogger logger) - { - var access = new AccessService(); - access.SetContext(new AccessContext { ObjectId = "user-1" }); - return new EaConsentController(ea, access, logger) - { - ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext - { - Request = { Scheme = "https", Host = new HostString("memex.example") }, - }, - }, - }; - } - - [Fact] - public void The_consent_url_does_not_force_the_consent_dialog() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Authentication:Microsoft:ClientId"] = "client-1", - ["Authentication:Microsoft:ClientSecret"] = "secret-1", - [MicrosoftTenant.ConfigurationKey] = "11111111-2222-3333-4444-555555555555", - }) - .Build(); - using var http = new HttpClient(); - var ea = new EaGraphAuth( - new ServiceCollection().BuildServiceProvider(), configuration, new PassThroughProtector(), http); - - var url = ea.BuildConsentUrl("state-1", "https://memex.example/auth/ea/callback"); - - url.Should().NotContain("prompt=", - "a forced prompt turns into an admin-approval request in tenants where users may not " - + "consent, even after an admin granted consent for the whole tenant (#6082)"); - } - - [Fact] - public async Task A_consent_Microsoft_refused_reports_failed_and_logs_the_description() - { - var logger = new CapturingLogger(); - - var result = await Controller(new FakeEaGraphAuth(exchangeSucceeds: true), logger).Callback( - code: null, state: "/inbox", error: "access_denied", - errorDescription: "AADSTS65004: User declined to consent", ct: CancellationToken.None); - - result.Should().BeOfType() - .Which.Url.Should().Be("/inbox?eaConnect=failed&reason=microsoft"); - logger.Lines(LogLevel.Warning).Should().Contain(line => line.Contains("AADSTS65004"), - "Microsoft's error_description names the cause; the error code alone does not"); - } - - [Fact] - public async Task A_failed_code_exchange_reports_failed() - { - var logger = new CapturingLogger(); - - var result = await Controller(new FakeEaGraphAuth(exchangeSucceeds: false), logger).Callback( - code: "code-1", state: "/inbox", error: null, errorDescription: null, ct: CancellationToken.None); - - result.Should().BeOfType() - .Which.Url.Should().Be("/inbox?eaConnect=failed&reason=exchange"); - } - - [Fact] - public async Task A_stored_grant_reports_connected() - { - var logger = new CapturingLogger(); - - var result = await Controller(new FakeEaGraphAuth(exchangeSucceeds: true), logger).Callback( - code: "code-1", state: "/inbox?tab=mail", error: null, errorDescription: null, - ct: CancellationToken.None); - - result.Should().BeOfType() - .Which.Url.Should().Be("/inbox?tab=mail&eaConnect=connected", - "a return URL that already has a query string gets the outcome appended to it"); - } -} diff --git a/test/Memex.Portal.Shared.Test/EaCredentialNodeTest.cs b/test/Memex.Portal.Shared.Test/EaCredentialNodeTest.cs deleted file mode 100644 index 51262887ba..0000000000 --- a/test/Memex.Portal.Shared.Test/EaCredentialNodeTest.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Graph.Configuration; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Regression guard for the EA-credential path mismatch. stores a -/// user's Graph refresh token as a node and later loads it; the two sides MUST agree on the path. -/// A previous form built the node with new MeshNode(EaCredentialNodeType.NodeType, PathFor(user)), -/// which the MeshNode(Id, Namespace) ctor turned into Auth/_EaCredential/{user}/EaCredential -/// (Id = "EaCredential", one level too deep, NodeType unset) — while loads -/// Auth/_EaCredential/{user}. The token was stored but never found, so the Executive Assistant -/// reported "mailbox not connected" on every send, forever — undetectable by recycle or restart. -/// -public class EaCredentialNodeTest -{ - [Theory] - [InlineData("rbuergi")] - [InlineData("a1b2c3d4-0000-1111-2222-abcdef012345")] - public void CredentialNode_WritePath_EqualsLoadPath(string userObjectId) - { - var node = EaGraphAuth.NewCredentialNode(userObjectId); - - // The whole bug in one assertion: the node we WRITE must live exactly where we READ. - Assert.Equal(EaGraphAuth.PathFor(userObjectId), node.Path); - Assert.Equal($"Auth/_EaCredential/{userObjectId}", node.Path); - } - - [Fact] - public void CredentialNode_CarriesTheEaCredentialNodeType() - { - var node = EaGraphAuth.NewCredentialNode("rbuergi"); - - // NodeType must be set so the node's hub activates with the EaCredential data source - // (WithContentType) — otherwise the content never binds on load. - Assert.Equal(EaCredentialNodeType.NodeType, node.NodeType); - Assert.Equal("EaCredential", node.NodeType); - } -} diff --git a/test/Memex.Portal.Shared.Test/EaCredentialReadTest.cs b/test/Memex.Portal.Shared.Test/EaCredentialReadTest.cs deleted file mode 100644 index dd56aea2c8..0000000000 --- a/test/Memex.Portal.Shared.Test/EaCredentialReadTest.cs +++ /dev/null @@ -1,526 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.AI; // IProviderKeyProtector, ProviderKeyProtector -using MeshWeaver.Graph.Configuration; // AddEaCredentialType -using MeshWeaver.Fixture; // TestTimeouts.Convergence -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; // IMasterKeyProvider -using MeshWeaver.Messaging; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #3433, both halves, on a real monolith mesh: the Executive Assistant's credential read must be -/// consumable from a HUB TURN, and a read that does not answer must not be reported as -/// "this user never connected". -/// -/// What went wrong in production (2026-09-06). EaGraphAuth was -/// async/await/Task<T> end to end and its credential read was -/// await ws.GetMeshNodeStream(path).Take(1).Timeout(10s).FirstAsync(). The class comment -/// justified that as "this sits at the OAuth/HTTP boundary (called from the consent controller and -/// the async EA tool)" — but the EA tool is not an HTTP boundary, it is an agent tool running on a -/// hub. Awaiting there parks the hub's turn, the reply to the read queues behind the parked turn, -/// the Timeout fires, and a blanket catch converted that into (null, null) — -/// the same value the method returns for a user who has no credential at all. A connected user was -/// handed the re-consent link. -/// -/// The two tests that fail on the old code. -/// pins the MECHANISM directly -/// against the mesh — no simulation, no mock — and -/// pins the consequence: the -/// failed read and the absent credential must not be the same value. Revert -/// EaGraphAuth/IEaGraphAuth with these unchanged and they collapse. -/// -public class EaCredentialReadTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string ConnectedUser = "ea-connected-user"; - private const string NeverConnectedUser = "ea-never-connected-user"; - private const string StaleGrantUser = "ea-stale-grant-user"; - - /// - /// The scope string every grant minted before 2026-09-09 was consented for — mail and - /// calendar only. A credential carrying it is exactly the production shape of 2026-09-10: the - /// Teams scopes landed, the user's stored grant predates them, and Entra refuses to redeem the - /// refresh token for the wider set. - /// - private const string PreTeamsScopes = - "https://graph.microsoft.com/Mail.ReadWrite https://graph.microsoft.com/Mail.Send " + - "https://graph.microsoft.com/Calendars.ReadWrite offline_access"; - - /// An arbitrary, valid AES-256 key: the protector is real, only the key source is local. - private sealed class FixedMasterKey : IMasterKeyProvider - { - private readonly byte[] key = new byte[32]; - public byte[]? GetMasterKey() => key; - } - - /// The probe a hub turn answers — see the two hub-turn tests below. - private record ReadCredentialFromTurn(string UserObjectId); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - // The same registration ConfigureMemexMesh wires: the EaCredential NodeType plus its - // mesh-wide content discriminator, so the node's content types on EVERY hub and not - // only on its own (#2729 — without it ContentAs sees a raw JsonElement). - .AddEaCredentialType() - .AddMeshNodes(EaGraphAuth.NewCredentialNode(ConnectedUser) with - { - Content = new EaCredential - { - UserObjectId = ConnectedUser, - // Stored form is irrelevant to the READ — GetConnection never unprotects. - RefreshTokenEncrypted = "enc:v1:seeded-for-the-read-test", - Scopes = EaGraphAuth.Scopes, - AcquiredAt = DateTimeOffset.UtcNow, - } - }) - .AddMeshNodes(EaGraphAuth.NewCredentialNode(StaleGrantUser) with - { - Content = new EaCredential - { - UserObjectId = StaleGrantUser, - RefreshTokenEncrypted = "enc:v1:seeded-for-the-stale-grant-test", - Scopes = PreTeamsScopes, - AcquiredAt = DateTimeOffset.UtcNow.AddDays(-30), - } - }); - - /// - /// The real class over the real mesh. is the ONLY thing a test - /// varies — the way ApiTokenService.ValidationReadTimeout and UserRoleResolver's - /// budget are reachable — so the undetermined branch can be reached deterministically - /// instead of waited for. - /// - private EaGraphAuth NewAuth(TimeSpan? readTimeout = null, HttpMessageHandler? tokenEndpoint = null) => new( - Mesh.ServiceProvider, - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - ["Authentication:Microsoft:ClientId"] = "test-client", - ["Authentication:Microsoft:ClientSecret"] = "test-secret", - }).Build(), - new ProviderKeyProtector(new FixedMasterKey()), - // Unused by the GetConnection tests, which read the node and post nothing. The one test - // that calls GetAccessToken passes a handler that RECORDS, so "the token endpoint was not - // asked" is a measured zero rather than the absence of a network. - tokenEndpoint is null ? new HttpClient() : new HttpClient(tokenEndpoint), - Mesh.ServiceProvider.GetRequiredService>()) - { - CredentialReadTimeout = readTimeout ?? TimeSpan.FromSeconds(10), - }; - - /// Counts token-endpoint calls; answers every one of them as Entra would refuse a stale grant. - private sealed class RecordingTokenEndpoint : HttpMessageHandler - { - private int calls; - public int Calls => Volatile.Read(ref calls); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - Interlocked.Increment(ref calls); - return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest) - { - Content = new StringContent("""{"error":"invalid_grant","error_description":"AADSTS65001: consent required"}"""), - }); - } - } - - // ── The mechanism ──────────────────────────────────────────────────────────────────────────── - - /// - /// 🚨 THE DEADLOCK, reproduced against the real mesh rather than simulated. - /// - /// The hub's turn loop (MessageService.DrainOne) SUBSCRIBES to each turn and, for a - /// turn that does not complete synchronously, returns without dequeuing the next one — - /// the drain resumes only from that turn's terminal callback. So an AsyncDelivery handler - /// that awaits holds the whole queue. The credential node lives on its own per-node hub, so the - /// read's reply has to be processed by THIS hub — the one whose only turn is parked waiting for - /// it. It cannot arrive, and the read's own Timeout is the only thing that ends the wait. - /// That is line 197 of the old EaGraphAuth, executed. - /// - /// The credential IS present (seeded above), so "not connected" here is never the truth — - /// it is the answer the parked turn manufactures. - /// - [Fact] - public async Task AwaitingTheCredentialReadInsideAHubTurn_NeverCompletes() - { - var verdict = new AsyncSubject(); - var readBudget = TimeSpan.FromSeconds(3); - - // The shape `await` produces, expressed in the only way the hub's handler surface allows. - // The hub's own SyncDelivery/AsyncDelivery delegates are already IObservable-typed, so an - // `async` handler will not even compile — that door was closed. What `await ea.…Async(…)` - // did was reopen it from the OTHER side: it made the caller's work not finish until the - // read finished, which is precisely a turn that holds the drain. That is what this - // registers. - using var handler = Mesh.Register((delivery, _) => - Mesh.GetMeshNodeStream(EaGraphAuth.PathFor(delivery.Message.UserObjectId)) - .Take(1) - .Timeout(readBudget) - .Select(node => node is null ? "no node" : "connected") - .Catch((Exception ex) => Observable.Return(ex.GetType().Name)) - .Do(v => { verdict.OnNext(v); verdict.OnCompleted(); }) - .Select(_ => delivery.Processed())); - - Mesh.Post(new ReadCredentialFromTurn(ConnectedUser), o => o.WithTarget(Mesh.Address)); - - var answer = await verdict.Should().Within(TestTimeouts.Convergence).Emit(); - Output.WriteLine($"awaited-inside-the-turn verdict: {answer}"); - - answer.Should().Be(nameof(TimeoutException), - "the reply to a mesh read cannot be processed by a hub whose only turn is parked " - + "awaiting that reply — the read times out even though the credential is present. " - + "If this ever reports 'connected', the turn loop stopped being one-at-a-time and the " - + "whole premise of the no-async rule needs re-measuring, not this test relaxing."); - } - - /// - /// The same read, same hub, same turn, same node — issued the sanctioned way. The handler - /// composes the reactive seam and SUBSCRIBES, so the turn returns immediately, the hub is free - /// to process the read's reply, and the answer arrives. - /// - /// This is the control for the test above: it is what makes that one a statement about - /// await rather than about the mesh being slow. - /// - [Fact] - public async Task SubscribingToTheReactiveSeamInsideAHubTurn_Answers() - { - var ea = NewAuth(); - var verdict = new AsyncSubject(); - - using var handler = Mesh.Register(delivery => - { - ea.GetConnection(delivery.Message.UserObjectId) - .Subscribe( - access => { verdict.OnNext(access); verdict.OnCompleted(); }, - ex => verdict.OnError(ex)); - return delivery.Processed(); - }); - - Mesh.Post(new ReadCredentialFromTurn(ConnectedUser), o => o.WithTarget(Mesh.Address)); - - var access = await verdict.Should().Within(TestTimeouts.Convergence).Emit(); - Output.WriteLine($"subscribed-from-the-turn verdict: {access}"); - - access.Connection.Should().Be(EaConnection.Connected, - "a hub turn that subscribes instead of awaiting leaves the hub free to process the " - + "read's own reply, so the stored credential is found"); - } - - // ── Absent is not failed ───────────────────────────────────────────────────────────────────── - - /// A stored credential reads back as connected. The positive control for everything below. - [Fact] - public async Task AStoredCredential_ReadsAsConnected() - { - var access = await NewAuth().GetConnection(ConnectedUser) - .Should().Within(TestTimeouts.Convergence).Emit(); - - access.Connection.Should().Be(EaConnection.Connected); - access.AccessToken.Should().BeNull("GetConnection reads the credential; it never mints a token"); - } - - /// - /// A user with no credential node reads as — the ONE - /// state in which offering the consent link is truthful. Without this the fix could "pass" by - /// answering to everything, which would strand every - /// genuinely-new user. - /// - [Fact] - public async Task AUserWithNoCredential_ReadsAsNotConnected() - { - var access = await NewAuth().GetConnection(NeverConnectedUser) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Output.WriteLine($"absent-credential verdict: {access}"); - access.Connection.Should().Be(EaConnection.NotConnected, - "an absent credential is a completed read with a negative answer, and the consent link " - + "is the correct response to it"); - } - - // ── A grant is only as wide as its consent ─────────────────────────────────────────────────── - - /// - /// 🚨 2026-09-10, on memex-cloud. The Teams scopes were added to ; - /// the user's stored grant predated them; Entra refused every refresh-token redemption for the - /// wider set (400 invalid_grant); the plugin reported the connection as undetermined; the - /// user clicked the reconnect link; and the consent controller — reading the SAME credential as - /// "connected" — bounced them straight back without ever showing Microsoft's dialog. Nothing in - /// that loop could end it, because the classification called a grant that cannot serve this - /// build "connected". - /// - /// The truthful answer is : the read completed and - /// found a credential consented for a smaller scope set. That one value both hands the user the - /// consent link and makes /auth/ea/connect run the dialog. The diagnostic must say the - /// mailbox side is intact, so the plugin's sentence is "reconnect once", never "you never - /// connected". - /// - [Fact] - public async Task AGrantConsentedForASmallerScopeSet_ReadsAsNotConnected_SoTheReconnectRunsConsent() - { - var stale = await NewAuth().GetConnection(StaleGrantUser) - .Should().Within(TestTimeouts.Convergence).Emit(); - var current = await NewAuth().GetConnection(ConnectedUser) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Output.WriteLine($"stale-grant verdict: {stale}"); - - stale.Connection.Should().Be(EaConnection.NotConnected, - "a credential consented for fewer scopes than this build asks for cannot mint a token, " - + "and NotConnected is the one state that makes the connect link run the consent dialog " - + "instead of bouncing a 'connected' user back"); - stale.Diagnostic.Should().Contain("reconnect", - "the user must be told what to do, and it is not 'connect for the first time'"); - current.Connection.Should().Be(EaConnection.Connected, - "the positive control: the same read on a grant consented for THIS build's scopes"); - } - - /// - /// The token endpoint is never asked to redeem a grant the classification already knows it will - /// refuse. A recorded zero, not an assumed one: the handler counts. - /// - [Fact] - public async Task AStaleGrant_IsNotOfferedToTheTokenEndpoint() - { - var endpoint = new RecordingTokenEndpoint(); - - var access = await NewAuth(tokenEndpoint: endpoint).GetAccessToken(StaleGrantUser) - .Should().Within(TestTimeouts.Convergence).Emit(); - - access.Connection.Should().Be(EaConnection.NotConnected); - access.AccessToken.Should().BeNull(); - endpoint.Calls.Should().Be(0, - "redeeming a refresh token for scopes it was never consented for is the 400 Entra " - + "answered all day on 2026-09-10; the classification exists so that call is not made"); - } - - /// - /// 🚨 THE COLLAPSE, pinned. A read that does not answer — here the same read as - /// , given a budget it cannot meet — must be - /// , and must therefore differ from the answer for a user - /// who genuinely has no credential. - /// - /// The two assertions are deliberately separate. The first says what the failed read IS; - /// the second says what it is NOT. On the pre-fix code both sites returned the same - /// (null, null) — GetAccessTokenAsync then answered null and the EA showed - /// the re-consent link — so BOTH fail, and the second is the one that names the user-visible - /// defect. - /// - /// The budget is a test-only override, not a production knob: #3433 says explicitly that - /// widening the timeout is not the fix. It is used here in the shrinking direction, to reach the - /// branch deterministically instead of racing it. - /// - [Fact] - public async Task AReadThatDoesNotAnswer_IsUndetermined_NotNotConnected() - { - // A budget no cross-hub node read can meet. Paired with AStoredCredential_ReadsAsConnected - // above, which reads the SAME node with the production budget and finds it — so this is a - // statement about the failed read, not about the node being missing. - var failed = await NewAuth(TimeSpan.FromTicks(1)).GetConnection(ConnectedUser) - .Should().Within(TestTimeouts.Convergence).Emit(); - var absent = await NewAuth().GetConnection(NeverConnectedUser) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Output.WriteLine($"failed-read verdict: {failed}"); - Output.WriteLine($"absent-credential verdict: {absent}"); - - failed.Connection.Should().Be(EaConnection.Undetermined, - "a read that did not complete tells us nothing about the user's connection state"); - failed.Diagnostic.Should().NotBeNullOrEmpty( - "an undetermined answer with nothing to say is indistinguishable from the swallow this " - + "fix removes — the bare `catch { return null; }` logged nothing at all"); - - failed.Connection.Should().NotBe(absent.Connection, - "THIS is #3433: the failed read and the absent credential used to be the same value, so " - + "a user whose grant was stored and valid was told to connect their mailbox again"); - } - - /// - /// The seam is reactive, and stays reactive. A Task-returning member on - /// is what FORCED the await at the hub-side call site — a caller - /// cannot consume a Task<T> any other way — so the three reactive entry points are - /// pinned here by reflection rather than trusted to review. - /// - /// The three retiring …Async forwarders are now asserted ABSENT. They existed for - /// the duration of the MeshWeaver.Plugins migration, that migration landed - /// (MeshWeaver.Plugins#1416), and the deleting half removed them. Absence is asserted here - /// rather than left to the ratchet because the two say different things: the ratchet counts - /// debt and would be satisfied by a re-added member that someone also allow-listed, while this - /// says the SHAPE may not come back. Re-adding a Task-returning member to this seam is how - /// #3433 recurs. - /// - // ── A refused grant is a finding, an outage is not (MeshWeaver.Plugins#1615) ───────────────── - - /// - /// Answers the code exchange with a fresh grant and every refresh-token redemption with the - /// scripted status/body, counting calls — so "the token endpoint was not asked again" is a - /// measured number. - /// - private sealed class ScriptedTokenEndpoint(System.Net.HttpStatusCode refreshStatus, string refreshBody) : HttpMessageHandler - { - private int refreshCalls; - public int RefreshCalls => Volatile.Read(ref refreshCalls); - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var form = await request.Content!.ReadAsStringAsync(cancellationToken); - if (form.Contains("grant_type=authorization_code", StringComparison.Ordinal)) - return new HttpResponseMessage(System.Net.HttpStatusCode.OK) - { - Content = new StringContent("""{"access_token":"at-1","refresh_token":"rt-1","expires_in":3600}"""), - }; - Interlocked.Increment(ref refreshCalls); - return new HttpResponseMessage(refreshStatus) { Content = new StringContent(refreshBody) }; - } - } - - private const string InvalidGrantBody = - """{"error":"invalid_grant","error_description":"AADSTS70000: The user or administrator has not consented to use the application.\r\nTrace ID: t\r\nCorrelation ID: c"}"""; - - /// - /// 🚨 THE STRANDING, pinned. A grant consented for THIS build's scopes that Entra then refuses - /// (revoked, expired, password changed, consent withdrawn) used to read as "could not determine - /// … ask me again in a moment" — forever, because the next call redeemed the same dead token and - /// got the same 400 — while the consent controller, whose read never asks the token endpoint, - /// saw a stored credential and bounced the reconnect without a dialog. Now the refusal is a - /// finding: NotConnected (the consent link), the credential is stamped so BOTH readers agree, - /// and the dead token is not offered again. - /// - [Fact] - public async Task AGrantEntraRefuses_ReadsAsNotConnected_IsStamped_AndIsNotOfferedAgain() - { - const string user = "ea-refused-grant-user"; - var endpoint = new ScriptedTokenEndpoint(System.Net.HttpStatusCode.BadRequest, InvalidGrantBody); - var auth = NewAuth(tokenEndpoint: endpoint); - - (await auth.ExchangeAndStore("code", "https://portal.test/auth/ea/callback", user) - .Should().Within(TestTimeouts.Convergence).Emit()).Should().BeTrue("the consent stores a current-scope grant"); - (await auth.GetConnection(user).Should().Within(TestTimeouts.Convergence).Emit()) - .Connection.Should().Be(EaConnection.Connected, "before any redemption the stored grant reads as connected"); - - var refused = await auth.GetAccessToken(user).Should().Within(TestTimeouts.Convergence).Emit(); - - refused.Connection.Should().Be(EaConnection.NotConnected, - "Entra ANSWERED, and the answer names the grant — consent is the remedy, not a retry"); - refused.AccessToken.Should().BeNull(); - refused.Diagnostic.Should().Contain("invalid_grant").And.Contain("AADSTS70000") - .And.Contain("consented again", "the sentence must say reconnect, never 'you never connected'"); - endpoint.RefreshCalls.Should().Be(1); - - // The controller's read — GetConnection never asks the token endpoint — agrees, because the - // refusal was stamped on the credential: the reconnect link now runs the dialog. The stamp - // is a write through the node stream; its echo reaches the mirror a later read snapshots - // moments after the write is acknowledged, so this WAITS for the stamped reading (the - // request/response re-query shape) rather than asserting on one read — CI read Connected - // once, a few ms after the write, on a snapshot the echo had not reached yet. - var controllerRead = await Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .SelectMany(_ => auth.GetConnection(user)) - .Where(read => read.Connection == EaConnection.NotConnected) - .FirstAsync() - .Should().Within(TestTimeouts.Convergence).Emit( - "a reconnect that bounces a 'connected' user back without a dialog is the loop this stamp ends"); - controllerRead.Diagnostic.Should().Contain("refused the stored grant"); - - (await auth.GetAccessToken(user).Should().Within(TestTimeouts.Convergence).Emit()) - .Connection.Should().Be(EaConnection.NotConnected); - endpoint.RefreshCalls.Should().Be(1, "a grant Entra has refused is not redeemed again — the stamp answers first"); - } - - /// - /// The other half of #3433, kept: a token endpoint that is DOWN says nothing about the grant. - /// The answer stays Undetermined (no consent link), the credential is not stamped, the - /// controller's read still says Connected, and the next call asks again. - /// - [Fact] - public async Task ATokenEndpointOutage_StaysUndetermined_AndTheGrantIsNotStamped() - { - const string user = "ea-outage-user"; - var endpoint = new ScriptedTokenEndpoint(System.Net.HttpStatusCode.ServiceUnavailable, "503 Service Unavailable"); - var auth = NewAuth(tokenEndpoint: endpoint); - (await auth.ExchangeAndStore("code", "https://portal.test/auth/ea/callback", user) - .Should().Within(TestTimeouts.Convergence).Emit()).Should().BeTrue(); - - var outage = await auth.GetAccessToken(user).Should().Within(TestTimeouts.Convergence).Emit(); - - outage.Connection.Should().Be(EaConnection.Undetermined, "a 503 is a failure to find out, not a finding"); - outage.Diagnostic.Should().Contain("503"); - (await auth.GetConnection(user).Should().Within(TestTimeouts.Convergence).Emit()) - .Connection.Should().Be(EaConnection.Connected, "nothing was learned about the grant, so nothing was stamped"); - await auth.GetAccessToken(user).Should().Within(TestTimeouts.Convergence).Emit(); - endpoint.RefreshCalls.Should().Be(2, "an outage is retried on the next call — that is what 'ask me again in a moment' promises"); - } - - /// The classifier, without a network: which answers name the grant and which do not. - [Theory] - [InlineData(400, """{"error":"invalid_grant","error_description":"AADSTS70008: expired"}""", true, "invalid_grant: AADSTS70008: expired")] - [InlineData(400, """{"error":"interaction_required","error_description":"AADSTS50076: MFA"}""", true, "interaction_required: AADSTS50076: MFA")] - [InlineData(400, """{"error":"consent_required"}""", true, "consent_required")] - [InlineData(400, """{"error":"invalid_request","error_description":"AADSTS90014: missing field"}""", false, "invalid_request: AADSTS90014: missing field")] - [InlineData(401, """{"error":"invalid_client","error_description":"AADSTS7000215: bad secret"}""", false, "invalid_client: AADSTS7000215: bad secret")] - [InlineData(429, "", false, "HTTP 429")] - [InlineData(503, "down", false, "HTTP 503")] - [InlineData(400, "[1,2]", false, "HTTP 400")] - public void TheRefusalClassifier_NamesTheGrantOnlyWhenEntraDid(int status, string body, bool grantRefused, string summary) - { - var refusal = TokenRefusal.Of(status, body); - - refusal.GrantRefused.Should().Be(grantRefused); - refusal.Summary.Should().Be(summary); - } - - [Fact] - public void TheSeamsPrimarySurfaceIsReactive() - { - foreach (var name in new[] - { - nameof(IEaGraphAuth.GetConnection), - nameof(IEaGraphAuth.GetAccessToken), - nameof(IEaGraphAuth.ExchangeAndStore), - nameof(IEaGraphAuth.GetMailboxes), - nameof(IEaGraphAuth.RemoveMailbox), - }) - // Every OVERLOAD — the per-mailbox ones (2026-10-13) are hub-facing exactly as the originals. - foreach (var method in typeof(IEaGraphAuth).GetMethods().Where(m => m.Name == name)) - { - method.ReturnType.IsGenericType.Should().BeTrue(); - method.ReturnType.GetGenericTypeDefinition().Should().Be(typeof(IObservable<>), - $"{name} is consumed from an agent round on a hub, where a Task can only be " - + "consumed by awaiting and awaiting parks the turn (#3433)"); - } - - // The retiring forwarders are GONE, and may not come back. MeshWeaver.Plugins#1416 was the - // migrating half; this repository's deleting half followed it, which is the ordering the - // cross-repo pair gate enforces. - foreach (var name in new[] { "GetAccessTokenAsync", "IsConnectedAsync", "ExchangeAndStoreAsync" }) - typeof(IEaGraphAuth).GetMethod(name).Should().BeNull( - $"{name} was a Task-shaped forwarder over the reactive surface, deleted once " - + "MeshWeaver.Plugins stopped calling it. A Task on this seam does not merely " - + "permit an await at a hub-side call site, it FORCES one — which is #3433 itself"); - - // …and no member may reintroduce the SHAPE under another name. Not "everything returns a - // stream": IsConfigured, ConnectPath and BuildConsentUrl are synchronous and pure, and a - // pure member is not a hub hazard. The hazard is specifically a Task, because a Task has - // exactly one consumption idiom and so FORCES the await at a hub-side call site. - typeof(IEaGraphAuth).GetMethods() - .Where(m => m.DeclaringType == typeof(IEaGraphAuth)) - .Select(m => m.ReturnType) - .Should().OnlyContain( - t => t != typeof(Task) - && t != typeof(ValueTask) - && !(t.IsGenericType - && (t.GetGenericTypeDefinition() == typeof(Task<>) - || t.GetGenericTypeDefinition() == typeof(ValueTask<>))), - "a Task-shaped member on this seam is #3433 under a new name — the await it forces " - + "parks the very hub turn the agent round is running on"); - } -} diff --git a/test/Memex.Portal.Shared.Test/EaMailboxesTest.cs b/test/Memex.Portal.Shared.Test/EaMailboxesTest.cs deleted file mode 100644 index cc22954d6c..0000000000 --- a/test/Memex.Portal.Shared.Test/EaMailboxesTest.cs +++ /dev/null @@ -1,260 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.AI; // ProviderKeyProtector -using MeshWeaver.Data; // GetMeshNodeStream -using MeshWeaver.Fixture; // TestTimeouts.Convergence -using MeshWeaver.Graph.Configuration; // AddEaCredentialType -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; // IMasterKeyProvider -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Several Microsoft 365 mailboxes per person (2026-10-13): the account-id rules, the OAuth state -/// that carries the account across the consent round trip, the address read off the id token, and -/// — on a real monolith mesh — that an added mailbox is stored beside the primary, listed with it, -/// de-duplicated by address, and removed without touching the other. -/// -public class EaMailboxesTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - // ── Pure rules ─────────────────────────────────────────────────────────────────────────────── - - [Fact] - public void NewAccountIds_AreWellFormed_AndNeverRepeat() - { - var ids = Enumerable.Range(0, 200).Select(_ => EaMailboxes.NewAccountId()).ToList(); - ids.Should().OnlyContain(id => EaMailboxes.IsAddedAccountId(id)); - ids.Distinct().Should().HaveCount(ids.Count); - } - - [Theory] - [InlineData(null, true)] - [InlineData("", true)] - [InlineData("primary", true)] - [InlineData("new", true)] - [InlineData("ea-credential--0123456789", true)] - [InlineData("ea-credential--0123456789/../x", false)] - [InlineData("ea-credential--ABCDEF0123", false)] - [InlineData("ea-credential--012345678", false)] - [InlineData("someone-else", false)] - public void OnlyThePrimary_New_OrAWellFormedId_IsAValidRequest(string? account, bool valid) => - EaMailboxes.IsValidRequest(account).Should().Be(valid); - - private static EaMailbox Box(string id, string? address, EaConnection state) => - new(id, address, new EaGraphAccess(state)); - - [Fact] - public void TheDefaultMailbox_IsTheConnectedPrimary_ElseTheFirstConnected() - { - var work = Box(EaMailboxes.Primary, "max@fabrikam.example", EaConnection.Connected); - var home = Box("ea-credential--aaaaaaaaaa", "max@acme.example", EaConnection.Connected); - EaMailboxes.Select([work, home], null).Should().Be(work); - - var refusedPrimary = work with { Access = EaGraphAccess.NotConnected("refused") }; - EaMailboxes.Select([refusedPrimary, home], null).Should().Be(home, - "a primary that cannot serve must not hide a mailbox that can"); - EaMailboxes.Select([refusedPrimary], null).Should().Be(refusedPrimary, - "with nothing connected the primary is still returned, so the caller can say why"); - EaMailboxes.Select(ImmutableList.Empty, null).Should().BeNull(); - } - - [Fact] - public void AMailboxIsAddressed_ByAddress_CaseInsensitively_OrByAccountId() - { - var work = Box(EaMailboxes.Primary, "max@fabrikam.example", EaConnection.Connected); - var home = Box("ea-credential--aaaaaaaaaa", "max@acme.example", EaConnection.Connected); - EaMailboxes.Select([work, home], "MAX@Acme.example").Should().Be(home); - EaMailboxes.Select([work, home], "ea-credential--aaaaaaaaaa").Should().Be(home); - EaMailboxes.Select([work, home], "primary").Should().Be(work); - EaMailboxes.Select([work, home], "jane@initech.example").Should().BeNull(); - } - - [Fact] - public void ANewConsent_ReusesTheMailboxWithTheSameAddress_ElseFillsAnEmptyPrimary_ElseMintsAnId() - { - var work = Box(EaMailboxes.Primary, "max@fabrikam.example", EaConnection.Connected); - EaGraphAuth.ChooseNewTarget([], "max@fabrikam.example").Should().Be(EaMailboxes.Primary); - EaGraphAuth.ChooseNewTarget([work], "MAX@fabrikam.example").Should().Be(EaMailboxes.Primary); - EaGraphAuth.ChooseNewTarget([work], "max@acme.example").Should().StartWith(EaMailboxes.AddedPrefix); - EaGraphAuth.ChooseNewTarget([work], null).Should().StartWith(EaMailboxes.AddedPrefix, - "without an address there is nothing to de-duplicate on, and the primary is taken"); - } - - [Fact] - public void TheState_CarriesTheAccount_AndAnOldStateStillMeansThePrimary() - { - var state = EaConsentController.StateFor(EaMailboxes.New, "/rbuergi/Settings?tab=x"); - EaConsentController.ParseState(state).Should().Be((EaMailboxes.New, "/rbuergi/Settings?tab=x")); - - var legacy = EaConsentController.StateFor(EaMailboxes.Primary, "/inbox"); - legacy.Should().NotStartWith(EaConsentController.AccountStatePrefix, "the primary keeps the pre-existing shape"); - EaConsentController.ParseState(legacy).Should().Be((EaMailboxes.Primary, "/inbox")); - - EaConsentController.ParseState("acct:../../Admin:%2Finbox").Account.Should().Be(EaMailboxes.Primary, - "a state that left our process never names a path"); - } - - private static string IdToken(string claimsJson) - { - static string B64(string s) => Convert.ToBase64String(Encoding.UTF8.GetBytes(s)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - return $"{B64("""{"alg":"none"}""")}.{B64(claimsJson)}.sig"; - } - - [Fact] - public void TheSignedInAddress_IsReadOffTheIdToken() - { - EaGraphAuth.SignedInAddress($$"""{"refresh_token":"r","id_token":"{{IdToken("""{"preferred_username":"max@fabrikam.example","name":"Max"}""")}}"}""") - .Should().Be("max@fabrikam.example"); - EaGraphAuth.SignedInAddress($$"""{"id_token":"{{IdToken("""{"preferred_username":"Max","email":"max@acme.example"}""")}}"}""") - .Should().Be("max@acme.example", "a username that is not an address falls through to email"); - EaGraphAuth.SignedInAddress("""{"refresh_token":"r"}""").Should().BeNull(); - EaGraphAuth.SignedInAddress("""{"id_token":"not-a-jwt"}""").Should().BeNull(); - } - - [Fact] - public void AddingAMailbox_ShowsTheAccountPicker_AndAReconnectHintsTheAddress() - { - using var http = new HttpClient(); - var ea = NewAuth(http); - var add = ea.BuildConsentUrl("s", "https://memex.example/auth/ea/callback", loginHint: null, selectAccount: true); - add.Should().Contain("prompt=select_account").And.NotContain("prompt=consent").And.NotContain("login_hint"); - add.Should().Contain(Uri.EscapeDataString("openid profile email")); - - var reconnect = ea.BuildConsentUrl("s", "https://memex.example/auth/ea/callback", "max@acme.example", selectAccount: false); - reconnect.Should().Contain("login_hint=max%40acme.example").And.NotContain("prompt="); - } - - // ── On a real mesh ─────────────────────────────────────────────────────────────────────────── - - private sealed class FixedMasterKey : IMasterKeyProvider - { - private readonly byte[] key = new byte[32]; - public byte[]? GetMasterKey() => key; - } - - /// Answers every code exchange with a grant for the NEXT scripted address. - private sealed class AddressTokenEndpoint(params string[] addresses) : HttpMessageHandler - { - private int next; - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var address = addresses[Math.Min(Interlocked.Increment(ref next) - 1, addresses.Length - 1)]; - var body = $$"""{"access_token":"at","refresh_token":"rt-{{address}}","id_token":"{{IdToken($$"""{"preferred_username":"{{address}}"}""")}}"}"""; - return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(body) }); - } - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddEaCredentialType(); - - private EaGraphAuth NewAuth(HttpClient http) => new( - Mesh.ServiceProvider, - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - ["Authentication:Microsoft:ClientId"] = "test-client", - ["Authentication:Microsoft:ClientSecret"] = "test-secret", - }).Build(), - new ProviderKeyProtector(new FixedMasterKey()), - http, - Mesh.ServiceProvider.GetRequiredService>()); - - private static IObservable> Until( - EaGraphAuth auth, string user, Func, bool> done) => - // The query index follows a write; wait for the listing to reflect it rather than reading once. - Observable.Interval(TimeSpan.FromMilliseconds(100)).StartWith(0L) - .SelectMany(_ => auth.GetMailboxes(user)) - .Where(done) - .FirstAsync(); - - [Fact] - public async Task ASecondMailbox_IsStoredBesideThePrimary_ListedWithIt_AndDeduplicatedByAddress() - { - const string user = "ea-two-mailboxes-user"; - using var http = new HttpClient(new AddressTokenEndpoint( - "max@fabrikam.example", "max@acme.example", "MAX@acme.example")); - var auth = NewAuth(http); - const string callback = "https://portal.test/auth/ea/callback"; - - (await auth.ExchangeAndStore("c1", callback, user).Should().Within(TestTimeouts.Convergence).Emit()).Should().BeTrue(); - (await auth.ExchangeAndStore("c2", callback, user, EaMailboxes.New).Should().Within(TestTimeouts.Convergence).Emit()).Should().BeTrue(); - - var two = await Until(auth, user, l => l.Count == 2).Should().Within(TestTimeouts.Convergence).Emit(); - two[0].IsPrimary.Should().BeTrue(); - two[0].Address.Should().Be("max@fabrikam.example"); - two[1].AccountId.Should().StartWith(EaMailboxes.AddedPrefix); - two[1].Address.Should().Be("max@acme.example"); - two.Should().OnlyContain(m => m.Access.Connection == EaConnection.Connected); - - // Adding the SAME address again reconnects that mailbox instead of adding a third. - (await auth.ExchangeAndStore("c3", callback, user, EaMailboxes.New).Should().Within(TestTimeouts.Convergence).Emit()).Should().BeTrue(); - await Task.Delay(500); - var after = await auth.GetMailboxes(user).Should().Within(TestTimeouts.Convergence).Emit(); - string.Join(",", after.Select(m => m.AccountId)).Should().Be(string.Join(",", two.Select(m => m.AccountId))); - - // The legacy entry points still address the primary. - (await auth.GetConnection(user).Should().Within(TestTimeouts.Convergence).Emit()).IsConnected.Should().BeTrue(); - } - - [Fact] - public async Task RemovingThePrimary_KeepsTheAddedMailbox_AndRemovingTheAddedOneDeletesIt() - { - const string user = "ea-remove-mailboxes-user"; - using var http = new HttpClient(new AddressTokenEndpoint("max@fabrikam.example", "max@acme.example")); - var auth = NewAuth(http); - const string callback = "https://portal.test/auth/ea/callback"; - (await auth.ExchangeAndStore("c1", callback, user).Should().Within(TestTimeouts.Convergence).Emit()).Should().BeTrue(); - (await auth.ExchangeAndStore("c2", callback, user, EaMailboxes.New).Should().Within(TestTimeouts.Convergence).Emit()).Should().BeTrue(); - var added = (await Until(auth, user, l => l.Count == 2).Should().Within(TestTimeouts.Convergence).Emit())[1]; - - (await auth.RemoveMailbox(user, EaMailboxes.Primary).Should().Within(TestTimeouts.Convergence).Emit()).Should().BeTrue(); - var left = await Until(auth, user, l => l.Count == 1).Should().Within(TestTimeouts.Convergence).Emit(); - left.Single().AccountId.Should().Be(added.AccountId, - "the primary is emptied, never deleted — deleting it would cascade to the mailboxes beneath it"); - (await auth.GetConnection(user).Should().Within(TestTimeouts.Convergence).Emit()) - .Connection.Should().Be(EaConnection.NotConnected); - (await auth.GetAccessToken(user, added.AccountId).Should().Within(TestTimeouts.Convergence).Emit()) - .Connection.Should().NotBe(EaConnection.NotConnected, "the added mailbox's grant is untouched"); - - (await auth.RemoveMailbox(user, added.AccountId).Should().Within(TestTimeouts.Convergence).Emit()).Should().BeTrue(); - await Until(auth, user, l => l.Count == 0).Should().Within(TestTimeouts.Convergence).Emit(); - } - - [Fact] - public async Task TheConnectRoute_WithAccountNew_AlwaysRunsTheConsent_WithTheAccountPicker() - { - var access = new AccessService(); - access.SetContext(new AccessContext { ObjectId = "user-1" }); - using var http = new HttpClient(); - var controller = new EaConsentController(NewAuth(http), access, - Mesh.ServiceProvider.GetRequiredService>()) - { - ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext { Request = { Scheme = "https", Host = new HostString("memex.example") } }, - }, - }; - - var result = await controller.Connect(returnUrl: "/user-1/Settings", account: EaMailboxes.New, ct: CancellationToken.None); - var url = result.Should().BeOfType().Which.Url; - url.Should().Contain("prompt=select_account"); - url.Should().Contain(Uri.EscapeDataString(EaConsentController.StateFor(EaMailboxes.New, "/user-1/Settings"))); - - (await controller.Connect(account: "../x", ct: CancellationToken.None)).Should().BeOfType(); - } -} diff --git a/test/Memex.Portal.Shared.Test/EmailCopiedRecipientsTest.cs b/test/Memex.Portal.Shared.Test/EmailCopiedRecipientsTest.cs deleted file mode 100644 index 70ba5920b5..0000000000 --- a/test/Memex.Portal.Shared.Test/EmailCopiedRecipientsTest.cs +++ /dev/null @@ -1,349 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Memex.Portal.Shared.Email; -using MeshWeaver.Fixture; // TestTimeouts.Convergence -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.Reactive.Assertions; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #3473: IEmailSender took ONE recipient and had no Cc/Bcc, so a mail with copied -/// recipients could not be delivered as the message the sender approved. -/// -/// What went wrong. Every SendEmail overload took a single -/// toAddress, so SendDocumentDispatch.ExportAndSend did the only thing available to -/// it: emails.ToObservable().SelectMany(to => hub.SendEmail(to, …)) — one mail per -/// recipient. A To with seven Cc, the ordinary shape of a business mail, went out as eight separate -/// messages. None of them named the others, so nobody could see who else had it, Reply-All reached -/// only the sender, and the thread split into eight unrelated conversations. Bcc — whose entire -/// definition is "receives the message but is not on its visible header" — had no expressible form -/// at all: sending separately to a blind recipient produces a message whose header does not match -/// what anyone else got. -/// -/// The two arms. shows the -/// fixed shape with numbers, and shows -/// the same four recipients through the old surface — four sends, each blind to the others. That -/// second test is the defect, measured, and it stays green forever: it is what a single-address -/// sender still does, which is exactly why -/// makes it -/// unreachable by accident. -/// -public class EmailCopiedRecipientsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Primary = "client@example.com"; - private const string CopiedOne = "colleague@example.com"; - private const string CopiedTwo = "manager@example.com"; - private const string Blind = "archive@example.com"; - - private readonly EnvelopeRecordingSender envelopeSender = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .ConfigureServices(services => services.AddSingleton(envelopeSender)); - - private static EmailMessage BusinessMail() => new() - { - To = [Primary], - Cc = [CopiedOne, CopiedTwo], - Bcc = [Blind], - Subject = "Q3 review deck", - HtmlBody = "

Attached.

", - }; - - // ── The fix, with numbers ──────────────────────────────────────────────────────────────────── - - /// - /// 🚨 ONE message, four recipients — and the visibility is right in both directions. - /// - /// The counts are the assertion: 1 send carrying 4 recipients, of whom - /// 3 can see each other and 1 cannot be seen. On the old surface the same mail was - /// 4 sends carrying 1 recipient each, with 0 able to see anyone — see - /// . - /// - [Fact] - public async Task AMessageWithCcAndBcc_IsDeliveredAsOneMessage() - { - var sent = await Mesh.SendEmail(BusinessMail()) - .Should().Within(TestTimeouts.Convergence).Emit(); - - sent.Should().BeTrue(); - - var envelopes = envelopeSender.Sent; - Output.WriteLine($"sends: {envelopes.Count}"); - foreach (var envelope in envelopes) - Output.WriteLine( - $" to=[{string.Join(", ", envelope.To)}] cc=[{string.Join(", ", envelope.Cc)}] " - + $"bcc=[{string.Join(", ", envelope.Bcc)}]"); - - envelopes.Should().HaveCount(1, - "a message with copied recipients IS one message; sending it once per recipient " - + "delivers a different mail than the one the sender approved"); - - var only = envelopes[0]; - only.AllRecipients.Should().HaveCount(4); - - // Where they SHOULD see each other. - only.VisibleRecipients.Should().Equal([Primary, CopiedOne, CopiedTwo], - "To and Cc are the visible header — that mutual visibility is the whole point of a Cc, " - + "and it is precisely what N separate mails cannot express"); - - // Where they should NOT. - only.VisibleRecipients.Should().NotContain(Blind, - "a blind-copied recipient appearing on the visible header is the disclosure Bcc exists " - + "to prevent"); - only.Bcc.Should().Equal([Blind]); - } - - /// - /// The defect, measured on the surface that produced it. Four recipients through the - /// single-address surface become FOUR mails, and each one's visible header names exactly one - /// person — so no recipient learns that any other received it, and Reply-All reaches nobody. - /// - /// This is not a regression test for the fix; it is the CONTROL that gives the numbers in - /// their meaning. It stays green, - /// because a single-address sender still behaves this way — which is why the contract now - /// refuses to route a copied message into it. - /// - [Fact] - public async Task TheOldPerRecipientShape_SplitsOneMailIntoFour() - { - var recorder = new SingleAddressRecordingSender(); - IEmailSender legacy = recorder; - var everyone = new[] { Primary, CopiedOne, CopiedTwo, Blind }; - - // Verbatim the shape SendDocumentDispatch.SendToAll used. - var results = await everyone.ToObservable() - .SelectMany(to => legacy.SendEmail(to, "Q3 review deck", "

Attached.

")) - .ToList() - .Should().Within(TestTimeouts.Convergence).Emit(); - - results.Should().HaveCount(4); - recorder.Sent.Should().HaveCount(4, - "one mail per recipient — four messages where the sender composed one"); - - foreach (var (to, _, _) in recorder.Sent) - Output.WriteLine($" separate mail to {to}"); - - foreach (var address in everyone) - recorder.Sent.Should().Contain(s => s.To == address, - "each recipient got their OWN mail — that is the four-way split #3473 names"); - - recorder.Sent.Should().OnlyContain(s => everyone.Contains(s.To), - "each mail's visible header names ONE address, so no recipient can see any other, " - + "and Bcc has no expressible form here at all"); - } - - // ── Refusing, never dropping ───────────────────────────────────────────────────────────────── - - /// - /// 🚨 A sender that cannot carry copied recipients REFUSES the message; it does not quietly - /// deliver a smaller one. - /// - /// Two assertions. The fault is the first — the caller who asked for a Cc learns it did - /// not happen. Zero recorded sends is the second, and it is the one that discriminates: relax - /// the default to "forward the To and drop the rest" and this records one send whose Cc has - /// silently vanished, which is the same class of defect as stamping an undelivered mail - /// Sent (#2023). - /// - [Fact] - public async Task ASingleAddressSender_RefusesCopiedRecipients_RatherThanDroppingThem() - { - var recorder = new SingleAddressRecordingSender(); - IEmailSender legacy = recorder; - - var notification = await legacy.SendEmail(BusinessMail()).Materialize() - .Should().Within(TestTimeouts.Convergence).Emit(); - - Output.WriteLine($"refusal: {notification.Kind}: {notification.Exception?.Message}"); - - notification.Kind.Should().Be(NotificationKind.OnError, - "a Cc that cannot be honoured is a refusal, not a smaller success"); - notification.Exception.Should().BeOfType(); - notification.Exception!.Message.Should().Contain("Cc: 2"); - notification.Exception!.Message.Should().Contain("Bcc: 1", - "the refusal names what could not be carried, so an operator can act on it"); - - recorder.Sent.Should().BeEmpty( - "NOTHING may go out — a mail delivered to fewer people than the sender addressed is " - + "the silent data loss this refusal exists to prevent"); - } - - /// - /// The additive proof: a message that FITS a single-address sender still reaches one, through - /// the legacy overload, with its delivery identity intact. Without this the refusal above could - /// "pass" by refusing everything and breaking every existing caller. - /// - [Fact] - public async Task ASingleRecipientMessage_StillReachesAnOldSender() - { - var recorder = new SingleAddressRecordingSender(); - IEmailSender legacy = recorder; - var identity = EmailDelivery.AsSharedMailboxReplyingTo("author@example.com"); - - var sent = await legacy.SendEmail(new EmailMessage - { - To = [Primary], - Subject = "One recipient", - HtmlBody = "

Hi.

", - Delivery = identity, - }).Should().Within(TestTimeouts.Convergence).Emit(); - - sent.Should().BeTrue(); - recorder.Sent.Should().HaveCount(1); - recorder.Sent[0].To.Should().Be(Primary); - recorder.Sent[0].Delivery.ReplyToAddress.Should().Be("author@example.com", - "the sender identity travels with the message, not beside it"); - } - - /// A message nobody would receive is refused rather than reported as sent. - [Fact] - public async Task AMessageWithNoRecipients_IsRefused() - { - var recorder = new SingleAddressRecordingSender(); - IEmailSender legacy = recorder; - - var notification = await legacy - .SendEmail(new EmailMessage { Subject = "Nobody", HtmlBody = "

" }) - .Materialize().Should().Within(TestTimeouts.Convergence).Emit(); - - notification.Kind.Should().Be(NotificationKind.OnError); - notification.Exception!.Message.Should().Contain("no recipients"); - recorder.Sent.Should().BeEmpty(); - } - - // ── The no-op sender carries the whole envelope ─────────────────────────────────────────────── - - ///

- /// The host's ACCEPTS copied recipients when mail is switched off. - /// It delivers nothing to anybody and says so, so there is no smaller message for it to deliver - /// silently — and refusing here would break every local-dev and test send that carries a Cc, - /// while protecting a delivery that is not happening either way. - /// - [Fact] - public async Task TheNoOpSender_AcceptsCopiedRecipients_WhenMailIsOff() - { - var noOp = new NoOpEmailSender(new EmailOptions { Enabled = false }); - - var sent = await noOp.SendEmail(BusinessMail()) - .Should().Within(TestTimeouts.Convergence).Emit(); - - sent.Should().BeTrue("mail is off; nothing is claimed to have been delivered to anyone"); - } - - /// - /// …and still REFUSES the whole envelope on a misconfigured install (#2023), rather than - /// stamping a four-recipient mail delivered while nothing left the process. - /// - [Fact] - public async Task TheNoOpSender_RefusesTheWholeEnvelope_WhenMailClaimsToBeOn() - { - var noOp = new NoOpEmailSender(new EmailOptions { Enabled = true }); - - var notification = await noOp.SendEmail(BusinessMail()).Materialize() - .Should().Within(TestTimeouts.Convergence).Emit(); - - notification.Kind.Should().Be(NotificationKind.OnError, - "a mail-enabled install with nothing to send with must fail visibly, copies included"); - } - - // ── The envelope's own rules ───────────────────────────────────────────────────────────────── - - /// - /// Visible recipients are To then Cc, de-duplicated case-insensitively — and never Bcc. - /// Pinned separately because this is the property a UI renders as "who will see this". - /// - [Fact] - public void VisibleRecipients_AreToThenCc_DeduplicatedAndNeverBlind() - { - var message = new EmailMessage - { - To = [Primary, "CLIENT@example.com"], - Cc = [CopiedOne], - Bcc = [Blind], - Subject = "s", - HtmlBody = "b", - }; - - message.VisibleRecipients.Should().Equal([Primary, CopiedOne]); - message.AllRecipients.Should().Equal([Primary, CopiedOne, Blind]); - message.HasCopiedRecipients.Should().BeTrue(); - message.FitsASingleAddressSender.Should().BeFalse(); - } - - /// The simple case is still simple, and still fits every sender ever written. - [Fact] - public void AddressedTo_OneRecipient_FitsASingleAddressSender() - { - var message = EmailMessage.Addressed(Primary, "s", "b"); - - message.FitsASingleAddressSender.Should().BeTrue(); - message.HasCopiedRecipients.Should().BeFalse(); - message.VisibleRecipients.Should().Equal([Primary]); - } - - // ── Senders ────────────────────────────────────────────────────────────────────────────────── - - /// Records the whole envelope of every send — a real sender, not a mock. - private sealed class EnvelopeRecordingSender : IEmailSender - { - private readonly ConcurrentQueue sent = new(); - - public IReadOnlyList Sent => [.. sent]; - - public IObservable SendEmail(EmailMessage message) - { - sent.Enqueue(message); - return Observable.Return(true); - } - - public IObservable SendEmail(string toAddress, string subject, string htmlBody) - => SendEmail(EmailMessage.Addressed(toAddress, subject, htmlBody)); - - public IObservable SendEmail( - string toAddress, string subject, string htmlBody, - IReadOnlyCollection attachments) - => SendEmail(EmailMessage.Addressed(toAddress, subject, htmlBody) with - { - Attachments = attachments, - }); - } - - /// - /// A sender written against the PRE-#3473 contract: one address per send, no envelope member. - /// The absence of SendEmail(EmailMessage) is the assertion — it is what makes the - /// interface's default refusal the thing under test. - /// - private sealed class SingleAddressRecordingSender : IEmailSender - { - private readonly ConcurrentQueue<(string To, string Subject, EmailDelivery Delivery)> sent = new(); - - public IReadOnlyList<(string To, string Subject, EmailDelivery Delivery)> Sent => [.. sent]; - - public IObservable SendEmail(string toAddress, string subject, string htmlBody) - => SendEmail(toAddress, subject, htmlBody, [], EmailDelivery.AsSharedMailbox); - - public IObservable SendEmail( - string toAddress, string subject, string htmlBody, - IReadOnlyCollection attachments) - => SendEmail(toAddress, subject, htmlBody, attachments, EmailDelivery.AsSharedMailbox); - - public IObservable SendEmail( - string toAddress, string subject, string htmlBody, - IReadOnlyCollection attachments, EmailDelivery delivery) - { - sent.Enqueue((toAddress, subject, delivery)); - return Observable.Return(true); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/EmailMisconfigurationRefusalTest.cs b/test/Memex.Portal.Shared.Test/EmailMisconfigurationRefusalTest.cs deleted file mode 100644 index 6178fa0283..0000000000 --- a/test/Memex.Portal.Shared.Test/EmailMisconfigurationRefusalTest.cs +++ /dev/null @@ -1,362 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Email; -using MeshWeaver.Mesh; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A half-configured OPTIONAL integration must not be able to stop the portal from -/// starting (#2510). -/// -/// The defect. Email:Enabled=true with Email:TenantId unset crashed the -/// host outright: Hosting failed to start … An exception was thrown while activating -/// GraphEmailSender … ArgumentException: Invalid tenant id provided, and the pod never became -/// ready. Nothing about mail should be able to do that — the blast radius of an unconfigured mail -/// sender is mail. -/// -/// What actually pulled the trigger is the interesting part, and it is why this file -/// sits next to : it was EmailDeliveryGuard, the -/// guard added for #2023 to stop mail being falsely stamped Sent. It ran in -/// IHostedService.StartAsync — where a throw aborts the HOST, not a feature — and its first -/// act was to ask the CONTAINER for an . Resolving it ACTIVATES the -/// module's Graph sender, which builds an Azure ClientSecretCredential in its constructor, -/// which validates the tenant id eagerly. So the guard against silent data loss became a -/// deterministic startup crash: a guard that can fail worse than the thing it guards. -/// -/// The fix, and what these cases pin. The guard now reaches its verdict from the -/// CONFIGURATION first — inert data that cannot throw and cannot activate anything — and only a -/// completely-configured install goes on to ask the container. So both halves have to hold, and -/// each is asserted below: -/// -/// the portal STARTS with the section half-filled, even though the -/// registered sender cannot be constructed at all; and -/// a send attempt on that install REFUSES, naming the missing key — -/// never the silent success that #2023 was about. -/// -/// -/// Both are needed, and neither implies the other: "starts" alone is satisfied by quietly -/// dropping mail, which is the worse bug; "refuses" alone is satisfied by a portal that never -/// starts, which is this one. -/// -public class EmailMisconfigurationRefusalTest -{ - private static readonly TimeSpan TestBudget = TimeSpan.FromSeconds(30); - - /// The section as a deployment that never finished wiring mail leaves it: switched ON, - /// with the client-secret credential blank. This is the memex configuration from the incident. - private static EmailOptions HalfConfigured() => new() - { - Enabled = true, - MailboxAddress = "no-reply@example.test", - // TenantId / ClientId / ClientSecret deliberately unset. - }; - - private static EmailOptions FullyConfigured() => new() - { - Enabled = true, - MailboxAddress = "no-reply@example.test", - TenantId = "00000000-0000-0000-0000-000000000000", - ClientId = "client", - ClientSecret = "secret", - }; - - // ───────────────────────── half 1: the portal starts ───────────────────────── - - /// - /// 🚨 The regression pin, at the level the incident happened: a REAL generic host, running the - /// real mail hosted services, with a sender registered BY TYPE so the container has to - /// construct it — and that constructor throws exactly the way - /// Azure.Identity's does on an unset tenant id. - /// - /// Before the fix this is Hosting failed to start, which in production is a pod - /// that never becomes ready. After it, the host starts: the guard reads the missing key out of - /// the configuration and refuses there, so nothing ever asks the container for the sender. - /// - [Fact] - public async Task HalfConfiguredEmail_TheHostStillStarts_AnOptionalIntegrationCannotAbortStartup() - { - using var cts = new CancellationTokenSource(TestBudget); - using var host = BuildHost(HalfConfigured()); - - // No try/catch and no assertion helper on purpose: the failure mode IS the throw, and the - // xUnit failure then carries the real stack — the same one the pod logged. - await host.StartAsync(cts.Token); - - Assert.False( - SenderProbe.Of(host).WasConstructed, - "the sender must never be CONSTRUCTED on a half-configured install: constructing it is " - + "what threw, and the whole point is that the verdict comes from configuration instead."); - - await host.StopAsync(cts.Token); - } - - /// - /// The control, and it is what stops the case above from being satisfied by a guard that - /// refuses everything: a fully-configured install DOES resolve its sender at startup. That - /// resolution is the #2023 question ("is a real sender actually here?"), which has no - /// configuration-shaped answer and must keep being asked. - /// - [Fact] - public async Task FullyConfiguredEmail_TheSenderIsStillResolvedAtStartup_SoTheModuleAbsentCheckSurvives() - { - using var cts = new CancellationTokenSource(TestBudget); - using var host = BuildHost(FullyConfigured()); - - await host.StartAsync(cts.Token); - - Assert.True( - SenderProbe.Of(host).WasConstructed, - "a complete configuration must still resolve the sender — otherwise #2023's " - + "module-absent refusal would silently stop being checked."); - - await host.StopAsync(cts.Token); - } - - /// - /// Managed identity needs no keys in configuration at all (DefaultAzureCredential takes - /// no tenant id), which is exactly why Email:UseManagedIdentity=true was never hit by - /// the crash. Pinned so the new configuration gate cannot start refusing the production - /// deployment shape — a false refusal here would stop mail on every install that works today. - /// - [Fact] - public async Task ManagedIdentity_NeedsNoCredentialKeys_AndIsNeverRefused() - { - using var cts = new CancellationTokenSource(TestBudget); - var options = new EmailOptions - { - Enabled = true, - UseManagedIdentity = true, - MailboxAddress = "no-reply@example.test", - }; - - Assert.Empty(options.MissingCredentialKeys()); - - using var host = BuildHost(options); - await host.StartAsync(cts.Token); - - Assert.True( - SenderProbe.Of(host).WasConstructed, - "managed identity is a COMPLETE configuration — it must reach the container check."); - - await host.StopAsync(cts.Token); - } - - /// - /// The same verdict one level down, observed on the provider rather than through a host: the - /// guard must not so much as ASK for the sender. Asserting "the host started" alone would also - /// pass if the resolution were merely wrapped in a catch — which is not the fix, because - /// a catch there would swallow real activation failures too. - /// - [Fact] - public async Task HalfConfiguredEmail_TheWatcherNeverEvenAsksTheContainerForASender() - { - var options = HalfConfigured(); - var services = new ServiceCollection() - .AddSingleton() - .AddSingleton(options) - .BuildServiceProvider(); - var recording = new SenderRequestCountingProvider(services); - using var lifetime = new StartableLifetime(); - - var watcher = new OutboundEmailSender(recording, lifetime, options); - await watcher.StartAsync(CancellationToken.None); - lifetime.NotifyStarted(); - - Assert.Equal(0, recording.SenderRequests); - watcher.Dispose(); - } - - // ───────────────────────── half 2: a send refuses ───────────────────────── - - /// - /// 🚨 The other half. A portal that starts and then silently swallows mail is a WORSE outcome - /// than the crash — that is #2023, and it cost an afternoon of inbox archaeology. So on the - /// same half-configured install a send must surface as a failure, and the message must name the - /// key to set: an operator told "email is broken" goes hunting; one told - /// Email:TenantId is done. - /// - [Fact] - public async Task HalfConfiguredEmail_ASendAttemptRefuses_AndNamesTheMissingKey() - { - IEmailSender sender = new NoOpEmailSender(HalfConfigured()); - - var failure = await Assert.ThrowsAsync( - () => sender.SendEmail("x@example.test", "s", "

b

").FirstAsync().Await()); - - Assert.Contains("Email:TenantId", failure.Message, StringComparison.Ordinal); - Assert.Contains("Email:ClientId", failure.Message, StringComparison.Ordinal); - Assert.Contains("Email:ClientSecret", failure.Message, StringComparison.Ordinal); - Assert.Contains("Email:Enabled=true", failure.Message, StringComparison.Ordinal); - } - - /// - /// The refusal must diagnose the RIGHT cause. On this install the module may be present and - /// perfectly healthy — only a credential is missing — so telling the operator to land a module - /// they already have is a confidently wrong answer that sends them somewhere there is nothing - /// to find. - /// - [Fact] - public void TheIncompleteConfigurationRefusal_DoesNotBlameTheModule() - { - var explanation = EmailDeliveryGuard.ExplainRefusal(HalfConfigured(), "The send is refused."); - - Assert.Contains("Email:TenantId", explanation, StringComparison.Ordinal); - Assert.DoesNotContain(EmailDeliveryGuard.SenderModule, explanation, StringComparison.Ordinal); - } - - /// - /// And the converse: a COMPLETE configuration whose sender still cannot deliver is the - /// module-absent case, and there the module name IS the whole diagnosis (#2023). Pinned - /// together with the case above because the failure mode of "one wording for everything" is - /// that each install gets told the other one's cause. - /// - [Fact] - public void TheModuleAbsentRefusal_StillNamesTheModule() - { - var explanation = EmailDeliveryGuard.ExplainRefusal(FullyConfigured(), "The send is refused."); - - Assert.Contains(EmailDeliveryGuard.SenderModule, explanation, StringComparison.Ordinal); - } - - // ───────────────────────── the verdict table ───────────────────────── - - /// - /// What "configured" means, stated once. The client-secret flow needs all three keys; managed - /// identity needs none. Whitespace counts as unset — a Helm value templated from an empty - /// secret arrives as " " and would fail Azure's validator exactly like "". - /// - [Theory] - [InlineData(false, "t", "c", "s", 0)] // client secret, complete - [InlineData(false, "", "c", "s", 1)] // 🚨 the incident: tenant id unset - [InlineData(false, " ", "c", "s", 1)] // whitespace is not a tenant id - [InlineData(false, "", "", "", 3)] // nothing configured at all - [InlineData(true, "", "", "", 0)] // managed identity needs no keys - public void MissingCredentialKeys_NamesExactlyWhatTheSelectedFlowNeeds( - bool useManagedIdentity, string tenantId, string clientId, string clientSecret, int expected) - { - var options = new EmailOptions - { - Enabled = true, - UseManagedIdentity = useManagedIdentity, - TenantId = tenantId, - ClientId = clientId, - ClientSecret = clientSecret, - }; - - Assert.Equal(expected, options.MissingCredentialKeys().Length); - Assert.All(options.MissingCredentialKeys(), - key => Assert.StartsWith($"{EmailOptions.SectionName}:", key, StringComparison.Ordinal)); - } - - /// - /// A DISABLED section is legitimately blank — that is what Email:Enabled=false means, and - /// it is every local dev run and every test host. Refusing it would break all of them, so the - /// guard's verdict is empty regardless of what the keys hold. - /// - [Fact] - public void Disabled_IsNeverRefusedForIncompleteConfiguration() - => Assert.Empty(EmailDeliveryGuard.MissingConfiguration(HalfConfigured() with { Enabled = false })); - - // ───────────────────────── fixtures ───────────────────────── - - /// - /// A host wired the way the portal is: the bound , an - /// registered BY TYPE (so the container must construct it — an - /// already-built instance would never reproduce the defect), and the two mail hosted services - /// whose StartAsync runs the guard. - /// - private static IHost BuildHost(EmailOptions options) - { - var builder = Host.CreateEmptyApplicationBuilder(settings: null); - // Empty: no console provider, so the refusals these cases EXPECT do not read as test noise. - builder.Services.AddLogging(); - builder.Services.AddSingleton(builder.Configuration); - builder.Services.AddSingleton(options); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - return builder.Build(); - } - - /// Records whether the container ever constructed the sender — the outward sign of the - /// activation that used to abort the host. - private sealed class SenderProbe - { - public bool WasConstructed { get; private set; } - - public void Constructed() => WasConstructed = true; - - public static SenderProbe Of(IHost host) => host.Services.GetRequiredService(); - } - - /// - /// Stands in for the module's GraphEmailSender, and faithfully in the one respect that - /// matters: it builds and VALIDATES its credential in the CONSTRUCTOR. That is what - /// new ClientSecretCredential(options.TenantId, …) does — Azure.Identity's - /// Validations.ValidateTenantId was the top frame of the production stack — so activating - /// it on a blank tenant id throws at exactly the point the real one did. - /// - /// Not a mock: it is a real the container constructs by type, - /// which is the whole mechanism under test. Substituting a pre-built instance would pass - /// whether or not the fix is present. - /// - private sealed class CredentialValidatingSender : IEmailSender - { - public CredentialValidatingSender(EmailOptions options, SenderProbe probe) - { - probe.Constructed(); - if (string.IsNullOrWhiteSpace(options.TenantId) && !options.UseManagedIdentity) - throw new ArgumentException("Invalid tenant id provided.", "tenantId"); - } - - public IObservable SendEmail(string toAddress, string subject, string htmlBody) - => Observable.Return(true); - - public IObservable SendEmail( - string toAddress, string subject, string htmlBody, - IReadOnlyCollection attachments) - => Observable.Return(true); - } - - /// Counts how often anything asked the container for an — - /// zero is the assertion that the guard never reached for it. - private sealed class SenderRequestCountingProvider(IServiceProvider inner) : IServiceProvider - { - private int senderRequests; - - public int SenderRequests => Volatile.Read(ref senderRequests); - - public object? GetService(Type serviceType) - { - if (serviceType == typeof(IEmailSender)) - Interlocked.Increment(ref senderRequests); - return inner.GetService(serviceType); - } - } - - /// A lifetime whose can be - /// fired on demand — the real token the watcher registers its watch on. - private sealed class StartableLifetime : IHostApplicationLifetime, IDisposable - { - private readonly CancellationTokenSource started = new(); - - public CancellationToken ApplicationStarted => started.Token; - public CancellationToken ApplicationStopping => CancellationToken.None; - public CancellationToken ApplicationStopped => CancellationToken.None; - public void StopApplication() { } - public void NotifyStarted() => started.Cancel(); - public void Dispose() => started.Dispose(); - } -} diff --git a/test/Memex.Portal.Shared.Test/EmailSendAsCapabilityTest.cs b/test/Memex.Portal.Shared.Test/EmailSendAsCapabilityTest.cs deleted file mode 100644 index 77d7f49c5c..0000000000 --- a/test/Memex.Portal.Shared.Test/EmailSendAsCapabilityTest.cs +++ /dev/null @@ -1,384 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; // TestTimeouts.Convergence -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.Reactive.Assertions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #3450, on a real monolith mesh: IEmailSender.CanSendAsUser had TWO answers for THREE -/// states of the world, and the send dialog rendered the missing one as a lie. -/// -/// What the user saw. The Share ⇒ as email dialog probes "can I send as this person?" -/// when it OPENS. On false it renders ui.sendDocument.connectFirst — "Your -/// Microsoft 365 mailbox is not connected yet" — beside a Connect button. Both consumers wrapped -/// the probe in .Catch(_ => Observable.Return(false)), so a probe that TIMED OUT or FAULTED -/// arrived as the same false as a probe that completed and found no credential. A connected -/// user was told, in a sentence, that they had not connected — and nothing was logged, so the state -/// was not even greppable. This is #3433 one layer down, on the mail seam. -/// -/// What the fix is. answers with -/// three states, and the RENDERING RULE lives on the answer: -/// is true for -/// alone. A consumer cannot re-derive the collapse by writing -/// !IsAvailable, because that expression is not what the UI asks. -/// -/// The positive control that must pass BOTH ways. -/// . The SEND path -/// is a binary decision — send as the person, or ask which mailbox — and folding an unknown to -/// false there is CORRECT: the user is asked rather than assumed. That half was never the -/// bug and must not move. If a "fix" makes it pass, the fix broke the send path. -/// -public class EmailSendAsCapabilityTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string User = "send-as-user-object-id"; - - /// - /// The sender this test's mesh resolves. One instance per test (the base builds a mesh per - /// test), scripted before the probe runs — a real the container - /// hands out, not a mock of a core interface. - /// - private readonly ScriptedEmailSender sender = new(); - - /// Captures what the framework LOGGED, so "greppable" is asserted rather than assumed. - private readonly CapturingLoggerProvider logs = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .ConfigureServices(services => services - .AddSingleton(sender) - .AddLogging(l => l.AddProvider(logs))); - - // ── The three states, and what each one lets the dialog claim ───────────────────────────────── - - /// - /// A connected user. The positive control for everything below: without it a "fix" that - /// answered to everything would pass the defect test and - /// strand every genuinely connected user behind a "we could not check" panel. - /// - [Fact] - public async Task AConnectedUser_IsAvailable_AndTheDialogClaimsNothingAboutConnecting() - { - sender.Answer = _ => Observable.Return(EmailSendAsCapability.Available); - - var capability = await Mesh.ObserveSendAsCapability(User) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Output.WriteLine($"connected verdict: {capability}"); - capability.IsAvailable.Should().BeTrue(); - capability.OffersConnect.Should().BeFalse( - "a connected user must never be shown a Connect panel"); - } - - /// - /// A user who genuinely never connected. This is the ONE state in which - /// ui.sendDocument.connectFirst is a true sentence, and the fix must keep offering it — - /// otherwise it trades a false claim for a missing affordance. - /// - [Fact] - public async Task AUserWhoNeverConnected_IsUnavailable_AndTheDialogOffersConnect() - { - sender.Answer = _ => Observable.Return( - EmailSendAsCapability.Unavailable("no stored delegated credential for this user")); - - var capability = await Mesh.ObserveSendAsCapability(User) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Output.WriteLine($"never-connected verdict: {capability}"); - capability.OffersConnect.Should().BeTrue( - "a completed check with a negative answer is exactly when the Connect offer is truthful"); - capability.IsUndetermined.Should().BeFalse(); - } - - /// - /// 🚨 THE DEFECT, pinned. The probe FAULTS — the shape both consumers swallowed with - /// .Catch(_ => Observable.Return(false)). - /// - /// Three assertions, deliberately separate. The first says what the failed probe IS - /// (); the second says what the dialog may therefore NOT - /// claim; the third says the reason survived, so the state is greppable rather than merely - /// modelled. On the old code the answer was false at both consumers, which - /// would have rendered as the Connect panel — - /// so the second assertion is the one that names the user-visible defect. - /// - [Fact] - public async Task AProbeThatFaults_IsUndetermined_AndTheDialogDoesNotClaimNotConnected() - { - sender.Answer = _ => Observable.Throw( - new TimeoutException("the credential read did not answer within its budget")); - - var capability = await Mesh.ObserveSendAsCapability(User) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Output.WriteLine($"faulted-probe verdict: {capability}"); - - capability.IsUndetermined.Should().BeTrue( - "a probe that faulted produced NO answer about this user's mailbox"); - capability.OffersConnect.Should().BeFalse( - "rendering 'your Microsoft 365 mailbox is not connected yet' here states as fact " - + "something nothing was learned about — that is #3450, and the user it hits is a " - + "CONNECTED one"); - capability.Diagnostic.Should().NotBeNullOrWhiteSpace( - "an undetermined state with nothing to say is indistinguishable from the swallow it " - + "replaces"); - capability.Diagnostic!.Should().Contain(nameof(TimeoutException)); - } - - /// - /// The other way a probe fails to answer: it completes without emitting. A dropped mesh read - /// looks exactly like this, and it must not be read as a negative either. - /// - [Fact] - public async Task AProbeThatNeverAnswers_IsUndetermined() - { - sender.Answer = _ => Observable.Empty(); - - var capability = await Mesh.ObserveSendAsCapability(User) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Output.WriteLine($"silent-probe verdict: {capability}"); - capability.IsUndetermined.Should().BeTrue(); - capability.OffersConnect.Should().BeFalse(); - } - - /// - /// 🚨 THE POSITIVE CONTROL — this must pass with the fix AND without it. - /// - /// At SEND time the question really is binary: send as the person, or show - /// AskWhichMailbox. Folding an unknown to false there is the RIGHT answer, and the - /// code says so — "The send does NOT proceed on an assumption either way." So - /// hub.CanSendAsUser still answers false for an undetermined probe, and the user - /// is asked which mailbox instead of being assumed connected. A change that made this pass - /// would have started sending as a person whose mailbox nobody could confirm. - /// - [Theory] - [InlineData("faulted")] - [InlineData("silent")] - [InlineData("unavailable")] - public async Task AnUndeterminedProbe_StillFoldsToFalse_SoTheSendPathAsksWhichMailbox(string shape) - { - sender.Answer = shape switch - { - "faulted" => _ => Observable.Throw(new TimeoutException("no answer")), - "silent" => _ => Observable.Empty(), - _ => _ => Observable.Return(EmailSendAsCapability.Unavailable("no credential")), - }; - - var canSend = await Mesh.CanSendAsUser(User) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Output.WriteLine($"send-time fold for '{shape}': {canSend}"); - canSend.Should().BeFalse( - "the send path asks which mailbox unless it KNOWS it can send as the person — an " - + "unknown must never become a silent send from someone else's identity"); - } - - /// The other half of the fold: a known-available probe still sends as the person. - [Fact] - public async Task AnAvailableProbe_FoldsToTrue_SoTheSendGoesOutAsThePerson() - { - sender.Answer = _ => Observable.Return(EmailSendAsCapability.Available); - - var canSend = await Mesh.CanSendAsUser(User) - .Should().Within(TestTimeouts.Convergence).Emit(); - - canSend.Should().BeTrue( - "without this the fold could 'pass' the control above by answering false to everything"); - } - - /// - /// 🚨 The probe stays LIVE — the hub extension must not truncate it to one answer. - /// - /// The dialog CombineLatests this stream into the form it renders, so it is a live - /// data-bound view, and .Take(1) on one of those freezes the binding. It is also the - /// mechanism the "Check again" affordance needs: a sender that re-probes has to be able to - /// replace an answer with a real one in place, - /// without the dialog being torn down and rebuilt. - /// - /// Neither operator in the extension needs a single emission — Catch replaces only - /// the tail after a fault, and DefaultIfEmpty fires only on an empty completion — so the - /// truncation would buy nothing and cost the retry. - /// - [Fact] - public async Task TheProbeStaysLive_SoARetryCanReplaceAnUndeterminedAnswerInPlace() - { - sender.Answer = _ => Observable - .Return(EmailSendAsCapability.Unknown("first check did not answer")) - .Concat(Observable.Return(EmailSendAsCapability.Available)); - - var answers = await Mesh.ObserveSendAsCapability(User) - .Take(2).ToList() - .Should().Within(TestTimeouts.Convergence).Emit(); - - foreach (var answer in answers) - Output.WriteLine($"live probe emitted: {answer}"); - - answers.Should().HaveCount(2, - "truncating the probe to one answer freezes the dialog's binding and makes 'Check " - + "again' unable to replace the panel it is offered on"); - answers[0].IsUndetermined.Should().BeTrue(); - answers[1].IsAvailable.Should().BeTrue( - "the second answer must reach the view, in place, without a rebuild"); - } - - // ── The fault is now greppable ─────────────────────────────────────────────────────────────── - - /// - /// #3433 added the Warning that names the user and the diagnostic; #3450's collapse had no log - /// line at all, which is why nobody could tell how often it fired. Pinned here so the routing - /// cannot silently become a swallow again: a fault must be REPORTED as well as modelled. - /// - [Fact] - public async Task AFaultedProbe_IsLoggedAtWarning_NamingTheUser() - { - sender.Answer = _ => Observable.Throw( - new InvalidOperationException("graph transport refused the credential read")); - - await Mesh.ObserveSendAsCapability(User) - .Should().Within(TestTimeouts.Convergence).Emit(); - - var warnings = logs.Entries(LogLevel.Warning); - foreach (var line in warnings) - Output.WriteLine($"warning: {line}"); - - warnings.Should().Contain(line => line.Contains(User, StringComparison.Ordinal), - "the fault must name the user it was asked about, or an operator cannot tell whose " - + "dialog was affected"); - warnings.Should().Contain( - line => line.Contains("graph transport refused the credential read", StringComparison.Ordinal), - "the diagnostic is what makes the third state greppable — it was absent entirely before"); - } - - // ── Additive: an old two-state sender is unchanged ──────────────────────────────────────────── - - /// - /// 🚨 The compatibility proof, and the reason this change needs no cross-repo pair. - /// - /// implements ONLY what required - /// before this change — the shape of every sender in a repository that pins an older core - /// (MeshWeaver.Plugins' GraphEmailSender and its capturing test doubles). It compiles - /// untouched, and it answers exactly what it answered before: no third state appears from - /// nowhere, and no answer changes. - /// - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task AnOldTwoStateSender_KeepsItsExactAnswer(bool canSendAsUser) - { - IEmailSender legacy = new TwoStateSender(canSendAsUser); - - var capability = await legacy.ObserveSendAsCapability(User) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Output.WriteLine($"two-state sender ({canSendAsUser}) verdict: {capability}"); - - capability.IsAvailable.Should().Be(canSendAsUser); - capability.OffersConnect.Should().Be(!canSendAsUser, - "a sender that only speaks yes/no reports its negative as a completed negative — which " - + "is what it has always meant, and all it can mean"); - capability.IsUndetermined.Should().BeFalse( - "the new state must not be invented for a sender that cannot observe it"); - } - - /// - /// A deployment with no sender registered at all cannot send as anybody, and that is a - /// COMPLETED check with a negative answer — not an unknown. Pinned because the tempting reading - /// ("could not resolve, so we do not know") would show every user on a mail-less deployment a - /// permanent "we could not check" panel. - /// - [Fact] - public void NoSenderRegistered_IsAnAnsweredNegative_NotAnUnknown() - { - var capability = EmailSendAsCapability.Unavailable("no sender"); - capability.IsUndetermined.Should().BeFalse(); - capability.OffersConnect.Should().BeTrue(); - } - - /// An unknown with nothing to say is refused at construction, not merely discouraged. - [Fact] - public void AnUnknownWithNoDiagnostic_IsRefused() - => Assert.Throws(() => EmailSendAsCapability.Unknown(" ")); - - // ── Senders ────────────────────────────────────────────────────────────────────────────────── - - /// - /// A real whose three-state probe is scripted per test. It sends - /// nothing — every test here is about the PROBE, and a sender that delivered would only add a - /// mailbox this suite must never touch. - /// - private sealed class ScriptedEmailSender : IEmailSender - { - public Func> Answer { get; set; } = - _ => Observable.Return(EmailSendAsCapability.Available); - - public IObservable ObserveSendAsCapability(string userObjectId) - => Answer(userObjectId); - - public IObservable SendEmail(string toAddress, string subject, string htmlBody) - => Observable.Return(true); - - public IObservable SendEmail( - string toAddress, string subject, string htmlBody, - IReadOnlyCollection attachments) - => Observable.Return(true); - } - - /// - /// A sender written against the PRE-#3450 contract: it knows only - /// . Deliberately does not mention the new member — that - /// absence is the assertion. - /// - private sealed class TwoStateSender(bool canSendAsUser) : IEmailSender - { - public IObservable CanSendAsUser(string userObjectId) - => Observable.Return(canSendAsUser); - - public IObservable SendEmail(string toAddress, string subject, string htmlBody) - => Observable.Return(true); - - public IObservable SendEmail( - string toAddress, string subject, string htmlBody, - IReadOnlyCollection attachments) - => Observable.Return(true); - } - - /// Records formatted log lines per level — instance state, owned by the test. - private sealed class CapturingLoggerProvider : ILoggerProvider - { - private readonly ConcurrentQueue<(LogLevel Level, string Line)> entries = new(); - - public IReadOnlyList Entries(LogLevel level) - { - var matching = new List(); - foreach (var (entryLevel, line) in entries) - if (entryLevel == level) - matching.Add(line); - return matching; - } - - public ILogger CreateLogger(string categoryName) => new Sink(entries); - - public void Dispose() { } - - private sealed class Sink(ConcurrentQueue<(LogLevel, string)> entries) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - => entries.Enqueue((logLevel, formatter(state, exception))); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/EmailStartupConfigurationGuardTest.cs b/test/Memex.Portal.Shared.Test/EmailStartupConfigurationGuardTest.cs deleted file mode 100644 index cf442cda6b..0000000000 --- a/test/Memex.Portal.Shared.Test/EmailStartupConfigurationGuardTest.cs +++ /dev/null @@ -1,396 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Memex.Portal.Shared.Email; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The regression guard for MeshWeaver#2636 / #2637: the memex portal said mail was ON and -/// dropped every message. -/// -/// Email:Enabled=true with Email:TenantId and Email:ClientId unset. The -/// portal started, served, answered /health 200 — and OutboundEmailSender and -/// InvitationEmailSender both refused to start, so every invitation, notification and -/// document share queued as New and stayed there. did -/// exactly its job (nothing was ever falsely stamped Sent), but its whole output is one -/// Error line per host start, and a log line nobody reads is not a signal. The install was -/// half-configured for as long as it took a human to notice mail had not arrived. -/// -/// Both directions are pinned, and neither implies the other. A guard that refuses -/// everything satisfies half of this file and would abort every local dev run and every deployment -/// that never wanted mail — which is #2510, the incident where a half-configured OPTIONAL -/// integration took the whole host down. A guard that refuses nothing satisfies the other half and -/// is the bug being fixed. So: -/// -/// Absent / Enabled=false starts. Blank is what "no mail on this -/// install" looks like; it is never a misconfiguration. -/// Enabled=true and complete starts — both credential flows. -/// Enabled=true and incomplete REFUSES, naming every missing key -/// in the binder form the docs use AND the __ form an operator actually -/// sets. -/// -/// -/// Every case is a pure call on inert configuration data — no host, no container, no -/// credential object. That is not test convenience: it is the property #2510 bought (the verdict -/// must be unable to throw for any reason other than the one it reports), and a test that needed a -/// host to reach it would not be pinning it. -/// -public class EmailStartupConfigurationGuardTest -{ - /// The memex install from the two incident tickets: switched on, the client secret - /// present (the refusal named only the other two), tenant and client id unset. - private static EmailOptions TheIncident() => new() - { - Enabled = true, - MailboxAddress = "memex@example.test", - ClientSecret = "secret", - // TenantId / ClientId deliberately unset — this is the half-configured deployment. - }; - - private static EmailOptions Complete() => new() - { - Enabled = true, - MailboxAddress = "memex@example.test", - TenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47", - ClientId = "client", - ClientSecret = "secret", - }; - - // ───────────── rule 1: never configured must NOT refuse (this is the #2510 half) ───────────── - - /// - /// 🚨 The case that protects against #2510, stated at its weakest point: NOTHING configured at - /// all. An absent section binds to null, and a null section is not a claim — it is every - /// local dev run, every test host and every deployment that never wanted mail. Refusing it - /// would abort hosts that are working perfectly. - /// - [Fact] - public void AnAbsentEmailSection_IsNeverRefused() - { - EmailConfigurationGuard.Validate((EmailOptions?)null); - Assert.Empty(EmailConfigurationGuard.MissingRequiredKeys(null)); - } - - /// - /// Email:Enabled=false is a COMPLETE, supported configuration however blank the rest of - /// the section is — that is precisely what the switch means, and the host registers - /// NoOpEmailSender for it. Asserted over the shapes a real deployment leaves behind: all - /// blank, half-filled (someone started wiring it and turned it off), and fully filled but - /// switched off (mail deliberately parked). - /// - [Theory] - [InlineData("", "", "", "")] - [InlineData("memex@example.test", "", "", "secret")] - [InlineData("memex@example.test", "tenant", "client", "secret")] - public void ADisabledEmailSection_IsNeverRefused_HoweverBlankOrFullItIs( - string mailbox, string tenantId, string clientId, string clientSecret) - { - var options = new EmailOptions - { - Enabled = false, - MailboxAddress = mailbox, - TenantId = tenantId, - ClientId = clientId, - ClientSecret = clientSecret, - }; - - EmailConfigurationGuard.Validate(options); - Assert.Empty(EmailConfigurationGuard.MissingRequiredKeys(options)); - } - - /// - /// The same rule through the seam the portal actually uses — the bound configuration, not a - /// hand-built record. An environment that never set a single Email__* variable must pass - /// the boot guard, and so must one that explicitly switched mail off. - /// - [Theory] - [InlineData(null)] - [InlineData("false")] - [InlineData("False")] - public void ConfigurationWithNoEmailClaim_PassesTheBootGuard(string? enabled) - { - var settings = new Dictionary - { - ["Graph:Storage:Type"] = "FileSystem", - }; - if (enabled is not null) - settings["Email__Enabled"] = enabled; - - EmailConfigurationGuard.Validate(Configuration(settings)); - } - - // ───────────── rule 2: enabled + incomplete REFUSES, by name ───────────── - - /// - /// 🚨 The incident itself: enabled, client secret set, tenant and client id unset. Refused, and - /// the refusal names BOTH missing keys in BOTH forms — the binder form the documentation and - /// the code speak, and the __ form that goes into a Helm value or a configMap. It must - /// NOT name the key that is set: a refusal that lists everything tells an operator nothing. - /// - [Fact] - public void TheIncidentConfiguration_IsRefusedAtStartup_NamingBothMissingKeysInBothForms() - { - var ex = Assert.Throws( - () => EmailConfigurationGuard.Validate(TheIncident())); - - Assert.Contains("Email:TenantId", ex.Message, StringComparison.Ordinal); - Assert.Contains("Email__TenantId", ex.Message, StringComparison.Ordinal); - Assert.Contains("Email:ClientId", ex.Message, StringComparison.Ordinal); - Assert.Contains("Email__ClientId", ex.Message, StringComparison.Ordinal); - Assert.DoesNotContain("Email:ClientSecret", ex.Message, StringComparison.Ordinal); - Assert.DoesNotContain("Email__ClientSecret", ex.Message, StringComparison.Ordinal); - } - - /// - /// Each required key on its own, so the guard is proven to name THE key that is missing rather - /// than a fixed list that happens to contain it. Every one of these is an install that would - /// start, claim mail, and silently deliver nothing. - /// - /// Email:MailboxAddress is in this set although it is not a credential: the system - /// send path is /users/{MailboxAddress}/sendMail, so a blank value composes a Graph - /// request for no mailbox at all. It is required by BOTH flows, which is why it is checked here - /// rather than in . - /// - [Theory] - [InlineData("Email:MailboxAddress")] - [InlineData("Email:TenantId")] - [InlineData("Email:ClientId")] - [InlineData("Email:ClientSecret")] - public void EachRequiredKey_RefusesOnItsOwn_AndTheRefusalNamesThatKey(string key) - { - var options = Blank(Complete(), key); - - var missing = EmailConfigurationGuard.MissingRequiredKeys(options); - Assert.Equal(new[] { key }, missing.ToArray()); - - var ex = Assert.Throws(() => EmailConfigurationGuard.Validate(options)); - Assert.Contains(key, ex.Message, StringComparison.Ordinal); - Assert.Contains(EmailConfigurationGuard.EnvironmentKey(key), ex.Message, StringComparison.Ordinal); - } - - /// - /// Whitespace is unset. A Helm value templated from an empty secret arrives as " ", and - /// Azure's own validator rejects it exactly like "" — so a guard that accepted it would - /// wave through the very install it exists to catch. - /// - [Theory] - [InlineData(" ")] - [InlineData("\t")] - [InlineData("\n ")] - public void AWhitespaceValueIsUnset(string blank) - { - Assert.Equal( - new[] { "Email:TenantId" }, - EmailConfigurationGuard.MissingRequiredKeys(Complete() with { TenantId = blank }).ToArray()); - } - - /// - /// Enabled and nothing else: every required key of the selected (default, client-secret) flow - /// is named at once, so one restart tells the operator the whole job rather than one key per - /// roll. - /// - [Fact] - public void EnabledWithNothingElse_NamesEveryRequiredKeyAtOnce() - { - var missing = EmailConfigurationGuard.MissingRequiredKeys(new EmailOptions { Enabled = true }); - - Assert.Equal( - new[] { "Email:MailboxAddress", "Email:TenantId", "Email:ClientId", "Email:ClientSecret" }, - missing.ToArray()); - } - - /// - /// Managed identity carries no credential keys — DefaultAzureCredential takes no tenant - /// id — but it still sends AS a mailbox. So a managed-identity install missing only the mailbox - /// is refused for that key ALONE: naming tenant/client/secret here would send an operator to - /// set three keys their flow does not use, which is a confidently wrong answer. - /// - [Fact] - public void ManagedIdentity_IsRefusedOnlyForTheMailbox_NeverForCredentialKeysItDoesNotUse() - { - var options = new EmailOptions { Enabled = true, UseManagedIdentity = true }; - - Assert.Equal( - new[] { "Email:MailboxAddress" }, - EmailConfigurationGuard.MissingRequiredKeys(options).ToArray()); - - var ex = Assert.Throws(() => EmailConfigurationGuard.Validate(options)); - Assert.DoesNotContain("Email:TenantId", ex.Message, StringComparison.Ordinal); - } - - // ───────────── rule 3: enabled + complete starts ───────────── - - /// - /// The control that stops every case above from being satisfied by a guard that refuses - /// everything: a completely configured install passes, on BOTH flows. Managed identity is the - /// production deployment shape — a false refusal here would stop mail on every install that - /// works today. - /// - [Fact] - public void ACompleteConfiguration_Starts_OnEitherCredentialFlow() - { - EmailConfigurationGuard.Validate(Complete()); - Assert.Empty(EmailConfigurationGuard.MissingRequiredKeys(Complete())); - - var managedIdentity = new EmailOptions - { - Enabled = true, - UseManagedIdentity = true, - MailboxAddress = "memex@example.test", - }; - EmailConfigurationGuard.Validate(managedIdentity); - Assert.Empty(EmailConfigurationGuard.MissingRequiredKeys(managedIdentity)); - } - - /// - /// …and through the bound configuration, written the way an operator writes it: as - /// Email__* environment entries. This is the shape the fix is asking a maintainer to - /// produce, so it is worth asserting that it is actually accepted. - /// - [Fact] - public void ACompleteConfigurationInEnvironmentVariableForm_PassesTheBootGuard() - { - EmailConfigurationGuard.Validate(Configuration(new Dictionary - { - ["Email__Enabled"] = "true", - ["Email__MailboxAddress"] = "memex@example.test", - ["Email__TenantId"] = "72f988bf-86f1-41af-91ab-2d7cd011db47", - ["Email__ClientId"] = "client", - ["Email__ClientSecret"] = "secret", - })); - } - - // ───────────── the message, and the one definition of "what does this flow need" ───────────── - - /// - /// A refusal that only says what is wrong is half a message. This one has to carry every way - /// OUT: complete the client-secret flow, switch to managed identity, or turn the integration - /// off — the last of which matters most, because an operator who does not want mail on this - /// install must not have to guess that Email:Enabled=false is a supported answer. - /// - [Fact] - public void TheRefusalNamesEveryWayOut() - { - var message = EmailConfigurationGuard.Refusal(EmailConfigurationGuard.MissingRequiredKeys(TheIncident())); - - Assert.Contains("Email:UseManagedIdentity=true", message, StringComparison.Ordinal); - Assert.Contains("Email__UseManagedIdentity=true", message, StringComparison.Ordinal); - Assert.Contains("Email:Enabled=false", message, StringComparison.Ordinal); - Assert.Contains("Email__Enabled=false", message, StringComparison.Ordinal); - Assert.Contains("#2636", message, StringComparison.Ordinal); - } - - /// The binder → environment mapping, pinned: every level separator becomes __. - [Theory] - [InlineData("Email:TenantId", "Email__TenantId")] - [InlineData("Email:Enabled", "Email__Enabled")] - [InlineData("Email:MailboxAddress", "Email__MailboxAddress")] - public void TheEnvironmentFormOfAKey(string binder, string environment) - => Assert.Equal(environment, EmailConfigurationGuard.EnvironmentKey(binder)); - - /// - /// 🚨 ONE definition of "which credential keys does the selected flow need". This guard - /// delegates that half to — the same answer - /// and NoOpEmailSender report — instead of re-deriving - /// it. A second copy would be free to drift, and the two would then tell the same operator - /// different stories about the same install. - /// - [Theory] - [InlineData(false, "", "", "")] - [InlineData(false, "t", "", "s")] - [InlineData(false, "t", "c", "s")] - [InlineData(true, "", "", "")] - public void TheCredentialHalfIsTheSameAnswerTheWatchersGive( - bool useManagedIdentity, string tenantId, string clientId, string clientSecret) - { - var options = new EmailOptions - { - Enabled = true, - MailboxAddress = "memex@example.test", - UseManagedIdentity = useManagedIdentity, - TenantId = tenantId, - ClientId = clientId, - ClientSecret = clientSecret, - }; - - Assert.Equal( - EmailDeliveryGuard.MissingConfiguration(options).ToArray(), - EmailConfigurationGuard.MissingRequiredKeys(options).ToArray()); - } - - // ───────────── the guard is WIRED, not merely present ───────────── - - /// - /// 🚨 The case that stops all of the above from being dead code: the guard has to run on the - /// portal boot path. ConfigureMemexMesh is what both hosts (Monolith and Distributed, - /// plugins repo) call, and it is where ValidateContentStorageDurability and - /// MicrosoftTenant.Validate already sit. - /// - /// A pure function nobody calls refuses nothing, and its unit tests stay green forever — - /// which is exactly how this class of guard rots. - /// - [Fact] - public void TheBootPathRefusesAHalfConfiguredEmailSection() - { - var ex = Assert.Throws( - () => ConfigureMemexMeshWith(new Dictionary - { - ["Email:Enabled"] = "true", - ["Email:MailboxAddress"] = "memex@example.test", - ["Email:ClientSecret"] = "secret", - })); - - Assert.Contains("Email__TenantId", ex.Message, StringComparison.Ordinal); - } - - /// - /// …and the boot path is unaffected by an install that never configured mail. Same call, no - /// Email section at all — the #2510 direction, asserted where it would actually happen. - /// - [Fact] - public void TheBootPathIsUnaffectedByAnInstallThatNeverConfiguredMail() - => ConfigureMemexMeshWith(new Dictionary()); - - // ───────────── fixtures ───────────── - - private static IConfiguration Configuration(Dictionary settings) - => new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); - - /// Runs the real portal boot configuration over an in-memory configuration, with the - /// minimum storage answer that gets past the awaiting-setup short-circuit. - private static void ConfigureMemexMeshWith(Dictionary settings) - { - var temp = Directory.CreateTempSubdirectory("mw-2636-").FullName; - try - { - settings["Graph:Storage:Type"] = "FileSystem"; - settings["Graph:Storage:BasePath"] = temp; - settings["Modules:Root"] = temp; - - new MeshBuilder(configure => configure(new ServiceCollection()), new Address("mesh", "test")) - .ConfigureMemexMesh(Configuration(settings)); - } - finally - { - try { Directory.Delete(temp, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - } - - /// The complete section with exactly one key blanked — "this deployment set everything - /// but that one". - private static EmailOptions Blank(EmailOptions options, string key) => key switch - { - "Email:MailboxAddress" => options with { MailboxAddress = "" }, - "Email:TenantId" => options with { TenantId = "" }, - "Email:ClientId" => options with { ClientId = "" }, - "Email:ClientSecret" => options with { ClientSecret = "" }, - _ => throw new ArgumentOutOfRangeException(nameof(key), key, "not a required Email key"), - }; -} diff --git a/test/Memex.Portal.Shared.Test/EmailWatchesReopenAfterAFaultTest.cs b/test/Memex.Portal.Shared.Test/EmailWatchesReopenAfterAFaultTest.cs deleted file mode 100644 index b764446302..0000000000 --- a/test/Memex.Portal.Shared.Test/EmailWatchesReopenAfterAFaultTest.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using Memex.Portal.Shared.Email; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The portal's two e-mail senders hold ONE live query each for the life of the process — the -/// outbound-mail watch () and the invitation watch -/// (). Each used to subscribe its query with an error arm that only -/// logged, so a single fault of the query (a stalled provider, a dropped connection) stopped every -/// outbound or invitation e-mail until the process restarted. -/// -/// Each test: the first open throws a , the watch -/// survives, the re-open runs on the test's manual clock, and a listing after the fault is handled. -/// is the negative control: the pre-fix -/// shape, driven by the same source, opens once and never again. -/// -public class EmailWatchesReopenAfterAFaultTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private sealed class ManualReopenClock - { - private ImmutableQueue due = ImmutableQueue.Empty; - - public int Scheduled { get; private set; } - - public IDisposable Schedule(Action reopen) - { - due = due.Enqueue(reopen); - Scheduled++; - return Disposable.Empty; - } - - public bool FireNext() - { - if (due.IsEmpty) - return false; - due = due.Dequeue(out var reopen); - reopen(); - return true; - } - } - - private sealed class FlakySource - { - public Subject> Live { get; } = new(); - - public int Opens { get; private set; } - - public IObservable> Open() => ++Opens == 1 - ? Observable.Throw>( - new QueryProviderStalledException("test-provider", TimeSpan.FromSeconds(1), "q", "system")) - : Live; - } - - private delegate IDisposable WatchInstaller( - Func>> open, - Action> onItems, - Func scheduleReopen); - - private void ReopensAndHandlesTheNextListing(WatchInstaller install) - { - var clock = new ManualReopenClock(); - var source = new FlakySource(); - var handled = ImmutableList.Empty; - - using var watch = install(source.Open, items => - { - foreach (var node in items) - handled = handled.Add(node.Path); - }, clock.Schedule); - - source.Opens.Should().Be(1); - clock.Scheduled.Should().Be(1, "the fault schedules a re-open instead of ending the watch"); - - clock.FireNext().Should().BeTrue(); - source.Opens.Should().Be(2, "the re-open asks for a FRESH query"); - source.Live.HasObservers.Should().BeTrue(); - - source.Live.OnNext([new MeshNode("after-the-fault", "Admin/Invitation")]); - handled.Should().Equal("Admin/Invitation/after-the-fault"); - } - - [Fact] - public void TheOutboundMailWatch_ReopensAfterAFault_AndHandlesTheNextListing() => - ReopensAndHandlesTheNextListing((open, onItems, schedule) => - OutboundEmailSender.SubscribeOutboundWatch(Mesh, open, onItems, logger: null, schedule)); - - [Fact] - public void TheInvitationWatch_ReopensAfterAFault_AndHandlesTheNextListing() => - ReopensAndHandlesTheNextListing((open, onItems, schedule) => - InvitationEmailSender.SubscribeInvitationWatch(Mesh, open, onItems, logger: null, schedule)); - - /// - /// The negative control: the pre-fix shape of both senders — the query subscribed once, the error - /// arm logging — driven by the same flaky source. It opens once, nothing re-opens it, and a - /// listing after the fault reaches nobody. - /// - [Fact] - public void TheUnguardedShape_HandlesNothingAfterTheFault() - { - var source = new FlakySource(); - var handled = 0; - var logged = 0; - - using var watch = source.Open().Subscribe(_ => handled++, _ => logged++); - - logged.Should().Be(1); - source.Opens.Should().Be(1); - source.Live.HasObservers.Should().BeFalse("nothing re-opened the query"); - source.Live.OnNext([new MeshNode("after-the-fault", "Admin/Invitation")]); - handled.Should().Be(0); - } -} diff --git a/test/Memex.Portal.Shared.Test/EntraStepUpTest.cs b/test/Memex.Portal.Shared.Test/EntraStepUpTest.cs deleted file mode 100644 index 518d34e628..0000000000 --- a/test/Memex.Portal.Shared.Test/EntraStepUpTest.cs +++ /dev/null @@ -1,274 +0,0 @@ -using System.Collections.Immutable; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The Entra rung of the approval step-up (Refs #4305, Doc/Architecture/ApprovalStepUp): -/// which session gets which rung, and what an id_token must prove. Every refusal test is -/// paired with the accepted baseline token it differs from in exactly one claim, so a refusal can -/// never pass by the check refusing everything. -/// -public class EntraStepUpTest -{ - private const string Tenant = "11111111-2222-3333-4444-555555555555"; - private static readonly string Issuer = $"https://login.microsoftonline.com/{Tenant}/v2.0"; - private static readonly DateTimeOffset Now = new(2026, 10, 9, 12, 0, 0, TimeSpan.Zero); - - private static StepUpOptions Options(bool requireAmr = true) => new() - { - Enabled = true, - EntraAuthenticationContext = "c1", - EntraRequireAmr = requireAmr, - MaxAuthAge = TimeSpan.FromSeconds(120), - }; - - private static EntraStepUpExpectation Expect(StepUpOptions? options = null, string? sessionOid = "oid-alice") => - new(Issuer, "nonce-1", sessionOid, "alice@acme.com", options ?? Options(), Now); - - private static Dictionary Baseline() => new() - { - ["iss"] = Issuer, - ["nonce"] = "nonce-1", - ["oid"] = "oid-alice", - ["tid"] = Tenant, - ["preferred_username"] = "alice@acme.com", - ["auth_time"] = Now.AddSeconds(-30).ToUnixTimeSeconds(), - ["acrs"] = new[] { "c1" }, - ["amr"] = new[] { "fido", "mfa" }, - }; - - private static IReadOnlyCollection ClaimsOf(Dictionary values) => - values.SelectMany(kv => kv.Value is string[] many - ? many.Select(v => new Claim(kv.Key, v)) - : [new Claim(kv.Key, Convert.ToString(kv.Value, System.Globalization.CultureInfo.InvariantCulture)!)]) - .ToList(); - - private static EntraStepUpCheck Check(Action>? mutate = null, EntraStepUpExpectation? expect = null) - { - var claims = Baseline(); - mutate?.Invoke(claims); - return EntraStepUpTokenCheck.Evaluate(ClaimsOf(claims), expect ?? Expect()); - } - - [Fact] - public void AFreshPhishingResistantTokenForTheSession_IsAccepted() - { - var check = Check(); - Assert.True(check.Ok, check.Reason); - Assert.Equal(Now.AddSeconds(-30), check.AuthenticatedAt); - Assert.Contains("acrs=c1", check.Evidence); - } - - [Theory] - [InlineData("auth_time")] - [InlineData("acrs")] - [InlineData("nonce")] - [InlineData("subject")] - [InlineData("issuer")] - [InlineData("amr")] - public void EachClaimIsLoadBearing(string reason) - { - Action> mutate = reason switch - { - "auth_time" => c => c["auth_time"] = Now.AddSeconds(-121).ToUnixTimeSeconds(), // one second too old - "acrs" => c => c["acrs"] = new[] { "c2" }, - "nonce" => c => c["nonce"] = "nonce-of-another-step-up", - "subject" => c => c["oid"] = "oid-mallory", - "issuer" => c => c["iss"] = "https://login.microsoftonline.com/other-tenant/v2.0", - "amr" => c => c["amr"] = new[] { "pwd", "mfa" }, // MFA, but not phishing-resistant - _ => throw new ArgumentOutOfRangeException(nameof(reason)), - }; - var refused = Check(mutate); - Assert.False(refused.Ok); - Assert.Equal(reason, refused.Reason); - Assert.True(Check().Ok); // negative control: the unmutated token passes - } - - [Fact] - public void AnAbsentAmr_IsRefusedByDefault_AndPassesOnlyWhenTheRecordWaivesIt() - { - var byDefault = Check(c => c.Remove("amr")); - Assert.False(byDefault.Ok); - Assert.Equal("amr", byDefault.Reason); - Assert.True(new StepUpOptions().EntraRequireAmr); // the shipped default, not just this test's - Assert.True(Check(c => c.Remove("amr"), Expect(Options(requireAmr: false))).Ok); - } - - [Theory] - [InlineData("rsa", "ngcmfa", "mfa")] // Authenticator push — MFA, but phishable - [InlineData("pwd")] - [InlineData("x509")] // single-factor certificate - [InlineData("swk", "rsa", "mfa")] // phone sign-in - public void MfaThatIsNotPhishingResistant_IsRefused(params string[] amr) - { - var refused = Check(c => c["amr"] = amr); - Assert.False(refused.Ok); - Assert.Equal("amr", refused.Reason); - Assert.True(Check(c => c["amr"] = new[] { "hwk", "mfa", "ngcmfa" }).Ok); // Windows Hello for Business passes - } - - [Fact] - public void TheSessionProvider_ComesFromTheTicket_NeverFromTheRoute() - { - var googleSession = new[] { new Claim(StepUpClaims.Idp, "Google") }; - // A signed-in Google user opening /auth/callback/Microsoft: no ticket item, the session says Google. - Assert.Equal("Google", StepUpClaims.ResolveProvider(null, googleSession)); - // A real sign-in carries the challenged scheme on its ticket. - Assert.Equal("Microsoft", StepUpClaims.ResolveProvider("Microsoft", [])); - // An Entra guest's own `idp` claim (an STS URI) is not ours and is never read as the provider. - Assert.Null(StepUpClaims.ResolveProvider(null, [new Claim("idp", "https://sts.windows.net/x/")])); - } - - [Fact] - public void ASessionWithoutOid_IsPinnedByItsAccount() - { - Assert.True(Check(expect: Expect(sessionOid: null)).Ok); - var other = Check(c => c["preferred_username"] = "mallory@acme.com", Expect(sessionOid: null)); - Assert.Equal("subject", other.Reason); - } - - [Fact] - public void TheLadder_SendsMicrosoftAccountsToEntra_AndNeverWavesThrough() - { - var on = Options(); - Assert.Equal(StepUpRung.Entra, StepUpLadder.Decide("Microsoft", on, entraUsable: true, factors: null)); - // Every other account steps up with the portal's own factors — with none yet, it enrols first. - Assert.Equal(StepUpRung.Enroll, StepUpLadder.Decide("Google", on, entraUsable: true, factors: null)); - Assert.Equal(StepUpRung.Enroll, StepUpLadder.Decide("LinkedIn", on, entraUsable: true, factors: null)); - Assert.Equal(StepUpRung.RefuseUnknownSession, StepUpLadder.Decide(null, on, entraUsable: true, factors: null)); - Assert.Equal(StepUpRung.RefuseNotConfigured, StepUpLadder.Decide("Microsoft", on with { EntraAuthenticationContext = null }, entraUsable: true, factors: null)); - Assert.Equal(StepUpRung.RefuseNotConfigured, StepUpLadder.Decide("Microsoft", on, entraUsable: false, factors: null)); - // Off until declared — only then does nobody step up. - Assert.Equal(StepUpRung.NotRequired, StepUpLadder.Decide("Google", new StepUpOptions(), entraUsable: true, factors: null)); - } - - [Fact] - public void TheSessionCarriesTheProviderAndTheEntraSubject() - { - var claims = StepUpClaims.ForSession("Microsoft", - [new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier", "oid-1"), new Claim("tid", Tenant)], Now).ToList(); - Assert.Contains(claims, c => c.Type == StepUpClaims.Idp && c.Value == "Microsoft"); - Assert.Contains(claims, c => c.Type == StepUpClaims.Oid && c.Value == "oid-1"); - Assert.Contains(claims, c => c.Type == StepUpClaims.Tid && c.Value == Tenant); - } - - [Fact] - public void TargetsMustPair() - { - Assert.NotNull(StepUpController.Pair(["/A/b"], ["h"])); - Assert.Equal("A/b", StepUpController.Pair(["/A/b"], ["h"])![0].ActionPath); - Assert.Null(StepUpController.Pair(["A/b", "A/c"], ["h"])); - Assert.Null(StepUpController.Pair(["A/b"], [" "])); - Assert.Null(StepUpController.Pair([], [])); - } - - // ---- the IO half, end to end against a fake Entra that signs with a local key ---- - - private sealed class FakeEntra(RsaSecurityKey signingKey, Func idToken) : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - var path = request.RequestUri!.AbsolutePath; - string body; - if (path.EndsWith("/.well-known/openid-configuration", StringComparison.Ordinal)) - body = JsonSerializer.Serialize(new Dictionary - { - ["issuer"] = Issuer, - ["jwks_uri"] = $"https://login.microsoftonline.com/{Tenant}/discovery/v2.0/keys", - ["authorization_endpoint"] = $"https://login.microsoftonline.com/{Tenant}/oauth2/v2.0/authorize", - ["token_endpoint"] = $"https://login.microsoftonline.com/{Tenant}/oauth2/v2.0/token", - }); - else if (path.EndsWith("/keys", StringComparison.Ordinal)) - { - var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(signingKey); - body = JsonSerializer.Serialize(new { keys = new[] { new { kty = jwk.Kty, kid = jwk.Kid, use = "sig", n = jwk.N, e = jwk.E } } }); - } - else if (path.EndsWith("/token", StringComparison.Ordinal)) - body = JsonSerializer.Serialize(new { id_token = idToken(), token_type = "Bearer" }); - else - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }); - } - } - - private static RsaSecurityKey NewKey(string kid) => new(RSA.Create(2048)) { KeyId = kid }; - - private static string Sign(RsaSecurityKey key, Dictionary claims) => - new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor - { - Claims = claims, - Audience = "client-1", - Expires = DateTime.UtcNow.AddMinutes(5), - NotBefore = DateTime.UtcNow.AddMinutes(-1), - IssuedAt = DateTime.UtcNow.AddMinutes(-1), - SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256), - }); - - private static EntraStepUp Rung(HttpMessageHandler handler) => new( - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - ["Authentication:Microsoft:ClientId"] = "client-1", - ["Authentication:Microsoft:ClientSecret"] = "secret", - ["Authentication:Microsoft:TenantId"] = Tenant, - [StepUpOptions.EnabledKey] = "true", - [StepUpOptions.EntraContextKey] = "c1", - }).Build(), - new HttpClient(handler), IoPool.Unbounded, new EntraMetadataCache(new HttpClient(handler)), NullLogger.Instance); - - private static Dictionary LiveClaims() - { - var c = Baseline(); - c["auth_time"] = DateTimeOffset.UtcNow.AddSeconds(-10).ToUnixTimeSeconds(); - return c; - } - - [Fact(Timeout = 60000)] - public async Task ATokenSignedByTheTenantsKey_IsRedeemed_AndOneSignedByAnotherKey_IsRefused() - { - var ct = TestContext.Current.CancellationToken; - var tenantKey = NewKey("tenant"); - var forger = NewKey("tenant"); // same kid, different key material - EntraStepUpExpectation Expectation(string issuer) => new(issuer, "nonce-1", "oid-alice", "alice@acme.com", Options(), DateTimeOffset.UtcNow); - - var good = await Rung(new FakeEntra(tenantKey, () => Sign(tenantKey, LiveClaims()))) - .Redeem("code", "https://portal/auth/step-up/callback", Expectation) - .Timeout(MeshWeaver.Fixture.TestTimeouts.Convergence).Await(ct); - Assert.True(good.Ok, good.Reason); - - var forged = await Rung(new FakeEntra(tenantKey, () => Sign(forger, LiveClaims()))) - .Redeem("code", "https://portal/auth/step-up/callback", Expectation) - .Timeout(MeshWeaver.Fixture.TestTimeouts.Convergence).Await(ct); - Assert.False(forged.Ok); - Assert.Equal("signature", forged.Reason); - } - - [Fact] - public void TheAuthorizeRequestAsksForAFreshLoginAndTheContext() - { - var url = Rung(new FakeEntra(NewKey("k"), () => "")).AuthorizeUrl("st", "nn", "https://portal/auth/step-up/callback", "alice@acme.com", "c1"); - Assert.StartsWith($"https://login.microsoftonline.com/{Tenant}/oauth2/v2.0/authorize?", url); - Assert.Contains("&prompt=login", url); - Assert.Contains("&nonce=nn", url); - Assert.Contains("&login_hint=alice%40acme.com", url); - Assert.Contains("&claims=" + Uri.EscapeDataString("{\"id_token\":{\"acrs\":{\"essential\":true,\"value\":\"c1\"}}}"), url); - } -} diff --git a/test/Memex.Portal.Shared.Test/EveryClientNamesItsOwnResiliencePipelineTest.cs b/test/Memex.Portal.Shared.Test/EveryClientNamesItsOwnResiliencePipelineTest.cs deleted file mode 100644 index 8f448dd083..0000000000 --- a/test/Memex.Portal.Shared.Test/EveryClientNamesItsOwnResiliencePipelineTest.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -// The namespace and the class share a name, so the class needs an alias to be nameable. -using Defaults = Memex.Portal.ServiceDefaults.ServiceDefaults; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 Source: '-standard//…' NAMED NOBODY — Systemorph/MeshWeaver#4528. -/// -/// ConfigureHttpClientDefaults hands out a builder with NO name, and -/// AddStandardResilienceHandler names its pipeline {builder.Name}-standard. So the -/// defaults pipeline was called -standard for EVERY client that does not re-register -/// itself — named or not — and its instance slot was empty. Every Polly line read -/// -standard//Standard-AttemptTimeout, which was taken for "some caller resolves an unnamed -/// client". Measured on memex-cloud 2026-09-24, every stack under those timeouts ended in -/// SelfUpdateHandover.Post — the NAMED self-update-handover client posting to the -/// control instance's inbox. And one (name, instance) key meant ONE pipeline, so one circuit breaker -/// was shared by every such client and every host they call. -/// -/// This builds exactly what production builds -/// (, the method AddServiceDefaults -/// calls) and reads the pipeline identity off the Polly log line itself — the only place the -/// fleet's log watcher ever sees it. Negative control: drop the SelectPipelineBy call and the -/// three default-riding assertions fail with -standard//…. -/// -public class EveryClientNamesItsOwnResiliencePipelineTest -{ - private const string Handover = "self-update-handover"; - private const string Report = "deployment-report"; - - [Fact] - public void EachNamedClient_IsItsOwnPipelineInstance_AndSaysSoInEveryPollyLine() - { - var sink = new PollyLines(); - using var provider = Build(sink); - var factory = provider.GetRequiredService(); - - Send(factory.CreateClient(Handover)); - Send(factory.CreateClient(Report)); - Send(factory.CreateClient()); - - Assert.Contains(sink.Lines, l => l.Contains($"Source: '-standard/{Handover}/Standard-", StringComparison.Ordinal)); - Assert.Contains(sink.Lines, l => l.Contains($"Source: '-standard/{Report}/Standard-", StringComparison.Ordinal)); - Assert.Contains(sink.Lines, l => l.Contains($"Source: '-standard/{Defaults.UnnamedClient}/Standard-", StringComparison.Ordinal)); - - // The form that named nobody must be gone entirely — not merely outnumbered. - Assert.DoesNotContain(sink.Lines, l => l.Contains("Source: '-standard//", StringComparison.Ordinal)); - } - - /// - /// A client re-registered with its own budget keeps its OWN pipeline (#1133/#1137) — the stamp - /// the defaults add must not leak it back onto the shared one. - /// - [Fact] - public void AReRegisteredClient_KeepsItsOwnNamedPipeline() - { - var sink = new PollyLines(); - using var provider = Build(sink); - - Send(provider.GetRequiredService().CreateClient("plugin-registry")); - - Assert.Contains(sink.Lines, l => l.Contains("Source: 'plugin-registry-standard/", StringComparison.Ordinal)); - Assert.DoesNotContain(sink.Lines, l => l.Contains("Source: '-standard/", StringComparison.Ordinal)); - } - - /// The selector is pure: a request that never passed the stamp is the unnamed client's. - [Fact] - public void ARequestWithoutAStamp_BelongsToTheUnnamedInstance() - { - using var request = new HttpRequestMessage(HttpMethod.Get, "http://example.invalid/"); - Assert.Equal(Defaults.UnnamedClient, Defaults.ClientNameOf(request)); - - request.Options.Set(Defaults.ClientNameKey, Handover); - Assert.Equal(Handover, Defaults.ClientNameOf(request)); - } - - private static ServiceProvider Build(PollyLines sink) - { - var services = new ServiceCollection(); - services.AddLogging(logging => logging - .SetMinimumLevel(LogLevel.Trace) - .AddProvider(sink)); - Defaults.AddHttpClientResilienceDefaults(services); - // The one test seam: no socket. Every client answers 200 from memory. - services.ConfigureHttpClientDefaults(http => - http.ConfigurePrimaryHttpMessageHandler(() => new AnswersOk())); - return services.BuildServiceProvider(); - } - - // Synchronous on purpose: HttpClient.Send drives the same handler chain (the stamp and the - // resilience handler both implement Send) without any Task in the test. - private static void Send(HttpClient client) - { - using var request = new HttpRequestMessage(HttpMethod.Get, "http://example.invalid/"); - using var response = client.Send(request, CancellationToken.None); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - private sealed class AnswersOk : HttpMessageHandler - { - protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) => - new(HttpStatusCode.OK) { RequestMessage = request }; - - protected override System.Threading.Tasks.Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) => - System.Threading.Tasks.Task.FromResult(Send(request, cancellationToken)); - } - - /// Collects every formatted line logged under Polly's category — per test instance. - private sealed class PollyLines : ILoggerProvider - { - private readonly ConcurrentQueue lines = new(); - - public string[] Lines => lines.ToArray(); - - public ILogger CreateLogger(string categoryName) => new Sink(categoryName, lines); - - public void Dispose() { } - - private sealed class Sink(string category, ConcurrentQueue lines) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - if (category.StartsWith("Polly", StringComparison.Ordinal)) - lines.Enqueue(formatter(state, exception)); - } - } - } -} diff --git a/test/Memex.Portal.Shared.Test/ExecuteScriptPreflightTest.cs b/test/Memex.Portal.Shared.Test/ExecuteScriptPreflightTest.cs deleted file mode 100644 index 28b71fd3a8..0000000000 --- a/test/Memex.Portal.Shared.Test/ExecuteScriptPreflightTest.cs +++ /dev/null @@ -1,233 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #2918 — ExecuteScript against a target that CANNOT answer used to sit out the whole -/// dispatch budget and then report a . -/// -/// Why the wait was total. The ExecuteScriptRequest handler is registered by -/// the Code NodeType's HubConfiguration and by nothing else. A target that is not a -/// Code node — a Markdown page, or a path with no node at all — therefore routes to a hub that -/// carries no such handler: nothing refuses, nothing NACKs, the request is simply never answered. -/// The caller's own .Timeout(...) is the only thing that ever ends the wait, so a call that -/// could never have succeeded costs the full 60–120 s and one server-side error log — multiplied -/// by the retry count when an agent loops (production fingerprint 6094e6b21967f47b, -/// memex-cloud, 2026-08-31). -/// -/// What these tests pin is the ELAPSED TIME, not merely "an error came back". The -/// pre-fix code already returned a structured error — it just took the entire budget to say it, so -/// an assertion on the payload alone passes on the defect. Each test therefore runs with a -/// generous dispatch budget and asserts the answer arrives in a small fraction of it: on the old -/// code the observable emits only when the budget elapses, so the stopwatch is the discriminator. -/// -/// -/// And what the pre-flight must not lose. Answering the caller is only half of what -/// the dispatch it replaced did: #841's contract is that a run which ends without an Activity node -/// is visible to the OPERATOR too. That half is pinned by -/// , which -/// asserts on the mesh's own logger rather than on elapsed time. -/// -public class ExecuteScriptPreflightTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// - /// Captures what the mesh's own emits, so "the refusal is visible - /// at Warning+" is an assertion rather than a claim. Instance-owned — its lifetime is this - /// test's mesh — never static state. - /// - private readonly CapturingLoggerProvider logs = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .ConfigureServices(services => services.AddSingleton(logs)); - - /// - /// The dispatch budget handed to ExecuteScript. Large enough that "waited it out" and - /// "answered from the pre-flight" cannot be confused for one another. - /// - private const int DispatchBudgetSeconds = 45; - - /// - /// The bar the answer must beat. The pre-flight's own read is bounded at 10 s (and only - /// reaches that bound when it gets NO verdict, in which case it deliberately falls through to - /// the dispatch rather than refusing), so a real pre-flight refusal lands far below this — - /// while the old wait-it-out path cannot come in under . - /// - private static readonly TimeSpan FailFastBar = TimeSpan.FromSeconds(20); - - /// - /// The overall await bound. Comfortably above so the OLD - /// behaviour is measured rather than cut short — a test that times out where the defect merely - /// waits proves nothing about how long it waited. - /// - private static readonly TimeSpan AwaitBound = TimeSpan.FromSeconds(120); - - /// - /// The target does not exist at all. There is nothing to execute and no amount of waiting will - /// change that, so the answer must be immediate and must NAME the condition. - /// - [Fact(Timeout = 180000)] - public async Task ExecuteScript_OnAnAbsentPath_AnswersFromThePreflight_WithoutBurningTheDispatchBudget() - { - var absent = $"{TestPartition}/NoSuchScript-{Guid.NewGuid():N}"; - - var (result, elapsed) = await Run(absent, TestContext.Current.CancellationToken); - - elapsed.Should().BeLessThan(FailFastBar, - $"a target that does not exist is knowable up front — the pre-flight must answer it " - + $"instead of sitting out the {DispatchBudgetSeconds}s dispatch budget (#2918). " - + $"Took {elapsed.TotalSeconds:F1}s."); - - result.GetProperty("status").GetString().Should().Be("Error", - "a target that cannot run must be refused, never reported as Dispatched"); - result.GetProperty("errorType").GetString().Should().Be("NodeNotFound", - "the caller needs the CONDITION, not an exception type name — the old answer was " - + $"System.TimeoutException, which says only that we gave up. Got: {result}"); - } - - /// - /// The target EXISTS but is not a Code node — the test mesh's TestData partition root is - /// a Markdown node. Its hub activates perfectly happily; it just has no - /// ExecuteScriptRequest handler, so it answers nothing. This is the case that proves the - /// wait is about the HANDLER, not about routing failing to find a hub. - /// - [Fact(Timeout = 180000)] - public async Task ExecuteScript_OnANodeThatIsNotCode_AnswersFromThePreflight_WithoutBurningTheDispatchBudget() - { - var (result, elapsed) = await Run(TestPartition, TestContext.Current.CancellationToken); - - elapsed.Should().BeLessThan(FailFastBar, - $"the node was readable and demonstrably not executable — deciding that needs one " - + $"bounded read, not the whole {DispatchBudgetSeconds}s dispatch budget (#2918). " - + $"Took {elapsed.TotalSeconds:F1}s."); - - result.GetProperty("status").GetString().Should().Be("Error", - "a Markdown node carries no script — this can only ever be refused"); - result.GetProperty("errorType").GetString().Should().Be("NotExecutable", - $"the refusal must say WHAT is wrong with the target. Got: {result}"); - } - - /// - /// 🚨 #841's OTHER half, which the pre-flight silently dropped. - /// - /// A dispatch that ends without an Activity node must reach the CALLER and the - /// OPERATOR — that is why CodeNodeType.HandleExecuteScript's refusal sink does both - /// things in one place, and why every test in the engine's dispatch-diagnostics suite asserts - /// the Warning+ trace as well as the verdict. The pre-flight above moved two of those three - /// verdicts — "no readable node" and "not a runnable Code node" — from the owning hub to the - /// caller, where the answer is cheaper; the log line did not come with them. From that change - /// onward the commonest refusals reproduced #841's reported picture exactly: the caller is - /// told, and the pod emits NOTHING at Warning or above. - /// - /// Both pre-flight branches are exercised, because they are two returns in - /// DescribeUnrunnableTarget and a trace on one says nothing about the other. The - /// assertion is on the mesh's own , not on test output, so it - /// measures what an operator would actually see in Loki. - /// - [Theory(Timeout = 180000)] - [InlineData(true)] - [InlineData(false)] - public async Task ExecuteScript_PreflightRefusal_IsVisibleToTheOperator_NotJustToTheCaller( - bool absentPath) - { - var path = absentPath - ? $"{TestPartition}/NoSuchScript-{Guid.NewGuid():N}" - : TestPartition; - - var (result, _) = await Run(path, TestContext.Current.CancellationToken); - result.GetProperty("status").GetString().Should().Be("Error", result.ToString()); - - var refusals = logs.Records - .Where(r => r.Level >= LogLevel.Warning - && r.Message.Contains("ExecuteScript refused", StringComparison.Ordinal)) - .ToArray(); - - refusals.Should().NotBeEmpty( - "a refused dispatch must leave a Warning+ trace naming the target — the caller's JSON " - + "is not evidence anybody but the caller can see, and an operator with no trace is " - + "the whole of #841. Captured at Warning+: [{0}]", - string.Join(" | ", logs.Records - .Where(r => r.Level >= LogLevel.Warning) - .Select(r => $"{r.Level} {r.Category}: {r.Message}"))); - - refusals.Should().Contain(r => r.Message.Contains(path, StringComparison.Ordinal), - "the trace must name the PATH that was refused, or an operator reading it cannot tell " - + "which run died"); - } - - // ── helpers ── - - /// - /// Runs the tool exactly as the MCP surface does and returns its parsed JSON plus the wall - /// clock the caller actually paid. The stopwatch starts at SUBSCRIBE — ExecuteScript - /// returns a cold observable, so timing the call itself would measure nothing. - /// - private async Task<(JsonElement Result, TimeSpan Elapsed)> Run(string path, CancellationToken cancellationToken) - { - var operations = new MeshOperations(Mesh); - var stopwatch = Stopwatch.StartNew(); - var json = await Observable.Defer(() => - operations.ExecuteScript(path, DispatchBudgetSeconds)) - .FirstAsync() - .Timeout(AwaitBound) - .Await(cancellationToken); - stopwatch.Stop(); - - Output.WriteLine($"ExecuteScript('{path}') answered in {stopwatch.Elapsed.TotalSeconds:F2}s: {json}"); - return (JsonDocument.Parse(json).RootElement.Clone(), stopwatch.Elapsed); - } - - // ── log capture ── - - private sealed record CapturedLog( - LogLevel Level, string Category, string Message, Exception? Exception); - - /// - /// An that keeps every record the mesh's ILoggerFactory - /// routes to it at or above — the factory's own filter rules apply - /// first, so what lands here is what a deployment's sink would receive, minus Trace. - /// That is deliberate: the assertion is about Warning+, and the failure dump is more useful - /// without per-message trace chatter buried in it. - /// - /// The backing queue is an INSTANCE field on an instance the test's mesh owns — the - /// no-static-state rule applies to test infrastructure exactly as it does to src/. - /// - private sealed class CapturingLoggerProvider : ILoggerProvider - { - private readonly ConcurrentQueue records = new(); - - public IReadOnlyList Records => records.ToArray(); - - public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, records); - - public void Dispose() { } - - private sealed class CapturingLogger(string category, ConcurrentQueue sink) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Debug; - - public void Log(LogLevel logLevel, EventId eventId, TState state, - Exception? exception, Func formatter) - => sink.Enqueue(new CapturedLog( - logLevel, category, formatter(state, exception), exception)); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/FakeOciRegistry.cs b/test/Memex.Portal.Shared.Test/FakeOciRegistry.cs deleted file mode 100644 index a02679d923..0000000000 --- a/test/Memex.Portal.Shared.Test/FakeOciRegistry.cs +++ /dev/null @@ -1,246 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.PluginCatalog; - -namespace Memex.Portal.Shared.Test; - -/// -/// An in-process OCI Distribution registry holding ONE bundle manifest and its layers, speaking -/// the pull surface the fleet's registry speaks: a bare 401 with a Bearer challenge until -/// a bearer is presented, /v2/token answering a bearer for Basic user:key, -/// manifests by tag or digest, blobs by digest, and a paginated tags listing. -/// -/// The fake substitutes the NETWORK, not a MeshWeaver interface: it holds the protocol's -/// invariants (a 401 without a bearer, a 401 on a wrong key, content-addressed blobs) rather than -/// replaying recorded answers. Its two switches, and -/// , serve bytes that do NOT hash to the digest they are asked for -/// under — the case digest verification exists for. -/// -internal sealed class FakeOciRegistry : HttpMessageHandler -{ - public const string Username = OciRegistryClient.DefaultUsername; - - public FakeOciRegistry(string host, string key, string repository, byte[] bundle, string tag = "1.0.0-s1") - { - Host = host; - Key = key; - Repository = repository; - Bundle = bundle; - Tag = tag; - BundleDigest = OciDigest.Of(bundle); - Manifest = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new - { - schemaVersion = 2, - mediaType = OciRegistryClient.ImageManifestMediaType, - config = new - { - mediaType = OciRegistryClient.PublicationConfigMediaType, - digest = OciDigest.Of("{}"u8), - size = 2, - }, - layers = new[] - { - new - { - mediaType = OciRegistryClient.BundleLayerMediaType, - digest = BundleDigest, - size = bundle.Length, - }, - }, - })); - ManifestDigest = OciDigest.Of(Manifest); - } - - public string Host { get; } - public string Key { get; } - public string Repository { get; } - public string Tag { get; } - public byte[] Bundle { get; } - public string BundleDigest { get; } - public byte[] Manifest { get; } - public string ManifestDigest { get; } - - /// The artifact reference a registry would advertise for this bundle. - public string Reference => $"{Host}/{Repository}@{ManifestDigest}"; - - /// Two per page, whatever ?n= asks, so pagination is always exercised. - public string[] Tags { get; init; } = ["1.0.0-s1", "1.1.0-s1", "2.0.0-s1"]; - - /// Serve the blob's bytes with one byte flipped — a registry that swapped the layer. - public bool TamperBlob { get; set; } - - /// Serve a manifest whose bytes differ from the digest they are asked for under. - public bool TamperManifest { get; set; } - - /// The token realm the 401 challenge names; null = this registry's own /v2/token. - /// A test points it at ANOTHER authority to stage a registry redirecting the key (MeshWeaver#4123). - public string? Realm { get; set; } - - /// Refuse the key at the realm. - public bool RefuseKey { get; set; } - - /// When set, a blob request answers this status instead of the bytes — a registry that - /// is briefly down (503), rate-limiting (429), or refusing (403/404). - public HttpStatusCode? BlobStatus { get; set; } - - private ImmutableList requests = ImmutableList.Empty; - private int tokenRequests; - private int blobRequests; - private int manifestRequests; - - public ImmutableList Requests => requests; - public int TokenRequests => tokenRequests; - public int BlobRequests => blobRequests; - public int ManifestRequests => manifestRequests; - public string? PresentedUser { get; private set; } - public string? PresentedSecret { get; private set; } - - /// Whether this handler serves 's host. - public bool Serves(Uri uri) => string.Equals(uri.Host, Host, StringComparison.OrdinalIgnoreCase); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri!; - ImmutableInterlocked.Update(ref requests, r => r.Add($"{request.Method} {uri.PathAndQuery}")); - if (!Serves(uri)) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - - if (uri.AbsolutePath == "/v2/token") - { - Interlocked.Increment(ref tokenRequests); - var header = request.Headers.Authorization; - string? user = null; - string? secret = null; - if (header is { Scheme: "Basic", Parameter: { } basic }) - { - var pair = Encoding.UTF8.GetString(Convert.FromBase64String(basic)); - var colon = pair.IndexOf(':'); - user = pair[..colon]; - secret = pair[(colon + 1)..]; - } - PresentedUser = user; - PresentedSecret = secret; - if (RefuseKey || secret != Key) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)); - return Task.FromResult(Json($$"""{"token":"bearer-for-{{Key}}"}""")); - } - - if (request.Headers.Authorization is not { Scheme: "Bearer" } auth || auth.Parameter != $"bearer-for-{Key}") - return Task.FromResult(Challenge()); - - var manifestsPrefix = $"/v2/{Repository}/manifests/"; - if (uri.AbsolutePath.StartsWith(manifestsPrefix, StringComparison.Ordinal)) - { - Interlocked.Increment(ref manifestRequests); - var reference = uri.AbsolutePath[manifestsPrefix.Length..]; - if (reference != ManifestDigest && reference != Tag) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - var body = TamperManifest - ? Encoding.UTF8.GetBytes(Encoding.UTF8.GetString(Manifest).Replace("\"size\":2", "\"size\":3")) - : Manifest; - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(body), - }; - response.Content.Headers.ContentType = new MediaTypeHeaderValue(OciRegistryClient.ImageManifestMediaType); - // What the registry STATES the manifest's digest is — the sealed one, so a tampered - // body is caught against the header as well as against the reference. - response.Headers.TryAddWithoutValidation("Docker-Content-Digest", ManifestDigest); - return Task.FromResult(response); - } - - var blobsPrefix = $"/v2/{Repository}/blobs/"; - if (uri.AbsolutePath.StartsWith(blobsPrefix, StringComparison.Ordinal)) - { - Interlocked.Increment(ref blobRequests); - if (BlobStatus is { } status) - return Task.FromResult(new HttpResponseMessage(status)); - var digest = uri.AbsolutePath[blobsPrefix.Length..]; - if (digest != BundleDigest) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - var bytes = Bundle; - if (TamperBlob) - { - bytes = (byte[])Bundle.Clone(); - bytes[bytes.Length / 2] ^= 0xFF; - } - var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }; - response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); - response.Headers.TryAddWithoutValidation("Docker-Content-Digest", BundleDigest); - return Task.FromResult(response); - } - - if (uri.AbsolutePath == $"/v2/{Repository}/tags/list") - { - const int pageSize = 2; - var query = System.Web.HttpUtility.ParseQueryString(uri.Query); - var last = query["last"]; - var remaining = last is null ? Tags : Tags.SkipWhile(t => t != last).Skip(1).ToArray(); - var page = remaining.Take(pageSize).ToArray(); - var response = Json( - $$"""{"name":"{{Repository}}","tags":[{{string.Join(",", page.Select(t => $"\"{t}\""))}}]}"""); - if (page.Length < remaining.Length) - response.Headers.TryAddWithoutValidation( - "Link", $"; rel=\"next\""); - return Task.FromResult(response); - } - - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - private HttpResponseMessage Challenge() - { - var response = new HttpResponseMessage(HttpStatusCode.Unauthorized); - response.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", - $"realm=\"{Realm ?? $"https://{Host}/v2/token"}\",service=\"{Host}\"")); - return response; - } - - private static HttpResponseMessage Json(string body) => - new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; -} - -/// Routes each request to the first handler that serves its host — one factory for a -/// test whose consumer talks to a plugin registry AND an OCI registry. -internal sealed class HostRoutingClientFactory(params HttpMessageHandler[] handlers) : IHttpClientFactory -{ - public HttpClient CreateClient(string name) => new(new Router(handlers), disposeHandler: false); - - private sealed class Router(IReadOnlyList handlers) : DelegatingHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var host = request.RequestUri!.Host; - foreach (var handler in handlers) - { - var serves = handler switch - { - FakeOciRegistry oci => oci.Serves(request.RequestUri!), - IHostHandler h => h.Serves(host), - _ => false, - }; - if (serves) - return new HttpMessageInvoker(handler, disposeHandler: false).SendAsync(request, cancellationToken); - } - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent($"no fake serves {host}"), - }); - } - } -} - -/// A fake handler that says which host it answers for. -internal interface IHostHandler -{ - bool Serves(string host); -} diff --git a/test/Memex.Portal.Shared.Test/Fakes/FakeRegistry.cs b/test/Memex.Portal.Shared.Test/Fakes/FakeRegistry.cs deleted file mode 100644 index 5926978722..0000000000 --- a/test/Memex.Portal.Shared.Test/Fakes/FakeRegistry.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Net; -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.PluginCatalog; - -namespace Memex.Portal.Shared.Test.Fakes; - -/// -/// A registry in-process — the two routes the first-run wizard calls, and nothing else. -/// -/// 🚨 Why a fake and not the real registry: registering claims an instance id -/// GLOBALLY, and the platform states an id is never re-issued even after deletion. A suite pointed -/// at a live registry would burn a real, permanent id on every run, on shared infrastructure. So the -/// wire contract is served here while the client under test stays the REAL one — real -/// serialization, real status handling, real refusal paths. -/// -/// It can be told to refuse, because the refusals are the interesting half: a taken id, a -/// rejected key and an unreachable host each have a different fix, and the wizard has to say -/// which. -/// -public sealed class FakeRegistry : HttpMessageHandler -{ - private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); - - /// Status to answer registration with. 200 by default. - public HttpStatusCode RegisterStatus { get; init; } = HttpStatusCode.OK; - - /// Status to answer the package listing with. 200 by default. - public HttpStatusCode ListStatus { get; init; } = HttpStatusCode.OK; - - /// The plan the fake enrols an open registration on. - public string Plan { get; init; } = "free"; - - /// The key it issues. Empty simulates "accepted but returned no key". - public string IssuedKey { get; init; } = "mwi_faketestkey"; - - /// What the listing returns. - public ImmutableList Packages { get; init; } = - [ - new PackageManifest { Id = "Plugins/Store", Name = "Store", Description = "Browse and install plugins." }, - // The one that answers the DATABASE question — the flow's whole point. - new PackageManifest - { - Id = "Plugins/PostgreSql", - Name = "PostgreSQL", - Description = "Durable storage on a Postgres server you run.", - StorageType = "PostgreSql", - }, - // A storage package for a backend the image CANNOT open — it must appear in the plugin list - // but must NOT be offered as a database, or the instance records a backend that never - // resolves and fails at the next boot with the wizard gone. - new PackageManifest - { - Id = "Plugins/Exotic", - Name = "Exotic DB", - Description = "A backend this image does not ship.", - StorageType = "ExoticDb", - }, - ]; - - /// Every registration this fake was asked to perform, for assertions. - public List Registrations { get; } = []; - - /// The bearer tokens the listing was called with, for assertions. - public List ListTokens { get; } = []; - - /// - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var path = request.RequestUri?.AbsolutePath ?? ""; - - if (path == InstanceRegistrationPayloads.Route && request.Method == HttpMethod.Post) - { - var body = await request.Content!.ReadFromJsonAsync( - Json, cancellationToken); - if (body is not null) Registrations.Add(body); - if (RegisterStatus != HttpStatusCode.OK) - return new HttpResponseMessage(RegisterStatus) - { - Content = new StringContent("registration refused by the fake registry"), - }; - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = JsonContent.Create( - new InstanceRegistrationPayloads.Response(body?.InstanceId ?? "", IssuedKey) { Plan = Plan }, - options: Json), - }; - } - - if (path == RegistryPackageSource.RoutePrefix && request.Method == HttpMethod.Get) - { - ListTokens.Add(request.Headers.Authorization?.Parameter); - if (ListStatus != HttpStatusCode.OK) - return new HttpResponseMessage(ListStatus) - { - Content = new StringContent("listing refused by the fake registry"), - }; - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = JsonContent.Create(new { packages = Packages }, options: Json), - }; - } - - // Anything else is a route the wizard should not be calling. Say so loudly rather than - // answering 200 and letting a wrong call look correct. - return new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent($"the fake registry serves no {request.Method} {path}"), - }; - } -} diff --git a/test/Memex.Portal.Shared.Test/FirstAdministratorHasNoLearningPathTest.cs b/test/Memex.Portal.Shared.Test/FirstAdministratorHasNoLearningPathTest.cs deleted file mode 100644 index 8fb1976583..0000000000 --- a/test/Memex.Portal.Shared.Test/FirstAdministratorHasNoLearningPathTest.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Memex.Portal.Shared.Authentication; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The first global administrator is SEEDED with no learning path; an ordinary user still is — -/// policy first-admin-no-learning-path (Doc/Architecture/PolicyNotProse). They are -/// setting the instance up, not learning it. -/// -/// These are the pure halves: the seed function and the request's default. What the seed -/// does on a LIVE mesh — and, the case that matters, what a second onboarding of the same user -/// does NOT do to pins added in between — is . -/// -public class FirstAdministratorHasNoLearningPathTest -{ - [Fact] - public void TheFirstAdministratorIsPinnedToNothing() - { - Assert.Empty(UserOnboardingDefaults.PinnedPathsFor(isPlatformBootstrap: true)); - } - - [Fact] - public void AnOrdinaryUserKeepsTheFourDocSections() - { - Assert.Equal( - ["Doc/Architecture", "Doc/DataMesh", "Doc/GUI", "Doc/AI"], - UserOnboardingDefaults.PinnedPathsFor(isPlatformBootstrap: false)); - } - - [Fact] - public void TheRequestDefaultsToAnOrdinaryUser() - { - // Every existing caller constructs the request without the flag, and every one of them - // creates an ordinary user: the default must not silently strip anybody's pins. - Assert.False(new UserOnboardingRequest("u", "u@x.example").IsPlatformBootstrap); - } -} diff --git a/test/Memex.Portal.Shared.Test/FrozenHoldIsHistoryTest.cs b/test/Memex.Portal.Shared.Test/FrozenHoldIsHistoryTest.cs deleted file mode 100644 index 322e6600d4..0000000000 --- a/test/Memex.Portal.Shared.Test/FrozenHoldIsHistoryTest.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System; -using Memex.Portal.Shared.SelfUpdate; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A hold RECORD is not a live verdict (#3812). -/// -/// / HeldReason are written only when a -/// candidate is actually EVALUATED; LastCheckedAt is written on EVERY check. With updates -/// switched off the check records "updates are disabled" and evaluates nothing, so the hold is -/// never revisited and never cleared — while the fresh LastCheckedAt beside it makes a -/// days-old record read as the reason the install is standing still. -/// -/// Measured on memex 2026-09-09: heldAt 2026-09-07T22:27Z against lastCheckedAt -/// 2026-09-09T10:41Z, and the frozen reason quoted three module floors that #3648 had stopped -/// holding on TWO HOURS after that hold was written. #3706 was filed on that record and read it as -/// current. This suite is the control that could have caught it. -/// -/// 🚨 What is deliberately NOT tested, because it must not happen: the hold being cleared when -/// updates are switched off. The last real evaluation is the only diagnostic an operator has before -/// turning updates back on, so it is kept — and shown as history. -/// -public class FrozenHoldIsHistoryTest -{ - private const string Tag = "3.0.0-ci.8057"; - private const string Running = "3.0.0-ci.8000"; - private const string Reason = "Agent: the module requires platform 3.0.0 or newer"; - - private static string EchoLocalizer(string key, object?[] args) => - $"{key}[{string.Join(',', args)}]"; - - private static UpdatePolicyContent Held(UpdatePolicyKind policy, bool dated = true) => - new() - { - Policy = policy, - LatestAvailableTag = Tag, - HeldTag = Tag, - HeldReason = Reason, - HeldAt = dated ? new DateTimeOffset(2026, 9, 7, 22, 27, 17, TimeSpan.Zero) : null, - // The field that made the frozen record look current: a check ran 36 h later and - // recorded only that it was not allowed to evaluate anything. - LastCheckedAt = new DateTimeOffset(2026, 9, 9, 10, 41, 24, TimeSpan.Zero), - LastCheckVerdict = "updates are disabled on this install (Admin/UpdatePolicy = None)", - }; - - [Theory] - [InlineData(UpdatePolicyKind.Continuous)] - [InlineData(UpdatePolicyKind.Stable)] - public void WithCheckingON_TheHoldIsOperative(UpdatePolicyKind policy) => - Held(policy).IsHoldOperative(Tag).Should().BeTrue( - "a poller that is running would clear this hold the moment the candidate resolves, so " - + "it is a live verdict"); - - [Fact] - public void WithCheckingOFF_TheHoldIsRecordedButNotOperative() - { - var content = Held(UpdatePolicyKind.None); - - content.IsHeld(Tag).Should().BeTrue("the record is still there, and still worth showing"); - content.IsHoldOperative(Tag).Should().BeFalse( - "nothing evaluates while updates are off, so this verdict can neither be reaffirmed " - + "nor cleared — it is history, not the reason the install is standing still"); - } - - [Fact] - public void AnOperativeHold_StillReadsAsAHold() - { - var markdown = UpdatePolicySettingsTab.StatusMarkdown( - Held(UpdatePolicyKind.Continuous), EchoLocalizer); - - markdown.Should().Contain($"ui.updateHeld[{Tag}]", - "with checking on, the hold IS why this install is not moving"); - markdown.Should().Contain("ui.updateHeldAt["); - markdown.Should().NotContain("ui.updateHoldHistorical"); - markdown.Should().Contain(Reason, "the reason is quoted either way"); - } - - [Fact] - public void AFrozenHold_ReadsAsHistory_NeverAsTheCurrentVerdict() - { - var markdown = UpdatePolicySettingsTab.StatusMarkdown( - Held(UpdatePolicyKind.None), EchoLocalizer); - - markdown.Should().Contain("ui.updateHoldHistorical[", - "the record must announce itself as a record"); - markdown.Should().Contain("2026-09-07 22:27", - "and carry the moment it was written, which is the fact a reader needs to judge it"); - markdown.Should().NotContain($"ui.updateHeld[{Tag}]", - "'update is held' states an ongoing refusal; nothing is refusing anything here"); - markdown.Should().NotContain("ui.updateHeldUnknown"); - markdown.Should().NotContain("ui.updateHeldAt[", - "'(held )' reads as an ongoing state and is dropped with the live framing"); - markdown.Should().Contain(Reason, - "🚨 the reason is still shown — it is the only diagnostic an operator has before " - + "switching updates back on. Only its FRAMING changes"); - } - - [Fact] - public void AFrozenHoldWithNoTimestamp_StillReadsAsHistory() - { - var markdown = UpdatePolicySettingsTab.StatusMarkdown( - Held(UpdatePolicyKind.None, dated: false), EchoLocalizer); - - markdown.Should().Contain($"ui.updateHoldHistoricalUndated[{Tag}]", - "a record with no timestamp is still a record, and the undated wording says so rather " - + "than falling back to the live phrasing"); - markdown.Should().NotContain($"ui.updateHeld[{Tag}]"); - } - - [Fact] - public void TheAboutSurface_DoesNotReportAFrozenHoldAsHeld() - { - var status = PlatformUpdateStatus.Derive(Held(UpdatePolicyKind.None), Running); - - status.Availability.Should().NotBe(PlatformUpdateAvailability.UpdateHeld, - "rendering 'update held' off a note nothing can refresh names an incompatibility that " - + "may have been fixed days ago — which is exactly how #3706 was filed"); - } - - [Fact] - public void TheAboutSurface_StillReportsAnOperativeHold() - { - var status = PlatformUpdateStatus.Derive(Held(UpdatePolicyKind.Continuous), Running); - - status.Availability.Should().Be(PlatformUpdateAvailability.UpdateHeld, - "an install that is actively refusing a build must not look like one about to take it"); - } - - /// - /// 🚨 The OTHER half. A Red combo verification is a recorded FACT about the build, not a note - /// about the poller — but under policy platform-rolls-independently-of-packages it is a - /// fact about PACKAGES, and the platform rolls - /// independently of them. So it never renders as "held"; it renders as an advisory naming what does not - /// follow — with checking off as with checking on. - /// - [Fact] - public void ARedComboVerdict_IsAnAdvisory_NeverAHold() - { - var content = new UpdatePolicyContent - { - Policy = UpdatePolicyKind.None, - LatestAvailableTag = Tag, - ComboVerifications = - [ - new ComboVerification - { - CandidateTag = Tag, - Verdict = ComboVerdictKind.Red, - }, - ], - }; - - var status = PlatformUpdateStatus.Derive(content, Running); - status.Availability.Should().Be(PlatformUpdateAvailability.UpdateAvailable, - "a Red names modules that do not follow the build — it does not hold the platform"); - status.HasAdvisories.Should().BeTrue( - "the verdict is still the fact, and it is announced as the advisory it now is"); - } -} diff --git a/test/Memex.Portal.Shared.Test/GitHubAppInstallationSelectionTest.cs b/test/Memex.Portal.Shared.Test/GitHubAppInstallationSelectionTest.cs deleted file mode 100644 index 2606760fd0..0000000000 --- a/test/Memex.Portal.Shared.Test/GitHubAppInstallationSelectionTest.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Net.Http; -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Mesh.Threading; -using Microsoft.Extensions.Options; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// names the installation the token was MINTED -/// for — the one the mint selected — and never the configured owner when the mint did not use it. -/// A GitSync activity names its read identity from this (), -/// so an operator reading "installation N (account)" reads the account that performed the read. -/// -public class GitHubAppInstallationSelectionTest : IAsyncDisposable -{ - private static readonly TimeSpan Budget = TimeSpan.FromSeconds(10); - - // One registry per test (xUnit builds one instance per test), owned here and torn down below: - // a minted token runs on its HTTP pool, whose cancel thread lives until the registry is disposed. - private readonly IoPoolRegistry pools = new(); - - private const string TwoInstallations = - """[{"id": 11, "account": {"login": "Contoso"}}, {"id": 33, "account": {"login": "Fabrikam"}}]"""; - - private GitHubAppTokenService Service(RSA key, long? installationId, string? owner) => - new(pools, - Options.Create(new GitHubAppOptions - { - ClientId = "Iv23liTestApp", - PrivateKey = key.ExportRSAPrivateKeyPem(), - InstallationId = installationId, - InstallationOwner = owner, - }), - httpClient: new HttpClient(new GitSyncNamesItsReadIdentityTest.InstallationsHandler(TwoInstallations))); - - [Fact] - public async Task AConfiguredOwnerThatMatches_IsTheSelectedInstallation() - { - using var key = RSA.Create(2048); - var installation = await Service(key, null, "Fabrikam").GetInstallation().FirstAsync().Timeout(Budget).Await(); - Assert.Equal(new GitHubAppInstallation(33, "Fabrikam"), installation); - } - - /// The regression: no installation on the configured owner — the mint falls back to the - /// first, and the reading must name THAT one, not the owner it was configured with. - [Fact] - public async Task AConfiguredOwnerThatMatchesNothing_NamesTheFallbackTheMintUsed() - { - using var key = RSA.Create(2048); - var installation = await Service(key, null, "Northwind").GetInstallation().FirstAsync().Timeout(Budget).Await(); - Assert.Equal(new GitHubAppInstallation(11, "Contoso"), installation); - } - - /// A pinned id is named by its id alone: nothing was asked of GitHub about its account, - /// so the configured owner is not presented as if it had been verified. - [Fact] - public async Task APinnedInstallationId_IsNamedByItsIdAlone() - { - using var key = RSA.Create(2048); - var installation = await Service(key, 162001736, "Northwind").GetInstallation().FirstAsync().Timeout(Budget).Await(); - Assert.Equal(new GitHubAppInstallation(162001736, null), installation); - } - - /// Disposes the registry and waits for its pools to report they have unwound — - /// only starts the cleanup. - public async ValueTask DisposeAsync() - { - pools.Dispose(); - await pools.Disposed.FirstAsync().Timeout(Budget).Await(); - GC.SuppressFinalize(this); - } -} diff --git a/test/Memex.Portal.Shared.Test/GitHubAppTokenMintFailureTest.cs b/test/Memex.Portal.Shared.Test/GitHubAppTokenMintFailureTest.cs deleted file mode 100644 index 5f39268a61..0000000000 --- a/test/Memex.Portal.Shared.Test/GitHubAppTokenMintFailureTest.cs +++ /dev/null @@ -1,245 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Mesh.Threading; -using Microsoft.Extensions.Options; -using Octokit; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The two halves of "GitHub said no", told apart by TYPE alone (#4736). -/// -/// 🚨 The failure this file makes impossible. A GitHub App installation token that -/// could NOT BE MINTED and one that was minted and then REJECTED are different events with -/// different operator actions — reprovision the App or reinstall it, versus look at the -/// installation's repository permissions. They used to be indistinguishable: every mint failure -/// threw a bare , so a consumer that degrades to an -/// anonymous fetch (the Store's package feed does, deliberately — a public source must keep -/// working) could only report the fault it saw ONE LAYER LATER, which is Octokit's -/// AuthorizationException: Bad credentials. That sentence describes a credential GitHub -/// REJECTED; read against a mint that never happened it sends the reader to the App's repository -/// permissions, which are fine, and away from the private key or the installation, which are not. -/// -/// -/// Each case names a stage AND proves it reached that stage — how many requests the fake -/// GitHub answered, and which. "The fault is a mint failure" would otherwise also pass on a -/// service that refused at its configuration guard and never called GitHub at all. -/// -public class GitHubAppTokenMintFailureTest -{ - // In-process fakes over an I/O pool; nothing here waits on a network or on a convergence. - private static readonly TimeSpan Budget = TestTimeouts.Quick; - - [Fact] - public async Task AnExchangeGitHubRefuses_IsAMintFailure_NamingTheExchange() - { - using var rsa = RSA.Create(2048); - var handler = new FakeGitHub { ExchangeStatus = HttpStatusCode.Unauthorized }; - var service = Service(handler, rsa.ExportRSAPrivateKeyPem()); - - var failure = await Assert.ThrowsAsync( - () => service.GetInstallationToken().Timeout(Budget).Await()); - - Assert.Equal(GitHubAppTokenMintStage.TokenExchange, failure.Stage); - Assert.Equal(401, failure.StatusCode); - // 🚨 The path was actually walked: the App identity passed the configuration guard and the - // exchange really was attempted. Without these two the assertion above would also pass on - // a service that refused at NotConfigured without calling GitHub once. - Assert.True(service.IsConfigured); - Assert.Equal(1, handler.Exchanges); - // Verdicts and statuses travel in the report; nothing that could be a secret does. - Assert.DoesNotContain("BEGIN RSA", failure.Message, StringComparison.Ordinal); - } - - [Fact] - public async Task AMintedTokenRejectedDownstream_IsNotAMintFailure() - { - using var rsa = RSA.Create(2048); - var handler = new FakeGitHub(); - var service = Service(handler, rsa.ExportRSAPrivateKeyPem()); - - // The other side of the change, in the shape a Store poll pass has: the mint SUCCEEDS, the - // token flows, and the repository read is what GitHub refuses. - var rejected = service.GetInstallationToken() - .SelectMany(_ => Observable.Throw(new AuthorizationException())); - - var failure = await Assert.ThrowsAsync( - () => rejected.Timeout(Budget).Await()); - - // The discriminator, with no message read on either side. - Assert.IsNotType(failure); - // A credential that WAS obtained — the whole difference from the case above. - Assert.Equal(1, handler.Exchanges); - } - - [Fact] - public async Task AnUnusablePrivateKey_IsAMintFailure_AndNeverReachesGitHub() - { - var handler = new FakeGitHub(); - var service = Service( - handler, "-----BEGIN RSA PRIVATE KEY-----\nnot-a-key\n-----END RSA PRIVATE KEY-----"); - - var failure = await Assert.ThrowsAsync( - () => service.GetInstallationToken().Timeout(Budget).Await()); - - Assert.Equal(GitHubAppTokenMintStage.Signing, failure.Stage); - Assert.Null(failure.StatusCode); - // 🚨 It failed at SIGNING rather than at the configuration guard — the identity IS - // configured — and GitHub was never asked, so this can never be a rejected credential. - Assert.True(service.IsConfigured); - Assert.Equal(0, handler.Requests); - Assert.DoesNotContain("not-a-key", failure.Message, StringComparison.Ordinal); - } - - [Fact] - public async Task AnAppWithNoInstallation_IsAMintFailure_NamingTheInstallation() - { - using var rsa = RSA.Create(2048); - var handler = new FakeGitHub { DiscoveryStatus = HttpStatusCode.NotFound }; - var service = Service(handler, rsa.ExportRSAPrivateKeyPem(), installationId: null); - - var failure = await Assert.ThrowsAsync( - () => service.GetInstallationToken().Timeout(Budget).Await()); - - Assert.Equal(GitHubAppTokenMintStage.InstallationDiscovery, failure.Stage); - Assert.Equal(404, failure.StatusCode); - // Discovery was reached and the exchange never was: the App is not installed, rather than - // installed with a credential GitHub turned down. - Assert.Equal(1, handler.Discoveries); - Assert.Equal(0, handler.Exchanges); - } - - [Theory] - [InlineData("""{"expires_at": "2026-09-18T12:00:00+00:00"}""")] // no 'token' at all - [InlineData("""{"token": 12345, "expires_at": "2026-09-18T12:00:00+00:00"}""")] // present, not a string - public async Task AnExchangeThatSucceedsWithNoUsableToken_IsAMintFailure_NamingTheResponse(string body) - { - using var rsa = RSA.Create(2048); - var handler = new FakeGitHub { ExchangeBody = body }; - var service = Service(handler, rsa.ExportRSAPrivateKeyPem()); - - var failure = await Assert.ThrowsAsync( - () => service.GetInstallationToken().Timeout(Budget).Await()); - - Assert.Equal(GitHubAppTokenMintStage.Response, failure.Stage); - // 🚨 NULL, and the null is the assertion. StatusCode is set only where GitHub REFUSED - // something; here the exchange SUCCEEDED, so a 201 attached to this failure would be an - // accurate number under a misleading claim — the very confusion #4736 is about. - Assert.Null(failure.StatusCode); - // The exchange was really walked: this cannot pass on a service that stopped earlier. - Assert.Equal(1, handler.Exchanges); - } - - [Fact] - public async Task AMintThatNeverReachesAVerdict_IsStillAMintFailure() - { - using var rsa = RSA.Create(2048); - // The transport dies before any status exists. Translate ALL of it or let it through: a - // fault with no GitHub verdict behind it is still "no credential was obtained", and a - // caller must not need a second catch arm for the untyped remainder. - var handler = new FakeGitHub { Transport = () => new HttpRequestException("no route to host") }; - var service = Service(handler, rsa.ExportRSAPrivateKeyPem()); - - var failure = await Assert.ThrowsAsync( - () => service.GetInstallationToken().Timeout(Budget).Await()); - - Assert.Equal(GitHubAppTokenMintStage.Transport, failure.Stage); - Assert.Null(failure.StatusCode); - Assert.NotNull(failure.InnerException); - Assert.Equal(1, handler.Requests); - } - - /// A usable App identity, so each case fails where it claims to rather than on the - /// configuration guard. - private static GitHubAppTokenService Service( - HttpMessageHandler handler, string privateKey, long? installationId = 77) => - new( - new IoPoolRegistry(), - Options.Create(new GitHubAppOptions - { - ClientId = "Iv23liTestApp", - PrivateKey = privateKey, - InstallationId = installationId, - }), - httpClient: new HttpClient(handler)); - - /// - /// The GitHub App endpoints, answering what each case asks for and COUNTING what it was asked — - /// the counts are what make an assertion about a stage an assertion about a path walked. - /// - private sealed class FakeGitHub : HttpMessageHandler - { - private int discoveries; - private int exchanges; - - /// Status for GET /app/installations — 200 with one installation by default. - public HttpStatusCode DiscoveryStatus { get; init; } = HttpStatusCode.OK; - - /// Status for POST /app/installations/{id}/access_tokens. - public HttpStatusCode ExchangeStatus { get; init; } = HttpStatusCode.Created; - - /// When set, the body a SUCCESSFUL exchange answers with — so a 2xx carrying no - /// usable token can be exercised without pretending GitHub refused anything. - public string? ExchangeBody { get; init; } - - /// When set, the request dies in transport instead of answering a status. - public Func? Transport { get; init; } - - public int Discoveries => Volatile.Read(ref discoveries); - - public int Exchanges => Volatile.Read(ref exchanges); - - public int Requests => Discoveries + Exchanges; - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - var path = request.RequestUri!.AbsolutePath; - if (request.Method == HttpMethod.Get && path == "/app/installations") - { - Interlocked.Increment(ref discoveries); - if (Transport is { } deadDiscovery) - return Task.FromException(deadDiscovery()); - return Task.FromResult(new HttpResponseMessage(DiscoveryStatus) - { - Content = new StringContent( - DiscoveryStatus == HttpStatusCode.OK - ? """[{"id": 77, "account": {"login": "Systemorph"}}]""" - : """{"message": "Not Found"}""", - Encoding.UTF8, "application/json"), - }); - } - - if (request.Method == HttpMethod.Post && path == "/app/installations/77/access_tokens") - { - Interlocked.Increment(ref exchanges); - if (Transport is { } deadExchange) - return Task.FromException(deadExchange()); - var expires = DateTimeOffset.UtcNow.AddHours(1).ToString("o"); - var success = ExchangeBody - ?? $$"""{"token": "ghs_test", "expires_at": "{{expires}}"}"""; - return Task.FromResult(new HttpResponseMessage(ExchangeStatus) - { - Content = new StringContent( - ExchangeStatus == HttpStatusCode.Created - ? success - : """{"message": "Bad credentials", "status": "401"}""", - Encoding.UTF8, "application/json"), - }); - } - - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent($"unexpected {request.Method} {path}"), - }); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/GitHubAppTokenRefreshTest.cs b/test/Memex.Portal.Shared.Test/GitHubAppTokenRefreshTest.cs deleted file mode 100644 index e4350f9d1a..0000000000 --- a/test/Memex.Portal.Shared.Test/GitHubAppTokenRefreshTest.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Mesh.Threading; -using Microsoft.Extensions.Options; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The installation-token cache of as a LONG-LIVED consumer -/// sees it: one observable held for the -/// life of a feed and subscribed once per poll pass — the shape of the Store's git poll loop. -/// -/// 🚨 The failure this file makes impossible (Systemorph/Memex#165): the promise was -/// captured when the observable was BUILT, so the refresh guard matched it exactly once. The first -/// expiry minted a new token; every later expiry compared against the stale capture, found the -/// cache already replaced, and handed the expired token back. Two token lifetimes after boot the -/// portal presented an expired installation token on every pass, and GitHub reported that as -/// 401 Bad credentials on every private source until the process restarted — measured on -/// memex-cloud as the first failure landing 2 h 01 min after the container started. -/// -/// Time is injected, so each assertion names the minute at which the held observable is -/// subscribed and the token it must answer with; the third refresh is the one the defect skipped. -/// -public class GitHubAppTokenRefreshTest -{ - private static readonly DateTimeOffset T0 = new(2026, 9, 6, 0, 0, 0, TimeSpan.Zero); - - // Every await is bounded: a wedged token stream must surface as a timeout, never a stuck run. - private static readonly TimeSpan Budget = TimeSpan.FromSeconds(10); - - [Fact] - public async Task OneHeldObservable_RefreshesOnEveryExpiry_NotOnlyTheFirst() - { - using var rsa = RSA.Create(2048); - var clock = T0; - var handler = new MintingHandler(() => clock); - var service = new GitHubAppTokenService( - new IoPoolRegistry(), - Options.Create(new GitHubAppOptions - { - ClientId = "Iv23liTestApp", - PrivateKey = rsa.ExportRSAPrivateKeyPem(), - InstallationId = 77, - }), - httpClient: new HttpClient(handler), - clock: () => clock); - - // ONE observable, held — the Store feed's shape — subscribed per pass below. - var held = service.GetInstallationToken(); - - Assert.Equal("ghs_1", await held.FirstAsync().Timeout(Budget).Await()); - Assert.Equal(1, handler.Mints); - - // Well inside the lifetime: replayed, no mint. - clock = T0.AddMinutes(30); - Assert.Equal("ghs_1", await held.FirstAsync().Timeout(Budget).Await()); - Assert.Equal(1, handler.Mints); - - // Inside the five-minute refresh window: the FIRST refresh — this one always worked. - clock = T0.AddMinutes(56); - Assert.Equal("ghs_2", await held.FirstAsync().Timeout(Budget).Await()); - Assert.Equal(2, handler.Mints); - - // Token 2 expires at T0+116 min. The SECOND refresh is the one the defect skipped: before - // the fix this pass answered ghs_2, four minutes from expiry, and every later pass answered - // it too — long after GitHub had stopped accepting it. - clock = T0.AddMinutes(112); - Assert.Equal("ghs_3", await held.FirstAsync().Timeout(Budget).Await()); - Assert.Equal(3, handler.Mints); - - // And the third, to show it is every expiry rather than the first two. - clock = T0.AddMinutes(168); - Assert.Equal("ghs_4", await held.FirstAsync().Timeout(Budget).Await()); - Assert.Equal(4, handler.Mints); - - // A pass while the token is fresh still replays: the fix did not turn the cache into a mint-per-pass. - clock = T0.AddMinutes(170); - Assert.Equal("ghs_4", await held.FirstAsync().Timeout(Budget).Await()); - Assert.Equal(4, handler.Mints); - } - - [Fact] - public async Task ConcurrentPassesOnAnExpiredToken_ShareOneRefresh() - { - using var rsa = RSA.Create(2048); - var clock = T0; - var handler = new MintingHandler(() => clock); - var service = new GitHubAppTokenService( - new IoPoolRegistry(), - Options.Create(new GitHubAppOptions - { - ClientId = "Iv23liTestApp", - PrivateKey = rsa.ExportRSAPrivateKeyPem(), - InstallationId = 77, - }), - httpClient: new HttpClient(handler), - clock: () => clock); - - var held = service.GetInstallationToken(); - Assert.Equal("ghs_1", await held.FirstAsync().Timeout(Budget).Await()); - - // Five sources poll on the same timer; when the token is stale they all refresh at once - // and must share the single new promise rather than mint five tokens. - clock = T0.AddMinutes(58); - var tokens = await Observable.Range(0, 5) - .SelectMany(_ => held.Take(1)) - .ToList() - .FirstAsync() - .Timeout(Budget) - .Await(); - Assert.All(tokens, t => Assert.Equal("ghs_2", t)); - Assert.Equal(2, handler.Mints); - } - - /// GitHub App token endpoint fake: every mint answers the next token, valid one hour from the injected clock. - private sealed class MintingHandler(Func clock) : HttpMessageHandler - { - private int mints; - public int Mints => Volatile.Read(ref mints); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - var path = request.RequestUri!.AbsolutePath; - if (request.Method == HttpMethod.Post && path == "/app/installations/77/access_tokens") - { - var n = Interlocked.Increment(ref mints); - var expires = clock().AddHours(1).ToString("o"); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created) - { - Content = new StringContent( - $$"""{"token": "ghs_{{n}}", "expires_at": "{{expires}}"}""", - Encoding.UTF8, "application/json"), - }); - } - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent($"unexpected {request.Method} {path}"), - }); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/GitHubBuildTokenTest.cs b/test/Memex.Portal.Shared.Test/GitHubBuildTokenTest.cs deleted file mode 100644 index 3d94805955..0000000000 --- a/test/Memex.Portal.Shared.Test/GitHubBuildTokenTest.cs +++ /dev/null @@ -1,304 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Security.Cryptography; -using MeshWeaver.Mesh.Security; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The verifier half of the build principal (#2483): what a GitHub Actions OIDC token has to prove -/// before the mesh will even look up a rule for it. -/// -/// 🚨 Signature-only is the failure this file exists to make impossible. Every workflow -/// run on GitHub carries a token signed by the same keys, so a verifier that checks the signature and -/// stops authenticates the entire public internet's CI. Each refusal below is therefore asserted with -/// an otherwise VALID token — same key, same signature, one claim moved — so a passing test can only -/// mean the claim itself was checked. -/// -/// The third state is asserted too: an unknown kid is -/// , neither accepted nor refused, because a routine -/// GitHub key rotation must become a re-read rather than a fleet-wide outage — and must never become -/// an admission. -/// -public class GitHubBuildTokenTest -{ - private const string Audience = "https://registry.example.test"; - private const string Repository = "Systemorph/MeshWeaver.SocialMedia"; - private static readonly DateTimeOffset Now = new(2026, 9, 1, 12, 0, 0, TimeSpan.Zero); - private static readonly string[] Audiences = [Audience]; - - private static GitHubTokenVerification Verify( - GitHubTokenFactory factory, string token, DateTimeOffset? now = null, - IReadOnlyCollection? audiences = null, - IReadOnlyDictionary? keys = null) => - GitHubActionsToken.Verify(token, now ?? Now, audiences ?? Audiences, keys ?? factory.Keys()); - - [Fact] - public void AGenuineTokenVerifies_AndCarriesTheClaimsADecisionNeeds() - { - using var factory = new GitHubTokenFactory(); - - var result = Verify(factory, factory.Mint(Audience, issuedAt: Now)); - - Assert.True(result.IsVerified); - Assert.Equal(Repository, result.Claims!.Repository); - Assert.Equal("push", result.Claims.EventName); - Assert.Equal("refs/heads/main", result.Claims.Ref); - Assert.Equal("123456789", result.Claims.RepositoryId); - Assert.Equal(Audience, result.Claims.Audience); - } - - [Fact] - public void NoConfiguredAudience_RefusesAPerfectlyValidToken() - { - // The whole build-principal leg is off until an operator names an audience. "Unconfigured" - // must never read as "accept anything": every GitHub run in the world verifies here. - using var factory = new GitHubTokenFactory(); - - var result = Verify(factory, factory.Mint(Audience, issuedAt: Now), audiences: []); - - Assert.False(result.IsVerified); - Assert.False(result.KeyUnknown); - } - - [Fact] - public void AnotherServicesAudienceIsRefused() - { - // The exact attack the audience exists to stop: a token a workflow legitimately minted for - // Azure, replayed here. - using var factory = new GitHubTokenFactory(); - - var result = Verify(factory, factory.Mint("api://AzureADTokenExchange", issuedAt: Now)); - - Assert.False(result.IsVerified); - } - - [Fact] - public void AnAudienceArrayIsHonoured_WhenItContainsOurs() - { - using var factory = new GitHubTokenFactory(); - var token = factory.Mint( - Audience, issuedAt: Now, audienceJson: $"[\"api://other\",\"{Audience}\"]"); - - Assert.True(Verify(factory, token).IsVerified); - // …and not when it does not. - var other = factory.Mint(Audience, issuedAt: Now, audienceJson: "[\"api://other\"]"); - Assert.False(Verify(factory, other).IsVerified); - } - - [Fact] - public void AnotherIssuerIsRefused() - { - using var factory = new GitHubTokenFactory(); - - var result = Verify(factory, factory.Mint(Audience, issuedAt: Now, issuer: "https://evil.example")); - - Assert.False(result.IsVerified); - // …and the routing peek agrees it was never ours to verify in the first place. - Assert.False(GitHubActionsToken.IsGitHubIssued( - factory.Mint(Audience, issuedAt: Now, issuer: "https://evil.example"))); - } - - [Fact] - public void AnIssuerThatMerelySTARTSWithGitHubsIsRefused() - { - // A prefix match here would trust https://token.actions.githubusercontent.com.evil.example. - using var factory = new GitHubTokenFactory(); - var token = factory.Mint( - Audience, issuedAt: Now, issuer: GitHubActionsToken.Issuer + ".evil.example"); - - Assert.False(Verify(factory, token).IsVerified); - Assert.False(GitHubActionsToken.IsGitHubIssued(token)); - } - - [Fact] - public void AnExpiredTokenIsRefused() - { - using var factory = new GitHubTokenFactory(); - var token = factory.Mint(Audience, issuedAt: Now.AddHours(-2), lifetime: TimeSpan.FromMinutes(10)); - - Assert.False(Verify(factory, token).IsVerified); - // …and the same token verified at its own issue instant does, so only the clock moved. - Assert.True(Verify(factory, token, now: Now.AddHours(-2)).IsVerified); - } - - [Fact] - public void ATokenThatIsNotYetValidIsRefused() - { - using var factory = new GitHubTokenFactory(); - var token = factory.Mint(Audience, issuedAt: Now.AddHours(1)); - - Assert.False(Verify(factory, token).IsVerified); - } - - [Fact] - public void TheTokensOwnAlgIsNeverHonoured() - { - using var factory = new GitHubTokenFactory(); - - // `none` — the classic forgery. - Assert.False(Verify(factory, factory.Mint(Audience, issuedAt: Now, algorithm: "none")).IsVerified); - // …and an HMAC claim over the same bytes: the header decides which verifier runs, and this - // one only ever runs RS256. - Assert.False(Verify(factory, factory.Mint(Audience, issuedAt: Now, algorithm: "HS256")).IsVerified); - } - - [Fact] - public void ASignatureFromAnotherKeyIsRefused() - { - using var factory = new GitHubTokenFactory(); - using var stranger = RSA.Create(2048); - - var token = factory.Mint(Audience, issuedAt: Now, signWith: stranger); - - Assert.False(Verify(factory, token).IsVerified); - } - - [Fact] - public void AKeySetPublishingSomeoneElsesKeyUnderTheSameKidIsRefused() - { - // The JWKS is the trust anchor; a token signed by us must not verify against a key set that - // merely REUSES our kid. - using var factory = new GitHubTokenFactory(); - var keys = GitHubActionsToken.ParseJwks(factory.JwksOfAStranger()); - - var result = Verify(factory, factory.Mint(Audience, issuedAt: Now), keys: keys); - - Assert.False(result.IsVerified); - Assert.False(result.KeyUnknown, "the kid WAS published — this is a rejection, not an unknown key"); - } - - [Fact] - public void ASwappedPayloadIsRefused() - { - // A valid signature over DIFFERENT bytes: the only forgery left once `alg` and the key are - // pinned, and the one that would hand another repository's identity to this one. - using var factory = new GitHubTokenFactory(); - - var token = factory.MintWithSwappedPayload(Audience, Repository, "Systemorph/Evil"); - - Assert.False(Verify(factory, token).IsVerified); - } - - [Fact] - public void AnUnknownKidIsUNDETERMINED_NeitherAcceptedNorRefused() - { - // 🚨 The third state (core #2901). GitHub rotates these keys; folding "I do not hold that - // key" into "rejected" turns a routine rotation into a fleet-wide outage, and folding it - // into "accepted" is a hole. It is neither: the caller re-reads the set and asks again. - using var factory = new GitHubTokenFactory(); - - var result = Verify(factory, factory.Mint(Audience, issuedAt: Now, keyId: "rotated-away")); - - Assert.False(result.IsVerified); - Assert.True(result.KeyUnknown); - } - - [Fact] - public void ATokenWithNoRepositoryClaimIsRefused() - { - using var factory = new GitHubTokenFactory(); - - Assert.False(Verify(factory, factory.Mint(Audience, issuedAt: Now, repository: "")).IsVerified); - } - - [Fact] - public void GarbageIsRefusedWithoutThrowing() - { - // This runs on an UNAUTHENTICATED request with attacker-controlled input: a throwing parse - // would let anyone make the registry raise and unwind an exception per request. - using var factory = new GitHubTokenFactory(); - - foreach (var candidate in new[] { null, "", " ", "a.b", "a.b.c", "...", new string('x', 20_000) }) - Assert.False(GitHubActionsToken.Verify(candidate, Now, Audiences, factory.Keys()).IsVerified); - } - - // ── the JWKS reader ────────────────────────────────────────────────────── - - [Fact] - public void TheKeySetSkipsEveryEntryThisVerifierWouldNotUse() - { - using var factory = new GitHubTokenFactory(); - var keys = GitHubActionsToken.ParseJwks(factory.Jwks( - """{"kty":"EC","use":"sig","kid":"ec","crv":"P-256","x":"AA","y":"BB"}""", - """{"kty":"RSA","use":"enc","alg":"RS256","kid":"enc","n":"AQAB","e":"AQAB"}""", - """{"kty":"RSA","use":"sig","alg":"RS512","kid":"rs512","n":"AQAB","e":"AQAB"}""", - // RSA-1024: a modulus short enough to be forgeable is dropped at PARSE time rather - // than trusted at verify time. - $$"""{"kty":"RSA","use":"sig","alg":"RS256","kid":"short","n":"{{new string('A', 171)}}","e":"AQAB"}""")); - - Assert.Equal([factory.KeyId], keys.Keys); - } - - [Fact] - public void AMalformedKeySetIsEmpty_NeverAThrow() - { - foreach (var document in new[] { null, "", "not json", "[]", """{"keys":{}}""", """{"keys":[1,2]}""" }) - Assert.Empty(GitHubActionsToken.ParseJwks(document)); - } - - [Fact] - public void DiscoveryMayMoveThePath_NeverTheHost() - { - Assert.Equal( - "https://token.actions.githubusercontent.com/somewhere/else", - GitHubActionsToken.JwksUriFromDiscovery( - """{"jwks_uri":"https://token.actions.githubusercontent.com/somewhere/else"}""")); - - // 🚨 A discovery document is fetched over the network. It is allowed to move the PATH; it is - // never allowed to move the trust anchor. - Assert.Null(GitHubActionsToken.JwksUriFromDiscovery("""{"jwks_uri":"https://evil.example/jwks"}""")); - Assert.Null(GitHubActionsToken.JwksUriFromDiscovery( - """{"jwks_uri":"http://token.actions.githubusercontent.com/jwks"}""")); - Assert.Null(GitHubActionsToken.JwksUriFromDiscovery("""{"jwks_uri":"/relative"}""")); - Assert.Null(GitHubActionsToken.JwksUriFromDiscovery("not json")); - } - - // ── repository matching, classic and immutable ─────────────────────────── - - [Fact] - public void TheImmutableSubjectFormatMatchesTheClassicOne() - { - // 🚨 The migration hazard: when GitHub moves an org onto immutable ids, a principal written - // in the classic form must keep matching — or every build principal in the fleet silently - // stops authenticating on a day nobody changed anything. - Assert.True(GitHubActionsToken.RepositoryEquals( - "Systemorph/MeshWeaver.SocialMedia", "Systemorph@12345/MeshWeaver.SocialMedia@67890")); - Assert.True(GitHubActionsToken.RepositoryEquals( - "Systemorph@12345/MeshWeaver.SocialMedia@67890", "Systemorph/MeshWeaver.SocialMedia")); - Assert.Equal( - "Systemorph/MeshWeaver.SocialMedia", - GitHubActionsToken.NormalizeRepository("Systemorph@12345/MeshWeaver.SocialMedia@67890")); - } - - [Fact] - public void RepositoryMatchingIsExact_NeverAPrefix() - { - Assert.False(GitHubActionsToken.RepositoryEquals( - "Systemorph/MeshWeaver", "Systemorph/MeshWeaver.Evil")); - Assert.False(GitHubActionsToken.RepositoryEquals( - "Systemorph/MeshWeaver", "Evil/MeshWeaver")); - Assert.False(GitHubActionsToken.RepositoryEquals("Systemorph/MeshWeaver", null)); - Assert.False(GitHubActionsToken.RepositoryEquals("", "")); - // GitHub names are case-insensitive, so an admin's casing must not lock a repo out. - Assert.True(GitHubActionsToken.RepositoryEquals( - "systemorph/meshweaver.socialmedia", "Systemorph/MeshWeaver.SocialMedia")); - // …and a non-numeric suffix after '@' is NOT an immutable id, so it is not stripped. - Assert.False(GitHubActionsToken.RepositoryEquals( - "Systemorph/MeshWeaver", "Systemorph/MeshWeaver@evil")); - } - - [Fact] - public void ARepositoryResolvesToOneDeterministicNodePath() - { - Assert.Equal( - "Admin/_BuildPrincipal/systemorph--meshweaver.socialmedia", - BuildPrincipal.PathFor("Systemorph/MeshWeaver.SocialMedia")); - // Both claim formats route to the SAME node — the path is derived from the normalized form. - Assert.Equal( - BuildPrincipal.PathFor("Systemorph/MeshWeaver.SocialMedia"), - BuildPrincipal.PathFor("Systemorph@1/MeshWeaver.SocialMedia@2")); - Assert.Equal("", BuildPrincipal.PathFor("")); - } -} diff --git a/test/Memex.Portal.Shared.Test/GitHubLoginEndpointsTest.cs b/test/Memex.Portal.Shared.Test/GitHubLoginEndpointsTest.cs deleted file mode 100644 index ebc42cedc5..0000000000 --- a/test/Memex.Portal.Shared.Test/GitHubLoginEndpointsTest.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Memex.Portal.Shared.Social; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Security-critical logic of the "Sign in with GitHub" provider: the open-redirect guard on the -/// post-login return path (an attacker must not be able to bounce a freshly-authenticated user to an -/// external site), and the sign-in allow-predicate (which today admits everyone per the deployment -/// decision, and is the single place to tighten to an org allowlist later). The full authorize → -/// callback → cookie flow + the GitHub email fetch are integration-level (HTTP + cookies) and are -/// exercised by the e2e portal stack. -/// -public class GitHubLoginEndpointsTest -{ - [Theory] - [InlineData(null, "/")] // no return → home - [InlineData("", "/")] // empty → home - [InlineData(" ", "/")] // whitespace → home - [InlineData("/AgenticEngineering/Training", "/AgenticEngineering/Training")] // local path preserved - [InlineData("/space/x?tab=1", "/space/x?tab=1")] // local path + query preserved - [InlineData("https://evil.example.com", "/")] // absolute URL rejected - [InlineData("http://evil.example.com/x", "/")] // absolute URL rejected - [InlineData("//evil.example.com", "/")] // protocol-relative rejected - [InlineData("evil.example.com", "/")] // bare host (no leading slash) rejected - public void SafeLocal_RejectsExternalRedirects_KeepsLocalPaths(string? input, string expected) => - Assert.Equal(expected, GitHubLoginEndpoints.SafeLocal(input)); - - [Fact] - public void IsGitHubUserAllowed_AdmitsEveryone_Today() - { - // The deployment decision: any GitHub account may sign in (same access as an Entra user). - // If this ever tightens to an allowlist, this test documents the change point. - Assert.True(GitHubLoginEndpoints.IsGitHubUserAllowed("octocat", "octocat@example.com")); - Assert.True(GitHubLoginEndpoints.IsGitHubUserAllowed(null, "someone@example.com")); - } -} diff --git a/test/Memex.Portal.Shared.Test/GitHubPackageSourceInstallReadTest.cs b/test/Memex.Portal.Shared.Test/GitHubPackageSourceInstallReadTest.cs deleted file mode 100644 index c5c895e714..0000000000 --- a/test/Memex.Portal.Shared.Test/GitHubPackageSourceInstallReadTest.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The package.json source's INSTALL read through the narrow fetch is scoped by the -/// SUBDIRECTORY argument, and only by it (MeshWeaver#5826): it passes the package's folder as the -/// subdirectory and a keep-everything predicate, and — unlike , -/// whose snapshot paths are repo-relative — it cannot post-filter, because the paths it gets back -/// are already relative to that folder. -/// -/// So the answer is right exactly when the folder reaches the fetch. That is the filtered -/// fetch's contract (: "like Fetch … but downloads ONLY the blobs -/// whose subdirectory-relative path satisfies pathFilter"; the Octokit and git-protocol clients and -/// the interface's default all scope by the prefix first), and this pins the CALL: a refactor that -/// dropped the folder, or handed the listing's manifest-only predicate to the install, would -/// install the whole repository or an empty package with no other test noticing. -/// -public class GitHubPackageSourceInstallReadTest -{ - private const string RepoUrl = "https://github.com/acme/catalog"; - - private static readonly IReadOnlyList Repo = - [ - new("welcome-note/package.json", """{"id":"welcome-note"}"""), - new("welcome-note/content/Note.md", "# Welcome"), - new("welcome-note/content/logo.png", "", [1, 2, 3]), - new("other/package.json", """{"id":"other"}"""), - new("other/content/Secret.md", "# Not this package"), - ]; - - /// What a fetch was asked for. - private sealed record Call(string? Subdirectory, bool Narrow); - - /// A repository that honours the fetch contract: confine to the subdirectory, return - /// subdirectory-relative paths, keep what the predicate keeps. - private sealed class ContractRepo - { - public ImmutableList Calls { get; private set; } = ImmutableList.Empty; - - public IObservable Plain(string url, string gitRef, string? subdirectory, string token) - => Read(subdirectory, static _ => true, narrow: false); - - public IObservable Narrow( - string url, string gitRef, string? subdirectory, string token, Func keep) - => Read(subdirectory, keep, narrow: true); - - private IObservable Read(string? subdirectory, Func keep, bool narrow) - => Observable.Defer(() => - { - Calls = Calls.Add(new Call(subdirectory, narrow)); - var prefix = string.IsNullOrEmpty(subdirectory) ? "" : subdirectory.TrimEnd('/') + "/"; - var files = Repo - .Where(f => f.Path.StartsWith(prefix, StringComparison.Ordinal)) - .Select(f => f with { Path = f.Path[prefix.Length..] }) - .Where(f => keep(f.Path)) - .ToList(); - return Observable.Return(new RepoSnapshot("commit-1", files)); - }); - } - - private static Task> Files(GitHubPackageSource source) => - source.FetchPackageFiles(new PackageManifest { Id = "welcome-note" }, "main") - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - [Fact] - public async Task TheNarrowInstallRead_AsksForThePackagesFolder_AndReturnsExactlyThatFolder() - { - var repo = new ContractRepo(); - var narrow = new GitHubPackageSource(repo.Plain, RepoUrl) { NarrowFetch = repo.Narrow }; - - var files = await Files(narrow); - - repo.Calls.Should().Equal([new Call("welcome-note", Narrow: true)], - "the install read goes through the narrow fetch, once, and the package's folder is its " - + "subdirectory — the only thing that scopes this read"); - files.Select(f => f.RelativePath).OrderBy(path => path, StringComparer.Ordinal).Should().Equal( - ["content/Note.md", "content/logo.png", "package.json"], - "the WHOLE folder — the keep-everything predicate, never the listing's manifest-only one " - + "— and nothing of any other package"); - files.Single(f => f.RelativePath == "content/logo.png").Binary.Should().Equal([1, 2, 3], - "a binary blob travels as bytes (#848)"); - } - - [Fact] - public async Task TheNarrowRead_AndThePlainRead_GiveTheSameAnswer() - { - var narrowRepo = new ContractRepo(); - var plainRepo = new ContractRepo(); - var narrow = await Files(new GitHubPackageSource(narrowRepo.Plain, RepoUrl) { NarrowFetch = narrowRepo.Narrow }); - var plain = await Files(new GitHubPackageSource(plainRepo.Plain, RepoUrl)); - - plainRepo.Calls.Should().Equal([new Call("welcome-note", Narrow: false)], - "the control: without a narrow fetch the plain one is asked for the same folder"); - narrow.Select(f => $"{f.RelativePath}|{f.Content}|{f.Binary?.Length}").Should().Equal( - plain.Select(f => $"{f.RelativePath}|{f.Content}|{f.Binary?.Length}"), - "the answer must not depend on which of the two reads served it"); - } -} diff --git a/test/Memex.Portal.Shared.Test/GitHubTokenFactory.cs b/test/Memex.Portal.Shared.Test/GitHubTokenFactory.cs deleted file mode 100644 index e58fb093d2..0000000000 --- a/test/Memex.Portal.Shared.Test/GitHubTokenFactory.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using MeshWeaver.Mesh.Security; - -namespace Memex.Portal.Shared.Test; - -/// -/// Mints GitHub-Actions-shaped OIDC tokens and the JWKS that verifies them, so the build-principal -/// leg (#2483) can be exercised against REAL RSA signatures rather than a stubbed verifier. -/// -/// 🚨 This is the producer half of the contract under test, written independently of -/// : it signs with directly and lays the JWKS out -/// the way GitHub does. A helper that reused the verifier's own encoder could agree with a broken -/// one — the classic gate-that-tests-its-own-input. -/// -internal sealed class GitHubTokenFactory : IDisposable -{ - private readonly RSA key = RSA.Create(2048); - - /// The kid this factory's key is published under. - public string KeyId { get; init; } = "test-key-1"; - - /// The JWKS document publishing this factory's public key, exactly as GitHub lays one - /// out — plus, deliberately, entries this verifier must skip. - /// Extra raw JWK JSON objects to include beside the real key. - /// The JWKS document. - public string Jwks(params string[] extras) - { - var parameters = key.ExportParameters(includePrivateParameters: false); - var entries = new List - { - $$""" - {"kty":"RSA","use":"sig","alg":"RS256","kid":"{{KeyId}}", - "n":"{{Base64Url(parameters.Modulus!)}}","e":"{{Base64Url(parameters.Exponent!)}}"} - """, - }; - entries.AddRange(extras); - return $$"""{"keys":[{{string.Join(",", entries)}}]}"""; - } - - /// A JWKS that publishes a DIFFERENT key under the same kid — the shape a - /// signature forged with a key of the attacker's own choosing has to fail against. - /// The JWKS document. - public string JwksOfAStranger() - { - using var stranger = RSA.Create(2048); - var parameters = stranger.ExportParameters(includePrivateParameters: false); - return $$""" - {"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"{{KeyId}}", - "n":"{{Base64Url(parameters.Modulus!)}}","e":"{{Base64Url(parameters.Exponent!)}}"}]} - """; - } - - /// The parsed key set this factory's tokens verify against. - /// Keys by kid. - public IReadOnlyDictionary Keys() => GitHubActionsToken.ParseJwks(Jwks()); - - /// - /// Mints a token. Every claim has a realistic default so a test names only what it is varying. - /// - /// The aud claim. - /// The repository claim. - /// The event_name claim. - /// The ref claim. - /// The iss claim. - /// Issue instant; nbf/iat come from here. - /// How long after the token expires. - /// The alg header — vary it to forge. - /// The kid header — vary it to name a key nobody publishes. - /// The repository_id claim. - /// Sign with this key instead of the factory's own. - /// Raw JSON for aud (e.g. an array), overriding - /// . - /// The token string. - public string Mint( - string audience, - string repository = "Systemorph/MeshWeaver.SocialMedia", - string eventName = "push", - string gitRef = "refs/heads/main", - string issuer = GitHubActionsToken.Issuer, - DateTimeOffset? issuedAt = null, - TimeSpan? lifetime = null, - string algorithm = "RS256", - string? keyId = null, - string repositoryId = "123456789", - RSA? signWith = null, - string? audienceJson = null) - { - var start = issuedAt ?? DateTimeOffset.UtcNow; - var expires = start.Add(lifetime ?? TimeSpan.FromMinutes(10)); - var header = $$"""{"alg":"{{algorithm}}","typ":"JWT","kid":"{{keyId ?? KeyId}}","x5t":"ignored"}"""; - var payload = $$""" - {"iss":{{Json(issuer)}},"sub":"repo:{{repository}}:ref:{{gitRef}}", - "aud":{{audienceJson ?? Json(audience)}}, - "iat":{{start.ToUnixTimeSeconds()}},"nbf":{{start.ToUnixTimeSeconds()}}, - "exp":{{expires.ToUnixTimeSeconds()}},"jti":"{{Guid.NewGuid():N}}", - "repository":{{Json(repository)}},"repository_id":{{Json(repositoryId)}}, - "repository_owner":{{Json(repository.Split('/')[0])}},"repository_owner_id":"9999", - "event_name":{{Json(eventName)}},"ref":{{Json(gitRef)}}, - "workflow":"CI","actor":"rbuergi","run_id":"42", - "job_workflow_ref":"{{repository}}/.github/workflows/ci.yml@{{gitRef}}"} - """; - - var signingInput = Base64Url(Encoding.UTF8.GetBytes(Compact(header))) - + "." + Base64Url(Encoding.UTF8.GetBytes(Compact(payload))); - var signature = (signWith ?? key).SignData( - Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - return $"{signingInput}.{Base64Url(signature)}"; - } - - /// A token whose PAYLOAD was swapped after signing — a valid signature over different - /// bytes, which is the only interesting forgery once `alg` is pinned. - /// The aud of the honest token. - /// The repository actually signed for. - /// The repository substituted afterwards. - /// The tampered token. - public string MintWithSwappedPayload( - string audience, string honestRepository, string swappedRepository) - { - var honest = Mint(audience, honestRepository); - var forged = Mint(audience, swappedRepository); - var honestParts = honest.Split('.'); - var forgedParts = forged.Split('.'); - // header + payload from the forgery, signature from the honest token. - return $"{forgedParts[0]}.{forgedParts[1]}.{honestParts[2]}"; - } - - /// - public void Dispose() => key.Dispose(); - - private static string Compact(string json) => - string.Concat(json.Where(c => c is not ('\n' or '\r' or ' '))); - - private static string Json(string value) => JsonSerializer.Serialize(value); - - private static string Base64Url(byte[] bytes) => - Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); -} diff --git a/test/Memex.Portal.Shared.Test/GitHubWebhookSignerScopeTest.cs b/test/Memex.Portal.Shared.Test/GitHubWebhookSignerScopeTest.cs deleted file mode 100644 index a05a732fbd..0000000000 --- a/test/Memex.Portal.Shared.Test/GitHubWebhookSignerScopeTest.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Security.Cryptography; -using System.Text; -using Memex.Portal.Shared.Social; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A delivery signed with the instance's OWN GitHub App secret reaches the push trigger only. That -/// secret is held by the instance's administrators, a platform role with no data rights, so a forged -/// issues/issue_comment/workflow_run delivery signed with it must never reach -/// the processors that write into Spaces as System. The deployment's secret keeps every event. -/// -public class GitHubWebhookSignerScopeTest -{ - private const string DeploymentSecret = "deployment-secret"; - private const string AppSecret = "instance-app-secret"; - private static readonly byte[] Body = Encoding.UTF8.GetBytes("""{"repository":{"full_name":"Contoso/records"}}"""); - - private static string Sign(string secret) => - "sha256=" + Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Body)).ToLowerInvariant(); - - [Theory] - [InlineData("issues")] - [InlineData("issue_comment")] - [InlineData("workflow_run")] - public void AnInstanceAppSignedDelivery_OfANonPushEvent_IsRefused(string eventType) - { - var signer = GitHubWebhookEndpoints.VerifiedSigner(DeploymentSecret, AppSecret, Body, Sign(AppSecret)); - Assert.Equal(GitHubWebhookEndpoints.Signer.InstanceApp, signer); - Assert.False(GitHubWebhookEndpoints.SignerMayDeliver(signer!, eventType)); - - // Control: the same event signed with the deployment's secret is admitted. - var deployment = GitHubWebhookEndpoints.VerifiedSigner(DeploymentSecret, AppSecret, Body, Sign(DeploymentSecret)); - Assert.Equal(GitHubWebhookEndpoints.Signer.Deployment, deployment); - Assert.True(GitHubWebhookEndpoints.SignerMayDeliver(deployment!, eventType)); - } - - [Theory] - [InlineData("push")] - [InlineData("ping")] - public void AnInstanceAppSignedPush_IsAdmitted(string eventType) - { - var signer = GitHubWebhookEndpoints.VerifiedSigner(null, AppSecret, Body, Sign(AppSecret)); - Assert.Equal(GitHubWebhookEndpoints.Signer.InstanceApp, signer); - Assert.True(GitHubWebhookEndpoints.SignerMayDeliver(signer!, eventType)); - } - - [Fact] - public void ADeliverySignedByNeither_HasNoSigner() - => Assert.Null(GitHubWebhookEndpoints.VerifiedSigner(DeploymentSecret, AppSecret, Body, Sign("someone-else"))); -} diff --git a/test/Memex.Portal.Shared.Test/GitSyncNamesItsReadIdentityTest.cs b/test/Memex.Portal.Shared.Test/GitSyncNamesItsReadIdentityTest.cs deleted file mode 100644 index 8a0ece3561..0000000000 --- a/test/Memex.Portal.Shared.Test/GitSyncNamesItsReadIdentityTest.cs +++ /dev/null @@ -1,181 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A GitSync read NAMES the identity it authenticates as — the instance's GitHub App installation, -/// a token a source declared (by its configuration key), or an installation a source named — and -/// never the token. Asked of an operator: "show that the records sync runs on the App, not on a -/// personal access token"; the activity said "Imported" and nothing about its credential. -/// -/// The name comes from the SAME decision the token resolution takes -/// (), so a repository read as the App is named as -/// the App, and a repository whose source declared a token is named by that token's key. -/// -public class GitSyncNamesItsReadIdentityTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Records = "https://github.com/Northwind/records"; - private const string Tokened = "https://github.com/Northwind/tokened"; - private const string Elsewhere = "https://github.com/Fabrikam/modules"; - private const string TokenValue = "github_pat_never_named_on_an_activity"; - - private static readonly TimeSpan Budget = TimeSpan.FromSeconds(20); - - // A real key, so the App JWT signs and the mint reaches the fake GitHub below. - private readonly RSA appKey = RSA.Create(2048); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .AddGitHubSyncTypes() - .ConfigureServices(services => services - .AddGitHubSyncServices() - // Last registration wins. The App is configured with an OWNER but no installation id, - // and the fake GitHub has NO installation on that owner: the mint falls back to the - // first installation (Contoso, 11) — the case where naming the configured owner - // would attribute the read to an account that did not perform it. - .AddSingleton>(Options.Create(new GitHubAppOptions - { - ClientId = "Iv23-test", - PrivateKey = appKey.ExportRSAPrivateKeyPem(), - InstallationOwner = "Northwind", - })) - .AddSingleton(sp => new GitHubAppTokenService( - sp.GetRequiredService(), - sp.GetRequiredService>(), - httpClient: new HttpClient(new InstallationsHandler( - """[{"id": 11, "account": {"login": "Contoso"}}, {"id": 33, "account": {"login": "Fabrikam"}}]""")))) - .AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["PluginCatalog:Sources:0:Name"] = "Records", - ["PluginCatalog:Sources:0:RepoPath"] = Records, - ["PluginCatalog:Sources:1:Name"] = "Tokened", - ["PluginCatalog:Sources:1:RepoPath"] = Tokened, - ["PluginCatalog:Sources:1:TokenKey"] = "PluginCatalog__SourceTokens__Tokened", - ["PluginCatalog:SourceTokens:Tokened"] = TokenValue, - ["PluginCatalog:Sources:2:Name"] = "Elsewhere", - ["PluginCatalog:Sources:2:RepoPath"] = Elsewhere, - ["PluginCatalog:Sources:2:InstallationOwner"] = "Fabrikam", - }) - .Build())); - - private GitHubSyncService Sync => Mesh.ServiceProvider.GetRequiredService(); - - private static string Caller => MeshWeaver.Mesh.Security.WellKnownUsers.System; - - /// - /// The App installation is named from the installation the token was MINTED for, not from - /// configuration: no installation matches the configured owner (Northwind), so the mint falls - /// back to the first (Contoso, 11) — and that is what the activity must name. - /// - [Fact] - public async Task ARepositoryNoSourceDeclares_IsNamedByTheInstallationTheMintSelected() - { - var identity = await Sync.ReadIdentityFor(Caller, Records).FirstAsync().Timeout(Budget).Await(); - - Assert.Equal(GitHubReadIdentityKind.AppInstallation, identity.Kind); - Assert.Equal(11, identity.InstallationId); - Assert.Equal("Contoso", identity.InstallationOwner); - - var line = identity.ToLogMessage(); - Assert.Equal("activity.gitsync.identity.app", line.MessageKey); - Assert.Equal(LogLevel.Information, line.LogLevel); - Assert.Contains("11 (Contoso)", line.Message, StringComparison.Ordinal); - Assert.DoesNotContain("Northwind", line.Message, StringComparison.Ordinal); - } - - /// A repository with no source at all (a Space's own sync config) is the App too. - [Fact] - public async Task ARepositoryOutsideTheCatalog_IsReadAsTheApp() - { - var identity = await Sync.ReadIdentityFor(Caller, "https://github.com/Northwind/space") - .FirstAsync().Timeout(Budget).Await(); - Assert.Equal(GitHubReadIdentityKind.AppInstallation, identity.Kind); - } - - [Fact] - public async Task ATokenDeclaredSource_IsNamedByItsKey_NeverByItsValue() - { - var identity = await Sync.ReadIdentityFor(Caller, Tokened).FirstAsync().Timeout(Budget).Await(); - - Assert.Equal(GitHubReadIdentityKind.SourceToken, identity.Kind); - Assert.Equal("PluginCatalog:SourceTokens:Tokened", identity.TokenKey); - - var line = identity.ToLogMessage(); - Assert.Equal("activity.gitsync.identity.sourceToken", line.MessageKey); - Assert.Contains("PluginCatalog:SourceTokens:Tokened", line.Message, StringComparison.Ordinal); - Assert.DoesNotContain(TokenValue, line.Message, StringComparison.Ordinal); - Assert.DoesNotContain(TokenValue, identity.ToString(), StringComparison.Ordinal); - } - - [Fact] - public async Task AnInstallationNamingSource_IsNamedByItsOwner() - { - var identity = await Sync.ReadIdentityFor(Caller, Elsewhere).FirstAsync().Timeout(Budget).Await(); - - Assert.Equal(GitHubReadIdentityKind.SourceInstallation, identity.Kind); - Assert.Equal("Fabrikam", identity.InstallationOwner); - Assert.Equal("activity.gitsync.identity.sourceInstallation", identity.ToLogMessage().MessageKey); - } - - /// The line for each kind is keyed; "none" is a Warning; the person is named by login. - [Fact] - public void EveryKind_WritesAKeyedLine() - { - var person = new GitHubReadIdentity(GitHubReadIdentityKind.PersonConnected, Login: "octocat").ToLogMessage(); - Assert.Equal("activity.gitsync.identity.person", person.MessageKey); - Assert.Contains("@octocat", person.Message, StringComparison.Ordinal); - - var none = new GitHubReadIdentity(GitHubReadIdentityKind.None).ToLogMessage(); - Assert.Equal("activity.gitsync.identity.none", none.MessageKey); - Assert.Equal(LogLevel.Warning, none.LogLevel); - - // An installation pinned by id alone is named by its id under its own key — never an - // English fragment passed as an argument, which the catalog would not translate. - var pinned = new GitHubReadIdentity(GitHubReadIdentityKind.AppInstallation, InstallationId: 162001736) - .ToLogMessage(); - Assert.Equal("activity.gitsync.identity.appId", pinned.MessageKey); - Assert.Contains("162001736", pinned.Message, StringComparison.Ordinal); - } - - /// Fake GitHub: lists the App's installations and mints a token for any of them. - internal sealed class InstallationsHandler(string installationsJson) : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - var path = request.RequestUri!.AbsolutePath; - if (request.Method == HttpMethod.Get && path == "/app/installations") - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(installationsJson), - }); - if (request.Method == HttpMethod.Post && path.StartsWith("/app/installations/", StringComparison.Ordinal) - && path.EndsWith("/access_tokens", StringComparison.Ordinal)) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created) - { - Content = new StringContent( - $$"""{"token": "ghs_test", "expires_at": "{{DateTimeOffset.UtcNow.AddHours(1):o}}"}"""), - }); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/GlobalAdminSeedRefusesBlankIdsTest.cs b/test/Memex.Portal.Shared.Test/GlobalAdminSeedRefusesBlankIdsTest.cs deleted file mode 100644 index c70d53a442..0000000000 --- a/test/Memex.Portal.Shared.Test/GlobalAdminSeedRefusesBlankIdsTest.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Mesh.Security; -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// MeshWeaver#5217. Auth:GlobalAdmins now reaches a pod through the chart -/// (Auth__GlobalAdmins__0..2), so its values arrive as environment strings — and a -/// declared-but-blank entry, or a hole between indices, is an ordinary thing for an overlay to -/// produce. The seed used to trim each id and grant it: a blank became an Admin assignment on -/// the Admin partition for the EMPTY username. These pin that a blank grants nothing, a -/// duplicate grants once, and a real id still grants exactly the two assignments it always did. -/// -public class GlobalAdminSeedRefusesBlankIdsTest -{ - private static IConfiguration Config(params (string Key, string Value)[] entries) => - new ConfigurationBuilder() - .AddInMemoryCollection(entries.Select(e => new KeyValuePair(e.Key, e.Value))) - .Build(); - - [Fact] - public void ABlankEntry_GrantsNothing() - { - var nodes = GlobalAdminSeed.Build(Config( - ("Auth:GlobalAdmins:0", "alice"), - ("Auth:GlobalAdmins:1", ""), - ("Auth:GlobalAdmins:2", " "))); - - Assert.All(nodes, node => - { - var grant = Assert.IsType(node.Content); - Assert.False(string.IsNullOrWhiteSpace(grant.AccessObject), - $"{node.Path} grants Admin to a blank identity"); - }); - Assert.Equal(["Admin/_Access/alice_Access", "Provider/_Access/alice_Access"], - nodes.Select(n => n.Path).OrderBy(p => p, StringComparer.Ordinal)); - } - - [Fact] - public void OnlyBlankEntries_SeedNothingAtAll() - { - Assert.Empty(GlobalAdminSeed.Build(Config(("Auth:GlobalAdmins:0", "")))); - } - - [Fact] - public void ADuplicateId_IsSeededOnce() - { - var nodes = GlobalAdminSeed.Build(Config( - ("Auth:GlobalAdmins:0", "alice"), - ("Auth:GlobalAdmins:1", " Alice "))); - - Assert.Equal(2, nodes.Length); - Assert.Equal(nodes.Length, nodes.Select(n => n.Path).Distinct(StringComparer.OrdinalIgnoreCase).Count()); - // The LAST spelling wins — what AddMeshNodes' case-insensitive last-wins made effective - // before the filter existed, and the permission fold matches AccessObject ordinally. - Assert.All(nodes, n => Assert.Equal("Alice", Assert.IsType(n.Content).AccessObject)); - } - - /// Control: the shape a real id gets is unchanged — Admin on Admin, Admin on Provider. - [Fact] - public void ARealId_StillGetsBothGrants_ScopedToTheirPartitions() - { - var nodes = GlobalAdminSeed.Build(Config(("Auth:GlobalAdmins:0", " bob "))); - - Assert.Collection(nodes.OrderBy(n => n.Path, StringComparer.Ordinal), - admin => - { - Assert.Equal("Admin/_Access/bob_Access", admin.Path); - Assert.Equal("Admin", admin.MainNode); - Assert.Equal("bob", Assert.IsType(admin.Content).AccessObject); - }, - provider => - { - Assert.Equal("Provider/_Access/bob_Access", provider.Path); - Assert.Equal("Provider", provider.MainNode); - }); - } -} diff --git a/test/Memex.Portal.Shared.Test/GlobalSettingsNavigationRouteTest.cs b/test/Memex.Portal.Shared.Test/GlobalSettingsNavigationRouteTest.cs deleted file mode 100644 index d71e912812..0000000000 --- a/test/Memex.Portal.Shared.Test/GlobalSettingsNavigationRouteTest.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Markdown; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Every in-app link to the global settings page must land on a path the mesh actually registers. -/// -/// It did not: the settings node is registered at _Setting -/// () while four call sites navigated to the plural -/// lowercase form (_settings), so the About page and What's New answered "Page not found: -/// does not match any registered address pattern" from the profile menu and the build chip -/// (#1817). Nothing failed — no exception, no log, no test — because the two halves lived in -/// different projects and nothing had ever compared them. -/// -/// So this test compares them, for the WHOLE family rather than the one string that was -/// reported: it takes each settings tab the portal actually seeds -/// ( — About, What's New, Privacy, Invitations, Inbox, -/// Updates, Published, Token Usage) plus the compiled API-tokens tab, builds that tab's link exactly -/// as the portal does, and resolves it through the real -/// on a live mesh. A new tab is covered the moment it is seeded, and a future rename of the settings -/// node breaks here instead of in the browser. -/// -/// The resolution is asserted the way navigation consumes it — prefix = the settings node, -/// then ParseAreaAndId on the remainder — because a link can resolve to a node and still -/// address the wrong area, which renders a blank pane rather than a 404. -/// -public class GlobalSettingsNavigationRouteTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// - /// Every tab a settings link can point at. Read from the seeds themselves (not a copy) so the - /// list cannot go stale, plus , which still registers through - /// the compiled provider lane. - /// - private static IEnumerable TabIds => - PlatformSettingsTabAreas.Seeds.Select(s => s.Id).Append(ApiTokensSettingsTab.TabId); - - [Fact] - public async Task EverySettingsTabLink_ResolvesToTheGlobalSettingsNodeAndItsArea() - { - var offenders = new List(); - - foreach (var tabId in TabIds) - { - var href = GlobalSettingsNodeType.TabHref(tabId); - var resolution = await PathResolver.ResolveNavigationPath(href).Should().Emit(); - - if (resolution is null) - { - offenders.Add($"{tabId,-14} → {href} does not match any registered address pattern"); - continue; - } - - var (area, id) = LayoutAreaMarkdownParser.ParseAreaAndId(resolution.Remainder); - if (resolution.Prefix.Trim('/') != GlobalSettingsNodeType.SettingsPath - || area != GlobalSettingsLayoutArea.GlobalSettingsArea - || id != tabId) - { - offenders.Add( - $"{tabId,-14} → {href} resolved to node '{resolution.Prefix}' area '{area}' id '{id}'"); - } - } - - Assert.True(offenders.Count == 0, - "A settings tab link does not address the registered settings node + area. The node is " - + $"'{GlobalSettingsNodeType.SettingsPath}' and the area is " - + $"'{GlobalSettingsLayoutArea.GlobalSettingsArea}' — build links with " - + "GlobalSettingsNodeType.TabHref so they cannot drift from the registration (#1817):\n " - + string.Join("\n ", offenders)); - } - - /// The bare Settings link (the profile menu's fallback for a user with no user node). - [Fact] - public async Task SettingsHref_ResolvesToTheGlobalSettingsNodeItself() - { - var resolution = await PathResolver - .ResolveNavigationPath(GlobalSettingsNodeType.SettingsHref).Should().Emit(); - - Assert.NotNull(resolution); - Assert.Equal(GlobalSettingsNodeType.SettingsPath, resolution!.Prefix.Trim('/')); - Assert.True(string.IsNullOrEmpty(resolution.Remainder), - $"'{GlobalSettingsNodeType.SettingsHref}' must resolve to the node exactly, " - + $"but left remainder '{resolution.Remainder}'."); - } - - /// - /// The negative control, and the reason this bug was invisible: the retired plural/lowercase - /// spelling resolves to NOTHING. It is not an alias, and it must never become one — lowercase - /// _settings is a reserved satellite/schema segment in the cross-schema Postgres/Snowflake - /// routing, so accepting it here would collide with an unrelated meaning. Without this case the - /// test above could pass on a resolver that happily matched anything. - /// - [Fact] - public async Task TheRetiredPluralSpelling_ResolvesToNothing() - { - var retired = "/_" + "settings/GlobalSettings/" + AboutSettingsTab.TabId; - - var resolution = await PathResolver.ResolveNavigationPath(retired).Should().Emit(); - - Assert.True(resolution is null, - $"'{retired}' resolved to '{resolution?.Prefix}' — the plural/lowercase spelling is a " - + "reserved schema segment, not a second name for the settings node."); - } -} diff --git a/test/Memex.Portal.Shared.Test/GuiShellSwitchTest.cs b/test/Memex.Portal.Shared.Test/GuiShellSwitchTest.cs deleted file mode 100644 index 05c34ec54a..0000000000 --- a/test/Memex.Portal.Shared.Test/GuiShellSwitchTest.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Memex.Portal.Shared; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the per-browser GUI-shell switch decision () — the pure -/// half of the both-shells middleware. The contract: only NAVIGATIONS switch (GET + text/html + -/// a page path), ?gui= records the choice, the cookie replays it, and the deployment's -/// Features:Gui:Default decides where a fresh browser lands. -/// -public class GuiShellSwitchTest -{ - private const string Html = "text/html,application/xhtml+xml"; - - [Fact] - public void FreshBrowser_DefaultBlazor_PassesThrough() - { - var (redirect, setCookie) = GuiShellSwitch.Decide("GET", "/Doc/Architecture", Html, null, null, "Blazor"); - Assert.Null(redirect); - Assert.Null(setCookie); - } - - [Fact] - public void FreshBrowser_DefaultNext_LandsOnNext_SamePath() - { - var (redirect, _) = GuiShellSwitch.Decide("GET", "/Doc/Architecture", Html, null, null, "Next"); - Assert.Equal("/next/Doc/Architecture", redirect); - } - - [Fact] - public void QueryGuiNext_SetsCookie_AndRedirects() - { - var (redirect, setCookie) = GuiShellSwitch.Decide("GET", "/", Html, "next", null, "Blazor"); - Assert.Equal("next", setCookie); - Assert.Equal("/next", redirect); - } - - [Fact] - public void QueryGuiBlazor_OverridesNextCookie_AndStays() - { - var (redirect, setCookie) = GuiShellSwitch.Decide("GET", "/rbuergi", Html, "blazor", "next", "Blazor"); - Assert.Equal("blazor", setCookie); - Assert.Null(redirect); - } - - [Fact] - public void NextCookie_RedirectsEveryNavigation() - { - var (redirect, _) = GuiShellSwitch.Decide("GET", "/Store", Html, null, "next", "Blazor"); - Assert.Equal("/next/Store", redirect); - } - - [Theory] - [InlineData("/api/mesh/whoami")] - [InlineData("/meshweaver.v1.Mesh/Connect")] - [InlineData("/_blazor/negotiate")] - [InlineData("/static/NodeTypeIcons/book.svg")] - [InlineData("/login")] - [InlineData("/next/Doc")] - [InlineData("/mcp")] - [InlineData("/api/mcp")] - public void NonPageSurfaces_NeverRedirect(string path) - { - var (redirect, _) = GuiShellSwitch.Decide("GET", path, Html, null, "next", "Blazor"); - Assert.Null(redirect); - } - - [Fact] - public void NonHtmlAccept_NeverRedirects_TheMeshSurfacesFlowUntouched() - { - var (redirect, _) = GuiShellSwitch.Decide("GET", "/Doc", "application/json", null, "next", "Blazor"); - Assert.Null(redirect); - } - - [Fact] - public void Post_NeverRedirects_ButStillRecordsAnExplicitChoice() - { - var (redirect, setCookie) = GuiShellSwitch.Decide("POST", "/Doc", Html, "next", null, "Blazor"); - Assert.Null(redirect); - Assert.Equal("next", setCookie); - } -} diff --git a/test/Memex.Portal.Shared.Test/HealthCensusTest.cs b/test/Memex.Portal.Shared.Test/HealthCensusTest.cs deleted file mode 100644 index a5c1afa319..0000000000 --- a/test/Memex.Portal.Shared.Test/HealthCensusTest.cs +++ /dev/null @@ -1,462 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using MeshWeaver.Hosting; -using Memex.Portal.ServiceDefaults; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A census entry must PRINT its reading on /health even when it is Healthy — otherwise -/// "I measured nothing" and "I measured, and it was clean" are the same silence (MeshWeaver -/// #3703, #3704). -/// -/// Both issues were blocked on the same thing and it was never their own mechanism: the -/// reading that would settle them existed only as a boot LOG line, and log access on this fleet is -/// break-glass. Publishing the numbers on /health — public, unauthenticated, past RLS — turns -/// two permanently-unmeasurable issues into two measurable ones. That only works if a CLEAN reading -/// is visible: WriteHealthWithDetail prints non-Healthy entries only, so an entry that -/// answered Healthy-and-silent would be byte-identical on the wire to one that was never -/// registered. -/// -/// Everything here drives the REAL + -/// composition over a TestServer, for the same -/// reason does: a restatement of the wiring would agree with -/// itself while the deployment answered something else. -/// -public class HealthCensusTest -{ - /// - /// 🚨 The property, and the POSITIVE CONTROL that keeps every "does not print" assertion below - /// from being vacuous: a Healthy census entry appears in the body, with its description. - /// - [Fact] - public async Task ACleanCensusReading_IsPrintedAlthoughItIsHealthy() - { - var body = await HealthBodyAsync(services => services.AddSingleton(CleanBakeRegistry())); - - Assert.True(body.Contains($"{NodeTypeBakeReportRegistry.HealthCheckName}: Healthy", StringComparison.Ordinal), - "a CLEAN bake reading did not appear on /health. The whole publication is the NUMBER, " - + "so a census entry that prints only when it is unhappy is indistinguishable from one " - + "that was never registered — which is the ambiguity #3703 and #3704 are stuck in. " - + $"Body was:\n{body}"); - - Assert.True(body.Contains("total=209 baked=5 pending=204", StringComparison.Ordinal), - $"the bake summary's numbers are missing from /health. Body was:\n{body}"); - Assert.True(body.Contains("Adoption stamps held by this process: 78", StringComparison.Ordinal), - "the adoption-stamp count is the OTHER half of the 78-vs-5 pair #3703 was filed as; " - + $"published apart from the bake counts it settles nothing. Body was:\n{body}"); - } - - /// - /// 🚨 #4258's acceptance criterion, end to end: a reader with NO grant on the partition can - /// learn from an instrument they are authorised to call that a NodeType in it is broken. - /// - /// previouslybroken=1 is the only record anywhere that a NodeType is permanently - /// broken — the rollout gate skips such a type on purpose so one abandoned NodeType cannot - /// freeze the platform's deploys — and every probe a session can run (the RLS-filtered sweep, - /// the point get, get_diagnostics) answers "I cannot tell" for a partition it - /// holds no grant on. /health is composed by the process and is past RLS by - /// construction, so it is the one instrument that can answer; it was counting the failure and - /// dropping its identity one call before publication. - /// - /// 🚨 Also the disclosure assertion, and it is the half that has to hold: this body - /// is public and unauthenticated, so the PARTITION prints and the node's own title does not. - /// #3890 closed a narrower version of this surface on exactly that principle. - /// - [Fact] - public async Task ABrokenNodeTypesPARTITION_IsNamedOnTheUnauthenticatedBody_ButNotItsTitle() - { - var registry = new NodeTypeBakeReportRegistry(); - registry.Record(BakeReading( - fromLocalAdoption: 0, - ownership: "previouslybroken in BinaryClickerV2/…; frameworkstale in Edu/…")); - var body = await HealthBodyAsync(services => services.AddSingleton(registry)); - - Assert.True(body.Contains("previouslybroken in BinaryClickerV2/…", StringComparison.Ordinal), - "the census counted a permanently-broken NodeType and would not say whose it is, so " - + "#3883 could not be closed or routed for three months on the ambiguity between " - + $"'denied' and 'deleted'. Body was:\n{body}"); - Assert.False(body.Contains("BinaryToggle", StringComparison.Ordinal), - "the NODE's own name must not reach a public, unauthenticated body — a control that " - + "works by disclosing other people's node titles is a disclosure surface wearing an " - + $"instrument's colours (#3890). Body was:\n{body}"); - Assert.True(body.Contains($"{NodeTypeBakeReportRegistry.HealthCheckName}: Healthy", StringComparison.Ordinal), - "a permanently-broken type must not flip this entry's STATUS — the gate skips it by " - + "design — so the census tag is the only reason the reading prints at all. If this " - + $"entry ever goes silent when Healthy, the identity is dropped again. Body was:\n{body}"); - } - - /// - /// 🚨 #4632's acceptance criterion, end to end: a record another replica re-keyed to ITS - /// framework AFTER this replica booted reaches /health as a non-Healthy entry, naming the - /// partition and both identities — and not the node. This is the reading the two serving - /// replicas of 2026-09-17 had no instrument for: content-types records only a degraded - /// content READ (an area that never registers is not one), and every other line on - /// bake-report was taken at boot, before the stamp landed. - /// - [Fact] - public async Task ARecordReKeyedAfterBoot_IsDegradedOnHealth_AndNamesThePartitionNotTheNode() - { - var registry = CleanBakeRegistry(); - var bootedAt = new DateTimeOffset(2026, 9, 17, 12, 0, 0, TimeSpan.Zero); - registry.RecordLiveRecords(NodeTypeLiveRecordCensus.Of( - [ - ("Approvals/Desk", new MeshWeaver.Graph.Configuration.NodeTypeDefinition - { - Configuration = "config => config", - CompilationStatus = MeshWeaver.Mesh.Services.CompilationStatus.Ok, - CompiledFrameworkVersion = "saec4a2dc1f075f1fff7cf076055e150e", - LatestAssemblyCollection = "nodetype-cache", - LatestAssemblyPath = "Approvals_Desk/v844-saec4a2d-6ad498520d75.dll", - LastCompiledVersion = 844, - LastCompileSucceededAt = bootedAt.AddHours(2), - }), - ], - liveFrameworkVersion: "s2902ab117d8b351f11b358de1755decd", - bootedAt, at: bootedAt.AddHours(5))); - - var body = await HealthBodyAsync(services => services.AddSingleton(registry)); - - Assert.True(body.Contains($"{NodeTypeBakeReportRegistry.HealthCheckName}: Degraded", StringComparison.Ordinal), - "a NodeType record re-keyed to another framework after this replica booted did not reach " - + "/health as a non-Healthy entry. The boot-time reading on the same registry is CLEAN " - + "(217 of 218 baked was the incident's own bake-report), so only the live half can say " - + $"it — and on 2026-09-17 nothing did. Body was:\n{body}"); - Assert.True(body.Contains("RE-KEYED to a framework this replica does not run AFTER it booted", StringComparison.Ordinal), - $"the verdict's sentence is missing from the body a reader curls. Body was:\n{body}"); - Assert.True(body.Contains("saec4a2d×1 in Approvals/… (1 since boot)", StringComparison.Ordinal), - "the partition and the foreign identity are what route the finding and make it checkable " - + $"by hand against the DLL name. Body was:\n{body}"); - Assert.False(body.Contains("Approvals/Desk", StringComparison.Ordinal), - "the NODE's own name must not reach a public, unauthenticated body (#3890, #4258). " - + $"Body was:\n{body}"); - } - - [Fact] - public async Task AFaultedLiveWatch_IsDegradedOnHealth_AndNamesTheFault() - { - var registry = CleanBakeRegistry(); - registry.RecordLiveRecordsFault("UnanchoredQueryException: the catalog query was refused"); - - var body = await HealthBodyAsync(services => services.AddSingleton(registry)); - - Assert.True(body.Contains($"{NodeTypeBakeReportRegistry.HealthCheckName}: Degraded", StringComparison.Ordinal), - "a standing watch that FAULTED reported clean. It was armed and stopped; reading its " - + $"silence as a pass is the missing-instrument defect one level up (#4632). Body was:\n{body}"); - Assert.True(body.Contains("LIVE RECORD CENSUS WATCH FAULTED (UnanchoredQueryException", StringComparison.Ordinal), - $"the fault is not named in the body a reader curls. Body was:\n{body}"); - } - - [Fact] - public async Task NoLiveCensusYet_StaysHealthy_AndStillPrintsThatNothingWasMeasured() - { - var body = await HealthBodyAsync(services => services.AddSingleton(CleanBakeRegistry())); - - Assert.True(body.Contains($"{NodeTypeBakeReportRegistry.HealthCheckName}: Healthy", StringComparison.Ordinal), - "a replica whose standing catalog watch has not emitted yet reported non-Healthy. Every " - + "boot passes through that state; degrading on it would teach readers to ignore the " - + $"entry. Body was:\n{body}"); - Assert.True(body.Contains("LIVE RECORD CENSUS: NONE taken on this replica", StringComparison.Ordinal), - "'no live reading' and 'a live reading that found nothing' have to be two different " - + $"printed sentences, or the absence reads as a pass (#4632). Body was:\n{body}"); - } - - /// - /// The positive control for the case above: with nothing outstanding the body carries no - /// ownership clause, so that assertion is reading the reading rather than a constant. - /// - [Fact] - public async Task ACleanBakeReading_NamesNoPartitionAtAll() - { - var body = await HealthBodyAsync(services => services.AddSingleton(CleanBakeRegistry())); - - Assert.False(body.Contains("Non-baked types by partition", StringComparison.Ordinal), - "an ownership clause printed for a reading with nothing outstanding would make the " - + $"assertion above unfalsifiable. Body was:\n{body}"); - } - - /// - /// 🚨 The NEGATIVE half, paired with the case above so neither can go vacuous: the census tag is - /// opt-in and did NOT turn /health into a wall of green. `self` is Healthy and untagged, and it - /// must stay silent. - /// - [Fact] - public async Task AHealthyCheckWithoutTheCensusTag_StaysSilent() - { - var body = await HealthBodyAsync(services => services.AddSingleton(CleanBakeRegistry())); - - Assert.False(body.Contains("self:", StringComparison.Ordinal), - "the trivial process-up check printed on /health. The census tag must be OPT-IN: " - + "printing every Healthy entry would bury the ones that matter and change what every " - + $"existing reader of this body sees. Body was:\n{body}"); - } - - /// - /// 🚨 The absence case, and the reason it is Degraded rather than Healthy: the adopt-only probe - /// runs on every boot, so a replica with NO report measured nothing about its bake — and a - /// missing instrument may not read as a clean result. - /// - [Fact] - public async Task NoBakeReportAtAll_IsDegraded_AndSaysItMeasuredNothing() - { - var body = await HealthBodyAsync(configure: null); - - Assert.True(body.Contains($"{NodeTypeBakeReportRegistry.HealthCheckName}: Degraded", StringComparison.Ordinal), - "a replica that published NO bake report reported clean. That is the " - + "missing-instrument-reads-as-a-pass defect, and publishing a report that prints " - + $"nothing when it has nothing would leave #3703 exactly where it was. Body was:\n{body}"); - Assert.True(body.Contains("measured NOTHING", StringComparison.Ordinal), - $"the absence is not spelled out in the body a reader curls. Body was:\n{body}"); - } - - /// - /// A report whose enumeration snapshot was behind this process's own adoptions — #3703's actual - /// verdict — is Degraded and names the count. - /// - [Fact] - public async Task ASnapshotBehindItsOwnAdoptions_IsDegraded_AndNamesTheCount() - { - var registry = new NodeTypeBakeReportRegistry(); - registry.Record(BakeReading(fromLocalAdoption: 73)); - var body = await HealthBodyAsync(services => services.AddSingleton(registry)); - - Assert.True(body.Contains($"{NodeTypeBakeReportRegistry.HealthCheckName}: Degraded", StringComparison.Ordinal), - $"#3703's verdict did not reach /health as a non-Healthy entry. Body was:\n{body}"); - Assert.True(body.Contains("PREDATED this replica's own prebuilt adoptions for 73 of 209", StringComparison.Ordinal), - $"the count that IS the verdict is missing from the body. Body was:\n{body}"); - } - - /// - /// 🚨 A warm replica issues no discovery query at all, so "no pass" is Healthy — but it must - /// still be VISIBLE, or the check is back to being silence. - /// - [Fact] - public async Task NoDiscoveryPass_IsHealthy_AndStillPrintsThatNothingWasMeasured() - { - var body = await HealthBodyAsync(configure: null); - - Assert.True(body.Contains($"{SourceDiscoveryRegistry.HealthCheckName}: Healthy", StringComparison.Ordinal), - "a replica that ran no discovery pass reported Degraded (or did not report at all). " - + "Degrading on it would make every warm portal permanently non-Healthy — a check that " - + $"cannot pass — and silence would say nothing. Body was:\n{body}"); - Assert.True(body.Contains("NO source-discovery pass recorded", StringComparison.Ordinal), - "'nothing ran' and 'it ran and was clean' have to be two different printed sentences; " - + $"that is the entire reason this entry carries the census tag. Body was:\n{body}"); - } - - /// #3704's discriminator, reaching the wire: a gap that approached the window. - [Fact] - public async Task AWideInterChunkGap_IsDegraded_AndPublishesTheNumbers() - { - var registry = new SourceDiscoveryRegistry(); - registry.Record(new SourceDiscoveryPass( - "nodeType:Code partitions:all", Settled: 1145, Chunks: 7, Items: 1145, - ElapsedMs: 2100, LargestGapMs: 900, WindowMs: 1000, At: DateTimeOffset.UnixEpoch)); - - var body = await HealthBodyAsync(services => services.AddSingleton(registry)); - - Assert.True(body.Contains($"{SourceDiscoveryRegistry.HealthCheckName}: Degraded", StringComparison.Ordinal), - $"a 90%-of-window inter-chunk gap did not reach /health as Degraded. Body was:\n{body}"); - Assert.True(body.Contains("largest inter-chunk gap 900ms = 90% of the 1000ms completion window", StringComparison.Ordinal), - "#3704 turns on this exact number, and it existed only in a Loki line before. Body " - + $"was:\n{body}"); - Assert.True(body.Contains("settled at 1145 node(s) from 7 change(s)", StringComparison.Ordinal), - $"the folded-change count is the other half of the discriminator. Body was:\n{body}"); - } - - /// - /// 🚨 Publishing a number may NEVER restart a pod or evict it from the Service. The census tag - /// is not a probe tag, so neither post-startup probe can see these entries — and both must - /// still answer 200 with a Degraded census on /health. - /// - [Fact] - public async Task ADegradedCensus_ReachesNeitherPostStartupProbe() - { - // No registries at all: bake-report is Degraded on /health (asserted above). - var (health, healthBody) = await ProbeAsync(ProbeEndpoints.Health, configure: null); - var (live, _) = await ProbeAsync(ProbeEndpoints.Live, configure: null); - var (ready, _) = await ProbeAsync(ProbeEndpoints.Ready, configure: null); - - Assert.True(healthBody.Contains($"{NodeTypeBakeReportRegistry.HealthCheckName}: Degraded", StringComparison.Ordinal), - "the precondition of this test — a Degraded census entry — is not present, so the " - + $"assertions below would hold vacuously. Body was:\n{healthBody}"); - Assert.True(health.StatusCode == HttpStatusCode.OK, - $"/health answered {(int)health.StatusCode} with a Degraded census. Degraded is a 200; " - + "the startupProbe reads this path and a census must never hold a pod out of its " - + "startup."); - Assert.True(live.StatusCode == HttpStatusCode.OK, - $"{ProbeEndpoints.Live} answered {(int)live.StatusCode} because a CENSUS entry was " - + "Degraded. Failing liveness means 'restart me', and no restart makes a replica able " - + "to describe a bake it never ran."); - Assert.True(ready.StatusCode == HttpStatusCode.OK, - $"{ProbeEndpoints.Ready} answered {(int)ready.StatusCode} because a CENSUS entry was " - + "Degraded. Failing readiness hands this pod's traffic to its siblings — a claim about " - + "the siblings, made here on the strength of a diagnostic number."); - } - - /// - /// 🚨 MeshWeaver#4063 reaching the wire. A publication seal that stopped advancing froze every - /// GitSynced Space of MeshWeaver.Plugins on BOTH production portals for nine hours, and every - /// surface said the sources were settled: lastSyncOutcome: Imported, attempted == - /// synced, lastAttemptWasFinal: true. The instance-level fact — this identity has no - /// publication of that repository at or after its last green build — was stated nowhere at all. - /// - [Fact] - public async Task AFrozenRepository_IsDegraded_AndNamesItAndHowLongOnHealth() - { - // The health check reads DateTimeOffset.UtcNow, so the hold's first observation is placed - // relative to it — the duration under assertion is the one a live portal would print. - var census = new SealedSyncCensus(); - census.RecordPublication(SealedAt("24c2d024")); - census.RecordHold( - "Systemorph/MeshWeaver.Plugins", "7660ca73", - "built at 7660ca73, not sealed for this instance (identity s6649734: " - + "'plugins' is sealed at 24c2d024)", - heldSources: 34, now: DateTimeOffset.UtcNow - TimeSpan.FromHours(9)); - - var body = await HealthBodyAsync(services => services.AddSingleton(census)); - - Assert.True(body.Contains($"{SealedSyncCensus.HealthCheckName}: Degraded", StringComparison.Ordinal), - "a repository frozen for nine hours by a seal that stopped advancing did not reach " - + "/health as a non-Healthy entry. That freeze cost nine hours of undelivered releases " - + $"and two sessions, with every per-node field reading 'settled'. Body was:\n{body}"); - Assert.True(body.Contains("Systemorph/MeshWeaver.Plugins: 34 GitSynced source(s) held", StringComparison.Ordinal), - "the census did not name the frozen REPOSITORY and how many of its sources are held — " - + $"which is the whole reading #4063 asks for. Body was:\n{body}"); - Assert.True(body.Contains("(9h 0m ago, this process only)", StringComparison.Ordinal), - "how long the repository has been held is missing. A duration is what separates the " - + "ordinary webhook-before-seal ordering from a freeze, and it is the one number no " - + $"existing surface carried. Body was:\n{body}"); - Assert.True(body.Contains("'plugins' is sealed at 24c2d024", StringComparison.Ordinal), - "the gate's own hold reason — the same string the log line and the node's lastSyncNote " - + $"carry — did not survive to the health payload. Body was:\n{body}"); - } - - /// - /// 🚨 The pair that keeps the case above from being "degrade on any hold". The ORDINARY hold is - /// correct behaviour — the green-build hook fires before the repository's publish-bake seals for - /// this identity — so a fresh hold must stay Healthy, and still PRINT. - /// - [Fact] - public async Task AHoldInsideTheCiJobCap_StaysHealthy_AndStillPrintsTheRepository() - { - var census = new SealedSyncCensus(); - census.RecordPublication(SealedAt("24c2d024")); - census.RecordHold( - "Systemorph/MeshWeaver.Plugins", "7660ca73", "built at 7660ca73, not sealed yet", - heldSources: 34, now: DateTimeOffset.UtcNow - TimeSpan.FromMinutes(2)); - - var body = await HealthBodyAsync(services => services.AddSingleton(census)); - - Assert.True(body.Contains($"{SealedSyncCensus.HealthCheckName}: Healthy", StringComparison.Ordinal), - "a two-minute hold was reported as Degraded. Every green build of every satellite is " - + "held for the length of one publish-bake by design, so degrading on that would leave " - + $"every portal permanently non-Healthy — a check that cannot pass. Body was:\n{body}"); - Assert.True(body.Contains("Systemorph/MeshWeaver.Plugins: 34 GitSynced source(s) held", StringComparison.Ordinal), - "a Healthy hold printed nothing. The census tag exists so the reading is published " - + $"whatever the status; silence here is the #4063 blindness again. Body was:\n{body}"); - } - - /// - /// 🚨 The clean reading, which is the whole reason this entry is census-tagged: a freeze and a - /// quiet week are both "no import happened", so "none held" has to be a printed sentence. - /// - [Fact] - public async Task NoRepositoryHeld_IsHealthy_AndPrintsTheIdentityAndWhatItSealed() - { - var census = new SealedSyncCensus(); - census.RecordPublication(SealedAt("4b97be19")); - - var body = await HealthBodyAsync(services => services.AddSingleton(census)); - - Assert.True(body.Contains($"{SealedSyncCensus.HealthCheckName}: Healthy", StringComparison.Ordinal), - $"a replica holding nothing back reported non-Healthy. Body was:\n{body}"); - Assert.True(body.Contains("no repository is currently held by the seal", StringComparison.Ordinal), - "'nothing is frozen' was not printed, so it is indistinguishable from this check never " - + $"having been registered — the exact ambiguity #4063 was unmeasurable inside. Body was:\n{body}"); - Assert.True(body.Contains("'plugins' ← Systemorph/MeshWeaver.Plugins @ 4b97be19 (sealed)", StringComparison.Ordinal), - "the DENOMINATOR is missing: a hold count means nothing without the publication set " - + $"this identity actually holds. Body was:\n{body}"); - } - - /// - /// The third absence, spelled as its own sentence: no reading at all. Healthy — a deployment - /// that receives no green builds cannot have a freeze — but never silent. - /// - [Fact] - public async Task NoPublicationSealReadingAtAll_StillPrintsThatNothingWasMeasured() - { - var body = await HealthBodyAsync(configure: null); - - Assert.True(body.Contains($"{SealedSyncCensus.HealthCheckName}:", StringComparison.Ordinal), - "the publication-seal census did not print at all on a host with no registry. A missing " - + $"instrument must never read as a clean result. Body was:\n{body}"); - Assert.True(body.Contains("absence of measurement, NOT a clean one", StringComparison.Ordinal), - $"the absence is not spelled out in the body a reader curls. Body was:\n{body}"); - } - - private static SealedPublicationReading SealedAt(string commit) => - new("s6649734", "/data/prebuilt-bundles", - [new SealedSource("plugins", "Systemorph/MeshWeaver.Plugins", commit, true, null)], - DateTimeOffset.UnixEpoch); - - private static NodeTypeBakeReportRegistry CleanBakeRegistry() - { - var registry = new NodeTypeBakeReportRegistry(); - registry.Record(BakeReading(fromLocalAdoption: 0)); - return registry; - } - - private static BakeReportReading BakeReading(int fromLocalAdoption, string ownership = "") => - new( - NodeTypeBakeReportRegistry.AdoptOnlyProbe, - "sb43f9287dbd6922a7937bd24be103937", - Total: 209, - Baked: 5, - Pending: 204, - ClassifiedFromLocalAdoption: fromLocalAdoption, - AdoptionStamps: 78, - Summary: "framework=sb43f928 total=209 baked=5 pending=204 frameworkstale=201", - At: DateTimeOffset.UnixEpoch) - { - Ownership = ownership, - }; - - private static async Task HealthBodyAsync(Action? configure) - => (await ProbeAsync(ProbeEndpoints.Health, configure)).Body; - - /// - /// The pipeline under test: the REAL health-check composition over a TestServer, with whatever - /// census registries adds to the host container — which is the same - /// container resolves them from in production. - /// - private static async Task<(HttpResponseMessage Response, string Body)> ProbeAsync( - string route, Action? configure) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.AddDefaultHealthChecks(); - configure?.Invoke(builder.Services); - - var app = builder.Build(); - app.MapDefaultEndpoints(); - - await using (app) - { - await app.StartAsync(TestContext.Current.CancellationToken); - using var client = app.GetTestClient(); - var response = await client.GetAsync(route, TestContext.Current.CancellationToken); - var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - return (response, body); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/HealthTimingIsPublishedTest.cs b/test/Memex.Portal.Shared.Test/HealthTimingIsPublishedTest.cs deleted file mode 100644 index 054a97ecbb..0000000000 --- a/test/Memex.Portal.Shared.Test/HealthTimingIsPublishedTest.cs +++ /dev/null @@ -1,211 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.Diagnostics; -using System.Globalization; -using System.Linq; -using System.Net.Http; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.ServiceDefaults; -using Defaults = Memex.Portal.ServiceDefaults.ServiceDefaults; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 /health is the startupProbe's instrument, so it must publish what it SPENT — -/// otherwise a probe that times out names no cause, and the slowest check is by construction the one -/// nobody can see (MeshWeaver#4588). -/// -/// The incident. Measured 2026-09-17 from outside memex.systemorph.com, three -/// consecutive reads: /health answered 200 in 8.12 s, 9.62 s and 9.52 s, while -/// /alive and /ready on the same host and pod answered in 0.12 s — so the seconds were -/// entirely in the untagged checks. The chart's startupProbe reads /health, and that -/// instance gives it timeoutSeconds: 5 — so every probe ran out of time before the endpoint -/// could answer. A container that never records ONE startup success never leaves startup, is never -/// Ready, and is killed when periodSeconds × failureThreshold runs out (10 s × 1080 = 3 h -/// there) — then starts over. The replica rolled onto -/// 3.0.0-ci.8812 at 11:23:40Z was still not Ready at 14:51Z with restarts: 1, and the -/// bake gate was GREEN across the reading at 14:38:15Z: the aggregate word on line one was -/// Degraded, which is a 200, which means no registered check was Unhealthy. -/// -/// Why nothing could name the cost. The framework logs every check's duration, but a -/// check answering logs it at Information — which this fleet -/// filters out of Loki for the Microsoft.* categories (measured: not one line matching -/// with status Healthy has ever reached the log store). And -/// WriteHealthWithDetail prints only entries that are not Healthy, plus the census-tagged -/// ones. So an expensive check that is perfectly healthy appeared NOWHERE: not in the body, not in -/// the log. That is the same blind spot #3703 and #3704 were stuck in, this time over the endpoint's -/// own cost, and it is exactly the hole behind #4588's attribution caveat. -/// -/// Everything here drives the REAL + -/// composition over a TestServer, for the same -/// reason does: a restatement of the wiring would agree with itself -/// while the deployment answered something else. The one exception is the naming FLOOR from the -/// cheap side: no live check can be guaranteed to measure under 10 ms of wall clock, so that -/// direction is pinned on the pure line over fixed durations. -/// -public class HealthTimingIsPublishedTest -{ - /// - /// How long the deliberately expensive check below burns. Comfortably above - /// ServiceDefaults.TimingNamedAboveMs (10 ms) and small enough that the suite does not - /// notice — the assertion is that the check is NAMED, never that a particular number came back. - /// - private const int SlowCheckMs = 80; - - /// - /// 🚨 The property: a check that is expensive AND Healthy AND uncensused is named on - /// /health, with its cost. That combination is the one the body used to drop - /// entirely, and it is the only combination that can explain a probe timeout on a pod whose - /// every verdict is fine. - /// - [Fact] - public async Task AnExpensiveButHealthyCheck_IsNamedWithItsCost() - { - var body = await HealthBodyAsync(services => services.AddHealthChecks() - .AddCheck("slow_and_healthy", Burn(SlowCheckMs))); - - var timing = TimingLineOf(body); - - // 🚨 The NAME alone would pass over a line that published the identity and dropped the - // number, which is the one thing this change exists to add. Pin the published shape, and - // read the cost back out of it: a named check is named BECAUSE it reached the floor, so a - // smaller number would mean the line and the rule disagree. - var named = Regex.Match(timing, @"\bslow_and_healthy (\d+)ms\b"); - Assert.True(named.Success, - "the timing line did not publish 'slow_and_healthy ms'. Naming the check without its " - + $"cost is the defect this change removes, not the fix. Line was:\n{timing}"); - Assert.True(double.Parse(named.Groups[1].Value, CultureInfo.InvariantCulture) >= 10, - $"a check was named below the 10ms naming floor. Line was:\n{timing}"); - - Assert.DoesNotContain("slow_and_healthy", string.Join('\n', body.Split('\n').Skip(2)), - StringComparison.Ordinal); - } - - /// - /// 🚨 The NEGATIVE CONTROL that keeps the assertion above from passing on a line that simply - /// names everything: a cheap check is NOT named — and is still COUNTED, so the line states its - /// own denominator rather than quietly dropping what it did not print. - /// - /// 🚨 It reads the PURE over a report whose - /// durations are FIXED, never a live probe. Whether a check is named is decided by the wall - /// clock the framework measured, and the wall clock is not the test's to choose: a check that - /// returns Healthy() at once was measured at 15 ms on a loaded CI runner (run - /// 39647720589, shard 5) and the line correctly named it, failing a test that had asserted the - /// machine is fast. The slow direction stays end-to-end above, because a spin of 80 ms is a - /// LOWER bound no scheduler can undercut; the cheap direction has no such bound, so it is pinned - /// here, including both sides of the floor. - /// - [Fact] - public void ACheapCheck_IsCountedRatherThanNamed() - { - var timing = Defaults.TimingLine(ReportOf( - ("cheap_and_healthy", 2), - ("just_under_the_floor", 9.9))); - - Assert.DoesNotContain("cheap_and_healthy", timing, StringComparison.Ordinal); - Assert.DoesNotContain("just_under_the_floor", timing, StringComparison.Ordinal); - Assert.Contains("over 2 check(s)", timing, StringComparison.Ordinal); - Assert.Contains("all 2 under 10ms", timing, StringComparison.Ordinal); - } - - /// - /// The other side of the same floor, over the same fixed durations: a check AT the floor is - /// named with its cost, and the cheap one beside it is still counted, not dropped. Without this - /// the test above would pass over a line that names nothing at all. - /// - [Fact] - public void ACheckAtTheFloor_IsNamedAndTheRestAreCounted() - { - var timing = Defaults.TimingLine(ReportOf( - ("cheap_and_healthy", 2), - ("at_the_floor", 10))); - - Assert.Contains("slowest first — at_the_floor 10ms; 1 more under 10ms", timing, StringComparison.Ordinal); - Assert.DoesNotContain("cheap_and_healthy", timing, StringComparison.Ordinal); - } - - /// - /// A report of Healthy, uncensused entries with exactly the given durations, so the naming - /// rule is read against a known input rather than against how busy the machine was. - /// - private static HealthReport ReportOf(params (string Name, double Ms)[] checks) => - new(checks.ToImmutableDictionary( - c => c.Name, - c => new HealthReportEntry(HealthStatus.Healthy, null, TimeSpan.FromMilliseconds(c.Ms), null, null)), - TimeSpan.FromMilliseconds(checks.Sum(c => c.Ms))); - - /// - /// 🚨 Line ONE stays the bare status word. The fleet watch takes the first - /// whitespace-delimited token of the body as the replica's verdict - /// (ObservationQueries.HealthVerdict, in MeshWeaver.Plugins), and every deployment record - /// on the control instance is written from it. A timing line placed anywhere but line two would - /// either be read as the verdict or be lost to the 2000-character detail clip — so its position - /// is part of the contract, not formatting. - /// - [Fact] - public async Task TheVerdictStaysOnLineOneAndTheTimingIsLineTwo() - { - var body = await HealthBodyAsync(services => services.AddHealthChecks() - .AddCheck("slow_and_healthy", Burn(SlowCheckMs))); - - var lines = body.Split('\n'); - Assert.True(Enum.TryParse(lines[0], out _), - $"line one must be the bare status word the fleet watch parses. Body was:\n{body}"); - Assert.StartsWith("timing: ", lines[1], StringComparison.Ordinal); - } - - /// - /// A check that burns of wall clock and answers Healthy. The - /// wait is a bounded SpinWait.SpinUntil over a — the shape this - /// repository sanctions for a deliberate park in a test — never a sleep or a delay task. - /// - private static Func Burn(int milliseconds) => () => - { - var clock = Stopwatch.StartNew(); - SpinWait.SpinUntil(() => clock.ElapsedMilliseconds >= milliseconds, milliseconds * 20); - return HealthCheckResult.Healthy($"burned {clock.ElapsedMilliseconds}ms on purpose"); - }; - - /// The timing line, with a failure message that shows the whole body when it is absent. - private static string TimingLineOf(string body) - { - var lines = body.Split('\n'); - Assert.True(lines.Length > 1 && lines[1].StartsWith("timing: ", StringComparison.Ordinal), - "/health published no timing line. The endpoint that gates every rollout has to say what " - + "it SPENT: once its own latency reaches the startup probe's timeout the verdict stops " - + $"mattering, and nothing else can name the cost (#4588). Body was:\n{body}"); - return lines[1]; - } - - /// - /// The pipeline under test: the REAL health-check composition over a TestServer, plus whatever - /// registers — the same container the shipped checks resolve from. - /// - private static async Task HealthBodyAsync(Action configure) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.AddDefaultHealthChecks(); - configure(builder.Services); - - var app = builder.Build(); - app.MapDefaultEndpoints(); - - await using (app) - { - await app.StartAsync(TestContext.Current.CancellationToken); - using var client = app.GetTestClient(); - using var response = await client.GetAsync(ProbeEndpoints.Health, TestContext.Current.CancellationToken); - return await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/HeldNodeTypeBundleRetryTest.cs b/test/Memex.Portal.Shared.Test/HeldNodeTypeBundleRetryTest.cs deleted file mode 100644 index a5d26f009c..0000000000 --- a/test/Memex.Portal.Shared.Test/HeldNodeTypeBundleRetryTest.cs +++ /dev/null @@ -1,382 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A bake published after its source landed is adopted when it is published -/// (, the registry reconcile's held-NodeType lane). -/// -/// The incident it pins (an enterprise client's test instance, 2026-10-18): the source of a -/// NodeType landed at 06:56Z, the bundle that named the type still carried the previous source and -/// was declined, and the type went into a delivery hold. The bake was published at 07:43Z, and -/// nothing asked for it again, because no component re-requests a held type. What is pinned: every -/// reconcile pass asks the registry's index for the packages of held types; it downloads a -/// package's bundle exactly when the index names a bundle this process has not tried; an unchanged -/// index costs no download; and a mesh with nothing held does not even read the index. The -/// witness is the fake registry's own request log, because what was asked is the one thing the -/// consumer cannot fake. -/// -public class HeldNodeTypeBundleRetryTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string RegistryHost = "registry.held-retry.test"; - private const string RegistryUrl = "https://" + RegistryHost; - private const string Token = "mwi_held_retry"; - private const string Package = "HeldPkg"; - private const string HeldType = Package + "/Submission"; - private static readonly DateTimeOffset Now = new(2026, 10, 18, 7, 44, 0, TimeSpan.Zero); - - private readonly FakeIndex registry = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new HostRoutingClientFactory(registry))); - - // ── the decision ────────────────────────────────────────────────────────────────────────── - - private static PluginBundleClient.BundleRef Bundle(string plugin, string version, string? artifact = null) => - new(plugin, version, $"{RegistryUrl}{PluginBundleClient.RoutePrefix}/{plugin}/{version}") { Artifact = artifact }; - - [Fact] - public void AHeldTypesPackage_IsRetried_WhenNothingWasTriedYet_OrTheAdvertisedBundleMoved() - { - var advertised = new[] { Bundle(Package, "1.2", "cr.test/plugins/reinsurance/heldpkg@sha256:" + new string('b', 64)) }; - - var first = HeldNodeTypeBundleRetry.Plan([HeldType], advertised, _ => null, Now, HeldNodeTypeBundleRetry.ReattemptAfter); - first.Should().ContainSingle(); - first[0].Package.Should().Be(Package); - first[0].HeldTypes.Should().Equal(HeldType); - - var tried = new HeldNodeTypeBundleRetry.Attempt(HeldNodeTypeBundleRetry.BundleKeyOf(advertised[0]), Now.AddMinutes(-5)); - HeldNodeTypeBundleRetry.Plan([HeldType], advertised, _ => tried, Now, HeldNodeTypeBundleRetry.ReattemptAfter) - .Should().BeEmpty("the index names exactly the bundle this process already tried"); - - // The rebake: a new digest-addressed artifact for the same package and version. - var rebaked = new[] { Bundle(Package, "1.2", "cr.test/plugins/reinsurance/heldpkg@sha256:" + new string('c', 64)) }; - var after = HeldNodeTypeBundleRetry.Plan([HeldType], rebaked, _ => tried, Now, HeldNodeTypeBundleRetry.ReattemptAfter); - after.Should().ContainSingle("a rebake moves the artifact, and that is what the lane must see"); - after[0].Because.Should().Contain("now advertises"); - } - - [Fact] - public void AnUnchangedBundle_IsRetriedAgain_OnlyOnceTheBoundHasPassed() - { - var advertised = new[] { Bundle(Package, "1.2") }; - var key = HeldNodeTypeBundleRetry.BundleKeyOf(advertised[0]); - - HeldNodeTypeBundleRetry.Plan([HeldType], advertised, - _ => new HeldNodeTypeBundleRetry.Attempt(key, Now.AddHours(-1)), Now, TimeSpan.FromHours(6)) - .Should().BeEmpty(); - HeldNodeTypeBundleRetry.Plan([HeldType], advertised, - _ => new HeldNodeTypeBundleRetry.Attempt(key, Now.AddHours(-7)), Now, TimeSpan.FromHours(6)) - .Should().ContainSingle("a registry may re-shelve new bytes under an unchanged reference"); - } - - [Fact] - public void APackageTheIndexDoesNotAdvertise_OrNothingHeld_IsNeverRetried() - { - HeldNodeTypeBundleRetry.Plan([HeldType], [Bundle("Other", "1.0")], _ => null, Now, HeldNodeTypeBundleRetry.ReattemptAfter) - .Should().BeEmpty("there is nothing to adopt for a package the registry does not serve"); - HeldNodeTypeBundleRetry.Plan([], [Bundle(Package, "1.0")], _ => null, Now, HeldNodeTypeBundleRetry.ReattemptAfter) - .Should().BeEmpty("no hold, no retry"); - // Control: the same inputs with a hold DO plan, so the two empties above are about the inputs. - HeldNodeTypeBundleRetry.Plan([HeldType], [Bundle(Package, "1.0")], _ => null, Now, HeldNodeTypeBundleRetry.ReattemptAfter) - .Should().ContainSingle(); - } - - // ── the lane, against a registry ────────────────────────────────────────────────────────── - - private void Hold(params string[] typePaths) - { - var records = typePaths.Select(p => (p, (NodeTypeDefinition?)new NodeTypeDefinition - { - Configuration = "config => config", - CompilationStatus = CompilationStatus.Ok, - CompiledFrameworkVersion = PrebuiltAssemblySeeder.LiveFrameworkMvid, - BuildProvenance = BuildProvenance.StaleAdopted, - LatestAssemblyCollection = "local", - LatestAssemblyPath = $"{p.Replace('/', '_')}/v16.dll", - DeliveryHoldReason = "Serving the last build this mesh holds; the current source is waiting for a bundle.", - })).ToArray(); - Mesh.ServiceProvider.GetRequiredService().RecordLiveRecords( - NodeTypeLiveRecordCensus.Of(records, PrebuiltAssemblySeeder.LiveFrameworkMvid, Now.AddHours(-2), Now)); - } - - /// The install record the lane requires before it adopts a package (Plugins/{id}). - private Task Install(string packageId, CancellationToken ct) - { - var record = MeshNode.FromPath($"{PackageInstaller.InstalledPartition}/{packageId}") with - { - NodeType = PackageInstaller.PackageNodeType, - Name = packageId, - State = MeshNodeState.Active, - Content = new PackageManifest { Id = packageId, Name = packageId, Version = "1.0.0", TargetPartition = packageId }, - }; - return Mesh.ServiceProvider.GetRequiredService() - .RunAsSystem(() => NodeFactory.CreateOrUpdateNode(record)) - .Timeout(TestTimeouts.Convergence) - .Await(ct); - } - - /// - /// The installed-package boundary: a NodeType held in a partition whose id matches a package the - /// registry advertises but this mesh never installed is NOT adopted for — no index read, no - /// download. The control installs the package, and the same hold is then adopted for. - /// - [Fact(Timeout = 120_000)] - public async Task AHeldTypeOfAnUninstalledPackage_IsNeverAdoptedFor() - { - var ct = TestContext.Current.CancellationToken; - var lane = new HeldNodeTypeBundleRetry(); - Hold(HeldType); - - (await Pass(lane, ct)).Should().Be(0); - registry.IndexReads.Should().Be(0, "an uninstalled package is not asked about"); - registry.Downloads.Should().BeEmpty(); - - // Control: installed, the same hold is adopted for. - await Install(Package, ct); - await Pass(lane, ct); - registry.Downloads.Should().Equal([Package]); - } - - /// The index can advertise a bundle before its bytes are reachable: a 404 for them - /// judged nothing, so the same reference is asked for again on the next pass. - [Fact(Timeout = 120_000)] - public async Task ABundleAnsweredNotFound_IsAskedForAgain_OnTheNextPass() - { - var ct = TestContext.Current.CancellationToken; - var lane = new HeldNodeTypeBundleRetry(); - await Install(Package, ct); - Hold(HeldType); - - registry.DownloadStatus = HttpStatusCode.NotFound; - await Pass(lane, ct); - registry.Downloads.Should().HaveCount(1); - - registry.DownloadStatus = HttpStatusCode.OK; - await Pass(lane, ct); - registry.Downloads.Should().HaveCount(2, "a 404 for the bytes is not an inspected bundle"); - registry.DownloadedVersions.Should().Equal(["1.2"]); - } - - private Task Pass(HeldNodeTypeBundleRetry lane, CancellationToken ct) => - Pass(lane, new PluginBundleClient(Mesh, RegistryUrl, Token), ct); - - private Task Pass(HeldNodeTypeBundleRetry lane, PluginBundleClient client, CancellationToken ct) => - lane.Run(Mesh, client, RegistryUrl, "fleet", [Package], null) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - /// - /// The lane adopts the bundle of the index snapshot the PASS reads — not a fresher one. An - /// earlier lane of the same pass (the content lane) has already read the client's shared index - /// when the rebake lands; planning from a fresh read recorded the NEW bundle as tried while the - /// adoption downloaded the OLD one, and the next pass then suppressed the new bundle. - /// - [Fact(Timeout = 120_000)] - public async Task TheLane_PlansFromTheIndexItsAdoptionReads_SoARebakeBetweenLanesIsNotLost() - { - var ct = TestContext.Current.CancellationToken; - var lane = new HeldNodeTypeBundleRetry(); - await Install(Package, ct); - Hold(HeldType); - - // One pass's client: an earlier lane reads its shared index (version 1.2)… - var client = new PluginBundleClient(Mesh, RegistryUrl, Token); - await client.Adopt("AnEarlierLanesPackage").FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - // …and the rebake lands before this lane runs. - registry.Version = "1.2.1"; - - await Pass(lane, client, ct); - registry.DownloadedVersions.Should().Equal(["1.2"], "the pass adopts the bundle of its own index snapshot"); - - // The next pass reads the index afresh, sees 1.2.1 — never tried — and adopts it. - await Pass(lane, ct); - registry.DownloadedVersions.Should().Equal(["1.2", "1.2.1"], - "the rebake was not recorded as tried by the pass that never downloaded it"); - } - - /// A transfer that fails is not an inspected bundle: the next pass asks again, with an - /// unchanged index — unlike a bundle whose bytes arrived and were judged (the control below). - [Fact(Timeout = 120_000)] - public async Task ATransferThatFailed_IsNotRecordedAsTried_AndTheNextPassAsksAgain() - { - var ct = TestContext.Current.CancellationToken; - var lane = new HeldNodeTypeBundleRetry(); - await Install(Package, ct); - Hold(HeldType); - - registry.DownloadStatus = HttpStatusCode.ServiceUnavailable; - await Pass(lane, ct); - var failedAttempts = registry.Downloads.Count; - failedAttempts.Should().BeGreaterThan(0); - - // The registry recovers; the index is unchanged. - registry.DownloadStatus = HttpStatusCode.OK; - await Pass(lane, ct); - registry.Downloads.Should().HaveCount(failedAttempts + 1, "a failed transfer must not suppress the bundle"); - - // Control: the bytes arrived and were judged — the same unchanged index is not downloaded again. - await Pass(lane, ct); - registry.Downloads.Should().HaveCount(failedAttempts + 1); - } - - /// An adoption that never answers is bounded: the pass completes, and the stalled - /// bundle is not recorded as tried. - [Fact(Timeout = 120_000)] - public async Task AnAdoptionThatNeverAnswers_IsBoundedByTheBudget() - { - var ct = TestContext.Current.CancellationToken; - var lane = new HeldNodeTypeBundleRetry { AdoptBudget = TimeSpan.FromSeconds(2) }; - await Install(Package, ct); - Hold(HeldType); - - registry.StallDownloads = true; - (await Pass(lane, ct)).Should().Be(0, "the pass ends at the budget rather than waiting for ever"); - - registry.StallDownloads = false; - var before = registry.Downloads.Count; - await Pass(lane, ct); - registry.Downloads.Should().HaveCount(before + 1, "a stalled transfer is not an inspected bundle"); - } - - [Fact(Timeout = 120_000)] - public async Task EveryPass_AsksForAHeldTypesPackage_AndDownloadsOnlyABundleItHasNotTried() - { - var ct = TestContext.Current.CancellationToken; - var lane = new HeldNodeTypeBundleRetry(); - await Install(Package, ct); - Hold(HeldType); - - await Pass(lane, ct); - registry.Downloads.Should().Equal([Package], - "the first pass after the hold adopts the advertised bundle once — the old process had no reason to"); - - await Pass(lane, ct); - registry.Downloads.Should().HaveCount(1, "an unchanged index is not a reason to download again"); - registry.IndexReads.Should().BeGreaterThanOrEqualTo(2, "but every pass does ask"); - - // The bake lands: the index advertises a new bundle for the same package. - registry.Version = "1.2.1"; - await Pass(lane, ct); - registry.Downloads.Should().Equal([Package, Package], "the new bake is adopted on the pass that sees it"); - } - - [Fact(Timeout = 120_000)] - public async Task WithNothingHeld_TheIndexIsNotEvenRead() - { - var ct = TestContext.Current.CancellationToken; - var lane = new HeldNodeTypeBundleRetry(); - - await Pass(lane, ct); - registry.IndexReads.Should().Be(0); - registry.Downloads.Should().BeEmpty(); - - // Control: the same lane with a hold reads the index — the zero above is about the census. - await Install(Package, ct); - Hold(HeldType); - await Pass(lane, ct); - registry.IndexReads.Should().BeGreaterThan(0); - } - - [Fact] - public void TheCensus_NamesExactlyTheRecordsInADeliveryHold() - { - var held = new NodeTypeDefinition { Configuration = "c", DeliveryHoldReason = "awaiting a bundle" }; - var free = new NodeTypeDefinition { Configuration = "c" }; - var census = NodeTypeLiveRecordCensus.Of( - [("B/Held", held), ("A/Free", free), ("A/Held", held), ("C/Untyped", null)], - PrebuiltAssemblySeeder.LiveFrameworkMvid, Now.AddHours(-1), Now); - - census.AwaitingBundle.Should().Equal("A/Held", "B/Held"); - } - - /// The plugin registry's bundle index and HTTP bundle route, logging what was asked. - private sealed class FakeIndex : HttpMessageHandler, IHostHandler - { - private ImmutableList downloads = ImmutableList.Empty; - private ImmutableList downloadedVersions = ImmutableList.Empty; - private int indexReads; - - public string Version { get; set; } = "1.2"; - public HttpStatusCode DownloadStatus { get; set; } = HttpStatusCode.OK; - public bool StallDownloads { get; set; } - public ImmutableList Downloads => downloads; - public ImmutableList DownloadedVersions => downloadedVersions; - public int IndexReads => Volatile.Read(ref indexReads); - - public bool Serves(string host) => string.Equals(host, RegistryHost, StringComparison.OrdinalIgnoreCase); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var path = request.RequestUri!.AbsolutePath; - if (path == $"{PluginBundleClient.RoutePrefix}/index.json") - { - Interlocked.Increment(ref indexReads); - var body = JsonSerializer.Serialize(new - { - frameworkMvid = PrebuiltAssemblySeeder.LiveFrameworkMvid, - bundles = new[] - { - new - { - plugin = Package, - version = Version, - url = $"{RegistryUrl}{PluginBundleClient.RoutePrefix}/{Package}/{Version}", - }, - }, - }, PluginRegistryPayloads.Json); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }); - } - if (path.StartsWith($"{PluginBundleClient.RoutePrefix}/", StringComparison.Ordinal)) - { - var segments = path[(PluginBundleClient.RoutePrefix.Length + 1)..].Split('/'); - ImmutableInterlocked.Update(ref downloads, d => d.Add(Uri.UnescapeDataString(segments[0]))); - if (StallDownloads) - // A registry that accepted the request and never answers: completes only when - // the caller gives up (the lane's budget cancels the transfer). - return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken) - .ContinueWith(_ => new HttpResponseMessage(HttpStatusCode.GatewayTimeout), TaskScheduler.Default); - if (DownloadStatus != HttpStatusCode.OK) - return Task.FromResult(new HttpResponseMessage(DownloadStatus) - { - Content = new StringContent("registry unavailable"), - }); - if (segments.Length > 1) - ImmutableInterlocked.Update(ref downloadedVersions, d => d.Add(Uri.UnescapeDataString(segments[1]))); - // Not a bundle: the seeder adopts nothing from it, which is the point — the lane's - // contract is WHEN it asks, and the adoption gates are the install's own. - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(Encoding.UTF8.GetBytes("not a zip")), - }); - } - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/HeldUpdatesConvergeTest.cs b/test/Memex.Portal.Shared.Test/HeldUpdatesConvergeTest.cs deleted file mode 100644 index 2cef0eaa5e..0000000000 --- a/test/Memex.Portal.Shared.Test/HeldUpdatesConvergeTest.cs +++ /dev/null @@ -1,639 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 Held updates CONVERGE (maintainer, 2026-10-04: "we must know which version we want to test -/// and update everyone to latest"). Measured on the control instance that night: Plugins/AI -/// stayed at 1.18.1 with "held: 1.19.4 needs platform ≥ 3.0.0-ci.9887 … updates when the platform -/// rolls", the platform rolled to 9887 and nothing applied it; Plugins/Hosting had been held -/// since 09-18. Both partitions carry a _GitSync, so once the floor was met the unattended lane -/// degraded to the sync-owned REMINDER (One Partition, One Bookkeeping, gates 1 and 1b) — and no lane -/// ever landed the MODULE of a sync-owned package. A human ran RefreshModules each time. -/// -/// The rule pinned here: when the partition's sync source has landed EXACTLY the candidate's -/// content, the install record adopts it without writing a node (still one writer), and the module -/// lane is licensed to land the matching code. When the sync has landed something else, the hold is -/// stamped on the record WITH its reason and since-when, so the fleet view lists it. -/// -public class HeldUpdatesConvergeTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddGitHubSyncTypes() - .AddPluginCatalog() - .ConfigureServices(services => - { - services.AddGitHubSyncServices(); - services.AddSingleton(new PluginCatalogOptions { AutoUpdateByDefault = true }); - return services; - }); - - private const string V1 = "1111111111111111"; - private const string V2 = "2222222222222222"; - private const string Converges = "ConvergesPkg"; - private const string StaysHeld = "StaysHeldPkg"; - - private IMeshService MeshService => Mesh.ServiceProvider.GetRequiredService(); - - private ILogger Logger => Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - /// - /// THE 10-04 shape, both arms: two sync-owned packages installed at V1, the registry serves V2 of - /// both; one partition's sync has LANDED V2, the other's still carries V1. - /// - /// Fails on main. There a sync-owned partition always degrades to the reminder, so - /// Converges' record stays at V1 for ever — the AI and Hosting holds verbatim. - /// - [Fact(Timeout = 300_000)] - public async Task ASyncOwnedPackage_ConvergesOnceItsSyncHasLandedTheCandidate_AndOtherwiseSaysWhyItIsHeld() - { - var ct = TestContext.Current.CancellationToken; - var first = new RecordingSource(new Dictionary> - { - [Converges] = Files(Converges, V1), - [StaysHeld] = Files(StaysHeld, V1), - }); - await Install(first, Converges, V1, ct); - await Install(first, StaysHeld, V1, ct); - - // The one fact that differs: what each partition's sync source has LANDED. - await TrackPartition(Converges, landed: V2, ct); - await TrackPartition(StaysHeld, landed: V1, ct); - - var second = new RecordingSource(new Dictionary> - { - [Converges] = Files(Converges, V2), - [StaysHeld] = Files(StaysHeld, V2), - }); - await PackageUpdateReconciler.ReconcileInstalled( - Mesh, second, "HEAD", [Candidate(Converges, V2), Candidate(StaysHeld, V2)], - "Served by registry 'test'", Logger) - .Should().Within(TestTimeouts.CrossSilo) - .Emit("the reconcile pass must complete before anything about it is asserted", cancellationToken: ct); - - second.Fetched.Should().BeEmpty( - "neither partition is the installer's to write — converging must not fetch, let alone " - + "write, a single file (still ONE writer per partition, MeshWeaver#4355)"); - - var converged = await AwaitRecord(Converges, r => r.ModuleVersion == V2, ct); - converged.HeldUpdate.Should().BeNull("a converged package carries no hold"); - converged.HeldSince.Should().BeNull(); - converged.NotifiedModuleVersion.Should().Be(V2, "no reminder is owed for a version that is installed"); - - var held = await AwaitRecord(StaysHeld, r => r.HeldUpdate is not null, ct); - held.ModuleVersion.Should().Be(V1, "a sync that has not landed the candidate keeps the hold"); - held.HeldUpdate.Should().Contain("kept by its sync source").And.Contain(V1).And.Contain(V2, - "the record — and the fleet view that lists it — says WHY it is held and what it waits for"); - held.HeldSince.Should().NotBeNull("the view's 'held since' and the too-long rule read it"); - - // ── The same hold, decided again: its since-when must SURVIVE (review on #6065 — clearing a - // non-floor hold on every pass reset it, so the too-long rule could never fire). ──────── - var since = held.HeldSince; - await PackageUpdateReconciler.ReconcileInstalled( - Mesh, second, "HEAD", [Candidate(StaysHeld, V2)], "Served by registry 'test'", Logger) - .Should().Within(TestTimeouts.CrossSilo).Emit(cancellationToken: ct); - (await AwaitRecord(StaysHeld, r => r.HeldUpdate is not null, ct)).HeldSince.Should().Be(since, - "a hold that is still in force keeps the instant it began"); - - // ── The sync lands V2 for the held one too: the next pass converges it. ───────────────── - await TrackPartition(StaysHeld, landed: V2, ct); - await PackageUpdateReconciler.ReconcileInstalled( - Mesh, second, "HEAD", [Candidate(StaysHeld, V2)], "Served by registry 'test'", Logger) - .Should().Within(TestTimeouts.CrossSilo).Emit(cancellationToken: ct); - var later = await AwaitRecord(StaysHeld, r => r.ModuleVersion == V2, ct); - later.HeldUpdate.Should().BeNull("the hold clears the moment its condition is true"); - later.HeldSince.Should().BeNull(); - second.Fetched.Should().BeEmpty(); - } - - private static PackageManifest Candidate(string id, string moduleVersion) => new() - { - Id = id, - Name = id, - Kind = PackageKind.NodeRepo, - TargetPartition = id, - SourceFolder = id, - Version = moduleVersion == V1 ? "1.0.0" : "1.1.0", - ModuleVersion = moduleVersion, - }; - - private static IReadOnlyList Files(string id, string moduleVersion) => - [ - new PackageFile($"{id}/{ModuleManifest.FileName}", $$""" - { - "module": "{{id}}", - "moduleVersion": "{{moduleVersion}}", - "version": "1.0.0", - "files": { - "{{id}}/index.json": "aaa", - "{{id}}/Guide.md": "{{moduleVersion}}" - } - } - """), - new PackageFile($"{id}/index.json", $$""" - { - "id": "{{id}}", - "path": "{{id}}", - "nodeType": "Space", - "name": "{{id}}", - "state": "Active" - } - """), - new PackageFile($"{id}/Guide.md", $"# Guide\n\nGeneration {moduleVersion}."), - ]; - - private async Task Install(IPackageSource source, string id, string moduleVersion, CancellationToken ct) - { - await CatalogLayoutAreas.InstallOrUpdate(Mesh, source, "HEAD", Candidate(id, moduleVersion), Logger) - .Should().Within(TestTimeouts.CrossSilo) - .Emit($"installing {id} at {moduleVersion} is a precondition", cancellationToken: ct); - (await AwaitRecord(id, r => r.ModuleVersion == moduleVersion, ct)).EffectiveUpdatePolicy - .Should().Be(PackageUpdatePolicy.Auto, "the premise is an UNATTENDED apply"); - } - - /// A real {partition}/_GitSync naming a repository, with the module version the - /// sync last LANDED — read back through the seam under test before the test proceeds. - private async Task TrackPartition(string partition, string landed, CancellationToken ct) - { - var config = new GitHubSyncConfig - { - RepositoryUrl = "https://github.com/Systemorph/Example", - Branch = "main", - ModuleVersions = ImmutableDictionary.Empty.Add(partition, landed), - }; - var path = $"{partition}/{GitHubSyncService.ConfigId}"; - var exists = await Mesh.ServiceProvider.GetRequiredService() - .Read(path, Mesh.JsonSerializerOptions).Take(1).DefaultIfEmpty(null) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - if (exists is null) - await MeshService.CreateNode(new MeshNode(GitHubSyncService.ConfigId, partition) - { - Name = "GitHub Sync", - NodeType = GitHubSyncService.ConfigNodeType, - State = MeshNodeState.Active, - Content = config, - }) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: ct); - else - await Mesh.GetMeshNodeStream(path) - .Update(_ => config) - .Should().Within(TestTimeouts.WriteConvergence).Emit(cancellationToken: ct); - - var reading = await Observable.Interval(TestTimeouts.Quick / 20).StartWith(0L) - .SelectMany(_ => PartitionContentOwnership.SyncedModules(Mesh, partition)) - .Where(r => r.Delivered(partition, landed)) - .FirstAsync() - .Timeout(TestTimeouts.CrossSilo) - .Await(ct); - reading.Known.Should().BeTrue("the seam must read the sync's landed version before the arm means anything"); - } - - private Task AwaitRecord(string id, Func predicate, CancellationToken ct) => - Observable.Interval(TimeSpan.FromMilliseconds(100)).StartWith(0L) - .SelectMany(_ => Mesh.ServiceProvider.GetRequiredService() - .Read($"{PackageInstaller.InstalledPartition}/{id}", Mesh.JsonSerializerOptions).Take(1).DefaultIfEmpty(null)) - .Select(n => n?.ContentAs(Mesh.JsonSerializerOptions)) - .OfType() - .Where(predicate) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(120)) - .Await(ct); - - private sealed record Fetch(string PackageId, IReadOnlyCollection? Paths); - - private sealed class RecordingSource(IReadOnlyDictionary> byPackage) - : IPackageSource - { - private ImmutableList fetched = ImmutableList.Empty; - - public ImmutableList Fetched => fetched; - - public IObservable> ListPackages(string gitRef) => - Observable.Return>( - byPackage.Keys.Select(id => Candidate(id, V1)).ToList()); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) => - FetchPackageFiles(package, gitRef, null); - - public IObservable> FetchPackageFiles( - PackageManifest package, string gitRef, IReadOnlyCollection? paths) => - Observable.Defer(() => - { - ImmutableInterlocked.Update(ref fetched, f => f.Add(new Fetch(package.Id, paths))); - var files = byPackage[package.Id]; - return Observable.Return>(paths is null - ? files - : files.Where(f => f.RelativePath.EndsWith(ModuleManifest.FileName, StringComparison.Ordinal) - || paths.Contains(f.RelativePath)).ToList()); - }); - } -} - -/// The convergence rules, pure — every arm, no mesh. -public class HeldUpdateConvergenceRulesTest -{ - private static readonly PartitionContentOwnershipVerdict SyncOwned = - new(PartitionContentOwner.SyncSource, "P", "synced"); - private static readonly PartitionContentOwnershipVerdict InstallerOwned = - new(PartitionContentOwner.Installer, "P", "installer"); - private static readonly PartitionContentOwnershipVerdict Undetermined = - new(PartitionContentOwner.Undetermined, "P", "unknown"); - - [Fact] - public void Delivered_IsTrueOnlyForAKnownReading_ThatNamesExactlyTheCandidate() - { - var landed = SyncedModuleVersions.Of([new("AI", "abc")]); - landed.Delivered("AI", "abc").Should().BeTrue(); - landed.Delivered("ai", "abc").Should().BeTrue("module ids are case-insensitive, hashes are not"); - landed.Delivered("AI", "ABC").Should().BeFalse(); - landed.Delivered("AI", "other").Should().BeFalse("the sync landed a different tree"); - landed.Delivered("Hosting", "abc").Should().BeFalse("the sync reports nothing for that module"); - landed.Delivered("AI", null).Should().BeFalse("a blank candidate is no evidence"); - SyncedModuleVersions.Unknown.Delivered("AI", "abc").Should().BeFalse("cannot-tell licenses nothing"); - } - - [Fact] - public void FoldSynced_IsKnownWhenAnyProviderAnswers_AndUnknownOtherwise() - { - PartitionContentOwnership.FoldSynced([SyncedModuleVersions.Unknown]).Known.Should().BeFalse(); - PartitionContentOwnership.FoldSynced([]).Known.Should().BeFalse(); - var folded = PartitionContentOwnership.FoldSynced( - [SyncedModuleVersions.Unknown, SyncedModuleVersions.Of([new("AI", "abc")])]); - folded.Known.Should().BeTrue(); - folded.Delivered("AI", "abc").Should().BeTrue(); - } - - [Fact] - public void FoldSynced_TwoSourcesNamingDifferentHashesForOneModule_IsAmbiguousAndLicensesNothing() - { - var folded = PartitionContentOwnership.FoldSynced( - [SyncedModuleVersions.Of([new("AI", "abc"), new("Hosting", "h1")]), - SyncedModuleVersions.Of([new("AI", "def"), new("Hosting", "h1")])]); - folded.Known.Should().BeTrue(); - folded.Delivered("AI", "abc").Should().BeFalse("two sources disagree: no winner"); - folded.Delivered("AI", "def").Should().BeFalse("two sources disagree: no winner"); - folded.Delivered("Hosting", "h1").Should().BeTrue("sources that agree are not a conflict"); - SyncedModuleVersions.Of([new("AI", "abc"), new("ai", "def")]).Delivered("AI", "def") - .Should().BeFalse("the same conflict inside one provider's reading"); - } - - [Fact] - public void SyncDelivered_ConvergesOnlyASyncOwnedPartition() - { - var landed = SyncedModuleVersions.Of([new("P", "abc")]); - PartitionContentOwnership.SyncDelivered(SyncOwned, landed, "P", "abc").Should().BeTrue(); - PartitionContentOwnership.SyncDelivered(InstallerOwned, landed, "P", "abc").Should().BeFalse( - "an installer-owned partition is applied, not adopted"); - PartitionContentOwnership.SyncDelivered(Undetermined, landed, "P", "abc").Should().BeFalse( - "an unobserved partition is not a synced one"); - } - - [Fact] - public void AdoptedFromSync_MovesTheBooks_AndKeepsTheInstallTimeFields() - { - var record = new PackageManifest - { - Id = "P", Version = "1.0.0", ModuleVersion = "old", UpdatePolicy = PackageUpdatePolicy.Auto, - AuthorizedBy = "system-security", HeldUpdate = "held: …", HeldSince = DateTimeOffset.UnixEpoch, - HeldUpdateDispatch = "dispatched", HeldUpdateDispatchedAt = DateTimeOffset.UnixEpoch, - }; - var candidate = new PackageManifest - { - Id = "P", Version = "1.1.0", ReleasedVersion = "1.1.0", ModuleVersion = "new", MinMeshVersion = "3.0.0-ci.9887", - ManifestFiles = ImmutableSortedDictionary.Empty.Add("P/index.json", "h"), - }; - var now = DateTimeOffset.Parse("2026-10-04T00:00:00Z"); - var adopted = PackageUpdateReconciler.AdoptedFromSync(record, candidate, now); - adopted.ModuleVersion.Should().Be("new"); - adopted.ReleasedVersion.Should().Be("1.1.0"); - adopted.MinMeshVersion.Should().Be("3.0.0-ci.9887"); - adopted.InstalledFiles.Should().ContainKey("P/index.json"); - adopted.InstalledAtUtc.Should().Be(now); - adopted.HeldUpdate.Should().BeNull(); - adopted.HeldSince.Should().BeNull(); - adopted.HeldUpdateDispatch.Should().BeNull(); - adopted.UpdatePolicy.Should().Be(PackageUpdatePolicy.Auto, "policy is the administrator's, never the lane's"); - adopted.AuthorizedBy.Should().Be("system-security"); - } - - [Fact] - public void OnlyAFloorHold_EndsWhenTheFloorIsMet() - { - var floorHold = PackagePlatformFloorGate.HeldSentence( - new PackageManifest { Id = "AI", ReleasedVersion = "1.19.4" }, - new PlatformFloorVerdict(PlatformFloorKind.Held, "3.0.0-ci.9887", "3.0.0-ci.9885", null)); - PackagePlatformFloorGate.IsFloorHold(floorHold).Should().BeTrue(); - PackagePlatformFloorGate.IsFloorHold(PackageUpdateReconciler.SyncHoldSentence( - new PackageManifest { Id = "AI", ReleasedVersion = "1.19.4", ModuleVersion = "a4" }, - SyncedModuleVersions.Of([new("AI", "a1")]), "AI")) - .Should().BeFalse("a sync hold is re-decided by its own lane and keeps its since-when"); - PackagePlatformFloorGate.IsFloorHold(null).Should().BeFalse(); - } - - [Fact] - public void TheInstaller_RefusesADifferentVersionAboveTheFloor_EvenOverAnExistingRecord() - { - PackagePlatformFloorGate.AllowedOverExistingRecord(true, "same", "same").Should().BeTrue( - "a re-install of what is already here heals in place"); - PackagePlatformFloorGate.AllowedOverExistingRecord(true, "old", "new").Should().BeFalse( - "a maintenance refresh must not land the very version the floor holds (the 09-27 breakage)"); - PackagePlatformFloorGate.AllowedOverExistingRecord(false, null, "new").Should().BeFalse( - "a fresh install above the floor was always refused"); - PackagePlatformFloorGate.AllowedOverExistingRecord(true, null, null).Should().BeFalse( - "no content identity is no evidence of sameness"); - } - - [Fact] - public void TheSyncHoldSentence_NamesWhatTheSyncLanded_AndWhatItWaitsFor() - { - var candidate = new PackageManifest { Id = "AI", ReleasedVersion = "1.19.4", ModuleVersion = "085cc77d" }; - PackageUpdateReconciler.SyncHoldSentence(candidate, SyncedModuleVersions.Of([new("AI", "8b9aad84")]), "AI") - .Should().StartWith("held: 1.19.4").And.Contain("8b9aad84").And.Contain("085cc77d"); - PackageUpdateReconciler.SyncHoldSentence(candidate, SyncedModuleVersions.Unknown, "AI") - .Should().Contain("could not be read", "cannot-tell is said, never spelt as 'nothing'"); - } -} - -/// -/// 🚨 The first step of every test run (maintainer, 2026-10-04: "should be always beginning -/// of each test run", "done by framework"). The decision, every arm; the header; and the catalog's -/// reading of a stale instance. -/// -public class TestRunPreflightRulesTest -{ - private static VersionsUnderTest Reading(params ModuleUnderTest[] modules) => - new("3.0.0-ci.9887", "1bafcb28526951fe", "3.0.0-ci.9898", modules.ToImmutableList(), "fabrikam"); - - [Fact] - public void AnUpToDateInstance_DoesNothing() - { - var verdict = TestRunPreflight.Decide(Reading(new ModuleUnderTest("AI", "1.19.4", null)), - platformAtTarget: true, mayConverge: true, alreadyConverged: false); - verdict.Kind.Should().Be(TestRunPreflightKind.UpToDate); - verdict.Proceed.Should().BeTrue(); - verdict.Message.Should().Contain("nothing to converge"); - } - - [Fact] - public void AStaleInstance_ConvergesFirst_WhenTheRunMayChangeIt() - { - var verdict = TestRunPreflight.Decide(Reading(new ModuleUnderTest("AI", "1.18.1", "1.19.4")), - platformAtTarget: true, mayConverge: true, alreadyConverged: false); - verdict.Kind.Should().Be(TestRunPreflightKind.Converge); - verdict.Message.Should().Contain("AI 1.18.1 → 1.19.4"); - } - - [Fact] - public void AStaleInstance_FailsNamingTheSkew_WhenTheRunMayNotChangeIt_OrConvergingDidNotCloseIt() - { - var refused = TestRunPreflight.Decide( - Reading(new ModuleUnderTest("AI", "1.18.1", "1.19.4", "held: 1.19.4 needs platform ≥ 3.0.0-ci.9887")), - platformAtTarget: true, mayConverge: false, alreadyConverged: false); - refused.Kind.Should().Be(TestRunPreflightKind.Skew); - refused.Proceed.Should().BeFalse("never silently test an old version"); - refused.Message.Should().Contain("AI 1.18.1 ≠ target 1.19.4").And.Contain("held: 1.19.4") - .And.Contain("may not change it"); - - var stillBehind = TestRunPreflight.Decide(Reading(new ModuleUnderTest("AI", "1.18.1", "1.19.4")), - platformAtTarget: true, mayConverge: true, alreadyConverged: true); - stillBehind.Kind.Should().Be(TestRunPreflightKind.Skew); - stillBehind.Message.Should().Contain("did not close it"); - } - - [Fact] - public void APlatformBehindTheTarget_IsAlwaysASkew_ATestRunNeverRollsAnImage() - { - var verdict = TestRunPreflight.Decide(Reading(), platformAtTarget: false, mayConverge: true, alreadyConverged: false); - verdict.Kind.Should().Be(TestRunPreflightKind.Skew); - verdict.Message.Should().Contain("3.0.0-ci.9887").And.Contain("3.0.0-ci.9898").And.Contain("never rolls"); - } - - [Fact] - public void TheHeader_ListsThePlatform_TheTarget_AndEveryModuleVersion() - { - var header = Reading(new ModuleUnderTest("AI", "1.19.4", null), new ModuleUnderTest("Hosting", "1.53.0", "1.53.1")) - .Header(); - header.Should().Contain("platform 3.0.0-ci.9887").And.Contain("core 1bafcb285").And.Contain("target 3.0.0-ci.9898") - .And.Contain("AI 1.19.4").And.Contain("Hosting 1.53.0 → target 1.53.1").And.Contain("1 off-target"); - Reading(new ModuleUnderTest("AI", "1.19.4", null)).Header(all: false).Should().NotContain(" AI", - "the short form lists only what is off-target"); - VersionsUnderTest.OfThisProcess().Header().Should().Contain("in-process mesh").And.Contain("no target known"); - } - - [Fact] - public void AnUnreadInstance_HeaderSaysUnknown_NotTheHostProcessVersions() - { - var header = VersionsUnderTest.NotRead("this instance").Header(); - header.Should().Contain("NOT READ").And.Contain("unknown").And.NotContain("0 module(s)") - .And.NotContain("core ").And.NotContain("in-process mesh"); - } - - [Fact] - public void TheCatalogReading_NamesTheTargetFromTheServedFeed() - { - var served = ImmutableList.Create( - new ServedPackage("AI", "1.19.4", "085cc77d", "3.0.0-ci.9887"), - new ServedPackage("Hosting", "1.53.1", "h2", "3.0.0-ci.9898"), - new ServedPackage("Store", "1.20.0", "s1", null)); - var records = ImmutableList.Create( - new PackageManifest { Id = "AI", ReleasedVersion = "1.19.4", ModuleVersion = "085cc77d" }, - new PackageManifest - { - Id = "Hosting", ReleasedVersion = "1.53.0", ModuleVersion = "h1", HeldUpdate = "held: …", - AutoUpdate = true, UpdatePolicy = PackageUpdatePolicy.Auto, - }, - new PackageManifest { Id = "Local", Version = "0.1.0", ModuleVersion = "l1" }); - - var (versions, platformAtTarget) = CatalogTestRunPreflight.Compose("3.0.0-ci.9887", "abc", served, records); - versions.TargetPlatform.Should().Be("3.0.0-ci.9898", "the target platform is the newest floor CI stamped on a served package"); - platformAtTarget.Should().BeFalse(); - versions.Modules.Single(m => m.Id == "AI").AtTarget.Should().BeTrue(); - var hosting = versions.Modules.Single(m => m.Id == "Hosting"); - hosting.AtTarget.Should().BeFalse(); - hosting.Target.Should().Be("1.53.1"); - hosting.Held.Should().Be("held: …"); - versions.Modules.Single(m => m.Id == "Local").AtTarget.Should().BeTrue("a package the registry does not serve has no target"); - - CatalogTestRunPreflight.Compose("3.0.0-ci.9898", "abc", served, records).PlatformAtTarget.Should().BeTrue(); - - // 🚨 The ARMED cap (measured 2026-10-04: 9898 sealed, not armed — no roll could reach it). - var (armed, armedAtTarget) = CatalogTestRunPreflight.Compose("3.0.0-ci.9887", "abc", served, records, latestArmed: "3.0.0-ci.9887"); - armed.TargetPlatform.Should().Be("3.0.0-ci.9887", "the target is the newest set the fleet can RUN"); - armedAtTarget.Should().BeTrue(); - armed.Modules.Single(m => m.Id == "Hosting").AtTarget.Should().BeTrue( - "a served version whose floor is above the armed target is AWAITING ARMING, not behind"); - TargetSet.Standing("h1", served[1], "3.0.0-ci.9887").Should().Be(TargetStanding.AwaitingArming); - TargetSet.Standing("h1", served[1], "3.0.0-ci.9898").Should().Be(TargetStanding.Behind); - CatalogTestRunPreflight.Compose("3.0.0-ci.9898", "abc", null, records).Versions.TargetPlatform - .Should().BeNull("no feed read yet is 'no target known', never 'at target'"); - var (armedOnly, armedOnlyAtTarget) = CatalogTestRunPreflight.Compose("3.0.0-ci.9885", "abc", null, records, latestArmed: "3.0.0-ci.9887"); - armedOnly.TargetPlatform.Should().Be("3.0.0-ci.9887", "the armed platform binds before the first feed read (review on #6065)"); - armedOnlyAtTarget.Should().BeFalse("a platform behind the armed set is refused even then"); - } - - [Fact] - public void ARepublishedVersionWithNewContent_IsBehind_AndTheTargetNamesItsHash() - { - // Review on MeshWeaver#6065: Standing decides by CONTENT; the header's AtTarget must not - // re-derive it from SemVer strings, or a same-SemVer re-publish reads as at target. - var served = ImmutableList.Create(new ServedPackage("AI", "1.19.4", "new-hash", null)); - var records = ImmutableList.Create(new PackageManifest - { - Id = "AI", ReleasedVersion = "1.19.4", ModuleVersion = "old-hash", - AutoUpdate = true, UpdatePolicy = PackageUpdatePolicy.Auto, - }); - - var (versions, platformAtTarget) = CatalogTestRunPreflight.Compose("3.0.0-ci.9887", "abc", served, records); - var ai = versions.Modules.Single(); - ai.AtTarget.Should().BeFalse("the installed content is not the served content, whatever the SemVer says"); - ai.Target.Should().Be("new-hash", "the same SemVer would name no difference at all"); - TestRunPreflight.Decide(versions, platformAtTarget, mayConverge: false, alreadyConverged: false) - .Kind.Should().Be(TestRunPreflightKind.Skew, "a run on stale content is the case the preflight refuses"); - versions.Header(all: true).Should().Contain("AI 1.19.4 → target new-hash"); - } - - [Fact] - public void APackageAPersonKeepsOnNotify_IsAReportedHold_NotASkewThatRefusesEveryRun() - { - // Review on MeshWeaver#6065: a Notify/None package is never applied unattended, so no - // convergence pass can close it; it must not refuse every test run on the instance. - var served = ImmutableList.Create( - new ServedPackage("Manual", "2.0.0", "m2", null), - new ServedPackage("Auto", "2.0.0", "a2", null)); - var records = ImmutableList.Create( - new PackageManifest { Id = "Manual", ReleasedVersion = "1.0.0", ModuleVersion = "m1", UpdatePolicy = PackageUpdatePolicy.Notify }, - new PackageManifest - { - Id = "Auto", ReleasedVersion = "1.0.0", ModuleVersion = "a1", - AutoUpdate = true, UpdatePolicy = PackageUpdatePolicy.Auto, - }); - - var (versions, platformAtTarget) = CatalogTestRunPreflight.Compose("3.0.0-ci.9887", "abc", served, records); - var manual = versions.Modules.Single(m => m.Id == "Manual"); - manual.AtTarget.Should().BeTrue("a person's policy is a hold, not a gap the run can be refused for"); - manual.Held.Should().Contain("update policy is Notify", "the reason is shown"); - versions.Modules.Single(m => m.Id == "Auto").AtTarget.Should().BeFalse(); - - var decision = TestRunPreflight.Decide(versions, platformAtTarget, mayConverge: false, alreadyConverged: false); - decision.Kind.Should().Be(TestRunPreflightKind.Skew, "the Auto package is still a gap"); - decision.Message.Should().Contain("Auto 1.0.0").And.NotContain("Manual 1.0.0 ≠"); - versions.Header(all: true).Should().Contain("Manual 1.0.0 → target 2.0.0 [the package's update policy is Notify"); - - var onlyManual = CatalogTestRunPreflight.Compose("3.0.0-ci.9887", "abc", served, records.RemoveAt(1)); - TestRunPreflight.Decide(onlyManual.Versions, onlyManual.PlatformAtTarget, mayConverge: false, alreadyConverged: false) - .Kind.Should().Be(TestRunPreflightKind.UpToDate); - } - - [Fact] - public void TheTargetSet_PlatformIsTheNewestOrderedFloor() - { - TargetSet.Platform([ - new ServedPackage("A", "1", "a", "3.0.0-ci.9606"), - new ServedPackage("B", "1", "b", "3.0.0-ci.9898"), - new ServedPackage("C", "1", "c", "3.0.0-ci.0"), - new ServedPackage("D", "1", "d", null), - ]).Should().Be("3.0.0-ci.9898"); - TargetSet.Platform([]).Should().BeNull(); - TargetSet.SetsBehind("3.0.0-ci.9606", "3.0.0-ci.9898").Should().Be(292L); - TargetSet.SetsBehind("3.0.0-ci.9900", "3.0.0-ci.9898").Should().Be(0L); - TargetSet.PlatformAtTarget(null, "3.0.0-ci.9898").Should().BeFalse("an unreadable running version is not at target"); - } -} - -/// The in-process run header, written by the monolith base before any test body. -public class MonolithRunHeaderTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - [Fact] - public void TheBase_RecordsTheVersionsUnderTest_BeforeTheTestRuns() - { - VersionsUnderTest.Should().NotBeNull("the framework records the run's header in InitializeAsync"); - var versions = VersionsUnderTest ?? throw new InvalidOperationException("no versions under test were recorded"); - versions.Source.Should().Be("in-process mesh"); - versions.Commit.Should().Be(PlatformBuildInfo.CommitHash); - versions.Header().Should().Contain("Versions under test"); - } -} - -/// -/// The catalog preflight on a STALE mesh it may change, where the convergence pass cannot close the -/// gap (no registry is configured to land from): it tries, re-reads, and refuses naming the skew — -/// never a silent pass on an old version. -/// -public class CatalogTestRunPreflightTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - // Auto: the premise is a package the convergence pass MAY apply (a Notify package - // is a reported hold, pinned in TestRunPreflightRulesTest). - .AddSingleton(new PluginCatalogOptions - { - InstallPreInstalledPackages = false, - DefaultUpdatePolicy = PackageUpdatePolicy.Auto, - })); - - [Fact(Timeout = 300_000)] - public async Task AStaleInstance_IsConvergedFirst_AndRefusedNamingTheSkew_WhenThatDoesNotCloseIt() - { - var ct = TestContext.Current.CancellationToken; - var logger = Mesh.ServiceProvider.GetRequiredService().CreateLogger(); - await FloorHoldFixture.InstallV1(Mesh, logger, ct); - var preflight = Mesh.ServiceProvider.GetRequiredService(); - preflight.Should().BeOfType("the catalog registers the framework step"); - - // No feed read yet: no target is known, so there is nothing to converge — and it says so. - var unknown = await preflight.Prepare(mayConverge: true).FirstAsync().Timeout(TimeSpan.FromSeconds(60)).Await(ct); - unknown.Kind.Should().Be(TestRunPreflightKind.UpToDate); - unknown.Versions.Modules.Should().Contain(m => m.Id == FloorHoldFixture.Package && m.Installed == FloorHoldFixture.V1Version); - - // The feed now serves V2: the instance is stale. - await WriteLedger(new ServedPackage(FloorHoldFixture.Package, FloorHoldFixture.V2Version, FloorHoldFixture.V2Hash, null), ct); - var stale = await preflight.Prepare(mayConverge: true).FirstAsync().Timeout(TimeSpan.FromSeconds(120)).Await(ct); - stale.Kind.Should().Be(TestRunPreflightKind.Skew, "the pass ran and could not land V2 — the run must not test V1 as if current"); - stale.Message.Should().Contain(FloorHoldFixture.Package).And.Contain(FloorHoldFixture.V2Version).And.Contain("did not close it"); - stale.Versions.Header().Should().Contain($"{FloorHoldFixture.Package} {FloorHoldFixture.V1Version} → target {FloorHoldFixture.V2Version}"); - - var mayNot = await preflight.Prepare(mayConverge: false).FirstAsync().Timeout(TimeSpan.FromSeconds(60)).Await(ct); - mayNot.Kind.Should().Be(TestRunPreflightKind.Skew); - mayNot.Message.Should().Contain("may not change it"); - } - - private async Task WriteLedger(ServedPackage served, CancellationToken ct) - { - var node = MeshNode.Satellite(RegistryUpdateReconciler.LedgerId, PackageInstaller.InstalledPartition) with - { - Name = "Registry reconcile ledger", - NodeType = RegistryUpdateReconciler.LedgerNodeType, - State = MeshNodeState.Active, - Content = new RegistryReconcileLedger - { - Registries = [new RegistryReconcileEntry { Url = "https://registry.example.com", Name = "test", Served = [served] }], - UpdatedAt = DateTimeOffset.UtcNow, - }, - }; - var access = Mesh.ServiceProvider.GetRequiredService(); - await access.RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService().CreateOrUpdateNode(node)) - .FirstAsync().Timeout(TimeSpan.FromSeconds(60)).Await(ct); - } -} diff --git a/test/Memex.Portal.Shared.Test/IconRasterizerTest.cs b/test/Memex.Portal.Shared.Test/IconRasterizerTest.cs deleted file mode 100644 index 21734063b3..0000000000 --- a/test/Memex.Portal.Shared.Test/IconRasterizerTest.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System; -using System.Linq; -using Memex.Portal.Shared.Seo; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The raster favicon (issue #2075, item 3). Safari renders no SVG favicon at all, so the -/// per-content favicon — which every node page has emitted server-side since 2026-08-11 — was -/// invisible on macOS and iOS: every tab wore the portal mark. -/// -/// 🚨 These assert on an AUTHORED mark, not on art the portal generates. That is the whole -/// point of the dependency: the icon population is mixed, and drawing only the generated -/// backplates would have shipped an endpoint Safari honours while leaving the issue's own -/// reproduction case — the Chess package, whose mark is an authored board — still showing -/// the portal favicon. A fix that does not cover its own repro is the shape worth refusing. -/// -public class IconRasterizerTest -{ - /// - /// 🚨 THE ISSUE'S OWN REPRO, byte for byte as Chess/index.json carries it: an authored - /// board (the #f0d9b5 / #b58863 squares and the knight path), not something this - /// test made up to be easy to draw. - /// - private const string AuthoredMark = - "" - + "" - + "" - + "" - + ""; - - /// PNG magic + IHDR width/height, read straight out of the bytes — the same reading - /// a browser does, and the only one that proves this is a PNG rather than "some bytes came - /// back". - private static (int Width, int Height) PngSize(byte[] png) - { - Assert.True(png.Length > 24, "a file that short cannot be a PNG"); - Assert.Equal(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }, png.Take(8).ToArray()); - Assert.Equal("IHDR", System.Text.Encoding.ASCII.GetString(png, 12, 4)); - int Be(int at) => (png[at] << 24) | (png[at + 1] << 16) | (png[at + 2] << 8) | png[at + 3]; - return (Be(16), Be(20)); - } - - /// - /// The two sizes the head declares: the 32 px favicon Safari will actually read, and the 180 px - /// apple-touch-icon — a separate channel Safari does NOT fall back to the favicon for. - /// - [Theory] - [InlineData(32)] - [InlineData(180)] - public void AnAuthoredSvgMark_RastersToARealPngAtThatExactSize(int size) - { - var png = IconRasterizer.Render(AuthoredMark, size); - - Assert.NotNull(png); - var (width, height) = PngSize(png); - Assert.Equal(size, width); - Assert.Equal(size, height); - } - - /// - /// A PNG of the right dimensions could still be blank. The mark's own ground is - /// #f0d9b5, so the centre pixel must be opaque and light — proof the authored markup was - /// PARSED and drawn, not merely allocated as a transparent canvas. - /// - [Fact] - public void TheAuthoredMarkIsActuallyDrawn_NotABlankCanvasOfTheRightSize() - { - var png = IconRasterizer.Render(AuthoredMark, 64); - - Assert.NotNull(png); - using var bitmap = SkiaSharp.SKBitmap.Decode(png); - Assert.Equal(64, bitmap.Width); - var corner = bitmap.GetPixel(32, 6); // board ground, above the knight - Assert.Equal(255, corner.Alpha); - Assert.Equal(0xF0, corner.Red); - Assert.Equal(0xD9, corner.Green); - Assert.Equal(0xB5, corner.Blue); - } - - /// - /// The endpoint serves a strong ETag computed from these bytes, so the same mark at the same - /// size must render identically — otherwise every refetch is a cache miss and the 304 path is - /// dead code. - /// - [Fact] - public void TheSameMarkAtTheSameSize_RendersByteIdentically() - => Assert.Equal(IconRasterizer.Render(AuthoredMark, 32), IconRasterizer.Render(AuthoredMark, 32)); - - /// A non-square mark keeps its aspect ratio, so the output is square by CANVAS, not by - /// stretching the artwork. - [Fact] - public void ANonSquareMark_IsFittedAndCentred_NotStretched() - { - const string wide = - "" - + ""; - - var png = IconRasterizer.Render(wide, 32); - - Assert.NotNull(png); - using var bitmap = SkiaSharp.SKBitmap.Decode(png); - // Scaled by the wider axis (32/64), so the band is 8 px tall and centred: the middle row is - // painted and the top row is not. - Assert.Equal(255, bitmap.GetPixel(16, 16).Alpha); - Assert.Equal(0, bitmap.GetPixel(16, 1).Alpha); - } - - /// - /// 🚨 Markup that PARSES but paints nothing must say "no icon" rather than encode a fully - /// transparent PNG. A blank icon behind a 200 looks exactly like a working endpoint, so it is - /// the one failure nobody can see — and it is not hypothetical: an empty svg root parses - /// cleanly and reports a 1×1 cull rect, so a bounds check alone would pass it straight through. - /// - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("")] - [InlineData("")] - [InlineData("")] - public void MarkupThatPaintsNothing_YieldsNoIconRatherThanABlankOne(string markup) - => Assert.Null(IconRasterizer.Render(markup, 32)); - - /// The blank test is on the PIXELS, not on the bounds — a mark that paints only a - /// speck still counts as an icon. - [Fact] - public void AMarkThatPaintsOnlyASpeck_IsStillAnIcon() - => Assert.NotNull(IconRasterizer.Render( - "" - + "", 32)); - - /// - /// The size set is an ALLOW-LIST, not a clamped range: the route is anonymous and - /// shared-cacheable, so a free-form size is an unbounded number of distinct renders per node. - /// - [Fact] - public void TheSupportedSizes_AreAnAllowList_CoveringBothDeclaredLinks() - { - Assert.True(IconRasterizer.IsSupportedSize(IconRasterizer.FaviconSize)); - Assert.True(IconRasterizer.IsSupportedSize(IconRasterizer.AppleTouchSize)); - Assert.False(IconRasterizer.IsSupportedSize(33)); - Assert.False(IconRasterizer.IsSupportedSize(4096)); - Assert.False(IconRasterizer.IsSupportedSize(0)); - } - - /// A non-positive size is a caller bug, not an image — it must raise rather than - /// produce a degenerate canvas. - [Fact] - public void ANonPositiveSize_Raises() - => Assert.Throws(() => IconRasterizer.Render(AuthoredMark, 0)); -} diff --git a/test/Memex.Portal.Shared.Test/IncompleteInstallIsReassertedTest.cs b/test/Memex.Portal.Shared.Test/IncompleteInstallIsReassertedTest.cs deleted file mode 100644 index 5bd92df40d..0000000000 --- a/test/Memex.Portal.Shared.Test/IncompleteInstallIsReassertedTest.cs +++ /dev/null @@ -1,426 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.Persistence.Parsers; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 THE SWEEP'S VERDICT NEEDS A CONSUMER THAT ACTS — MeshWeaver#4812. -/// -/// #3485 put the completeness sweep into the boot repair pass (the one complete inventory of -/// what is installed) and the heal into CatalogLayoutAreas.InstallOrUpdate's up-to-date -/// exit — and nothing connected the two. The funnel runs only for a package some lane VISITS: -/// the platform baseline and the environment's flags on every boot, the operator's seed once, the -/// update reconciler only when the module hash MOVED (an equal hash returns before the funnel), -/// and a human's click. A package installed by hand from a source that has not moved since is -/// visited by nothing, so the sweep's own line — "Reinstalling it now repairs it" — described a -/// click nobody made, on every boot, for weeks. -/// -/// This fixture is that shape: a package installed through the funnel (not the boot lane, so -/// no lane re-asserts it), one declared node lost, the source unchanged. The REPAIR PASS — the -/// production — must hand the shortfall to -/// the install lane and the node must come back. On main the pass reports at Error and the -/// node stays gone. -/// -public class IncompleteInstallIsReassertedTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - // The source the install came from, REGISTERED so the repair's re-assert can list - // it at its ref (the boot lane names a DI source `registered-0`). No baseline, no - // pattern: the boot pass itself selects nothing, which is exactly the point — the - // package below is visited by no lane but the repair. - .AddSingleton(_ => new ReassertFixture.Source(ReassertFixture.Files())) - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false })); - - private InstanceAutoRegistrationService Installer => - Mesh.ServiceProvider.GetRequiredService(); - - private InstalledPackageRepairService Repair => - Mesh.ServiceProvider.GetRequiredService(); - - [Fact(Timeout = 300_000)] - public async Task ALostNodeOfAHandInstalledPackage_ComesBackOnTheNextRepairPass() - { - var ct = TestContext.Current.CancellationToken; - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - // The boot pass has nothing to install here and must have SAID so before the repair can - // sequence after it — the heal waits on Completed by design. - await Installer.Completed.FirstAsync().Timeout(180.Seconds()).Await(ct); - - // A hand install through the funnel — the catalog click's path — at the source's ref. - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, new ReassertFixture.Source(ReassertFixture.Files()), "HEAD", - ReassertFixture.Candidate(), logger) - .Should().Within(180.Seconds()) - .Emit("the install must land before a loss can be measured", cancellationToken: ct); - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.GuidePath, present: true)).Should().BeTrue( - "the Guide node is the subject — if the install never wrote it, the assertion below " - + "would pass for the wrong reason"); - - var before = await ReassertFixture.ReadRecord(Mesh); - before.Should().NotBeNull("the installer stamps an install record"); - before!.ModuleVersion.Should().Be(ReassertFixture.ModuleHash, - "the source serves the same module version the record carries — the premise: nothing " - + "about the SOURCE changed, so no update lane will ever visit this package"); - - // The loss. - await meshService.DeleteNode(ReassertFixture.GuidePath) - .Should().Within(60.Seconds()).Emit("the deletion is the precondition", cancellationToken: ct); - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.GuidePath, present: false)).Should().BeTrue( - "the node must actually be gone before the repair runs"); - - // The boot repair pass, exactly as the next process start runs it. - await Repair.RunRepair().Timeout(240.Seconds()).Await(ct); - - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.GuidePath, present: true)).Should().BeTrue( - "THE assertion: the repair pass hands an incomplete install to the install lane, which " - + "re-observes and heals it. On main the pass only REPORTS — the sweep named " - + "ClaimsDeepfield's shortfall at Error on every boot for three weeks while nothing " - + "ran the lane its own line pointed at (MeshWeaver#4812)"); - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.OtherPath, present: true)).Should().BeTrue( - "the control: the node that was never lost is still there"); - - var after = await ReassertFixture.ReadRecord(Mesh); - after.Should().NotBeNull(); - after!.ModuleVersion.Should().Be(ReassertFixture.ModuleHash, - "a re-assert is a HEAL at the recorded module version, never an update"); - } -} - -/// -/// 🚨 THE CONTROL: a partition DELETED after its install is never reinstalled unattended — -/// the other half of MeshWeaver#4812, and the shape the issue was actually opened on. -/// -/// Plugins/ClaimsDeepfield on memex.meshweaver.cloud was installed 2026-08-27; the -/// module was retired from its repository on 09-04 and the Space deleted on 09-05T09:03Z, before -/// the record-follows-partition handler (#3451) existed — so the record survived. Thirteen minutes -/// later the boot pass re-asserted the record's declared access, which is create-only and CREATED -/// the root and its _Policy (createdBy: system-security, 09:16:50Z). From then on -/// the partition read as "present" and the sweep counted 82 of 83 declared node(s) are -/// ABSENT — reinstalling it now repairs it, at Error, on every boot: a recommendation to -/// resurrect a retired module. -/// -/// With the heal above in place, that record would be exactly what gets re-asserted — unless -/// the sweep can tell a DELETED partition from a DAMAGED one. It can: the root is YOUNGER than the -/// install that declared it, and nothing else the install wrote is there -/// (). This fixture re-enacts the incident — the -/// residue record, the create-only re-assert that resurrects the root — and asserts the repair pass -/// leaves it alone and says why. -/// -public class ATornDownPartitionIsNeverReinstalledUnattendedTest(ITestOutputHelper output) - : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(_ => new ReassertFixture.Source(ReassertFixture.Files())) - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false })); - - private InstanceAutoRegistrationService Installer => - Mesh.ServiceProvider.GetRequiredService(); - - private InstalledPackageRepairService Repair => - Mesh.ServiceProvider.GetRequiredService(); - - [Fact(Timeout = 300_000)] - public async Task ARecordThatOutlivedItsPartition_IsNamedTornDown_AndNotReinstalled() - { - var ct = TestContext.Current.CancellationToken; - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var access = Mesh.ServiceProvider.GetRequiredService(); - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - await Installer.Completed.FirstAsync().Timeout(180.Seconds()).Await(ct); - - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, new ReassertFixture.Source(ReassertFixture.Files()), "HEAD", - ReassertFixture.Candidate(), logger) - .Should().Within(180.Seconds()) - .Emit("the install must land before a deletion can be measured", cancellationToken: ct); - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.GuidePath, present: true)).Should().BeTrue(); - var record = await ReassertFixture.ReadRecord(Mesh); - record.Should().NotBeNull(); - record!.InstalledAtUtc.Should().NotBeNull("the install stamp is half of the discriminator"); - - // ── The operator retires the space: the partition root goes, recursively. The - // record-follows-partition handler (#3451) removes the record with it — which is the - // FORWARD fix; the incident predates it. - await meshService.DeleteNode(ReassertFixture.Package) - .Should().Within(60.Seconds()).Emit("the partition delete is the precondition", cancellationToken: ct); - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.Package, present: false)).Should().BeTrue( - "the root must be gone"); - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.RecordPath, present: false)).Should().BeTrue( - "#3451 removes the record with its partition — the residue below is re-created " - + "deliberately, as a record written before that handler existed"); - - // ── The residue: the record as it was, outliving its partition (pre-#3451 state). - var residue = MeshNode.FromPath(ReassertFixture.RecordPath) with - { - NodeType = PackageInstaller.PackageNodeType, - Name = ReassertFixture.Package, - State = MeshNodeState.Active, - Content = record, - }; - await access.RunAsSystem(() => meshService.CreateNode(residue)) - .Should().Within(60.Seconds()).Emit("the residue record is the precondition", cancellationToken: ct); - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.RecordPath, present: true)).Should().BeTrue(); - - // ── The resurrection. In production the NEXT boot's access re-assert on the surviving - // record created the root — a fresh pod has no memory of the delete. In ONE process the - // #3451 recently-deleted registry refuses to heal a root it just saw go ("NOT healing - // partition … it was just deleted"), which is the forward fix doing its job. So the end - // state the incident left — a content-free root YOUNGER than the install, with the record - // beside it — is written here as the deliberate create that clears that registry, and the - // access re-assert then lands the policy exactly as it did at 09:16:50Z. - await access.RunAsSystem(() => meshService.CreateNode(new MeshNode(ReassertFixture.Package) - { - NodeType = "Space", - Name = ReassertFixture.Package, - State = MeshNodeState.Active, - })) - .Should().Within(60.Seconds()).Emit("the re-created root is the precondition", cancellationToken: ct); - await access.RunAsSystem(() => PackageInstaller.EnsureDeclaredAccess( - Mesh, record, ReassertFixture.Package, logger)) - .Should().Within(60.Seconds()).Emit("the access re-assert is the precondition", cancellationToken: ct); - (await ReassertFixture.WaitForNode(Mesh, ReassertFixture.Package, present: true)).Should().BeTrue( - "the root is back — the shape the incident measured"); - var root = await ReassertFixture.Read(Mesh, ReassertFixture.Package); - root!.CreatedDate.Should().BeAfter(record.InstalledAtUtc!.Value, - "the resurrected root is YOUNGER than the install that declared it — the discriminator"); - - // ── The boot repair pass. It must name the shape and leave it alone. - await Repair.RunRepair().Timeout(240.Seconds()).Await(ct); - - (await ReassertFixture.Read(Mesh, ReassertFixture.GuidePath)).Should().BeNull( - "THE assertion: a partition deleted after its install is NOT reinstalled by a boot pass " - + "— that would resurrect a module an operator retired (MeshWeaver#4812)"); - (await ReassertFixture.Read(Mesh, ReassertFixture.RecordPath)).Should().NotBeNull( - "and the pass does not delete the record on a heuristic either — the admin orphan " - + "list is the remedy, and the line says so"); - - // The verdict itself, through the same observation the sweep uses. - var verdict = await InstallCompleteness.Observe( - Mesh.ServiceProvider.GetRequiredService(), Mesh.JsonSerializerOptions, - ReassertFixture.Package, ReassertFixture.Package, record, - new FileFormatParserRegistry(Mesh.JsonSerializerOptions, - Mesh.ServiceProvider.GetServices()), - recordIdentity: null) - .Timeout(60.Seconds()).Await(ct); - verdict.Kind.Should().Be(InstallCompletenessKind.TornDown, - "the root is younger than the install and nothing else the install wrote is present"); - verdict.Because.Should().Contain("orphaned install records", - "the line names the remedy, not a reinstall"); - } -} - -/// The pure discriminator — every arm pinnable without a mesh. -public class TornDownVerdictTest -{ - private const string Package = "TornPkg"; - private static readonly DateTimeOffset Installed = new(2026, 8, 27, 19, 48, 39, TimeSpan.Zero); - - private static PackageManifest Record() => new() - { - Id = Package, - Kind = PackageKind.NodeRepo, - TargetPartition = Package, - ModuleVersion = "d9718de1c34ee4e5", - InstalledAtUtc = Installed, - InstalledFiles = ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, new[] - { - KeyValuePair.Create($"{Package}/index.json", "a"), - KeyValuePair.Create($"{Package}/Agent.md", "b"), - KeyValuePair.Create($"{Package}/Cedent.json", "c"), - }), - }; - - private static IReadOnlySet Present(params string[] paths) => - paths.ToImmutableHashSet(StringComparer.Ordinal); - - [Fact] - public void ARootYoungerThanTheInstall_WithNothingElsePresent_IsTornDown() - { - var verdict = InstallCompleteness.Compare( - Package, Package, Record(), Present(Package), PackageInstaller.BuiltInParsers, - rootCreated: Installed.AddDays(9)); - verdict.Kind.Should().Be(InstallCompletenessKind.TornDown); - verdict.Declared.Should().Be(3); - verdict.Present.Should().Be(1, "the resurrected root is the one declared node present"); - verdict.Missing.Should().Equal($"{Package}/Agent", $"{Package}/Cedent"); - verdict.IsComplete.Should().BeFalse(); - } - - [Fact] - public void ARootFromTheInstall_WithNodesLostBesideIt_IsIncomplete() - { - // The root dates from the install (or before it): a genuine loss, to be healed. - InstallCompleteness.Compare( - Package, Package, Record(), Present(Package), PackageInstaller.BuiltInParsers, - rootCreated: Installed.AddSeconds(-1)) - .Kind.Should().Be(InstallCompletenessKind.Incomplete); - } - - [Fact] - public void AYoungRootWithSurvivingContent_IsIncomplete_NotTornDown() - { - // The #638 shape: a root row lost and re-created while the partition's content lived on. - // Content beside the root means the install left a trace — a loss, never a deletion. - InstallCompleteness.Compare( - Package, Package, Record(), Present(Package, $"{Package}/Agent"), - PackageInstaller.BuiltInParsers, rootCreated: Installed.AddDays(9)) - .Kind.Should().Be(InstallCompletenessKind.Incomplete); - } - - [Fact] - public void AnUnknownStampOnEitherSide_NeverInventsADeletion() - { - InstallCompleteness.Compare( - Package, Package, Record(), Present(Package), PackageInstaller.BuiltInParsers, - rootCreated: null) - .Kind.Should().Be(InstallCompletenessKind.Incomplete, "no root stamp"); - InstallCompleteness.Compare( - Package, Package, Record() with { InstalledAtUtc = null }, Present(Package), - PackageInstaller.BuiltInParsers, rootCreated: Installed.AddDays(9)) - .Kind.Should().Be(InstallCompletenessKind.Incomplete, "no install stamp"); - } - - /// The enum is public: a member inserted mid-list renumbers every later one for a - /// precompiled dependent or an integer-serialised verdict, so the existing values are pinned and - /// the new member is last (review on #4985). - [Fact] - public void TheNewKindIsAppended_SoExistingValuesKeepTheirNumbers() - { - ((int)InstallCompletenessKind.Complete).Should().Be(0); - ((int)InstallCompletenessKind.Incomplete).Should().Be(1); - ((int)InstallCompletenessKind.Undeclared).Should().Be(2); - ((int)InstallCompletenessKind.NotObserved).Should().Be(3); - ((int)InstallCompletenessKind.RootWithoutRecord).Should().Be(4); - ((int)InstallCompletenessKind.TornDown).Should().Be(5); - } - - [Fact] - public void TheSummaryCountsTornDownOnItsOwn_AndTheLandingIsAnError() - { - var torn = InstallCompleteness.Compare( - Package, Package, Record(), Present(Package), PackageInstaller.BuiltInParsers, - rootCreated: Installed.AddDays(9)); - var summary = InstallCompleteness.Summarize([torn]); - summary.TornDown.Should().Be(1); - summary.Incomplete.Should().Be(0); - summary.Total.Should().Be(1); - summary.ToString().Should().Contain("1 torn down"); - InstallCompleteness.DescribeLanding(torn, "d9718de1c34ee4e5").Level.Should().Be(LogLevel.Error, - "an install that just WROTE and still reads torn down is a landing failure"); - } -} - -/// One package, three declared nodes, served by a fixed source — shared by both fixtures. -internal static class ReassertFixture -{ - public const string Package = "ReassertPkg"; - public const string GuidePath = $"{Package}/Guide"; - public const string OtherPath = $"{Package}/Other"; - public const string ModuleHash = "4812481248124812"; - public static readonly string RecordPath = $"{PackageInstaller.InstalledPartition}/{Package}"; - - private const string ManifestLock = $$""" - { - "module": "{{Package}}", - "moduleVersion": "{{ModuleHash}}", - "version": "1.0.0", - "files": { - "{{Package}}/index.json": "aaa", - "{{Package}}/Guide.md": "bbb", - "{{Package}}/Other.md": "ccc" - } - } - """; - - public static PackageManifest Candidate() => new() - { - Id = Package, - Name = Package, - Kind = PackageKind.NodeRepo, - TargetPartition = Package, - SourceFolder = Package, - Version = "1.0.0", - ModuleVersion = ModuleHash, - }; - - public static IReadOnlyList Files() => - [ - new PackageFile($"{Package}/{ModuleManifest.FileName}", ManifestLock), - new PackageFile($"{Package}/index.json", $$""" - { - "id": "{{Package}}", - "path": "{{Package}}", - "nodeType": "Space", - "name": "Re-assert package", - "state": "Active" - } - """), - new PackageFile($"{Package}/Guide.md", "# Guide\n\nThe node the tests lose."), - new PackageFile($"{Package}/Other.md", "# Other\n\nThe node that stays, as the control."), - ]; - - public sealed class Source(IReadOnlyList files) : IPackageSource - { - public IObservable> ListPackages(string gitRef) => - Observable.Return>([Candidate()]); - - public IObservable> FetchPackageFiles( - PackageManifest package, string gitRef) => Observable.Return(files); - } - - /// Authoritative single read off storage (never the lagging index); null when absent. - public static Task Read(IMessageHub mesh, string path) => - mesh.ServiceProvider.GetRequiredService() - .Read(path, mesh.JsonSerializerOptions) - .Take(1) - .DefaultIfEmpty(null) - .Timeout(60.Seconds()) - .Await(TestContext.Current.CancellationToken); - - public static async Task ReadRecord(IMessageHub mesh) => - (await Read(mesh, RecordPath))?.ContentAs(mesh.JsonSerializerOptions); - - public static Task WaitForNode(IMessageHub mesh, string path, bool present) => - Observable.Interval(TimeSpan.FromMilliseconds(100)).StartWith(0L) - .SelectMany(_ => mesh.ServiceProvider.GetRequiredService() - .Read(path, mesh.JsonSerializerOptions) - .Take(1) - .DefaultIfEmpty(null) - .Select(n => n is not null) - .Catch(ex => Observable.Throw(new InvalidOperationException( - $"reading '{path}' from storage FAILED, so neither its presence nor its absence " - + "was observed.", ex)))) - .Where(found => found == present) - .Select(_ => true) - .FirstAsync() - .Timeout(120.Seconds()) - .Await(TestContext.Current.CancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/IncrementalUpdateReadsOneSnapshotTest.cs b/test/Memex.Portal.Shared.Test/IncrementalUpdateReadsOneSnapshotTest.cs deleted file mode 100644 index 11f3222f05..0000000000 --- a/test/Memex.Portal.Shared.Test/IncrementalUpdateReadsOneSnapshotTest.cs +++ /dev/null @@ -1,300 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Memex.Portal.Shared.Api; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#6718 — an incremental update reads its manifest and its delta from ONE snapshot -/// (policy record-served-commit-on-install). -/// -/// The defect. The incremental path reads twice: manifest.lock at the moving -/// ref, then the changed files at the moving ref AGAIN. When the ref advances from commit A to B -/// between the two reads, the record pairs A's module version and file map with B's files and B's -/// installedFromRef. The fixture's ref advances exactly there, deterministically: the first -/// read of main during the update resolves to A, every later one to B. -/// -/// Two sources, one fact apart: one honours a commit it is asked for (a git source, a -/// registry that honours the snapshot token), one ignores it and serves its current tip (a -/// registry that predates the token). The first must land A entirely; the second must refuse the -/// mixed read and install B entirely, from one read. On the code before the fix both record a -/// commit whose content they do not hold. -/// -public class IncrementalUpdateReadsOneSnapshotTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Package = "OneSnapshotPkg"; - private const string OtherPath = $"{Package}/Other"; - - private static readonly string Installed = new('0', 40); - private static readonly string CommitA = new('a', 40); - private static readonly string CommitB = new('b', 40); - - private const string VersionInstalled = "v0000000000000000"; - private const string VersionA = "va000000000000000"; - private const string VersionB = "vb000000000000000"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - /// - /// The source honours the commit: the delta is asked for AT the manifest's commit A, so the - /// update lands A's files under A's manifest and states A — although main already - /// resolves to B by the time the delta is read. - /// - [Fact(Timeout = 300_000)] - public async Task TheDelta_IsReadAtTheManifestsCommit_ThoughTheRefAdvanced() - { - var ct = TestContext.Current.CancellationToken; - var source = new AdvancingSource(honoursCommit: true); - await InstallInstalledVersion(source, ct); - - source.Advance(); - await Update(source, VersionA, ct); - - var record = await Record(); - record.ModuleVersion.Should().Be(VersionA, "the manifest was read at A"); - record.InstalledFromRef.Should().Be(CommitA, - "THE assertion: the record names the commit its manifest was read at. Before the fix " - + "the delta re-resolved 'main' and the record named B over A's manifest"); - (await OtherContent()).Should().Contain("Generation A", - "and the changed file is A's, not the B the moving ref pointed at by then"); - source.Requested.Should().Equal(["main", "main", CommitA], - "the first install reads main, the update's manifest read reads main, and its delta " - + "read asks for the manifest's commit — never 'main' a second time"); - } - - /// - /// The source IGNORES the commit and serves its tip (B): the mixed read is refused, and the - /// update falls back to a full install, which reads manifest and files at once — so the record - /// is B throughout, its module version and its commit agreeing. - /// - [Fact(Timeout = 300_000)] - public async Task ADeltaServedAtAnotherCommit_IsRefused_AndTheUpdateInstallsOneSnapshotInFull() - { - var ct = TestContext.Current.CancellationToken; - var source = new AdvancingSource(honoursCommit: false); - await InstallInstalledVersion(source, ct); - - source.Advance(); - await Update(source, VersionA, ct); - - var record = await Record(); - record.InstalledFromRef.Should().Be(CommitB, "the full install read B"); - record.ModuleVersion.Should().Be(VersionB, - "THE assertion: module version and commit come from ONE read. Before the fix the record " - + "carried A's module version over B's commit"); - (await OtherContent()).Should().Contain("Generation B"); - source.Requested.Should().Equal(["main", "main", CommitA, "main"], - "the delta was asked for at A, served at B and refused; the fall-back reads main once"); - } - - /// - /// A manifest read that names NO commit (an old registry mid-rollout) cannot pin the delta, - /// so the delta reads the moving ref — and if THAT read names a commit, the record must not - /// state it: nothing ties it to the manifest, which may have come from an earlier tip. The - /// record keeps the requested ref. Before the fix it stated B over A's manifest. - /// - [Fact(Timeout = 300_000)] - public async Task AnUnnamedManifestRead_NeverStampsTheDeltasCommit() - { - var ct = TestContext.Current.CancellationToken; - // Read 0 = the first install (names its commit), read 1 = the update's manifest read - // (names NONE), read 2 = the delta (names B). - var source = new AdvancingSource(honoursCommit: true, unnamedRead: 1); - await InstallInstalledVersion(source, ct); - - source.Advance(); - await Update(source, VersionA, ct); - - var record = await Record(); - record.InstalledFromRef.Should().Be("main", - "THE assertion: the manifest read named no commit, so the delta's commit B proves " - + "nothing about the manifest (A) and the record must not claim it"); - source.Requested.Should().Equal(["main", "main", "main"], - "with no snapshot the delta reads the moving ref, as before #6718"); - } - - // ── The registry's half: the snapshot token ───────────────────────────────────────────────── - - /// A full commit sha reachable from the configured ref is read; the configured ref - /// is read for everything else. - [Fact] - public async Task TheRegistry_ReadsAReachableCommit_AndItsConfiguredRefOtherwise() - { - var ct = TestContext.Current.CancellationToken; - var published = new ConfiguredPackageSource(new AncestrySource(reachable: true), "main", "Plugins"); - var unpublished = new ConfiguredPackageSource(new AncestrySource(reachable: false), "main", "Plugins"); - - (await PluginRegistryEndpoints.ReadRef(published, CommitA).Await(ct)).Should().Be(CommitA, - "a commit published on the configured ref is the snapshot the consumer asked for"); - (await PluginRegistryEndpoints.ReadRef(unpublished, CommitA).Await(ct)).Should().Be("main", - "a commit NOT reachable from the configured ref (an unmerged branch) is never read"); - (await PluginRegistryEndpoints.ReadRef(published, "feature/x").Await(ct)).Should().Be("main", - "a branch name is no snapshot token — the registry serves its own ref, as before"); - (await PluginRegistryEndpoints.ReadRef(published, null).Await(ct)).Should().Be("main"); - } - - /// NEGATIVE CONTROL — a source that cannot establish ancestry (the interface default) - /// never honours a token, so a registry over it behaves exactly as before. - [Fact] - public async Task ASourceThatCannotEstablishAncestry_NeverHonoursAToken() - { - var source = new ConfiguredPackageSource(new AdvancingSource(honoursCommit: true), "main", "Plugins"); - - (await PluginRegistryEndpoints.ReadRef(source, CommitA).Await(TestContext.Current.CancellationToken)) - .Should().Be("main"); - } - - // ── Harness ────────────────────────────────────────────────────────────────────────────────── - - private ILogger Logger => Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - private async Task InstallInstalledVersion(AdvancingSource source, CancellationToken ct) - { - await Update(source, VersionInstalled, ct); - var record = await Record(); - record.InstalledFromRef.Should().Be(Installed, - "the first install is the baseline the update diffs against"); - record.InstalledFiles.Should().ContainKey($"{Package}/Other.md", - "without a file map the update is a full install and the incremental path is not tested"); - } - - private Task Update(IPackageSource source, string candidateVersion, CancellationToken ct) => - CatalogLayoutAreas.InstallOrUpdate(Mesh, source, "main", Candidate(candidateVersion), Logger) - .Timeout(TimeSpan.FromSeconds(180)) - .Await(ct); - - private async Task Record() - { - var node = await Mesh.ServiceProvider.GetRequiredService() - .Read($"{PackageInstaller.InstalledPartition}/{Package}", Mesh.JsonSerializerOptions) - .Take(1) - .Timeout(TimeSpan.FromSeconds(60)) - .Await(TestContext.Current.CancellationToken); - var record = node?.ContentAs(Mesh.JsonSerializerOptions); - record.Should().NotBeNull("the installer stamps an install record"); - return record!; - } - - private async Task OtherContent() - { - var node = await Mesh.ServiceProvider.GetRequiredService() - .Read(OtherPath, Mesh.JsonSerializerOptions) - .Take(1) - .Timeout(TimeSpan.FromSeconds(60)) - .Await(TestContext.Current.CancellationToken); - node.Should().NotBeNull($"'{OtherPath}' is installed by every version"); - return JsonSerializer.Serialize(node!.Content, Mesh.JsonSerializerOptions); - } - - private static PackageManifest Candidate(string moduleVersion) => new() - { - Id = Package, - Name = Package, - Kind = PackageKind.NodeRepo, - TargetPartition = Package, - SourceFolder = Package, - Version = "1.0.0", - ModuleVersion = moduleVersion, - }; - - private static ImmutableList Tree(string moduleVersion, string otherHash, string generation) => - [ - new PackageFile($"{Package}/{ModuleManifest.FileName}", $$""" - { - "module": "{{Package}}", - "moduleVersion": "{{moduleVersion}}", - "version": "1.0.0", - "files": { - "{{Package}}/index.json": "aaa", - "{{Package}}/Other.md": "{{otherHash}}" - } - } - """), - new PackageFile($"{Package}/index.json", $$""" - { "id": "{{Package}}", "path": "{{Package}}", "nodeType": "Space", "name": "{{Package}}", "state": "Active" } - """), - new PackageFile($"{Package}/Other.md", $"# Other\n\nGeneration {generation}."), - ]; - - /// - /// A repository whose main ADVANCES: before it resolves to the - /// installed commit; after it, the FIRST read of main resolves to A and every later one - /// to B — the race, made deterministic. A commit asked for by sha is served at that commit - /// when honoursCommit, and at the current tip otherwise (a registry that ignores the - /// requested ref). - /// - private sealed class AdvancingSource(bool honoursCommit, int unnamedRead = -1) : IPackageSource - { - private static readonly ImmutableDictionary> Trees = - ImmutableDictionary>.Empty - .Add(Installed, Tree(VersionInstalled, "h0", "zero")) - .Add(CommitA, Tree(VersionA, "hA", "A")) - .Add(CommitB, Tree(VersionB, "hB", "B")); - - private ImmutableQueue tips = ImmutableQueue.Create(Installed); - private ImmutableList requested = []; - - public ImmutableList Requested => requested; - - public void Advance() => tips = ImmutableQueue.Create(CommitA, CommitB); - - public IObservable> ListPackages(string gitRef) => - Observable.Return>([Candidate(VersionA)]); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) => - FetchPackage(package, gitRef, null).Select(f => (IReadOnlyList)f.Files); - - public IObservable FetchPackage( - PackageManifest package, string gitRef, IReadOnlyCollection? paths) => - Observable.Defer(() => - { - var index = requested.Count; - ImmutableInterlocked.Update(ref requested, r => r.Add(gitRef)); - var commit = honoursCommit && Trees.ContainsKey(gitRef) ? gitRef : Tip(); - var files = Trees[commit]; - return Observable.Return(new PackageFetch( - paths is null ? files : files.Where(f => paths.Contains(f.RelativePath)).ToImmutableList(), - index == unnamedRead ? null : commit)); - }); - - /// The commit main resolves to on this read; the last tip stays. - private string Tip() - { - var tip = tips.Peek(); - var rest = tips.Dequeue(); - if (!rest.IsEmpty) - tips = rest; - return tip; - } - } - - /// A source whose ancestry answer is fixed — the registry's ref choice is about the - /// answer, never the transport. - private sealed class AncestrySource(bool reachable) : IPackageSource - { - public IObservable> ListPackages(string gitRef) => - Observable.Return>([]); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) => - Observable.Return>([]); - - public IObservable PublishesCommit(string commit, string gitRef) => Observable.Return(reachable); - } -} diff --git a/test/Memex.Portal.Shared.Test/InlineIoPool.cs b/test/Memex.Portal.Shared.Test/InlineIoPool.cs deleted file mode 100644 index 9309b76f3c..0000000000 --- a/test/Memex.Portal.Shared.Test/InlineIoPool.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Mesh.Threading; - -namespace Memex.Portal.Shared.Test; - -/// -/// An that runs blocking work INLINE on the subscribing thread. -/// -/// 🚨 It is not a stand-in for the production pool and must never be used to assert anything -/// about scheduling — using it to "prove" that a probe does not block would prove the opposite of -/// what it looks like. It exists so a test ABOUT something else (how many questions a reading asks -/// of the volume) does not become a test about when a pool thread was scheduled. -/// -/// Only is implemented. The other leaves throw rather than -/// returning something plausible: a test whose subject starts using one of them should fail loudly -/// here rather than silently measure a different mechanism. -/// -internal sealed class InlineIoPool : IIoPool -{ - /// The shared instance — it holds no state. - public static readonly InlineIoPool Instance = new(); - - private InlineIoPool() - { - } - - /// - public IObservable InvokeBlocking(Func work) - => Observable.Defer(() => Observable.Return(work(CancellationToken.None))); - - /// - public int CurrentInFlight => 0; - - /// - public IObservable Invoke(Func> io) - => throw new NotSupportedException( - "InlineIoPool runs blocking leaves only — see the type remarks."); - - /// - public IObservable InvokeStream(Func> source) - => throw new NotSupportedException( - "InlineIoPool runs blocking leaves only — see the type remarks."); - - /// - public IObservable SubscribeThroughPool(IObservable source) - => throw new NotSupportedException( - "InlineIoPool runs blocking leaves only — see the type remarks."); -} diff --git a/test/Memex.Portal.Shared.Test/InstallCompletenessTest.cs b/test/Memex.Portal.Shared.Test/InstallCompletenessTest.cs deleted file mode 100644 index ae075ba155..0000000000 --- a/test/Memex.Portal.Shared.Test/InstallCompletenessTest.cs +++ /dev/null @@ -1,2101 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.Persistence.Parsers; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 AN INSTALL THAT HALF-LANDS MUST NOT READ AS A COMPLETE ONE — MeshWeaver#3485. -/// -/// The defect. Until this change, nothing in the platform ever compared what an install -/// RECORD declares against what is actually in the mesh. The up-to-date gate compared two content -/// hashes — record.ModuleVersion against the catalogue's — which is a statement about the -/// SOURCE, not about what landed. So memex.systemorph.com lost -/// Feedback/Feedback/Source/FeedbackContent on 2026-08-26, a REINSTALL on 2026-09-03 returned -/// InstallResult(0, 0) without fetching a file, and eleven days later the gap was the -/// proximate cause of the #3472 outage. The installer's own numbers could not have caught it: they -/// count what it DECIDED to write, and PackageManifest.InstalledNodeCount had no reader at -/// all. -/// -/// Why the behavioural case is the whole test. The pure arms below fix the semantics of -/// a new type and would pass against any implementation of it. -/// is the one that fails on main: it -/// installs a package, deletes one of its nodes, and re-runs the SAME install path with an unchanged -/// module hash. Before the fix that returns InstallResult(0, 0) and the node stays gone — -/// which is exactly the 2026-09-03 reinstall, reproduced. -/// -public class InstallCompletenessTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// The install record is a Package node, so the catalog's own types have to be - /// registered for an install to record itself — the same registration every portal makes. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - private const string Package = "CompletenessPkg"; - private const string GuidePath = $"{Package}/Guide"; - private const string OtherPath = $"{Package}/Other"; - private const string ModuleHash = "0123456789abcdef"; - - /// The manifest.lock CI ships beside a plugin — the DECLARATION half of the comparison. - private const string ManifestLock = $$""" - { - "module": "{{Package}}", - "moduleVersion": "{{ModuleHash}}", - "version": "1.0.0", - "files": { - "{{Package}}/index.json": "aaa", - "{{Package}}/Guide.md": "bbb", - "{{Package}}/Other.md": "ccc" - } - } - """; - - /// - /// The parser registry the INSTALL would use — the file→node rule's second half since #3659. - /// Built exactly as InstallNodeRepo builds it (hub serializer options + the - /// DI-contributed parsers), so these arms cannot agree with themselves while the installer - /// answers differently. - /// - private FileFormatParserRegistry Parsers() => new( - Mesh.JsonSerializerOptions, - Mesh.ServiceProvider.GetServices()); - - private static PackageManifest Candidate() => new() - { - Id = Package, - Name = Package, - Kind = PackageKind.NodeRepo, - TargetPartition = Package, - SourceFolder = Package, - Version = "1.0.0", - ModuleVersion = ModuleHash, - }; - - private static IReadOnlyList Files() => - [ - new PackageFile($"{Package}/{ModuleManifest.FileName}", ManifestLock), - new PackageFile($"{Package}/index.json", $$""" - { - "id": "{{Package}}", - "path": "{{Package}}", - "nodeType": "Space", - "name": "Completeness package", - "state": "Active" - } - """), - new PackageFile($"{Package}/Guide.md", "# Guide\n\nThe node this test deletes."), - new PackageFile($"{Package}/Other.md", "# Other\n\nThe node that stays, as the control."), - ]; - - // ── The behavioural case: the one that fails on main ──────────────────────────────────────── - - /// - /// 🚨 THE REGRESSION. Install, delete one declared node, re-run the same install path with an - /// UNCHANGED module hash — the node must come back. - /// - /// On main the hash equality alone short-circuits to InstallResult(0, 0) and - /// the node stays missing: the 2026-09-03 reinstall of Feedback, reproduced in a test. - /// The control node proves the install did not simply rewrite everything blindly — and that the - /// package really was installed in the first place, so a green result cannot come from an empty - /// package. - /// - [Fact(Timeout = 300_000)] - public async Task AReinstallOverAMissingNode_RestoresIt() - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var source = new FixedSource(Files()); - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - await CatalogLayoutAreas.InstallOrUpdate(Mesh, source, "HEAD", Candidate(), logger) - .Should().Within(180.Seconds()) - .Emit("the first install must land before anything about a reinstall can be measured", - cancellationToken: TestContext.Current.CancellationToken); - - (await WaitForNode(GuidePath, present: true)).Should().BeTrue( - "the package's Guide node is the subject of this test — if the install never wrote it, " - + "the assertion below would pass for the wrong reason"); - - var record = await ReadRecord(); - record.Should().NotBeNull("the installer stamps an install record"); - record!.ModuleVersion.Should().Be(ModuleHash, - "the record's module hash is what the up-to-date gate compares — the whole premise is " - + "that it stays EQUAL across the deletion, so the gate sees 'nothing to sync'"); - InstallCompleteness.DeclaredNodePaths(record, Parsers()).Should().Contain(GuidePath, - "the record must DECLARE the node for its absence to be detectable at all — a record " - + "with no file map is the 'Undeclared' verdict, not a shortfall"); - - // ── The loss. Exactly what happened to Feedback/Feedback/Source/FeedbackContent. - await meshService.DeleteNode(GuidePath) - .Should().Within(60.Seconds()) - .Emit("the deletion is this test's precondition", cancellationToken: TestContext.Current.CancellationToken); - // 🚨 The ABSENCE is read through the storage adapter, never GetMeshNodeStream: a point read - // of an absent node FAULTS ("No node found at …") rather than emitting null, which is the - // framework behaviour AGENTS.md warns about — and an assertion built on it would fail on - // its own precondition instead of measuring the reinstall. - (await WaitForNode(GuidePath, present: false)).Should().BeTrue( - "the node must actually be gone before the reinstall runs, or the restore below would " - + "be measuring a deletion that never took"); - - // ── The remedy an operator reaches for first, with the module hash UNCHANGED. - var second = await CatalogLayoutAreas - .InstallOrUpdate(Mesh, source, "HEAD", Candidate(), logger) - .Timeout(180.Seconds()) - .Await(TestContext.Current.CancellationToken); - - second.Written.Should().BeGreaterThan(0, - "a reinstall over a mesh that is missing a declared node must WRITE — returning " - + "InstallResult(0, 0) is the #3485 defect verbatim: the module hash matches, so the " - + "gate concluded 'up to date' from a record it wrote itself, never from the mesh"); - - (await WaitForNode(GuidePath, present: true)).Should().BeTrue( - "THE assertion: the reinstall must restore the missing node. On main it does not — " - + "memex.systemorph.com's Feedback install was reinstalled on 2026-09-03 and " - + "Feedback/Feedback/Source/FeedbackContent was still absent on 2026-09-07"); - - (await WaitForNode(OtherPath, present: true)).Should().BeTrue( - "the control: the node that was never deleted is still there, so the repair did not " - + "work by wiping and rewriting the partition"); - } - - /// - /// The completeness verdict, read against the LIVE mesh through the same observation the install - /// gate and the boot sweep use: complete after an install, incomplete after a deletion, and it - /// NAMES the missing path. - /// - [Fact(Timeout = 300_000)] - public async Task TheVerdictFollowsTheMesh_NotTheRecord() - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, new FixedSource(Files()), "HEAD", Candidate(), logger) - .Should().Within(180.Seconds()) - .Emit("the install is the precondition", cancellationToken: TestContext.Current.CancellationToken); - - var record = await ReadRecord(); - record.Should().NotBeNull(); - - (await Observe(record!)).IsComplete.Should().BeTrue( - "immediately after an install every declared node is present — if this were false the " - + "'incomplete' assertion below would prove nothing"); - - await meshService.DeleteNode(GuidePath).Should().Within(60.Seconds()).Emit("precondition", - cancellationToken: TestContext.Current.CancellationToken); - - var after = await WaitForVerdict(record!, InstallCompletenessKind.Incomplete); - after.Kind.Should().Be(InstallCompletenessKind.Incomplete); - after.Missing.Should().Contain(GuidePath, - "the verdict has to NAME what is gone — 'something is missing' is not actionable, and " - + "naming it is the difference between eleven days and one boot"); - after.IsComplete.Should().BeFalse(); - } - - /// - /// 🚨 The v1-shell case: a partition ROOT that no install record accounts for. Measured on - /// memex.systemorph.com 2026-09-07 — FOUR of them (AgenticPrimerDe, - /// DataImportExport, DataModeling, ThinkInStreams), all version 1, all - /// created inside one fifteen-second window, none with a record, all served by the portal as - /// ordinary empty spaces. - /// - /// The record-driven arm cannot see this by construction: there is no record to compare - /// against. The control in the same assertion is an installed package's root — accounted for, so - /// it must NOT be reported, or the sweep would cry wolf over every healthy install. - /// - [Fact(Timeout = 300_000)] - public async Task ARootNoRecordAccountsFor_IsReported_AndAnInstalledOneIsNot() - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, new FixedSource(Files()), "HEAD", Candidate(), logger) - .Should().Within(180.Seconds()) - .Emit("the accounted-for control has to exist before absence can discriminate anything", - cancellationToken: TestContext.Current.CancellationToken); - - // The wreckage: the installer's stage-0 placeholder shape — a Space root with no content — - // left behind because the install never reached its final root write or its record. - const string Abandoned = "AbandonedInstallRoot"; - await meshService.CreateNode(new MeshNode(Abandoned) - { - NodeType = "Space", - Name = Abandoned, - State = MeshNodeState.Active, - }) - .Should().Within(60.Seconds()) - .Emit("the abandoned root is this test's subject", - cancellationToken: TestContext.Current.CancellationToken); - - var persistence = Mesh.ServiceProvider.GetRequiredService(); - var accounted = ImmutableHashSet.Create(StringComparer.Ordinal, Package); - - var reported = await Observable - .Interval(TimeSpan.FromMilliseconds(250)).StartWith(0L) - .SelectMany(_ => InstallCompleteness - .ObserveUnaccountedRoots(persistence, Mesh.JsonSerializerOptions, accounted) - .ToList()) - .Where(list => list.Any(v => string.Equals(v.Partition, Abandoned, StringComparison.Ordinal))) - .FirstAsync() - .Timeout(120.Seconds()) - .Await(TestContext.Current.CancellationToken); - - reported.Should().Contain( - v => v.Kind == InstallCompletenessKind.RootWithoutRecord - && v.Partition == Abandoned, - "a partition root with no content, nothing but satellites and NO install record is what " - + "an install leaves when it writes its placeholder and stops — the portal serves it as " - + "an ordinary empty space and nothing says otherwise (MeshWeaver#3485)"); - - reported.Should().NotContain(v => v.Partition == Package, - "the control: an installed package's root IS accounted for by its record, so reporting " - + "it would make the sweep noise nobody reads"); - } - - // ── The pure arms: every verdict, driven offline ───────────────────────────────────────────── - - /// - /// 🚨 The rule the whole type exists for: a record that declares nothing, and a mesh that could - /// not be read, are NOT passes. If either returned Complete, "not checked" and "clean" - /// would be the same answer — which is the bug, not the fix. - /// - [Fact] - public void NotCheckedIsNeverClean() - { - var parsers = Parsers(); - var declared = new PackageManifest - { - Id = Package, - TargetPartition = Package, - InstalledFiles = ImmutableSortedDictionary.Empty - .Add($"{Package}/Guide.md", "bbb"), - }; - - InstallCompleteness.Compare(Package, Package, null, ImmutableHashSet.Empty, parsers) - .Kind.Should().Be(InstallCompletenessKind.Undeclared, - "no record at all means nothing declares what should be here"); - - InstallCompleteness - .Compare(Package, Package, new PackageManifest { Id = Package }, ImmutableHashSet.Empty, parsers) - .IsComplete.Should().BeFalse( - "a record with no file map cannot be compared against anything — reporting that as " - + "complete is the 'zero found, zero expected, green' family"); - - InstallCompleteness.Compare(Package, Package, declared, null, parsers) - .Kind.Should().Be(InstallCompletenessKind.NotObserved, - "a mesh that could not be read was NOT checked; a failed read must never be spelled " - + "the same way as a real negative"); - - InstallCompleteness.Compare(Package, Package, declared, null, parsers) - .IsComplete.Should().BeFalse("and it is not a pass"); - - InstallCompleteness - .Compare(Package, Package, declared, ImmutableHashSet.Create(StringComparer.Ordinal, GuidePath), parsers) - .IsComplete.Should().BeTrue("the one case that IS a pass: every declared node observed"); - - var short_ = InstallCompleteness.Compare( - Package, Package, declared, ImmutableHashSet.Empty.WithComparer(StringComparer.Ordinal), - parsers); - short_.Kind.Should().Be(InstallCompletenessKind.Incomplete); - short_.Missing.Should().ContainSingle().Which.Should().Be(GuidePath); - } - - /// - /// 🚨 A DECLARED COUNT MUST NAME THE RECORD IT WAS TAKEN OVER — MeshWeaver#4200. - /// - /// A production sweep reported 201 file(s) declared and named two of them ABSENT. - /// Reconciling that number took a version-by-version read of Plugins/Store plus a - /// commit-by-commit count of the source repo — and the answer was that the two record versions - /// straddling the sweep carry a 193-file map declaring neither name. The count was right about - /// SOMETHING and nothing in the line could say what. - /// - /// This is the same defect as a missing denominator: a count taken over the right record - /// and one taken over a stale record render identically. And the record is exactly the half - /// that CAN be stale — the observed side is kept off a query on purpose, while the declared - /// side arrives through an eventually-consistent GetQuery. - /// - [Fact] - public async Task EveryVerdictNamesTheRecordVersionItWasTakenOver() - { - var record = new PackageManifest - { - Id = Package, - TargetPartition = Package, - Version = "1.9.16", - ModuleVersion = "b53d4f05232ce57f", - InstalledAtUtc = new DateTimeOffset(2026, 9, 10, 12, 55, 28, TimeSpan.Zero), - InstalledFiles = ImmutableSortedDictionary.Empty - .Add($"{Package}/Guide.md", "bbb"), - }; - - var identity = InstallCompleteness.DescribeRecord( - $"Plugins/{Package}", 32, new DateTimeOffset(2026, 9, 12, 2, 0, 24, TimeSpan.Zero), record); - - identity.Should().Contain("v32", "the NODE VERSION is the part that says WHICH record was read"); - identity.Should().Contain("2026-09-12T02:00:24", "and when that version was written"); - identity.Should().Contain("1.9.16").And.Contain("b53d4f05232ce57f", - "the stamps identify the SOURCE snapshot the map was written from"); - identity.Should().Contain("installedAtUtc 2026-09-10T12:55:28", - "which can be older than the version that carries it — that gap is the thing to see"); - identity.Should().Contain("1 file(s) in the map", - "the map's own size, so a regressed map is visible beside the count it produced"); - - // 🚨 The arm that is NOT a pass carries it too: a NotObserved nobody can attribute is as - // unactionable as an Incomplete nobody can attribute. - // awaited, never bridged to a blocking wait — BlockingBridgeInTestRatchetGuard (#2013). - var notObserved = await InstallCompleteness - .Observe(null, Mesh.JsonSerializerOptions, Package, Package, record, Parsers(), identity) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - notObserved.Kind.Should().Be(InstallCompletenessKind.NotObserved); - notObserved.RecordIdentity.Should().Be(identity); - notObserved.Provenance.Should().Be(identity); - - // 🚨 AND THE ABSENCE SAYS SO. "Not identified" must never render as a blank that reads like - // an identified one — the same rule that makes NotObserved not a pass. - var unattributed = await InstallCompleteness - .Observe(null, Mesh.JsonSerializerOptions, Package, Package, record, Parsers()) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - unattributed.RecordIdentity.Should().BeNull(); - unattributed.Provenance.Should().Contain("NOT identified"); - unattributed.Provenance.Should().NotBeNullOrWhiteSpace( - "a provenance nobody supplied must be a sentence, not an empty string in a log line"); - - // 🚨 AND THE SELECT THAT FEEDS IT. A test that only exercises DescribeRecord with - // hand-supplied values would still pass with `version`/`lastModified` dropped from the - // listing — at which point every verdict names a record it cannot identify and nothing is - // red. The projection is the other half of the same guarantee. - InstalledPackageRepairService.RecordsQuery.Should().Contain("select:", - "the listing projects — it does not read whole nodes"); - InstalledPackageRepairService.RecordsQuery.Should().Contain("version", - "the record's VERSION is what makes a declared count attributable"); - InstalledPackageRepairService.RecordsQuery.Should().Contain("lastModified", - "and when that version was written"); - InstalledPackageRepairService.RecordsQuery.Should().Contain("content", - "the control: the manifest itself must still be selected, or nothing is declared at all"); - } - - - /// - /// A record whose file map addresses a DIFFERENT partition cannot be compared against this one, - /// and guessing a rebase would manufacture a shortfall out of a naming difference. It reports - /// — unverifiable, never "incomplete". - /// - [Fact] - public void AFileMapThatDoesNotAddressThePartition_IsUnverifiable_NotAShortfall() - { - var record = new PackageManifest - { - Id = Package, - TargetPartition = "SomewhereElse", - InstalledFiles = ImmutableSortedDictionary.Empty - .Add($"{Package}/Guide.md", "bbb"), - }; - - var verdict = InstallCompleteness.Compare( - Package, "SomewhereElse", record, ImmutableHashSet.Empty, Parsers()); - - verdict.Kind.Should().Be(InstallCompletenessKind.Undeclared, - "a file map that maps outside the target partition cannot answer whether that partition " - + "is whole — and an unverifiable install accused of being incomplete would be a false " - + "alarm that trains people to ignore the real one"); - verdict.IsComplete.Should().BeFalse("it is still not a pass"); - } - - /// Non-node files a package ships must never be counted as missing nodes. - [Fact] - public void ContentAssetsAndTheManifestSidecar_AreNotNodes() - { - var record = new PackageManifest - { - Id = Package, - TargetPartition = Package, - InstalledFiles = ImmutableSortedDictionary.Empty - .Add($"{Package}/Guide.md", "bbb") - .Add($"{Package}/{ModuleManifest.FileName}", "ddd") - .Add($"{Package}/content/og.png", "eee") - .Add("README.md", "fff"), - }; - - InstallCompleteness.DeclaredNodePaths(record, Parsers()).Should().ContainSingle().Which.Should().Be( - GuidePath, - "the manifest sidecar, content/** assets and the README are files, not nodes — counting " - + "them would make every healthy install read as incomplete, and a check that is always " - + "red is a check nobody reads"); - } - - // ── #3659: the DENOMINATOR — the declared population must be the installer's own ─────────── - - /// - /// 🚨 THE repro of #3659, end to end. A package ships files a parser claims (.json, - /// .md) and files no parser claims (.tsx, .png, an extension-less - /// LICENSE) — the ordinary shape of a real node repo. The install writes the first kind - /// and silently skips the second. The sweep must count the SAME population. - /// - /// Before the fix the declared side applied only the by-design exclusions (README at the - /// repo root, the manifest sidecar, content/**) and counted every other file as a node - /// the install owed the mesh, so this package read as Incomplete with three phantom - /// paths ABSENT — at Error, on every pod boot, forever, and driving a full reinstall of the - /// package that could never make the count reach zero. Chess/gui/rn/chess.tsx is the - /// live instance. - /// - /// The assertion that keeps this honest is the second half: after deleting a node - /// that IS a declared one, the same sweep must go Incomplete naming exactly it. Without - /// that, "Complete" here could be bought by a gate that excludes everything. - /// - [Fact(Timeout = 300_000)] - public async Task FilesNoParserClaims_AreNotDeclaredNodes_AndARealAbsenceStillIs() - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, new FixedSource(CarryAlongFiles()), "HEAD", CarryAlongCandidate(), logger) - .Should().Within(180.Seconds()) - .Emit("the install has to land before its completeness can be measured", - cancellationToken: TestContext.Current.CancellationToken); - - (await WaitForNode($"{CarryAlong}/Guide", present: true)).Should().BeTrue( - "the package's Guide node is what the second half of this test deletes — if the " - + "install never wrote it, both halves would pass for the wrong reason"); - - var record = await ReadRecord(CarryAlong); - record.Should().NotBeNull("the installer stamps an install record"); - record!.InstalledFiles.Should().HaveCount(8, - "the record declares every file the package ships — that is the population the sweep " - + "counts over, and stating it is the point of this test"); - - var declared = InstallCompleteness.DeclaredNodePaths(record, Parsers()); - declared.Should().Equal( - [CarryAlong, $"{CarryAlong}/Guide", $"{CarryAlong}/README"], - "only the files a registered parser claims become nodes — the installer skips the " - + "rest, so counting them is counting a population no install ever writes"); - declared.Should().NotContain($"{CarryAlong}/gui/rn/widget", - "a .tsx view is a carry-along asset; this is Chess/gui/rn/chess, the reported phantom"); - declared.Should().NotContain($"{CarryAlong}/logo", "nor is a .png"); - declared.Should().NotContain($"{CarryAlong}/LICENSE", "nor is an extension-less file"); - - var verdict = await WaitForVerdict(record, InstallCompletenessKind.Complete, CarryAlong); - verdict.DeclaredFiles.Should().Be(8, "the record's file map is the population read"); - verdict.NonNodeFiles.Should().Be(5, - "README.md at the REPO root is not in this map; the five are the manifest sidecar, " - + "the content/** asset, the .tsx, the .png and the extension-less LICENSE"); - verdict.Declared.Should().Be(3, "leaving index.json, Guide.md and README.md"); - verdict.Population.Should().Contain("8 file(s) declared").And.Contain("3 distinct node path(s)", - "a count over the wrong population reads exactly like a correct one unless the line " - + "says which population it was taken over"); - verdict.ToString().Should().Contain("8 file(s) declared", - "and every surface that prints a verdict carries it"); - - // ── The control: the gate must not have blinded the sweep to a REAL loss. - await meshService.DeleteNode($"{CarryAlong}/Guide") - .Should().Within(60.Seconds()) - .Emit("the deletion is the control's precondition", - cancellationToken: TestContext.Current.CancellationToken); - (await WaitForNode($"{CarryAlong}/Guide", present: false)).Should().BeTrue( - "the node has to be gone before its absence can be the thing measured"); - - var afterLoss = await WaitForVerdict(record, InstallCompletenessKind.Incomplete, CarryAlong); - afterLoss.Missing.Should().ContainSingle().Which.Should().Be($"{CarryAlong}/Guide", - "a declared node that is genuinely absent is still a shortfall — the fix narrows the " - + "population, it does not narrow what a shortfall means"); - } - - /// - /// 🚨 The one-argument NodePathForFile is public API a DEPENDENT repo already calls — - /// MeshWeaver.Plugins' ModuleManifestTest.NodePathMappingSkipsNonNodeFiles, six - /// assertions. Removing it when #3659 gave the rule a second half would have reddened that - /// repo's build at its next platform-pin move while its adaptation could not compile until that - /// same pin moved, so it stays as a convenience over the BUILT-IN parser set. Its contract is - /// pinned HERE, in the repo that owns the method, because this is where a change can break it — - /// a cross-repo caller cannot defend itself. - /// - /// The loop is the guard that matters: on this host the convenience overload and the - /// hub's own registry must give the SAME answer for every shape, so the overload is a - /// convenience and never a second rule. - /// - [Fact] - public void TheBuiltInOverload_AgreesWithTheHubsRegistry_AndWithTheDependentsAssertions() - { - string?[] shapes = - [ - "Widget/Thing.json", "Widget/index.json", "Widget/Thing/Source/Thing.cs", - "Widget/manifest.lock", "README.md", "Widget/Poster/content/poster.png", - "Widget/gui/rn/widget.tsx", "Widget/logo.png", "Widget/LICENSE", - ]; - - // The dependent's six assertions, verbatim. - PackageInstaller.NodePathForFile("Widget/Thing.json").Should().Be("Widget/Thing"); - PackageInstaller.NodePathForFile("Widget/index.json").Should().Be("Widget"); - PackageInstaller.NodePathForFile("Widget/Thing/Source/Thing.cs") - .Should().Be("Widget/Thing/Source/Thing"); - PackageInstaller.NodePathForFile("Widget/manifest.lock").Should().BeNull(); - PackageInstaller.NodePathForFile("README.md").Should().BeNull(); - PackageInstaller.NodePathForFile("Widget/Poster/content/poster.png").Should().BeNull( - "a removed content asset must never prune its owning node"); - - // …and #3659's own half: a carry-along asset is not a node candidate either. - PackageInstaller.NodePathForFile("Widget/gui/rn/widget.tsx").Should().BeNull( - "no registered parser claims .tsx, so no install ever writes it — this is the " - + "Chess/gui/rn/chess phantom"); - PackageInstaller.NodePathForFile("Widget/logo.png").Should().BeNull("nor .png"); - PackageInstaller.NodePathForFile("Widget/LICENSE").Should().BeNull( - "nor a file with no extension at all"); - - var parsers = Parsers(); - foreach (var shape in shapes) - PackageInstaller.NodePathForFile(shape!, parsers).Should() - .Be(PackageInstaller.NodePathForFile(shape!), - "the convenience overload must not be a SECOND rule — where this host's " - + "registry and the built-in set disagree, the install and the sweep would " - + "again be counting different populations"); - } - - /// - /// The pure arm of the same rule, so the population statement can be read without a mesh: a - /// record whose files are ALL carry-along assets declares no node at all, and says so as - /// — never Complete, which would be - /// "zero found, zero expected, green" (AGENTS.md), and never Incomplete, which is what - /// it answered before #3659. - /// - [Fact] - public void ARecordOfNothingButCarryAlongFiles_DeclaresNoNode_AndSaysSo() - { - var record = new PackageManifest - { - Id = Package, - TargetPartition = Package, - InstalledFiles = ImmutableSortedDictionary.Empty - .Add($"{Package}/gui/rn/widget.tsx", "aaa") - .Add($"{Package}/logo.png", "bbb") - .Add($"{Package}/LICENSE", "ccc"), - }; - - var verdict = InstallCompleteness.Compare( - Package, Package, record, - ImmutableHashSet.Empty.WithComparer(StringComparer.Ordinal), Parsers()); - - verdict.Kind.Should().Be(InstallCompletenessKind.Undeclared, - "no file in this record is a node candidate, so nothing declares what the partition " - + "should hold — before #3659 all three were counted and reported ABSENT on every boot"); - verdict.IsComplete.Should().BeFalse("'not declared' is not a clean bill of health either"); - verdict.DeclaredFiles.Should().Be(3); - verdict.NonNodeFiles.Should().Be(3); - verdict.Because.Should().Contain("non-node files", - "the reason has to name WHY there is nothing to compare, or the operator cannot tell " - + "this apart from a record that was never stamped"); - } - - // ── #3659, the CONTENT half: the question a PATH cannot answer ─────────────────────────────── - - /// - /// 🚨 THE second repro of #3659, end to end. A package ships an ordinary config file - /// inside its own folder — gui/rn/tsconfig.json, exactly the shape Chess's - /// React-Native folder has. Its extension IS claimed (.json), so the extension gate #3685 - /// added lets it through; its CONTENT carries no $type/id/nodeType, so - /// JsonFileParser answers "no node here" and the installer writes nothing. - /// - /// Before this change the sweep counted it anyway and reported - /// UnreadablePkg/gui/rn/tsconfig ABSENT, at Error, on every boot of every pod — and no - /// reinstall could ever clear it, because the same bytes fail the same way. That is the issue's - /// own defect class one level down: an unhealable phantom spelled identically to the genuinely - /// lost node the sweep exists to find, sitting at the same log site. - /// - /// The fix is evidence, not another exclusion list. The installer is the only side - /// that holds the bytes, so it RECORDS what it could not read - /// () and the sweep reads that. A list would have - /// needed a new case for .tsx, then for package.json, then for the next shape — - /// which is precisely how this issue was filed twice. - /// - /// Two controls, because an exclusion test that cannot fail proves nothing. The - /// unreadable file must still be REPORTED (under its own sentence, at Error — it is a packaging - /// defect, not a clean bill of health), and a genuinely deleted node must still come back as - /// Incomplete naming exactly it. - /// - [Fact(Timeout = 300_000)] - public async Task AConfigFileInsideAPackage_IsRecordedUnreadable_AndIsNeverSpelledAbsent() - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, new FixedSource(UnreadableFiles()), "HEAD", UnreadableCandidate(), logger) - .Should().Within(180.Seconds()) - .Emit("the install has to land before its completeness can be measured", - cancellationToken: TestContext.Current.CancellationToken); - - (await WaitForNode($"{Unreadable}/Guide", present: true)).Should().BeTrue( - "the Guide node is what the control below deletes — if the install never wrote it, both " - + "halves of this test would pass for the wrong reason"); - (await WaitForNode(UnreadableConfigNode, present: false)).Should().BeTrue( - "THE premise: no install ever writes a node for a file the parser cannot read. If this " - + "node existed the whole test would be about nothing"); - - var record = await ReadRecord(Unreadable); - record.Should().NotBeNull("the installer stamps an install record"); - - // ── The write side recorded what it could not read. - record!.UnreadableFiles.Should().NotBeNull( - "a node-repo install always answers this question now — null means UNKNOWN (a record " - + "stamped before the field existed) and must never be produced by an install that " - + "actually parsed the package"); - record.UnreadableFiles.Should().ContainSingle().Which.Should().Be(UnreadableConfigFile, - "the installer parsed it, got no node, and that is the one fact a path-only reader can " - + "never derive"); - - // ── The declared side reads that instead of guessing. - var declared = InstallCompleteness.DeclaredNodePaths(record, Parsers()); - declared.Should().Equal([Unreadable, $"{Unreadable}/Guide"], - "the declared population is what the install actually writes — index.json and Guide.md"); - declared.Should().NotContain(UnreadableConfigNode, - "counting it is the defect: it was reported ABSENT at Error on every boot, forever, and " - + "no reinstall could clear it"); - - var verdict = await WaitForVerdict(record, InstallCompletenessKind.Complete, Unreadable); - verdict.Missing.Should().BeEmpty( - "nothing is absent — before this change the tsconfig was, on every boot of every pod"); - verdict.DeclaredFiles.Should().Be(4, "the record declares the lock, index, Guide and tsconfig"); - verdict.Declared.Should().Be(2, "leaving index.json and Guide.md as node paths"); - - // ── Control 1: excluded from the ABSENT count is not the same as unreported. - verdict.UnreadableFilePaths.Should().ContainSingle().Which.Should().Be(UnreadableConfigFile, - "a package that declares a node it can never deliver is still a fault — silently " - + "dropping it would trade a wrong Error for a missing one"); - verdict.Population.Should().Contain("could NOT read as a node", - "and the population line states it, so a reader can tell 'excluded by design' from " - + "'the install met this file and failed on it'"); - var landing = InstallCompleteness.DescribeLanding(verdict, UnreadableHash); - landing.Level.Should().Be(LogLevel.Error, - "it outranks the completeness verdict: the package is otherwise whole and still ships a " - + "declared node that does not exist"); - landing.Message.Should().Contain("PACKAGING defect", - "and it must NOT say 'reinstalling it now repairs it' — the one remedy that cannot work"); - // 🚨 The line has to name the FILE, with its extension — that is the thing an operator - // moves or fixes. Naming the node path it WOULD have produced points at nothing on disk. - landing.Message.Should().Contain(UnreadableConfigFile, - "the remedy is applied to a file, so a report that names only the node path is a report " - + "nobody can act on"); - landing.Message.Should().NotContain($"{UnreadableConfigNode}]", - "and it must not name the bare node path in the file's place"); - - // ── Control 2: the sweep is not blinded to a REAL loss. - await meshService.DeleteNode($"{Unreadable}/Guide") - .Should().Within(60.Seconds()) - .Emit("the deletion is the control's precondition", - cancellationToken: TestContext.Current.CancellationToken); - (await WaitForNode($"{Unreadable}/Guide", present: false)).Should().BeTrue( - "the node has to be gone before its absence can be the thing measured"); - - var afterLoss = await WaitForVerdict(record, InstallCompletenessKind.Incomplete, Unreadable); - afterLoss.Missing.Should().ContainSingle().Which.Should().Be($"{Unreadable}/Guide", - "a declared node that is genuinely absent is still a shortfall — this change narrows " - + "the population to what the installer writes, never what a shortfall means"); - afterLoss.Missing.Should().NotContain(UnreadableConfigNode, - "and the unreadable file never joins the absences, whatever else is wrong"); - } - - /// - /// The pure arm, and the one that proves the answer comes from the RECORD rather than from a - /// new hard-coded exclusion: the same file map, compared twice, differing only in whether the - /// record carries . - /// - /// 🚨 The null case must reproduce the PRE-fix behaviour exactly — an absent - /// answer is UNKNOWN, and upgrading it to "checked, none" would let a record stamped by an - /// older installer read as clean over a node that is genuinely gone. - /// - [Fact] - public void WhetherAFileIsAnAbsence_ComesFromTheRecord_NotFromAHardCodedList() - { - var files = ImmutableSortedDictionary.Empty - .Add($"{Package}/Guide.md", "bbb") - .Add($"{Package}/gui/rn/tsconfig.json", "ccc"); - var present = ImmutableHashSet.Empty - .WithComparer(StringComparer.Ordinal) - .Add(GuidePath); - - // Unknown — no install has recorded the content answer. - var unrecorded = InstallCompleteness.Compare( - Package, Package, - new PackageManifest { Id = Package, TargetPartition = Package, InstalledFiles = files }, - present, Parsers()); - - unrecorded.Kind.Should().Be(InstallCompletenessKind.Incomplete, - "with nothing recorded the sweep must behave exactly as it did before — a path-only " - + "reader cannot know the file is unreadable, and guessing either way would be the " - + "second derivation this whole change removes"); - unrecorded.Missing.Should().ContainSingle().Which.Should().Be($"{Package}/gui/rn/tsconfig"); - unrecorded.UnreadableFilePaths.Should().BeEmpty("nothing was recorded, so nothing is named"); - - // Recorded — the installer met the file and could not read it. - var recorded = InstallCompleteness.Compare( - Package, Package, - new PackageManifest - { - Id = Package, - TargetPartition = Package, - InstalledFiles = files, - UnreadableFiles = ImmutableSortedSet.Empty - .WithComparer(StringComparer.Ordinal) - .Add($"{Package}/gui/rn/tsconfig.json"), - }, - present, Parsers()); - - recorded.Kind.Should().Be(InstallCompletenessKind.Complete, - "the only thing that changed is the install's own answer about the bytes — which is the " - + "point: the declared side stopped re-deriving a question it cannot ask"); - recorded.Missing.Should().BeEmpty(); - recorded.UnreadableFilePaths.Should().ContainSingle().Which.Should() - .Be($"{Package}/gui/rn/tsconfig.json", - "still named, still reported — as the FILE, which is what an operator moves or " - + "fixes; the node path it would have produced exists nowhere"); - InstallCompleteness.DescribeLanding(recorded, "hash").Level.Should().Be(LogLevel.Error, - "'complete' plus a node that can never exist is not a clean landing"); - - // 🚨 The population arithmetic must still ADD UP: every declared file is in exactly one - // bucket. A recorded file that is no longer a node candidate at all — a module contributed - // the parser and is not loaded on this boot — must fall to NonNodeFiles rather than out of - // the count entirely (Copilot review). - var parserGone = InstallCompleteness.Compare( - Package, Package, - new PackageManifest - { - Id = Package, - TargetPartition = Package, - InstalledFiles = files.Add($"{Package}/widget.tsx", "ddd"), - UnreadableFiles = ImmutableSortedSet.Empty - .WithComparer(StringComparer.Ordinal) - .Add($"{Package}/widget.tsx"), - }, - present, Parsers()); - - (parserGone.NonNodeFiles + parserGone.UnreadableFilePaths.Count + parserGone.Declared) - .Should().Be(parserGone.DeclaredFiles, - "three disjoint buckets covering every declared file — a file that fell out of all " - + "of them would make the printed population silently wrong, which is the exact " - + "class of defect this whole sweep exists to remove"); - parserGone.UnreadableFilePaths.Should().NotContain($"{Package}/widget.tsx", - "no parser claims .tsx on this host, so it is an ordinary non-node file TODAY — " - + "reporting it as a packaging defect would accuse a package because this host is " - + "configured differently from the one that installed it"); - } - - /// - /// 🚨 An INCREMENTAL update examines only the files it fetched, so the record MERGES rather than - /// replaces. Replacing would forget every unreadable file outside the delta and the very next - /// boot sweep would start reporting them ABSENT again — this defect, recreated inside its own - /// bookkeeping. - /// - /// Pure and total, so every arm is pinned with no hub and no mesh. - /// - [Fact] - public void TheUnreadableSetMerges_KeepsWhatWasNotExamined_AndForgetsWhatLeftThePackage() - { - var previous = ImmutableSortedSet.Empty - .WithComparer(StringComparer.Ordinal).Add("P/a.json").Add("P/b.json"); - string[] declared = ["P/a.json", "P/b.json", "P/c.json"]; - - PackageInstaller.MergeUnreadableFiles(previous, ["P/a.json"], [], declared) - .Should().Equal(["P/b.json"], - "a file this update EXAMINED is decided by this update — a.json now parses, so it " - + "must stop being reported; b.json was not fetched and keeps its verdict"); - - PackageInstaller.MergeUnreadableFiles(previous, ["P/a.json", "P/c.json"], ["P/c.json"], declared) - .Should().Equal(["P/b.json", "P/c.json"], - "and a newly unreadable file joins without disturbing what was not examined"); - - PackageInstaller.MergeUnreadableFiles(previous, null, null, ["P/a.json"]) - .Should().Equal(["P/a.json"], - "a write that parsed nothing learns nothing — but an entry for a file that has LEFT " - + "the package is dropped, or it could never be cleared again"); - - PackageInstaller.MergeUnreadableFiles(null, null, null, declared) - .Should().BeNull( - "🚨 unknown stays unknown: a record stamped by an older installer must not be " - + "upgraded to 'checked, none' by a write that checked nothing"); - - // 🚨 …and not by a write that checked only PART of it either (Copilot review). An empty set - // is the positive claim "every declared candidate was parsed and all became nodes"; two - // files out of three cannot certify the third. Getting this wrong would let a legacy record - // plus one small delta read as a clean full-package parse — "not checked reads as clean", - // recreated inside the bookkeeping that exists to prevent it. - PackageInstaller.MergeUnreadableFiles(null, ["P/a.json"], [], declared) - .Should().BeNull( - "an incremental update over a record that never had an answer still has none"); - - // 🚨 The two shapes that reach here with a NON-NULL but uninformative `examined`: a - // source-only delta (every changed file is a src/** module source, which never travels) and - // a delta of nothing but carry-along assets. Both parsed ZERO node candidates, so both must - // leave an unknown record unknown — turning either into an empty set would manufacture a - // clean answer out of a measurement that never happened, which is this issue's own defect - // class pointed the other way. - PackageInstaller.MergeUnreadableFiles(null, [], [], declared) - .Should().BeNull( - "a delta that fetched NOTHING examined nothing — 'observed, and all clean' is a " - + "claim it has no standing to make"); - - PackageInstaller.MergeUnreadableFiles(null, ["P/logo.png"], [], declared) - .Should().BeNull( - "and neither does a delta carrying only files that were never node candidates"); - - // The control that keeps the two above from passing vacuously: the SAME uninformative - // deltas over a record that DOES have an answer must preserve it — not null it, not empty - // it. If the rule were "always null", this would fail. - PackageInstaller.MergeUnreadableFiles(previous, ["P/logo.png"], [], declared) - .Should().Equal(["P/a.json", "P/b.json"], - "an existing answer survives a delta that says nothing about it"); - - PackageInstaller.MergeUnreadableFiles(null, declared, [], declared) - .Should().BeEmpty( - "but a pass that looked at EVERY declared file records an EMPTY set — 'checked, " - + "none' and 'never checked' are different answers, and a FULL install can say it"); - - PackageInstaller.MergeUnreadableFiles(null, declared, ["P/c.json"], declared) - .Should().Equal(["P/c.json"], - "and the same full pass reports what it did find"); - } - - /// - /// 🚨 A file the install could not read as a node is PERMANENTLY node-less, so the incremental - /// update's restore set must stop widening the fetch for it — otherwise every update re-fetches, - /// re-parses and re-skips it forever, under a line calling it an absent node being restored: a - /// second place where the unhealable case wears the actionable one's words. - /// - /// 🚨 And the control that matters: this must not strand a package that FIXES the file. - /// A changed file is in the delta, which the restore set never returns anyway — so it still - /// travels and is still re-examined. - /// - [Fact] - public void ARecordedUnreadableFile_StopsWideningEveryUpdate_ButAFixedOneStillTravels() - { - var declaredFiles = ImmutableSortedDictionary.Empty - .Add($"{Package}/Guide.md", "bbb") - .Add($"{Package}/gui/rn/tsconfig.json", "ccc"); - var nothingPresent = ImmutableHashSet.Empty.WithComparer(StringComparer.Ordinal); - var recorded = ImmutableSortedSet.Empty - .WithComparer(StringComparer.Ordinal) - .Add($"{Package}/gui/rn/tsconfig.json"); - var nothingFetching = ImmutableHashSet.Empty.WithComparer(StringComparer.Ordinal); - - InstallCompleteness.FilesToRestore( - declaredFiles, nothingFetching, nothingPresent, Parsers(), knownUnreadable: null) - .Should().Contain($"{Package}/gui/rn/tsconfig.json", - "with nothing recorded the behaviour is unchanged — which is what makes the next " - + "assertion a statement about the RECORD rather than about a new exclusion"); - - InstallCompleteness.FilesToRestore( - declaredFiles, nothingFetching, nothingPresent, Parsers(), recorded) - .Should().Equal([$"{Package}/Guide.md"], - "the recorded file is dropped from the widening; the genuinely absent node is not"); - - // The control: its hash MOVED, so it is in the delta and travels regardless. - var fetching = ImmutableHashSet.Empty - .WithComparer(StringComparer.Ordinal) - .Add($"{Package}/gui/rn/tsconfig.json"); - InstallCompleteness.FilesToRestore( - declaredFiles, fetching, nothingPresent, Parsers(), recorded) - .Should().NotContain($"{Package}/gui/rn/tsconfig.json", - "a file already being fetched is never in the restore set — so excluding it here " - + "cannot be what decides whether a FIXED file is re-examined; the delta does"); - } - - // ── #3659 content-half fixture: a package carrying an ordinary config file ─────────────────── - - private const string Unreadable = "UnreadablePkg"; - private const string UnreadableHash = "9876543210fedcba"; - private const string UnreadableConfigFile = $"{Unreadable}/gui/rn/tsconfig.json"; - private const string UnreadableConfigNode = $"{Unreadable}/gui/rn/tsconfig"; - - private static PackageManifest UnreadableCandidate() => new() - { - Id = Unreadable, - Name = Unreadable, - Kind = PackageKind.NodeRepo, - TargetPartition = Unreadable, - SourceFolder = Unreadable, - Version = "1.0.0", - ModuleVersion = UnreadableHash, - }; - - private static IReadOnlyList UnreadableFiles() => - [ - new PackageFile($"{Unreadable}/{ModuleManifest.FileName}", $$""" - { - "module": "{{Unreadable}}", - "moduleVersion": "{{UnreadableHash}}", - "version": "1.0.0", - "files": { - "{{Unreadable}}/{{ModuleManifest.FileName}}": "000", - "{{Unreadable}}/index.json": "aaa", - "{{Unreadable}}/Guide.md": "bbb", - "{{UnreadableConfigFile}}": "ccc" - } - } - """), - new PackageFile($"{Unreadable}/index.json", $$""" - { - "id": "{{Unreadable}}", - "path": "{{Unreadable}}", - "nodeType": "Space", - "name": "Unreadable-file package", - "state": "Active" - } - """), - new PackageFile($"{Unreadable}/Guide.md", "# Guide\n\nA node, and the control's subject."), - // 🚨 The subject: a claimed extension whose content is not a node. No $type, no id, no - // nodeType — the shape of every tsconfig.json, package.json and app.json that has ever sat - // inside a package's gui/ folder. - new PackageFile(UnreadableConfigFile, """ - { - "compilerOptions": { "strict": true, "jsx": "react-native" }, - "include": ["src"] - } - """), - ]; - - /// - /// 🚨 A sweep that could not run must not produce the same ZERO as a sweep that found nothing. - /// Copilot caught this on the first version of this PR: ObserveUnaccountedRoots folded - /// both "no adapter" and "the listing faulted" into an empty sequence, so the summary printed - /// 0 root(s) with no record either way — the `not checked reads as clean` failure this - /// whole change exists to remove, recreated inside it. - /// - [Fact(Timeout = 60_000)] - public async Task ASweepThatCouldNotRun_SaysSo_InsteadOfReportingZero() - { - var verdicts = await InstallCompleteness - .ObserveUnaccountedRoots( - persistence: null, Mesh.JsonSerializerOptions, ImmutableHashSet.Empty) - .ToList() - // TestTimeouts.Convergence, never a literal: a hand-written 30 s is a guess about how - // fast a machine is, and it is also the framework's OWN write bound — so a test that - // waits exactly that long gives up one second before the framework can explain itself - // (TestTimeoutLiteralRatchetGuard). - .Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - - verdicts.Should().ContainSingle( - "a sweep that could not run emits exactly one verdict saying so — not zero, which is " - + "what a clean sweep emits"); - verdicts[0].Kind.Should().Be(InstallCompletenessKind.NotObserved); - verdicts[0].IsComplete.Should().BeFalse("it was not checked, so it is not a pass"); - verdicts[0].Because.Should().Contain("did NOT run", - "the line an operator reads has to distinguish an absent result from a clean one"); - - InstallCompleteness.Summarize(verdicts.ToImmutableList()).NotObserved.Should().Be(1, - "and the denominator has to carry it, or the summary is back to printing a bare zero"); - } - - /// The denominator: a sweep reporting zero problems has to say what it looked at. - [Fact] - public void TheSummaryPrintsEveryKind() - { - var summary = InstallCompleteness.Summarize( - [ - Verdict(InstallCompletenessKind.Complete), - Verdict(InstallCompletenessKind.Complete), - Verdict(InstallCompletenessKind.Incomplete), - Verdict(InstallCompletenessKind.Undeclared), - Verdict(InstallCompletenessKind.NotObserved), - Verdict(InstallCompletenessKind.RootWithoutRecord), - ]); - - summary.Total.Should().Be(6); - summary.Complete.Should().Be(2); - summary.Incomplete.Should().Be(1); - summary.Undeclared.Should().Be(1); - summary.NotObserved.Should().Be(1); - summary.RootWithoutRecord.Should().Be(1); - summary.ToString().Should().Contain("INCOMPLETE", - "the line an operator greps for has to carry the word that matters"); - } - - private static InstallCompletenessVerdict Verdict(InstallCompletenessKind kind) => - new("x", "x", kind, 0, 0, - ImmutableSortedSet.Empty.WithComparer(StringComparer.Ordinal), ""); - - // ── #4101: a MIXED package's src// sources are not nodes ──────────────────────────── - - private const string Module = "MeshWeaver.SelfUpdate.Aks"; - private const string ModuleSourceDir = $"src/{Module}"; - - /// Three node files under the partition plus two module sources — the shape the - /// canonical gen-manifests.py produces for a mixed package with - /// hashModuleSources: true (Plugins#878), which is 38 of the 63 locks in MeshWeaver.Plugins. - private static PackageManifest MixedRecord(ImmutableSortedDictionary? extra = null) => new() - { - Id = Package, - TargetPartition = Package, - Module = Module, - InstalledFiles = (extra ?? ImmutableSortedDictionary.Empty) - .Add($"{Package}/Guide.md", "aaa") - .Add($"{Package}/Other.md", "bbb") - .Add($"{Package}/Third.md", "ccc") - .Add($"{ModuleSourceDir}/AcrTagLister.cs", "ddd") - .Add($"{ModuleSourceDir}/{Module}.csproj", "eee"), - }; - - /// - /// 🚨 THE repro of #4101. Measured 2026-09-12 on build.meshweaver.cloud, first boot - /// after the Hosting package landed: NOT VERIFIED (Undeclared) — 13 of 189 declared file(s) - /// map outside the target partition 'Hosting' (e.g. 'src/MeshWeaver.SelfUpdate.Aks/AcrTagLister'). - /// .cs is a claimed extension, so a declared module source mapped to an off-partition - /// node path and the whole record became uncomparable — for every mixed package, on every fresh - /// mesh. No install ever writes a src/ file as a node (NodeRepoPackageSource keeps - /// only {Id}/…); the pack lane compiles them into the bundle. - /// - [Fact] - public void ModuleSources_AreNotNodes_AndTheVerdictCountsThemSeparately() - { - var record = MixedRecord(); - var parsers = Parsers(); - - InstallCompleteness.DeclaredNodePaths(record, parsers).Should().Equal( - [GuidePath, OtherPath, $"{Package}/Third"], - "a src// source is compiled into the module bundle, never written as a node — " - + "counting it made every mixed package read NOT VERIFIED (Undeclared) on every fresh mesh"); - - var verdict = InstallCompleteness.Compare( - Package, Package, record, - ImmutableHashSet.Create(StringComparer.Ordinal, GuidePath, OtherPath, $"{Package}/Third"), - parsers); - - verdict.Kind.Should().Be(InstallCompletenessKind.Complete, - "every node the record declares is present; the two module sources are not nodes it owes the mesh"); - verdict.IsComplete.Should().BeTrue(); - verdict.Declared.Should().Be(3); - verdict.DeclaredFiles.Should().Be(5); - verdict.NonNodeFiles.Should().Be(2); - verdict.ModuleSourceFiles.Should().Be(2, - "the sources are counted SEPARATELY from a README so the line says what they are"); - verdict.ModuleSourceDirectories.Should().ContainSingle().Which.Should().Be(ModuleSourceDir); - verdict.Population.Should().Contain("5 file(s) declared, 2 of them not node files") - .And.Contain($"2 of them module sources ({ModuleSourceDir})") - .And.Contain("not compared as nodes") - .And.Contain("3 distinct node path(s) compared", - "an operator reading the line must see both counts, or a wrong population is silent again"); - - // The install-time rule is the SAME predicate — a removed source must never ask the delta - // prune to delete a node path that never existed. - PackageInstaller.NodePathForFile($"{ModuleSourceDir}/AcrTagLister.cs", parsers).Should().BeNull(); - PackageInstaller.IsModuleSourcePath($"{ModuleSourceDir}/AcrTagLister.cs").Should().BeTrue(); - PackageInstaller.IsModuleSourcePath($"{Package}/src/NotASource.md").Should().BeFalse( - "only the lock's top-level src/ is the module-source directory; a package folder named " - + "src inside the partition is ordinary content"); - } - - /// - /// 🚨 The negative control: excluding src/ must not buy a VERIFIED for a record that - /// genuinely maps outside its partition. A mixed record plus one rogue partition file still - /// reads Undeclared, naming the rogue file and NOT any module source. - /// - [Fact] - public void AGenuinelyUndeclaredPartitionFile_StillReads_NotVerified() - { - var record = MixedRecord(ImmutableSortedDictionary.Empty - .Add("SomewhereElse/Rogue.md", "fff")); - - var verdict = InstallCompleteness.Compare( - Package, Package, record, - ImmutableHashSet.Create(StringComparer.Ordinal, GuidePath, OtherPath, $"{Package}/Third"), - Parsers()); - - verdict.Kind.Should().Be(InstallCompletenessKind.Undeclared, - "a file map that maps outside the target partition still cannot be compared against it"); - verdict.IsComplete.Should().BeFalse("the negative control must stay red"); - verdict.Missing.Should().ContainSingle( - "the off-partition path named is the rogue node file — never a src/ source") - .Which.Should().Be("SomewhereElse/Rogue"); - verdict.Missing.Should().NotContain(p => p.StartsWith("src/", StringComparison.Ordinal)); - verdict.ModuleSourceFiles.Should().Be(2); - verdict.Because.Should().Contain("1 of 4 declared file(s) map outside the target partition"); - } - - /// - /// The module half of the sweep reads the REAL activation record through - /// and answers "active on this pod" from the - /// entry plus the loaded assembly set — and every wording says the sources themselves were NOT - /// checked, because the volume holds assemblies, not sources. - /// - [Fact(Timeout = 60_000)] - public async Task TheModuleLine_ReadsTheActivationRecord_AndSaysWhatItDidNotCheck() - { - var root = Path.Combine(Path.GetTempPath(), "mw-4101-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - using var landing = new ModuleLandingService(baseDirectory: root); - try - { - var entry = new ModuleActivationEntry { Name = Module, Directory = $"{Module}@gen1" }; - ModuleActivationSidecar.WriteEntry(root, entry); - var activation = await landing.GetActivation() - .Take(1) - .Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - var record = MixedRecord(); - var loaded = ImmutableHashSet.Create(StringComparer.Ordinal, Module); - var expectedDirectory = ModuleLandingService.ModuleDirectoryFor(root, Module, entry); - - var active = InstallCompleteness.ModuleActivation( - Package, record, activation, loaded, landing.BaseDirectory); - active.Should().NotBeNull(); - active!.Kind.Should().Be(ModuleActivationVerdictKind.Active); - active.IsActive.Should().BeTrue(); - active.Directory.Should().Be(expectedDirectory, - "the line names the generation directory the entry resolves to"); - active.DeclaredSourceFiles.Should().Be(2); - active.Because.Should().Contain($"module '{Module}' active from '{expectedDirectory}'") - .And.Contain("cannot be checked file-by-file", - "'active' must never read as 'every source file arrived'"); - - var notLoaded = InstallCompleteness.ModuleActivation( - Package, record, activation, ImmutableHashSet.Empty, landing.BaseDirectory); - notLoaded!.Kind.Should().Be(ModuleActivationVerdictKind.LandedNotLoaded, - "an enabled entry whose assembly is not in this process is a restart away, not active"); - notLoaded.IsActive.Should().BeFalse(); - notLoaded.Because.Should().Contain("NOT loaded").And.Contain("not a pass"); - - var noEntry = InstallCompleteness.ModuleActivation( - Package, record with { Module = "MeshWeaver.Nowhere" }, activation, loaded, landing.BaseDirectory); - noEntry!.Kind.Should().Be(ModuleActivationVerdictKind.NotActive); - noEntry.Because.Should().Contain("NO activation entry"); - - var unread = InstallCompleteness.ModuleActivation( - Package, record, activation: null, loaded, landing.BaseDirectory); - unread!.Kind.Should().Be(ModuleActivationVerdictKind.NotObserved); - unread.IsActive.Should().BeFalse("not checked is not a pass"); - unread.Because.Should().Contain("NOT checked"); - - InstallCompleteness.ModuleActivation( - Package, record with { Module = null }, activation, loaded, landing.BaseDirectory) - .Should().BeNull("a record that declares no module has nothing to ask"); - } - finally - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - } - - /// - /// Waits until is present (or absent) in STORAGE — a condition, never a - /// clock. Read through because that is the one read that answers - /// "absent" with a value instead of a fault. - /// - /// 🚨 A READ FAILURE IS NOT AN OBSERVATION OF ABSENCE. Mapping a fault to false - /// would make a broken read path satisfy present: false instantly — the precondition - /// would pass having measured nothing, and the assertion after it would then be testing a mesh - /// nobody could read. So the fault propagates and fails the test naming the path, which is the - /// same rule the production code under test is built on. - /// - private async Task WaitForNode(string path, bool present) => - await Observable.Interval(TimeSpan.FromMilliseconds(100)).StartWith(0L) - .SelectMany(_ => Mesh.ServiceProvider.GetRequiredService() - .Read(path, Mesh.JsonSerializerOptions) - .Take(1) - .DefaultIfEmpty(null) - .Select(n => n is not null) - .Catch(ex => Observable.Throw(new InvalidOperationException( - $"reading '{path}' from storage FAILED, so neither its presence nor its " - + "absence was observed. Treating this as 'absent' would let the test proceed " - + "on a broken read path.", ex)))) - .Where(found => found == present) - .Select(_ => true) - .FirstAsync() - .Timeout(120.Seconds()) - .Await(TestContext.Current.CancellationToken); - - private Task ReadRecord() => ReadRecord(Package); - - private async Task ReadRecord(string packageId) - { - var persistence = Mesh.ServiceProvider.GetRequiredService(); - var node = await persistence - .Read($"{PackageInstaller.InstalledPartition}/{packageId}", Mesh.JsonSerializerOptions) - .Take(1) - .Timeout(60.Seconds()) - .Await(TestContext.Current.CancellationToken); - return node?.ContentAs(Mesh.JsonSerializerOptions); - } - - // ── #3659 fixture: a package carrying the files a real node repo carries ──────────────────── - - private const string CarryAlong = "CarryAlongPkg"; - private const string CarryAlongHash = "fedcba9876543210"; - - private static PackageManifest CarryAlongCandidate() => new() - { - Id = CarryAlong, - Name = CarryAlong, - Kind = PackageKind.NodeRepo, - TargetPartition = CarryAlong, - SourceFolder = CarryAlong, - Version = "1.0.0", - ModuleVersion = CarryAlongHash, - }; - - private static IReadOnlyList CarryAlongFiles() => - [ - new PackageFile($"{CarryAlong}/{ModuleManifest.FileName}", $$""" - { - "module": "{{CarryAlong}}", - "moduleVersion": "{{CarryAlongHash}}", - "version": "1.0.0", - "files": { - "{{CarryAlong}}/{{ModuleManifest.FileName}}": "000", - "{{CarryAlong}}/index.json": "aaa", - "{{CarryAlong}}/Guide.md": "bbb", - "{{CarryAlong}}/README.md": "ccc", - "{{CarryAlong}}/gui/rn/widget.tsx": "ddd", - "{{CarryAlong}}/logo.png": "eee", - "{{CarryAlong}}/LICENSE": "fff", - "{{CarryAlong}}/content/og.png": "ggg" - } - } - """), - new PackageFile($"{CarryAlong}/index.json", $$""" - { - "id": "{{CarryAlong}}", - "path": "{{CarryAlong}}", - "nodeType": "Space", - "name": "Carry-along package", - "state": "Active" - } - """), - new PackageFile($"{CarryAlong}/Guide.md", "# Guide\n\nA node, and the control's subject."), - new PackageFile($"{CarryAlong}/README.md", "# Carry-along\n\nA node: the root-only README " - + "exclusion does not match a package-folder README."), - // The three the installer silently skips — a React Native view, an image, a licence file. - new PackageFile($"{CarryAlong}/gui/rn/widget.tsx", "export const Widget = () => null;"), - new PackageFile($"{CarryAlong}/logo.png", "not really a png, and no parser claims .png"), - new PackageFile($"{CarryAlong}/LICENSE", "MIT"), - // …and one that is a non-node BY DESIGN, which was already excluded before #3659. - new PackageFile($"{CarryAlong}/content/og.png", "a content asset"), - ]; - - private IObservable Observe(PackageManifest record) => - InstallCompleteness.Observe( - Mesh.ServiceProvider.GetRequiredService(), - Mesh.JsonSerializerOptions, Package, Package, record, Parsers()); - - /// - /// Waits for the verdict to REACH rather than sampling once: the delete - /// above is confirmed on the node stream, but the storage adapter's own read is a separate - /// observation and this is a condition, never a clock (AGENTS.md — never Task.Delay to wait for - /// propagation). - /// - private Task WaitForVerdict( - PackageManifest record, InstallCompletenessKind kind) => - WaitForVerdict(record, kind, Package); - - private async Task WaitForVerdict( - PackageManifest record, InstallCompletenessKind kind, string packageId) => - await Observable.Interval(TimeSpan.FromMilliseconds(100)).StartWith(0L) - .SelectMany(_ => InstallCompleteness.Observe( - Mesh.ServiceProvider.GetRequiredService(), - Mesh.JsonSerializerOptions, packageId, packageId, record, Parsers())) - .Where(v => v.Kind == kind) - .FirstAsync() - .Timeout(120.Seconds()) - .Await(TestContext.Current.CancellationToken); - - // ── #2387 / #3485 second half: THE SEVERITY BELONGS TO THE OUTCOME ───────────────────────── - - /// - /// 🚨 A REPAIR THAT WORKED IS NOT A FAILURE. Install, lose a declared node, re-run the - /// same install path — and read the LOG, because the defect is the severity and no assertion - /// about behaviour can see it. - /// - /// Fails on main. There, the only completeness line is written BEFORE the repair, - /// at LogError, and nothing is written after it. So on main this test finds an Error - /// naming an absence that was healed eight seconds later, and finds no outcome line at all. - /// Measured on memex.meshweaver.cloud 2026-09-13: Feedback/Feedback/Source/FeedbackHandover - /// named ABSENT at 22:02:37Z, present at 22:02:45Z — and that Error re-opened MeshWeaver#2387, - /// an issue about a different call site, because incident identity folds per log CATEGORY. - /// - /// 🚨 The second assertion is the control that stops the first passing vacuously: the - /// detection must still HAPPEN, at Warning. Without it, deleting the detection entirely would - /// make "no Error mentioning ABSENT" true for the wrong reason. - /// - [Fact(Timeout = 300_000)] - public async Task ARepairThatRestoredTheNode_IsReportedAsASuccess_NotAsAnError() - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var source = new FixedSource(Files()); - var log = new RecordingLogger(); - - await CatalogLayoutAreas.InstallOrUpdate(Mesh, source, "HEAD", Candidate(), log) - .Should().Within(180.Seconds()) - .Emit("the first install is the precondition", cancellationToken: TestContext.Current.CancellationToken); - (await WaitForNode(GuidePath, present: true)).Should().BeTrue( - "the node this test deletes has to be there first"); - - await meshService.DeleteNode(GuidePath).Should().Within(60.Seconds()).Emit("the loss", - cancellationToken: TestContext.Current.CancellationToken); - (await WaitForNode(GuidePath, present: false)).Should().BeTrue( - "the node must really be gone, or the repair below repairs nothing"); - - log.Clear(); - await CatalogLayoutAreas.InstallOrUpdate(Mesh, source, "HEAD", Candidate(), log) - .Should().Within(180.Seconds()) - .Emit("the repairing install", cancellationToken: TestContext.Current.CancellationToken); - (await WaitForNode(GuidePath, present: true)).Should().BeTrue( - "the repair must actually restore the node — this is #3485's own assertion, repeated " - + "here so the log assertions below cannot be about a repair that never happened"); - - log.Entries(LogLevel.Warning).Should().Contain(m => m.Contains("ABSENT", StringComparison.Ordinal), - "THE CONTROL: the detection must still fire, at Warning. If it did not fire at all, the " - + "assertion below would pass having measured nothing — which is the same defect as a " - + "gate that skips on missing input"); - - log.Entries(LogLevel.Error).Should().NotContain(m => m.Contains("ABSENT", StringComparison.Ordinal), - "THE ASSERTION: this install REPAIRED the mesh, so nothing about it is an Error. On main " - + "the pre-repair detection logs at Error, that line ships to Loki, the watcher mints an " - + "incident from it and re-opens MeshWeaver#2387 — for a success"); - - log.Entries(LogLevel.Information).Should().Contain(m => m.Contains("landed whole", StringComparison.Ordinal), - "and the outcome has to be SAID: 'the install is being repaired' with no follow-up is a " - + "claim nothing ever checks. On main there is no post-install verdict at all"); - } - - /// - /// 🚨 AN INSTALL THAT DID NOT LAND WHOLE HAS TO SAY SO — and on main nothing does. - /// - /// #3485 compares the record against the mesh only on the SKIP path (module hash - /// unchanged). An install that actually WRITES is never read back, so a half-landed install - /// stamps a record claiming every file and nothing asks again until the module version moves. - /// Measured on memex.meshweaver.cloud 2026-09-13: Plugins/Hosting stamped a 224-file - /// record at 22:03:03Z; Hosting/Deployment/Source/TriageIntake.cs and both - /// Hosting/TriageStatus/Source/*.cs never arrived; eight Hosting NodeTypes sat at - /// compilationStatus: Error with MISSING SOURCES twelve hours later, and the only - /// trace anywhere was the compiler complaining about a type it could not find. - /// - /// The fixture is that condition exactly: a manifest.lock declaring a file the - /// source does not serve — which is how the record acquires a declaration nothing satisfies, - /// since InstalledFiles is stamped from the LOCK, not from what was fetched. - /// - [Fact(Timeout = 300_000)] - public async Task AnInstallThatDidNotLandWhole_SaysSoAtError_NamingWhatIsStillAbsent() - { - var log = new RecordingLogger(); - - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, new FixedSource(ShortFiles()), "HEAD", ShortCandidate(), log) - .Should().Within(180.Seconds()) - .Emit("the install itself must complete — a half-landed install is not a failed one, " - + "which is exactly why nothing noticed", cancellationToken: TestContext.Current.CancellationToken); - - (await WaitForNode(ShortOtherPath, present: true)).Should().BeTrue( - "THE CONTROL: the file the source DID serve landed, so the install really ran and the " - + "assertion below is about a shortfall, not about an install that never happened"); - (await WaitForNode(ShortGuidePath, present: false)).Should().BeTrue( - "the declared-but-unserved file is the subject: it must genuinely be absent"); - - var record = await ReadRecord(Short); - record.Should().NotBeNull("the install stamps a record even though it did not land whole — " - + "that is the defect: the record is a claim nobody re-reads"); - InstallCompleteness.DeclaredNodePaths(record, Parsers()).Should().Contain(ShortGuidePath, - "the record DECLARES the node that never arrived, stamped from the lock rather than " - + "from what was fetched — without this the shortfall would be undetectable by " - + "construction and the assertion below would be vacuous"); - - log.Entries(LogLevel.Error).Should().Contain( - m => m.Contains("STILL ABSENT", StringComparison.Ordinal) - && m.Contains(ShortGuidePath, StringComparison.Ordinal), - "THE ASSERTION: an install that finished with a declared node missing must say so, at " - + "Error, NAMING it. On main the completeness check never runs after a write, so this " - + "produces no line at any level — which is how eight Hosting NodeTypes were dead for " - + "twelve hours with nothing in the log about the install that did it"); - } - - /// - /// The pure arm: the severity is a VALUE, so all three outcomes can be pinned without a host — - /// and a check whose only output is a side effect could only ever be tested by observing it. - /// - [Fact] - public void DescribeLanding_PutsTheErrorOnTheOutcome_AndNeverOnAnUnverifiedOne() - { - var missing = ImmutableSortedSet.Create(StringComparer.Ordinal, GuidePath); - - var incomplete = InstallCompleteness.DescribeLanding( - new InstallCompletenessVerdict(Package, Package, InstallCompletenessKind.Incomplete, - 3, 2, missing, "because"), - ModuleHash); - incomplete.Level.Should().Be(LogLevel.Error, - "an install that ran, stamped its record and left a declared node missing is the one " - + "arm that deserves to wake someone"); - incomplete.Message.Should().Contain("STILL ABSENT").And.Contain(GuidePath).And.Contain(ModuleHash, - "naming the path is the difference between eleven days and one boot"); - - var complete = InstallCompleteness.DescribeLanding( - new InstallCompletenessVerdict(Package, Package, InstallCompletenessKind.Complete, - 3, 3, ImmutableSortedSet.Empty.WithComparer(StringComparer.Ordinal), "ok"), - ModuleHash); - complete.Level.Should().Be(LogLevel.Information, - "a repair that worked is a success, and logging it at Error is what manufactures an " - + "incident out of the platform healing itself (MeshWeaver#2387)"); - complete.Message.Should().Contain("landed whole"); - - foreach (var kind in new[] { InstallCompletenessKind.NotObserved, InstallCompletenessKind.Undeclared }) - { - var unverified = InstallCompleteness.DescribeLanding( - new InstallCompletenessVerdict(Package, Package, kind, 3, 0, - ImmutableSortedSet.Empty.WithComparer(StringComparer.Ordinal), - "the mesh could not be read"), - ModuleHash); - unverified.Level.Should().Be(LogLevel.Warning, - $"{kind} is neither a pass nor a shortfall — nothing was shown to be missing, only " - + "that nothing was shown at all"); - unverified.Message.Should().Contain("NOT verified").And.Contain("not a pass", - "'not checked' must never be spelled like 'clean' — the rule this whole type is " - + "built on"); - } - } - - // ── The half-landing fixture: a lock that declares a file the source does not serve ───────── - - private const string Short = "ShortLandingPkg"; - private const string ShortHash = "abcdef0123456789"; - private const string ShortGuidePath = $"{Short}/Guide"; - private const string ShortOtherPath = $"{Short}/Other"; - - private static PackageManifest ShortCandidate() => new() - { - Id = Short, - Name = Short, - Kind = PackageKind.NodeRepo, - TargetPartition = Short, - SourceFolder = Short, - Version = "1.0.0", - ModuleVersion = ShortHash, - }; - - private static IReadOnlyList ShortFiles() => - [ - // The lock DECLARES Guide.md … - new PackageFile($"{Short}/{ModuleManifest.FileName}", $$""" - { - "module": "{{Short}}", - "moduleVersion": "{{ShortHash}}", - "version": "1.0.0", - "files": { - "{{Short}}/index.json": "aaa", - "{{Short}}/Guide.md": "bbb", - "{{Short}}/Other.md": "ccc" - } - } - """), - new PackageFile($"{Short}/index.json", $$""" - { - "id": "{{Short}}", - "path": "{{Short}}", - "nodeType": "Space", - "name": "Short landing package", - "state": "Active" - } - """), - // … and the source serves everything EXCEPT it. Plugins/Hosting, 2026-09-13, in a fixture. - new PackageFile($"{Short}/Other.md", "# Other\n\nThe node that does land — the control."), - ]; - - /// - /// A real that keeps what was written, so a test can assert on the - /// SEVERITY. Not a mock of a core interface — ILogger is the framework's own sink - /// abstraction, and the thing under test here is which level a line is written at. - /// - /// The queue is an INSTANCE field, never static: the install writes from whichever thread - /// the reactive pipeline lands on, and process-wide state would bleed across tests. - /// - private sealed class RecordingLogger : ILogger - { - private readonly ConcurrentQueue<(LogLevel Level, string Message)> entries = new(); - - public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) => - entries.Enqueue((logLevel, formatter(state, exception))); - - /// Every message written at , in order. - public IReadOnlyList Entries(LogLevel level) => - entries.Where(e => e.Level == level).Select(e => e.Message).ToList(); - - /// Drops everything recorded so far, so an assertion is about ONE install. - public void Clear() - { - while (entries.TryDequeue(out _)) { } - } - - private sealed class NullScope : IDisposable - { - public static readonly NullScope Instance = new(); - public void Dispose() { } - } - } - - /// - /// A package source that always serves the same files — the local-checkout shape, standing in - /// for the registry read. A real implementation, not a mock of a - /// core interface. - /// - private sealed class FixedSource(IReadOnlyList files) : IPackageSource - { - public IObservable> ListPackages(string gitRef) => - Observable.Return>([Candidate()]); - - public IObservable> FetchPackageFiles( - PackageManifest package, string gitRef) => Observable.Return(files); - } - - // ── #4259: THE UPDATE PATH, which #3485 did not reach ─────────────────────────────────────── - - private const string Incr = "IncrementalPkg"; - private const string IncrHashV1 = "1111111111111111"; - private const string IncrHashV2 = "2222222222222222"; - private const string IncrGuidePath = $"{Incr}/Guide"; - private const string IncrOtherPath = $"{Incr}/Other"; - - private static PackageManifest IncrCandidate(string moduleVersion) => new() - { - Id = Incr, - Name = Incr, - Kind = PackageKind.NodeRepo, - TargetPartition = Incr, - SourceFolder = Incr, - Version = "1.0.0", - ModuleVersion = moduleVersion, - }; - - /// - /// The package at one generation. / - /// are the ONLY things that move between the two generations — Guide.md's hash is - /// deliberately identical in both locks, which is the whole premise: the diff will not name it, - /// so only a mesh observation can. - /// - private static IReadOnlyList IncrFiles( - string moduleVersion, string otherHash, string otherBody) => - [ - new PackageFile($"{Incr}/{ModuleManifest.FileName}", $$""" - { - "module": "{{Incr}}", - "moduleVersion": "{{moduleVersion}}", - "version": "1.0.0", - "files": { - "{{Incr}}/index.json": "aaa", - "{{Incr}}/Guide.md": "bbb", - "{{Incr}}/Other.md": "{{otherHash}}" - } - } - """), - new PackageFile($"{Incr}/index.json", $$""" - { - "id": "{{Incr}}", - "path": "{{Incr}}", - "nodeType": "Space", - "name": "Incremental package", - "state": "Active" - } - """), - new PackageFile($"{Incr}/Guide.md", "# Guide\n\nThe node this test deletes. Its hash never " - + "moves, so the manifest diff never names it."), - new PackageFile($"{Incr}/Other.md", otherBody), - ]; - - /// - /// 🚨 THE REGRESSION (MeshWeaver#4259). Install, delete one declared node, then run an - /// UPDATE — the module hash MOVED, and the deleted node's own file did not. The node must come - /// back. - /// - /// Fails on main. There, IncrementalUpdate fetches exactly - /// newManifest.DiffFrom(record.InstalledFiles) — a comparison of the candidate's lock - /// against the record the installer itself stamped. Guide.md's hash is identical across - /// the two locks, so it is not in the delta, is never fetched, and never reaches - /// DecideAndWrite — which would have written it, because it writes whenever - /// current is null. The update reports success and the node stays gone. - /// - /// Why this is not covered by . - /// #3485's heal lives on the module-hash-EQUAL exit. An UPDATE never takes that exit, and every - /// new publication moves the hash again — so a package that loses a node can stay short across - /// arbitrarily many updates, each one green. That is memex.meshweaver.cloud's - /// Plugins/Hosting on 2026-09-13T22:03Z: the update wrote the two files whose content had - /// moved and left eleven declared-and-absent ones absent. - /// - /// The changed file is the CONTROL in both directions: it proves the update really ran - /// (so a green result cannot come from an install that did nothing) and that the repair did not - /// work by rewriting the whole package. - /// - [Fact(Timeout = 300_000)] - public async Task AnIncrementalUpdateOverAMissingNode_RestoresIt() - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - await CatalogLayoutAreas.InstallOrUpdate( - Mesh, new IncrSource(IncrFiles(IncrHashV1, "ccc", "# Other\n\nGeneration one.")), - "HEAD", IncrCandidate(IncrHashV1), logger) - .Should().Within(180.Seconds()) - .Emit("the first install must land before anything about an update can be measured", - cancellationToken: TestContext.Current.CancellationToken); - - (await WaitForNode(IncrGuidePath, present: true)).Should().BeTrue( - "the Guide node is the subject — if the first install never wrote it, the assertion " - + "below would pass for the wrong reason"); - (await WaitForNode(IncrOtherPath, present: true)).Should().BeTrue( - "and the control node has to exist before it can be shown to change"); - - var record = await ReadRecord(Incr); - record.Should().NotBeNull("the installer stamps an install record"); - record!.ModuleVersion.Should().Be(IncrHashV1); - record.InstalledFiles.Should().NotBeNull().And.ContainKey($"{Incr}/Guide.md", - "the record's file map is the incremental path's baseline — with no map the installer " - + "falls back to a FULL install, which repairs by accident and would make this test " - + "vacuous"); - - // ── The loss. - await meshService.DeleteNode(IncrGuidePath) - .Should().Within(60.Seconds()).Emit("the deletion is this test's precondition", - cancellationToken: TestContext.Current.CancellationToken); - (await WaitForNode(IncrGuidePath, present: false)).Should().BeTrue( - "the node must actually be gone before the update runs"); - - // ── The UPDATE: module hash moves, Other.md's content moves, Guide.md's does NOT. - var second = await CatalogLayoutAreas.InstallOrUpdate( - Mesh, new IncrSource(IncrFiles(IncrHashV2, "ddd", "# Other\n\nGeneration TWO.")), - "HEAD", IncrCandidate(IncrHashV2), logger) - .Timeout(180.Seconds()) - .Await(TestContext.Current.CancellationToken); - - second.Written.Should().BeGreaterThan(0, - "the update changed a file, so it must have written something — a zero here would mean " - + "the incremental path never ran and this test is measuring the wrong exit"); - - (await WaitForNode(IncrGuidePath, present: true)).Should().BeTrue( - "THE assertion: an update must restore a declared node that is absent from the mesh, " - + "even though its own file hash did not move. On main it does not — the fetch set is " - + "the manifest diff, which is a statement about the SOURCE and the RECORD and never " - + "about the mesh (MeshWeaver#4259)"); - - (await WaitForNode(IncrOtherPath, present: true)).Should().BeTrue( - "the control: the node that was never deleted is still there, so the repair did not " - + "work by wiping and rewriting the partition"); - - var after = await ReadRecord(Incr); - after!.ModuleVersion.Should().Be(IncrHashV2, - "the update really did move the package forward — if the record still carried the old " - + "hash the install would have taken the up-to-date exit, whose heal is #3485's and not " - + "the one under test here"); - } - - /// - /// The pure arms of — every one pinnable with - /// no mesh, the same split DescribeLanding and NodeTypeBakeStatus.Classify keep. - /// - /// 🚨 The arm that matters most is the null observation. "The mesh was not read" - /// must yield EMPTY and must never be spelled like "nothing is missing" at the call site — an - /// unobserved mesh is not a clean one, and widening the fetch on a read that faulted would - /// re-fetch a whole package on every hiccup. - /// - [Fact] - public void FilesToRestore_NamesTheAbsentOnes_AndRefusesToGuessWhenTheMeshWasNotRead() - { - var parsers = Parsers(); - var declared = ImmutableSortedDictionary.Empty - .Add($"{Incr}/index.json", "aaa") - .Add($"{Incr}/Guide.md", "bbb") - .Add($"{Incr}/Other.md", "ccc") - .Add($"{Incr}/README.md", "ddd"); - var fetching = ImmutableHashSet.Create(StringComparer.Ordinal, $"{Incr}/Other.md"); - - // Everything present ⇒ nothing to add back. - var allPresent = ImmutableHashSet.Create( - StringComparer.Ordinal, Incr, IncrGuidePath, IncrOtherPath, $"{Incr}/README"); - InstallCompleteness.FilesToRestore(declared, fetching, allPresent, parsers) - .Should().BeEmpty("a complete mesh needs no repair — if this were non-empty the " - + "assertion below would prove nothing"); - - // Guide's node gone ⇒ its FILE comes back, and only it. - var guideGone = allPresent.Remove(IncrGuidePath); - InstallCompleteness.FilesToRestore(declared, fetching, guideGone, parsers) - .Should().Equal([$"{Incr}/Guide.md"], - "the absent node's file is what the fetch has to ask for — a node path cannot be " - + "fetched, and naming the wrong half is how a repair asks for something the source " - + "does not serve"); - - // A file already in the delta is never returned twice. - var otherGone = allPresent.Remove(IncrOtherPath); - InstallCompleteness.FilesToRestore(declared, fetching, otherGone, parsers) - .Should().BeEmpty("Other.md is already being fetched by the delta — returning it again " - + "would make the caller's union a lie about how much this repair added"); - - // 🚨 The read did not happen. EMPTY, and never confusable with "nothing is missing". - InstallCompleteness.FilesToRestore(declared, fetching, presentNodePaths: null, parsers) - .Should().BeEmpty("a null observation means the mesh was NOT read; widening the fetch " - + "from it would re-fetch a whole package every time a read hiccups"); - - // Nothing declared ⇒ nothing to restore, whatever the mesh says. - InstallCompleteness.FilesToRestore(null, fetching, guideGone, parsers) - .Should().BeEmpty(); - } - - /// - /// against the live mesh: it reports what is - /// there, it reports an absence as an absence, and with no storage adapter it answers - /// null — "not read" — rather than an empty set. - /// - [Fact(Timeout = 300_000)] - public async Task ObservePresent_ReadsTheMesh_AndSaysNullWhenItCannot() - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - var persistence = Mesh.ServiceProvider.GetRequiredService(); - - await CatalogLayoutAreas.InstallOrUpdate( - Mesh, new FixedSource(Files()), "HEAD", Candidate(), logger) - .Should().Within(180.Seconds()).Emit("the install is the precondition", - cancellationToken: TestContext.Current.CancellationToken); - (await WaitForNode(GuidePath, present: true)).Should().BeTrue(); - - var paths = new[] { GuidePath, OtherPath }; - var present = await InstallCompleteness - .ObservePresent(persistence, Mesh.JsonSerializerOptions, paths) - .Timeout(60.Seconds()).Await(TestContext.Current.CancellationToken); - present.Should().NotBeNull("the adapter is registered and the paths exist, so this is a " - + "real observation and not a failed read"); - present!.Should().Contain(GuidePath).And.Contain(OtherPath); - - await meshService.DeleteNode(GuidePath).Should().Within(60.Seconds()).Emit("precondition", - cancellationToken: TestContext.Current.CancellationToken); - (await WaitForNode(GuidePath, present: false)).Should().BeTrue(); - - var afterLoss = await Observable.Interval(TimeSpan.FromMilliseconds(100)).StartWith(0L) - .SelectMany(_ => InstallCompleteness - .ObservePresent(persistence, Mesh.JsonSerializerOptions, paths)) - .Where(p => p is not null && !p.Contains(GuidePath)) - .FirstAsync().Timeout(120.Seconds()).Await(TestContext.Current.CancellationToken); - afterLoss!.Should().Contain(OtherPath, - "the control: the surviving node is still observed, so the absence above is about " - + "Guide and not about a read that stopped working"); - - // 🚨 No adapter ⇒ null, NEVER an empty set. An empty set says "the mesh holds none of - // them", which would make every declared file look absent and re-fetch the package. - (await InstallCompleteness - .ObservePresent(null, Mesh.JsonSerializerOptions, paths) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken)) - .Should().BeNull("'there is no adapter' and 'the mesh holds nothing' are different " - + "facts, and spelling them alike is what this whole type exists to prevent"); - } - - /// - /// A package source serving one fixed generation — the same shape as - /// , but listing the INCREMENTAL package so a listing read cannot - /// silently answer about the other fixture's package. - /// - private sealed class IncrSource(IReadOnlyList files) : IPackageSource - { - public IObservable> ListPackages(string gitRef) => - Observable.Return>([IncrCandidate(IncrHashV1)]); - - public IObservable> FetchPackageFiles( - PackageManifest package, string gitRef) => Observable.Return(files); - } - - // ══════════════════════════════════════════════════════════════════════════ - // A fetch shortfall must never reach the record - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The shortfall FAILS the incremental update; it does not merely report it. - /// InstallNodeRepoDelta stamps the new manifest as the next baseline unconditionally, so - /// a file that never travelled — while its OLD node is still present, which is the ordinary - /// case — would leave the record claiming the NEW hash over OLD content and every later update - /// of that module diffing clean and skipping it forever. The caller turns this throw into a - /// full install, so the user still gets the package. - /// - [Fact(Timeout = 60_000)] - public void AFileThatDidNotTravel_FailsTheIncrementalUpdate_RatherThanStampingTheRecord() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - var wanted = new[] { "Guide/index.json", "Guide/Overview.md" } - .ToImmutableHashSet(StringComparer.Ordinal); - var fetched = new List { new("Guide/index.json", "{}") }; - - var act = () => CatalogLayoutAreas.EnsureFetchComplete("Acme.Guide", wanted, fetched, null); - - act.Should().Throw( - "a requested file that did not travel must not reach WriteInstalledRecord — the " - + "record would declare content nothing ever wrote, and the next update would " - + "diff clean and skip it permanently") - .WithMessage("*full install required*") - .And.WithMessage("*Guide/Overview.md*", - "the operator has to be told WHICH file did not travel; a shortfall that names " - + "nothing sends them to the whole package"); - } - - /// - /// The control for the case above: when everything asked for arrives, the incremental update - /// proceeds. Without this, a guard that threw unconditionally would pass the test above and - /// turn every update into a full install. - /// - [Fact(Timeout = 60_000)] - public void AFetchThatReturnedEverythingAskedFor_LetsTheIncrementalUpdateProceed() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - var wanted = new[] { "Guide/index.json" }.ToImmutableHashSet(StringComparer.Ordinal); - var fetched = new List { new("Guide/index.json", "{}") }; - - var act = () => CatalogLayoutAreas.EnsureFetchComplete("Acme.Guide", wanted, fetched, null); - - act.Should().NotThrow( - "the incremental path is the fast path; it must stay available when the source served " - + "every file that was asked for"); - } - - /// - /// 🚨 The shortfall is computed with NO logger. Gating the detection on a logger existing would - /// make the guard vanish exactly where diagnostics are off — which is where a silently stale - /// install record is least likely to be noticed. - /// - [Fact(Timeout = 60_000)] - public void TheShortfallIsDetected_EvenWithNoLogger() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - var wanted = new[] { "Guide/index.json", "Guide/Gone.md" } - .ToImmutableHashSet(StringComparer.Ordinal); - var fetched = new List { new("Guide/index.json", "{}") }; - - var act = () => CatalogLayoutAreas.EnsureFetchComplete("Acme.Guide", wanted, fetched, logger: null); - - act.Should().Throw( - "detection must not depend on someone listening"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // #4429 — a module SOURCE rides the bundle, never the content fetch - // ══════════════════════════════════════════════════════════════════════════ - - private const string MixedPkg = "MixedSourcePkg"; - private const string MixedModule = "MeshWeaver.MixedSourcePkg"; - private const string MixedHashV1 = "5555555555555555"; - private const string MixedHashV2 = "6666666666666666"; - private const string MixedHashV3 = "7777777777777777"; - private const string MixedSourceFile = $"src/{MixedModule}/CourseAssetService.cs"; - private const string MixedGuideFile = $"{MixedPkg}/Guide.md"; - private const string MixedGuidePath = $"{MixedPkg}/Guide"; - private const string MixedManifestFile = $"{MixedPkg}/{ModuleManifest.FileName}"; - - private static PackageManifest MixedCandidate(string moduleVersion) => new() - { - Id = MixedPkg, - Name = MixedPkg, - Kind = PackageKind.NodeRepo, - TargetPartition = MixedPkg, - SourceFolder = MixedPkg, - Module = MixedModule, - Version = "1.0.0", - ModuleVersion = moduleVersion, - }; - - /// - /// The repository at one generation — the WHOLE tree, src/ included, which is what the - /// registry's git read sees. The lock is the shape gen-manifests.py writes for a MIXED - /// package (Plugins#878): node files under {Id}/ and the module's own sources under - /// src/<Module>/ in ONE files map, so a source-only commit moves the module - /// version. Edu/manifest.lock on 2026-09-15 carried 117 Edu/… entries and 273 - /// src/… ones. - /// - private static RepoSnapshot MixedRepo( - string moduleVersion, string sourceHash, string guideHash, string guideBody) => - new($"commit-{moduleVersion}", - [ - new RepoFile(MixedManifestFile, $$""" - { - "module": "{{MixedPkg}}", - "moduleVersion": "{{moduleVersion}}", - "version": "1.0.0", - "files": { - "{{MixedPkg}}/index.json": "aaa", - "{{MixedGuideFile}}": "{{guideHash}}", - "{{MixedSourceFile}}": "{{sourceHash}}" - } - } - """), - new RepoFile($"{MixedPkg}/index.json", $$""" - { - "id": "{{MixedPkg}}", - "path": "{{MixedPkg}}", - "nodeType": "Space", - "name": "Mixed package", - "state": "Active" - } - """), - new RepoFile(MixedGuideFile, guideBody), - // In the repository, and — by construction — never in what the content source serves. - new RepoFile(MixedSourceFile, - $"namespace {MixedModule};\n\npublic sealed class CourseAssetService {{ }} // {sourceHash}\n"), - ]); - - /// - /// The PRODUCTION node-repo source, observed. Every fetch the installer actually runs is - /// recorded on SUBSCRIBE: an unfiltered one is a full install (null), a filtered one - /// carries the paths it asked for. The filtering itself is 's - /// own ({Id}/… only) and the interface default's — nothing here decides what is served. - /// - private sealed class ObservedSource(IPackageSource inner) : IPackageSource - { - private readonly ConcurrentQueue?> fetches = new(); - - /// Every fetch run so far, in order; null = the whole package. - public IReadOnlyList?> Fetches => fetches.ToList(); - - public IObservable> ListPackages(string gitRef) => - inner.ListPackages(gitRef); - - public IObservable> FetchPackageFiles( - PackageManifest package, string gitRef) => - Observable.Defer(() => - { - fetches.Enqueue(null); - return inner.FetchPackageFiles(package, gitRef); - }); - - public IObservable> FetchPackageFiles( - PackageManifest package, string gitRef, IReadOnlyCollection? paths) => - Observable.Defer(() => - { - fetches.Enqueue(paths?.ToImmutableList()); - return inner.FetchPackageFiles(package, gitRef, paths); - }); - } - - /// - /// 🚨 THE REGRESSION (MeshWeaver#4429). A mixed package whose update changes ONLY a - /// module source must update incrementally — it must not ask the content source for the source - /// file, and must not fall back to a full install. - /// - /// Fails on main. There, IncrementalUpdate fetches every key of - /// delta.AddedOrChangedFiles, and a changed src/<Module>/… is one. The - /// content source serves only {Id}/… — filters to the - /// package folder, and the registry's /api/plugins/files answers from it — so the fetch - /// returns 0 of 1, EnsureFetchComplete throws (correctly: it guards a real shortfall), and - /// the caller falls back to a FULL install. memex.systemorph.com, 2026-09-15T14:12Z: - /// Updating Edu incrementally: the source returned 0 of the 1 file(s) asked for — - /// [src/MeshWeaver.Courses/CourseAssetService.cs] did NOT travel, on two pods. Every mixed - /// package pays it on every update that touches a source — and a module's bundle carries its - /// riding siblings too, so an engine change moves 234 src/MeshWeaver.AI/… entries in the - /// Edu lock alone. - /// - /// Generation three is the control in the other direction: a CONTENT file that moves - /// beside a source is still asked for and still lands, so the fix narrows the fetch to what the - /// source serves and does not stop fetching. - /// - [Fact(Timeout = 300_000)] - public async Task AModuleSourceChange_UpdatesIncrementally_WithoutAskingTheContentSourceForIt() - { - var ct = TestContext.Current.CancellationToken; - var generation = MixedRepo(MixedHashV1, "src-1", "g-1", "# Guide\n\nGeneration one."); - var source = new ObservedSource( - new NodeRepoPackageSource((_, _, _, _) => Observable.Return(generation), repoUrl: "local")); - - await CatalogLayoutAreas.InstallOrUpdate( - Mesh, source, "HEAD", MixedCandidate(MixedHashV1), new RecordingLogger()) - .Should().Within(TestTimeouts.CrossSilo) - .Emit("the first install must land before anything about an update can be measured", - cancellationToken: ct); - (await WaitForNode(MixedGuidePath, present: true)).Should().BeTrue( - "the package really installed — a green update over an empty partition would prove nothing"); - - var installed = await ReadRecord(MixedPkg); - installed.Should().NotBeNull("the installer stamps an install record"); - installed!.ModuleVersion.Should().Be(MixedHashV1); - installed.InstalledFiles.Should().NotBeNull().And.ContainKey(MixedSourceFile, - "the record keeps the module source's token — that is what makes the NEXT diff name a " - + "source-only change at all; without it this test would reach a full install for a " - + "different reason (no baseline) and measure nothing about #4429"); - installed.InstalledFiles![MixedSourceFile].Should().Be("src-1"); - - // ── Generation two: ONLY the module source moves — the Edu 1.10.9 → 1.10.10 shape. - generation = MixedRepo(MixedHashV2, "src-2", "g-1", "# Guide\n\nGeneration one."); - var before = source.Fetches.Count; - var sourceOnlyLog = new RecordingLogger(); - await CatalogLayoutAreas.InstallOrUpdate( - Mesh, source, "HEAD", MixedCandidate(MixedHashV2), sourceOnlyLog) - .Should().Within(TestTimeouts.CrossSilo) - .Emit("a source-only update has to complete", cancellationToken: ct); - var update = source.Fetches.Skip(before).ToList(); - - update.Should().Contain(f => f != null && f.Contains(MixedManifestFile), - "the control: the incremental exit reads manifest.lock first — without that read the " - + "update took some other exit and nothing below would be about it"); - update.Count(f => f is null).Should().Be(0, - "THE assertion: a change to a module source must not force a FULL install. On main the " - + "source is asked for, 0 of 1 comes back, and the update falls back to a full install " - + "— every mixed package, on every update that touches its sources (MeshWeaver#4429). " - + $"Fetches run: [{string.Join(" | ", update.Select(f => f is null ? "FULL" : string.Join(", ", f)))}]"); - update.Where(f => f is not null).SelectMany(f => f!) - .Should().NotContain(p => PackageInstaller.IsModuleSourcePath(p), - "a module source is compiled into the module bundle and delivered by the module " - + "lane; the content source serves only the package folder, so asking it for a " - + "source can only ever come back empty"); - sourceOnlyLog.Entries(LogLevel.Error).Should().NotContain(m => m.Contains("did NOT travel"), - "nothing was asked for that the source does not serve, so nothing can be short"); - sourceOnlyLog.Entries(LogLevel.Warning) - .Should().NotContain(m => m.Contains("falling back to full install")); - - var afterSourceOnly = await ReadRecord(MixedPkg); - afterSourceOnly!.ModuleVersion.Should().Be(MixedHashV2, - "the update moved the package forward on the incremental exit"); - afterSourceOnly.InstalledFiles![MixedSourceFile].Should().Be("src-2", - "the record still declares the source at its NEW token — true, because the module " - + "bundle is what carries it — so the next diff is clean and does not ask again"); - - // ── Generation three: a content file moves BESIDE a source. It must still be fetched. - generation = MixedRepo(MixedHashV3, "src-3", "g-3", "# Guide\n\nGeneration THREE."); - before = source.Fetches.Count; - var both = await CatalogLayoutAreas.InstallOrUpdate( - Mesh, source, "HEAD", MixedCandidate(MixedHashV3), new RecordingLogger()) - .Timeout(TestTimeouts.CrossSilo) - .Await(ct); - var mixedUpdate = source.Fetches.Skip(before).ToList(); - - mixedUpdate.Count(f => f is null).Should().Be(0, - "a content-and-source update stays incremental too"); - var asked = mixedUpdate.Where(f => f is not null).SelectMany(f => f!).ToList(); - asked.Should().Contain(MixedGuideFile, - "the negative control of the filter: the CONTENT file that moved is still asked for — " - + "excluding module sources narrows the fetch to what the source serves, it must not " - + "stop fetching what it does serve"); - asked.Should().NotContain(p => PackageInstaller.IsModuleSourcePath(p)); - both.WrittenPaths.Should().Contain(MixedGuidePath, - "the changed content node was written by the incremental update"); - var afterBoth = await ReadRecord(MixedPkg); - afterBoth!.ModuleVersion.Should().Be(MixedHashV3); - afterBoth.InstalledFiles![MixedGuideFile].Should().Be("g-3"); - afterBoth.InstalledFiles![MixedSourceFile].Should().Be("src-3"); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstallLegTimeoutTest.cs b/test/Memex.Portal.Shared.Test/InstallLegTimeoutTest.cs deleted file mode 100644 index 383922f039..0000000000 --- a/test/Memex.Portal.Shared.Test/InstallLegTimeoutTest.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A bound inside the install chain NAMES the leg it bounded when it expires (MeshWeaver#2254, -/// #5826). Rx's bare Timeout faults with "The operation has timed out." — a message that -/// named no leg in a chain carrying five such bounds, which is how a boot logged a failed package -/// nobody could attribute to a wait. -/// -/// The five legs wraps all complete -/// inside their bound in every other suite, so without this test the expiry branch never ran in CI -/// and a regression back to the bare form would pass. The bound here is small and the source never -/// emits, so the branch under test is the only way out. -/// -public class InstallLegTimeoutTest -{ - private const string Leg = "reading the install record Plugins/Anthropic"; - - /// The terminal of : null when it emitted, else its fault. - private static Task TerminalOf(IObservable source) => - source.Select(_ => (Exception?)null) - .Catch((Exception ex) => Observable.Return(ex)) - .FirstAsync() - // The outer wait is the test's own bound; it must never be what ends the sequence. - .Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - - [Fact] - public async Task AnExpiredBound_FaultsNamingTheLegAndTheBudget() - { - var fault = await TerminalOf( - Observable.Never().TimeoutNamingTheLeg(TimeSpan.FromMilliseconds(200), Leg)); - - var timeout = fault.Should().BeOfType( - "the bound keeps its exception type — callers that catch a TimeoutException still do").Subject; - timeout.Message.Should().Be($"Install leg timed out after 0.2s: {Leg}.", - "the fault says WHICH wait expired and after how long; Rx's bare 'The operation has " - + "timed out.' is the #2254 symptom this helper exists to remove"); - } - - [Fact] - public async Task ASourceThatAnswersInsideTheBound_PassesThroughUntouched() - { - var value = await Observable.Return(42) - .TimeoutNamingTheLeg(TestTimeouts.Convergence, Leg) - .FirstAsync() - .Await(TestContext.Current.CancellationToken); - - value.Should().Be(42); - } - - [Fact] - public async Task ASourceThatFaultsInsideTheBound_KeepsItsOwnFault() - { - var fault = await TerminalOf( - Observable.Throw(new InvalidOperationException("the read itself failed")) - .TimeoutNamingTheLeg(TestTimeouts.Convergence, Leg)); - - fault.Should().BeOfType( - "only an EXPIRED bound is renamed; a real failure must not be reported as a timeout") - .Subject.Message.Should().Be("the read itself failed"); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstallNeverInheritsRepoCompileVerdictTest.cs b/test/Memex.Portal.Shared.Test/InstallNeverInheritsRepoCompileVerdictTest.cs deleted file mode 100644 index d850d0db66..0000000000 --- a/test/Memex.Portal.Shared.Test/InstallNeverInheritsRepoCompileVerdictTest.cs +++ /dev/null @@ -1,151 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A fresh install never inherits a repo file's compile verdict (MeshWeaver#3474). -/// -/// The NodeType content ownership split — the repo owns the authored definition, the mesh owns -/// the compile bookkeeping () — was enforced on export (strip) -/// and on update (keep the live node's values). A package installed into a FRESH mesh has no live node, -/// and every install path wrote the file's embedded verdict verbatim as the type's initial live state. -/// -/// Measured 2026-09-06 on the Education disposable meshes (portal bbcb22f25): the -/// MeshWeaver.Plugins node files GitSync had written on 2026-07-18 — before the export strip existed — -/// carried compilationStatus: Ok, compiledFrameworkVersion: eec04a05… (a framework no -/// portal runs), latestAssemblyPath: Store_Catalog/v201-eec04a05-….dll (a path into a mesh that -/// no longer exists) and, on Store's core types, a standing requestedReleaseForce: true from -/// 2026-07-19. Every fresh mesh installed them verbatim: Store's core types framework-stale-kicked into a -/// FORCED live-source compile at every boot, the boot sweep then adopted the shipped prebuilt over that -/// compile, and every Edu/Exercise instance came up bound to a foreign assembly path and never -/// answered — every Education mesh gate red, and nothing in the log named it, because the file looked -/// like a type that had already compiled fine. -/// -/// This test installs exactly that file shape through the real installer and reads the type back: -/// the file's verdict must not be there, the authored definition must, and the mesh must then reach its -/// OWN verdict — a build whose framework is this process's, not the file's. -/// -public class InstallNeverInheritsRepoCompileVerdictTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - private const string Package = "VerdictPkg"; - private const string TypePath = $"{Package}/Catalog"; - private const string JulyFramework = "eec04a058bd644f1b2b5eda01e952b5c"; - private const string JulyAssemblyPath = "Store_Catalog/v201-eec04a05-870bdb050e1e.dll"; - private const string JulyRequestedAt = "2026-07-19T11:32:29.8663063+00:00"; - - /// Store/Catalog/index.json as MeshWeaver.Plugins main shipped it on 2026-09-06, - /// reduced to a configuration this mesh can compile. - private const string RepoFileWithVerdict = """ - { - "id": "Catalog", - "namespace": "VerdictPkg", - "path": "VerdictPkg/Catalog", - "nodeType": "NodeType", - "name": "Catalog", - "state": "Active", - "content": { - "$type": "NodeTypeDefinition", - "description": "authored description", - "configuration": "config => config", - "compilationStatus": "Ok", - "compiledFrameworkVersion": "eec04a058bd644f1b2b5eda01e952b5c", - "lastCompileStartedAt": "2026-07-19T11:32:00+00:00", - "lastCompileSucceededAt": "2026-07-19T11:32:29+00:00", - "lastCompiledVersion": 201, - "latestReleasePath": "Store/Catalog/Release/20260719113229-abcdefgh", - "latestAssemblyCollection": "local", - "latestAssemblyPath": "Store_Catalog/v201-eec04a05-870bdb050e1e.dll", - "requestedReleaseForce": true, - "requestedReleaseAt": "2026-07-19T11:32:29.8663063+00:00", - "lastReleaseRequestHandledAt": "2026-07-19T11:32:29.8663063+00:00" - } - } - """; - - private static void AssertTheFilesVerdictIsAbsent(NodeTypeDefinition def, string when) - { - def.Configuration.Should().Be("config => config", $"the authored configuration is kept ({when})"); - def.Description.Should().Be("authored description", $"the authored description is kept ({when})"); - def.CompiledFrameworkVersion.Should().NotBe(JulyFramework, - $"a framework identity this mesh never ran must not be recorded as the type's build ({when})"); - def.LatestAssemblyPath.Should().NotBe(JulyAssemblyPath, - $"an assembly path into a mesh that no longer exists must not land ({when})"); - def.RequestedReleaseForce.Should().BeFalse( - $"a months-old FORCED release must not make this mesh compile from source ({when}; #2824 honours the flag)"); - def.RequestedReleaseAt.Should().NotBe(DateTimeOffset.Parse(JulyRequestedAt), - $"the file's release request is not this mesh's ({when})"); - def.LastReleaseRequestHandledAt.Should().NotBe(DateTimeOffset.Parse(JulyRequestedAt), - $"the file's handled stamp is not this mesh's ({when})"); - def.LastCompiledVersion.Should().NotBe(201, $"the file's compile version is not this mesh's ({when})"); - } - - [Fact(Timeout = 300_000)] - public async Task InstallingAPackageWhoseNodeFileCarriesAVerdict_LandsTheAuthoredDefinitionOnly() - { - var result = await PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = Package, - Name = Package, - Kind = PackageKind.NodeRepo, - TargetPartition = Package, - SourceFolder = Package, - Version = "1.0.0", - }, - [ - new PackageFile($"{Package}.md", $"# {Package}"), - new PackageFile($"{TypePath}.json", RepoFileWithVerdict), - ], - "HEAD") - .Should().Within(180.Seconds()) - .Emit("the install itself must complete before anything about the type can be read", cancellationToken: TestContext.Current.CancellationToken); - result.WrittenPaths.Should().Contain(TypePath, "the type node is what this test is about"); - - // 1. As installed: none of the file's verdict landed, all of the authored definition did. - var installed = await Mesh.GetWorkspace().GetMeshNodeStream(TypePath) - .Where(n => n?.ContentAs(Mesh.JsonSerializerOptions) is not null) - .FirstAsync() - .Timeout(60.Seconds()) - .Await(TestContext.Current.CancellationToken); - var installedDef = installed!.ContentAs(Mesh.JsonSerializerOptions)!; - Output.WriteLine( - $"as installed: status={installedDef.CompilationStatus} framework={installedDef.CompiledFrameworkVersion ?? "(null)"} " - + $"assembly={installedDef.LatestAssemblyPath ?? "(null)"} force={installedDef.RequestedReleaseForce}"); - AssertTheFilesVerdictIsAbsent(installedDef, "as installed"); - - // 2. The mesh then reaches its OWN verdict for the type — a build of THIS framework, never - // the file's. This is the difference between "stale green" and a type that actually runs. - await Mesh.GetWorkspace().GetMeshNodeStream(TypePath) - .Should().Within(180.Seconds()) - .Match( - n => n.ContentAs(Mesh.JsonSerializerOptions) is - { CompilationStatus: CompilationStatus.Ok, CompiledFrameworkVersion: { Length: > 0 } } def - && def.CompiledFrameworkVersion != JulyFramework - && !string.IsNullOrEmpty(def.LatestAssemblyPath) - && def.LatestAssemblyPath != JulyAssemblyPath, - "the type must compile on THIS mesh and record this mesh's framework and assembly — " - + "the file's July verdict claimed a build that did not exist here, which is exactly the " - + "\"stale green\" that parks a type on a cold cache", cancellationToken: TestContext.Current.CancellationToken); - var compiled = (await Mesh.GetWorkspace().GetMeshNodeStream(TypePath).FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken))! - .ContentAs(Mesh.JsonSerializerOptions)!; - AssertTheFilesVerdictIsAbsent(compiled, "after the mesh compiled it"); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstallRecordFollowsItsPartitionTest.cs b/test/Memex.Portal.Shared.Test/InstallRecordFollowsItsPartitionTest.cs deleted file mode 100644 index b7430d37d2..0000000000 --- a/test/Memex.Portal.Shared.Test/InstallRecordFollowsItsPartitionTest.cs +++ /dev/null @@ -1,234 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 An install record must not outlive the partition it points at -/// (Systemorph/MeshWeaver#3451, the second half). -/// -/// A package's install record lives at Plugins/{packageId} — in the RECORDS partition, -/// never in the package's own — so deleting the installed space left the record behind, aimed at a -/// partition that no longer exists. Nothing reconciled it, and -/// InstalledPackageRepairService re-drove PackageInstaller.EnsureDeclaredAccess at that -/// dead partition on EVERY boot: a permanent per-boot error for every partition anyone had ever -/// deleted (21 of them on one portal when this was filed), and the writer that re-armed the -/// resurrection race on every restart. -/// -/// The fix is referential integrity maintained by the side that owns the reference. Core -/// deliberately knows nothing about Plugins/Package — teaching the delete pipeline about it -/// would re-introduce exactly the coupling #3436 removed — so the plugin catalog registers its OWN -/// INodePostDeletionHandler, matching the same structural partition-root predicate the -/// platform's partition teardown uses. -/// -public class InstallRecordFollowsItsPartitionTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// The Store/Plugin stand-in: a partition-root NodeType that declares no - /// OwnsPartition, because what provisioned its partition was the installer. - private const string PluginLikeNodeType = "TestStore/InstalledPlugin"; - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .AddMeshNodes(new MeshNode(PluginLikeNodeType) - { - Name = "Installed plugin root", - NodeType = MeshNode.NodeTypePath, - State = MeshNodeState.Active, - Content = new NodeTypeDefinition { DefaultNamespace = "", RestrictedToNamespaces = [""] }, - }); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - private IStorageAdapter Persistence => Mesh.ServiceProvider.GetRequiredService(); - - private static string NewPackageId() => "recpkg" + Guid.NewGuid().ToString("N")[..8]; - - private static string RecordPath(string packageId) => - $"{PackageInstaller.InstalledPartition}/{packageId}"; - - /// - /// Writes what an install leaves behind: the partition root and one child so the delete has a - /// real subtree to drain, then the record in the Plugins partition. This mirrors the - /// installer's order: it publishes the installed content before its inventory record. - /// - private async Task Install(CancellationToken cancellationToken) - { - var packageId = NewPackageId(); - var manifest = new PackageManifest - { - Id = packageId, - Name = $"Package {packageId}", - Version = "1.0.0", - TargetPartition = packageId, - }; - - await CreateAsSystem(new MeshNode(packageId) - { - Name = manifest.Name, - NodeType = PluginLikeNodeType, - State = MeshNodeState.Active, - }, cancellationToken); - await CreateAsSystem(new MeshNode("Page", packageId) - { - Name = "Page", - NodeType = "Markdown", - State = MeshNodeState.Active, - Content = new MarkdownContent { Content = "# page" }, - }, cancellationToken); - - // The hosted repair service lists install records during fixture startup. Publishing the - // record before its root and files briefly exposes an incomplete package to that pass; it - // can race the fixture's first root create. The real installer writes its record last. - await Access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode( - MeshNode.FromPath(RecordPath(packageId)) with - { - NodeType = PackageInstaller.PackageNodeType, - Name = manifest.Name, - State = MeshNodeState.Active, - Content = manifest, - })) - .Timeout(TestTimeouts.Convergence).Await(cancellationToken); - - (await Exists(RecordPath(packageId), cancellationToken)).Should().BeTrue( - "the fixture must actually record the install, or the assertions below prove nothing"); - Output.WriteLine($"installed '{packageId}' with record {RecordPath(packageId)}"); - return packageId; - } - - private async Task CreateAsSystem(MeshNode node, CancellationToken cancellationToken = default) - { - var response = await Access - .RunAsSystem(() => ObserveNodeOperation(new CreateNodeRequest(node))) - .FirstAsync() - .Select(d => d.Message) - .Timeout(TestTimeouts.Convergence) - .Await(cancellationToken); - response.Success.Should().BeTrue($"the fixture write '{node.Path}' must land: {response.Error}"); - return response; - } - - private async Task DeleteAsSystem(string path) - { - var response = await Access - .RunAsSystem(() => ObserveNodeOperation( - new DeleteNodeRequest(path) - { - Recursive = true, - IncludeSatellites = true, - ConfirmWarnings = true, - DeletedBy = WellKnownUsers.System, - })) - .FirstAsync() - .Select(d => d.Message) - .Timeout(TestTimeouts.CrossSilo) - .Await(TestContext.Current.CancellationToken); - Output.WriteLine($"delete {path} success={response.Success} error={response.Error}"); - response.Success.Should().BeTrue($"the delete itself must succeed: {response.Error}"); - } - - private async Task Exists(string path, CancellationToken cancellationToken = default) => - await Persistence.Exists(path).FirstAsync().Timeout(TestTimeouts.Convergence).Await(cancellationToken); - - /// - /// 🚨 THE FALSIFIER. On the pre-fix code the delete succeeds, the partition's nodes are - /// gone, and Plugins/{packageId} is still there — pointing at nothing, and re-asserting - /// {packageId}/_Policy on every boot for the rest of the instance's life. - /// - [Fact(Timeout = 240000)] - public async Task DeletingAnInstalledPartition_RemovesItsInstallRecord() - { - var packageId = await Install(TestContext.Current.CancellationToken); - - await DeleteAsSystem(packageId); - - (await Exists(RecordPath(packageId))).Should().BeFalse( - $"'{RecordPath(packageId)}' names a partition that no longer exists. A record that " - + "outlives its partition is a dangling reference the boot repair pass keeps writing " - + "into forever — a permanent per-boot error for every partition anyone ever deleted, " - + "and the writer that re-armed the resurrection race on every restart (#3451)"); - } - - /// - /// The positive control that keeps the assertion above honest: a NESTED node is not a partition - /// root, and deleting one must never remove the install record. A handler that fired on every - /// delete would pass the test above while uninstalling a package on its first page deletion. - /// - [Fact(Timeout = 240000)] - public async Task DeletingANestedNode_LeavesTheInstallRecordAlone() - { - var packageId = await Install(TestContext.Current.CancellationToken); - - await DeleteAsSystem($"{packageId}/Page"); - - (await Exists(RecordPath(packageId))).Should().BeTrue( - "only a partition ROOT ends an install — deleting a page out of an installed package " - + "leaves the package installed"); - } - - /// - /// The second control: the handler must remove the record for the partition that was deleted and - /// no other. A handler that removed every record on any partition delete would pass both tests - /// above. - /// - [Fact(Timeout = 240000)] - public async Task DeletingADifferentPartition_LeavesOtherInstallRecordsAlone() - { - var kept = await Install(TestContext.Current.CancellationToken); - var deleted = await Install(TestContext.Current.CancellationToken); - - await DeleteAsSystem(deleted); - - (await Exists(RecordPath(deleted))).Should().BeFalse("its partition is gone"); - (await Exists(RecordPath(kept))).Should().BeTrue( - $"'{kept}' was not touched — the teardown must be scoped to the partition that was " - + "actually deleted"); - } - - /// - /// The registration itself, asserted on the mesh this repository ships and stated WITHOUT naming - /// the new type — so this file compiles, and fails, against the pre-fix source too. Two - /// structural teardowns must reach an arbitrary partition root (the platform's backing-store - /// drop and the catalog's install-record removal), and only ONE of them may reach the records - /// partition itself. - /// - [Fact(Timeout = 60000)] - public void TwoStructuralTeardownsCoverAnArbitraryPartitionRoot() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - var handlers = Mesh.ServiceProvider.GetServices().ToList(); - var probe = new MeshNode("ProbePartition") - { - NodeType = "TestProbe/UnknownInMeshType", - State = MeshNodeState.Active, - }; - var recordsPartition = new MeshNode(PackageInstaller.InstalledPartition) - { - NodeType = "Space", - State = MeshNodeState.Active, - }; - - handlers.Count(h => h.Matches(probe)).Should().Be(2, - "a partition root fires TWO structural teardowns — the platform's backing-store drop and " - + "the catalog's install-record removal (#3451) — and a NodeType neither of them could " - + "have enumerated must reach both"); - handlers.Count(h => h.Matches(recordsPartition)).Should().Be(1, - "the RECORDS partition is excluded from the install-record teardown: its records go with " - + "it, and cascading each one first would race the drain that just removed them — only " - + "the platform's backing-store drop applies there"); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstallRecordUpdateRacingDeleteTest.cs b/test/Memex.Portal.Shared.Test/InstallRecordUpdateRacingDeleteTest.cs deleted file mode 100644 index cd153aed83..0000000000 --- a/test/Memex.Portal.Shared.Test/InstallRecordUpdateRacingDeleteTest.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 An install-record update racing the record's delete must never leave the record behind. -/// -/// The boot repair pass's install-record migration () -/// updates every reminder-only record through GetMeshNodeStream(record).Update; a partition -/// delete (InstallRecordPartitionTeardownHandler) or an uninstall removes the same record. -/// When the update's patch committed at the record's owner after the delete had removed the row, -/// the owner's post-commit flush wrote it straight back — the delete had already answered -/// removed=true. Measured before the fix: 7 of 40 rounds of exactly this shape left the -/// record in storage; it is what reddened InstallRecordFollowsItsPartitionTest and -/// PackageUninstallTest in CI. The deterministic half of the guard is -/// MeshWeaver.Graph.Test.UpdateNeverResurrectsADeletedNodeTest. -/// -public class InstallRecordUpdateRacingDeleteTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - [Fact(Timeout = 240000)] - public async Task TheMigrationsUpdateRacingTheRecordsDelete_NeverLeavesTheRecordBehind() - { - var ct = TestContext.Current.CancellationToken; - var access = Mesh.ServiceProvider.GetRequiredService(); - var storage = Mesh.ServiceProvider.GetRequiredService(); - var survivors = 0; - for (var i = 0; i < 40; i++) - { - var id = "racepkg" + Guid.NewGuid().ToString("N")[..8]; - var path = $"{PackageInstaller.InstalledPartition}/{id}"; - await access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode(MeshNode.FromPath(path) with - { - NodeType = PackageInstaller.PackageNodeType, - Name = id, - State = MeshNodeState.Active, - Content = new PackageManifest { Id = id, Name = id, Version = "1.0.0", TargetPartition = id }, - })) - .Timeout(TestTimeouts.Convergence).Await(ct); - // Warm the owner, so the migration's patch meets a live per-node hub holding the record. - await access.RunAsSystem(() => Mesh.GetMeshNodeStream(path)) - .Where(n => n is not null).FirstAsync() - .Timeout(TestTimeouts.Convergence).Await(ct); - - var update = access.RunAsSystem(() => Mesh.GetMeshNodeStream(path) - .Update(PackageAutoUpdateMigration.Migrated)) - .Take(1) - .Select(_ => "applied") - // Only the refusals an update racing a delete can legitimately get are absorbed, and - // each says the node is gone: a NotFound from the update (the owner's NACK — the - // post-commit flush's refusal arrives as exactly this — or the router's typed - // absence once the patch met a vanished node, #6626), and a routing NotFound on the - // update's base read. Every other fault — an unclassified Unknown included, access - // denied, an unreachable owner, a deserialization or validation failure, a conflict — - // propagates and fails the test, as does any other exception type, so a broken setup - // cannot pass as "no survivors". - .Catch((Exception ex) => ex switch - { - MeshNodeStreamException { Error.Code: MeshNodeErrorCode.NotFound } => - Observable.Return("refused: NotFound"), - DeliveryFailureException { Failure.ErrorType: ErrorType.NotFound } => - Observable.Return("refused: routing NotFound"), - _ => Observable.Throw(ex), - }) - .Replay(); - using var inFlight = update.Connect(); - - var removed = await access.RunAsSystem(() => NodeFactory.DeleteNode(path)) - .Timeout(TestTimeouts.Convergence).Await(ct); - var outcome = await update.Timeout(TestTimeouts.Convergence).Await(ct); - var survived = await storage.Exists(path).FirstAsync() - .Timeout(TestTimeouts.Convergence).Await(ct); - Output.WriteLine($"round {i}: removed={removed} update={outcome} survived={survived}"); - removed.Should().BeTrue( - "the premise of every round: the record existed and THIS delete removed it"); - if (survived) - survivors++; - } - - survivors.Should().Be(0, - "a delete that answered success must leave nothing behind — the migration's update " - + "committing at the record's owner after the delete must not write the record back"); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstallRecordsTheServedCommitTest.cs b/test/Memex.Portal.Shared.Test/InstallRecordsTheServedCommitTest.cs deleted file mode 100644 index a352267f82..0000000000 --- a/test/Memex.Portal.Shared.Test/InstallRecordsTheServedCommitTest.cs +++ /dev/null @@ -1,256 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#3848 / #4413 — policy record-served-commit-on-install: the plugin -/// registry's install response returns the EXACT commit it served, and the install record stores -/// that commit, so the combo verifier can check the installed content against it -/// () instead of the source repository's -/// HEAD. -/// -/// Before the decision the files answer carried no commit and the installer recorded the ref -/// it ASKED for (main) — a moving ref, so the verifier could only fetch HEAD and prove -/// the module hash, which fails for every package changed since its install (17 of 86 on the public -/// instance, run 38416709107). -/// -public class InstallRecordsTheServedCommitTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Package = "ServedCommitPkg"; - private const string Source = "Plugins"; - private const string Repo = "https://github.com/Systemorph/ServedCommitFixture"; - private const string Instance = "served-commit-consumer"; - private static readonly string ServedCommit = new('c', 40); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services.AddSingleton(new FixtureRepoClient())); - - // ── The registry half: the install response names the commit ───────────────────────────────── - - [Fact(Timeout = 300_000)] - public async Task TheRegistryFilesAnswer_NamesTheCommitItServed() - { - var ct = TestContext.Current.CancellationToken; - var key = await RegisterInstance(ct); - var app = await StartRegistryHost(ct); - await using var _ = app; - - using var request = new HttpRequestMessage(HttpMethod.Post, RegistryPackageSource.RoutePrefix + "/files") - { - Content = new StringContent(JsonSerializer.Serialize(new { id = Package }), Encoding.UTF8, "application/json"), - }; - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - using var response = await app.GetTestClient().SendAsync(request, ct); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var fetched = PluginRegistryPayloads.ParseFiles(await response.Content.ReadAsStringAsync(ct)); - fetched.Files.Should().NotBeEmpty("the package's files travel as before"); - fetched.Commit.Should().Be(ServedCommit, - "the install response states the exact commit the registry read the files at"); - } - - /// The wire form both ways: a commit round-trips; an OLD registry's answer (no - /// commit) and a commit that is not a full sha both parse as none. - [Fact] - public void TheFilesPayload_CarriesTheCommit_AndAnOldAnswerCarriesNone() - { - ImmutableList files = [new PackageFile($"{Package}/Doc.md", "# Doc")]; - - PluginRegistryPayloads.ParseFiles(PluginRegistryPayloads.Files(new PackageFetch(files, ServedCommit))) - .Commit.Should().Be(ServedCommit); - PluginRegistryPayloads.ParseFiles(PluginRegistryPayloads.Files(files)) - .Commit.Should().BeNull("an answer without a commit names none"); - PluginRegistryPayloads.Files(new PackageFetch(files, "main")) - .Should().Be(PluginRegistryPayloads.Files(files), - "a ref that is not a commit is never stated as one"); - } - - // ── The consumer half: the install record stores it ───────────────────────────────────────── - - [Fact(Timeout = 300_000)] - public async Task AnInstall_RecordsTheCommitTheSourceServed_NotTheRefItAskedFor() - { - var source = new NodeRepoPackageSource( - (_, _, _, _) => Observable.Return(Snapshot()), Repo); - - var record = await InstallAndReadRecord(source, TestContext.Current.CancellationToken); - - record.InstalledFromRef.Should().Be(ServedCommit, - "the record states the exact commit the source served, so the verifier fetches the " - + "installed content and not whatever 'main' points at by then"); - } - - /// NEGATIVE CONTROL — a source that cannot name a commit (an old registry) records the - /// ref it asked for, exactly as before the decision. - [Fact(Timeout = 300_000)] - public async Task AnInstallFromASourceThatNamesNoCommit_RecordsTheRequestedRef_AsBefore() - { - var record = await InstallAndReadRecord(new NoCommitSource(), TestContext.Current.CancellationToken); - - record.InstalledFromRef.Should().Be("main"); - } - - // ── Harness ────────────────────────────────────────────────────────────────────────────────── - - private async Task InstallAndReadRecord(IPackageSource source, CancellationToken ct) - { - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - await CatalogLayoutAreas.InstallOrUpdate(Mesh, source, "main", Candidate(), logger) - .Timeout(TimeSpan.FromSeconds(180)) - .Await(ct); - - var node = await Mesh.ServiceProvider.GetRequiredService() - .Read($"{PackageInstaller.InstalledPartition}/{Package}", Mesh.JsonSerializerOptions) - .Take(1) - .Timeout(TimeSpan.FromSeconds(60)) - .Await(ct); - var record = node?.ContentAs(Mesh.JsonSerializerOptions); - record.Should().NotBeNull("the installer stamps an install record"); - return record!; - } - - private static PackageManifest Candidate() => new() - { - Id = Package, - Name = Package, - Kind = PackageKind.NodeRepo, - TargetPartition = Package, - SourceFolder = Package, - Version = "1.0.0", - }; - - private static RepoSnapshot Snapshot() => new(ServedCommit, - [ - new RepoFile($"{Package}/index.json", $$""" - { "id": "{{Package}}", "path": "{{Package}}", "nodeType": "Space", "name": "{{Package}}", "state": "Active" } - """), - new RepoFile($"{Package}/Doc.md", "# Doc\n\nThe package's one content node."), - ]); - - private sealed class NoCommitSource : IPackageSource - { - public IObservable> ListPackages(string gitRef) => - Observable.Return>([Candidate()]); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) => - Observable.Return>( - [.. Snapshot().Files.Select(f => new PackageFile(f.Path, f.Content))]); - } - - private Task RegisterInstance(CancellationToken ct) => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection([new KeyValuePair( - $"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:0", $"{Source}/*")]) - .Build()) - .Register("served-commit-owner", "Served Commit Owner", "owner@test.com", Instance, Instance) - .Select(r => r.RawKey) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(60)) - .Await(ct); - - private async Task StartRegistryHost(CancellationToken ct) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Configuration.AddInMemoryCollection( - [ - new KeyValuePair("PluginCatalog:Sources:0:Name", Source), - new KeyValuePair("PluginCatalog:Sources:0:RepoPath", Repo), - new KeyValuePair("PluginCatalog:Sources:0:Ref", "main"), - new KeyValuePair("PluginCatalog:Sources:0:Format", "node-repo"), - ]); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(new InstanceRegistryAuthenticator( - Mesh, Mesh.ServiceProvider.GetRequiredService>())); - var app = builder.Build(); - app.MapPluginRegistry(); - await app.StartAsync(ct); - return app; - } - - /// The git transport: every read of the repository resolves 'main' to - /// — the snapshot sha a real transport reports. - private sealed class FixtureRepoClient : IGitHubRepoClient - { - public IObservable Fetch( - string repositoryUrl, string commitish, string? subdirectory, string accessToken) - => Observable.Return(Snapshot()); - - public IObservable Fetch( - string repositoryUrl, string commitish, string? subdirectory, string accessToken, - Func pathFilter) - => Observable.Return(new RepoSnapshot(ServedCommit, - Snapshot().Files.Where(f => pathFilter(f.Path)).ToImmutableList())); - - public IObservable Push(GitHubPushRequest request) => NotUsed(); - - public IObservable CreateBranch(GitHubCreateBranchRequest request) => NotUsed(); - - public IObservable OpenPullRequest(GitHubOpenPullRequestRequest request) => NotUsed(); - - public IObservable GetPullRequestStatus( - string repositoryUrl, int number, string accessToken) => NotUsed(); - - public IObservable> ListIssues( - string repositoryUrl, GitHubIssueState? state, string accessToken) => NotUsed>(); - - public IObservable GetIssue(string repositoryUrl, int number, string accessToken) => NotUsed(); - - public IObservable CreateIssue(GitHubCreateIssueRequest request) => NotUsed(); - - public IObservable CommentIssue( - string repositoryUrl, int number, string body, string accessToken) => NotUsed(); - - public IObservable SetIssueState( - string repositoryUrl, int number, GitHubIssueState state, string accessToken) => NotUsed(); - - public IObservable> ListPullRequests( - string repositoryUrl, PullRequestStatus? state, string accessToken) => NotUsed>(); - - public IObservable GetPullRequestDetail( - string repositoryUrl, int number, string accessToken) => NotUsed(); - - public IObservable CommentPullRequest( - string repositoryUrl, int number, string body, string accessToken) => NotUsed(); - - public IObservable MergePullRequest(GitHubMergePullRequestRequest request) => NotUsed(); - - private static IObservable NotUsed() => Observable.Throw( - new InvalidOperationException("The served-commit test uses the fetches only.")); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstallTimePrebuiltAdoptionTest.cs b/test/Memex.Portal.Shared.Test/InstallTimePrebuiltAdoptionTest.cs deleted file mode 100644 index aaa8218c8d..0000000000 --- a/test/Memex.Portal.Shared.Test/InstallTimePrebuiltAdoptionTest.cs +++ /dev/null @@ -1,301 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Systemorph/MeshWeaver#3429 — install-time prebuilt adoption for a package installed AFTER -/// BOOT, and the silence that made its failure unreadable. -/// -/// What was measured. MeshWeaver.Education's disposable-mesh e2e mounted 40 bundles at -/// /bundles — including Edu.zip, which the bake had adopted 10/10 under the very -/// framework identity the harness was running. Store installed and adopted 17 assemblies. -/// Three minutes later Edu installed 99 nodes, adopted NOTHING, and its ten NodeTypes went to -/// Roslyn on a mesh that was standing on their bytes. The log carried no adoption line, no decline -/// line, and no reason: a completed seed that covered zero said exactly as much as a deployment with -/// no bundles at all. That is the defect — not "it did not adopt", but that a failed adoption and -/// a successful one were indistinguishable. -/// -/// Why the boot lane cannot cover this. A package installed after boot is the normal -/// case for every consumer of a registry: the boot sweep enumerates the NodeTypes the mesh HOLDS, -/// and a type that arrives later was not in that snapshot. Install-time adoption -/// (PackageInstaller.SeedPrebuiltAssemblies → IPrebuiltAssemblyConsumer.SeedForTypes) -/// is the only lane that can serve it — so a silent zero there is a whole capability failing -/// invisibly. -/// -/// The two arms. is the -/// positive control: without it the silence arm would pass just as well against a harness in which -/// adoption cannot happen at all. is the -/// defect: the same install, the same mount, a bundle naming somebody else's types — and the log -/// must now name the package, the uncovered type, and the reason. -/// -public class InstallTimePrebuiltAdoptionTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Package = "PrebuiltPkg"; - private const string CoveredType = $"{Package}/Widget"; - - /// Where this test class's bundles are mounted — the harness's /bundles. - /// Per-instance (each [Fact] builds its own mesh), so the two arms cannot see each - /// other's bundles. - private readonly string _bundleDirectory = Path.Combine( - Path.GetTempPath(), "mw3429-" + Guid.NewGuid().ToString("N")); - - /// Captures what the SEEDER said. The install's own line goes to the logger handed to - /// PackageInstaller.Install; the reason line comes from ShippedPrebuiltBundles - /// through the mesh's logger factory, and the whole point of #3429 is that BOTH have to be - /// there. - private readonly LogSink _meshLog = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(s => s - // The deployment's bundle mount, exactly as an image or a volume declares it. - .AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Ai:KeyProtection:MasterKey"] = "test-master-key-do-not-use-outside-tests", - }) - .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) - .AddInMemoryCollection(new Dictionary - { - [ShippedPrebuiltBundles.DirectoryConfigKey] = _bundleDirectory, - }) - .Build()) - // 🚨 The production registration, and the narrow one on purpose: until #3429 the - // ONLY way to get an IPrebuiltAssemblyConsumer was AddDynamicTypePreWarming, which - // also adds a hosted service requiring IHostApplicationLifetime — so a composition - // without a generic host had no consumer and adopted nothing, silently. - .AddPrebuiltAssemblyConsumption() - // 🚨 The provider-scoped filter is load-bearing, not tidiness: test/appsettings.json - // pins every MeshWeaver category at Warning, and the seeder's reason line is - // Information (a mesh holding more types than the bake covers is a fact about the - // deployment, not a fault — the install's own line above it carries the Warning). - // Without this the assertion would measure the harness's log level and pass or fail - // for a reason that has nothing to do with the product. - .AddLogging(l => l - .AddProvider(_meshLog) - .AddFilter(category: null, level: LogLevel.Trace))); - - /// Real PE bytes with a real MVID. The seeder stamps - /// NodeTypeDefinition.LatestAssemblyMvid from the bytes it is handed, so this identity is - /// the ONE positive signal that an adoption LANDED — a Roslyn compile of this package's (empty) - /// source set can never produce it. - private static byte[] BundleBytes() => - File.ReadAllBytes(typeof(BundleWriter).Assembly.Location); - - private void MountBundle(string fileName, params string[] nodePaths) - { - Directory.CreateDirectory(_bundleDirectory); - using var file = File.Create(Path.Combine(_bundleDirectory, fileName)); - BundleWriter.Write( - file, Package, "1.0.0", PrebuiltAssemblySeeder.LiveFrameworkMvid, - nodePaths - .Select(p => new BundleWriter.AssemblyEntry(p, () => new MemoryStream(BundleBytes()))) - .ToArray()); - } - - /// The package's NodeType, authored as a repo authors one — nodeType: NodeType - /// with an untyped content object, which is what the polymorphic converter hands the installer - /// for content carrying no $type. - private const string TypeNodeJson = """ - { - "id": "Widget", - "namespace": "PrebuiltPkg", - "path": "PrebuiltPkg/Widget", - "nodeType": "NodeType", - "name": "Widget", - "state": "Active", - "content": { "configuration": "config => config" } - } - """; - - private Task InstallAfterBoot(ILogger logger, CancellationToken cancellationToken) => - PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = Package, - Name = Package, - Kind = PackageKind.NodeRepo, - TargetPartition = Package, - SourceFolder = Package, - Version = "1.0.0", - }, - [ - new PackageFile($"{Package}.md", $"# {Package}"), - new PackageFile($"{CoveredType}.json", TypeNodeJson), - ], - "HEAD", - logger) - .Should().Within(180.Seconds()) - .Emit("the install itself must complete before anything about adoption can be read", cancellationToken); - - /// - /// THE POSITIVE CONTROL — a package installed after boot whose baked assembly is mounted is - /// adopted, and the install SAYS so. Without this arm the silence arm below would pass against - /// a harness in which adoption is simply unreachable, which is the shape of a gate that cannot - /// fail. - /// - [Fact(Timeout = 300_000)] - public async Task AfterBoot_APackageWhoseBundleIsMounted_AdoptsIt() - { - try - { - MountBundle($"{Package}.zip", CoveredType); - var expectedMvid = ServedBuildIdentity.OfBytes(BundleBytes()); - expectedMvid.Should().NotBeNullOrEmpty("the mounted bundle carries a real PE image"); - - var install = new LogSink(); - var result = await InstallAfterBoot(install.Logger, TestContext.Current.CancellationToken); - result.Written.Should().BeGreaterThan(0, "the install must have written its nodes"); - - await Mesh.GetWorkspace().GetMeshNodeStream(CoveredType) - .Should().Within(120.Seconds()) - .Match( - n => string.Equals( - n.ContentAs(Mesh.JsonSerializerOptions)?.LatestAssemblyMvid, - expectedMvid, StringComparison.Ordinal), - "the bytes for this type were mounted when the package installed — install-time " - + "adoption is the ONLY lane that can serve a package that arrives after boot", cancellationToken: TestContext.Current.CancellationToken); - - install.Dump(Output, "INSTALL"); - install.Lines.Should().Contain( - l => l.Contains($"Install: {Package}: adopted 1 prebuilt", StringComparison.Ordinal), - "an adoption that happened must be reported, naming the package"); - } - finally - { - RemoveMount(); - } - } - - /// - /// THE DEFECT — the same install, the same mount, and no bundle covering these types. Before - /// #3429 this completed in silence: no adoption line, no reason, nothing to grep, and the ten - /// Education types compiled. It must now name the package, the uncovered type and the reason. - /// - [Fact(Timeout = 300_000)] - public async Task AfterBoot_APackageNoBundleCovers_SaysWhy() - { - try - { - // A real, well-formed, identity-matching bundle — for somebody else's types. This is - // exactly Edu's shape as measured: bundles mounted, none of them a candidate. - MountBundle("Other.zip", "SomeOtherPackage/Thing"); - - var install = new LogSink(); - await InstallAfterBoot(install.Logger, TestContext.Current.CancellationToken); - - install.Dump(Output, "INSTALL"); - _meshLog.Dump(Output, "MESH"); - - install.Lines.Should().Contain( - l => l.StartsWith("Warning: ", StringComparison.Ordinal) - && l.Contains($"Install: {Package}: adopted NO prebuilt assembly", StringComparison.Ordinal), - "the install lane must say, loudly and naming the PACKAGE, that it adopted nothing — " - + "a completed seed that covered zero used to log exactly as much as a deployment " - + "with no bundles at all"); - - _meshLog.Lines.Should().Contain( - l => l.Contains("adopted no prebuilt assembly for 1 of 1 requested", StringComparison.Ordinal) - && l.Contains(CoveredType, StringComparison.Ordinal), - "the seeder must name the UNCOVERED type paths and count them against what was " - + "REQUESTED — 'nothing adopted' without them cannot be acted on, and a denominator " - + "derived from per-entry sums would double-count a type two sources both carry"); - - _meshLog.Lines.Should().Contain( - l => l.Contains("name only NodeTypes OUTSIDE this set", StringComparison.Ordinal), - "…and the REASON, which is the half #3429 asks for: a bundle source WAS mounted and " - + "read, and none of its entries named a requested type. That reads differently from " - + "'no bundle source exists' and from 'a bundle named it and it did not land', and " - + "the three must never look alike again"); - } - finally - { - RemoveMount(); - } - } - - /// Removes the mounted bundle directory. Best-effort in a finally, matching this - /// suite's own convention (PrebuiltPublicationTest): a failing assertion must not be replaced by - /// a cleanup exception, and a temp root left behind on one run would accumulate across CI. - private void RemoveMount() - { - try { Directory.Delete(_bundleDirectory, recursive: true); } catch { /* best effort */ } - } - - /// An that keeps every line, so a test can assert on what - /// the product SAID. Not a mock of anything the framework owns — the logging abstraction is the - /// product's own diagnostic surface, and this issue is entirely about that surface. - private sealed class LogSink : ILoggerProvider - { - private readonly ConcurrentQueue _lines = new(); - - public IReadOnlyCollection Lines => _lines; - - /// A logger to hand to an API that takes one directly. - public ILogger Logger => new SinkLogger(_lines); - - public ILogger CreateLogger(string categoryName) => new SinkLogger(_lines); - - public void Dispose() { } - - /// - /// Prints the captured Warning+ lines. The sink records EVERY level (the assertions read - /// , and the seeder's reason line is Information), but echoing the whole - /// Trace capture put ~20k lines per run into the shard log (2026-09-13: shard 5 was 51k - /// lines, 24k of them this class's MESH dump) — the maintainer's rule is CI logs at Warning. - /// - public void Dump(ITestOutputHelper output, string tag) - { - var kept = 0; - foreach (var line in _lines) - { - if (!line.StartsWith("Warning: ", StringComparison.Ordinal) - && !line.StartsWith("Error: ", StringComparison.Ordinal) - && !line.StartsWith("Critical: ", StringComparison.Ordinal)) - continue; - output.WriteLine($"{tag} {line}"); - kept++; - } - output.WriteLine($"{tag}: {kept} of {_lines.Count} captured line(s) shown (Warning and above)"); - } - - private sealed class SinkLogger(ConcurrentQueue lines) : ILogger - { - public IDisposable BeginScope(TState state) where TState : notnull => NoScope.Instance; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel level, EventId id, TState state, Exception? ex, - Func formatter) - => lines.Enqueue($"{level}: {formatter(state, ex)}"); - } - - private sealed class NoScope : IDisposable - { - public static readonly NoScope Instance = new(); - public void Dispose() { } - } - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceComboReaderDiscoveryTest.cs b/test/Memex.Portal.Shared.Test/InstanceComboReaderDiscoveryTest.cs deleted file mode 100644 index d203c7a2e9..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceComboReaderDiscoveryTest.cs +++ /dev/null @@ -1,73 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The combo reader's discovery leg reads Admin/_Discovery/* — the ONE place -/// writes discovery records — with an ANCHORED query. -/// -/// Until 2026-10-04 it read them with a declared mesh-wide fan-out. On PostgreSQL a declared -/// fan-out unions public.searchable_schemas, which never includes the Admin partition, so the -/// leg could not find a single record and still unioned every partition schema once an hour -/// (memex, 12 h: 6 slow runs over 251 schemas). The in-memory store this fixture runs on DOES see -/// Admin from a fan-out, so this test pins the behaviour (a written record is read and the -/// "no discovery record" caveat goes away) and the census below pins the shape. -/// -public class InstanceComboReaderDiscoveryTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false })); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - [Fact(Timeout = 120_000)] - public async Task ReadsTheDiscoveryRecordUnderAdmin() - { - var reader = new InstanceComboReader(Mesh); - - var before = await reader.Read() - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - before.Caveats.Should().Contain(InstanceComboReader.NoDiscoveryCaveat, - "the negative control: with no record under Admin/_Discovery the reader says so"); - - var path = ModuleDiscovery.PathFor("https://github.com/acme/widgets"); - path.Should().StartWith("Admin/_Discovery/"); - await Access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode( - MeshNode.FromPath(path) with - { - NodeType = ModuleDiscovery.NodeType, - Name = "acme/widgets", - State = MeshNodeState.Active, - Content = new ModuleDiscovery - { - RepositoryUrl = "https://github.com/acme/widgets", - SourceName = "Widgets", - GitRef = "main", - LastScannedAt = DateTimeOffset.UtcNow, - Modules = ImmutableList.Empty, - }, - })) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - var after = await reader.Read() - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - after.Caveats.Should().NotContain(InstanceComboReader.NoDiscoveryCaveat, - "the record at Admin/_Discovery/acme.widgets is exactly what the discovery leg reads"); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceIdRulesMatchTheRegistryTest.cs b/test/Memex.Portal.Shared.Test/InstanceIdRulesMatchTheRegistryTest.cs deleted file mode 100644 index 263a846080..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceIdRulesMatchTheRegistryTest.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using Memex.Portal.Shared.Authentication; -using Memex.Portal.Shared.Setup; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The setup host's id rule and the REGISTRY's must agree, character for character. -/// -/// 🚨 They are two copies on purpose — the setup host composes no mesh and cannot reach -/// MeshWeaverInstanceService — and two copies of one rule is exactly the shape that drifts. -/// Drifting APART is not a cosmetic problem here: the id is claimed GLOBALLY and never re-issued, so -/// a rule that is too permissive lets the wizard send an id the registry rejects (after the round -/// trip, on the one surface the instance serves), and one that is too strict refuses ids that would -/// have been fine. -/// -public class InstanceIdRulesMatchTheRegistryTest -{ - public static TheoryData Candidates() => - [ - // The shape the wizard actually mints. - Guid.NewGuid().ToString("d"), - "0f8fad5b-d9cb-469f-a165-70867728950e", - // Boundaries. - "abc", "ab", "a", "", - new string('a', 48), new string('a', 49), - // Hyphen rules. - "-abc", "abc-", "a--b", "a-b-c", - // Alphabet. - "ABC", "roland-macbook", "roland_macbook", "roland macbook", "café-instance", - // The registry's own examples. - "memex-cloud", "rbuergi", - ]; - - [Theory] - [MemberData(nameof(Candidates))] - public void TheSetupHostAgreesWithTheRegistry(string candidate) - { - var registry = MeshWeaverInstanceService.IsValidInstanceId(candidate); - var setup = InstanceIdRules.IsWellFormed(candidate); - - Assert.True(registry == setup, - $"'{candidate}': the registry says {registry}, the setup host says {setup}. These are " - + "two copies of one rule and they have drifted — an id is claimed globally and never " - + "re-issued, so the disagreement costs a real id."); - } - - [Fact] - public void AMintedGuidIsAlwaysAcceptable() - { - // The premise of minting guids at all. If this ever fails, every fresh install is refused - // by the registry on its first action. - for (var i = 0; i < 100; i++) - { - var id = Guid.NewGuid().ToString("d").ToLowerInvariant(); - Assert.True(InstanceIdRules.IsWellFormed(id), $"minted id rejected: {id}"); - Assert.True(MeshWeaverInstanceService.IsValidInstanceId(id), $"registry would reject: {id}"); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceKeyAdoptionTest.cs b/test/Memex.Portal.Shared.Test/InstanceKeyAdoptionTest.cs deleted file mode 100644 index c8cca6fb0e..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceKeyAdoptionTest.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Memex.Portal.Shared.Authentication; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🅿️ Registry-side half of a key ROTATION driven by the Hosting operator (MeshWeaver#2802). -/// -/// The operator's hosting-kv-rotate mints the raw mwi_ key, puts it in Key -/// Vault, and reports ONLY its SHA-256 through a ::hosting:: key_hash= line. The registry -/// then adopts that hash: the instance node takes it, a fresh index entry points at it, and the -/// PREVIOUS index entry is deleted so the old key stops authenticating at once. The raw key never -/// reaches the registry process — this test never holds one either, only hashes. -/// -public class InstanceKeyAdoptionTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - private MeshWeaverInstanceService Service() => new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build()); - - private static string IndexPath(string hash) - => $"{MeshWeaverInstanceNodeType.IndexNamespace}/{InstanceKeys.HashPrefix(hash)}"; - - // 🚨 Index nodes live in the system-owned `MeshWeaverInstance/` namespace and are read here the - // way the registry's own tests read fresh nodes: as System (RLS), through GetMeshNode, which - // re-probes a NotFound once against a fresh activation instead of terminating the stream — - // the point-read-of-a-node-that-may-not-exist rule from AGENTS.md. - private Task Node(string path) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - return access.RunAsSystem(() => Mesh.GetMeshNode(path, TimeSpan.FromSeconds(10)).Take(1)) - .Timeout(TimeSpan.FromSeconds(30)) - .Await(TestContext.Current.CancellationToken); - } - - [Fact(Timeout = 120_000)] - public async Task AdoptKeyHash_MovesTheInstanceOntoTheNewHash_AndRetiresTheOldIndexEntry() - { - var service = Service(); - var registered = await service - .Register("owner", "Owner", "owner@test.com", "rotate-me", "Rotate Me") - .Timeout(TimeSpan.FromSeconds(60)).Await(TestContext.Current.CancellationToken); - var instancePath = registered.Node.Path!; - var oldHash = registered.Instance.KeyHash; - oldHash.Should().HaveLength(64, "registration persists the SHA-256 hex of the key"); - (await Node(IndexPath(oldHash))).Should().NotBeNull("registration wrote the index entry for the original key"); - - // What the operator reports: a hash of a key this process has never seen. - var newHash = InstanceKeys.Hash("mwi_" + Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N")); - - // Through the CONTRACT the Hosting plugin resolves — by instance id, as the operator knows it. - await ((IInstanceKeyRegistry)service).AdoptKeyHash("rotate-me", newHash).Timeout(TimeSpan.FromSeconds(60)).Await(TestContext.Current.CancellationToken); - - var node = await Node(instancePath); - var instance = node!.ContentAs(Mesh.JsonSerializerOptions)!; - instance.KeyHash.Should().Be(newHash, "the instance now authenticates the rotated key"); - instance.KeyIssuedAt.Should().NotBeNull(); - instance.KeyIssuedAt!.Value.Should().BeAfter(registered.Instance.KeyIssuedAt!.Value, - "re-issue moves the issued-at stamp"); - - var newIndex = await Node(IndexPath(newHash)); - newIndex.Should().NotBeNull("a fresh index entry must point at the instance"); - newIndex!.ContentAs(Mesh.JsonSerializerOptions)!.InstancePath - .Should().Be(instancePath); - - // 🚨 The old key must stop authenticating: its index entry is GONE, not merely stale. - (await Node(IndexPath(oldHash))).Should().BeNull("the previous index entry is deleted in the same adoption"); - } - - [Fact(Timeout = 60_000)] - public async Task AdoptKeyHash_RefusesAnythingThatIsNotAHash() - { - var service = Service(); - // A raw key is exactly what must never be sent here — shape-refused before any read. - var act = () => service.AdoptKeyHash("anything", "mwi_notahash").Timeout(TimeSpan.FromSeconds(10)).Await(TestContext.Current.CancellationToken); - await act.Should().ThrowAsync("the registry stores hashes only"); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceKeyLiveResolutionTest.cs b/test/Memex.Portal.Shared.Test/InstanceKeyLiveResolutionTest.cs deleted file mode 100644 index 30940c34b0..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceKeyLiveResolutionTest.cs +++ /dev/null @@ -1,265 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Systemorph/MeshWeaver#3119 — the registry's instance-key resolution must not be a fresh owner -/// round-trip on the request hot path. -/// -/// Measured on memex-cloud 2026-09-02: under cross-schema fan-out load the owning per-node hub -/// of the CI instance's node did not answer the authenticator's one-shot read inside its ten-second -/// budget, so once a minute (the verdict cache) every fetch of every satellite gate and bake was told -/// 503 Instance-key resolution is temporarily unavailable. The fix reads each leg through the -/// process-wide — a live listing for existence, the owner's mirror -/// for content — so only the FIRST request for an instance waits on an owner. -/// -/// These pin the three properties that make that true, against the cache's OWN bookkeeping -/// rather than timing: (a) two resolutions leave one live mirror and evict nothing between them, -/// (b) a disable is seen by the next resolution with no restart and no Invalidate, (c) an -/// unknown key is a definitive no that opens no point read on the absent path. -/// -public class InstanceKeyLiveResolutionTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - private MeshWeaverInstanceService Service() => new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build()); - - /// A REAL authenticator on the real mesh hub — no read seam, so what is exercised is the - /// production listing-then-mirror composition. - private InstanceRegistryAuthenticator Authenticator() => new( - Mesh, Mesh.ServiceProvider.GetRequiredService>()); - - /// The process-wide cache, as its concrete type: its live-entry and eviction seams are - /// the evidence that a read went through it rather than around it. - private MeshNodeStreamCache Cache() => - (MeshNodeStreamCache)Mesh.ServiceProvider.GetRequiredService(); - - private AccessService Access() => Mesh.ServiceProvider.GetRequiredService(); - - private Task Register(string instanceId) => - Service().Register("owner", "Owner", "owner@test.com", instanceId, instanceId) - .Timeout(TestTimeouts.CrossSilo).Await(TestContext.Current.CancellationToken); - - private static string IndexPath(string hash) => - $"{MeshWeaverInstanceNodeType.IndexNamespace}/{InstanceKeys.HashPrefix(hash)}"; - - private static Task Resolve(InstanceRegistryAuthenticator authenticator, string rawKey) => - authenticator.AuthenticateOutcome($"Bearer {rawKey}") - .Should().Within(TestTimeouts.Convergence) - .Emit("a resolution always reaches one of its three outcomes", - cancellationToken: TestContext.Current.CancellationToken); - - /// - /// The index trails the store. Registration is acknowledged when the nodes are WRITTEN; the - /// listing the authenticator gates on sees them a beat later — in production that beat passed - /// long before the key is first used, here it has to be waited out. Same id and query set as the - /// authenticator, so this shares (and pre-hydrates) the very listing it will read. - /// - private Task AwaitListed(string path) - { - var parent = path[..path.LastIndexOf('/')]; - return Access().RunAsSystem(() => Mesh.GetQuery( - $"instance-key-listing:{parent}", $"path:{parent} scope:children select:path")) - .Where(nodes => nodes.Any(n => string.Equals(n.Path, path, StringComparison.Ordinal))) - .Should().Within(TestTimeouts.Convergence) - .Emit($"the listing of {parent} must catch up with the registration of {path}", - cancellationToken: TestContext.Current.CancellationToken); - } - - /// - /// SHOULD-FAIL-IF: a resolution reads the instance with a one-shot request. Then no read-stream - /// entry exists after the first resolution (the old code left none) and every request pays the - /// owner again — the exact shape that produced the 503s. - /// - [Fact] - public async Task TwoResolutions_OpenTheOwnerOnce_TheSecondReadsTheLiveMirror() - { - var registered = await Register("live-once"); - var instancePath = registered.Node.Path!; - var indexPath = IndexPath(registered.Instance.KeyHash); - await AwaitListed(indexPath); - var cache = Cache(); - cache.IsReadStreamLive(instancePath).Should().BeFalse("nothing has read the instance yet"); - cache.IsReadStreamLive(indexPath).Should().BeFalse("the listing gate is not a point read"); - - var evicted = ImmutableList.Empty; - using var evictions = cache.ReadStreamEvictions - .Where(e => e.Path == instancePath || e.Path == indexPath) - .Subscribe(e => ImmutableInterlocked.Update(ref evicted, list => list.Add(e.Path))); - - var authenticator = Authenticator(); - var first = await Resolve(authenticator, registered.RawKey); - first.IsUnavailable.Should().BeFalse(first.UnavailableReason ?? "the first resolution reaches a verdict"); - first.Instance.Should().NotBeNull("a registered key authenticates"); - first.Instance!.Instance.InstanceId.Should().Be("live-once"); - cache.IsReadStreamLive(indexPath).Should().BeTrue( - "the index leg read through the process-wide cache and left a live mirror behind"); - cache.IsReadStreamLive(instancePath).Should().BeTrue("so did the instance leg"); - - var second = await Resolve(authenticator, registered.RawKey); - second.IsUnavailable.Should().BeFalse(second.UnavailableReason ?? "the second resolution reaches a verdict"); - second.Instance!.Instance.InstanceId.Should().Be("live-once"); - cache.IsReadStreamLive(indexPath).Should().BeTrue("the second resolution reused the index mirror"); - cache.IsReadStreamLive(instancePath).Should().BeTrue("…and the instance mirror"); - evicted.Count.Should().Be(0, - "no mirror was torn down between the two resolutions, so no second owner subscription " - + "could have been opened — one owner read served both"); - } - - /// - /// SHOULD-FAIL-IF: a verdict is memoised anywhere. The disable lands in the very mirror the - /// authenticator reads, so the NEXT resolution must already refuse — no cache window, no - /// Invalidate, no restart. - /// - [Fact] - public async Task ADisable_IsSeenByTheNextResolution_WithoutARestartOrAnInvalidate() - { - var registered = await Register("live-disable"); - var instancePath = registered.Node.Path!; - await AwaitListed(IndexPath(registered.Instance.KeyHash)); - var authenticator = Authenticator(); - var before = await Resolve(authenticator, registered.RawKey); - before.Instance.Should().NotBeNull("the control: the key resolves while the instance is enabled"); - - await Access().RunAsSystem(() => Service().SetDisabled(instancePath, true)) - .Timeout(TestTimeouts.Convergence).Await(); - // The authenticator reads the SAME mirror this waits on: once the disabled frame has landed - // here, it has landed there. - await Access().RunAsSystem(() => (IObservable)Mesh.GetMeshNodeStream(instancePath)) - .Where(node => node.ContentAs(Mesh.JsonSerializerOptions)?.IsDisabled == true) - .Should().Within(TestTimeouts.Convergence) - .Emit("the disable reaches the live mirror"); - - var after = await Resolve(authenticator, registered.RawKey); - after.IsUnavailable.Should().BeFalse("a disabled instance is a VERDICT, not a stall"); - after.Instance.Should().BeNull( - "…and the verdict is 'no' on the very next request — no cache window, no Invalidate"); - } - - /// - /// SHOULD-FAIL-IF: an unknown key opens a point read on its (absent) index path. That is the - /// framework's forbidden shape — a routing NotFound that arms the storm breaker — and it would - /// also turn the definitive 401 into a 503. The listing decides, from memory. - /// - [Fact] - public async Task AnUnknownKey_IsADefinitiveNo_ThatOpensNoPointRead() - { - // One registration so the index namespace exists and is listable — the production state. - var registered = await Register("live-anchor"); - await AwaitListed(IndexPath(registered.Instance.KeyHash)); - var unknown = InstanceKeys.Generate(); - var unknownIndex = IndexPath(InstanceKeys.Hash(unknown)); - - var outcome = await Resolve(Authenticator(), unknown); - - outcome.IsUnavailable.Should().BeFalse("an absent index entry IS an answer: this key is unknown"); - outcome.Instance.Should().BeNull(); - Cache().IsReadStreamLive(unknownIndex).Should().BeFalse( - "the listing decided; no point stream was opened on a path that does not exist, so no " - + "storm-breaker window was armed either"); - } -} - -/// -/// The classification the live read applies, pinned without a mesh: which combinations of listing -/// and mirror become Present, Absent or Unavailable. The one that matters most is the first — -/// a frame that has NOT ARRIVED YET is , which the endpoints -/// render as the 503 the consumers retry on, never Absent, which they would read as a -/// definitive "your key is unknown". -/// -public class InstanceKeyFirstFrameTest -{ - private const string Path = "MeshWeaverInstance/0123456789ab"; - - private static IEnumerable Listing(params string[] paths) => - paths.Select(p => MeshNode.FromPath(p)).ToList(); - - private static Task Read( - IObservable>? listing, Func> stream, TimeSpan? budget = null) => - InstanceRegistryAuthenticator.FirstFrame(listing, stream, Path, budget ?? TestTimeouts.Convergence) - .Should().Within(TestTimeouts.Convergence) - .Emit("FirstFrame always produces exactly one outcome", - cancellationToken: TestContext.Current.CancellationToken); - - [Fact] - public async Task NoFrameWithinTheBudget_IsUnavailable_NeverAbsent() - { - var outcome = await Read( - Observable.Never>(), - () => Observable.Never(), - budget: TimeSpan.FromMilliseconds(50)); - - outcome.Status.Should().Be(NodeReadStatus.Unavailable, - "a listing that has not hydrated establishes NOTHING about the key"); - (outcome.Failure is TimeoutException).Should().BeTrue("the budget elapsed"); - outcome.Failure!.Message.Should().Contain(Path, "the log line beside the 503 names the leg"); - outcome.Failure.Message.Should().Contain("NOT 'node not found'", - "…and says what the stall is, so it cannot be mistaken for the old owner-never-answered diagnosis"); - } - - [Fact] - public async Task AListingWithoutThePath_IsAbsent_AndNeverOpensTheMirror() - { - var opened = false; - var outcome = await Read( - Observable.Return(Listing("MeshWeaverInstance/someoneelse")), - () => { opened = true; return Observable.Never(); }); - - outcome.Status.Should().Be(NodeReadStatus.Absent, "the listing is empty-on-absent and that IS the verdict"); - opened.Should().BeFalse("a point read on a path the listing does not hold is the forbidden shape"); - } - - [Fact] - public async Task AListedPath_ReadsTheMirrorsFirstFrame() - { - var node = MeshNode.FromPath(Path) with { Content = new MeshWeaverInstanceIndex { KeyHash = "abc" } }; - var outcome = await Read(Observable.Return(Listing(Path)), () => Observable.Return(node)); - - outcome.Status.Should().Be(NodeReadStatus.Present); - outcome.Node.Should().Be(node); - } - - [Fact] - public async Task AListedPath_WhoseMirrorCompletesWithoutAFrame_IsAbsent() - { - // A tombstoned delete: the index still lists it for a beat, the owner has nothing to emit. - var outcome = await Read(Observable.Return(Listing(Path)), Observable.Empty); - - outcome.Status.Should().Be(NodeReadStatus.Absent, "a record that is going away is 'unknown key', not a stall"); - } - - [Fact] - public async Task AFaultedMirror_IsUnavailable_WithTheFaultAttached() - { - var outcome = await Read( - Observable.Return(Listing(Path)), - () => Observable.Throw(new InvalidOperationException("owner rejected the subscription"))); - - outcome.Status.Should().Be(NodeReadStatus.Unavailable, "a fault is not a fact about the key"); - outcome.Failure!.Message.Should().Contain("owner rejected"); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceKeyRotationRulesTest.cs b/test/Memex.Portal.Shared.Test/InstanceKeyRotationRulesTest.cs deleted file mode 100644 index f4b28e489a..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceKeyRotationRulesTest.cs +++ /dev/null @@ -1,154 +0,0 @@ -using MeshWeaver.Mesh.Security; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The registry-side RULES of a two-phase instance-key rotation (, -/// MeshWeaver#2802), pure: which key may stage, which may commit, what stops authenticating when. -/// -/// The invariant every case serves: a key the instance may still present is never retired -/// until a key it demonstrably reads is presented back. The first rotation design violated it — -/// the registry retired the old key the moment the operator REPORTED a new hash — and every failure -/// after that point left a pod restart away from a 401 storm. -/// -public class InstanceKeyRotationRulesTest -{ - private static readonly DateTimeOffset Now = new(2026, 9, 11, 20, 0, 0, TimeSpan.Zero); - - private static string H(string raw) => InstanceKeys.Hash(raw); - - private static MeshWeaverInstance Instance(string current, string staged = "") => new() - { - InstanceId = "memex", - KeyHash = current, - PendingKeyHash = staged, - }; - - [Fact] - public void Stage_KeepsTheCurrentKeyAuthenticating_BesideTheNewOne() - { - var old = H("mwi_old"); - var next = H("mwi_next"); - var step = InstanceKeyRotation.Stage(Instance(old), old, next, Now); - - step.Refusal.Should().BeNull(); - step.Changed.Should().BeTrue(); - step.Indexed.Should().Be(next, "the new hash's index entry is written so the new key resolves"); - step.Retired.Should().BeEmpty("🚨 staging retires NOTHING the instance may present — that is the fix"); - InstanceKeyRotation.SlotOf(step.Next, old).Should().Be(InstanceKeySlot.Current); - InstanceKeyRotation.SlotOf(step.Next, next).Should().Be(InstanceKeySlot.Staged); - } - - [Fact] - public void Stage_ReplacesAStagedKeyNobodyHolds_ButIsNeverAuthorisedByAStagedKey() - { - var old = H("mwi_old"); - var orphan = H("mwi_orphan"); - var next = H("mwi_next"); - - var replaced = InstanceKeyRotation.Stage(Instance(old, orphan), old, next, Now); - replaced.Refusal.Should().BeNull(); - replaced.Retired.Should().Equal([orphan], "a re-stage by the CURRENT key replaces the earlier staged hash"); - replaced.Next.PendingKeyHash.Should().Be(next); - replaced.Next.KeyHash.Should().Be(old); - - var byStaged = InstanceKeyRotation.Stage(Instance(old, orphan), orphan, next, Now); - byStaged.Refusal.Should().Contain("STAGED key", - "the registry cannot know which key the running pods present, so a staged key never stages another " - + "— a stage must never be able to retire the current key"); - byStaged.Changed.Should().BeFalse(); - byStaged.Retired.Should().BeEmpty(); - } - - [Fact] - public void Commit_OnlyByTheStagedKey_AndRefusesTheCurrentKeyWhileOneIsStaged() - { - var old = H("mwi_old"); - var next = H("mwi_next"); - var staged = Instance(old, next); - - var byCurrent = InstanceKeyRotation.Commit(staged, old, Now); - byCurrent.Refusal.Should().Contain("CURRENT key", - "whoever presents the current key has not received the staged one — retiring theirs would lock them out"); - byCurrent.Retired.Should().BeEmpty(); - - var byStaged = InstanceKeyRotation.Commit(staged, next, Now); - byStaged.Refusal.Should().BeNull(); - byStaged.Retired.Should().Equal([old], "the commit is the ONE place the previous key is retired"); - byStaged.Next.KeyHash.Should().Be(next); - byStaged.Next.PendingKeyHash.Should().BeEmpty(); - InstanceKeyRotation.SlotOf(byStaged.Next, old).Should().Be(InstanceKeySlot.None); - - var repeat = InstanceKeyRotation.Commit(byStaged.Next, next, Now); - repeat.Refusal.Should().BeNull("a repeated commit is idempotent"); - repeat.Changed.Should().BeFalse(); - } - - [Fact] - public void Revoke_StopsExactlyTheKeyItIsAskedTo() - { - var old = H("mwi_old"); - var next = H("mwi_next"); - - var current = InstanceKeyRotation.RevokePresented(Instance(old, next), old, Now); - current.Retired.Should().Equal([old]); - InstanceKeyRotation.SlotOf(current.Next, old).Should().Be(InstanceKeySlot.None); - InstanceKeyRotation.SlotOf(current.Next, next).Should().Be(InstanceKeySlot.Staged, - "revoking the current key leaves the staged one — the only other key — authenticating"); - current.Next.KeyRevokedAt.Should().Be(Now); - - var all = InstanceKeyRotation.RevokeAll(Instance(old, next), Now); - all.Retired.Should().Equal([old, next]); - InstanceKeyRotation.SlotOf(all.Next, old).Should().Be(InstanceKeySlot.None); - InstanceKeyRotation.SlotOf(all.Next, next).Should().Be(InstanceKeySlot.None); - InstanceKeyRotation.RevokeAll(all.Next, Now).Changed.Should().BeFalse("revoking an instance with no key is idempotent"); - - InstanceKeyRotation.RevokePresented(Instance(old), H("mwi_stranger"), Now).Refusal - .Should().NotBeNull("a key that is not the instance's cannot revoke it"); - } - - /// - /// An IMMEDIATE adoption supersedes a staged rotation — the staged key is retired even when the - /// adopted hash is already current (Copilot review on #4055: the old short-cut returned first and - /// left a staged key nobody installed authenticating), and an already-indexed hash is not - /// re-indexed. - /// - [Fact] - public void Adopt_RetiresAStagedKey_EvenWhenTheHashIsAlreadyCurrent() - { - var old = H("mwi_old"); - var staged = H("mwi_staged"); - var fresh = H("mwi_fresh"); - - var sameHash = InstanceKeyRotation.Adopt(Instance(old, staged), old, Now); - sameHash.Changed.Should().BeTrue("a staged key is still outstanding, so this is not a no-op"); - sameHash.Retired.Should().Equal([staged]); - sameHash.Indexed.Should().BeNull("the current hash is already indexed"); - InstanceKeyRotation.SlotOf(sameHash.Next, staged).Should().Be(InstanceKeySlot.None); - InstanceKeyRotation.SlotOf(sameHash.Next, old).Should().Be(InstanceKeySlot.Current); - - var theStaged = InstanceKeyRotation.Adopt(Instance(old, staged), staged, Now); - theStaged.Retired.Should().Equal([old]); - theStaged.Indexed.Should().BeNull("the staged hash already has its index entry"); - theStaged.Next.KeyHash.Should().Be(staged); - theStaged.Next.PendingKeyHash.Should().BeEmpty(); - - var another = InstanceKeyRotation.Adopt(Instance(old, staged), fresh, Now); - another.Retired.Should().Equal([old, staged]); - another.Indexed.Should().Be(fresh); - - InstanceKeyRotation.Adopt(Instance(old), old, Now).Changed - .Should().BeFalse("already current with nothing staged is the one idempotent repeat"); - } - - [Fact] - public void AnEmptySlot_NeverMatches_AndOnlyAHashShapeIsAccepted() - { - InstanceKeyRotation.SlotOf(Instance(""), "").Should().Be(InstanceKeySlot.None); - InstanceKeyRotation.SlotOf(Instance(H("mwi_a")), "mwi_a").Should().Be(InstanceKeySlot.None, - "a raw key is never a hash — the registry compares hashes only"); - InstanceKeyRotation.Stage(Instance(H("mwi_a")), H("mwi_a"), "mwi_b", Now).Refusal - .Should().Contain("SHA-256", "a stage that carries a key instead of its hash is refused before anything is written"); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceKeyRotationTest.cs b/test/Memex.Portal.Shared.Test/InstanceKeyRotationTest.cs deleted file mode 100644 index c26724ef26..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceKeyRotationTest.cs +++ /dev/null @@ -1,298 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Reactive.Linq; -using System.Text.Json; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🅿️ The registry's key-lifecycle surface over the REAL endpoint and the real authenticator -/// (MeshWeaver#2802): /api/instances/self and /api/instances/self/key/{stage,commit,revoke}. -/// -/// The defect these pin: a rotation used to ADOPT the new hash through whatever -/// the control instance's hub resolved — a store that does not hold -/// the instance — after Key Vault had already been written. The replacement is served by the -/// registry, authorised by the instance's own key, and TWO-PHASE: the old key authenticates until the -/// new one is presented back. Every raw key below is minted by the test itself and never printed. -/// -public class InstanceKeyRotationTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - private MeshWeaverInstanceService Service() => new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build()); - - private async Task StartRegistry(MeshWeaverInstanceService service, CancellationToken cancellationToken) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(service); - builder.Services.AddSingleton(Mesh.ServiceProvider.GetRequiredService()); - var app = builder.Build(); - // Mapped through registration, as every host maps it: a host that registers instances must - // never 404 the key lifecycle. - app.MapInstanceRegistration(); - await app.StartAsync(cancellationToken); - return app; - } - - private static async Task<(HttpStatusCode Status, JsonElement Body)> Call( - WebApplication app, HttpMethod method, string route, string rawKey, object? body = null) - { - using var request = new HttpRequestMessage(method, route); - request.Headers.TryAddWithoutValidation("Authorization", InstanceKeys.AuthorizationHeader(rawKey)); - if (body is not null) - request.Content = JsonContent.Create(body); - using var response = await app.GetTestClient().SendAsync(request); - var text = await response.Content.ReadAsStringAsync(); - var json = string.IsNullOrWhiteSpace(text) ? default : JsonDocument.Parse(text).RootElement.Clone(); - return (response.StatusCode, json); - } - - private static Task<(HttpStatusCode Status, JsonElement Body)> Self(WebApplication app, string rawKey) => - Call(app, HttpMethod.Get, InstanceKeyPayloads.SelfRoute, rawKey); - - private static Task<(HttpStatusCode Status, JsonElement Body)> Stage(WebApplication app, string rawKey, string newHash) => - Call(app, HttpMethod.Post, InstanceKeyPayloads.StageRoute, rawKey, new { keyHash = newHash }); - - private static Task<(HttpStatusCode Status, JsonElement Body)> Commit(WebApplication app, string rawKey) => - Call(app, HttpMethod.Post, InstanceKeyPayloads.CommitRoute, rawKey); - - private static Task<(HttpStatusCode Status, JsonElement Body)> Revoke(WebApplication app, string rawKey) => - Call(app, HttpMethod.Post, InstanceKeyPayloads.RevokeRoute, rawKey); - - private Task Register(MeshWeaverInstanceService service, string id) => - service.Register("owner", "Owner", "owner@test.com", id, id).Timeout(TimeSpan.FromSeconds(60)).Await(TestContext.Current.CancellationToken); - - /// - /// 🚨 THE CONTROL-INSTANCE CASE. A portal whose store does not hold the instance answers 401 to - /// every key-lifecycle call — definitively, before anything is changed — which is what the - /// operator's first step (hosting-kv-rotate) meets BEFORE it mints. Exactly 401, never - /// "not 200": a 404 would mean the route is unmapped, which proves nothing. - /// - [Fact(Timeout = 120_000)] - public async Task ARegistryThatDoesNotHoldTheInstance_RefusesEveryKeyCall_WithA401() - { - var service = Service(); - await using var app = await StartRegistry(service, TestContext.Current.CancellationToken); - var stranger = InstanceKeys.Generate(); // a key issued by some OTHER registry - var next = InstanceKeys.Hash(InstanceKeys.Generate()); - - (await Self(app, stranger)).Status.Should().Be(HttpStatusCode.Unauthorized); - (await Stage(app, stranger, next)).Status.Should().Be(HttpStatusCode.Unauthorized, - "nothing can be staged for an instance this store does not hold"); - (await Commit(app, stranger)).Status.Should().Be(HttpStatusCode.Unauthorized); - (await Revoke(app, stranger)).Status.Should().Be(HttpStatusCode.Unauthorized); - } - - /// - /// The old per-hub adoption on a store that does not hold the instance fails NAMING the id — never - /// the silent "adopted" a rotation would have believed. - /// - [Fact(Timeout = 60_000)] - public async Task AdoptionByIdOnAStoreWithoutTheInstance_FailsByName() - { - IInstanceKeyRegistry registry = Service(); - var act = () => registry.AdoptKeyHash("not-in-this-registry", InstanceKeys.Hash(InstanceKeys.Generate())) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - (await act.Should().ThrowAsync()) - .Which.InstanceId.Should().Be("not-in-this-registry"); - } - - /// - /// The registry-hosting path: stage with the current key, prove the new key, commit with the new - /// key. 🚨 The old key authenticates THROUGH the stage and stops only at the commit — the window - /// in which every failure of the old design turned into a delayed 401 storm. - /// - [Fact(Timeout = 180_000)] - public async Task StageProveCommit_TheOldKeyStopsOnlyAtTheCommit() - { - var service = Service(); - await using var app = await StartRegistry(service, TestContext.Current.CancellationToken); - var registered = await Register(service, "rotate-two-phase"); - var oldKey = registered.RawKey; - var newKey = InstanceKeys.Generate(); - - var before = await Self(app, oldKey); - before.Status.Should().Be(HttpStatusCode.OK); - before.Body.GetProperty("instanceId").GetString().Should().Be("rotate-two-phase", - "the operator compares this field with the record's instance id — its NAME is the wire contract"); - before.Body.GetProperty("key").GetString().Should().Be(InstanceKeyPayloads.CurrentKey); - before.Body.GetProperty("staged").GetBoolean().Should().BeFalse(); - - var staged = await Stage(app, oldKey, InstanceKeys.Hash(newKey)); - staged.Status.Should().Be(HttpStatusCode.OK); - staged.Body.GetProperty("staged").GetBoolean().Should().BeTrue(); - - var proof = await Self(app, newKey); - proof.Status.Should().Be(HttpStatusCode.OK, "the staged key authenticates before it is stored anywhere"); - proof.Body.GetProperty("key").GetString().Should().Be(InstanceKeyPayloads.StagedKey); - (await Self(app, oldKey)).Status.Should().Be(HttpStatusCode.OK, - "🚨 staging retires nothing — the key the pods present keeps authenticating"); - - (await Stage(app, newKey, InstanceKeys.Hash(InstanceKeys.Generate()))).Status - .Should().Be(HttpStatusCode.Conflict, "a staged key never stages another"); - (await Commit(app, oldKey)).Status.Should().Be(HttpStatusCode.Conflict, - "the CURRENT key cannot commit: whoever presents it has not received the new one"); - (await Self(app, oldKey)).Status.Should().Be(HttpStatusCode.OK, "a refused commit retired nothing"); - - var committed = await Commit(app, newKey); - committed.Status.Should().Be(HttpStatusCode.OK); - committed.Body.GetProperty("key").GetString().Should().Be(InstanceKeyPayloads.CurrentKey); - committed.Body.GetProperty("staged").GetBoolean().Should().BeFalse(); - - (await Self(app, oldKey)).Status.Should().Be(HttpStatusCode.Unauthorized, "the commit retires the previous key"); - (await Self(app, newKey)).Body.GetProperty("key").GetString().Should().Be(InstanceKeyPayloads.CurrentKey); - (await Commit(app, newKey)).Status.Should().Be(HttpStatusCode.OK, "a repeated commit is idempotent"); - } - - /// A key found somewhere it should not be is revoked by presenting it — nobody reads its value. - [Fact(Timeout = 120_000)] - public async Task RevokingAPresentedKey_MakesItStopAuthenticating() - { - var service = Service(); - await using var app = await StartRegistry(service, TestContext.Current.CancellationToken); - var registered = await Register(service, "revoke-me"); - var key = registered.RawKey; - (await Self(app, key)).Status.Should().Be(HttpStatusCode.OK, "CONTROL: the key authenticates before the revocation"); - - var revoked = await Revoke(app, key); - revoked.Status.Should().Be(HttpStatusCode.OK); - revoked.Body.GetProperty("instanceId").GetString().Should().Be("revoke-me", - "the revocation names the instance the key belonged to — how an operator learns whose it was"); - revoked.Body.GetProperty("key").GetString().Should().Be("revoked"); - - (await Self(app, key)).Status.Should().Be(HttpStatusCode.Unauthorized, "a revoked key no longer authenticates"); - var authenticated = await Mesh.ServiceProvider.GetRequiredService() - .Authenticate(InstanceKeys.AuthorizationHeader(key)).Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - authenticated.Should().BeNull("and every OTHER surface the registry serves refuses it too — one authenticator"); - } - - /// - /// The registry-side admin act: revoke an instance BY ID, with no key presented — for a key whose - /// value nobody should have to know. Every key of the instance stops authenticating; the test - /// base signs in as the DevLogin platform admin, so the global-admin gate is satisfied. - /// - [Fact(Timeout = 120_000)] - public async Task RevokingByIdAsAGlobalAdmin_StopsEveryKeyOfTheInstance() - { - var service = Service(); - await using var app = await StartRegistry(service, TestContext.Current.CancellationToken); - var registered = await Register(service, "revoke-by-id"); - var current = registered.RawKey; - var staged = InstanceKeys.Generate(); - (await Stage(app, current, InstanceKeys.Hash(staged))).Status.Should().Be(HttpStatusCode.OK); - (await Self(app, staged)).Status.Should().Be(HttpStatusCode.OK, "CONTROL: both keys authenticate before the revocation"); - - await ((IInstanceKeyRegistry)service).RevokeKey("revoke-by-id").Timeout(TimeSpan.FromSeconds(60)).Await(TestContext.Current.CancellationToken); - - (await Self(app, current)).Status.Should().Be(HttpStatusCode.Unauthorized, "the current key is revoked"); - (await Self(app, staged)).Status.Should().Be(HttpStatusCode.Unauthorized, "and so is the staged one"); - - var absent = () => ((IInstanceKeyRegistry)service).RevokeKey("never-registered") - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - await absent.Should().ThrowAsync( - "revoking an id this registry does not hold fails by name — never a silent no-op on the wrong store"); - } - - /// - /// An immediate adoption supersedes a staged rotation: adopting the hash that is ALREADY current - /// still retires the staged key (Copilot review on #4055 — the old short-cut returned first and - /// left it authenticating), and adopting the STAGED hash promotes it without re-indexing it. - /// - [Fact(Timeout = 180_000)] - public async Task AnImmediateAdoption_SupersedesAStagedRotation() - { - var service = Service(); - await using var app = await StartRegistry(service, TestContext.Current.CancellationToken); - - var first = await Register(service, "adopt-current"); - var stagedA = InstanceKeys.Generate(); - (await Stage(app, first.RawKey, InstanceKeys.Hash(stagedA))).Status.Should().Be(HttpStatusCode.OK); - await service.AdoptKeyHash(first.Node.Path!, InstanceKeys.Hash(first.RawKey)).Timeout(TimeSpan.FromSeconds(60)).Await(TestContext.Current.CancellationToken); - (await Self(app, stagedA)).Status.Should().Be(HttpStatusCode.Unauthorized, - "adopting the already-current hash retires the staged key"); - (await Self(app, first.RawKey)).Status.Should().Be(HttpStatusCode.OK, "…and keeps the current one"); - - var second = await Register(service, "adopt-staged"); - var stagedB = InstanceKeys.Generate(); - (await Stage(app, second.RawKey, InstanceKeys.Hash(stagedB))).Status.Should().Be(HttpStatusCode.OK); - await service.AdoptKeyHash(second.Node.Path!, InstanceKeys.Hash(stagedB)).Timeout(TimeSpan.FromSeconds(60)).Await(TestContext.Current.CancellationToken); - (await Self(app, stagedB)).Body.GetProperty("key").GetString().Should().Be(InstanceKeyPayloads.CurrentKey, - "adopting the staged hash promotes it (its index entry already existed)"); - (await Self(app, second.RawKey)).Status.Should().Be(HttpStatusCode.Unauthorized, "…and retires the previous key"); - } - - /// - /// A sync token exchanged with the STAGED key is bound to that key, so it survives the commit that - /// promotes it (Copilot review on #4055: it used to be bound to the CURRENT key's hash and would - /// have stopped resolving the moment the commit retired that key). - /// - [Fact(Timeout = 180_000)] - public async Task ATokenExchangedWithTheStagedKey_SurvivesTheCommit() - { - var service = Service(); - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(service); - builder.Services.AddSingleton(Mesh.ServiceProvider.GetRequiredService()); - await using var app = builder.Build(); - app.MapInstanceRegistration(); - app.MapInstanceTokenExchange(); - await app.StartAsync(); - - var registered = await Register(service, "token-through-commit"); - var staged = InstanceKeys.Generate(); - (await Stage(app, registered.RawKey, InstanceKeys.Hash(staged))).Status.Should().Be(HttpStatusCode.OK); - - using var exchange = new HttpRequestMessage(HttpMethod.Post, SyncTokenPayloads.Route) - { - Content = JsonContent.Create(new SyncTokenPayloads.Request(), options: SyncTokenPayloads.Json), - }; - exchange.Headers.TryAddWithoutValidation("Authorization", InstanceKeys.AuthorizationHeader(staged)); - using var exchanged = await app.GetTestClient().SendAsync(exchange); - exchanged.StatusCode.Should().Be(HttpStatusCode.OK, "the staged key may exchange for a token"); - var token = (await exchanged.Content.ReadFromJsonAsync(SyncTokenPayloads.Json))!.AccessToken; - - (await Commit(app, staged)).Status.Should().Be(HttpStatusCode.OK); - - var authenticator = Mesh.ServiceProvider.GetRequiredService(); - var outcome = await authenticator.AuthenticateOutcome($"{SyncAccessToken.Scheme} {token}") - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - outcome.Instance.Should().NotBeNull("a token minted with the staged key still resolves once that key is current"); - outcome.Instance!.Instance.InstanceId.Should().Be("token-through-commit"); - } - - /// A short-lived token may read the catalog; it can never re-key or revoke. - [Fact(Timeout = 60_000)] - public async Task ANonInstanceKeyCredential_IsRefusedBeforeAnythingIsResolved() - { - var service = Service(); - await using var app = await StartRegistry(service, TestContext.Current.CancellationToken); - using var request = new HttpRequestMessage(HttpMethod.Post, InstanceKeyPayloads.RevokeRoute); - request.Headers.TryAddWithoutValidation("Authorization", "Bearer mwa_not-an-instance-key"); - using var response = await app.GetTestClient().SendAsync(request); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceLookupHasNoBoundOfItsOwnTest.cs b/test/Memex.Portal.Shared.Test/InstanceLookupHasNoBoundOfItsOwnTest.cs deleted file mode 100644 index 6de0a4eb27..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceLookupHasNoBoundOfItsOwnTest.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Reactive.Assertions; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The Plan form's instance lookup is bounded by the QUERY's own budget, never by a shorter timer -/// of its own (#5894, the inverted ladder of #1198). -/// -/// InstancePlanService.FindInstancePath carried a 10 s Timeout, written when the -/// fan-in's Initial had no bound at all. The fan-in now faults at -/// (15 s on the default ladder), so the local -/// 10 s was a second, shorter bound on the same wait: a provider answering at 12 s — inside the -/// query's budget — got "TimeoutException" from the form. The two facts pinned here are the two -/// halves of "one bound, the query's": an answer inside the budget is FOUND, and a provider that -/// never answers fails with the QUERY's terminal (, which -/// names the provider), not with an anonymous local timeout. -/// -/// The ladder is set explicitly so the margin is the subject, not a coincidence of the test base. -/// -public class InstanceLookupHasNoBoundOfItsOwnTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string SlowInstance = "slow-instance"; - private const string StalledInstance = "stalled-instance"; - private const string Owner = "slow-owner"; - - /// The production default ladder (its fan-in budget is 15 s today), configured explicitly - /// so a change of the test base's ladder cannot move the margin under test. - private static readonly MeshOperationOptions Ladder = new(); - - /// When the slow provider answers: well past the old local 10 s, still inside the budget. - private static TimeSpan SlowAnswer => Ladder.QueryInitialBudget - TimeSpan.FromSeconds(3); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services.AddSingleton(new SlowInstanceProvider())) - .ConfigureHub(c => c.WithMeshOperationTimeout(Ladder.Timeout)); - - [Fact(Timeout = 300_000)] - public async Task AnAnswerInsideTheQuerysBudget_IsFound_EvenPastTheOldLocalTenSeconds() - { - SlowAnswer.Should().BeGreaterThan(TimeSpan.FromSeconds(10), - "the margin under test: the answer must land after the bound the form used to carry"); - - var path = await Mesh.ServiceProvider.GetRequiredService() - .FindInstancePath(SlowInstance) - .Timeout(TestTimeouts.Convergence + Ladder.QueryInitialBudget) - .Await(TestContext.Current.CancellationToken); - - path.Should().Be($"{Owner}/{MeshWeaverInstanceNodeType.NodeType}/{SlowInstance}", - "a provider answering inside the query's own budget must not be cut off by a shorter local timer"); - } - - [Fact(Timeout = 300_000)] - public async Task AProviderThatNeverAnswers_FailsWithTheQuerysOwnTerminal() - { - Exception? fault = null; - try - { - await Mesh.ServiceProvider.GetRequiredService() - .FindInstancePath(StalledInstance) - .Timeout(TestTimeouts.Convergence + Ladder.QueryInitialBudget) - .Await(TestContext.Current.CancellationToken); - } - catch (Exception ex) - { - fault = ex; - } - - fault.Should().BeOfType( - "the lookup's only bound is the fan-in's, which names the stalled provider"); - } - - /// - /// Answers the lookup of after , never answers - /// the lookup of , and answers every other query at once and empty. - /// A provider on the seam — the same one - /// InstanceLookupWaitsForEveryProviderTest drives — not a mock of a core service. - /// - private sealed class SlowInstanceProvider : IMeshQueryProvider - { - public string Name => nameof(SlowInstanceProvider); - - public bool Matches(IReadOnlyList queryNamespaces) => true; - - public IObservable> Query(MeshQueryRequest request, JsonSerializerOptions options) - { - bool Asks(string id) => typeof(T) == typeof(MeshNode) && request.EffectiveQueries.Any(q => - q.Contains($"nodeType:{MeshWeaverInstanceNodeType.NodeType}", StringComparison.Ordinal) - && q.Contains($"id:{id}", StringComparison.Ordinal)); - - if (Asks(StalledInstance)) - return Observable.Never>(); - if (!Asks(SlowInstance)) - return Observable.Return(Initial(Array.Empty())); - var record = new MeshNode(SlowInstance, $"{Owner}/{MeshWeaverInstanceNodeType.NodeType}") - { - NodeType = MeshWeaverInstanceNodeType.NodeType, - Content = new MeshWeaverInstance { InstanceId = SlowInstance, Plan = "free" }, - }; - return Observable.Timer(SlowAnswer).Select(_ => Initial(new[] { (T)(object)record })); - } - - private static QueryResultChange Initial(IReadOnlyList items) => new() - { - ChangeType = QueryChangeType.Initial, - Items = items, - Timestamp = DateTimeOffset.UtcNow, - }; - - public IObservable> Autocomplete( - string basePath, string prefix, JsonSerializerOptions options, - AutocompleteMode mode = AutocompleteMode.RelevanceFirst, int limit = 10, - string? contextPath = null, string? context = null) - => Observable.Return((IReadOnlyCollection)Array.Empty()); - - public IObservable Select(string path, string property, JsonSerializerOptions options) - => Observable.Return(default); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceLookupWaitsForEveryProviderTest.cs b/test/Memex.Portal.Shared.Test/InstanceLookupWaitsForEveryProviderTest.cs deleted file mode 100644 index d9f0b8000b..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceLookupWaitsForEveryProviderTest.cs +++ /dev/null @@ -1,186 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Reactive.Assertions; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A lookup of a registered instance BY ID decides on the answer of EVERY query provider — never -/// on the first frame of the progressive fan-in. -/// -/// seeds each provider with an empty -/// snapshot so it can emit before the slowest one has answered; its first frame therefore holds -/// only the rows of providers that answered synchronously. InstancePlanService.FindInstancePath -/// and MeshWeaverInstanceService.IsIdAvailable took that first frame. On a registry whose -/// instance records live in Postgres — a provider that answers asynchronously — the plan form at -/// Settings ▸ Instance grants answered "No registered instance carries that ID" for EVERY id -/// (measured on the public registry, platform 3.0.0-ci.9606), and the id check answered "available" -/// for an id that is taken. The in-memory store of a test mesh answers synchronously, which is why -/// the existing registration tests never saw it. -/// -/// The provider here is a test PROVIDER on the seam, the same -/// one PluginBundleStalledReadTest drives — not a mock of a core service. It holds ONE -/// instance record and answers the lookup for it only when the test releases it, after the lookup -/// has subscribed: the production shape of a store whose rows arrive after the seeds. -/// -public class InstanceLookupWaitsForEveryProviderTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string LateInstance = "late-instance"; - private const string LatePath = "late-owner/" + MeshWeaverInstanceNodeType.NodeType + "/" + LateInstance; - - private readonly LateInstanceProvider provider = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services.AddSingleton(provider)); - - /// - /// Subscribes , releases the late provider once the lookup's read has - /// reached it, and returns the lookup's answer. Replay keeps the answer whether it arrived - /// before or after the release — which is exactly what distinguishes the defect from the fix. - /// - private async Task AnswerAfterRelease(IObservable lookup, CancellationToken cancellationToken) - { - var answer = lookup.Take(1).Replay(1); - using var connection = answer.Connect(); - try - { - await provider.LookupReached.Should().Within(TestTimeouts.Convergence) - .Emit("the lookup must reach the provider that holds the instance, or nothing below is measured", - cancellationToken: cancellationToken); - } - finally - { - provider.Release(); - } - return await answer.Timeout(TimeSpan.FromSeconds(60)).Await(cancellationToken); - } - - /// - /// THE PLAN FORM'S LOOKUP: it finds an instance whose record arrives after the fan-in's seeds. - /// Unfixed, the first frame carries no such row and the path is null. - /// - [Fact(Timeout = 300_000)] - public async Task FindInstancePath_FindsAnInstanceAProviderAnswersLate() - { - var plans = Mesh.ServiceProvider.GetRequiredService(); - - var path = await AnswerAfterRelease( - plans.FindInstancePath(LateInstance), TestContext.Current.CancellationToken); - - path.Should().Be(LatePath, - "the lookup must decide on every provider's answer, not on the fan-in's empty-seeded first frame"); - } - - /// - /// THE REGISTRATION CLAIM: an id a late provider holds is TAKEN. Unfixed, the first frame is - /// empty and the id reads as available. - /// - [Fact(Timeout = 300_000)] - public async Task IsIdAvailable_IsFalseForAnIdAProviderAnswersLate() - { - var instances = new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder().Build()); - - var available = await AnswerAfterRelease( - instances.IsIdAvailable(LateInstance), TestContext.Current.CancellationToken); - - available.Should().BeFalse("an id whose record a slower provider holds is taken"); - } - - /// - /// THE CONTROL: an id no provider holds is still reported absent, so the fix cannot pass by - /// answering "found" or "taken" unconditionally. - /// - [Fact(Timeout = 300_000)] - public async Task FindInstancePath_StillAnswersNullForAnUnknownId() - { - var plans = Mesh.ServiceProvider.GetRequiredService(); - - var path = await plans.FindInstancePath("no-such-instance") - .Timeout(TimeSpan.FromSeconds(60)) - .Await(TestContext.Current.CancellationToken); - - path.Should().BeNull("no provider holds that id"); - } - - /// - /// Claims every query. Answers the lookup of with its record only once - /// is called; every other query gets an empty Initial at once. - /// - private sealed class LateInstanceProvider : IMeshQueryProvider - { - private readonly ReplaySubject released = new(1); - private readonly AsyncSubject lookupReached = new(); - - public string Name => nameof(LateInstanceProvider); - - /// Producer → test: completes when a lookup of has subscribed. - public IObservable LookupReached => lookupReached; - - /// Lets the held lookups answer. - public void Release() => released.OnNext(true); - - public bool Matches(IReadOnlyList queryNamespaces) => true; - - public IObservable> Query( - MeshQueryRequest request, JsonSerializerOptions options) => - Observable.Defer(() => - { - var lookup = typeof(T) == typeof(MeshNode) - && request.EffectiveQueries.Any(q => - q.Contains($"nodeType:{MeshWeaverInstanceNodeType.NodeType}", StringComparison.Ordinal) - && q.Contains($"id:{LateInstance}", StringComparison.Ordinal)); - if (!lookup) - return Observable.Return(Initial(Array.Empty())); - lookupReached.OnNext(Unit.Default); - lookupReached.OnCompleted(); - var record = new MeshNode(LateInstance, "late-owner/" + MeshWeaverInstanceNodeType.NodeType) - { - NodeType = MeshWeaverInstanceNodeType.NodeType, - Content = new MeshWeaverInstance { InstanceId = LateInstance, Plan = "free" }, - }; - return released.Take(1).Select(_ => Initial(new[] { (T)(object)record })); - }); - - private static QueryResultChange Initial(IReadOnlyList items) => new() - { - ChangeType = QueryChangeType.Initial, - Items = items, - Timestamp = DateTimeOffset.UtcNow, - }; - - public IObservable> Autocomplete( - string basePath, string prefix, JsonSerializerOptions options, - AutocompleteMode mode = AutocompleteMode.RelevanceFirst, int limit = 10, - string? contextPath = null, string? context = null) - => Observable.Return((IReadOnlyCollection)Array.Empty()); - - public IObservable Select(string path, string property, JsonSerializerOptions options) - => Observable.Return(default); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceManifestProjectionTest.cs b/test/Memex.Portal.Shared.Test/InstanceManifestProjectionTest.cs deleted file mode 100644 index 46f480ed8f..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceManifestProjectionTest.cs +++ /dev/null @@ -1,393 +0,0 @@ -using System.Collections.Immutable; -using System.Linq; -using MeshWeaver.AI; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The manifest read as configuration. Every assertion here is a rule whose violation is SILENT — -/// a provider that registers and then fails at the token exchange, a search that quietly matches -/// words instead of meaning, an authority Entra never serves — so none of them would surface as a -/// test failure anywhere else. -/// -public class InstanceManifestProjectionTest -{ - private static readonly IProviderKeyProtector Protector = - new ProviderKeyProtector(new LiteralMasterKeyProvider( - Convert.ToBase64String(new byte[32]))); - - [Fact] - public void AnIncompleteManifest_ProjectsNothing() - { - // The wizard's own starting point is AwaitingStorage with a PRE-FILLED backend. Projecting - // that would boot the instance past its own setup surface on an answer nobody gave. - var manifest = InstanceSetupDefaults.Manifest(); - Assert.Equal(InstanceSetupState.AwaitingStorage, manifest.State); - - Assert.Empty(InstanceManifestProjection.ToConfiguration(manifest, Protector)); - } - - [Fact] - public void AnUnreadableManifest_ProjectsNothing() - => Assert.Empty(InstanceManifestProjection.ToConfiguration(InstanceManifest.Unreadable, Protector)); - - [Fact] - public void NoManifestAtAll_ProjectsNothing() - => Assert.Empty(InstanceManifestProjection.ToConfiguration(null, Protector)); - - [Fact] - public void Storage_LandsOnTheKeysTheHostAlreadyReads() - { - var entries = InstanceManifestProjection.ToConfiguration( - Complete() with - { - Storage = new InstanceStorageSelection - { - Type = "Sqlite", - ConnectionString = "Data Source=/data/memex.db", - }, - }, - Protector); - - Assert.Equal("Sqlite", entries["Graph:Storage:Type"]); - Assert.Equal("Data Source=/data/memex.db", entries["Graph:Storage:ConnectionString"]); - } - - [Fact] - public void TheConnectionString_IsStoredEncrypted_AndRevealedForUse() - { - // 🚨 It carries a PASSWORD and lives in the same file as the sign-in secrets and provider - // keys. Leaving it readable while protecting those was an inconsistency, not a decision: a - // manifest gets copied to a new volume, backed up, and pasted into an issue when an - // instance will not boot. - var stored = Protector.Protect("Host=db;Username=postgres;Password=hunter2"); - Assert.StartsWith("enc:v1:", stored); - - var entries = InstanceManifestProjection.ToConfiguration( - Complete() with - { - Storage = new InstanceStorageSelection { Type = "PostgreSql", ConnectionString = stored }, - }, - Protector); - - // Revealed for the host that must actually open the database… - Assert.Equal("Host=db;Username=postgres;Password=hunter2", entries["Graph:Storage:ConnectionString"]); - } - - [Fact] - public void APlaintextConnectionString_FromAnOlderOrHandWrittenManifest_StillWorks() - // Reveal() passes an untagged value through unchanged, so this is not a breaking change for - // a manifest written before the encryption, or one an operator authored by hand. - => Assert.Equal("Host=db;Database=memex", - InstanceManifestProjection.ToConfiguration( - Complete() with - { - Storage = new InstanceStorageSelection - { - Type = "PostgreSql", ConnectionString = "Host=db;Database=memex", - }, - }, - Protector)["Graph:Storage:ConnectionString"]); - - [Fact] - public void ABlankMicrosoftTenant_BecomesTheWordCommon_NeverEmpty() - { - // 🚨 An empty Authentication__Microsoft__TenantId composes the authority - // login.microsoftonline.com//v2.0 — a URL Entra never serves — and 500-ed every Microsoft - // sign-in on 2026-08-28. "" and "common" are one character apart in a values file and a - // whole outage apart in behaviour. - var entries = InstanceManifestProjection.ToConfiguration( - WithProvider(new InstanceSignInProvider - { - Name = "Microsoft", - Section = "Authentication:Microsoft", - ClientId = "abc", - TenantId = " ", - ClientSecret = Protector.Protect("shh"), - }), - Protector); - - Assert.Equal("common", entries["Authentication:Microsoft:TenantId"]); - } - - [Fact] - public void AProviderWithNoClientId_ProjectsAnEmptyKey_NotAnAbsentOne() - { - // 🚨 An absent key means "not stated", which on a fleet record inherits the template's - // value and quietly turns the provider back ON. "" is the deployed shape of off, and every - // handler's own IsNullOrEmpty gate reads it that way. - var entries = InstanceManifestProjection.ToConfiguration( - WithProvider(new InstanceSignInProvider - { - Name = "Google", Section = "Authentication:Google", ClientId = "", - }), - Protector); - - Assert.True(entries.ContainsKey("Authentication:Google:ClientId")); - Assert.Equal("", entries["Authentication:Google:ClientId"]); - // …and nothing else for that provider: an off provider has no secret to reveal. - Assert.False(entries.ContainsKey("Authentication:Google:ClientSecret")); - } - - [Fact] - public void TheSectionIsTakenFromTheManifest_SoGitHubLandsOnGitHubOAuth() - { - // GitHub's hand-rolled OAuth endpoints read GitHub:OAuth, NOT Authentication:GitHub. A rule - // that composed the section from the name would put its credentials where nothing looks. - var entries = InstanceManifestProjection.ToConfiguration( - WithProvider(new InstanceSignInProvider - { - Name = "GitHub", - Section = "GitHub:OAuth", - ClientId = "gh-id", - ClientSecret = Protector.Protect("gh-secret"), - }), - Protector); - - Assert.Equal("gh-id", entries["GitHub:OAuth:ClientId"]); - Assert.Equal("gh-secret", entries["GitHub:OAuth:ClientSecret"]); - Assert.False(entries.ContainsKey("Authentication:GitHub:ClientId")); - } - - [Fact] - public void AnEncryptedSecretWithNoProtector_IsDropped_NeverPassedThrough() - { - // 🚨 Emitting ciphertext as a client secret produces a provider that registers, renders its - // button, and fails at the token exchange with an error naming the endpoint — the failure - // landing furthest from the missing master key that caused it. Absent is strictly better: - // the provider does not register, and the operator sees no button to click. - var entries = InstanceManifestProjection.ToConfiguration( - WithProvider(new InstanceSignInProvider - { - Name = "Microsoft", - Section = "Authentication:Microsoft", - ClientId = "abc", - ClientSecret = Protector.Protect("shh"), - }), - protector: null); - - Assert.Equal("abc", entries["Authentication:Microsoft:ClientId"]); - Assert.False(entries.ContainsKey("Authentication:Microsoft:ClientSecret")); - } - - [Fact] - public void ASecretEncryptedUnderADifferentKey_IsDropped_NotEmittedAsCiphertext() - { - // A rotated or truncated master key: Unprotect answers the input unchanged, which is still - // ciphertext, which is still unusable. Same reasoning as the no-protector case. - var other = new ProviderKeyProtector(new LiteralMasterKeyProvider("a-different-key")); - var entries = InstanceManifestProjection.ToConfiguration( - WithProvider(new InstanceSignInProvider - { - Name = "Microsoft", - Section = "Authentication:Microsoft", - ClientId = "abc", - ClientSecret = other.Protect("shh"), - }), - Protector); - - Assert.False(entries.ContainsKey("Authentication:Microsoft:ClientSecret")); - } - - [Fact] - public void ModelProviderKeys_LandOnTheSectionTheProviderPackageBinds() - { - var entries = InstanceManifestProjection.ToConfiguration( - Complete() with - { - Ai = new InstanceAiSelection - { - Providers = - [ - new InstanceAiProvider - { - Name = "Anthropic", - Section = "Anthropic", - ApiKey = Protector.Protect("sk-ant-xyz"), - }, - new InstanceAiProvider - { - Name = "OpenAICompatible", - Section = "OpenAICompatible", - Endpoint = "http://ollama:11434/v1", - Models = ["qwen3.6-code"], - }, - ], - }, - }, - Protector); - - Assert.Equal("sk-ant-xyz", entries["Anthropic:ApiKey"]); - Assert.Equal("http://ollama:11434/v1", entries["OpenAICompatible:Endpoint"]); - // Models bind as an array; the indexed colon form IS an IConfiguration array. - Assert.Equal("qwen3.6-code", entries["OpenAICompatible:Models:0"]); - } - - [Fact] - public void Embeddings_ProjectTheOpenAiCompatibleBackend_SoVectorSearchIsNotDark() - { - // Without these three keys SqliteStorageAdapter writes NULL embeddings and - // SqliteVectorMeshQuery.Matches answers false — search degrades to lexical with no error - // and no log line. This projection is what turns the wizard's answer into a live embedder. - var entries = InstanceManifestProjection.ToConfiguration( - Complete() with - { - Ai = new InstanceAiSelection - { - Embeddings = new InstanceEmbeddingsSelection - { - Endpoint = "http://localhost:11434/v1", - Model = "bge-m3", - }, - }, - }, - Protector); - - Assert.Equal("OpenAICompatible", entries["Embedding:Provider"]); - Assert.Equal("http://localhost:11434/v1", entries["Embedding:Endpoint"]); - Assert.Equal("bge-m3", entries["Embedding:Model"]); - } - - [Fact] - public void AnEndpointlessEmbeddingSelection_IsNotProjectedAsAConfiguredEmbedder() - { - var entries = InstanceManifestProjection.ToConfiguration( - Complete() with - { - Ai = new InstanceAiSelection { Embeddings = new InstanceEmbeddingsSelection() }, - }, - Protector); - - Assert.False(entries.ContainsKey("Embedding:Provider")); - } - - [Fact] - public void TheRegisteredIdentity_ProjectsTheTokenThatSTOPSASecondRegistration() - { - // 🚨 The whole point. InstanceAutoRegistrationService runs on the configured boot and - // registers whenever it sees an instance id and NO token — so without this projection an - // instance that had just registered through the wizard registers a SECOND time, under - // whatever id the deployment happens to carry, and claims another id permanently. Ids are - // global and never re-issued. With the token present that service takes its own Skip branch - // ("a registry token is already configured — the explicit token wins"). - var entries = InstanceManifestProjection.ToConfiguration( - Complete() with - { - Identity = new InstanceIdentitySelection - { - Id = "0f8fad5b-d9cb-469f-a165-70867728950e", - Name = "Roland laptop", - RegistryUrl = "https://memex.meshweaver.cloud", - InstanceKey = Protector.Protect("mwi_realkey"), - Plan = "free", - }, - }, - Protector); - - Assert.Equal("https://memex.meshweaver.cloud", entries["PluginCatalog:RegistryUrl"]); - Assert.Equal("0f8fad5b-d9cb-469f-a165-70867728950e", entries["PluginCatalog:InstanceId"]); - Assert.Equal("mwi_realkey", entries["PluginCatalog:RegistryToken"]); - } - - [Fact] - public void TheSameKey_AlsoBecomesADockerConfigForTheFleetRegistry() - { - // One credential, written once. The fleet's OCI registry authenticates an instance as - // Basic `instance:`, so an install that must pull images — and, once bundles move - // there, plugin bytes — needs a docker config, and this is the only key it has. Deriving it - // beside the registry token is what stops a second copy being kept in step by hand. - var entries = InstanceManifestProjection.ToConfiguration( - Complete() with - { - Identity = new InstanceIdentitySelection - { - Id = "0f8fad5b-d9cb-469f-a165-70867728950e", - Name = "Roland laptop", - RegistryUrl = "https://memex.meshweaver.cloud", - InstanceKey = Protector.Protect("mwi_realkey"), - }, - }, - Protector); - - var config = entries["ContainerRegistry:DockerConfigJson"]; - Assert.NotNull(config); - - using var parsed = System.Text.Json.JsonDocument.Parse(config!); - var auth = parsed.RootElement - .GetProperty("auths").GetProperty("cr.meshweaver.cloud").GetProperty("auth").GetString(); - Assert.Equal("instance:mwi_realkey", - System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(auth!))); - } - - [Fact] - public void NoDockerConfig_WithoutADecryptableKey() - // Same rule as the registry token: a credential that cannot be read is worse than absent — - // it would authenticate nothing while looking configured. - => Assert.False( - InstanceManifestProjection.ToConfiguration( - Complete() with - { - Identity = new InstanceIdentitySelection - { - Id = "0f8fad5b-d9cb-469f-a165-70867728950e", - Name = "Roland laptop", - RegistryUrl = "https://memex.meshweaver.cloud", - InstanceKey = new ProviderKeyProtector( - new LiteralMasterKeyProvider("a-different-key")).Protect("mwi_realkey"), - }, - }, - Protector).ContainsKey("ContainerRegistry:DockerConfigJson")); - - [Fact] - public void AnIdentityWhoseKeyCannotBeDecrypted_ProjectsNOTHING() - { - // 🚨 Not "projects the id without the token" — that is the shape that would register a - // second time. A registry token that is still ciphertext authenticates nothing either: every - // fetch 401s, the catalog looks empty, and the instance appears to have been granted - // nothing while its id sits claimed. Dropping the whole identity leaves auto-registration to - // report the real problem instead. - var other = new ProviderKeyProtector(new LiteralMasterKeyProvider("a-different-key")); - var entries = InstanceManifestProjection.ToConfiguration( - Complete() with - { - Identity = new InstanceIdentitySelection - { - Id = "0f8fad5b-d9cb-469f-a165-70867728950e", - Name = "Roland laptop", - RegistryUrl = "https://memex.meshweaver.cloud", - InstanceKey = other.Protect("mwi_realkey"), - }, - }, - Protector); - - Assert.False(entries.ContainsKey("PluginCatalog:RegistryToken")); - Assert.False(entries.ContainsKey("PluginCatalog:InstanceId")); - } - - [Fact] - public void NoIdentityAtAll_ProjectsNoPluginCatalogKeys() - // The ordinary state of every deployment configured through appsettings — it must stay - // byte-identical, and in particular must not have an empty registry token invented for it. - => Assert.DoesNotContain( - InstanceManifestProjection.ToConfiguration(Complete(), Protector).Keys, - k => k.StartsWith("PluginCatalog:", StringComparison.OrdinalIgnoreCase)); - - private static InstanceManifest Complete() => new() - { - State = InstanceSetupState.Complete, - Storage = new InstanceStorageSelection { Type = "Sqlite" }, - }; - - private static InstanceManifest WithProvider(InstanceSignInProvider provider) => - Complete() with - { - SignIn = new InstanceSignInSelection - { - EnableDevLogin = false, - Providers = ImmutableList.Create(provider), - }, - }; -} diff --git a/test/Memex.Portal.Shared.Test/InstanceManifestTest.cs b/test/Memex.Portal.Shared.Test/InstanceManifestTest.cs deleted file mode 100644 index ec172ebad1..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceManifestTest.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System.Collections.Immutable; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The instance manifest (#2550) — the durable answer to "which database, which modules boot, -/// which packages are provisioned, and which land for every user", written by the setup wizard on -/// an empty image and by a fleet Deployment record when it provisions one. -/// -/// These pin the properties that decide whether an instance SERVES, since every one of them -/// fails silently if it is wrong. -/// -public class InstanceManifestTest : IDisposable -{ - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-instance-" + Guid.NewGuid().ToString("N")); - - public InstanceManifestTest() => Directory.CreateDirectory(root); - - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - [Fact] - public void NoManifest_ReadsAsAbsent_NotAsAnError() - { - // Every deployment configured through appsettings today has no manifest. If absence were - // an error — or anything other than null — this feature would break all of them at once. - Assert.Null(InstanceManifest.Read(root)); - } - - [Fact] - public void ARoundTrip_KeepsEveryAnswerTheWizardCollects() - { - var manifest = new InstanceManifest - { - State = InstanceSetupState.Complete, - Storage = new InstanceStorageSelection - { - Type = "PostgreSql", - SecretName = "memex-db-connection", - }, - BootModules = ["MeshWeaver.Hosting.PostgreSql.dll"], - ProvisionPackages = ["AI", "Store"], - UserPreInstallPackages = ["MyAi"], - SetUpBy = "rbuergi", - SetUpAt = DateTimeOffset.Parse("2026-08-27T21:00:00Z"), - }; - - manifest.Write(root); - var read = InstanceManifest.Read(root); - - Assert.NotNull(read); - Assert.Equal(InstanceSetupState.Complete, read!.State); - Assert.Equal("PostgreSql", read.Storage!.Type); - Assert.Equal("memex-db-connection", read.Storage.SecretName); - // The three module questions stay THREE lists: booting a DLL, provisioning a package into - // the mesh, and landing one per user are different mechanisms, and collapsing them is how - // a deployment ends up with content installed but no module to render it. - Assert.Equal(["MeshWeaver.Hosting.PostgreSql.dll"], read.BootModules); - Assert.Equal(["AI", "Store"], read.ProvisionPackages); - Assert.Equal(["MyAi"], read.UserPreInstallPackages); - Assert.Equal("rbuergi", read.SetUpBy); - } - - [Fact] - public void ACorruptManifest_ReadsAsUnreadable_NeverAsAbsent() - { - // 🚨 The distinction that protects an instance's DATA. Absent means "configured elsewhere, - // carry on". Corrupt must NOT collapse to that: an instance that was set up once and now - // reads as never-configured would offer a fresh setup wizard over a database that already - // holds data, inviting an operator to point it somewhere new. - File.WriteAllText(InstanceManifest.PathFor(root), "{ not json"); - string? reported = null; - - var read = InstanceManifest.Read(root, msg => reported = msg); - - Assert.NotNull(read); - Assert.Equal(InstanceSetupState.Unreadable, read!.State); - Assert.False(read.HasStorage, "an unreadable manifest answers nothing, so it cannot leave setup"); - Assert.NotNull(reported); - Assert.Contains("SETUP", reported!); - } - - [Fact] - public void HasStorage_IsFalseUntilTheFirstQuestionIsAnswered() - { - Assert.False(new InstanceManifest().HasStorage); - Assert.False( - new InstanceManifest { Storage = new InstanceStorageSelection { Type = " " } }.HasStorage, - "a blank type is an unanswered question, not a backend named ' '"); - Assert.True( - new InstanceManifest { Storage = new InstanceStorageSelection { Type = "FileSystem" } } - .HasStorage); - } - - [Fact] - public void TheDefaultProfile_ProvisionsByPATTERN_NotAHandTypedList() - { - // 🚨 The failure this refuses. A default built as an enumerated list of package names is - // stale the moment the next package ships, and its symptom lands nowhere near its cause: - // the new package simply is not on a fresh instance — no error, no log line — and whoever - // published it sees a working store with their package missing. The catalog already - // understands source-scoped wildcards, so the default uses one. - var manifest = InstanceSetupDefaults.Manifest(); - - Assert.Equal(["Plugins/*"], manifest.ProvisionPackages); - Assert.All(manifest.ProvisionPackages, p => Assert.Contains('/', p)); - } - - [Fact] - public void TheDefaultProfile_MatchesWhatAWorkingDeploymentRuns() - { - var manifest = InstanceSetupDefaults.Manifest(); - - // Postgres, because that is what every DEPLOYED installation runs — defaulting to the - // file-system backend is how a portal ends up on container-ephemeral disk. - Assert.Equal("PostgreSql", manifest.Storage!.Type); - - // gRPC boots from the image: it is the React GUI's browser data plane, not merely the - // foreign-participant transport, so a fresh instance starts with it on. - Assert.Contains("MeshWeaver.Hosting.Grpc.dll", manifest.BootModules); - - // Nothing lands per-user by default — a package's own preInstalled declaration is the - // baseline, and anything beyond it spends every user's storage on their behalf. - Assert.Empty(manifest.UserPreInstallPackages); - } - - [Fact] - public void RequiredModules_AreTheRegistryServedOnes_NeverBaselineAssemblies() - { - // The two lists are mutually exclusive for one name: a Modules:Assemblies entry SHADOWS a - // landed store module (baseline wins, then dedupes by name), pinning the instance to an - // app-closure copy the image may not ship at all. - Assert.Empty(InstanceSetupDefaults.RequiredModules - .Intersect(InstanceSetupDefaults.BootModules)); - Assert.Contains("MeshWeaver.AI.dll", InstanceSetupDefaults.RequiredModules); - } - - [Fact] - public void WriteIsAtomic_SoACrashMidWriteCannotStrandTheInstanceInSetup() - { - var first = new InstanceManifest - { - State = InstanceSetupState.Complete, - Storage = new InstanceStorageSelection { Type = "FileSystem", BasePath = "/data/graph" }, - }; - first.Write(root); - - // A second write lands whole or not at all — never a truncated file that would read as - // Unreadable and park a working instance in setup. - new InstanceManifest - { - State = InstanceSetupState.Complete, - Storage = new InstanceStorageSelection { Type = "PostgreSql", SecretName = "conn" }, - }.Write(root); - - var read = InstanceManifest.Read(root); - Assert.Equal("PostgreSql", read!.Storage!.Type); - // No temp files left behind to be mistaken for a manifest. - Assert.Empty(Directory.EnumerateFiles(root, "*.tmp")); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceMasterKeyTest.cs b/test/Memex.Portal.Shared.Test/InstanceMasterKeyTest.cs deleted file mode 100644 index 5862f4c5ff..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceMasterKeyTest.cs +++ /dev/null @@ -1,141 +0,0 @@ -using MeshWeaver.AI; -using MeshWeaver.Mesh.Security; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The install's key-protection master key. Two of these are the difference between "encrypted at -/// rest" and a credential leak, and one is the difference between an instance that can read its own -/// secrets after a restart and one that silently cannot. -/// -public class InstanceMasterKeyTest : IDisposable -{ - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-key-" + Guid.NewGuid().ToString("N")); - - public InstanceMasterKeyTest() => Directory.CreateDirectory(root); - - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - [Fact] - public void WithNoKeyAnywhere_ResolveAnswersNull_AndNeverMintsOne() - { - // 🚨 A read that silently minted a key would make "the deployment's Secret did not reach - // the pod" indistinguishable from "this install has no secret store" — and a portal that - // quietly re-keys cannot decrypt anything it wrote before. Minting is EnsureCreated's job, - // reached only from the setup surface. - Assert.Null(InstanceMasterKey.Resolve(root, configured: null)); - Assert.False(File.Exists(InstanceMasterKey.PathFor(root))); - } - - [Fact] - public void TheDeploymentsOwnKeyWins_AndIsNeverWrittenToDisk() - { - // On Kubernetes the key comes from a Secret and only the ciphertext is on the PVC. Writing - // it to the volume would put both halves in the same place and undo that separation. - var key = InstanceMasterKey.EnsureCreated(root, configured: "from-the-deployment"); - - Assert.Equal("from-the-deployment", key); - Assert.False(File.Exists(InstanceMasterKey.PathFor(root))); - } - - [Fact] - public void ABlankConfiguredKey_ReadsAsUnset() - { - // An environment variable cannot be null, only empty — so "" legitimately means "unset", - // and treating it as a key would derive one from nothing and encrypt everything under it. - var key = InstanceMasterKey.EnsureCreated(root, configured: " "); - - Assert.NotEqual(" ", key); - Assert.True(File.Exists(InstanceMasterKey.PathFor(root))); - } - - [Fact] - public void EnsureCreated_IsIdempotent_BecauseReKeyingStrandsEverySecret() - { - var first = InstanceMasterKey.EnsureCreated(root, configured: null); - var second = InstanceMasterKey.EnsureCreated(root, configured: null); - - Assert.Equal(first, second); - Assert.Equal(first, InstanceMasterKey.Resolve(root, configured: null)); - } - - [Fact] - public void AMintedKey_IsOwnerOnly() - { - if (OperatingSystem.IsWindows()) - return; // Unix modes do not exist there; the inherited ACL is the equivalent control. - - InstanceMasterKey.EnsureCreated(root, configured: null); - var mode = File.GetUnixFileMode(InstanceMasterKey.PathFor(root)); - - Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, mode); - } - - [Fact] - public void AMintedKey_IsAes256SizedAndRandom() - { - var a = InstanceMasterKey.EnsureCreated(root, configured: null); - Assert.Equal(32, Convert.FromBase64String(a).Length); - - var other = Path.Combine(Path.GetTempPath(), "mw-key-" + Guid.NewGuid().ToString("N")); - try - { - Assert.NotEqual(a, InstanceMasterKey.EnsureCreated(other, configured: null)); - } - finally - { - try { Directory.Delete(other, recursive: true); } catch { /* best effort */ } - } - } - - [Fact] - public void LiteralAndConfigProviders_DeriveTheSameKey() - { - // 🚨 The setup surface encrypts through LiteralMasterKeyProvider; every later read goes - // through ConfigMasterKeyProvider. A different derivation over the same configured value - // yields a different AES key, so the secret would decrypt to nothing: the protector answers - // the ciphertext unchanged, the projection drops it as unusable, and the provider silently - // never registers. Nothing else in the system would notice. - const string configured = "a-passphrase-not-base64"; - - var services = new ServiceCollection() - .AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [ConfigMasterKeyProvider.ConfigKey] = configured, - }) - .Build()) - .BuildServiceProvider(); - - var fromConfig = new ConfigMasterKeyProvider(services).GetMasterKey(); - var fromLiteral = new LiteralMasterKeyProvider(configured).GetMasterKey(); - - Assert.NotNull(fromConfig); - Assert.Equal(fromConfig, fromLiteral); - } - - [Fact] - public void ASecretWrittenAtSetup_IsReadableByTheNextBoot() - { - // The end-to-end property the two providers exist to guarantee: encrypt with the key the - // wizard minted, restart, resolve the key from the file, decrypt. - var minted = InstanceMasterKey.EnsureCreated(root, configured: null); - var atSetup = new ProviderKeyProtector(new LiteralMasterKeyProvider(minted)); - var stored = atSetup.Protect("sk-ant-secret"); - - Assert.StartsWith("enc:v1:", stored); - - var atNextBoot = new ProviderKeyProtector( - new LiteralMasterKeyProvider(InstanceMasterKey.Resolve(root, configured: null))); - - Assert.Equal("sk-ant-secret", atNextBoot.Unprotect(stored)); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceOpenRegistrationTest.cs b/test/Memex.Portal.Shared.Test/InstanceOpenRegistrationTest.cs deleted file mode 100644 index 74a91b6489..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceOpenRegistrationTest.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Reactive.Linq; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 OPEN REGISTRATION — the Homebrew default lands on the FREE plan, and nowhere else. -/// -/// A local install (memex-local registry https://memex.meshweaver.cloud, no key) -/// presents NO bootstrap key. The registry accepts that only when its operator minted a registration -/// key for the plan un-keyed callers enrol into and configured it as -/// PluginCatalog:OpenRegistration:Key; the registration then runs exactly as if the caller -/// had presented that key — owned by its minting admin, seeded <source>/*@<plan>. -/// Moving the instance to a higher plan is an admin's edit of its grant on the registry, never -/// something the instance can ask for. -/// -/// Two properties, both pinned over the real endpoint: with no open key configured an -/// un-keyed registration is refused with 401 exactly as before (a registry that configures nothing -/// stays closed), and with one configured the instance lands on THAT plan — a free-tier key must -/// never seed a plan-less Plugins/*. -/// -public class InstanceOpenRegistrationTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Source = "Plugins"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - /// The registry's instance service with THIS configuration — one source, and the open - /// key when the test supplies one. Registered on the host's request services, where the - /// endpoint looks first. - private MeshWeaverInstanceService InstanceService(string? openKey) - { - var config = new Dictionary - { - ["PluginCatalog:Sources:0:Name"] = Source, - ["PluginCatalog:Sources:0:RepoPath"] = "/plugin-repos/plugins", - }; - if (openKey is not null) - config[MeshWeaverInstanceService.OpenRegistrationKeyConfigKey] = openKey; - return new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder().AddInMemoryCollection(config).Build()); - } - - /// Mints the key the operator would: for the FREE plan, owned by a platform admin. - private Task MintOpenKey(CancellationToken cancellationToken) => - Mesh.ServiceProvider.GetRequiredService() - .Mint("open-owner", "Open Owner", "open@test.com", "open registration (free)", null, "free") - .Select(r => r.RawKey) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(60)) - .Await(cancellationToken); - - private async Task StartRegistrationHost(string? openKey, CancellationToken cancellationToken) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(InstanceService(openKey)); - var app = builder.Build(); - app.MapInstanceRegistration(); - await app.StartAsync(cancellationToken); - return app; - } - - private static Task RegisterOpenly(WebApplication app, string instanceId) => - app.GetTestClient().PostAsJsonAsync( - InstanceRegistrationPayloads.Route, - new InstanceRegistrationPayloads.Request("", instanceId, DisplayName: "A Mac"), - InstanceRegistrationPayloads.Json); - - /// The grant node the registry wrote for an instance, read as System — the Admin - /// partition is deliberately out of everyone else's reach. - private Task GrantOf(string instanceId) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - // RunAsSystem, never Observable.Using over ImpersonateAsSystem: the scope is AsyncLocal and - // Rx may dispose on another thread, leaking System into whatever runs next (#1790). - return access.RunAsSystem(() => - Mesh.GetMeshNode(MeshWeaverInstanceNodeType.GrantPath(instanceId), TimeSpan.FromSeconds(10)) - .Take(1)) - .Select(node => node?.ContentAs(Mesh.JsonSerializerOptions)) - .Timeout(TimeSpan.FromSeconds(30)) - .Await(TestContext.Current.CancellationToken); - } - - [Fact(Timeout = 300_000)] - public async Task WithoutAnOpenKey_AnUnkeyedRegistrationIsRefused() - { - var app = await StartRegistrationHost(openKey: null, TestContext.Current.CancellationToken); - await using var _ = app; - - using var response = await RegisterOpenly(app, "homebrew-mac-closed"); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized, - "a registry that configures no open key stays closed — exactly the 401 an invalid key gets"); - (await GrantOf("homebrew-mac-closed")).Should().BeNull("nothing may be written for a refused registration"); - } - - [Fact(Timeout = 300_000)] - public async Task WithAnOpenKeyForTheFreePlan_AnUnkeyedRegistrationLandsOnFree() - { - var openKey = await MintOpenKey(TestContext.Current.CancellationToken); - var app = await StartRegistrationHost(openKey, TestContext.Current.CancellationToken); - await using var _ = app; - - using var response = await RegisterOpenly(app, "homebrew-mac-open"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var issued = await response.Content.ReadFromJsonAsync( - InstanceRegistrationPayloads.Json); - issued!.InstanceKey.Should().StartWith("mwi_", "the instance is issued its own durable key, once"); - - // The licence is on the RECORD (#2804): the instance enrols into the plan the open key was - // minted for, and the grant says only which source it may see — a plan-less entry, which - // is capped by that plan and can never raise it. - (await PlanOf("homebrew-mac-open")).Should().Be(PlanTierRanks.BaselinePlan, - "the instance enrols into the plan the open key was minted for — and ONLY that plan"); - var grant = await GrantOf("homebrew-mac-open"); - grant.Should().NotBeNull("open registration seeds the source grant"); - grant!.Entries.Should().ContainSingle(e => e.ToString() == $"{Source}/*", - "one whole-source entry per configured source, decided at the instance's plan"); - grant.Entries.Should().NotContain(e => e.IsPlanScoped, - "the plan lives on the instance record, never duplicated into the entry"); - } - - /// The plan on the registered instance's record, read the way the registry reads it. - private Task PlanOf(string instanceId) - { - var plans = Mesh.ServiceProvider.GetRequiredService(); - var access = Mesh.ServiceProvider.GetRequiredService(); - return plans.FindInstancePath(instanceId) - .SelectMany(path => path is null - ? Observable.Return(null) - : access.RunAsSystem(() => Mesh.GetMeshNode(path, TimeSpan.FromSeconds(10)).Take(1))) - .Select(node => node?.ContentAs(Mesh.JsonSerializerOptions)?.Plan) - .Timeout(TimeSpan.FromSeconds(30)) - .Await(TestContext.Current.CancellationToken); - } - - [Fact(Timeout = 300_000)] - public async Task AnOpenKeyMintedWithoutAPlan_StillNeverGrantsMoreThanTheDefaults() - { - // A registry operator who mints the open key WITHOUT a plan gets today's behaviour for - // un-keyed callers: the DefaultGrants seed (none here) and nothing else. Open registration - // never invents entitlement. - var openKey = await Mesh.ServiceProvider.GetRequiredService() - .Mint("open-owner", "Open Owner", "open@test.com", "open registration (no plan)") - .Select(r => r.RawKey).FirstAsync().Timeout(TimeSpan.FromSeconds(60)).Await(TestContext.Current.CancellationToken); - var app = await StartRegistrationHost(openKey, TestContext.Current.CancellationToken); - await using var _ = app; - - using var response = await RegisterOpenly(app, "homebrew-mac-noplan"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - (await GrantOf("homebrew-mac-noplan")).Should().BeNull( - "no plan and no DefaultGrants → no grant node at all: registering is identity, not entitlement"); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstancePlanSetByGlobalAdminTest.cs b/test/Memex.Portal.Shared.Test/InstancePlanSetByGlobalAdminTest.cs deleted file mode 100644 index dad8eb51f5..0000000000 --- a/test/Memex.Portal.Shared.Test/InstancePlanSetByGlobalAdminTest.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Reactive.Assertions; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A global administrator sets the plan of an instance ANOTHER user registered — and only a -/// global administrator can. -/// -/// An instance record lives in its registrant's partition ({owner}/MeshWeaverInstance/{id}), -/// and a global administrator is a PLATFORM admin, not a data superuser: it holds no read grant on -/// another user's partition. InstancePlanService.SetPlan read and wrote the record under the -/// CALLER's identity, so the Settings ▸ Instance grants Plan form answered "Access denied: user … lacks -/// Read permission on '{owner}/MeshWeaverInstance/{id}'" for every instance its admin had not -/// registered (measured on the public registry, 2026-09-30). RevokeKey already handles the same -/// situation: it checks hub.IsGlobalAdmin() and then writes as System. -/// -/// This suite stands on WITHOUT the default -/// public admin grant, and the platform admin's only grant is Admin on the Admin partition — the -/// production shape. Under the default grant everyone may read everything, which is why the existing -/// plan test never saw the defect. -/// -public class InstancePlanSetByGlobalAdminTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string AdminPartition = "Admin"; - private const string PlatformAdmin = "platform-boss"; - private const string Stranger = "plain-member"; - private const string Owner = "instance-owner"; - private const string InstanceId = "owned-elsewhere"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddMeshNodes( - new MeshNode(AdminPartition) { Name = "Admin", NodeType = "Markdown" }, - AssignmentNodeFactory.UserRole(PlatformAdmin, "Admin", AdminPartition)) - .AddPluginCatalog(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - private InstancePlanService Plans => Mesh.ServiceProvider.GetRequiredService(); - - /// Registers the instance under 's partition and returns its path. - private async Task RegisterElsewhere(CancellationToken ct) - { - var service = new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder().Build()); - var registered = await service.Register(Owner, "Owner", "owner@test.com", InstanceId, InstanceId) - .Timeout(TimeSpan.FromSeconds(60)).Await(ct); - return registered.Node.Path; - } - - /// The plan on the record, read as System (nobody else may read the owner's partition). - private Task PlanOf(string path, CancellationToken ct) => - Access.RunAsSystem(() => Mesh.GetMeshNode(path, TimeSpan.FromSeconds(10)).Take(1)) - .Select(node => node?.ContentAs(Mesh.JsonSerializerOptions)?.Plan) - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - private Task SetPlanAs(string userId, string path, string plan, CancellationToken ct) - { - using (Access.SwitchAccessContext(new AccessContext { ObjectId = userId, Name = userId })) - return Plans.SetPlan(path, plan) - .Timeout(TimeSpan.FromSeconds(60)) - .Await(ct); - } - - /// - /// THE FORM'S CASE: a global administrator promotes an instance another user registered. Unfixed, - /// the read of the record is refused ("lacks Read permission") and the plan is unchanged. - /// - [Fact(Timeout = 180_000)] - public async Task AGlobalAdmin_SetsThePlanOfAnInstanceAnotherUserRegistered() - { - var ct = TestContext.Current.CancellationToken; - var path = await RegisterElsewhere(ct); - path.Should().StartWith(Owner + "/", "the record lives in its registrant's partition — the premise of the defect"); - - await SetPlanAs(PlatformAdmin, path, "dedicated", ct); - - (await PlanOf(path, ct)).Should().Be("dedicated", - "a global administrator's plan change must land on a record in another user's partition"); - } - - /// - /// THE CONTROL: a member who is not a global administrator is refused and the plan stays as it - /// was — the fix must not turn the System write into a way around the admin gate. - /// - [Fact(Timeout = 180_000)] - public async Task ANonAdmin_IsRefused_AndThePlanIsUnchanged() - { - var ct = TestContext.Current.CancellationToken; - var path = await RegisterElsewhere(ct); - var before = await PlanOf(path, ct); - - var attempt = () => SetPlanAs(Stranger, path, "dedicated", ct); - - await attempt.Should().ThrowAsync( - "setting a plan is a global administrator's act on the registry"); - (await PlanOf(path, ct)).Should().Be(before, "a refused call changes nothing"); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceRebootTest.cs b/test/Memex.Portal.Shared.Test/InstanceRebootTest.cs deleted file mode 100644 index 88203fd4b1..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceRebootTest.cs +++ /dev/null @@ -1,715 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The pure rules of an instance reboot (Doc/Architecture/InstanceReboot): what a request may -/// say, which replica reports count, and why a verification that measured no smoke check is RED. -/// Each rule with its negative control beside it. -/// -public class InstanceRebootRulesTest -{ - private static InstanceRebootReplica Replica(string process, DateTimeOffset started, params InstanceRebootCheck[] checks) => new() - { - Process = process, - StartedAt = started, - ReportedAt = started.AddSeconds(5), - Image = "3.0.0-ci.9412", - Checks = [.. checks], - }; - - private static InstanceRebootCheck Check(string name, string outcome, string? detail = null) => - new() { Name = name, Outcome = outcome, Detail = detail }; - - [Fact] - public void AReboot_NeedsAReason_AndAKnownTrigger() - { - InstanceReboot.Validate(new InstanceRebootRequest { Reason = "x" }).Should().BeNull(); - InstanceReboot.Validate(new InstanceRebootRequest { Reason = " " }).Should().Contain("reason"); - InstanceReboot.Validate(new InstanceRebootRequest { Reason = "x", Trigger = "Cron" }).Should().Contain("not a reboot trigger"); - } - - [Fact] - public void OnlyAProcessBootedAfterTheRestart_Counts_AndASmokeFailureIsRedByName() - { - var restart = new DateTimeOffset(2026, 10, 5, 6, 0, 0, TimeSpan.Zero); - var request = new InstanceRebootRequest { Reason = "x", RestartRequestedAt = restart }; - var green = Check(InstanceReboot.SmokePrefix + "thread-start", InstanceRebootCheckOutcome.Passed); - - // The old pod, reporting on its way out — not evidence. - var old = request with { Replicas = ImmutableDictionary.Empty.Add("old", Replica("old", restart.AddHours(-1), green)) }; - InstanceReboot.EvaluateVerification(old, _ => true).Should().BeNull(); - - var booted = old with { Replicas = old.Replicas.Add("new", Replica("new", restart.AddMinutes(2), green)) }; - InstanceReboot.EvaluateVerification(booted, _ => true)!.Passed.Should().BeTrue(); - - // Negative control: the same booted process, its smoke check red. - var red = old with - { - Replicas = old.Replicas.Add("new", Replica("new", restart.AddMinutes(2), - Check(InstanceReboot.SmokePrefix + "thread-start", InstanceRebootCheckOutcome.Failed, "MissingMethodException: x"))), - }; - var verdict = InstanceReboot.EvaluateVerification(red, _ => true)!; - verdict.Passed.Should().BeFalse(); - verdict.Detail.Should().Contain("new: smoke:thread-start — MissingMethodException: x"); - } - - [Fact] - public void AVerificationThatMeasuredNoSmokeCheck_IsRed_AndAnUnregisteredHealthCheckIsNamedNotGreen() - { - var restart = DateTimeOffset.UtcNow.AddMinutes(-5); - var request = new InstanceRebootRequest - { - Reason = "x", - RestartRequestedAt = restart, - Replicas = ImmutableDictionary.Empty.Add("p", Replica("p", restart.AddMinutes(1), - Check("health:nodetype_bake", InstanceRebootCheckOutcome.NotMeasured, "not registered"))), - }; - var verdict = InstanceReboot.EvaluateVerification(request, _ => true)!; - verdict.Passed.Should().BeFalse("no smoke check was measured — whether a thread can start is not established"); - verdict.Detail.Should().Contain("no smoke check was measured").And.Contain("health:nodetype_bake not measured"); - - // Control: the same report WITH a measured smoke check is green, and still names the unmeasured one. - var withSmoke = request with - { - Replicas = request.Replicas.SetItem("p", request.Replicas["p"] with - { - Checks = request.Replicas["p"].Checks.Add(Check(InstanceReboot.SmokePrefix + "thread-start", InstanceRebootCheckOutcome.Passed)), - }), - }; - var green = InstanceReboot.EvaluateVerification(withSmoke, _ => true)!; - green.Passed.Should().BeTrue(); - green.Detail.Should().Contain("health:nodetype_bake not measured"); - } - - /// - /// #6625 review: the Modules step's audit line says "landed" only when files LANDED. A decision that - /// refused the bundle answers no failure, no files and a served version — that is "not landed", naming - /// the decision and what keeps running; a served version is not evidence of landing. - /// - [Fact] - public void TheModuleAuditLine_SaysLandedOnlyWhenFilesLanded() - { - var declined = new ModuleAdoptOutcome - { - Verdict = new ModuleUpdateVerdict(ModuleUpdateAction.SkipPlatformBelowFloor, "the floor 9.0.0 is above the running platform"), - ServedVersion = "1.2.0", - LandedBefore = "1.1.0", - }; - var line = InstanceRebootExecutor.DescribeAdopt(declined); - line.Should().StartWith("not landed").And.Contain("SkipPlatformBelowFloor").And.Contain("the floor 9.0.0") - .And.Contain("1.1.0 keeps running"); - line.Should().NotContain("landed 1.2.0"); - - // Controls: files that landed are a landing; a failure is named. - InstanceRebootExecutor.DescribeAdopt(declined with { FilesLanded = 3, LandedVersion = "1.2.0" }) - .Should().StartWith("landed 1.2.0"); - InstanceRebootExecutor.DescribeAdopt(declined with { Failure = "link probe refused" }) - .Should().Be("not landed — link probe refused"); - } - - [Fact] - public void TheBakeCheck_IsRedOnlyForACriticalNamespace() - { - var critical = ImmutableArray.Create("Hosting"); - NodeTypeBakeRebootCheck.Judge("b", MeshWeaver.Hosting.BakePhase.Regressed, "d", - [new("Hosting/TriageItem", "Regressed: CS0117")], critical) - .Outcome.Should().Be(InstanceRebootCheckOutcome.Failed); - // Control: the same failure outside the critical namespaces is named, not red. - var other = NodeTypeBakeRebootCheck.Judge("b", MeshWeaver.Hosting.BakePhase.Regressed, "d", - [new("Edu/Quiz", "Regressed: CS0117")], critical); - other.Outcome.Should().Be(InstanceRebootCheckOutcome.Passed); - other.Detail.Should().Contain("Edu/Quiz"); - // "Hostingfoo" is not under "Hosting". - NodeTypeBakeRebootCheck.Judge("b", MeshWeaver.Hosting.BakePhase.Regressed, "d", - [new("HostingX/Thing", "Regressed")], critical) - .Outcome.Should().Be(InstanceRebootCheckOutcome.Passed); - } -} - -/// -/// 🚨 Reboot, end to end through the real lanes — the real registry client, landing, -/// reconciler and module-reload resolve-and-land (a fake HTTP registry), the real request node, -/// executor and per-process agent, and the real as the image -/// and restart path (only its Kubernetes updater is recorded). -/// -/// What is simulated, and only that: which generation the PROCESS booted after the restart has -/// loaded, and the thread-start smoke check that process runs — a stand-in that throws the incident's -/// MissingMethodException when the process still binds the old module generation (the real -/// smoke check, which starts a thread, ships with the AI module in MeshWeaver.Plugins). -/// -public abstract class InstanceRebootScenario(ITestOutputHelper output) : ModuleReloadScenario(output) -{ - /// What the process booted after the restart has loaded: module → generation leaf. - private ImmutableDictionary bootedLoads = ImmutableDictionary.Empty; - - protected virtual IDeploymentUpdater RebootUpdater => Updater; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => - base.ConfigureMesh(builder).AddUpdatePolicyType().ConfigureServices(services => services - .AddSingleton(new InstanceRebootOptions - { - BakeSettleBudget = TimeSpan.FromSeconds(5), - CheckBudget = TestTimeouts.Convergence, - WatchdogEnabled = false, - }) - .AddSingleton(CreateActivation) - .AddSingleton(new ThreadStartStandIn(this))); - - /// The image + restart path of the reboot: the real self-updater on the recorded updater. - protected virtual IInstanceRebootActivation CreateActivation(IServiceProvider sp) => - new SelfUpdateHostedService(sp.GetRequiredService(), new NoTags(), RebootUpdater, new SelfUpdateOptions()); - - /// The update policy the Image step reads — Stable, so the empty tag listing admits nothing newer. - protected async Task PolicyExists(CancellationToken ct) => - await UpdatePolicyNodeType.EnsureExists(Mesh, Access, UpdatePolicyKind.Stable) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - protected async Task Reboot(CancellationToken ct) - { - var ticket = await InstanceReboot.Request(Mesh, new InstanceRebootRequest - { - Reason = "the control instance cannot start a thread (the 2026-10-05 incident shape)", - RequestedBy = "test-admin", - }) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - ticket.Refusal.Should().BeNull(); - return ticket.Path!; - } - - protected Task AwaitReboot(string path, Func until, CancellationToken ct) => - Access.RunAsSystem(() => Mesh.GetMeshNodeStream(path)) - .Select(node => node.ContentAs(Mesh.JsonSerializerOptions)) - .Where(r => r is not null && until(r)) - .Select(r => r!) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - /// The process the restart created boots, loading , and reports its checks. - protected async Task BootedProcessReports(string path, DateTimeOffset restart, string generation, CancellationToken ct) - { - ImmutableInterlocked.Update(ref bootedLoads, l => l.SetItem(Module, generation)); - var booted = new InstanceRebootAgent { StartedAt = restart.AddSeconds(30), ProcessOf = _ => "restarted-pod" }; - await booted.Handle(Mesh, path).DefaultIfEmpty().Timeout(TestTimeouts.Convergence).Await(ct); - } - - protected sealed class NoTags : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult>([]); - } - - /// - /// The stand-in thread-start smoke: a thread start binds against the module the process loaded; when - /// that is not the generation the activation record names, the start throws what the incident threw. - /// - private sealed class ThreadStartStandIn(InstanceRebootScenario scenario) : IInstanceRebootCheck - { - public string Name => InstanceReboot.SmokePrefix + "thread-start"; - - public IObservable Run(IMessageHub meshHub, InstanceRebootRequest request) => Observable.Defer(() => - { - var head = scenario.Head(); - var loads = scenario.bootedLoads.TryGetValue(Module, out var leaf) ? leaf : null; - return Observable.Return(string.Equals(loads, head.Directory, StringComparison.Ordinal) - ? new InstanceRebootCheck { Name = Name, Outcome = InstanceRebootCheckOutcome.Passed, Detail = $"a thread started on {Module} {head.Version}" } - : new InstanceRebootCheck - { - Name = Name, - Outcome = InstanceRebootCheckOutcome.Failed, - Detail = "MissingMethodException: Method not found: 'Void MeshWeaver.AI.ThreadPreparation.set_Group(System.String)'", - }); - }); - } -} - -/// The reboot's acceptance scenarios: a module store copy older than published. -public class InstanceRebootTest(ITestOutputHelper output) : InstanceRebootScenario(output) -{ - /// - /// 🚨 THE ACCEPTANCE SCENARIO. M@1.1.0 is landed and running, 1.2.0 is published with a floor this - /// platform meets. A reboot syncs (nothing to sync — named), lands 1.2.0, finds no newer image - /// (named), takes exactly ONE restart (inside the roll floor, which a reboot does not wait on), and - /// once the process booted after it — loading 1.2.0 — reports that a thread starts, it is Done. - /// - [Fact(Timeout = 240_000)] - public async Task AnOlderModuleStoreCopy_IsLandedRestartedAndVerified_InOneReboot() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - await PolicyExists(ct); - Registry.Serve("1.2.0", floor: FloorFixture.Below); - - var path = await Reboot(ct); - var waiting = await AwaitReboot(path, r => r.Status == InstanceRebootStatus.AwaitingRestart - && r.Steps.Any(s => s.Name == InstanceRebootSteps.Restart && s.Outcome == InstanceRebootStepOutcome.Ok), ct); - - Step(waiting, InstanceRebootSteps.Sync).Outcome.Should().Be(InstanceRebootStepOutcome.Skipped); - Step(waiting, InstanceRebootSteps.Sync).Detail.Should().Contain("no module source to sync"); - Step(waiting, InstanceRebootSteps.Modules).Outcome.Should().Be(InstanceRebootStepOutcome.Ok); - waiting.Modules.Single().TargetVersion.Should().Be("1.2.0"); - waiting.Modules.Single().Landed.Should().BeTrue(); - Step(waiting, InstanceRebootSteps.Image).Outcome.Should().Be(InstanceRebootStepOutcome.Skipped, Step(waiting, InstanceRebootSteps.Image).Detail ?? ""); - Step(waiting, InstanceRebootSteps.Restart).Detail.Should().StartWith(RebootActivationKinds.Restarted); - Updater.Restarts.Should().Be(1, "exactly one restart, not deferred by the roll floor"); - Head().Version.Should().Be("1.2.0", "the activation record names N+1 for the boot that follows"); - - await BootedProcessReports(path, waiting.RestartRequestedAt!.Value, Head().Directory!, ct); - - var done = await AwaitReboot(path, r => InstanceRebootStatus.IsTerminal(r.Status), ct); - done.Status.Should().Be(InstanceRebootStatus.Done, done.Failure ?? string.Join(" | ", done.Log)); - Step(done, InstanceRebootSteps.Verify).Outcome.Should().Be(InstanceRebootStepOutcome.Ok); - done.Replicas["restarted-pod"].Checks.Should().Contain(c => - c.Name == InstanceReboot.SmokePrefix + "thread-start" && c.Outcome == InstanceRebootCheckOutcome.Passed); - done.RequestedBy.Should().Be("test-admin", "the person's call is the signature and is recorded"); - Updater.Restarts.Should().Be(1); - } - - /// - /// The negative control: the process booted after the restart still binds the OLD generation — the - /// thread start throws the incident's MissingMethodException and the reboot is RED, naming the check. - /// - [Fact(Timeout = 240_000)] - public async Task AProcessThatStillLoadsTheOldModule_TurnsTheRebootRed_NamingTheThreadStart() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - var oldGeneration = Head().Directory!; - await PolicyExists(ct); - Registry.Serve("1.2.0", floor: FloorFixture.Below); - - var path = await Reboot(ct); - var waiting = await AwaitReboot(path, r => r.Status == InstanceRebootStatus.AwaitingRestart - && r.Steps.Any(s => s.Name == InstanceRebootSteps.Restart && s.Outcome == InstanceRebootStepOutcome.Ok), ct); - - await BootedProcessReports(path, waiting.RestartRequestedAt!.Value, oldGeneration, ct); - - var red = await AwaitReboot(path, r => InstanceRebootStatus.IsTerminal(r.Status), ct); - red.Status.Should().Be(InstanceRebootStatus.Failed); - Step(red, InstanceRebootSteps.Verify).Outcome.Should().Be(InstanceRebootStepOutcome.Failed); - red.Failure.Should().Contain("Verify:").And.Contain("smoke:thread-start").And.Contain("MissingMethodException"); - } - - /// A floor above the running platform is DECLINED by name on the Modules step — not red — and N keeps serving. - [Fact(Timeout = 240_000)] - public async Task ANewerModuleAboveTheFloor_IsDeclinedByName_AndTheRebootStillRestarts() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - await PolicyExists(ct); - Registry.Serve("1.2.0", floor: FloorFixture.Above); - - var path = await Reboot(ct); - var waiting = await AwaitReboot(path, r => r.Status == InstanceRebootStatus.AwaitingRestart - && r.Steps.Any(s => s.Name == InstanceRebootSteps.Restart && s.Outcome == InstanceRebootStepOutcome.Ok), ct); - - var modules = Step(waiting, InstanceRebootSteps.Modules); - modules.Outcome.Should().Be(InstanceRebootStepOutcome.Ok); - modules.Detail.Should().Contain("declined").And.Contain(FloorFixture.Above); - Head().Version.Should().Be("1.1.0", "N keeps serving"); - Updater.Restarts.Should().Be(1); - } - - internal static InstanceRebootStep Step(InstanceRebootRequest r, string name) => r.Steps.Single(s => s.Name == name); -} - -/// A step that fails is reported RED with its name — here the restart path cannot roll anything. -public class InstanceRebootFailedStepTest(ITestOutputHelper output) : InstanceRebootScenario(output) -{ - private readonly CannotRestart cannot = new(); - - protected override IDeploymentUpdater RebootUpdater => cannot; - - [Fact(Timeout = 240_000)] - public async Task ARestartThatCannotBeTaken_IsRedByName_AndNothingWaitsForAVerification() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - await PolicyExists(ct); - - var path = await Reboot(ct); - var red = await AwaitReboot(path, r => InstanceRebootStatus.IsTerminal(r.Status), ct); - - red.Status.Should().Be(InstanceRebootStatus.Failed); - var restart = InstanceRebootTest.Step(red, InstanceRebootSteps.Restart); - restart.Outcome.Should().Be(InstanceRebootStepOutcome.Failed); - restart.Detail.Should().StartWith(RebootActivationKinds.Unavailable); - red.Failure.Should().Contain("the restart could not be requested"); - cannot.Attempts.Should().Be(1, "asked exactly once"); - // Control: the steps before it are not painted red by the failure. - InstanceRebootTest.Step(red, InstanceRebootSteps.Modules).Outcome.Should().Be(InstanceRebootStepOutcome.Ok); - - // A DECIDED "cannot restart" is final: the retry pass (#6172) re-arms nothing, even at once. - var rearmed = await InstanceReboot.RetryFaulted(Mesh, TimeSpan.Zero, DateTimeOffset.UtcNow.AddDays(1)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - rearmed.Should().BeEmpty("Unavailable is a decided answer, never retried"); - cannot.Attempts.Should().Be(1, "still asked exactly once"); - } - - internal sealed class CannotRestart : IDeploymentUpdater - { - private int attempts; - public int Attempts => Volatile.Read(ref attempts); - public bool CanPatch => true; - public Task LastRolledAtAsync(CancellationToken ct) => Task.FromResult(null); - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) => Task.CompletedTask; - - public Task RestartAsync(CancellationToken ct) - { - Interlocked.Increment(ref attempts); - return Task.FromResult(false); - } - - public Task ReadRolloutStrategyAsync(CancellationToken ct) => - Task.FromResult(new("RollingUpdate", "1", "0", 2)); - } -} - -/// -/// 🚨 The reboot agent reads the commit the feed ANNOUNCED, never whatever its mirror holds — -/// the reboot twin of . The handler is -/// subscribed BEFORE the AwaitingRestart commit is written, so its mirror can only hold the -/// earlier state. Floored at the announced version it waits for the commit and reports; the negative -/// control — the same process with no floor, i.e. the old read — answers from the earlier state and -/// reports nothing. -/// -public class InstanceRebootAgentReadsTheAnnouncedCommitTest(ITestOutputHelper output) : InstanceRebootScenario(output) -{ - // The restart cannot be taken, so the reboot is DECIDED (Failed) and the executor writes nothing - // more on its own: the only writer below is this test. - protected override IDeploymentUpdater RebootUpdater => new InstanceRebootFailedStepTest.CannotRestart(); - - [Fact(Timeout = 240_000)] - public async Task AHandlerAheadOfItsMirror_WaitsForTheAnnouncedCommit_AndReports() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - await PolicyExists(ct); - - var path = await Reboot(ct); - var decided = await Access.RunAsSystem(() => Mesh.GetMeshNodeStream(path)) - .Where(n => n.ContentAs(Mesh.JsonSerializerOptions) is { } r && InstanceRebootStatus.IsTerminal(r.Status)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - var announced = decided.Version + 1; - var restart = DateTimeOffset.UtcNow.AddMinutes(-1); - - // Both processes booted AFTER the restart stamp, and both are subscribed before the commit exists. - var floored = new InstanceRebootAgent { StartedAt = restart.AddSeconds(30), ProcessOf = _ => "floored-pod" } - .Handle(Mesh, path, committed: announced).ToList().Replay(1); - var unfloored = new InstanceRebootAgent { StartedAt = restart.AddSeconds(30), ProcessOf = _ => "unfloored-pod" } - .Handle(Mesh, path).ToList().Replay(1); - using var flooredConnection = floored.Connect(); - using var unflooredConnection = unfloored.Connect(); - - await Access.RunAsSystem(() => Mesh.GetMeshNodeStream(path).Update(r => r with - { - Status = InstanceRebootStatus.AwaitingRestart, - RestartRequestedAt = restart, - })) - .Take(1).Timeout(TestTimeouts.Convergence).Await(ct); - - (await floored.Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().ContainSingle("the handler floored at the announced version reads the AwaitingRestart commit and reports"); - (await unfloored.Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().BeEmpty("the control: the unfloored read answers from the state before the commit and reports nothing"); - var reported = await AwaitReboot(path, r => r.Replicas.ContainsKey("floored-pod"), ct); - reported.Replicas.Should().NotContainKey("unfloored-pod"); - } -} - -/// -/// 🚨 A transient failure is never final (#6172): a restart request that CRASHED (the updater threw) -/// leaves the reboot , not Failed; the retry pass -/// () re-arms it, the executor asks for the ONE restart again, -/// and the reboot completes on that attempt. -/// -public class InstanceRebootFaultedRestartTest(ITestOutputHelper output) : InstanceRebootScenario(output) -{ - private readonly ThrowsOnce flaky = new(); - - protected override IDeploymentUpdater RebootUpdater => flaky; - - [Fact(Timeout = 240_000)] - public async Task ARestartThatCrashed_IsFaulted_RetriedByThePass_AndCompletesOnTheNextAttempt() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - await PolicyExists(ct); - - var path = await Reboot(ct); - var faulted = await AwaitReboot(path, r => r.Status == InstanceRebootStatus.Faulted || InstanceRebootStatus.IsTerminal(r.Status), ct); - faulted.Status.Should().Be(InstanceRebootStatus.Faulted, faulted.Failure ?? string.Join(" | ", faulted.Log)); - InstanceRebootTest.Step(faulted, InstanceRebootSteps.Restart).Detail.Should().Contain("faulted"); - faulted.FaultedAt.Should().NotBeNull(); - flaky.Attempts.Should().Be(1); - - // Before its backoff is due the pass re-arms nothing. - var early = await InstanceReboot.RetryFaulted(Mesh, TimeSpan.FromHours(1), DateTimeOffset.UtcNow) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - early.Should().BeEmpty("the backoff is not due yet"); - - var rearmed = await InstanceReboot.RetryFaulted(Mesh, TimeSpan.Zero, DateTimeOffset.UtcNow) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - rearmed.Should().ContainSingle().Which.Should().Be(path); - - var waiting = await AwaitReboot(path, r => r.Attempt == 1 && r.Status == InstanceRebootStatus.AwaitingRestart - && r.RestartRequestedAt is not null - && r.Steps.Any(s => s.Name == InstanceRebootSteps.Restart && s.Outcome == InstanceRebootStepOutcome.Ok), ct); - InstanceRebootTest.Step(waiting, InstanceRebootSteps.Restart).Detail.Should().StartWith(RebootActivationKinds.Restarted); - flaky.Attempts.Should().Be(2, "the re-armed attempt asked for the restart again, once"); - - var restart = waiting.RestartRequestedAt ?? throw new InvalidOperationException("the second attempt carries no restart stamp"); - await BootedProcessReports(path, restart, Head().Directory ?? throw new InvalidOperationException("no activation head"), ct); - - var done = await AwaitReboot(path, r => InstanceRebootStatus.IsTerminal(r.Status), ct); - done.Status.Should().Be(InstanceRebootStatus.Done, done.Failure ?? string.Join(" | ", done.Log)); - done.Log.Should().Contain(l => l.Contains("retry 1: re-armed after the fault")); - } - - private sealed class ThrowsOnce : IDeploymentUpdater - { - private int attempts; - public int Attempts => Volatile.Read(ref attempts); - public bool CanPatch => true; - public Task LastRolledAtAsync(CancellationToken ct) => Task.FromResult(null); - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) => Task.CompletedTask; - - public Task RestartAsync(CancellationToken ct) => - Interlocked.Increment(ref attempts) == 1 - ? Task.FromException(new HttpRequestException("the Kubernetes API reset the connection")) - : Task.FromResult(true); - - public Task ReadRolloutStrategyAsync(CancellationToken ct) => - Task.FromResult(new("RollingUpdate", "1", "0", 2)); - } -} - -/// -/// 🚨 Keep serving: a reboot's roll is issued only when the portal Deployment's rollout cannot take it below -/// its serving replicas (maxSurge ≥ 1, maxUnavailable 0) — otherwise the Restart step is REFUSED by name and -/// nothing is rolled. The acceptance scenario (non-disruptive strategy, one restart) is its control. -/// -public class InstanceRebootDisruptiveRolloutTest(ITestOutputHelper output) : InstanceRebootScenario(output) -{ - [Fact(Timeout = 240_000)] - public async Task ADisruptiveRolloutStrategy_RefusesTheRestart_ByName_AndRollsNothing() - { - var ct = TestContext.Current.CancellationToken; - Updater.Strategy = new RolloutStrategyReading("RollingUpdate", "25%", "25%", 4); - await RunningVersionOne(ct); - await PolicyExists(ct); - - var path = await Reboot(ct); - var red = await AwaitReboot(path, r => InstanceRebootStatus.IsTerminal(r.Status), ct); - - red.Status.Should().Be(InstanceRebootStatus.Failed); - var restart = InstanceRebootTest.Step(red, InstanceRebootSteps.Restart); - restart.Outcome.Should().Be(InstanceRebootStepOutcome.Failed); - restart.Detail.Should().StartWith(RebootActivationKinds.Refused).And.Contain("maxUnavailable resolves to 1 of 4"); - Updater.Restarts.Should().Be(0, "a roll that would drop serving pods is never issued"); - } -} - -/// -/// 🚨 The self-hand-over loop: a CONTROL instance (its hand-over route is its own inbox) that cannot -/// self-patch must not hand its own restart to its own control lane — refused loudly, nothing handed over. -/// -public class InstanceRebootControlSelfHandoverTest(ITestOutputHelper output) : InstanceRebootScenario(output) -{ - protected override IInstanceRebootActivation CreateActivation(IServiceProvider sp) => - new ControlInstanceThatCannotPatch(sp.GetRequiredService(), new NoTags(), Updater); - - [Fact(Timeout = 240_000)] - public async Task AControlInstanceThatCannotSelfPatch_RefusesToHandItsRestartToItself() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - await PolicyExists(ct); - - var path = await Reboot(ct); - var red = await AwaitReboot(path, r => InstanceRebootStatus.IsTerminal(r.Status), ct); - - red.Status.Should().Be(InstanceRebootStatus.Failed); - var restart = InstanceRebootTest.Step(red, InstanceRebootSteps.Restart); - restart.Detail.Should().StartWith(RebootActivationKinds.Refused).And.Contain("IS the control instance").And.Contain("nothing was handed over"); - Updater.Restarts.Should().Be(0); - } - - /// The real self-updater, configured as the control instance (local inbox route) with patching off. - private sealed class ControlInstanceThatCannotPatch(IMessageHub hub, IAcrTagLister tags, IDeploymentUpdater updater) - : SelfUpdateHostedService(hub, tags, updater, new SelfUpdateOptions { CanPatch = false }) - { - private readonly SelfUpdateHandover local = new LocalRoute(hub); - - protected override SelfUpdateHandover ResolveHandover() => local; - - private sealed class LocalRoute(IMessageHub hub) : SelfUpdateHandover(hub) - { - public override Settings ReadSettings() => - new("memex", null, SecretPresent: false, LocalTargetListed: true, LocalSecretPresent: true, "https://memex.example") - { - LocalSecretKey = LocalSecretKey, - }; - } - } -} - -/// -/// 🚨 MeshWeaver#6625 review: a request that ENDED (here: ended while its Modules step was in flight, as -/// the owner does for a stalled request) must not start the steps after the one in flight. A write that -/// meets a terminal request changes nothing AND emits nothing, so the late Modules result does not go on -/// to the Image step — nor, beyond it, to the restart. -/// -public class InstanceRebootEndedMidStepTest(ITestOutputHelper output) : InstanceRebootScenario(output) -{ - private readonly System.Reactive.Subjects.AsyncSubject imageSelected = new(); - - protected override IInstanceRebootActivation CreateActivation(IServiceProvider sp) => - new Watched(base.CreateActivation(sp), imageSelected); - - [Fact(Timeout = 240_000)] - public async Task ARequestEndedWhileAStepRuns_StartsNoLaterStep() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - await PolicyExists(ct); - await Mesh.ServiceProvider.GetRequiredService().BootReconciled - .DefaultIfEmpty().Timeout(TestTimeouts.Convergence).Await(ct); - Registry.Serve("1.2.0", floor: FloorFixture.Below); - Registry.ParkNextIndex(); - try - { - var path = await Reboot(ct); - await Registry.IndexParked.Should().Within(TestTimeouts.Convergence) - .Emit("the Modules step reads the bundle index — parked there", ct); - await AwaitReboot(path, r => r.Steps.Any(s => s.Name == InstanceRebootSteps.Modules && s.Outcome == InstanceRebootStepOutcome.Running), ct); - - // The request ends while the Modules step is in flight. - await Access.RunAsSystem(() => Mesh.GetMeshNodeStream(path).Update(r => r with - { - Status = InstanceRebootStatus.Failed, - Failure = "ended while the Modules step ran", - CompletedAt = DateTimeOffset.UtcNow, - })) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - await AwaitReboot(path, r => r.Status == InstanceRebootStatus.Failed, ct); - Registry.ReleaseIndex(); - - await imageSelected.Should().NotEmit(TestTimeouts.Quick, - "a step that ends after its request ended must not start the next step", ct); - var after = await AwaitReboot(path, _ => true, ct); - after.Status.Should().Be(InstanceRebootStatus.Failed); - after.Failure.Should().Be("ended while the Modules step ran"); - InstanceRebootTest.Step(after, InstanceRebootSteps.Image).Outcome.Should().Be(InstanceRebootStepOutcome.Pending); - Updater.Restarts.Should().Be(0); - } - finally - { - Registry.ReleaseIndex(); - } - } - - private sealed class Watched(IInstanceRebootActivation inner, System.Reactive.Subjects.AsyncSubject selected) - : IInstanceRebootActivation - { - public IObservable SelectImage(bool wedged) => Observable.Defer(() => - { - selected.OnNext(System.Reactive.Unit.Default); - selected.OnCompleted(); - return inner.SelectImage(wedged); - }); - - public IObservable Activate(RebootImageChoice choice, string reason) => inner.Activate(choice, reason); - } -} - -/// -/// 🚨 MeshWeaver#6625 review: the reboot's Modules step reloads its modules one after another, so a long -/// batch must be progress all the way through — each module is REPORTED the moment its reload ended, -/// before the next one starts () — and a -/// report answering false (the reboot ended) reloads no further module. -/// -public class ModuleLandingReportingTest(ITestOutputHelper output) : ModuleReloadScenario(output) -{ - private async Task TwoModulesWithANewerVersion(CancellationToken ct) - { - FloorFixture.AssertTheFloorHolds(Mesh); - Registry.ServesSecond = true; - Registry.Serve("1.1.0", floor: null); - await InstallRunning(Package, Module, ct); - await InstallRunning(SecondPackage, SecondModule, ct); - Registry.Serve("1.2.0", floor: FloorFixture.Below); - return Registry.Downloads.Count; - } - - [Fact(Timeout = 240_000)] - public async Task EachModuleIsReported_AsItsReloadEnds_BeforeTheNextStarts() - { - var ct = TestContext.Current.CancellationToken; - var before = await TwoModulesWithANewerVersion(ct); - var reported = ImmutableList<(string Module, int Downloads)>.Empty; - - var landing = await ModuleReloadExecutor.ResolveAndLandReporting(Mesh, module: null, (module, _) => Observable.Defer(() => - { - ImmutableInterlocked.Update(ref reported, r => r.Add((module, Registry.Downloads.Count - before))); - return Observable.Return(true); - })) - .Timeout(TestTimeouts.Convergence).Await(ct); - - landing.Problem.Should().BeNull(); - reported.Select(r => r.Module).Should().Equal([Module, SecondModule], "every module is reported, in order"); - reported[0].Downloads.Should().Be(1, "the first module is reported before the second module's bundle was fetched"); - reported[1].Downloads.Should().Be(2); - landing.Items.Select(i => i.Module).Should().Equal([Module, SecondModule]); - } - - [Fact(Timeout = 240_000)] - public async Task AReportAnsweringFalse_ReloadsNoFurtherModule() - { - var ct = TestContext.Current.CancellationToken; - var before = await TwoModulesWithANewerVersion(ct); - - var landing = await ModuleReloadExecutor.ResolveAndLandReporting(Mesh, module: null, (_, _) => Observable.Return(false)) - .Timeout(TestTimeouts.Convergence).Await(ct); - - landing.Items.Select(i => i.Module).Should().Equal([Module], "the reboot ended after the first module"); - (Registry.Downloads.Count - before).Should().Be(1, "the second module's bundle is never fetched"); - } -} - -/// The rollout rule, pure — each refusal with its control. -public class RolloutStrategyRuleTest -{ - [Fact] - public void OnlyASurgingRollWithNoUnavailablePods_IsNonDisruptive() - { - RolloutStrategyReading.NonDisruptiveRefusal(new("RollingUpdate", "1", "0", 2)).Should().BeNull(); - RolloutStrategyReading.NonDisruptiveRefusal(new("RollingUpdate", "25%", "0", 4)).Should().BeNull("25% of 4 rounds UP to a surge of 1"); - RolloutStrategyReading.NonDisruptiveRefusal(new(null, "1", "25%", 1)).Should().BeNull("25% of 1 rounds DOWN to 0 unavailable"); - RolloutStrategyReading.NonDisruptiveRefusal(null).Should().Contain("could not be read"); - RolloutStrategyReading.NonDisruptiveRefusal(new("Recreate", null, null, 2)).Should().Contain("Recreate"); - RolloutStrategyReading.NonDisruptiveRefusal(new("RollingUpdate", null, null, 4)).Should().Contain("maxUnavailable resolves to 1 of 4"); - RolloutStrategyReading.NonDisruptiveRefusal(new("RollingUpdate", "0", "0", 2)).Should().Contain("maxSurge resolves to 0"); - RolloutStrategyReading.NonDisruptiveRefusal(new("RollingUpdate", "x", "0", 2)).Should().Contain("could not be resolved"); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceRebootWatchdogTest.cs b/test/Memex.Portal.Shared.Test/InstanceRebootWatchdogTest.cs deleted file mode 100644 index a548fe3415..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceRebootWatchdogTest.cs +++ /dev/null @@ -1,563 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Reflection; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The watchdog's rules, pure (Doc/Architecture/InstanceReboot → "Self-trigger"): what counts as -/// a wedge, how "continuously in Error" is folded, and the rate limit — each with its negative control. -/// -public class InstanceRebootWatchdogRulesTest -{ - private static readonly DateTimeOffset Now = new(2026, 10, 5, 12, 0, 0, TimeSpan.Zero); - private static readonly InstanceRebootOptions Options = new(); - - private static WedgeSignal Fault(int minutesAgo, string kind = WedgeSignalKinds.ThreadStart) => - new(kind, "Hosting/TriageIntake", "MissingMethodException: Method not found: 'Void ThreadPreparation.set_Group(String)'", Now.AddMinutes(-minutesAgo)); - - [Fact] - public void OnlyLoadAndBindingFaults_AreKept() - { - RebootWatchdogRules.LoadOrBindingFault(new MissingMethodException("ThreadPreparation", "set_Group")).Should().StartWith("MissingMethodException"); - RebootWatchdogRules.LoadOrBindingFault(new TypeLoadException("x")).Should().StartWith("TypeLoadException"); - RebootWatchdogRules.LoadOrBindingFault(new TargetInvocationException(new MissingFieldException("x"))).Should().StartWith("MissingFieldException"); - RebootWatchdogRules.LoadOrBindingFault(new AggregateException(new InvalidOperationException("a"), new TypeLoadException("b"))).Should().StartWith("TypeLoadException"); - RebootWatchdogRules.LoadOrBindingFault(new FileNotFoundException("gone", "MeshWeaver.AI, Version=1.21.0.0, Culture=neutral")).Should().StartWith("FileNotFoundException"); - // Negative controls: an ordinary failure, and a missing DATA file, are not wedges. - RebootWatchdogRules.LoadOrBindingFault(new InvalidOperationException("the model refused")).Should().BeNull(); - RebootWatchdogRules.LoadOrBindingFault(new FileNotFoundException("gone", "/data/notes.md")).Should().BeNull(); - RebootWatchdogRules.LoadOrBindingFault(new TimeoutException()).Should().BeNull(); - } - - [Fact] - public void ThreadStartLeg_FiresAtTheThreshold_InsideTheWindow_AndNotBelowOrOutside() - { - var empty = ImmutableDictionary.Empty; - RebootWatchdogRules.Evaluate([Fault(1), Fault(2), Fault(3)], empty, Now, Options).Should().NotBeNull(); - // Below the threshold. - RebootWatchdogRules.Evaluate([Fault(1), Fault(2)], empty, Now, Options).Should().BeNull(); - // Outside the window. - RebootWatchdogRules.Evaluate([Fault(1), Fault(2), Fault(30)], empty, Now, Options).Should().BeNull(); - // Another kind of signal does not count toward the thread-start leg. - RebootWatchdogRules.Evaluate([Fault(1, "Other"), Fault(2, "Other"), Fault(3, "Other")], empty, Now, Options).Should().BeNull(); - } - - [Fact] - public void CompileLeg_NeedsContinuousErrorForTheWholeDuration_AndAnUnreadablePassResets() - { - var since = ImmutableDictionary.Empty; - since = RebootWatchdogRules.FoldCompileErrors(since, ["Hosting/TriageItem"], Now.AddMinutes(-25)); - since = RebootWatchdogRules.FoldCompileErrors(since, ["Hosting/TriageItem"], Now); - RebootWatchdogRules.Evaluate([], since, Now, Options)!.Evidence.Should().Contain("Hosting/TriageItem"); - - // A pass that does not see it removes it — "continuously" means every pass. - var gap = RebootWatchdogRules.FoldCompileErrors(since, [], Now); - RebootWatchdogRules.Evaluate([], RebootWatchdogRules.FoldCompileErrors(gap, ["Hosting/TriageItem"], Now), Now, Options).Should().BeNull(); - - // A pass that could not read resets: no evidence is not evidence of a wedge. - RebootWatchdogRules.FoldCompileErrors(since, null, Now).Should().BeEmpty(); - - // Not long enough yet. - var young = RebootWatchdogRules.FoldCompileErrors(ImmutableDictionary.Empty, ["Hosting/TriageItem"], Now.AddMinutes(-5)); - RebootWatchdogRules.Evaluate([], young, Now, Options).Should().BeNull(); - } - - [Fact] - public void TheRateLimit_RefusesAnOpenReboot_ARecentSelfReboot_AndTheSameEvidenceAgain() - { - var verdict = new WedgeVerdict("e", "fp1"); - RebootWatchdogRules.Admit(verdict, [], Now, Options).Fire.Should().BeTrue(); - - RebootWatchdogRules.Admit(verdict, [new("Admin/_Reboot/a", InstanceRebootTrigger.Person, Now.AddMinutes(-2), null, Terminal: false)], Now, Options) - .Why.Should().Contain("already in progress"); - RebootWatchdogRules.Admit(verdict, [new("Admin/_Reboot/a", InstanceRebootTrigger.Watchdog, Now.AddHours(-1), "other", Terminal: true)], Now, Options) - .Why.Should().Contain("rate-limited"); - RebootWatchdogRules.Admit(verdict, [new("Admin/_Reboot/a", InstanceRebootTrigger.Watchdog, Now.AddHours(-8), "fp1", Terminal: true)], Now, Options) - .Why.Should().Contain("same evidence survived"); - - // Controls: a PERSON's finished reboot an hour ago does not rate-limit the watchdog; a self-reboot - // with OTHER evidence beyond the interval does not either. - RebootWatchdogRules.Admit(verdict, [new("Admin/_Reboot/a", InstanceRebootTrigger.Person, Now.AddHours(-1), null, Terminal: true)], Now, Options).Fire.Should().BeTrue(); - RebootWatchdogRules.Admit(verdict, [new("Admin/_Reboot/a", InstanceRebootTrigger.Watchdog, Now.AddHours(-8), "fp2", Terminal: true)], Now, Options).Fire.Should().BeTrue(); - } - - /// - /// 🚨 MeshWeaver#6625 (+ review): a stalled request releases the self-reboot only once its OWNER ended - /// it. While it is still open — stalled-looking or not — it holds the self-reboot back, because it could - /// still be re-armed and roll beside a replacement. - /// - [Fact] - public void AStalledReboot_ReleasesTheSelfRebootOnlyOnceItsOwnerEndedIt() - { - var verdict = new WedgeVerdict("e", "fp1"); - var ended = new RebootRecord("Admin/_Reboot/a", InstanceRebootTrigger.Person, Now.AddHours(-21), null, Terminal: true) - { - Stalled = "status Faulted, no progress since …", - }; - RebootWatchdogRules.Admit(verdict, [ended], Now, Options).Fire.Should().BeTrue(); - RebootWatchdogRules.Admit(verdict, [ended with { Terminal = false }], Now, Options).Why.Should().Contain("already in progress"); - } - - private static readonly TimeSpan RetryUnit = TimeSpan.FromMinutes(30); - - [Fact] - public void ARequest_StallsWhenItRecordsNoProgressWithinItsDeclaredWindow_ByStatus() - { - var window = TimeSpan.FromHours(3); - var restart = Now.AddHours(-4); - var awaiting = new InstanceRebootRequest - { - Reason = "x", - RequestedAt = Now.AddHours(-5), - Status = InstanceRebootStatus.AwaitingRestart, - RestartRequestedAt = restart, - ProgressAt = restart, - ExecutionWindow = window, - }; - InstanceReboot.Stalled(awaiting, Now, Options.ExecutionWindow, RetryUnit) - .Should().Contain("AwaitingRestart").And.Contain("no process booted after it has reported"); - - // Controls: a replica that reported an hour ago is progress; so is a wider declared window; and a - // terminal request is never "stalled", however old. - InstanceReboot.Stalled(awaiting with - { - Replicas = ImmutableDictionary.Empty.Add("p", - new InstanceRebootReplica { Process = "p", StartedAt = Now.AddHours(-2), ReportedAt = Now.AddHours(-1) }), - }, Now, Options.ExecutionWindow, RetryUnit).Should().BeNull(); - InstanceReboot.Stalled(awaiting with { ExecutionWindow = TimeSpan.FromHours(5) }, Now, Options.ExecutionWindow, RetryUnit).Should().BeNull(); - InstanceReboot.Stalled(awaiting with { Status = InstanceRebootStatus.Failed }, Now, Options.ExecutionWindow, RetryUnit).Should().BeNull(); - - // A request filed before the window was declared is judged by the reader's default. - InstanceReboot.Stalled(awaiting with { ExecutionWindow = null }, Now, TimeSpan.FromHours(5), RetryUnit).Should().BeNull(); - InstanceReboot.Stalled(awaiting with { ExecutionWindow = null }, Now, window, RetryUnit).Should().NotBeNull(); - - // Preparing: the step it stopped at is named. - var preparing = new InstanceRebootRequest - { - Reason = "x", - RequestedAt = Now.AddHours(-21), - Status = InstanceRebootStatus.Preparing, - Steps = InstanceReboot.WithStep(InstanceReboot.FreshSteps(), InstanceRebootSteps.Sync, InstanceRebootStepOutcome.Running, "importing", - Now.AddHours(-21)), - }; - InstanceReboot.Stalled(preparing, Now, Options.ExecutionWindow, RetryUnit).Should().Contain("its last step is Sync (Running)"); - - // Faulted is judged from when its re-arm was DUE: a long backoff still ahead is not a stall. - var faulted = new InstanceRebootRequest - { - Reason = "x", - RequestedAt = Now.AddHours(-20), - Status = InstanceRebootStatus.Faulted, - FaultedAt = Now.AddHours(-10), - Attempt = 5, // 30 min × 32 = 16 h of backoff: due in 6 h - }; - InstanceReboot.Stalled(faulted, Now, Options.ExecutionWindow, RetryUnit).Should().BeNull(); - InstanceReboot.Stalled(faulted with { Attempt = 0 }, Now, Options.ExecutionWindow, RetryUnit) - .Should().Contain("nothing re-armed it when its retry was due"); - } - - /// - /// #6625 review: a long Sync batch is progress all the way through — each finished source stamps - /// , so a step that has been Running for longer than the - /// window is NOT stalled while its sources keep finishing. Control: the same step with no per-source - /// stamp is. - /// - [Fact] - public void ASyncBatchLongerThanTheWindow_IsNotStalled_WhileItsSourcesKeepFinishing() - { - var batch = new InstanceRebootRequest - { - Reason = "x", - RequestedAt = Now.AddHours(-4), - Status = InstanceRebootStatus.Preparing, - ExecutionWindow = TimeSpan.FromHours(3), - Steps = InstanceReboot.WithStep(InstanceReboot.FreshSteps(), InstanceRebootSteps.Sync, InstanceRebootStepOutcome.Running, - "importing", Now.AddHours(-4)), - ProgressAt = Now.AddMinutes(-9), // the 21st 9-minute import just finished - }; - InstanceReboot.Stalled(batch, Now, Options.ExecutionWindow, RetryUnit).Should().BeNull(); - InstanceReboot.Stalled(batch with { ProgressAt = Now.AddHours(-4) }, Now, Options.ExecutionWindow, RetryUnit) - .Should().Contain("its last step is Sync (Running)"); - } - - /// #6625 review: an ENDED request is named as stalled from its persisted marker, whoever ended it. - [Fact] - public void AnEndedRequest_IsNamedAsStalled_FromItsPersistedMarker() - { - var ended = InstanceReboot.EndIfStalled(new InstanceRebootRequest - { - Reason = "x", - RequestedAt = Now.AddHours(-5), - Status = InstanceRebootStatus.AwaitingRestart, - RestartRequestedAt = Now.AddHours(-4), - ProgressAt = Now.AddHours(-4), - ExecutionWindow = TimeSpan.FromHours(3), - }, Now, Options.ExecutionWindow, RetryUnit); - InstanceReboot.EndedAsStalled(ended).Should().Contain("no process booted after it has reported"); - // Controls: Failed for another reason, Done, and an open request are not "ended as stalled". - InstanceReboot.EndedAsStalled(ended with { Failure = "Verify: smoke red" }).Should().BeNull(); - InstanceReboot.EndedAsStalled(ended with { Status = InstanceRebootStatus.Done }).Should().BeNull(); - InstanceReboot.EndedAsStalled(ended with { Status = InstanceRebootStatus.AwaitingRestart }).Should().BeNull(); - } - - /// - /// #6625 review: the end is decided by the OWNER, against its current state. A replica report that - /// landed after a reader judged the request stalled keeps it open; without it, the owner ends it. - /// - [Fact] - public void TheOwnerEndsOnlyWhatIsStillStalled_AndAlwaysClearsTheQuestion() - { - var asked = new InstanceRebootRequest - { - Reason = "x", - RequestedAt = Now.AddHours(-5), - Status = InstanceRebootStatus.AwaitingRestart, - RestartRequestedAt = Now.AddHours(-4), - ProgressAt = Now.AddHours(-4), - ExecutionWindow = TimeSpan.FromHours(3), - RequestedEndIfStalledAt = Now.AddSeconds(-1), - }; - var ended = InstanceReboot.EndIfStalled(asked, Now, Options.ExecutionWindow, RetryUnit); - ended.Status.Should().Be(InstanceRebootStatus.Failed); - ended.Failure.Should().StartWith("stalled:"); - ended.RequestedEndIfStalledAt.Should().BeNull(); - - var reported = asked with - { - Replicas = ImmutableDictionary.Empty.Add("p", - new InstanceRebootReplica { Process = "p", StartedAt = Now.AddMinutes(-2), ReportedAt = Now.AddSeconds(-1) }), - }; - var kept = InstanceReboot.EndIfStalled(reported, Now, Options.ExecutionWindow, RetryUnit); - kept.Status.Should().Be(InstanceRebootStatus.AwaitingRestart); - kept.RequestedEndIfStalledAt.Should().BeNull(); - kept.ProgressAt.Should().Be(asked.ProgressAt, "answering the question is not progress"); - } -} - -/// -/// 🚨 The self-trigger on a real mesh: the watchdog fires on the wedge predicate (thread starts failing -/// with a load/binding fault), files ONE reboot as the watchdog, and refuses — alarmed, rate-limited — -/// to fire again; on a healthy instance, and on ordinary (non-binding) failures, it files nothing. -/// -public class InstanceRebootWatchdogTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => - base.ConfigureMesh(builder) - .AddGitHubSyncTypes() - .AddPluginCatalog() - .ConfigureServices(services => - { - services.AddGitHubSyncServices(); - // Enabled, but never armed on a timer here: each pass is driven by the test. - return services.AddSingleton(new InstanceRebootOptions - { - WatchdogEnabled = true, - WatchdogInterval = TimeSpan.Zero, - SingletonPasses = [new RebootSingletonPass("PR babysitter", BabysitterPath, "lastRunAt"), - new RebootSingletonPass("PR review sweep", "Ops/Triage/Status", "lastPrSweepAt")], - SingletonResumeBudget = TestTimeouts.Quick, - }); - }); - - private RebootWatchdog Watchdog => Mesh.ServiceProvider.GetRequiredService(); - private WedgeSignals Signals => Mesh.ServiceProvider.GetRequiredService(); - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - private Task> Reboots() => - Access.RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService().Query(MeshQueryRequest.FromQuery( - $"namespace:{InstanceRebootRequest.Namespace} scope:children nodeType:{InstanceRebootRequest.NodeType}")).Take(1)) - .Select(change => change.Items.Select(n => n.ContentAs(Mesh.JsonSerializerOptions)!).ToImmutableList()) - .Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - - /// - /// The control lane's Reboot: its plan is FIXED (the control side binds the digest without a dry run), - /// its target is the request namespace, and executing it files ONE person-triggered reboot carrying - /// the control-side requester. - /// - [Fact(Timeout = 240_000)] - public async Task TheLaneReboot_HasAFixedPlan_AndFilesOneRequestForTheRequester() - { - var ct = TestContext.Current.CancellationToken; - var operation = new MeshWeaver.Graph.ControlLane.RebootOperation(); - var lane = new MeshWeaver.Graph.ControlLane.ControlLaneRequest - { - RequestId = "r-0123456789abcdef", - Deployment = "memex", - Operation = MeshWeaver.Graph.ControlLane.ControlLaneOperation.Reboot, - Target = MeshWeaver.Graph.ControlLane.RebootOperation.Target, - Reason = "thread starts throw", - RequestedBy = "alice", - ApprovedBy = "alice", - Action = "Ops/Actions/reboot-memex", - }; - operation.ShapeRefusal(lane).Should().BeNull(); - operation.ShapeRefusal(lane with { Target = "Hosting/TriageItem" }).Should().Contain("names the target"); - - var preparation = await operation.Prepare(Mesh, lane).Timeout(TestTimeouts.Convergence).Await(ct); - preparation.Plan.Digest().Should().Be(MeshWeaver.Graph.ControlLane.RebootOperation.PlanFor("memex").Digest(), - "the control side computes the same digest without asking the target"); - var line = await preparation.Execute().Timeout(TestTimeouts.Convergence).Await(ct); - line.Should().Contain("[Reboot] filed " + InstanceRebootRequest.Namespace + "/"); - - var filed = (await Reboots()).Should().ContainSingle().Subject; - filed.Trigger.Should().Be(InstanceRebootTrigger.Person); - filed.RequestedBy.Should().Be("alice"); - } - - private const string BabysitterPath = "Ops/Babysitter"; - - private Task Stamp(string path, DateTimeOffset at, System.Threading.CancellationToken ct) => - Access.RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService().CreateOrUpdateNode( - MeshNode.FromPath(path) with - { - NodeType = "Markdown", - Content = System.Text.Json.JsonSerializer.SerializeToElement(new { lastRunAt = at }), - })) - .Timeout(TestTimeouts.Convergence).Await(ct); - - /// - /// 🚨 Singletons must RESUME after the reboot: a pass stamped BEFORE the restart is not evidence — the - /// check waits for a pass newer than the restart (green once one lands), stays RED naming the stale - /// pass when none lands in time, and reports a singleton this instance does not have as not measured. - /// - [Fact(Timeout = 240_000)] - public async Task ASingletonMustPassAfterTheRestart_AStalePassIsRed() - { - var ct = TestContext.Current.CancellationToken; - var restart = DateTimeOffset.UtcNow; - var request = new InstanceRebootRequest { Reason = "x", RestartRequestedAt = restart }; - var check = new SingletonsResumedRebootCheck(); - - // Negative control: the last pass is 49 minutes before the restart, and nothing passes again. - await Stamp(BabysitterPath, restart.AddMinutes(-49), ct); - var stale = await check.Run(Mesh, request).Timeout(TestTimeouts.Convergence).Await(ct); - stale.Outcome.Should().Be(InstanceRebootCheckOutcome.Failed); - stale.Detail.Should().Contain("PR babysitter: Failed").And.Contain("did NOT resume") - .And.Contain("PR review sweep: NotMeasured"); - - // A fresh pass after the restart lands while the check waits → green. - var waiting = check.Run(Mesh, request).Timeout(TestTimeouts.Convergence).Await(ct); - await Stamp(BabysitterPath, DateTimeOffset.UtcNow.AddSeconds(1), ct); - var resumed = await waiting; - resumed.Outcome.Should().Be(InstanceRebootCheckOutcome.Passed, resumed.Detail ?? ""); - resumed.Detail.Should().Contain("PR babysitter: Passed"); - } - - [Fact(Timeout = 240_000)] - public async Task AHealthyInstance_FilesNothing_AndOrdinaryFailuresAreNotAWedge() - { - var ct = TestContext.Current.CancellationToken; - (await Watchdog.Evaluate(Mesh).Timeout(TestTimeouts.Convergence).Await(ct)).Decision.Should().Be(RebootWatchdogDecision.Healthy); - - // Negative control: five thread starts failing for a reason that is NOT a load/binding fault. - for (var i = 0; i < 5; i++) - Mesh.ReportWedgeFault(WedgeSignalKinds.ThreadStart, "test", new InvalidOperationException("the model refused")).Should().BeFalse(); - (await Watchdog.Evaluate(Mesh).Timeout(TestTimeouts.Convergence).Await(ct)).Decision.Should().Be(RebootWatchdogDecision.Healthy); - (await Reboots()).Should().BeEmpty(); - } - - [Fact(Timeout = 240_000)] - public async Task RepeatedBindingFaultsOnThreadStart_FireOneSelfReboot_ThenTheRateLimitHolds() - { - var ct = TestContext.Current.CancellationToken; - for (var i = 0; i < 3; i++) - Mesh.ReportWedgeFault(WedgeSignalKinds.ThreadStart, "Hosting/TriageIntake", - new MissingMethodException("MeshWeaver.AI.ThreadPreparation", "set_Group")).Should().BeTrue(); - - var fired = await Watchdog.Evaluate(Mesh).Timeout(TestTimeouts.Convergence).Await(ct); - fired.Decision.Should().Be(RebootWatchdogDecision.Fired, fired.Detail); - fired.Path.Should().StartWith(InstanceRebootRequest.Namespace + "/"); - - var request = await Access.RunAsSystem(() => Mesh.GetMeshNodeStream(fired.Path!)) - .Select(n => n.ContentAs(Mesh.JsonSerializerOptions)) - .Where(r => r is not null) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - request!.Trigger.Should().Be(InstanceRebootTrigger.Watchdog); - request.Wedged.Should().BeTrue(); - request.RequestedBy.Should().Be(RebootWatchdog.RequesterName); - request.WedgeEvidence.Should().Contain("3 thread start(s) failed").And.Contain("MissingMethodException"); - - // The same evidence a pass later: rate-limited, alarmed, nothing filed. - var again = await Watchdog.Evaluate(Mesh).Timeout(TestTimeouts.Convergence).Await(ct); - again.Decision.Should().Be(RebootWatchdogDecision.Suppressed); - again.Detail.Should().Contain("rate-limited"); - (await Reboots()).Should().HaveCount(1, "at most one self-reboot per interval"); - } - - /// Writes a reboot request as System, exactly as would, - /// but in a state the test chooses. Faulted: the executor and the per-process agent leave it alone. - private async Task FileFaulted(string id, DateTimeOffset faultedAt, System.Threading.CancellationToken ct) - { - // The boot reconcile pass re-arms a due Faulted request; let it finish before the request exists. - await Mesh.ServiceProvider.GetRequiredService().BootReconciled - .DefaultIfEmpty().Timeout(TestTimeouts.Convergence).Await(ct); - var node = new MeshNode(id, InstanceRebootRequest.Namespace) - { - Name = "Reboot", - NodeType = InstanceRebootRequest.NodeType, - Content = new InstanceRebootRequest - { - Reason = "a reboot whose restart crashed", - RequestedBy = "alice", - RequestedAt = faultedAt.AddMinutes(-5), - Status = InstanceRebootStatus.Faulted, - FaultedAt = faultedAt, - ProgressAt = faultedAt, - Failure = "the restart request faulted: the hand-over failed", - ExecutionWindow = TimeSpan.FromHours(3), - Steps = InstanceReboot.FreshSteps(), - }, - }; - await Access.RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService().CreateNode(node)) - .Timeout(TestTimeouts.Convergence).Await(ct); - return node.Path; - } - - private Task Read(string path, Func until) => - Access.RunAsSystem(() => Mesh.GetMeshNodeStream(path)) - .Select(n => n.ContentAs(Mesh.JsonSerializerOptions)) - .Where(r => r is not null && until(r)) - .Select(r => r!) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - /// - /// 🚨 MeshWeaver#6625 — the incident's shape: ONE reboot request that stopped moving (its restart - /// crashed hours ago and nothing re-armed it) held every pod's self-reboot back for as long as it - /// existed. Now the watchdog judges it by its DECLARED execution window: stalled, so it fires (and - /// alarms on the stalled request by name), and the reconcile pass ends the stalled request as - /// Failed, naming the stall — it reaches a terminal state. - /// - [Fact(Timeout = 240_000)] - public async Task AStalledRebootRequest_NoLongerHoldsTheSelfRebootBack_AndThePassEndsIt() - { - var ct = TestContext.Current.CancellationToken; - var stalled = await FileFaulted("stalled-6625", DateTimeOffset.UtcNow.AddHours(-5), ct); - - for (var i = 0; i < 3; i++) - Mesh.ReportWedgeFault(WedgeSignalKinds.ThreadStart, "Hosting/TriageIntake", - new MissingMethodException("MeshWeaver.AI.ThreadPreparation", "set_Group")).Should().BeTrue(); - var pass = await Watchdog.Evaluate(Mesh).Timeout(TestTimeouts.Convergence).Await(ct); - pass.Decision.Should().Be(RebootWatchdogDecision.Fired, - "a request that recorded no progress within its declared window is ended by its owner, then released: " + pass.Detail); - - // The original was ENDED by its owner before the replacement was filed — not merely ignored. - var after = await Read(stalled, _ => true); - after.Status.Should().Be(InstanceRebootStatus.Failed); - after.Failure.Should().Contain("stalled:").And.Contain("declared execution window of 180 min") - .And.Contain("nothing re-armed it"); - after.CompletedAt.Should().NotBeNull(); - - // 🚨 The ordering the review named: the next reconcile pass — re-arm, then the stall pass — must not - // revive the original beside its replacement. Its re-arm is long due, so only its END prevents it. - var now = DateTimeOffset.UtcNow; - var rearmed = await InstanceReboot.RetryFaulted(Mesh, TimeSpan.FromMinutes(30), now) - .Timeout(TestTimeouts.Convergence).Await(ct); - rearmed.Should().NotContain(stalled, "an ended request is never re-armed, so it cannot roll beside the replacement"); - var asked = await InstanceReboot.FailStalled(Mesh, TimeSpan.FromHours(3), TimeSpan.FromMinutes(30), now) - .Timeout(TestTimeouts.Convergence).Await(ct); - asked.Should().NotContain(stalled); - (await Read(stalled, _ => true)).Status.Should().Be(InstanceRebootStatus.Failed); - } - - /// - /// 🚨 #6625 review — a report lands BETWEEN the reader's evaluation and the commit: the reader saw the - /// request stalled, a replica then reported, and only then is the end asked for. The owner judges its - /// own current state, so the request stays open; the question is answered (cleared) either way. - /// - [Fact(Timeout = 240_000)] - public async Task AReportLandingAfterTheEvaluation_KeepsTheRequestOpen() - { - var ct = TestContext.Current.CancellationToken; - var path = await FileFaulted("reported-6625", DateTimeOffset.UtcNow.AddHours(-5), ct); - - // The reader's evaluation: stalled. - var seen = await Read(path, _ => true); - InstanceReboot.Stalled(seen, DateTimeOffset.UtcNow, TimeSpan.FromHours(3), TimeSpan.FromMinutes(30)).Should().NotBeNull(); - - // A replica reports after that evaluation… - await Access.RunAsSystem(() => Mesh.GetMeshNodeStream(path).Update(r => r with - { - Replicas = r.Replicas.SetItem("late-pod", new InstanceRebootReplica - { - Process = "late-pod", StartedAt = DateTimeOffset.UtcNow.AddMinutes(-1), ReportedAt = DateTimeOffset.UtcNow, - }), - })) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - // …and only then is the end asked for. - var asked = await InstanceReboot.RequestEndIfStalled(Mesh, path, DateTimeOffset.UtcNow) - .Timeout(TestTimeouts.Convergence).Await(ct); - - // The owner's ANSWER is the version after the question that clears it. - var answered = await Access.RunAsSystem(() => Mesh.GetMeshNodeStream(path)) - .Where(n => n.Version > asked.Version) - .Select(n => n.ContentAs(Mesh.JsonSerializerOptions)) - .Where(r => r is not null && r.RequestedEndIfStalledAt is null) - .Select(r => r!) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - answered.Status.Should().Be(InstanceRebootStatus.Faulted, "the owner saw the late report — not stalled"); - answered.Failure.Should().NotContain("stalled:"); - answered.Replicas.Should().ContainKey("late-pod"); - } - - /// - /// 🚨 #6625 review — the request was ENDED (by the reconcile pass's question) BEFORE this watchdog read - /// it. It is terminal, so it never goes to its owner again — but it is still NAMED: the pass lists it, - /// read back from the persisted marker, and the Critical line is written for it. - /// - [Fact(Timeout = 240_000)] - public async Task ARequestEndedBeforeTheWatchdogReadIt_IsStillNamed() - { - var ct = TestContext.Current.CancellationToken; - var stalled = await FileFaulted("ended-first-6625", DateTimeOffset.UtcNow.AddHours(-5), ct); - var asked = await InstanceReboot.FailStalled(Mesh, TimeSpan.FromHours(3), TimeSpan.FromMinutes(30), DateTimeOffset.UtcNow) - .Timeout(TestTimeouts.Convergence).Await(ct); - asked.Should().Equal([stalled]); - (await Read(stalled, r => InstanceRebootStatus.IsTerminal(r.Status))).Status.Should().Be(InstanceRebootStatus.Failed); - - for (var i = 0; i < 3; i++) - Mesh.ReportWedgeFault(WedgeSignalKinds.ThreadStart, "Hosting/TriageIntake", - new MissingMethodException("MeshWeaver.AI.ThreadPreparation", "set_Group")).Should().BeTrue(); - var pass = await Watchdog.Evaluate(Mesh).Timeout(TestTimeouts.Convergence).Await(ct); - pass.Decision.Should().Be(RebootWatchdogDecision.Fired, pass.Detail); - pass.EndedStalled.Should().Contain(stalled, "a request another reader ended as stalled is still named"); - } - - /// The control: the same request, faulted a minute ago (its re-arm still ahead), IS in progress — - /// it holds the self-reboot back, and the pass leaves it open. - [Fact(Timeout = 240_000)] - public async Task AProgressingRebootRequest_StillHoldsTheSelfRebootBack_AndThePassLeavesItOpen() - { - var ct = TestContext.Current.CancellationToken; - var open = await FileFaulted("progressing-6625", DateTimeOffset.UtcNow.AddMinutes(-1), ct); - - for (var i = 0; i < 3; i++) - Mesh.ReportWedgeFault(WedgeSignalKinds.ThreadStart, "Hosting/TriageIntake", - new MissingMethodException("MeshWeaver.AI.ThreadPreparation", "set_Group")).Should().BeTrue(); - var pass = await Watchdog.Evaluate(Mesh).Timeout(TestTimeouts.Convergence).Await(ct); - pass.Decision.Should().Be(RebootWatchdogDecision.Suppressed); - pass.Detail.Should().Contain("already in progress").And.Contain(open); - - var ended = await InstanceReboot.FailStalled(Mesh, TimeSpan.FromHours(3), TimeSpan.FromMinutes(30), DateTimeOffset.UtcNow) - .Timeout(TestTimeouts.Convergence).Await(ct); - ended.Should().BeEmpty(); - (await Read(open, _ => true)).Status.Should().Be(InstanceRebootStatus.Faulted); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceRepositoryAccessUnitTest.cs b/test/Memex.Portal.Shared.Test/InstanceRepositoryAccessUnitTest.cs deleted file mode 100644 index a840a6da9b..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceRepositoryAccessUnitTest.cs +++ /dev/null @@ -1,545 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Mesh.Threading; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The pure half of the instance's own repository access (Doc/Architecture/InstanceRepositoryAccess): -/// the manifest an instance posts to GitHub, the state check that guards the callback, the -/// conversion reader, which App identity mints a token, and which identity reads an instance-local -/// package source. No mesh: every rule here is a function of its inputs. -/// -public class InstanceRepositoryAccessUnitTest -{ - private static readonly TimeSpan Budget = TimeSpan.FromSeconds(10); - private static readonly DateTimeOffset Now = new(2026, 10, 18, 9, 0, 0, TimeSpan.Zero); - - // ───────────────────────────── the manifest ───────────────────────────── - - [Fact] - public void TheManifest_AsksForAPrivateReadOnlyApp_ThatReturnsToThisInstanceOnly() - { - var manifest = JsonNode.Parse(InstanceGitHubApp.BuildManifest("https://portal.example.com/"))!; - - Assert.Equal("https://portal.example.com/admin/github-app/callback", (string?)manifest["redirect_url"]); - Assert.Equal("https://portal.example.com", (string?)manifest["url"]); - Assert.False((bool)manifest["public"]!); - var permissions = manifest["default_permissions"]!.AsObject(); - Assert.Equal(["contents", "metadata"], permissions.Select(p => p.Key).OrderBy(k => k)); - Assert.All(permissions, p => Assert.Equal("read", (string?)p.Value)); - Assert.Equal("https://portal.example.com/webhooks/github", (string?)manifest["hook_attributes"]!["url"]); - Assert.True((bool)manifest["hook_attributes"]!["active"]!); - Assert.Equal(["push"], manifest["default_events"]!.AsArray().Select(e => (string?)e)); - Assert.Equal("MeshWeaver portal.example.com", (string?)manifest["name"]); - } - - [Fact] - public void TheAppName_FitsGitHubsLimit() - { - var name = InstanceGitHubApp.AppName("a-very-long-instance-host-name.example.com"); - Assert.True(name.Length <= 34, name); - Assert.StartsWith("MeshWeaver ", name); - } - - [Theory] - [InlineData("acme", "https://github.com/organizations/acme/settings/apps/new?state=s%2Bt")] - [InlineData("", "https://github.com/settings/apps/new?state=s%2Bt")] - [InlineData(null, "https://github.com/settings/apps/new?state=s%2Bt")] - [InlineData("acme/../evil", null)] - [InlineData("-acme", null)] - public void TheCreationPage_IsTheOrganizationsOrTheUsersOwn_AndRefusesAnOddName(string? org, string? expected) => - Assert.Equal(expected, InstanceGitHubApp.CreationUrl(org, "s+t")); - - [Fact] - public void TheInstallPage_IsAddressedBySlug() - { - Assert.Equal("https://github.com/apps/meshweaver-portal/installations/new", - InstanceGitHubApp.InstallUrl(new InstanceGitHubAppContent { Slug = "meshweaver-portal" })); - Assert.Null(InstanceGitHubApp.InstallUrl(new InstanceGitHubAppContent())); - Assert.Null(InstanceGitHubApp.InstallUrl(null)); - } - - // ───────────────────────────── the state ───────────────────────────── - - private static InstanceGitHubAppFlowContent Pending(string state, string by = "boss", DateTimeOffset? expires = null) => new() - { - StateHash = InstanceGitHubApp.HashState(state), - ExpiresAt = expires ?? Now.AddMinutes(5), - StartedBy = by, - }; - - [Fact] - public void AStateIsRandom_AndOnlyItsHashIsKept() - { - var a = InstanceGitHubApp.NewState(); - var b = InstanceGitHubApp.NewState(); - Assert.NotEqual(a, b); - Assert.True(a.Length >= 40, "32 random bytes"); - Assert.DoesNotContain(a, InstanceGitHubApp.HashState(a)); - Assert.Matches("^[0-9a-f]{64}$", InstanceGitHubApp.HashState(a)); - } - - [Fact] - public void TheCallback_PassesOnlyWhenCookieStatePendingHashExpiryAndAdminAllAgree() - { - var state = InstanceGitHubApp.NewState(); - Assert.Null(InstanceGitHubApp.CheckState(Pending(state), state, state, "boss", Now)); - - // A code from a flow this browser did not start: GitHub's state is not the cookie's. - Assert.Equal("instanceApp.error.stateMismatch", - InstanceGitHubApp.CheckState(Pending(state), state, "other", "boss", Now)!.Key); - Assert.Equal("instanceApp.error.stateMismatch", - InstanceGitHubApp.CheckState(Pending(state), state, null, "boss", Now)!.Key); - Assert.Equal("instanceApp.error.stateMismatch", - InstanceGitHubApp.CheckState(Pending(state), null, null, "boss", Now)!.Key); - // A replay, after the pending flow was cleared — or a state nobody recorded. - Assert.Equal("instanceApp.error.noPendingFlow", - InstanceGitHubApp.CheckState(new InstanceGitHubAppFlowContent(), state, state, "boss", Now)!.Key); - Assert.Equal("instanceApp.error.noPendingFlow", - InstanceGitHubApp.CheckState(null, state, state, "boss", Now)!.Key); - Assert.Equal("instanceApp.error.noPendingFlow", - InstanceGitHubApp.CheckState(Pending("another"), state, state, "boss", Now)!.Key); - // Too late. - Assert.Equal("instanceApp.error.flowExpired", - InstanceGitHubApp.CheckState(Pending(state, expires: Now), state, state, "boss", Now)!.Key); - // Another administrator cannot finish somebody else's flow. - Assert.Equal("instanceApp.error.otherAdmin", - InstanceGitHubApp.CheckState(Pending(state), state, state, "colleague", Now)!.Key); - } - - // ───────────────────────────── the conversion ───────────────────────────── - - [Fact] - public void TheConversion_IsReadAndNeverPrinted() - { - const string pem = "-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----\n"; - var json = new JsonObject - { - ["id"] = 4242, - ["slug"] = "meshweaver-portal", - ["name"] = "MeshWeaver portal", - ["client_id"] = "Iv23liInstance", - ["client_secret"] = "never-kept", - ["pem"] = pem, - ["webhook_secret"] = "hook-secret", - ["html_url"] = "https://github.com/apps/meshweaver-portal", - ["owner"] = new JsonObject { ["login"] = "acme" }, - }.ToJsonString(); - - var conversion = GitHubAppManifestConversion.Parse(json); - Assert.Equal(4242, conversion.AppId); - Assert.Equal("meshweaver-portal", conversion.Slug); - Assert.Equal("Iv23liInstance", conversion.ClientId); - Assert.Equal(pem, conversion.PrivateKey); - Assert.Equal("hook-secret", conversion.WebhookSecret); - Assert.Equal("acme", conversion.Owner); - Assert.DoesNotContain("BEGIN", conversion.ToString()); - Assert.DoesNotContain("hook-secret", conversion.ToString()); - } - - [Theory] - [InlineData("""{"id": 1, "slug": "s", "client_id": "c"}""", "instanceApp.error.conversionIncomplete")] - [InlineData("""{"slug": "s", "client_id": "c", "pem": "p"}""", "instanceApp.error.conversionIncomplete")] - [InlineData("not json", "instanceApp.error.conversionUnreadable")] - public void AnIncompleteConversion_IsRefusedByName(string json, string key) - { - var refused = Assert.ThrowsAny(() => GitHubAppManifestConversion.Parse(json)); - Assert.True(GitSyncRefusalException.TryGet(refused, out var refusal)); - Assert.Equal(key, refusal.Key); - } - - [Fact] - public void AFlowsNode_IsNamedByItsStatesHash_NeverTheState() - { - var state = InstanceGitHubApp.NewState(); - var path = InstanceGitHubApp.FlowPath(state); - Assert.StartsWith("Admin/GitHubAppFlow-", path); - Assert.DoesNotContain(state, path); - Assert.Equal(path, InstanceGitHubApp.FlowPath(state)); - Assert.NotEqual(path, InstanceGitHubApp.FlowPath(InstanceGitHubApp.NewState())); - } - - // ───────────────────────────── the credential bundle ───────────────────────────── - - [Fact] - public void TheCredentialBundle_RoundTrips_AndIsNeverPrinted() - { - var bundle = new InstanceGitHubAppCredentials(4242, "Iv23liInstance", "-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----\n", Now); - var back = InstanceGitHubAppCredentials.Parse(bundle.Serialize()); - Assert.Equal(bundle, back); - Assert.DoesNotContain("PRIVATE KEY", bundle.ToString()); - Assert.Null(InstanceGitHubAppCredentials.Parse("not json")); - Assert.Null(InstanceGitHubAppCredentials.Parse("{\"clientId\":\"c\"}")); - Assert.Null(InstanceGitHubAppCredentials.Parse(null)); - } - - // ───────────────────────────── which App mints ───────────────────────────── - - [Fact] - public async Task WithoutADeploymentApp_TheInstancesOwnAppMints_AndAConnectOrDisconnectIsLive() - { - using var instanceKey = RSA.Create(2048); - GitHubAppOptions? connected = null; - var handler = new AppHandler(); - var service = new GitHubAppTokenService( - new IoPoolRegistry(), Options.Create(new GitHubAppOptions()), () => connected, - httpClient: new HttpClient(handler), clock: () => Now); - - Assert.False(service.IsConfigured); - Assert.False(service.HasInstanceApp); - Assert.False(service.IsDeploymentConfigured); - - // Connected on some replica: the next mint uses it — no restart, no new service. - connected = new GitHubAppOptions { ClientId = "Iv23liInstance", PrivateKey = instanceKey.ExportRSAPrivateKeyPem() }; - Assert.True(service.IsConfigured); - Assert.True(service.HasInstanceApp); - Assert.False(service.IsDeploymentConfigured); - Assert.Equal("ghs_Iv23liInstance_1", await service.GetInstallationToken().FirstAsync().Timeout(Budget).Await()); - Assert.Equal("ghs_Iv23liInstance_1", await service.GetInstanceAppInstallationToken().FirstAsync().Timeout(Budget).Await()); - - // Disconnected: nothing falls back — the mint is refused as NOT CONFIGURED. - connected = null; - Assert.False(service.IsConfigured); - var refused = await Assert.ThrowsAsync( - () => service.GetInstanceAppInstallationToken().FirstAsync().Timeout(Budget).Await()); - Assert.Equal(GitHubAppTokenMintStage.NotConfigured, refused.Stage); - // …and it carries its keyed refusal, so a German viewer reads it in German. - Assert.True(GitSyncRefusalException.TryGet(refused, out var keyed)); - Assert.Equal("gitsync.instanceApp.notConnected", keyed.Key); - Assert.NotEqual(refused.Message, GitSyncRefusalException.Localize(refused, "de")); - } - - [Fact] - public async Task TheDeploymentsApp_StillWinsTheDefault_WhileAnInstanceSourceAsksForTheInstanceApp() - { - using var deploymentKey = RSA.Create(2048); - using var instanceKey = RSA.Create(2048); - var handler = new AppHandler(); - var service = new GitHubAppTokenService( - new IoPoolRegistry(), - Options.Create(new GitHubAppOptions { ClientId = "Iv23liPlatform", PrivateKey = deploymentKey.ExportRSAPrivateKeyPem() }), - () => new GitHubAppOptions { ClientId = "Iv23liInstance", PrivateKey = instanceKey.ExportRSAPrivateKeyPem() }, - httpClient: new HttpClient(handler), clock: () => Now); - - Assert.True(service.IsDeploymentConfigured); - Assert.True(service.HasInstanceApp); - Assert.StartsWith("ghs_Iv23liPlatform_", await service.GetInstallationToken().FirstAsync().Timeout(Budget).Await()); - Assert.StartsWith("ghs_Iv23liInstance_", await service.GetInstanceAppInstallationToken().FirstAsync().Timeout(Budget).Await()); - // Two identities, two tokens: neither is ever served the other's. - Assert.Equal(2, handler.Mints); - } - - // ───────────────────────────── which identity reads a source ───────────────────────────── - - private const string Configured = "https://github.com/Systemorph/MeshWeaver.Plugins"; - private const string Local = "https://github.com/acme/reporting"; - - [Fact] - public async Task AnInstanceSource_IsReadWithTheInstanceApp_AConfiguredSourceAsTheRecordSays() - { - using var instanceKey = RSA.Create(2048); - GitHubAppOptions? connected = new() { ClientId = "Iv23liInstance", PrivateKey = instanceKey.ExportRSAPrivateKeyPem() }; - var handler = new AppHandler(); - var service = new GitHubAppTokenService( - new IoPoolRegistry(), Options.Create(new GitHubAppOptions()), () => connected, - httpClient: new HttpClient(handler), clock: () => Now); - var local = new InstancePackageSourceContent { Repo = Local }; - var credentials = new GitSourceCredentials( - Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Plugins", - ["PluginCatalog:Sources:0:RepoPath"] = Configured, - ["PluginCatalog:Sources:1:Name"] = "AcmeConfigured", - ["PluginCatalog:Sources:1:RepoPath"] = Local, - ["PluginCatalog:Sources:1:TokenKey"] = "PluginCatalog:SourceTokens:Acme", - ["PluginCatalog:SourceTokens:Acme"] = "github_pat_acme", - }), - service, - repo => IsLocal(repo) ? local : null); - - // The record declares a token for that repository: the record's declaration wins. - Assert.Equal("github_pat_acme", await credentials.TokenFor(Local)!.FirstAsync().Timeout(Budget).Await()); - // A configured source declaring nothing keeps the caller's default. - Assert.Null(credentials.TokenFor(Configured)); - - // Without the record's entry, the instance source is read with the instance App. - var instanceOnly = new GitSourceCredentials(Config(new()), service, - repo => IsLocal(repo) ? local : null); - var declared = instanceOnly.For("https://github.com/ACME/reporting.git/"); - Assert.NotNull(declared); - Assert.True(declared!.InstanceApp); - Assert.Equal("reporting", declared.SourceName); - Assert.StartsWith("ghs_Iv23liInstance_", await instanceOnly.TokenFor(Local)!.FirstAsync().Timeout(Budget).Await()); - - // Not connected: an instance source declares nothing, and the default identity applies. - connected = null; - Assert.Null(instanceOnly.For(Local)); - } - - [Fact] - public async Task AConfiguredSourceAskingForTheInstanceApp_IsRefusedByName_WhenNoneIsConnected() - { - var service = new GitHubAppTokenService( - new IoPoolRegistry(), Options.Create(new GitHubAppOptions()), () => null); - var credentials = new GitSourceCredentials(Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Acme", - ["PluginCatalog:Sources:0:RepoPath"] = Local, - ["PluginCatalog:Sources:0:Identity"] = "instance", - }), service); - - var refused = await Assert.ThrowsAsync( - () => credentials.TokenFor(Local)!.FirstAsync().Timeout(Budget).Await()); - Assert.Equal("gitsync.source.instanceAppNotConnected", refused.Refusal.Key); - - var both = new GitSourceCredentials(Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Acme", - ["PluginCatalog:Sources:0:RepoPath"] = Local, - ["PluginCatalog:Sources:0:Identity"] = "instance", - ["PluginCatalog:Sources:0:InstallationOwner"] = "acme", - }), service); - var twice = await Assert.ThrowsAsync( - () => both.TokenFor(Local)!.FirstAsync().Timeout(Budget).Await()); - Assert.Equal("gitsync.source.instanceAndOther", twice.Refusal.Key); - } - - [Fact] - public async Task OnAColdReplica_AnInstanceSourceWaitsForItsCatalogs_EvenWithADeploymentApp() - { - using var deploymentKey = RSA.Create(2048); - using var instanceKey = RSA.Create(2048); - var handler = new AppHandler(); - // Nothing has been read yet: no instance App, no instance source — until the stores list. - var read = new System.Reactive.Subjects.ReplaySubject(1); - var loaded = false; - var service = new GitHubAppTokenService( - new IoPoolRegistry(), - Options.Create(new GitHubAppOptions { ClientId = "Iv23liPlatform", PrivateKey = deploymentKey.ExportRSAPrivateKeyPem() }), - () => loaded ? new GitHubAppOptions { ClientId = "Iv23liInstance", PrivateKey = instanceKey.ExportRSAPrivateKeyPem() } : null, - () => read, - httpClient: new HttpClient(handler), clock: () => Now); - var local = new InstancePackageSourceContent { Repo = Local }; - var credentials = new GitSourceCredentials(Config(new()), service, - repo => loaded && IsLocal(repo) ? local : null, instanceReady: () => read); - - // Subscribed while cold: the choice waits instead of falling back to the deployment App. - var pending = credentials.TokenOrDefault(Local, () => service.GetInstallationToken()) - .FirstAsync().Timeout(Budget).Await(); - var explicitInstance = service.GetInstanceAppInstallationToken().FirstAsync().Timeout(Budget).Await(); - Assert.False(pending.IsCompleted); - Assert.False(explicitInstance.IsCompleted); - - loaded = true; - read.OnNext(System.Reactive.Unit.Default); - Assert.StartsWith("ghs_Iv23liInstance_", await pending); - Assert.StartsWith("ghs_Iv23liInstance_", await explicitInstance); - // A repository nobody declares still gets the caller's default — here the deployment App. - Assert.StartsWith("ghs_Iv23liPlatform_", - await credentials.TokenOrDefault("https://github.com/acme/other", () => service.GetInstallationToken()) - .FirstAsync().Timeout(Budget).Await()); - } - - [Fact] - public async Task ARepositoryTheDeploymentConfigures_KeepsTheDeploymentsIdentity_EvenWhenAlsoAddedLocally() - { - using var deploymentKey = RSA.Create(2048); - using var instanceKey = RSA.Create(2048); - var service = new GitHubAppTokenService( - new IoPoolRegistry(), - Options.Create(new GitHubAppOptions { ClientId = "Iv23liPlatform", PrivateKey = deploymentKey.ExportRSAPrivateKeyPem() }), - () => new GitHubAppOptions { ClientId = "Iv23liInstance", PrivateKey = instanceKey.ExportRSAPrivateKeyPem() }, - httpClient: new HttpClient(new AppHandler()), clock: () => Now); - var local = new InstancePackageSourceContent { Repo = Local }; - var credentials = new GitSourceCredentials(Config(new() - { - // Configured WITHOUT a credential of its own: the deployment's default identity reads it. - ["PluginCatalog:Sources:0:Name"] = "Acme", - ["PluginCatalog:Sources:0:RepoPath"] = Local, - }), service, repo => IsLocal(repo) ? local : null); - - Assert.Null(credentials.For(Local)); - Assert.StartsWith("ghs_Iv23liPlatform_", - await credentials.TokenOrDefault(Local, () => service.GetInstallationToken()).FirstAsync().Timeout(Budget).Await()); - } - - /// The LEGACY single configured source (PluginCatalog:SourceRepoPath, read when the - /// Sources list names no repository) also keeps the deployment's identity when added locally — - /// FromConfiguration lists it, so FromInstance drops the local duplicate in its favour. - [Fact] - public async Task TheLegacyConfiguredSource_KeepsTheDeploymentsIdentity_EvenWhenAlsoAddedLocally() - { - using var deploymentKey = RSA.Create(2048); - using var instanceKey = RSA.Create(2048); - var service = new GitHubAppTokenService( - new IoPoolRegistry(), - Options.Create(new GitHubAppOptions { ClientId = "Iv23liPlatform", PrivateKey = deploymentKey.ExportRSAPrivateKeyPem() }), - () => new GitHubAppOptions { ClientId = "Iv23liInstance", PrivateKey = instanceKey.ExportRSAPrivateKeyPem() }, - httpClient: new HttpClient(new AppHandler()), clock: () => Now); - var local = new InstancePackageSourceContent { Repo = Local }; - var credentials = new GitSourceCredentials(Config(new() - { - [GitSourceCredentials.LegacySourceRepoPathKey] = Local, - // A list entry without a repository does not make the list the configured set. - ["PluginCatalog:Sources:0:Name"] = "Unnamed", - }), service, repo => IsLocal(repo) ? local : null); - - Assert.Null(credentials.For(Local)); - Assert.StartsWith("ghs_Iv23liPlatform_", - await credentials.TokenOrDefault(Local, () => service.GetInstallationToken()).FirstAsync().Timeout(Budget).Await()); - - // Control: with no configured source naming it, the local source reads with the instance App. - var unconfigured = new GitSourceCredentials(Config(new()), service, repo => IsLocal(repo) ? local : null); - Assert.NotNull(unconfigured.For(Local)); - } - - [Fact] - public async Task ANamedInstallation_OnAColdReplica_WaitsForTheInstanceApp_InsteadOfRefusing() - { - using var instanceKey = RSA.Create(2048); - var read = new System.Reactive.Subjects.ReplaySubject(1); - var loaded = false; - var service = new GitHubAppTokenService( - new IoPoolRegistry(), Options.Create(new GitHubAppOptions()), - () => loaded ? new GitHubAppOptions { ClientId = "Iv23liInstance", PrivateKey = instanceKey.ExportRSAPrivateKeyPem() } : null, - () => read, - httpClient: new HttpClient(new AppHandler()), clock: () => Now); - var credentials = new GitSourceCredentials(Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Acme", - ["PluginCatalog:Sources:0:RepoPath"] = Local, - ["PluginCatalog:Sources:0:InstallationOwner"] = "acme", - }), service); - - var pending = credentials.TokenFor(Local)!.FirstAsync().Timeout(Budget).Await(); - Assert.False(pending.IsCompleted); - loaded = true; - read.OnNext(System.Reactive.Unit.Default); - Assert.StartsWith("ghs_Iv23liInstance_", await pending); - } - - [Fact] - public async Task AMisspelledIdentity_IsRefusedByName_NeverAFallback() - { - var service = new GitHubAppTokenService( - new IoPoolRegistry(), Options.Create(new GitHubAppOptions()), () => null); - var credentials = new GitSourceCredentials(Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Acme", - ["PluginCatalog:Sources:0:RepoPath"] = Local, - ["PluginCatalog:Sources:0:Identity"] = "instnace", - }), service); - - Assert.NotNull(credentials.For(Local)); - var refused = await Assert.ThrowsAsync( - () => credentials.TokenFor(Local)!.FirstAsync().Timeout(Budget).Await()); - Assert.Equal("gitsync.source.unknownIdentity", refused.Refusal.Key); - Assert.Contains("instnace", refused.Message); - } - - [Fact] - public void TheManifestDescription_SpeaksTheAdministratorsLanguage() - { - var english = (string?)JsonNode.Parse(InstanceGitHubApp.BuildManifest("https://portal.example.com", "en"))!["description"]; - var german = (string?)JsonNode.Parse(InstanceGitHubApp.BuildManifest("https://portal.example.com", "de"))!["description"]; - Assert.Contains("portal.example.com", english); - Assert.Contains("portal.example.com", german); - Assert.NotEqual(english, german); - } - - // ───────────────────────────── the source record ───────────────────────────── - - [Theory] - [InlineData("https://github.com/acme/reporting", "https://github.com/acme/reporting")] - [InlineData(" https://github.com/acme/reporting.git/ ", "https://github.com/acme/reporting")] - [InlineData("https://github.com/acme/reporting/tree/main", null)] - [InlineData("https://gitlab.com/acme/reporting", null)] - [InlineData("http://github.com/acme/reporting", null)] - [InlineData("https://github.com/acme/..", null)] - [InlineData("", null)] - public void OnlyAGitHubRepositoryUrl_IsASource(string repo, string? normalized) => - Assert.Equal(normalized, InstancePackageSources.NormalizeRepo(repo)); - - [Fact] - public void TwoSpellingsOfOneRepository_AddressOneNode() - { - Assert.Equal("PackageSource-acme--reporting", InstancePackageSources.IdOf("https://github.com/Acme/Reporting.git")); - Assert.Equal("Admin/PackageSource-acme--reporting", InstancePackageSources.PathOf("https://github.com/acme/reporting/")); - } - - [Fact] - public void InstallByDefault_BecomesSourcePackageEntries() - { - var entries = InstancePackageSources.InstallEntries( - [ - new InstancePackageSourceContent { Repo = Local, InstallByDefault = ["Reporting", " ", "reporting"] }, - new InstancePackageSourceContent { Repo = "https://github.com/acme/other", Name = "Other", InstallByDefault = ["Pack"] }, - new InstancePackageSourceContent { Repo = "https://github.com/acme/none" }, - ]); - Assert.Equal(["reporting/Reporting", "Other/Pack"], entries); - Assert.Equal("main", InstancePackageSources.RefOf(new InstancePackageSourceContent { Repo = Local })); - } - - // The catalog's own lookup is by node path (case-folded); the fake mirrors it. - private static bool IsLocal(string? repo) => - string.Equals(InstancePackageSources.PathOf(repo), InstancePackageSources.PathOf(Local), StringComparison.OrdinalIgnoreCase); - - private static IConfiguration Config(Dictionary values) => - new ConfigurationBuilder().AddInMemoryCollection(values).Build(); - - /// - /// A GitHub fake for two Apps: discovery answers one installation per App, and every token is - /// named after the App whose JWT asked for it — so a test can see WHICH identity minted. - /// - private sealed class AppHandler : HttpMessageHandler - { - private int mints; - private readonly ConcurrentDictionary perApp = new(); - public int Mints => Volatile.Read(ref mints); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - var path = request.RequestUri!.AbsolutePath; - var issuer = Issuer(request.Headers.Authorization?.Parameter); - if (request.Method == HttpMethod.Get && path == "/app/installations") - return Answer(HttpStatusCode.OK, """[{"id": 91, "account": {"login": "acme"}}]"""); - if (request.Method == HttpMethod.Post && path == "/app/installations/91/access_tokens") - { - Interlocked.Increment(ref mints); - var n = perApp.AddOrUpdate(issuer, 1, (_, c) => c + 1); - return Answer(HttpStatusCode.Created, - $$"""{"token": "ghs_{{issuer}}_{{n}}", "expires_at": "{{Now.AddHours(1):o}}"}"""); - } - return Answer(HttpStatusCode.NotFound, $"unexpected {request.Method} {path}"); - } - - private static Task Answer(HttpStatusCode status, string body) => - Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }); - - private static string Issuer(string? jwt) - { - var payload = (jwt ?? "..").Split('.')[1].Replace('-', '+').Replace('_', '/'); - payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='); - using var doc = JsonDocument.Parse(Convert.FromBase64String(payload)); - return doc.RootElement.GetProperty("iss").GetString() ?? ""; - } - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceSourceSelectionsUnderWinningNameTest.cs b/test/Memex.Portal.Shared.Test/InstanceSourceSelectionsUnderWinningNameTest.cs deleted file mode 100644 index 18eb072fbb..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceSourceSelectionsUnderWinningNameTest.cs +++ /dev/null @@ -1,45 +0,0 @@ -using MeshWeaver.GitSync; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A local package source naming a repository the deployment's configuration already names is -/// dropped from the source list (the configured source wins), so its install-by-default selections -/// are spelled under the WINNING source's name — otherwise reporting/Reporting never matches -/// the listing under Acme and the package is never installed. A renaming only: the entries -/// still carry no deployment authority. -/// -public class InstanceSourceSelectionsUnderWinningNameTest -{ - [Fact] - public void ALocalDuplicateOfAConfiguredRepository_SelectsUnderTheConfiguredName() - { - var local = new InstancePackageSourceContent - { - Repo = "https://github.com/acme/reporting", - InstallByDefault = ["Reporting"], - }; - var renamed = InstanceAutoRegistrationService.UnderWinningNames( - [local], [("Acme", "https://github.com/acme/reporting.git")]); - - Assert.Equal(["Acme/Reporting"], InstancePackageSources.InstallEntries(renamed)); - // Control: without the mapping the entry names the local source, which the listing never carries. - Assert.Equal(["reporting/Reporting"], InstancePackageSources.InstallEntries([local])); - } - - [Fact] - public void ALocalSourceOfAnotherRepository_KeepsItsOwnName() - { - var local = new InstancePackageSourceContent - { - Repo = "https://github.com/acme/billing", - InstallByDefault = ["Billing"], - }; - var renamed = InstanceAutoRegistrationService.UnderWinningNames( - [local], [("Acme", "https://github.com/acme/reporting")]); - - Assert.Equal(["billing/Billing"], InstancePackageSources.InstallEntries(renamed)); - } -} diff --git a/test/Memex.Portal.Shared.Test/InstanceSourcesWaitForAHealthyCatalogTest.cs b/test/Memex.Portal.Shared.Test/InstanceSourcesWaitForAHealthyCatalogTest.cs deleted file mode 100644 index 9b70897bd4..0000000000 --- a/test/Memex.Portal.Shared.Test/InstanceSourcesWaitForAHealthyCatalogTest.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The default-install pass reads the instance's own package sources only from a HEALTHY catalog -/// (): a catalog that loaded once and then -/// faulted still answers WhenLoaded at once, with the listing it held before the outage, and -/// a pass that read it would report itself complete although a source added during the outage was -/// never read. -/// -public class InstanceSourcesWaitForAHealthyCatalogTest -{ - [Fact] - public void ALoadedCatalogThatFaulted_IsNotReadUntilItRecovers() - { - var loaded = new BehaviorSubject(Unit.Default); - var changes = new BehaviorSubject(Unit.Default); - var faulted = true; - var healthy = 0; - - using var _ = InstanceAutoRegistrationService - .WhenHealthy(loaded, changes, () => faulted) - .Subscribe(_ => healthy++); - Assert.Equal(0, healthy); - - // A change while still down is not recovery. - changes.OnNext(Unit.Default); - Assert.Equal(0, healthy); - - // The re-opened query delivers a complete listing: the fault clears, then observers are told. - faulted = false; - changes.OnNext(Unit.Default); - Assert.Equal(1, healthy); - } - - [Fact] - public void AHealthyLoadedCatalog_IsReadAtOnce() - { - var healthy = 0; - using var _ = InstanceAutoRegistrationService - .WhenHealthy(Observable.Return(Unit.Default), new BehaviorSubject(Unit.Default), () => false) - .Subscribe(_ => healthy++); - Assert.Equal(1, healthy); - } - - [Fact] - public void ACatalogNotYetLoaded_IsNotRead() - { - var healthy = 0; - using var _ = InstanceAutoRegistrationService - .WhenHealthy(Observable.Never(), new BehaviorSubject(Unit.Default), () => false) - .Subscribe(_ => healthy++); - Assert.Equal(0, healthy); - } -} diff --git a/test/Memex.Portal.Shared.Test/InvitationEmailTest.cs b/test/Memex.Portal.Shared.Test/InvitationEmailTest.cs deleted file mode 100644 index 0e12d87a61..0000000000 --- a/test/Memex.Portal.Shared.Test/InvitationEmailTest.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Memex.Portal.Shared.Email; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Unit tests for — the pure email-body -/// builder. A space-scoped invitation ( set) addresses the -/// Space by name and links straight to it; a deployment-wide invitation keeps the generic -/// "invited to Memex" wording. -/// -public class InvitationEmailTest -{ - [Fact] - public void SpaceInvite_AddressesSpaceByName_AndLinksToTheSpace() - { - var invitation = new Invitation { Email = "carol@acme.com", SpacePath = "TeamSpace" }; - - var (subject, html) = InvitationEmailSender.BuildInviteEmail( - invitation, "Team Space", "https://memex.example.com"); - - Assert.Equal("You've been invited to Team Space", subject); - Assert.Contains("Team Space", html); - // Links to the Space itself, not just the portal root. - Assert.Contains("https://memex.example.com/TeamSpace", html); - Assert.DoesNotContain("invited to Memex", html); - } - - [Fact] - public void SpaceInvite_FallsBackToThePath_WhenTheNameIsUnresolved() - { - var invitation = new Invitation { Email = "carol@acme.com", SpacePath = "TeamSpace" }; - - // Trailing slash on the base url must not double up in the link. - var (subject, html) = InvitationEmailSender.BuildInviteEmail( - invitation, spaceName: null, "https://memex.example.com/"); - - Assert.Equal("You've been invited to TeamSpace", subject); - Assert.Contains("https://memex.example.com/TeamSpace", html); - } - - [Fact] - public void GlobalInvite_KeepsTheGenericMemexWording() - { - var invitation = new Invitation { Email = "dave@acme.com" }; - - var (subject, html) = InvitationEmailSender.BuildInviteEmail( - invitation, spaceName: null, "https://memex.example.com"); - - Assert.Equal("You've been invited to Memex", subject); - Assert.Contains("invited to Memex", html); - } -} diff --git a/test/Memex.Portal.Shared.Test/LegacySpacePrerenderTest.cs b/test/Memex.Portal.Shared.Test/LegacySpacePrerenderTest.cs deleted file mode 100644 index 6a0d63b906..0000000000 --- a/test/Memex.Portal.Shared.Test/LegacySpacePrerenderTest.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Legacy Spaces may store their object as a string. Prerender must recover the same authored -/// source as the Space view, while a Markdown page remains free to contain literal JSON. -/// -public class LegacySpacePrerenderTest -{ - [Theory] - [InlineData("body", "Current **page**", "

Current page

\n")] - [InlineData("content", "Current **page**", "

Current page

\n")] - [InlineData("body", "", "")] - [InlineData("content", "", "")] - [InlineData("body", " \n\t", "")] - [InlineData("content", " \n\t", "")] - public void SerializedSpaceSource_OverridesStaleCaches(string field, string source, string expected) - { - var json = JsonSerializer.Serialize(new Dictionary - { - [field] = source, - ["prerenderedHtml"] = "

Stale content cache

", - }); - foreach (var content in new object[] { json, JsonSerializer.SerializeToElement(json), JsonValue.Create(json)! }) - { - var node = Space(content); - Assert.Equal(expected, MarkdownBody.Render(node)); - Assert.Equal(expected, new SeoPageData(node, null, null).Body); - } - } - - [Theory] - [InlineData("Current **body**", "

Current body

\n")] - [InlineData("", "")] - [InlineData(" \n\t", "")] - public void SpaceBody_WinsOverContent_InEveryObjectShape(string body, string expected) - { - var json = JsonSerializer.Serialize(new { body, content = "Other markdown", prerenderedHtml = "Stale" }); - foreach (var content in new object[] { json, JsonSerializer.SerializeToElement(json), JsonValue.Create(json)!, JsonSerializer.Deserialize(json) }) - { - var node = Space(content); - Assert.Equal(expected, MarkdownBody.Render(node)); - Assert.Equal(expected, new SeoPageData(node, null, null).Body); - } - } - - [Fact] - public void SerializedSpaceWithoutSource_UsesItsHtmlFallback() - { - const string json = """{"description":"Metadata only","prerenderedHtml":"

Content cache

"}"""; - foreach (var content in new object[] { json, JsonSerializer.SerializeToElement(json), JsonValue.Create(json)! }) - { - var node = Space(content); - Assert.Equal("

Stale mirror

", MarkdownBody.Render(node)); - Assert.Equal("

Content cache

", MarkdownBody.Render(node with { PreRenderedHtml = null })); - } - } - - [Fact] - public void SerializedSpaceWithNullBody_UsesMarkdownContent() - { - var node = Space("""{"body":null,"content":"Current markdown"}""") with { NodeType = "space" }; - Assert.Equal("

Current markdown

\n", MarkdownBody.Render(node)); - Assert.Equal("

Current markdown

\n", new SeoPageData(node, null, null).Body); - } - - [Theory] - [InlineData("Space", "{ This is **authored prose**, not JSON }", "

{ This is authored prose, not JSON }

\n")] - [InlineData("Markdown", "{\"body\":\"Literal **JSON**\"}", "

{"body":"Literal JSON"}

\n")] - public void LiteralText_IsNotReinterpreted(string nodeType, string text, string expected) - { - foreach (var content in new object[] { text, JsonSerializer.SerializeToElement(text), JsonValue.Create(text)! }) - { - var node = Space(content) with { NodeType = nodeType }; - Assert.Equal(expected, MarkdownBody.Render(node)); - Assert.Equal(expected, new SeoPageData(node, null, null).Body); - } - } - - [Fact] - public void MarkdownObject_StillPrefersContentOverBody() - { - var node = Space(JsonSerializer.SerializeToElement(new { body = "Other body", content = "Markdown source" })) - with { NodeType = "Markdown" }; - Assert.Equal("

Markdown source

\n", MarkdownBody.Render(node)); - } - - private static MeshNode Space(object content) => new("Legacy", "Site") - { - NodeType = "Space", - Content = content, - PreRenderedHtml = "

Stale mirror

", - }; -} diff --git a/test/Memex.Portal.Shared.Test/LinkVerdictPerPlatformTest.cs b/test/Memex.Portal.Shared.Test/LinkVerdictPerPlatformTest.cs deleted file mode 100644 index ced468e072..0000000000 --- a/test/Memex.Portal.Shared.Test/LinkVerdictPerPlatformTest.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Mesh; -using MeshWeaver.PluginCatalog; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 #4026, Copilot's review of #4427: whether a generation LOADS is a fact about the bytes AND the -/// platform image, and a rolling update puts two images on one volume. The fallback is ranked -/// "loadable here first", so each image must rank by ITS OWN measurement — a verdict frozen by -/// whichever replica recorded first would keep a fleet on a stale answer for the length of the -/// roll, or for good. -/// -/// Two replicas on two IMAGES, deterministically: "image-old" is this process; "image-new" is -/// the same process handed a platform surface whose MeshWeaver.Mesh.Contract is a stand-in -/// carrying a type this build does not have — the memex-cloud shape of #3538. One module built -/// against that type is therefore unloadable on the old image and loadable on the new one, measured -/// by the real link probe on both. -/// -public class LinkVerdictPerPlatformTest : IDisposable -{ - private const string Module = "MeshWeaver.Test.PerPlatformVerdict"; - private const string ContractAssembly = "MeshWeaver.Mesh.Contract"; - private const string FutureType = "MeshWeaver.Mesh.PerPlatformVerdictFromTheFuture"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-verdict-per-platform-" + Guid.NewGuid().ToString("N")); - - /// Creates the landing root both replicas share. - public LinkVerdictPerPlatformTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - private static byte[] Emit( - string assemblyName, string source, bool withoutRealContract = false, MetadataReference? extra = null) - { - var references = PlatformReferences.Platform(excludeReference: withoutRealContract ? ContractAssembly : null); - if (extra is not null) - references = references.Add(extra); - var compilation = CSharpCompilation.Create( - assemblyName, [CSharpSyntaxTree.ParseText(source)], references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - return StandInCompile.Emit(compilation); - } - - /// A module using a type this platform really has — loadable on the old image. - private static byte[] BuiltAgainstThisPlatform(string body) => Emit(Module, $$""" - using MeshWeaver.Mesh; - public static class Views - { - public static string Render(MeshNode node) => node.Id + "{{body}}"; - } - """); - - /// - /// A self-contained view of the new image: everything this process's platform carries, except - /// that its contract is the stand-in — first in the list, so it is the copy the surface binds. - /// - private static Func NewImage(string standIn) => directories => - ModulePlatformSurface.OfFiles( - new[] { standIn } - .Concat(((AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string) ?? string.Empty) - .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) - .Where(path => !string.Equals(Path.GetFileNameWithoutExtension(path), ContractAssembly, - StringComparison.OrdinalIgnoreCase))) - .Concat(directories.Where(Directory.Exists) - .SelectMany(directory => Directory.EnumerateFiles(directory, "*.dll")) - .Where(path => !string.Equals(Path.GetFileNameWithoutExtension(path), ContractAssembly, - StringComparison.OrdinalIgnoreCase)))); - - private static async Task Shelve(ModuleLandingService replica, byte[] bytes, string version) => - await replica.ShelveModule(Module, [(Module + ".dll", bytes)], frameworkMvid: "test-build", version: version) - .Timeout(TestTimeouts.Convergence).Await(); - - private static async Task ViewOf(ModuleLandingService replica) => - Assert.Single((await replica.GetActivation().Timeout(TestTimeouts.Convergence).Await()).Entries); - - /// - /// 🚨 Copilot's interleaving: the OLD image lands 1.7.0 first and measures it unloadable; the NEW - /// image lands the very same bytes later and measures them loadable. The new image must rank its - /// fallback by ITS verdict — 1.7.0, the newest generation that loads there — and the old image - /// by its own — 1.6.0 — at the same moment, over the same volume. And the modules GC, which runs - /// on either, must keep BOTH fallbacks, because both images are live during a roll. - /// - [Fact] - public async Task EachImageRanksTheFallbackByItsOwnVerdict_NotTheFirstReplicasFrozenOne() - { - var standInBytes = Emit(ContractAssembly, $$""" - namespace MeshWeaver.Mesh; - /// The type the new image has and this build does not. - public enum {{FutureType.Split('.').Last()}} { Unknown, Current } - """, withoutRealContract: true); - var standIn = Path.Combine(root, "image-new", ContractAssembly + ".dll"); - Directory.CreateDirectory(Path.GetDirectoryName(standIn)!); - File.WriteAllBytes(standIn, standInBytes); - var futureModule = Emit(Module, $$""" - using MeshWeaver.Mesh; - public static class Views - { - public static string Render({{FutureType.Split('.').Last()}} currency) => currency.ToString(); - } - """, withoutRealContract: true, extra: MetadataReference.CreateFromImage(standInBytes)); - var oldLoadable = BuiltAgainstThisPlatform("1.6.0"); - var head = BuiltAgainstThisPlatform("1.8.0"); - - // The preconditions that make this able to fail, measured by the real probe on both images. - var closure = ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, Module); - Assert.False(ModulePlatformLink.Check(futureModule, Module, closure, - ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory)).MayLoad, - "precondition: the future-built generation does NOT load on the old image"); - Assert.True(ModulePlatformLink.Check(futureModule, Module, closure, - NewImage(standIn)([AppContext.BaseDirectory])).MayLoad, - "precondition: the same bytes DO load on the new image"); - - using var oldImage = new ModuleLandingService(null, root, beforeRecording: null, - platformIdentity: "image-old"); - using var newImage = new ModuleLandingService(null, root, beforeRecording: null, - platformIdentity: "image-new", platformSurface: NewImage(standIn)); - - await Shelve(oldImage, oldLoadable, "1.6.0"); - Assert.True((await Shelve(oldImage, futureModule, "1.7.0")).Held, - "the old image measures 1.7.0 unloadable and HOLDS it on the shelf"); - await Shelve(oldImage, head, "1.8.0"); - Assert.Equal("1.6.0", (await ViewOf(oldImage)).PreviousVersion); - - // The new image lands the SAME bytes, later, and measures them loadable. - Assert.False((await Shelve(newImage, futureModule, "1.7.0")).Held, - "precondition: on the new image 1.7.0 links"); - - var seenByNew = await ViewOf(newImage); - Assert.Equal("1.8.0", seenByNew.Version); - Assert.Equal("1.7.0", seenByNew.PreviousVersion); - var seenByOld = await ViewOf(oldImage); - Assert.Equal("1.8.0", seenByOld.Version); - Assert.Equal("1.6.0", seenByOld.PreviousVersion); - - ModuleLandingService.CollectGarbage(root, minAge: TimeSpan.Zero, nowUtc: DateTime.UtcNow.AddHours(1)); - Assert.True(ModuleActivationBoot.PreviousLandedModuleDllExists(root, seenByNew), - "the new image's fallback survives a GC pass"); - Assert.True(ModuleActivationBoot.PreviousLandedModuleDllExists(root, seenByOld), - "and so does the old image's — both are live during a roll"); - } -} diff --git a/test/Memex.Portal.Shared.Test/LoadedPackageModuleReaderTest.cs b/test/Memex.Portal.Shared.Test/LoadedPackageModuleReaderTest.cs deleted file mode 100644 index d55cef7088..0000000000 --- a/test/Memex.Portal.Shared.Test/LoadedPackageModuleReaderTest.cs +++ /dev/null @@ -1,177 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using MeshWeaver.GitSync; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#6067 follow-up: the dependency floor a GitSync import checks is judged against the -/// module generation this process LOADED, never against the newest LANDED one — a landing is -/// restart-as-activation, so the head can name 1.21.0 while 1.20.4 is what runs. -/// -public class LoadedPackageModuleReaderTest -{ - private static ModuleActivationEntry Ai(string? previousDirectory = "MeshWeaver.AI@1204", bool enabled = true) => new() - { - Name = "MeshWeaver.AI", - PackagePath = "Plugins/AI", - Directory = "MeshWeaver.AI@1210", - Version = "1.21.0", - PreviousDirectory = previousDirectory, - PreviousVersion = "1.20.4", - Enabled = enabled, - }; - - private static ModuleActivationList List(params ModuleActivationEntry[] entries) => new() { Entries = [.. entries] }; - - [Fact] - public void TheHeadLoaded_ReadsTheHeadsVersion() - => LoadedPackageModuleReader.Resolve(List(Ai()), - new Dictionary { ["MeshWeaver.AI"] = "MeshWeaver.AI@1210" })["AI"] - .Version.Should().Be("1.21.0"); - - /// THE case: 1.21.0 landed, the process still runs the 1.20.4 generation it booted on. - [Fact] - public void ALandingPendingRestart_ReadsTheVersionThatRuns() - => LoadedPackageModuleReader.Resolve(List(Ai()), - new Dictionary { ["MeshWeaver.AI"] = "MeshWeaver.AI@1204" })["ai"] - .Version.Should().Be("1.20.4", "the floor is judged against what LOADED, and the key is case-insensitive"); - - [Theory] - [InlineData("MeshWeaver.AI")] // the image's copy: no landing labelled it - [InlineData("MeshWeaver.AI@9999")] // a generation neither the head nor the previous names - public void AGenerationNoEntryLabels_IsNotJudged(string leaf) - => LoadedPackageModuleReader.Resolve(List(Ai()), - new Dictionary { ["MeshWeaver.AI"] = leaf }) - .Should().BeEmpty("absent means not judged — never a guessed version"); - - [Fact] - public void AnUnloadedOrUninstalledModule_IsNotJudged() - { - LoadedPackageModuleReader.Resolve(List(Ai()), new Dictionary()).Should().BeEmpty(); - LoadedPackageModuleReader.Resolve(List(Ai(enabled: false)), - new Dictionary { ["MeshWeaver.AI"] = "MeshWeaver.AI@1210" }) - .Should().BeEmpty(); - } - - // ───────────────────────── the IMAGE's copy is judged at its seed version (Plugins#2715) - - /// The image ships AI as modules/MeshWeaver.AI/, stamped as package AI 1.21.1 — - /// the control instance's image (Plugins 29bfaefb) on 2026-10-05. - private static readonly IReadOnlyDictionary ImageAi1211 = - new Dictionary - { - ["MeshWeaver.AI"] = new("MeshWeaver.AI", new ImageModuleSeed("MeshWeaver.AI", "AI", "1.21.1", "1af92ebc617c7648")), - }; - - /// 🚨 The image copy loaded and its stamp names the package: the reading states it, at - /// the stamped version — with or without a store entry for the same module. - [Fact] - public void TheImageCopyLoaded_ReadsTheSeedsVersion() - { - var loaded = new Dictionary { ["MeshWeaver.AI"] = "MeshWeaver.AI" }; - LoadedPackageModuleReader.ResolveWithImageCopies(List(Ai()), loaded, ImageAi1211)["AI"] - .Should().Be(new LoadedPackageModule("MeshWeaver.AI", "1.21.1"), - "the store head (1.21.0) did not load — the image's copy did, and its seed says what it is"); - LoadedPackageModuleReader.ResolveWithImageCopies(List(), loaded, ImageAi1211)["AI"].Version - .Should().Be("1.21.1", "an image-only module, with no landing at all, is judged too"); - } - - /// A landed generation that LOADED is what runs, so it wins over the image reading. - [Fact] - public void ALandedGenerationThatLoaded_WinsOverTheImageReading() - => LoadedPackageModuleReader.ResolveWithImageCopies(List(Ai()), - new Dictionary { ["MeshWeaver.AI"] = "MeshWeaver.AI@1210" }, ImageAi1211)["AI"] - .Version.Should().Be("1.21.0"); - - /// No stamp, a stamp naming no package, or a load from somewhere other than the image's - /// copy: still not judged. - [Fact] - public void AnImageCopyWithoutAStampOrAnotherLeaf_IsNotJudged() - { - var fromImage = new Dictionary { ["MeshWeaver.AI"] = "MeshWeaver.AI" }; - LoadedPackageModuleReader.ResolveWithImageCopies(List(Ai()), fromImage, - new Dictionary()).Should().BeEmpty(); - LoadedPackageModuleReader.ResolveWithImageCopies(List(Ai()), fromImage, - new Dictionary - { - ["MeshWeaver.AI"] = new("MeshWeaver.AI", new ImageModuleSeed("MeshWeaver.AI", null, "1.21.1", null)), - }).Should().BeEmpty(); - LoadedPackageModuleReader.ResolveWithImageCopies(List(Ai()), - new Dictionary { ["MeshWeaver.AI"] = "MeshWeaver.AI@9999" }, ImageAi1211) - .Should().BeEmpty("a generation neither a landing nor the image names is a substituted load"); - } - - /// - /// 🚨 THE incident, end to end through the real decision. Hosting's tree declares - /// AI@^1.22.0; the process runs the image's AI, stamped 1.21.1. The import must be - /// DECLINED. - /// Negative control (in the test): the reading the code had before this change - /// (, image copies not judged) lets the same tree - /// SYNC — which is what wrote Hosting's sources on the control instance and parked twenty - /// NodeTypes at CS0246 LanePolicy. - /// - [Fact] - public void ARequirementTheImagesCopyDoesNotMeet_DeclinesTheImport() - { - var loaded = new Dictionary { ["MeshWeaver.AI"] = "MeshWeaver.AI" }; - var hosting = new ModuleReading("Hosting", "Hosting", "07738e60f774423b", null) - { - Requires = ["Store@^1.0.0", "AI@^1.22.0", "Essentials@^1.11.0"], - }; - ImmutableList Judge(IReadOnlyDictionary reading) => - ModuleSyncDecision.DeclineUnmetRequirements( - ModuleSyncDecision.Decide([hosting], - new Dictionary { ["Hosting"] = "0d6823302ed089d4" }, - "3.0.0-ci.9984", reconcile: false), - [hosting], reading); - - var declined = Judge(LoadedPackageModuleReader.ResolveWithImageCopies(List(Ai()), loaded, ImageAi1211)).Single(); - declined.Outcome.Should().Be(ModuleSyncOutcomeKind.Declined); - declined.UnmetRequirement.Should().Be("AI@^1.22.0"); - declined.Reason.Should().Contain("1.21.1"); - - Judge(LoadedPackageModuleReader.Resolve(List(Ai()), loaded)).Single().Outcome - .Should().Be(ModuleSyncOutcomeKind.Synced, - "negative control: without the image reading nothing judges the image copy, and the sources move"); - } - - /// - /// The production reader of the image's copies, against a real stamp on disk in the image's own - /// modules/<Name>/ (this test host's base directory plays the image). - /// - [Fact] - public void ImageCopiesOf_ReadsTheStampBesideTheImagesCopy() - { - var name = "MeshWeaver.Test.SeededModule" + Guid.NewGuid().ToString("N")[..8]; - var directory = Path.Combine(AppContext.BaseDirectory, "modules", name); - Directory.CreateDirectory(directory); - try - { - File.WriteAllBytes(Path.Combine(directory, name + ".dll"), []); - File.WriteAllText(Path.Combine(directory, ImageModuleSeed.FileName), - $$"""{"schema":"{{ImageModuleSeed.Schema}}","module":"{{name}}","package":"Seeded","version":"2.3.4","moduleVersion":"abcdef0123456789"}"""); - - var copies = LoadedPackageModuleReader.ImageCopiesOf(new Dictionary - { - [name] = name, - ["MeshWeaver.Test.NoSuchModule"] = "MeshWeaver.Test.NoSuchModule", - }); - - copies.Keys.Should().Equal([name]); - var copy = copies[name]; - copy.Leaf.Should().Be(name); - copy.Seed.Package.Should().Be("Seeded"); - copy.Seed.Version.Should().Be("2.3.4"); - } - finally - { - Directory.Delete(directory, recursive: true); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/LocalCheckoutPredicateTest.cs b/test/Memex.Portal.Shared.Test/LocalCheckoutPredicateTest.cs deleted file mode 100644 index 4155bf884b..0000000000 --- a/test/Memex.Portal.Shared.Test/LocalCheckoutPredicateTest.cs +++ /dev/null @@ -1,57 +0,0 @@ -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 ONE definition of "is this repo a mounted path or something to clone" -/// (MeshWeaver.Plugins#1563). -/// -/// ConfiguredPackageSource.LocalCheckout has always been computed from a private -/// predicate, so a consumer that needed the same answer had to mirror it — MeshWeaver.Plugins -/// carries RegistryPackages.IsMountedPath, written against a rule it cannot see change. Two -/// definitions of this split fail in both directions: a mount misread as a URL is polled -/// anonymously and 404s; a URL misread as a mount is read off a filesystem path that does not -/// exist. -/// -/// Exposing it makes the mirror deletable. These cases are the contract that makes it safe to -/// delete — they are what a consumer is entitled to rely on. -/// -public class LocalCheckoutPredicateTest -{ - [Theory] - [InlineData("/data/repos/plugins")] - [InlineData("./relative/checkout")] - [InlineData("C:\\repos\\plugins")] - [InlineData("~/code/MeshWeaver.Plugins")] - public void APath_IsALocalCheckout(string repo) => - Assert.True(PackageSources.IsLocalCheckout(repo)); - - [Theory] - [InlineData("https://github.com/Systemorph/MeshWeaver.Plugins")] - [InlineData("http://internal.example/repo.git")] - [InlineData("git@github.com:Systemorph/MeshWeaver.Plugins.git")] - public void ARemote_IsNot(string repo) => - Assert.False(PackageSources.IsLocalCheckout(repo)); - - /// - /// An ABSENT repo is not a local checkout. Answering true would make an unconfigured source - /// look mounted — and a source believed mounted is not polled at all, so the failure would be - /// a silently empty feed rather than an error. - /// - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void AnAbsentRepo_IsNot(string? repo) => - Assert.False(PackageSources.IsLocalCheckout(repo)); - - /// Case does not rescue a URL, and surrounding whitespace does not create one. - [Fact] - public void TheSplitIsNotDefeatedByCasingOrWhitespace() - { - Assert.False(PackageSources.IsLocalCheckout("HTTPS://github.com/x/y")); - Assert.False(PackageSources.IsLocalCheckout(" https://github.com/x/y ")); - Assert.True(PackageSources.IsLocalCheckout(" /data/repos/plugins ")); - } -} diff --git a/test/Memex.Portal.Shared.Test/LocalPartCollisionTest.cs b/test/Memex.Portal.Shared.Test/LocalPartCollisionTest.cs deleted file mode 100644 index 46f4622f5c..0000000000 --- a/test/Memex.Portal.Shared.Test/LocalPartCollisionTest.cs +++ /dev/null @@ -1,25 +0,0 @@ -using MeshWeaver.Hosting.AspNetCore.Portal; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the decision behind MeshWeaver#3561: a first-time sign-in whose email has no mesh -/// User node must not adopt a local part that already names SOMEONE ELSE'S node as its -/// partition key. On memex.meshweaver.cloud (2026-09-07) rbuergi@systemorph.com and -/// rbuergi@icloud.com both became home rbuergi. The comparison is by mail address, -/// case-insensitively, and only an actual owner can collide. -/// -public class LocalPartCollisionTest -{ - [Theory] - [InlineData("rbuergi@systemorph.com", "rbuergi@icloud.com", true)] - [InlineData("Roger@example.org", "roger@other.example", true)] - [InlineData("rbuergi@systemorph.com", "rbuergi@systemorph.com", false)] - [InlineData("RBuergi@Systemorph.com", "rbuergi@systemorph.com", false)] - [InlineData(null, "rbuergi@icloud.com", false)] - [InlineData("", "rbuergi@icloud.com", false)] - public void ALocalPartCollidesOnlyWhenAnotherAddressOwnsIt(string? owner, string email, bool collides) => - UserContextMiddleware.LocalPartCollides(owner, email).Should().Be(collides, - "the partition key is the data boundary: a taken id is refused, the owner's own address is not"); -} diff --git a/test/Memex.Portal.Shared.Test/LocalSourceContentVersionTest.cs b/test/Memex.Portal.Shared.Test/LocalSourceContentVersionTest.cs deleted file mode 100644 index fde9624337..0000000000 --- a/test/Memex.Portal.Shared.Test/LocalSourceContentVersionTest.cs +++ /dev/null @@ -1,60 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -public class LocalSourceContentVersionTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => - ConfigureMeshBase(builder).AddPluginCatalog(); - - [Theory(Timeout = 120_000)] - [InlineData("Page.md")] - [InlineData("icon.png")] - public async Task EqualLengthContentEdits_ChangeTheListedVersion(string fileName) - { - var root = Path.Combine(Path.GetTempPath(), "mw-local-content-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(Path.Combine(root, "Widget")); - try - { - File.WriteAllText(Path.Combine(root, "Widget", "index.json"), - """{"$type":"MeshNode","id":"Widget","namespace":"","path":"Widget","mainNode":"Widget","name":"Widget","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"A widget."}}"""); - var path = Path.Combine(root, "Widget", fileName); - File.WriteAllBytes(path, [65, 66, 67]); - var source = PackageSources.FromRepo(Mesh, root, "", nodeRepo: PackageSources.IsNodeRepoFormat("node-repo")); - Assert.NotNull(source); - - var first = (await source.ListPackages("HEAD").Should().Within(TestTimeouts.Quick).Emit(cancellationToken: TestContext.Current.CancellationToken)).Single(); - var unchanged = (await source.ListPackages("HEAD").Should().Within(TestTimeouts.Quick).Emit(cancellationToken: TestContext.Current.CancellationToken)).Single(); - unchanged.Version.Should().Be(first.Version, "an unchanged source must keep its version"); - - // Keep both the byte count and timestamp: neither is evidence of equal content. - var modified = File.GetLastWriteTimeUtc(path); - File.WriteAllBytes(path, [68, 69, 70]); - File.SetLastWriteTimeUtc(path, modified); - var edited = (await source.ListPackages("HEAD").Should().Within(TestTimeouts.Quick).Emit(cancellationToken: TestContext.Current.CancellationToken)).Single(); - edited.Version.Should().NotBe(first.Version, "a mounted source version must identify its content, including equal-length edits"); - - // Infrastructure outside the package payload must not move the advertised version. - Directory.CreateDirectory(Path.Combine(root, ".git")); - File.WriteAllText(Path.Combine(root, ".git", "HEAD"), "ref: refs/heads/elsewhere"); - var infrastructureOnly = (await source.ListPackages("HEAD").Should().Within(TestTimeouts.Quick).Emit(cancellationToken: TestContext.Current.CancellationToken)).Single(); - infrastructureOnly.Version.Should().Be(edited.Version); - } - finally - { - Directory.Delete(root, recursive: true); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/LocalizedReleaseAdvisoriesTest.cs b/test/Memex.Portal.Shared.Test/LocalizedReleaseAdvisoriesTest.cs deleted file mode 100644 index 9d93d31bf9..0000000000 --- a/test/Memex.Portal.Shared.Test/LocalizedReleaseAdvisoriesTest.cs +++ /dev/null @@ -1,188 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using System.Linq; -using System.Reflection; -using System.Text.RegularExpressions; -using Memex.Portal.Shared.SelfUpdate; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Data; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🌍 MeshWeaver#6540 — release-availability advisories are persisted with a reason CODE and its -/// arguments and rendered in the VIEWER's language, never as the English sentence for everyone. -/// -/// Pinned from both ends: every advisory the shared predicate composes carries a key whose -/// English template reproduces the stored English exactly (so the en rendering, the log line and -/// an older reader agree), and whose German template differs; the Updates tab renders a record's -/// items in the viewer's language; and a record that carries no code — written before #6540, or -/// carrying a code no catalog knows — renders its stored English unchanged. -/// -public class LocalizedReleaseAdvisoriesTest -{ - private const string Tag = "9999.0.0-ci.1"; - private const string Identity = "s20261012localizedadvisories0000"; - - /// A verdict carrying three advisory kinds at once: a boot compile (Feedback has no - /// sealed bake), a declared floor above the target (Crm), and an install record naming a module - /// the sealed set does not carry (Speech). - private static UpdatabilityVerdict ThreeAdvisories() => - ReleaseAvailability.IsUpdatable( - new ReleaseTarget(Tag, Identity), - [ - new RequiredPackage("Feedback", "Feedback"), - new RequiredPackage("Crm", "Crm", MinMeshVersion: "99999.0.0"), - new RequiredPackage("Speech", "Speech", HasContent: false) { ModuleName = "MeshWeaver.Speech" }, - ], - ReleaseArtifacts.Of(["Crm"]) with - { - Modules = new SealedModuleSet(ImmutableDictionary.Empty, [], null), - }); - - [Fact] - public void EveryComposedAdvisory_IsKeyed_AndItsEnglishTemplateReproducesTheStoredSentence() - { - var verdict = ThreeAdvisories(); - var texts = verdict.AdvisoryTexts; - - texts.Select(t => t.Key).Should().Equal( - [ReleaseAdvisoryCode.BootCompile, ReleaseAdvisoryCode.ModuleNotProduced, ReleaseAdvisoryCode.PlatformFloor], - "each advisory kind carries its own reason code, in the order the verdict lists them"); - texts.Select(t => t.English).Should().Equal(verdict.Advisories, - "the keyed items and the English lines are one list — they cannot disagree"); - - foreach (var text in texts) - { - text.Localize("en").Should().Be(text.English, - $"the en template of {text.Key} must render exactly the sentence the log and older readers see"); - text.Localize("de").Should().NotBe(text.English, $"{text.Key} has a German template"); - } - } - - [Fact] - public void TheWaivedPackageHold_LeadsKeyed_AndKeepsTheOtherAdvisoriesKeyed() - { - var packages = ReleaseAvailability.IsUpdatable( - new ReleaseTarget(Tag, Identity), - [new RequiredPackage("Feedback", "Feedback"), new RequiredPackage("Crm", "Crm", MinMeshVersion: "99999.0.0")], - ReleaseArtifacts.Of(["Crm"]), - new ReleaseGatePolicy(RequirePrebuilt: true)); - - var roll = packages.ForPlatformRoll(); - - roll.AdvisoryTexts.Select(t => t.Key).Should().Equal( - [ReleaseAdvisoryCode.PackagesDoNotFollow, ReleaseAdvisoryCode.PlatformFloor]); - roll.AdvisoryTexts[0].Localize("en").Should().Be(roll.Advisories[0]); - roll.AdvisoryTexts[0].Localize("de").Should().StartWith( - "die Plattform wird unabhängig von den Paketen aktualisiert — 1 Paket(e)"); - } - - /// A producer that sets only the English (or an older writer) yields VERBATIM items — - /// never a key bound to a sentence it does not describe. - [Fact] - public void AnEnglishOnlyAdvisory_IsVerbatim_NeverAKeyItDoesNotDescribe() - { - var keyed = ThreeAdvisories(); - var replaced = keyed with { Advisories = ["an advisory a host composed without a key"] }; - - var item = replaced.AdvisoryTexts.Should().ContainSingle().Subject; - item.Key.Should().BeNull(); - item.Localize("de").Should().Be("an advisory a host composed without a key"); - } - - [Fact] - public void TheUpdatesTab_RendersTheItemsInTheViewersLanguage() - { - var verdict = ThreeAdvisories(); - var content = Recorded(verdict); - - var german = Render(content, "de"); - var english = Render(content, "en"); - - german.Should().Contain("würde beim Start auf 9999.0.0-ci.1 neu kompiliert") - .And.Contain("Feedback") - .And.NotContain("would recompile at boot"); - english.Should().Contain("would recompile at boot on 9999.0.0-ci.1") - .And.NotContain("würde beim Start"); - } - - /// A record written before #6540 carries only the joined English — it renders exactly - /// that, for every viewer. - [Fact] - public void ALegacyRecord_RendersItsStoredSentence() - { - const string Stored = "would recompile at boot on 1.2.3 (no sealed content bake for framework identity sold): Feedback"; - var content = new UpdatePolicyContent - { - LatestAvailableTag = Tag, - AdvisoriesTag = Tag, - Advisories = Stored, - }; - - Render(content, "de").Should().Contain(Stored, - "an unkeyed record has nothing to localize — its English is what it says"); - } - - /// Negative control on the fallback: a reason code the catalog does not carry (a - /// module's own, or one that has since been renamed) renders the item's stored English, never a - /// raw key and never another sentence. - [Fact] - public void AnUnknownCode_RendersTheStoredSentence() - { - const string Stored = "a module's own advisory, in English"; - var content = new UpdatePolicyContent - { - LatestAvailableTag = Tag, - AdvisoriesTag = Tag, - Advisories = Stored, - AdvisoryItems = [new UpdateAdvisory(Stored) { Key = "releaseAdvisory.noSuchCode" }], - }; - - var german = Render(content, "de"); - - german.Should().Contain(Stored).And.NotContain("releaseAdvisory.noSuchCode"); - } - - /// Every code of the platform's vocabulary is in BOTH catalogs, with the same named - /// placeholders — a German template that dropped or renamed one would render a visible - /// {name} gap. - [Fact] - public void EveryCode_IsInBothCatalogs_WithTheSamePlaceholders() - { - var codes = typeof(ReleaseAdvisoryCode) - .GetFields(BindingFlags.Public | BindingFlags.Static) - .Where(f => f.IsLiteral) - .Select(f => (string)f.GetRawConstantValue()!) - .ToList(); - codes.Should().HaveCountGreaterThan(10, "the reflection read the vocabulary, not nothing"); - - foreach (var code in codes) - { - LocalizationCatalog.KeysFor("en").Should().Contain(code); - LocalizationCatalog.KeysFor("de").Should().Contain(code); - Placeholders(LocalizationCatalog.Get(code, "de")).Order(StringComparer.Ordinal) - .Should().Equal(Placeholders(LocalizationCatalog.Get(code, "en")).Order(StringComparer.Ordinal), code); - } - } - - private static UpdatePolicyContent Recorded(UpdatabilityVerdict verdict) => new() - { - LatestAvailableTag = Tag, - AdvisoriesTag = Tag, - Advisories = string.Join("; ", verdict.Advisories), - AdvisoryItems = [.. verdict.AdvisoryTexts.Select(UpdateAdvisory.From)], - }; - - private static string Render(UpdatePolicyContent content, string locale) => - UpdatePolicySettingsTab.StatusMarkdown( - content, (key, args) => LocalizationCatalog.Get(key, locale, args), locale: locale); - - private static ImmutableHashSet Placeholders(string template) => - [.. Regex.Matches(template, @"\{([A-Za-z_][A-Za-z0-9_]*)\}").Select(m => m.Groups[1].Value)]; -} diff --git a/test/Memex.Portal.Shared.Test/McpBackConnectionEvictionTest.cs b/test/Memex.Portal.Shared.Test/McpBackConnectionEvictionTest.cs deleted file mode 100644 index 7673453329..0000000000 --- a/test/Memex.Portal.Shared.Test/McpBackConnectionEvictionTest.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Data; -using MeshWeaver.Hosting.AspNetCore; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Hosting.Monolith.TestBase; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// A revoked MCP back-connection token must stop being handed out (#2302, finding 6). -/// -/// 🚨 The cache had no eviction path at all: once a token was cached it was returned for the -/// life of the process, so revoking it simply did not take effect — mesh access stayed broken until -/// the portal restarted. The class comment claimed "a revoked token surfaces as a 401 on the next -/// MCP call, which the auth-on-exception path turns into a re-mint"; no such path existed, and -/// neither CLI client reports an /mcp 401 back here, so an Invalidate(userId) -/// contract would have been a method with no caller. Validating before reuse is what makes the -/// revocation land. -/// -public class McpBackConnectionEvictionTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - // ApiTokenService is not a registered component in a bare test mesh — construct it the way - // ApiTokenServiceStaleReadTest does, with a short validation window so the revoked-token read - // settles quickly rather than waiting out the 8 s production default. - private ApiTokenService Tokens() => new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService(), - Mesh.ServiceProvider.GetRequiredService>()) - { - ValidationReadTimeout = TimeSpan.FromSeconds(2), - }; - - private McpBackConnectionService Service(ApiTokenService tokens) => new( - tokens, - Options.Create(new McpConfiguration { BaseUrl = "https://portal.example" }), - NullLogger.Instance); - - [Fact] - public async Task A_revoked_cached_token_is_evicted_and_replaced() - { - var tokens = Tokens(); - var svc = Service(tokens); - var userId = $"mcpuser{Guid.NewGuid():N}"[..16]; - - var first = await svc.EnsureForUser(userId).FirstAsync().Await(); - first.Should().NotBeNull("the first call mints a token"); - var firstToken = first!.BearerToken; - - // Cached: the same token comes back without minting a second one. - var cached = await svc.EnsureForUser(userId).FirstAsync().Await(); - cached!.BearerToken.Should().Be(firstToken, "a valid cached token is reused — that is the hot path"); - - // Revoke it the way an operator would. - var mine = await tokens.GetTokensForUser(userId).FirstAsync().Await(); - mine.Should().NotBeEmpty("the mint created a token node for this user"); - (await tokens.RevokeToken(mine[0].NodePath).FirstAsync().Await()) - .Should().BeTrue("revocation must succeed for this test to mean anything"); - - var afterRevoke = await svc.EnsureForUser(userId).FirstAsync().Await(); - - afterRevoke.Should().NotBeNull(); - afterRevoke!.BearerToken.Should().NotBe( - firstToken, - "the revoked token must not be handed out again — before this fix the cache had no " - + "eviction path, so it was returned for the life of the process and revoking a " - + "back-connection token did nothing at all"); - } -} diff --git a/test/Memex.Portal.Shared.Test/McpRoutingTest.cs b/test/Memex.Portal.Shared.Test/McpRoutingTest.cs deleted file mode 100644 index ef47236dcd..0000000000 --- a/test/Memex.Portal.Shared.Test/McpRoutingTest.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Hosting.AspNetCore.Portal; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The MCP endpoint routing contract: /api/mcp is the primary published path, -/// /mcp the permanent compatibility alias, and /Mcp — the SAME path family, -/// route matching being case-insensitive — is ALSO the Mcp partition's page (its Store cover). -/// -/// This pins the resolution of that collision end-to-end over a real routing pipeline, -/// because it shipped broken in both directions: the blanket "mcp" prefix in -/// made GET /Mcp answer 404 forever (the partition -/// cover was unreachable on every deployment), while the module-not-loaded failure mode (#2093) -/// needs MCP-protocol traffic to keep answering an honest 404 — never a 200 HTML shell an MCP -/// client cannot parse and an operator reads as healthy. -/// -/// The MCP endpoint itself is a stand-in POST route here (the real one is mapped by the -/// MeshWeaver.Mcp module, which lives in the plugins repo) — what is under test is the -/// platform's routing around it: the front-of-pipeline alias, -/// the shape-based page-route constraint, and the RFC 9728 path-inserted discovery rewrite. -/// -public class McpRoutingTest -{ - private const string McpEndpointBody = "mcp-endpoint"; - - /// - /// The pipeline under test: the alias startup filter exactly as - /// registers it, a literal - /// POST /mcp endpoint standing in for the module's streamable-HTTP transport - /// (stateless mode maps no GET — mirrored here, it is what frees browser GETs for the page), - /// a bare protected-resource-metadata endpoint echoing the stashed resource path, and a - /// GET+POST catch-all page route carrying the real - /// (razor-components page endpoints accept POST for enhanced forms, so the emulation must - /// too — otherwise the "protocol POST never renders the shell" half would be vacuous). - /// - private static WebApplication BuildApp(bool mapMcpEndpoint) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.Services.AddTransient(); - - var app = builder.Build(); - - if (mapMcpEndpoint) - app.MapPost("/mcp", () => McpEndpointBody); - - app.MapGet(McpEndpointRoutes.ResourceMetadataPath, - (HttpContext ctx) => - ctx.Items.TryGetValue(McpEndpointRoutes.ResourcePathItem, out var stashed) - && stashed is string resourcePath - ? resourcePath - : "(bare)"); - - app.MapMethods("/{**path}", ["GET", "POST"], - (string? path) => $"page:{path}") - .ExcludeStaticAssetPaths(); - - return app; - } - - private static HttpRequestMessage JsonPost(string url) => - new(HttpMethod.Post, url) - { - Content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"initialize"}""", - Encoding.UTF8, - "application/json"), - }; - - [Theory] - [InlineData("/mcp")] // the compatibility alias every existing client config uses - [InlineData("/api/mcp")] // the primary path — rewritten onto the module's route - [InlineData("/API/MCP")] // route matching is case-insensitive; the rewrite must be too - public async Task JsonPost_ReachesTheMcpEndpoint(string url) - { - await using var app = BuildApp(mapMcpEndpoint: true); - await app.StartAsync(); - using var client = app.GetTestClient(); - - var response = await client.SendAsync(JsonPost(url)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - (await response.Content.ReadAsStringAsync()).Should().Be(McpEndpointBody); - } - - [Fact] - public async Task BrowserGetOfMcp_RendersThePartitionPage() - { - await using var app = BuildApp(mapMcpEndpoint: true); - await app.StartAsync(); - using var client = app.GetTestClient(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/Mcp"); - request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml"); - var response = await client.SendAsync(request); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - (await response.Content.ReadAsStringAsync()).Should().Be("page:Mcp", - because: "a browser navigation to /Mcp must render the Mcp partition's cover, " - + "not fall into the endpoint-or-404 hole that shipped"); - } - - [Theory] - [InlineData("text/event-stream")] // streamable-HTTP SSE channel probe - [InlineData("application/json, text/event-stream")] // API-shaped read - public async Task ProtocolShapedGet_NeverGetsTheHtmlShell(string accept) - { - await using var app = BuildApp(mapMcpEndpoint: true); - await app.StartAsync(); - using var client = app.GetTestClient(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/mcp"); - request.Headers.TryAddWithoutValidation("Accept", accept); - var response = await client.SendAsync(request); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - /// - /// An UNMAPPED /api/… path must be a 404, never the app shell. Every API client — curl in a CI - /// gate, a plugin fetcher, a webhook sender — reads a 200 as success and then parses HTML as - /// data. Measured 2026-08-27: the plugin gate's upstream-seed fetch took memex's HTML for a - /// sealed-but-empty publication index because the registry was one build behind #2487. - /// Mapped API endpoints are untouched (they outrank the catch-all); a partition page still renders. - /// - [Fact] - public async Task UnknownApiPath_AnswersAnHonest404_NeverTheShell() - { - await using var app = BuildApp(mapMcpEndpoint: true); - await app.StartAsync(); - using var client = app.GetTestClient(); - - foreach (var accept in new[] { "application/json", "text/html,application/xhtml+xml" }) - { - var request = new HttpRequestMessage(HttpMethod.Get, "/api/plugins/bundles/prebuilt/s0/plugins"); - request.Headers.TryAddWithoutValidation("Accept", accept); - var response = await client.SendAsync(request); - response.StatusCode.Should().Be(HttpStatusCode.NotFound, - because: $"an unmapped /api path with Accept '{accept}' must fail loud, not render the shell"); - } - - // Control: the exclusion is root-anchored on the `api` segment only — a partition named - // anything else still reaches the page, and a mesh path merely CONTAINING "api" is untouched. - foreach (var path in new[] { "/Apiary", "/Docs/api" }) - { - var browser = new HttpRequestMessage(HttpMethod.Get, path); - browser.Headers.TryAddWithoutValidation("Accept", "text/html"); - var page = await client.SendAsync(browser); - page.StatusCode.Should().Be(HttpStatusCode.OK, because: $"{path} is a page, not an API path"); - (await page.Content.ReadAsStringAsync()).Should().Be("page:" + path.TrimStart('/')); - } - } - - [Fact] - public async Task ModuleNotLoaded_JsonPostAnswersAnHonest404_NeverTheShell() - { - // The #2093 failure mode: the MeshWeaver.Mcp module never host-loaded, so nothing maps - // /mcp. A protocol POST must answer 404 — the signal that made that outage findable — - // and never a 200 HTML shell. - await using var app = BuildApp(mapMcpEndpoint: false); - await app.StartAsync(); - using var client = app.GetTestClient(); - - foreach (var url in new[] { "/mcp", "/api/mcp" }) - { - var response = await client.SendAsync(JsonPost(url)); - response.StatusCode.Should().Be(HttpStatusCode.NotFound, - because: $"a JSON POST to {url} with no MCP endpoint mapped must fail loud"); - } - - // …while the partition page stays reachable for browsers even then. - var browser = new HttpRequestMessage(HttpMethod.Get, "/Mcp"); - browser.Headers.TryAddWithoutValidation("Accept", "text/html"); - var page = await client.SendAsync(browser); - (await page.Content.ReadAsStringAsync()).Should().Be("page:Mcp"); - } - - [Fact] - public async Task ApiMcpPrefixIsSegmentExact_NeverRewritesNeighbours() - { - await using var app = BuildApp(mapMcpEndpoint: true); - await app.StartAsync(); - using var client = app.GetTestClient(); - - var response = await client.SendAsync(JsonPost("/api/mcpx")); - - (await response.Content.ReadAsStringAsync()).Should().NotBe(McpEndpointBody, - because: "/api/mcpx is not the MCP endpoint; the rewrite matches whole segments"); - } - - [Theory] - [InlineData("/.well-known/oauth-protected-resource/api/mcp", "/api/mcp")] - [InlineData("/.well-known/oauth-protected-resource/mcp", "/mcp")] - [InlineData("/.well-known/oauth-protected-resource", "(bare)")] - public async Task PathInsertedResourceMetadata_ReachesTheBareDocument_WithTheResourceStashed( - string url, string expectedStash) - { - // RFC 9728: a strict MCP client derives the metadata URL from the endpoint path it - // connects to. Both derivations must answer, each naming ITS resource; the bare - // document (the URL the WWW-Authenticate challenge carries) keeps its default. - await using var app = BuildApp(mapMcpEndpoint: true); - await app.StartAsync(); - using var client = app.GetTestClient(); - - var response = await client.GetAsync(url); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - (await response.Content.ReadAsStringAsync()).Should().Be(expectedStash); - } - - [Fact] - public void AddMcpAuthentication_RegistersTheAliasStartupFilter() - { - // The alias is self-wired: the composition (plugins repo) calls AddMcpAuthentication and - // nothing else — if this registration goes missing, /api/mcp silently stops being served - // while every test above still passes on its explicit registration. - var services = new ServiceCollection(); - services.AddLogging(); - services.AddMcpAuthentication(); - - services.Should().Contain(d => - d.ServiceType == typeof(IStartupFilter) - && d.ImplementationType == typeof(McpEndpointRoutes.StartupFilter)); - } -} diff --git a/test/Memex.Portal.Shared.Test/McpUnavailableValidationIsNotASignOutTest.cs b/test/Memex.Portal.Shared.Test/McpUnavailableValidationIsNotASignOutTest.cs deleted file mode 100644 index 25f49133c4..0000000000 --- a/test/Memex.Portal.Shared.Test/McpUnavailableValidationIsNotASignOutTest.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Text.Encodings.Web; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A token the mesh could not look up for a moment is NOT a sign-out (maintainer, -/// 2026-09-30: "this must not happen"). -/// -/// The ApiToken handler flags a request whose validation could not RUN, and its own -/// challenge answers 503 + Retry-After (issue #637). But the MCP endpoint's policy -/// challenges on the MCP scheme, which wrote 401 + WWW-Authenticate: Bearer itself — so -/// on /mcp the 503 override never ran, and an MCP client read a validation stall as "sign in -/// again". Driven here over real HTTP with the production wiring -/// (); only the ApiToken handler's -/// AUTHENTICATE is stubbed, so the challenge under test is the real one. -/// -public class McpUnavailableValidationIsNotASignOutTest -{ - /// Validation could not run → 503, Retry-After, and NO auth challenge. - [Fact] - public async Task UnavailableValidation_On_Mcp_Answers503_WithoutAnAuthChallenge() - { - await using var app = BuildApp(typeof(UnavailableTokenHandler)); - await app.StartAsync(TestContext.Current.CancellationToken); - var response = await Post(app); - - Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); - Assert.True(response.Headers.Contains("Retry-After"), "a retryable answer must say when to retry"); - Assert.False(response.Headers.Contains("WWW-Authenticate"), - "an auth challenge on a validation stall is what makes an MCP client discard a valid token and re-authenticate"); - } - - /// - /// The positive control: a genuinely INVALID token must still get the discovery challenge — - /// otherwise the test above could pass on a pipeline that never challenges at all. - /// - [Fact] - public async Task InvalidToken_On_Mcp_StillAnswers401_WithTheBearerChallenge() - { - await using var app = BuildApp(typeof(InvalidTokenHandler)); - await app.StartAsync(TestContext.Current.CancellationToken); - var response = await Post(app); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.True(response.Headers.Contains("WWW-Authenticate"), - "a definitive invalid token must keep the MCP discovery challenge"); - } - - private static async Task Post(WebApplication app) - { - var client = app.GetTestClient(); - using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") - { - Content = new StringContent("""{"jsonrpc":"2.0","id":1,"method":"initialize"}""", - System.Text.Encoding.UTF8, "application/json"), - }; - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "mw_test"); - return await client.SendAsync(request, TestContext.Current.CancellationToken); - } - - private static WebApplication BuildApp(System.Type apiTokenHandler) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(); - builder.Services.AddMcpAuthentication(); - // Swap ONLY the handler type behind the ApiToken scheme; every forward, selector and - // challenge stays the production registration. - builder.Services.Configure(o => - o.SchemeMap[ApiTokenAuthenticationHandler.SchemeName].HandlerType = apiTokenHandler); - - var app = builder.Build(); - app.UseAuthentication(); - app.UseAuthorization(); - app.MapPost("/mcp", () => "mcp").RequireAuthorization(Memex.Portal.Shared.Authentication.McpAuthenticationExtensions.PolicyName); - return app; - } - - /// Authenticates exactly as the real handler does when the store did not answer. - private sealed class UnavailableTokenHandler( - IOptionsMonitor options, ILoggerFactory loggerFactory, UrlEncoder encoder, - System.IServiceProvider serviceProvider) - : ApiTokenAuthenticationHandler(options, loggerFactory, encoder, serviceProvider) - { - protected override Task HandleAuthenticateAsync() - { - Context.Items[ValidationUnavailableItemKey] = "token store did not answer"; - return Task.FromResult(AuthenticateResult.Fail("API token validation is temporarily unavailable (retryable)")); - } - } - - /// Authenticates exactly as the real handler does for a definitive invalid token. - private sealed class InvalidTokenHandler( - IOptionsMonitor options, ILoggerFactory loggerFactory, UrlEncoder encoder, - System.IServiceProvider serviceProvider) - : ApiTokenAuthenticationHandler(options, loggerFactory, encoder, serviceProvider) - { - protected override Task HandleAuthenticateAsync() - => Task.FromResult(AuthenticateResult.Fail("Invalid or expired API token")); - } -} diff --git a/test/Memex.Portal.Shared.Test/Memex.Portal.Shared.Test.csproj b/test/Memex.Portal.Shared.Test/Memex.Portal.Shared.Test.csproj deleted file mode 100644 index fdc704f46b..0000000000 --- a/test/Memex.Portal.Shared.Test/Memex.Portal.Shared.Test.csproj +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/Memex.Portal.Shared.Test/MemexReadinessTest.cs b/test/Memex.Portal.Shared.Test/MemexReadinessTest.cs deleted file mode 100644 index 70c52850ed..0000000000 --- a/test/Memex.Portal.Shared.Test/MemexReadinessTest.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Threading.Tasks; -using Memex.Portal.ServiceDefaults; -using MeshWeaver.Testcontainers; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The memex test container's readiness must prove a started MESH, and the first-run setup -/// wizard must never satisfy it (MeshWeaver#6037). -/// -/// What went wrong. MemexBuilder waited for 200 on /healthz and -/// never stated Graph:Storage:Type. The image deliberately bakes no storage type, so every -/// container the helper started served the SETUP wizard — which answers every probe path with a flat -/// ok and composes no mesh. Measured on the portal image 3.0.0-ci.8323 with the -/// helper's exact environment: /healthz, /health, /ready and /alive all -/// 200 ok, /api/version → 302 /setup, and the log line "Serving the FIRST-RUN -/// SETUP wizard and nothing else". A green start proved a container, never a portal. -/// -/// Why these hosts and not recorded strings. Both sides are the REAL pipelines: the -/// setup-only host's probe + setup surface (, the same -/// mapping SetupOnlyHost.TryRun serves) and the composed host's -/// . A recorded body would agree with itself while -/// either host changed shape. -/// -public class MemexReadinessTest -{ - /// - /// The negative control, and the incident: the wizard is named — terminally — and is never - /// read as a started mesh, although every probe path it serves answers 200. - /// - [Fact] - public async Task TheSetupWizard_IsNamed_AndNeverReadAsAStartedMesh() - { - using var wizard = SetupSurfaceTest.BuildProbeApp(); - using var client = wizard.GetTestClient(); - var ct = TestContext.Current.CancellationToken; - - // The premise: the wizard satisfies the OLD wait. Without this the test below could pass - // against a wizard that had simply stopped answering /healthz. - var healthz = await client.GetAsync("/healthz", ct); - Assert.True(healthz.IsSuccessStatusCode, - $"premise: the setup host answers /healthz {(int)healthz.StatusCode} — the old wait's " - + "condition no longer holds on the wizard, so this test no longer reproduces #6037"); - - var verdict = await MemexReadiness.ProbeAsync(client, ct); - - Assert.True(verdict.State == MemexReadinessState.SetupWizard, - $"the setup wizard was read as {verdict.State} ({verdict.Reason}). It composes no mesh, " - + "and a wait that does not name it either reports a hollow start or polls to its " - + "timeout with no reason given (#6037)."); - } - - /// - /// The INSTALLED predicate inside Testcontainers' OWN retry loop - /// (, the loop - /// DockerContainer.CheckReadinessAsync runs) against the real wizard: the wait ends on the - /// FIRST poll with the wizard named — not a at the budget, - /// which is what a retry-and-swallow loop would produce. - /// - [Fact] - public async Task TheInstalledWait_EndsAtOnceOnTheWizard_InsideTestcontainersRetryLoop() - { - using var wizard = SetupSurfaceTest.BuildProbeApp(); - using var client = wizard.GetTestClient(); - var ct = TestContext.Current.CancellationToken; - var polls = 0; - - var ex = await Record.ExceptionAsync(() => DotNet.Testcontainers.Configurations.WaitStrategy.WaitUntilAsync( - async () => - { - polls++; - return UntilMeshStarted.Decide(await MemexReadiness.ProbeAsync(client, ct)); - }, - interval: System.TimeSpan.FromMilliseconds(50), - // A budget the assertion below never comes near: reaching it is the failure. - timeout: System.TimeSpan.FromMinutes(1), - retries: 0, - ct)); - - Assert.True(ex is System.InvalidOperationException && ex.Message.Contains("SETUP wizard"), - $"the wait over the wizard ended with {ex?.GetType().Name ?? "no exception"} " - + $"({ex?.Message}) after {polls} poll(s) — it must end on the first poll, naming the wizard."); - Assert.Equal(1, polls); - } - - /// - /// The positive control: the composed host's startup census satisfies the wait — so the - /// negative case above is discriminating, not a wait that nothing can satisfy. - /// - [Fact] - public async Task AComposedHostWhoseStartupCensusPasses_IsAStartedMesh() - { - var verdict = await ProbeComposedHost(meshCheck: HealthCheckResult.Healthy("mesh composed")); - - Assert.True(verdict.State == MemexReadinessState.MeshStarted, - $"the composed host's healthy census was read as {verdict.State} ({verdict.Reason})"); - } - - /// - /// A composed host whose startup checks still fail (the database, the schema, the mesh) is not - /// started yet — keep waiting, never declare it ready and never declare it the wizard. - /// - [Fact] - public async Task AComposedHostStillFailingItsStartupCensus_IsNotYetStarted() - { - var verdict = await ProbeComposedHost(meshCheck: HealthCheckResult.Unhealthy("mesh not composed yet")); - - Assert.True(verdict.State == MemexReadinessState.NotYet, - $"a composed host answering its startup census Unhealthy was read as {verdict.State} ({verdict.Reason})"); - } - - /// The readings a live container answers, pinned on the pure classifier. - [Theory] - // The wizard, as measured on image 3.0.0-ci.8323 with the helper's old environment. - [InlineData(302, "http://127.0.0.1:8080/setup", 200, "ok", MemexReadinessState.SetupWizard)] - // A probe path's flat ok is not a census, even when nothing redirects. - [InlineData(200, null, 200, "ok", MemexReadinessState.NotYet)] - // The same image configured and migrated: /health "Degraded" with census lines, /api/version 200. - [InlineData(200, null, 200, "Degraded\ncontent-types: Degraded — 1 node type(s) …", MemexReadinessState.MeshStarted)] - [InlineData(200, null, 200, "Healthy\nchecks: 12 in 40ms", MemexReadinessState.MeshStarted)] - [InlineData(200, null, 503, "Unhealthy\ndb_version: Unhealthy — schema behind", MemexReadinessState.NotYet)] - // A redirect elsewhere (an auth challenge) is not the wizard. - [InlineData(302, "/signin?returnUrl=%2Fapi%2Fversion", 200, "Healthy", MemexReadinessState.MeshStarted)] - public void TheClassifier_ReadsWhatALiveContainerAnswers( - int meshStatus, string? meshLocation, int healthStatus, string healthBody, MemexReadinessState expected) - { - var verdict = MemexReadiness.Classify(meshStatus, meshLocation, healthStatus, healthBody); - Assert.True(verdict.State == expected, $"expected {expected}, read {verdict.State}: {verdict.Reason}"); - } - - private static async Task ProbeComposedHost(HealthCheckResult meshCheck) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.AddDefaultHealthChecks(); - builder.Services.AddHealthChecks().AddCheck("mesh", () => meshCheck); - - var app = builder.Build(); - app.MapDefaultEndpoints(); - - await using (app) - { - var ct = TestContext.Current.CancellationToken; - await app.StartAsync(ct); - using var client = app.GetTestClient(); - return await MemexReadiness.ProbeAsync(client, ct); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/MeshApiCookieAuthTest.cs b/test/Memex.Portal.Shared.Test/MeshApiCookieAuthTest.cs deleted file mode 100644 index 54621d6266..0000000000 --- a/test/Memex.Portal.Shared.Test/MeshApiCookieAuthTest.cs +++ /dev/null @@ -1,345 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Security.Claims; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using ModelContextProtocol.AspNetCore.Authentication; -using Xunit; - -// The MCP SDK ships its own McpAuthenticationExtensions in Microsoft.Extensions.DependencyInjection, -// which the `using` above pulls into scope — alias the portal's so every reference is unambiguous. -using PortalMcpAuth = Memex.Portal.Shared.Authentication.McpAuthenticationExtensions; - -namespace Memex.Portal.Shared.Test; - -/// -/// The credential contract of /api/mesh/* (issue #1477). -/// -/// Why this exists. A server-side renderer that already holds the visitor's session -/// cookie used to MINT an API token per page render just to read a snapshot back, and every mint -/// writes TWO PERMANENT mesh nodes into that visitor's partition. Ordinary page traffic therefore -/// grew a user's partition without bound. The fix is that a READ accepts the credential the caller -/// already has — and the danger in that fix is doing it one verb too far: a session cookie must -/// never be able to drive a create, patch, delete, script execution or upload. -/// -/// What is asserted. Three links of one chain, because each is invisible from the -/// others: (1) which POLICY guards which verb, read off the real routing table rather than the -/// source; (2) what each policy AUTHENTICATES with, and that the composite scheme's challenge stays -/// API-shaped; (3) that a real cookie session actually passes the read policy and is actually -/// refused by the Bearer-only one, over a real HTTP pipeline. A regression in any single link would -/// otherwise look exactly like working code. -/// -public class MeshApiCookieAuthTest -{ - /// - /// The READ-ONLY verbs a browser session may drive. Anything not listed here must stay - /// Bearer-only — extending this set is a security decision, which is precisely why it is - /// written down as data and asserted exhaustively in both directions. - /// - private static readonly string[] CookieReadableRoutes = - [ - "/api/mesh/get", - "/api/mesh/whoami", - "/api/mesh/render-area", - "/api/mesh/resolve", - ]; - - /// - /// Verbs that MUTATE (or execute, or upload). Named explicitly — not merely "everything else" — - /// so that deleting a route from the routing table cannot silently satisfy the assertion. - /// - private static readonly string[] MustStayBearerOnly = - [ - "/api/mesh/create", - "/api/mesh/update", - "/api/mesh/patch", - "/api/mesh/delete", - "/api/mesh/move", - "/api/mesh/copy", - "/api/mesh/recycle", - "/api/mesh/compile", - "/api/mesh/execute-script", - "/api/mesh/run-tests", - "/api/mesh/mirror", - "/api/mesh/upload", - ]; - - /// - /// Every /api/mesh route the REAL registers, - /// paired with the authorization policy guarding it. The app is never started — endpoint - /// construction and its metadata are all this needs. - /// - private static async Task> RoutePoliciesAsync() - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - // The endpoint lambdas take IMessageHub; minimal-API parameter binding must see it as a - // SERVICE (otherwise it is inferred as a second JSON body parameter and mapping throws). - // Never resolved — no endpoint is invoked here. - builder.Services.AddSingleton(_ => null!); - - var app = builder.Build(); - await using (app) - { - app.MapMeshApi(); - return ((IEndpointRouteBuilder)app).DataSources - .SelectMany(source => source.Endpoints) - .OfType() - .ToDictionary( - e => "/" + (e.RoutePattern.RawText ?? string.Empty).TrimStart('/'), - e => e.Metadata.GetMetadata()?.Policy, - StringComparer.OrdinalIgnoreCase); - } - } - - /// - /// The read verbs an SSR page render needs — and ONLY those — accept a session cookie. - /// - [Fact] - public async Task Only_the_read_verbs_carry_the_cookie_or_bearer_policy() - { - var policies = await RoutePoliciesAsync(); - - policies.Should().NotBeEmpty("MapMeshApi must have registered its routes"); - - foreach (var route in CookieReadableRoutes) - { - policies.Keys.Should().Contain(route, "the route this test guards must actually exist"); - policies[route].Should().Be( - PortalMcpAuth.ReadPolicyName, - "{0} is what an SSR render reads; requiring a Bearer token here is what forced a " - + "token mint (and two permanent ApiToken nodes) per page view", route); - } - - var unexpectedlyCookieReadable = policies - .Where(kv => kv.Value == PortalMcpAuth.ReadPolicyName) - .Select(kv => kv.Key) - .Except(CookieReadableRoutes, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - unexpectedlyCookieReadable.Should().BeEmpty( - "a verb reachable with nothing but the browser's session cookie must be a pure READ — " - + "add it to CookieReadableRoutes deliberately, or leave it Bearer-only"); - } - - /// - /// Every mutating verb still demands a Bearer token. This is the half that keeps the fix from - /// becoming a CSRF surface. - /// - [Fact] - public async Task Every_mutating_verb_stays_bearer_only() - { - var policies = await RoutePoliciesAsync(); - - foreach (var route in MustStayBearerOnly) - { - policies.Keys.Should().Contain(route, - "the route set this test guards must actually exist — a renamed or deleted verb " - + "would otherwise pass by absence"); - policies[route].Should().Be( - PortalMcpAuth.PolicyName, - "{0} mutates; a session cookie must never be sufficient to drive it", route); - } - } - - /// - /// The policies authenticate what they claim to, and the composite scheme picks its inner - /// scheme off what the request actually presents — while CHALLENGING like an API in every case. - /// A cookie challenge here would answer an unauthenticated API call with 302 → /login - /// and an HTML page, which is the failure McpAuthenticationExtensions exists to prevent. - /// - [Fact] - public async Task The_read_policy_accepts_cookie_or_bearer_but_always_challenges_as_an_api() - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - AddPortalAuth(builder.Services); - - var app = builder.Build(); - await using (app) - { - var provider = app.Services.GetRequiredService(); - - var readPolicy = await provider.GetPolicyAsync(PortalMcpAuth.ReadPolicyName); - readPolicy.Should().NotBeNull(); - readPolicy!.AuthenticationSchemes.Should().Equal(PortalMcpAuth.CookieOrBearerScheme); - - // The Bearer-only policy must not have gained the cookie scheme. - var mcpPolicy = await provider.GetPolicyAsync(PortalMcpAuth.PolicyName); - mcpPolicy.Should().NotBeNull(); - mcpPolicy!.AuthenticationSchemes.Should().Equal(McpAuthenticationDefaults.AuthenticationScheme); - - var options = app.Services.GetRequiredService>() - .Get(PortalMcpAuth.CookieOrBearerScheme); - - options.ForwardChallenge.Should().Be(McpAuthenticationDefaults.AuthenticationScheme, - "an unauthenticated API caller must get 401 + WWW-Authenticate, never a redirect to " - + "the HTML login page"); - - options.ForwardDefaultSelector.Should().NotBeNull(); - options.ForwardDefaultSelector!(ContextWith("Bearer mw_something")) - .Should().Be(McpAuthenticationDefaults.AuthenticationScheme); - options.ForwardDefaultSelector(ContextWith(null)) - .Should().Be(CookieAuthenticationDefaults.AuthenticationScheme); - } - } - - private static HttpContext ContextWith(string? authorization) - { - var context = new DefaultHttpContext(); - if (authorization is not null) - context.Request.Headers.Authorization = authorization; - return context; - } - - private const string ReadRoute = "/probe/read"; - private const string MutateRoute = "/probe/mutate"; - private const string SignInRoute = "/probe/signin"; - - /// - /// The portal's authentication composition, reduced to what these policies depend on: the - /// unified cookie scheme as the default, plus the real - /// registration on top of it. - /// - private static void AddPortalAuth(IServiceCollection services) - { - services.AddAuthentication(options => - { - options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; - }) - .AddCookie(options => options.Cookie.Name = "MemexAuth"); - services.AddMcpAuthentication(); - } - - /// - /// A real HTTP pipeline carrying both policies on stub routes: the routing test above pins - /// which verb gets which policy, and this pins what each policy DOES. - /// - private static WebApplication BuildProbeApp() - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - AddPortalAuth(builder.Services); - - var app = builder.Build(); - app.UseAuthentication(); - app.UseAuthorization(); - - app.MapGet(SignInRoute, (HttpContext http) => - { - var identity = new ClaimsIdentity( - [new Claim(ClaimTypes.Name, "rbuergi"), new Claim("preferred_username", "rbuergi")], - CookieAuthenticationDefaults.AuthenticationScheme); - return http.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity)); - }).AllowAnonymous(); - - app.MapGet(ReadRoute, () => "read") - .RequireAuthorization(PortalMcpAuth.ReadPolicyName); - app.MapGet(MutateRoute, () => "mutate") - .RequireAuthorization(PortalMcpAuth.PolicyName); - return app; - } - - /// - /// Signs in through the pipeline and returns the session cookie the portal issued, so the - /// assertions below run against a REAL cookie principal rather than a stand-in scheme. - /// - private static async Task SignInAsync(HttpClient client) - { - var response = await client.GetAsync(SignInRoute, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.OK); - var setCookie = response.Headers.GetValues("Set-Cookie").First(); - return setCookie.Split(';')[0]; - } - - private static async Task GetAsync(HttpClient client, string route, string? cookie) - { - using var request = new HttpRequestMessage(HttpMethod.Get, route); - if (cookie is not null) - request.Headers.Add("Cookie", cookie); - return await client.SendAsync(request, TestContext.Current.CancellationToken); - } - - /// - /// The point of the whole change: a signed-in browser session is enough to READ, so a server - /// renderer holding that cookie never has to mint a credential to fetch a snapshot. - /// - [Fact] - public async Task A_session_cookie_authorizes_a_read() - { - var app = BuildProbeApp(); - await using (app) - { - await app.StartAsync(TestContext.Current.CancellationToken); - using var client = app.GetTestClient(); - var cookie = await SignInAsync(client); - - using var response = await GetAsync(client, ReadRoute, cookie); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - } - - /// - /// …and it is enough for NOTHING else. The same cookie, the same pipeline, a mutating verb's - /// policy: refused. - /// - [Fact] - public async Task The_same_session_cookie_does_not_authorize_a_mutating_verb() - { - var app = BuildProbeApp(); - await using (app) - { - await app.StartAsync(TestContext.Current.CancellationToken); - using var client = app.GetTestClient(); - var cookie = await SignInAsync(client); - - using var response = await GetAsync(client, MutateRoute, cookie); - - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized, - "accepting a cookie on a write verb would turn every mutating mesh operation into a " - + "cross-site-request-forgery target"); - } - } - - /// - /// An anonymous caller on the read policy gets an API refusal — a 401 it can act on — never a - /// 302 to the HTML login page (which a REST/SSR client would follow and parse as a 200 of - /// markup). - /// - [Fact] - public async Task An_anonymous_read_is_refused_with_401_not_a_login_redirect() - { - var app = BuildProbeApp(); - await using (app) - { - await app.StartAsync(TestContext.Current.CancellationToken); - using var client = app.GetTestClient(); - - using var response = await GetAsync(client, ReadRoute, cookie: null); - - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - response.Headers.Location.Should().BeNull("a redirect here would be answered as HTML"); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/MeshApiRecycleReasonBodyTest.cs b/test/Memex.Portal.Shared.Test/MeshApiRecycleReasonBodyTest.cs deleted file mode 100644 index 94104a6fcf..0000000000 --- a/test/Memex.Portal.Shared.Test/MeshApiRecycleReasonBodyTest.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Text.RegularExpressions; -using Memex.Portal.Shared.Api; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The wire contract of POST /api/mesh/recycle's body, on both sides of MeshWeaver#4782. -/// -/// Why it exists. AGENTS.md states carrying a recycle Reason as an -/// absolute, and until #4952 the platform surface could not take one at all. This route and -/// mw recycle are the operator-facing half, so they were where an absolute rule was -/// unsatisfiable. The route now binds instead of the -/// shared . -/// -/// 🚨 What these cases DO and DO NOT establish, stated because the obvious claim is the -/// wrong one. Swapping the bound record cannot produce a 400 for an older caller: the model binder is -/// System.Text.Json, which fills a missing property with the parameter's default and does not -/// refuse — so "an older body would 400" is not something this file could measure, and an earlier -/// draft of it asserted exactly that and stayed green with Reason made mandatory. What these -/// cases DO pin is the VALUE an absent, a present, and a present-but-blank reason arrives as, -/// which is what the one decider downstream branches on. -/// -/// The client side is established by reading rather than by a test, and deliberately: -/// MemexClient constructs its own HttpClient, so there is no seam to stub without -/// reshaping it for a test. MemexJson.Default carries -/// DefaultIgnoreCondition = WhenWritingNull, so mw recycle <path> with no -/// --reason posts {"path":"…"} — byte-identical to what it has always posted — and the -/// first case below is that body. -/// -/// Asserted over the binding rather than over HTTP because the route is on the Bearer policy: -/// an end-to-end test would need the whole token pipeline and a live mesh to prove something about a -/// record's shape. What the Reason then becomes inside the DisposeRequest is pinned by -/// RecycleReasonTest over MeshOperations.RecycleReason; this file covers the seam -/// between the two, and neither covers the three-line lambda that hands one to the other. -/// -public class MeshApiRecycleReasonBodyTest -{ - // The endpoint's own binder: System.Text.Json with web defaults, i.e. camelCase property names. - private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web); - - /// - /// The body every caller before #4782 sent, and the one mw recycle without --reason - /// still sends. It must arrive with Reason NULL — not "" — because null is the value - /// that selects the framework's self-describing fallback, and a blank is the one thing worse than - /// no reason at all (it looks like a caller who answered). Falsified by giving Reason any - /// non-null default. - /// - [Fact] - public void A_body_carrying_only_a_path_binds_with_no_reason_stated() - { - var body = JsonSerializer.Deserialize( - """{"path":"Doc/Architecture/AccessControl"}""", Web); - - body.Should().NotBeNull(); - body!.Path.Should().Be("Doc/Architecture/AccessControl"); - body.Reason.Should().BeNull(); - } - - /// - /// The case that could not be expressed before #4782. Falsified by renaming or dropping the - /// property, which is the way this route's half of the change actually breaks. - /// - [Fact] - public void A_body_carrying_a_reason_binds_it() - { - var body = JsonSerializer.Deserialize( - """{"path":"Store/Plugin","reason":"re-binding after the 9077 roll"}""", Web); - - body.Should().NotBeNull(); - body!.Path.Should().Be("Store/Plugin"); - body.Reason.Should().Be("re-binding after the 9077 roll"); - } - - /// - /// 🚨 A reason that is PRESENT and BLANK is delivered verbatim, not normalised here. - /// - /// The one assertion in this file about a decision rather than a shape. A script or a UI - /// sending an untouched text box supplies "" or whitespace, and those are refused by - /// MeshOperations.RecycleReason, which is parameterised over null, "" and - /// " " in RecycleReasonTest. Trimming or nulling them HERE would give the codebase - /// two places that decide what a blank reason means, and the one that is wrong would be the one - /// nobody reads. So the binder stays faithful and the decision stays in one place. - /// - [Theory] - [InlineData("")] - [InlineData(" ")] - public void A_present_but_blank_reason_is_delivered_verbatim_for_one_decider_downstream(string blank) - { - var json = JsonSerializer.Serialize(new { path = "Store/Plugin", reason = blank }); - - var body = JsonSerializer.Deserialize(json, Web); - - body.Should().NotBeNull(); - body!.Reason.Should().Be(blank); - } - - /// - /// 🚨 THE ROUTE ITSELF FORWARDS THE REASON — asserted over the SOURCE, because nothing else here - /// can see it (review finding on #5154). - /// - /// Every other case in this file deserializes and - /// stops there. MapMeshApi's route lambda is never executed, so the two regressions that - /// would actually break this feature's REST half — reverting the bound type to - /// , or leaving RecycleBody in place while the lambda - /// calls ops.Recycle(body.Path) and drops the reason — would both leave those cases - /// green. Acknowledging that in a doc comment, which the first version of this file did, is not - /// covering it. - /// - /// Why a source assertion and not an adapter seam. The alternative the finding offers - /// is to extract the forwarding into a named function a test can invoke. That catches a change to - /// the extracted function and NOT a route rewritten to bypass it — which is the more likely of the - /// two regressions, because bypassing an indirection is what someone simplifying the route would - /// do. A source assertion catches both named regressions directly, and adds no production - /// indirection whose only purpose is to be observed. Executing the route for real is the third - /// option and is out of proportion: the route is on the Bearer policy and RunString - /// constructs a MeshOperations over a session hub, so it would need the whole token pipeline - /// and a live mesh to prove that one argument is passed. - /// - /// What it does NOT establish: that the reason reaches the target's - /// DisposeRequest. That is RecycleReasonTest's subject, over - /// MeshOperations.RecycleReason. This case covers the one link between them that no - /// behavioural test in either project sees. - /// - [Fact] - public void The_recycle_route_binds_RecycleBody_and_forwards_its_Reason() - { - var source = File.ReadAllText(Path.Combine( - FindRepoRoot(), "memex", "Memex.Portal.Shared", "Api", "MeshApiEndpoints.cs")); - - // The mapping and its lambda, from `MapPost("/recycle"` to the `));` that closes the call — - // matched tolerantly so reformatting the argument list across lines cannot silently pass. - var mapping = Regex.Match(source, @"MapPost\(""/recycle"".*?\)\);", RegexOptions.Singleline); - - mapping.Success.Should().BeTrue("POST /api/mesh/recycle must still be mapped at all"); - - mapping.Value.Should().Contain("RecycleBody", - "the route must bind RecycleBody; PathBody carries no Reason, so reverting to it silently " - + "removes the operator's why from the REST surface"); - mapping.Value.Should().Contain("body.Reason", - "the route must FORWARD the reason — binding a body that carries one and then calling " - + "ops.Recycle(body.Path) is the regression this case exists for, and it breaks nothing " - + "else in this file"); - } - - /// - /// The routes that deliberately did NOT gain a reason. /compile and /diagnostics - /// bind , and a field that means nothing on two of three - /// routes is a field callers guess about — which is why the swap was to a NEW record rather than a - /// widened shared one. Asserted because widening the shared body is the cheaper edit and the one a - /// later change is likely to reach for. - /// - [Fact] - public void The_shared_PathBody_gains_nothing_so_compile_and_diagnostics_are_unchanged() - { - var names = typeof(MeshApiEndpoints.PathBody).GetProperties() - .Select(p => p.Name) - .ToArray(); - - names.Should().Equal(["Path"], - "/compile and /diagnostics bind PathBody and have no use for a recycle reason"); - } - - private static string FindRepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); - } -} diff --git a/test/Memex.Portal.Shared.Test/MeshApiSentinelStatusTest.cs b/test/Memex.Portal.Shared.Test/MeshApiSentinelStatusTest.cs deleted file mode 100644 index ad8cf8a6e3..0000000000 --- a/test/Memex.Portal.Shared.Test/MeshApiSentinelStatusTest.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.IO; -using System.Net; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using MeshWeaver.AI; -using MeshWeaver.Cli; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The HTTP contract of every string-returning /api/mesh/* verb (MeshWeaver.Plugins#1699): -/// response.ok means "here is the document", and nothing else. -/// -/// Why it exists. RunString shipped whatever string a -/// verb produced as 200 application/json — on the theory, written in its own comment, that -/// the sentinel was "a JSON-quoted value the client can branch on". It was never quoted: a bare -/// Unavailable: … is prose, so an Education e2e passed its expect(response.ok()) -/// and died inside JSON.parse with nothing naming the path or the reason. This pins the -/// mapping at the seam where the result becomes an , executed through a real -/// so what is asserted is the status and the bytes a caller sees, not the -/// classifier's opinion of itself. -/// -/// Both directions. A document must still go out verbatim with a 200 — a mapping that -/// reddened every read would be the opposite defect — and the negative controls are the three -/// prefixes each landing on its own status, plus a document whose CONTENT mentions the words. -/// -public class MeshApiSentinelStatusTest -{ - [Fact] - public async Task A_json_document_ships_verbatim_with_200() - { - const string document = """{"path":"Doc/Guide","content":{"error":"Error: not a sentinel"}}"""; - - var (status, body, contentType) = await ExecuteAsync(MeshApiEndpoints.Ship(document)); - - status.Should().Be((int)HttpStatusCode.OK); - contentType.Should().StartWith("application/json"); - body.Should().Be(document, "a document goes out byte-for-byte; the sentinel test is on the " - + "PREFIX, so a document whose content carries the words is untouched"); - } - - [Theory] - [InlineData("Not found: Doc/Nope", HttpStatusCode.NotFound, "NotFound")] - [InlineData("Unavailable: Doc/Guide — this read reached no verdict, so it is UNKNOWN whether this node exists. Retry shortly. Cause: timeout", HttpStatusCode.ServiceUnavailable, "Unavailable")] - [InlineData("Error: path is required.", HttpStatusCode.InternalServerError, "Error")] - [InlineData("Refused deleting ACME/Doc: Access denied. The current identity does not hold Delete here — do not retry.", HttpStatusCode.Forbidden, "Refused")] - public async Task A_sentinel_ships_as_a_non_2xx_json_envelope(string sentence, HttpStatusCode expected, string kind) - { - var (status, body, contentType) = await ExecuteAsync(MeshApiEndpoints.Ship(sentence)); - - status.Should().Be((int)expected); - contentType.Should().StartWith("application/json"); - using var doc = JsonDocument.Parse(body); - doc.RootElement.GetProperty("error").GetString().Should().Be(sentence, - "the sentence names the path and the cause — the envelope must not lose it"); - doc.RootElement.GetProperty("kind").GetString().Should().Be(kind); - } - - /// The MCP surface's contract is untouched: the classifier reads the prefix only. - [Fact] - public void The_classifier_reads_the_prefix_and_nothing_else() - { - OperationSentinel.Classify(null).Should().BeNull(); - OperationSentinel.Classify("").Should().BeNull(); - OperationSentinel.Classify("[]").Should().BeNull(); - OperationSentinel.Classify("\"Error: quoted\"").Should().BeNull("a JSON string literal is a document"); - OperationSentinel.Classify(" Error: leading space").Should().BeNull("the verbs never pad a sentinel"); - OperationSentinel.Classify("error: lower").Should().BeNull("ordinal, like the verbs that write it"); - OperationSentinel.Classify("Not found: x")!.Kind.Should().Be(SentinelKind.NotFound); - OperationSentinel.Classify("Unavailable: x")!.Kind.Should().Be(SentinelKind.Unavailable); - OperationSentinel.Classify("Error: x")!.Kind.Should().Be(SentinelKind.Error); - OperationSentinel.Classify("Refused deleting x: y")!.Kind.Should().Be(SentinelKind.Refused); - OperationSentinel.Classify("Refused: no").Should().BeNull("the refusal writer is 'Refused …', never 'Refused:'"); - } - - /// - /// The CLI's stdout/stderr/exit contract survives the status change: it unwraps the envelope - /// back to the sentence (so Error: still exits 1 with the sentence on stderr), and an - /// envelope that is NOT a sentinel — an unmapped route's 404 — stays an HTTP failure. - /// - [Fact] - public void The_cli_unwraps_the_envelope_and_only_the_envelope() - { - MemexClient.UnwrapSentinel("""{"error":"Not found: Doc/Nope","kind":"NotFound"}""") - .Should().Be("Not found: Doc/Nope"); - MemexClient.UnwrapSentinel("""{"kind":"Error","error":"Error: path is required."}""") - .Should().Be("Error: path is required."); - MemexClient.UnwrapSentinel("""{"error":"No API endpoint at /api/mesh/nope"}""") - .Should().BeNull("no kind — an unmapped route's 404 is an HTTP failure, not a verb answer"); - MemexClient.UnwrapSentinel("""{"error":"Refused deleting x: y","kind":"Refused"}""").Should().Be("Refused deleting x: y"); - MemexClient.UnwrapSentinel("""{"error":"x","kind":"Timeout"}""") - .Should().BeNull("a kind this client does not know is not a sentinel"); - MemexClient.UnwrapSentinel("Unavailable: raw prose").Should().BeNull("not JSON at all"); - MemexClient.UnwrapSentinel("[]").Should().BeNull(); - } - - /// Runs the result through a real HttpContext and reads back what a caller would see. - private static async Task<(int Status, string Body, string? ContentType)> ExecuteAsync(IResult result) - { - var services = new ServiceCollection(); - services.AddLogging(); - var http = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; - var stream = new MemoryStream(); - http.Response.Body = stream; - - await result.ExecuteAsync(http); - - return (http.Response.StatusCode, Encoding.UTF8.GetString(stream.ToArray()), http.Response.ContentType); - } -} diff --git a/test/Memex.Portal.Shared.Test/MeshApiUploadBodyTest.cs b/test/Memex.Portal.Shared.Test/MeshApiUploadBodyTest.cs deleted file mode 100644 index b3225ab15a..0000000000 --- a/test/Memex.Portal.Shared.Test/MeshApiUploadBodyTest.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Text; -using System.Threading.Tasks; -using System; -using System.Linq; -using Memex.Portal.Shared.Api; -using MeshWeaver.AI; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Metadata; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Primitives; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The refusal contract of POST /api/mesh/upload's multipart read. -/// -/// Why it exists. HandleUpload used to be one async method that read the -/// form, validated it, and then awaited MeshOperations.Upload — an await chain -/// reaching into hub code, which is the shape an endpoint's Task<IResult> signature is -/// not licence for. Splitting it moved the three refusals across a new seam -/// (ReadUploadAsync returns them; the composed chain ships them without ever constructing a -/// session hub), and a refusal that quietly became a 500 — or one that started resolving a session -/// before deciding the request was malformed — would look like working code from anywhere else. -/// -/// Asserted at that seam rather than over HTTP on purpose: the route is Bearer-only, so an -/// end-to-end 400 test would need the whole token pipeline and a live mesh in order to prove -/// something about a branch that reaches neither. -/// -public class MeshApiUploadBodyTest -{ - [Fact] - public async Task A_non_multipart_request_is_refused_before_the_body_is_read() - { - var http = NewContext(); - http.Request.ContentType = "application/json"; - - var (failure, path, bytes) = await MeshApiEndpoints.ReadUploadAsync( - http, TestContext.Current.CancellationToken); - - StatusOf(failure).Should().Be((int)HttpStatusCode.BadRequest); - path.Should().BeNull(); - bytes.Should().BeNull(); - } - - [Fact] - public async Task A_missing_path_field_is_refused() - { - var http = NewMultipart(path: null, fileName: "logo.png", content: "png-bytes"); - - var (failure, path, bytes) = await MeshApiEndpoints.ReadUploadAsync( - http, TestContext.Current.CancellationToken); - - StatusOf(failure).Should().Be((int)HttpStatusCode.BadRequest); - path.Should().BeNull(); - bytes.Should().BeNull(); - } - - [Fact] - public async Task A_missing_file_is_refused() - { - var http = NewMultipart(path: "@Foo/content/logo.png", fileName: null, content: null); - - var (failure, path, bytes) = await MeshApiEndpoints.ReadUploadAsync( - http, TestContext.Current.CancellationToken); - - StatusOf(failure).Should().Be((int)HttpStatusCode.BadRequest); - path.Should().BeNull(); - bytes.Should().BeNull(); - } - - [Fact] - public async Task An_empty_file_is_refused() - { - var http = NewMultipart(path: "@Foo/content/logo.png", fileName: "logo.png", content: ""); - - var (failure, path, bytes) = await MeshApiEndpoints.ReadUploadAsync( - http, TestContext.Current.CancellationToken); - - StatusOf(failure).Should().Be((int)HttpStatusCode.BadRequest); - path.Should().BeNull(); - bytes.Should().BeNull(); - } - - /// - /// The success shape: no failure, the declared path, and the file's bytes VERBATIM — including - /// a non-ASCII byte, since these bytes are what reaches MeshOperations.Upload and a - /// re-encode here would land a corrupt asset rather than fail. - /// - [Fact] - public async Task A_well_formed_upload_yields_the_path_and_the_exact_bytes() - { - var body = "the-file-contents ÿ"; - var http = NewMultipart(path: "@Foo/content/logo.png", fileName: "logo.png", content: body); - - var (failure, path, bytes) = await MeshApiEndpoints.ReadUploadAsync( - http, TestContext.Current.CancellationToken); - - failure.Should().BeNull(); - path.Should().Be("@Foo/content/logo.png"); - bytes.Should().Equal(Encoding.UTF8.GetBytes(body)); - } - - /// - /// 🚨 #5135: the route DECLARES its body limit, derived from the one upload ceiling. Without the - /// metadata Kestrel cuts the whole request at its 30 MB default, and the 200 MB the multipart - /// form limit promised was unreachable. Negative control (run by hand): removing - /// .WithMetadata(new UploadBodyLimit()) from MapMeshApi fails the NotBeNull. - /// - [Fact] - public void The_upload_route_declares_the_shared_upload_ceiling() - { - var builder = WebApplication.CreateSlimBuilder(); - // Minimal-API parameter inference only asks whether IMessageHub IS a service; no route runs. - builder.Services.AddSingleton(_ => throw new InvalidOperationException("not resolved")); - builder.Services.AddAuthorization(); - var app = builder.Build(); - app.MapMeshApi(); - - var upload = ((IEndpointRouteBuilder)app).DataSources - .SelectMany(source => source.Endpoints) - .OfType() - .Single(e => e.RoutePattern.RawText == MeshApiEndpoints.RoutePrefix + "/upload"); - - var limit = upload.Metadata.GetMetadata(); - limit.Should().NotBeNull("with no request-size metadata the route inherits Kestrel's 30 MB default"); - limit!.MaxRequestBodySize.Should().Be(UploadLimits.MultipartRequestBodyLimit); - limit.MaxRequestBodySize!.Value.Should().BeGreaterThan(UploadLimits.MaxUploadBytes, - "the multipart envelope sits around the file, so a limit equal to the file ceiling would " - + "refuse a legal maximum-size upload"); - } - - private static int? StatusOf(IResult? result) => - (result as IStatusCodeHttpResult)?.StatusCode; - - private static DefaultHttpContext NewContext() - { - var services = new ServiceCollection(); - services.AddLogging(); - return new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; - } - - /// A multipart/form-data request carrying the given (optional) field and file. - private static DefaultHttpContext NewMultipart(string? path, string? fileName, string? content) - { - var http = NewContext(); - var files = new FormFileCollection(); - var fields = new Dictionary(); - - if (path is not null) - fields["path"] = path; - - if (fileName is not null) - { - var bytes = Encoding.UTF8.GetBytes(content ?? string.Empty); - files.Add(new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) - { - Headers = new HeaderDictionary(), - }); - } - - http.Request.ContentType = "multipart/form-data; boundary=----test"; - http.Request.Form = new FormCollection(fields, files); - return http; - } -} diff --git a/test/Memex.Portal.Shared.Test/MeshServicePrerenderedBodyTest.cs b/test/Memex.Portal.Shared.Test/MeshServicePrerenderedBodyTest.cs deleted file mode 100644 index 91aecdd314..0000000000 --- a/test/Memex.Portal.Shared.Test/MeshServicePrerenderedBodyTest.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Reactive.Linq; -using System.Text.Json; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Mesh.Security; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The signed-in prerender read must honor current source just like the crawler-facing body. -/// Exercise the real owner and service: a stale mirror must not win over edited or cleared text. -/// -public class MeshServicePrerenderedBodyTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => base.ConfigureMesh(builder) - .AddMeshNodes( - new MeshNode("Prerender") { NodeType = "Space" }, - AssignmentNodeFactory.Policy("Prerender", new PartitionAccessPolicy { PublicRead = true }), - Page("Markdown", new { content = "Current **markdown**", prerenderedHtml = "Stale content" }), - Page("Cover", new { body = "Current **cover**", prerenderedHtml = "Stale content" }), - Page("Cleared", new { content = "", prerenderedHtml = "Stale content" }), - Page("Edited", new { content = "Initial page", prerenderedHtml = "Stale content" })); - - private static MeshNode Page(string id, object content) => new(id, "Prerender") - { - NodeType = id == "Cover" ? "Space" : "Markdown", - Content = JsonSerializer.SerializeToElement(content), - PreRenderedHtml = "Stale mirror", - }; - - [Theory] - [InlineData("Markdown", "

Current markdown

\n")] - [InlineData("Cover", "

Current cover

\n")] - [InlineData("Cleared", "")] - public async Task PrerenderReadsCurrentSource(string id, string expected) - { - var html = await Mesh.ServiceProvider.GetRequiredService() - .GetPreRenderedHtml($"Prerender/{id}").Should().Emit(); - - Assert.Equal(expected, html); - } - - [Fact] - public async Task AnEditAndClear_AreVisibleOnTheNextPrerenderRead() - { - const string path = "Prerender/Edited"; - var service = Mesh.ServiceProvider.GetRequiredService(); - Assert.Contains("Initial page", await service.GetPreRenderedHtml(path).Should().Emit()); - - await WriteAndObserve("Edited **page**", "Edited title"); - Assert.Equal("

Edited page

\n", - await service.GetPreRenderedHtml(path).Should().Emit()); - var edited = await SeoResolver.Resolve(Mesh, path).Should().Emit(); - Assert.NotNull(edited); - Assert.Equal("Edited title", edited.Node.Name); - Assert.Equal("

Edited page

\n", edited.Body); - - await WriteAndObserve("", "Cleared title"); - Assert.Equal("", await service.GetPreRenderedHtml(path).Should().Emit()); - var cleared = await SeoResolver.Resolve(Mesh, path).Should().Emit(); - Assert.NotNull(cleared); - Assert.Equal("Cleared title", cleared.Node.Name); - Assert.Equal("", cleared.Body); - - async Task WriteAndObserve(string source, string title) - { - await Mesh.GetMeshNodeStream(path).Update(node => node with - { - Name = title, - Content = new MarkdownContent { Content = source, PrerenderedHtml = "Stale content" }, - PreRenderedHtml = "Stale mirror", - }).Should().Emit(); - await Mesh.GetMeshNodeStream(path) - .Where(node => node.Name == title && MarkdownBody.Of(node, Mesh.JsonSerializerOptions) == source) - .Should().Emit(); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/MicrosoftTenantAuthorityTest.cs b/test/Memex.Portal.Shared.Test/MicrosoftTenantAuthorityTest.cs deleted file mode 100644 index 8f128ee62c..0000000000 --- a/test/Memex.Portal.Shared.Test/MicrosoftTenantAuthorityTest.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using Memex.Portal.Shared.Authentication; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The platform-side regression guard for MeshWeaver#2621: every Microsoft sign-in on -/// memex-cloud answered 500 because the tenant arrived as an EMPTY STRING. -/// -/// An environment variable or configMap entry cannot be null, only empty — so "" is -/// the deployed shape of "unset". The chart rendered Authentication__Microsoft__TenantId -/// with an empty default and the composition read that empty string as the tenant: the authority -/// became login.microsoftonline.com//v2.0, its discovery document could never be fetched -/// (IDX20803), and the failure surfaced as an unhandled InvalidOperationException on -/// every request that touched the handler — never at startup, and never naming the key an operator -/// had to set. -/// -/// Both directions are pinned, because neither implies the other: a blank tenant is unset -/// (so a portal that never wired Microsoft sign-in still starts — aborting on an unconfigured -/// OPTIONAL integration is the #2510 failure), and a value that CANNOT form an authority is -/// refused at boot with the key in the message, rather than composing a URL Entra never serves. -/// The // that was the outage's signature can never leave the composer at all. -/// -/// The sign-in handler that actually 500-ed lives in the portal package (plugins repo, -/// MicrosoftTenantBlankIsUnsetTest pins it there). This pins the platform-side composer — -/// , used by the Executive Assistant's delegated Graph flow — and the -/// boot-time guard MemexConfiguration runs on every portal host. -/// -public class MicrosoftTenantAuthorityTest -{ - /// THE defect: the deployed shape of "unset" must never become a tenant. - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("\t")] - [InlineData(null)] - public void ABlankTenantIsUnset(string? configured) - { - Assert.Equal(MicrosoftTenant.Unset, MicrosoftTenant.Resolve(configured)); - Assert.Equal("https://login.microsoftonline.com/common/v2.0", - MicrosoftTenant.Authority(configured, "v2.0")); - } - - /// - /// The outage's literal signature: whatever the tenant is, the composed authority never - /// carries an empty segment. This is the assertion that would have failed on 2026-08-28. - /// - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - [InlineData("organizations")] - public void TheAuthorityNeverCarriesAnEmptySegment(string? configured) - { - Assert.DoesNotContain("com//", MicrosoftTenant.Authority(configured, "v2.0"), StringComparison.Ordinal); - Assert.DoesNotContain("com//", MicrosoftTenant.Authority(configured, "oauth2/v2.0"), StringComparison.Ordinal); - } - - /// The other direction: a real tenant composes its authority, whitespace-trimmed. - [Theory] - [InlineData("organizations", "https://login.microsoftonline.com/organizations/v2.0")] - [InlineData(" organizations ", "https://login.microsoftonline.com/organizations/v2.0")] - [InlineData("consumers", "https://login.microsoftonline.com/consumers/v2.0")] - [InlineData("contoso.onmicrosoft.com", "https://login.microsoftonline.com/contoso.onmicrosoft.com/v2.0")] - [InlineData("72f988bf-86f1-41af-91ab-2d7cd011db47", - "https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47/v2.0")] - public void AnExplicitTenantFormsItsAuthority(string configured, string expected) - { - Assert.Equal(expected, MicrosoftTenant.Authority(configured, "v2.0")); - } - - /// The Executive Assistant's delegated Graph flow composes the same way, one path deeper. - [Fact] - public void TheGraphAuthorityIsComposedFromTheSameTenant() - { - Assert.Equal("https://login.microsoftonline.com/organizations/oauth2/v2.0", - MicrosoftTenant.Authority("organizations", "oauth2/v2.0")); - } - - /// - /// A value that is not a single authority segment is refused, and the refusal NAMES the - /// configuration key an operator has to set — the whole point of the guard. - /// - [Theory] - [InlineData("/")] - [InlineData("a/b")] - [InlineData("my tenant")] - [InlineData("../common")] - [InlineData("organizations?x=1")] - [InlineData("login.microsoftonline.com/organizations")] - public void AMalformedTenantIsRefusedByName(string configured) - { - var ex = Assert.Throws(() => MicrosoftTenant.Authority(configured, "v2.0")); - Assert.Contains(MicrosoftTenant.EnvironmentKey, ex.Message, StringComparison.Ordinal); - Assert.Contains(configured, ex.Message, StringComparison.Ordinal); - } - - /// - /// The boot-time guard: it refuses a malformed tenant at STARTUP (where - /// MemexConfiguration.ConfigureMemexMesh calls it) and — just as importantly — passes an - /// unset one, so a portal that never wired Microsoft sign-in still starts. - /// - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("organizations")] - public void ValidateAcceptsEveryUsableConfiguration(string? configured) - { - MicrosoftTenant.Validate(configured); - } - - /// …and the same guard refuses the value that cannot work, naming the key. - [Fact] - public void ValidateRefusesAMalformedTenantAtStartup() - { - var ex = Assert.Throws(() => MicrosoftTenant.Validate("a/b")); - Assert.Contains(MicrosoftTenant.EnvironmentKey, ex.Message, StringComparison.Ordinal); - } -} diff --git a/test/Memex.Portal.Shared.Test/MinMeshVersionHoldsTheUpdateTest.cs b/test/Memex.Portal.Shared.Test/MinMeshVersionHoldsTheUpdateTest.cs deleted file mode 100644 index 30a0d39006..0000000000 --- a/test/Memex.Portal.Shared.Test/MinMeshVersionHoldsTheUpdateTest.cs +++ /dev/null @@ -1,436 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using PackagingManifest = MeshWeaver.Plugin.Packaging.PluginManifest; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 Policy package-min-mesh-version on the COMPILED-MODULE lane — the pure decision. -/// A bundle whose declared floor is comparable with the running platform and strictly above it is -/// NOT landed (); the landed generation keeps -/// running. Positive and negative controls side by side, so a decision that held everything, or -/// nothing, fails here. -/// -public class ModuleFloorHoldDecisionTest -{ - private const string Running = "3.0.0-ci.9412"; - - private static string? Gate(string? floor) => ModulePlatformFloor.DeclineReason(floor, Running); - - private static string? Hold(string? floor) => PlatformFloor.HoldReason(floor, Running); - - private static ModuleActivationEntry Landed(string version) => new() - { - Name = "MeshWeaver.Store", - PackagePath = "Plugins/Store", - Version = version, - FrameworkMvid = "s-old", - Enabled = true, - }; - - private static ModuleUpdateVerdict Decide(string version, string? floor, ModuleActivationEntry? landed) => - ModuleUpdateDecision.Decide(version, floor, Gate, landed, policyDecline: null, _ => true, "s-new", - floorHold: Hold); - - /// The 2026-09-27 shape: Store 1.16 built on a newer platform than the instance runs. - [Theory] - [InlineData("3.0.0-ci.9494")] - [InlineData("3.0.0-ci.9413")] - [InlineData("3.1.0")] - public void AFloorAboveTheRunningPlatform_IsHeld_AndTheLandedVersionIsKept(string floor) - { - var upgrade = Decide("1.16.0", floor, Landed("1.15.0")); - var fresh = Decide("1.16.0", floor, landed: null); - - Assert.Equal(ModuleUpdateAction.SkipPlatformBelowFloor, upgrade.Action); - Assert.Contains(floor, upgrade.Reason); - Assert.Contains(Running, upgrade.Reason); - Assert.Contains("keeping 1.15.0", upgrade.Reason); - Assert.Equal(ModuleUpdateAction.SkipPlatformBelowFloor, fresh.Action); - } - - /// Floors at or below the running platform, and every floor that cannot be ordered - /// against it, LAND — the negative controls, including the 2026-09-07 pair. - [Theory] - [InlineData("3.0.0-ci.9412")] - [InlineData("3.0.0-ci.7845")] - [InlineData("1.0.0")] - [InlineData("3.0.0")] - [InlineData("3.0.0-rc8")] - [InlineData("not-a-version")] - [InlineData(null)] - public void AFloorAtOrBelow_OrUnorderable_Lands(string? floor) - => Assert.Equal(ModuleUpdateAction.Land, Decide("1.16.0", floor, Landed("1.15.0")).Action); - - /// A LOCAL source build (-ci.0) is never held, whatever the floor. - [Fact] - public void ALocalSourceBuild_IsNeverHeld() - => Assert.Equal(ModuleUpdateAction.Land, ModuleUpdateDecision.Decide( - "1.16.0", "3.0.0-ci.9494", Gate, Landed("1.15.0"), null, _ => true, "s-new", - floorHold: f => PlatformFloor.HoldReason(f, "3.0.0-ci.0")).Action); - - /// The floor holds a LAND only — every more specific skip keeps its own name. - [Fact] - public void TheFloor_NeverMasksAMoreSpecificSkip() - { - const string above = "3.0.0-ci.9494"; - Assert.Equal(ModuleUpdateAction.SkipUpToDate, ModuleUpdateDecision.Decide( - "1.15.0", above, Gate, Landed("1.15.0") with { FrameworkMvid = "s-new" }, null, _ => true, "s-new", - floorHold: Hold).Action); - Assert.Equal(ModuleUpdateAction.SkipOlder, Decide("1.14.0", above, Landed("1.15.0")).Action); - Assert.Equal(ModuleUpdateAction.SkipPolicy, ModuleUpdateDecision.Decide( - "1.16.0", above, Gate, Landed("1.15.0"), "policy Notify", _ => true, "s-new", floorHold: Hold).Action); - } -} - -/// -/// 🚨 Policy package-min-mesh-version through the REAL compiled-module path — -/// : the registry's index advertises a bundle with a -/// declared floor; above the running platform NOTHING is downloaded and the landed generation stays; -/// satisfied, the bundle lands. The witness is the fake registry's download log and the landing -/// service's own activation sidecar. -/// -public class ModuleBundleFloorHoldTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string RegistryHost = "registry.floor-hold.test"; - private const string RegistryUrl = "https://" + RegistryHost; - private const string Token = "mwi_floor_hold_consumer"; - private const string Plugin = "FloorHoldModulePkg"; - private const string Module = "MeshWeaver.FloorHoldModule"; - private const string Framework = "s1234567890abcdef1234567890abcdef"; - - private readonly string landingRoot = - Path.Combine(Path.GetTempPath(), "mw-floor-hold-" + Guid.NewGuid().ToString("N")); - - private readonly FloorRegistry registry = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - Directory.CreateDirectory(landingRoot); - return base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new HostRoutingClientFactory(registry)) - .AddSingleton(FloorFixture.Pin) - .AddSingleton(new ModuleLandingService(baseDirectory: landingRoot))); - } - - /// - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - try - { - if (Directory.Exists(landingRoot)) - Directory.Delete(landingRoot, recursive: true); - } - catch - { - // Best-effort cleanup. - } - } - - /// A fresh client per step: the index is cached per client instance. - private PluginBundleClient Client() => new(Mesh, RegistryUrl, Token); - - [Fact(Timeout = 180_000)] - public async Task ABundleAboveTheFloor_IsNotDownloaded_TheLandedGenerationStays_AndSatisfiedItLands() - { - var ct = TestContext.Current.CancellationToken; - FloorFixture.AssertTheFloorHolds(Mesh); - - // ── 1. v1.1.0, no floor: lands (the installed version that must keep serving). - registry.Serve("1.1.0", floor: null); - (await Client().AdoptModule(Plugin, Module, "Plugins/" + Plugin) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)).Should().Be(1); - var v1 = ModuleActivationSidecar.Read(landingRoot).Entries.Single(e => e.Name == Module); - v1.Version.Should().Be("1.1.0"); - var v1Dll = Path.Combine(ModuleLandingService.ModuleDirectoryFor(landingRoot, Module, v1), Module + ".dll"); - File.Exists(v1Dll).Should().BeTrue(); - registry.Downloads.Should().Equal("1.1.0"); - - // ── 2. v1.2.0 declaring a floor above the running platform: HELD. - registry.Serve("1.2.0", floor: FloorFixture.Above); - (await Client().AdoptModule(Plugin, Module, "Plugins/" + Plugin) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)).Should().Be(0, - "a bundle whose floor is above the running platform is not landed (policy package-min-mesh-version)"); - registry.Downloads.Should().Equal(["1.1.0"], - "the held bundle is not even DOWNLOADED — the index's floor decides before a byte travels"); - var kept = ModuleActivationSidecar.Read(landingRoot).Entries.Single(e => e.Name == Module); - kept.Version.Should().Be("1.1.0", "R1: the landed generation keeps running"); - File.Exists(v1Dll).Should().BeTrue("and its bytes stay where boot will load them"); - - // ── 3. the same version with a satisfied floor: LANDS. - registry.Serve("1.2.0", floor: FloorFixture.Below); - (await Client().AdoptModule(Plugin, Module, "Plugins/" + Plugin) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)).Should().Be(1); - ModuleActivationSidecar.Read(landingRoot).Entries.Single(e => e.Name == Module) - .Version.Should().Be("1.2.0"); - registry.Downloads.Should().Equal("1.1.0", "1.2.0"); - } - - /// The plugin registry: ONE module bundle at a settable version and floor, and a log of - /// every download. - private sealed class FloorRegistry : HttpMessageHandler, IHostHandler - { - private ImmutableList downloads = ImmutableList.Empty; - private (string Version, string? Floor) served = ("1.0.0", null); - - public ImmutableList Downloads => downloads; - - public void Serve(string version, string? floor) => served = (version, floor); - - public bool Serves(string host) => string.Equals(host, RegistryHost, StringComparison.OrdinalIgnoreCase); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var path = request.RequestUri!.AbsolutePath; - var (version, floor) = served; - if (request.Method == HttpMethod.Get && path == $"{PluginBundleClient.RoutePrefix}/index.json") - { - var body = JsonSerializer.Serialize(new - { - frameworkMvid = Framework, - bundles = new[] - { - new - { - plugin = Plugin, - version, - url = $"{RegistryUrl}{PluginBundleClient.RoutePrefix}/{Plugin}/{version}", - module = Module, - minMeshVersion = floor, - frameworkMvid = Framework, - }, - }, - }, PluginRegistryPayloads.Json); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }); - } - - if (request.Method == HttpMethod.Get && path.StartsWith($"{PluginBundleClient.RoutePrefix}/", StringComparison.Ordinal)) - { - ImmutableInterlocked.Update(ref downloads, d => d.Add(version)); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(Bundle(version, floor)), - }); - } - - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - /// A packed module bundle carrying REAL assembly bytes (the landing measures them). - private static byte[] Bundle(string version, string? floor) - { - var manifestJson = JsonSerializer.Serialize(new - { - plugin = Plugin, - version, - frameworkMvid = Framework, - module = new - { - assemblyName = Module, - assemblies = new[] { Module + ".dll" }, - minMeshVersion = floor, - }, - }); - var buffer = new MemoryStream(); - NuGetPackageWriter.Write( - buffer, - new PackagingManifest(Plugin, "MeshWeaver.Plugin." + Plugin, version, Plugin, null, []), - "3.0.0", - [ - new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleEntryPathFor(Module + ".dll"), - () => new MemoryStream(File.ReadAllBytes(typeof(BundleReader).Assembly.Location))), - ], - manifestJson); - return buffer.ToArray(); - } - } -} - -/// -/// 🚨 ONE comparator, ONE running-version reader — the guard behind policy -/// package-min-mesh-version. The floor used to be decided in three places with two -/// comparators and two notions of "running": ModuleUpdateDecision (SemVer, advisory), -/// PrebuiltAdoptionPolicy.FloorDecline (a private SemVer copy — the 2026-09-07 trap, live) -/// and GitSync's ModuleSyncDecision (PlatformCompatibility.ProducerIsNewer), reading -/// the running version from MeshWeaver.Graph's assembly stamp in one lane and from -/// PlatformBuildInfo in the other. This guard fails when a source file under src/ -/// decides a declared floor with anything but , or reads a running -/// version with anything but . -/// -public class OneFloorComparatorGuard -{ - /// The files allowed to compare a floor with SemVer, each with its reason. - private static readonly ImmutableDictionary SemVerFloorAllowed = - ImmutableDictionary.Empty - // The WORDING of the advisory lines and the pack-time lint's parity oracle - // (ModulePlatformFloorScriptParityTest) — decides nothing at runtime. - .Add("src/MeshWeaver.PluginCatalog/ModulePlatformFloor.cs", "advisory wording + pack-lint parity") - // THE decision itself: the numeric-core comparison inside PlatformFloor. - .Add("src/MeshWeaver.Plugin.Packaging/PlatformFloor.cs", "the one floor decision") - .Add("src/MeshWeaver.Plugin.Packaging/PlatformReleaseOrder.cs", "the one release order"); - - private static string RepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - return dir?.FullName ?? throw new InvalidOperationException( - "MeshWeaver.slnx not found above the test's base directory — the guard cannot see src/, so it " - + "must fail rather than pass having checked nothing"); - } - - private static ImmutableList<(string Path, string Text)> Sources() - { - var root = RepoRoot(); - return Directory.EnumerateFiles(Path.Combine(root, "src"), "*.cs", SearchOption.AllDirectories) - .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) - && !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) - .Select(f => (Path.GetRelativePath(root, f).Replace('\\', '/'), File.ReadAllText(f))) - .ToImmutableList(); - } - - /// Code lines only — a doc comment that NAMES the old comparator is not a call. - private static string Code(string text) => - string.Join('\n', text.Split('\n').Where(line => !line.TrimStart().StartsWith("//", StringComparison.Ordinal))); - - [Fact] - public void NoFloorIsDecidedWithSemVer_OutsideTheOneDecision() - { - var sources = Sources(); - sources.Should().NotBeEmpty("the guard must see src/"); - - // A file that both names a declared floor and calls the SemVer comparer is a second floor - // decision in waiting — exactly how PrebuiltAdoptionPolicy.FloorDecline kept the 09-07 trap. - var offenders = sources - .Where(s => !SemVerFloorAllowed.ContainsKey(s.Path)) - .Select(s => (s.Path, Code: Code(s.Text))) - .Where(s => Regex.IsMatch(s.Code, @"\bminMeshVersion\b", RegexOptions.IgnoreCase) - && Regex.IsMatch(s.Code, @"NuGetVersionComparer\.Instance\.Compare\([^;]*(?:minMeshVersion|floor)", - RegexOptions.IgnoreCase)) - .Select(s => s.Path) - .ToList(); - - offenders.Should().BeEmpty( - "a declared minMeshVersion is decided by PlatformFloor alone (policy package-min-mesh-version); " - + "SemVer ranks ci < rc < clean and held every portal on 2026-09-07. Offenders: " - + string.Join(", ", offenders)); - } - - [Fact] - public void TheModuleSyncDecline_UsesTheOneDecision() - { - var sync = Sources().Single(s => s.Path.EndsWith("MeshWeaver.GitSync/ModuleSyncDecision.cs", StringComparison.Ordinal)); - var code = Code(sync.Text); - code.Should().Contain("PlatformFloor.Evaluate("); - code.Should().NotContain("ProducerIsNewer(", "the GitSync decline and the catalog's hold are one decision"); - } - - [Fact] - public void TheRunningVersion_HasOneReader() - { - // Every "running platform version" a floor is compared against comes from - // PlatformBuildInfo.RunningPlatformVersion; the two lane-local readers delegate to it. - var sources = Sources(); - var floor = sources.Single(s => s.Path.EndsWith("MeshWeaver.PluginCatalog/ModulePlatformFloor.cs", StringComparison.Ordinal)); - Code(floor.Text).Should().Contain("RunningVersion => PlatformBuildInfo.RunningPlatformVersion"); - Code(floor.Text).Should().NotContain("AssemblyInformationalVersionAttribute", - "the module lane used to read MeshWeaver.Graph's stamp, which carries no run number"); - var prebuilt = sources.Single(s => s.Path.EndsWith("MeshWeaver.Hosting/PrebuiltAdoptionPolicy.cs", StringComparison.Ordinal)); - Code(prebuilt.Text).Should().Contain("RunningPlatformVersion => PlatformBuildInfo.RunningPlatformVersion"); - - // …and the floor gates read it through that one property. - var gate = sources.Single(s => s.Path.EndsWith("MeshWeaver.PluginCatalog/PackagePlatformFloorGate.cs", StringComparison.Ordinal)); - Code(gate.Text).Should().Contain("PlatformFloor.Evaluate(candidate.MinMeshVersion, RunningVersion(hub))"); - Code(gate.Text).Should().Contain(": PlatformBuildInfo.RunningPlatformVersion;", - "the catalog's reader falls back to the one reader; the override is a test seam"); - var bundle = sources.Single(s => s.Path.EndsWith("MeshWeaver.PluginCatalog/PluginBundleClient.cs", StringComparison.Ordinal)); - Code(bundle.Text).Should().Contain("floorHold: PackagePlatformFloorGate.HoldFor(_hub)", - "the compiled-module lane decides through the same gate as the content lane"); - } - - /// Behavioural parity across the matrix: the GitSync decline and the catalog's hold - /// answer the same for every (floor, running) pair — including the 2026-09-07 pairs. - [Theory] - [InlineData("3.0.0-ci.9494", "3.0.0-ci.9412")] - [InlineData("3.0.0-ci.9412", "3.0.0-ci.9412")] - [InlineData("3.0.0-rc8", "3.0.0-ci.8055")] - [InlineData("3.0.0", "3.0.0-ci.9412")] - [InlineData("1.0.0", "3.0.0-ci.9412")] - [InlineData("3.1.0", "3.0.0-ci.9412")] - [InlineData("garbage", "3.0.0-ci.9412")] - [InlineData("3.0.0-ci.9494", "3.0.0-ci.0")] - [InlineData("3.0.0-ci.9494", null)] - public void GitSyncAndTheCatalog_AgreeOnEveryPair(string floor, string? running) - { - var declined = ModuleSyncDecision.Decide( - [new ModuleReading("M", "", "hash", floor)], null, running, reconcile: false) - .Single().Outcome == ModuleSyncOutcomeKind.Declined; - var held = ModuleUpdateDecision.Decide("2.0.0", floor, _ => null, null, null, _ => true, "s", - floorHold: f => PlatformFloor.HoldReason(f, running)).Action == ModuleUpdateAction.SkipPlatformBelowFloor; - - declined.Should().Be(PlatformFloor.Evaluate(floor, running).IsHeld); - held.Should().Be(declined); - } -} - -/// -/// The platform the end-to-end fixtures run on, and the floors either side of it — the 2026-09-27 -/// numbers: instances on 3.0.0-ci.9412, packages built on 3.0.0-ci.9494. Pinned on the -/// mesh through , because a test process is a local -/// -ci.0 build, against which no floor is ordered (and correctly so — see -/// PlatformFloorTest.TheSeptember7Trap_NeverHolds). -/// -internal static class FloorFixture -{ - /// The running platform every end-to-end fixture pins. - public const string Running = "3.0.0-ci.9412"; - - /// A floor above — the build the 09-27 packages were made on. - public const string Above = "3.0.0-ci.9494"; - - /// A floor below . - public const string Below = "3.0.0-ci.7845"; - - /// The seam registration that pins on a test mesh. - public static RunningPlatformVersionOverride Pin => new(Running); - - /// - /// 🚨 The precondition every end-to-end hold test rests on, ASSERTED against the mesh's own - /// reading: the pinned version must be what the catalog reads, and the two floors must fall on - /// either side of it, or the "held" arm would pass (or fail) for the wrong reason. - /// - public static void AssertTheFloorHolds(MeshWeaver.Messaging.IMessageHub mesh) - { - var running = PackagePlatformFloorGate.RunningVersion(mesh); - running.Should().Be(Running, "the fixture pins the running platform on the mesh"); - PlatformFloor.Evaluate(Above, running).IsHeld.Should().BeTrue(); - PlatformFloor.Evaluate(Below, running).IsHeld.Should().BeFalse(); - } -} diff --git a/test/Memex.Portal.Shared.Test/MintedTokenCarriesItsOwnStampTest.cs b/test/Memex.Portal.Shared.Test/MintedTokenCarriesItsOwnStampTest.cs deleted file mode 100644 index f92587db73..0000000000 --- a/test/Memex.Portal.Shared.Test/MintedTokenCarriesItsOwnStampTest.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Fixture; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #6636: the OAuth exchange ranks this client's live tokens for eviction by the minted token's -/// creation stamp. That stamp is the one the mint WROTE, carried on -/// — it used to be re-read off the node's content with a -/// cast whose fallback was "now", taken after the read-back and therefore LATER than the real stamp. -/// -public class MintedTokenCarriesItsOwnStampTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string User = "mintstampuser"; - - private Task Within(IObservable source, string because = "") => - source.Should().Within(TestTimeouts.Convergence).Emit(because, TestContext.Current.CancellationToken); - - [Fact] - public async Task TheResult_CarriesTheStampTheMintWrote_NotOneTakenAfterTheReadBack() - { - var service = new ApiTokenService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService(), - new CapturingLogger()); - - var before = DateTimeOffset.UtcNow; - var creation = await Within(service.CreateOAuthToken(User, User, $"{User}@example.com", "stamp-client", null)); - var after = DateTimeOffset.UtcNow; - - var stored = creation.Node.ContentAs(Mesh.JsonSerializerOptions); - stored.Should().NotBeNull("the minted node carries its token record"); - creation.CreatedAt.Should().Be(stored!.CreatedAt, - "the eviction key is the stamp the mint wrote, not a value taken afterwards"); - creation.CreatedAt.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleActivationPostMergeTest.cs b/test/Memex.Portal.Shared.Test/ModuleActivationPostMergeTest.cs deleted file mode 100644 index 55c24a13de..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleActivationPostMergeTest.cs +++ /dev/null @@ -1,344 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #4026 after #4427 merged — the post-merge review's three defects, each reproduced on the real -/// landing service over a real landing root, deterministically. -/// -/// -/// An OLDER image's install or uninstall exists only as a <Name>.json without -/// projectionOf; the next current-image write of that file used to overwrite the only -/// trace of it, and from then on the derivation re-dated or dropped the event. -/// An UNREADABLE record, tombstone or entry file used to be skipped with a log line — so the -/// answer CHANGED (an uninstalled module loaded, an older generation took the head) instead of -/// the module being dropped, and a module set could be proposed from the partial read. -/// Per-platform verdicts were kept forever, so every old build pinned its own fallback -/// record and bytes until uninstall. -/// -/// -/// "An older image" is simulated exactly the way one writes: its generation's bytes put in -/// place under the content-addressed leaf, and its per-module entry written by -/// with no projectionOf — a field that image -/// does not know. -/// -public class ModuleActivationPostMergeTest : IDisposable -{ - private const string Module = "MeshWeaver.Test.PostMerge"; - private const string MarkerAsset = "wwwroot/build.txt"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-activation-post-merge-" + Guid.NewGuid().ToString("N")); - - /// Creates the landing root the replicas share. - public ModuleActivationPostMergeTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - private static (IReadOnlyList<(string, byte[])> Assemblies, IReadOnlyList<(string, byte[])> Assets) Bundle(string content) => - ([(Module + ".dll", File.ReadAllBytes(typeof(BundleReader).Assembly.Location))], - [(MarkerAsset, Encoding.UTF8.GetBytes(content))]); - - private static async Task Shelve(ModuleLandingService replica, string version) - { - var (assemblies, assets) = Bundle(version); - return await replica.ShelveModule( - Module, assemblies, frameworkMvid: "test-build", packagePath: "Plugins/PostMerge", - version: version, staticAssets: assets) - .Timeout(TestTimeouts.Convergence).Await(); - } - - private ModuleLandingService Replica(string? platform = null) => - new(null, root, beforeRecording: null, platformIdentity: platform); - - private ModuleActivationEntry? Entry() => - ModuleActivationSidecar.Read(root).Entries.SingleOrDefault(e => e.Name == Module); - - private string MarkerIn(ModuleActivationEntry entry) => - File.ReadAllText(Path.Combine( - ModuleLandingService.ModuleDirectoryFor(root, Module, entry), - MarkerAsset.Replace('/', Path.DirectorySeparatorChar))); - - /// - /// What an image that predates the landing records does when it installs a build: the bytes go - /// into the content-addressed generation, and its per-module entry names them — the displaced - /// head as the previous generation, the way that image's PreviousToKeep did. No record, - /// no projectionOf. - /// - private string OlderImageInstalls(string version) - { - var (assemblies, assets) = Bundle(version); - var generation = $"{Module}@{ModuleLandingService.GenerationIdOf(assemblies, assets)}"; - var directory = Path.Combine(root, "modules", generation); - Directory.CreateDirectory(Path.Combine(directory, "wwwroot")); - foreach (var (file, bytes) in assemblies) - File.WriteAllBytes(Path.Combine(directory, file), bytes); - foreach (var (file, bytes) in assets) - File.WriteAllBytes(Path.Combine(directory, file.Replace('/', Path.DirectorySeparatorChar)), bytes); - var displaced = Entry() is { Enabled: true } current ? current : null; - ModuleActivationSidecar.WriteEntry(root, new ModuleActivationEntry - { - Name = Module, - PackagePath = "Plugins/PostMerge", - Directory = generation, - Version = version, - FrameworkMvid = "test-build", - PreviousDirectory = displaced?.Directory, - PreviousVersion = displaced?.Version, - PreviousFrameworkMvid = displaced?.FrameworkMvid, - Enabled = true, - }); - return generation; - } - - /// What an image that predates the landing records writes to uninstall: its disabled - /// per-module entry, and nothing else. - private void OlderImageUninstalls() - { - var existing = Entry()!; - ModuleActivationSidecar.WriteEntry(root, existing with - { - Enabled = false, - Directory = null, - PreviousDirectory = null, - PreviousVersion = null, - PreviousFrameworkMvid = null, - }); - } - - // ── 1. an older image's event outlives the next current-image write of its file ────────── - - /// - /// 🚨 The review's scenario, verbatim: a current image uninstalls (tombstone T1); during a - /// rollback an older image reinstalls 1.7.0 (T2); current images read 1.7.0; a current image - /// then shelves 1.6.1 (T3) — and its own report says 1.7.0 stays the head. The NEXT read must - /// say the same: before this change it derived 1.6.1 with no fallback, because the overwrite - /// was the only trace of T2, and the module set would have carried that fleet-wide (#3996 - /// again). - /// - [Fact] - public async Task AnOlderImagesReinstallAfterAnUninstall_SurvivesTheNextCurrentLanding() - { - using var current = Replica(); - await Shelve(current, "1.7.0"); - current.RemoveCore(Module); - Assert.False(Entry()!.Enabled); - - OlderImageInstalls("1.7.0"); - var reinstalled = Entry()!; - Assert.True(reinstalled.Enabled, "precondition: current images read the older image's reinstall"); - Assert.Equal("1.7.0", reinstalled.Version); - - var outcome = await Shelve(current, "1.6.1"); - Assert.True(outcome.ShelfOnly); - Assert.Equal("1.7.0", outcome.HeadVersion); - - var after = Entry()!; - Assert.True(after.Enabled); - Assert.Equal("1.7.0", after.Version); - Assert.Equal("1.7.0", MarkerIn(after)); - Assert.Equal("1.6.1", after.PreviousVersion); - } - - /// - /// The mirror: an older image UNINSTALLS after current-image landings, then a current image - /// lands an older build. That landing comes after the uninstall, so it is a FIRST landing and - /// takes the head — and it must stay one after the overwrite. Before this change the overwrite - /// erased the uninstall and the pre-uninstall 1.7.0 record came back to life as the head. - /// - [Fact] - public async Task AnOlderImagesUninstall_SurvivesTheNextCurrentLanding() - { - using var current = Replica(); - await Shelve(current, "1.7.0"); - OlderImageUninstalls(); - Assert.False(Entry()!.Enabled, "precondition: current images read the older image's uninstall"); - - var outcome = await Shelve(current, "1.6.0"); - Assert.False(outcome.ShelfOnly, "the first landing after an uninstall is the head"); - - var after = Entry()!; - Assert.True(after.Enabled); - Assert.Equal("1.6.0", after.Version); - Assert.Null(after.PreviousDirectory); - } - - /// - /// An older image's ADOPT of an older version (the Store's rollback there: 1.5.0 over 1.6.0) - /// is that image's decision at its time. A later shelf landing of 1.4.0 ranks below it and - /// leaves it the head — and the overwrite that landing does must not flip the head back to - /// 1.6.0, which is what re-dating the older image's head to "the beginning of time" did. - /// - [Fact] - public async Task AnOlderImagesAdoptOfAnOlderVersion_SurvivesTheNextShelfLanding() - { - using var current = Replica(); - await Shelve(current, "1.6.0"); - OlderImageInstalls("1.5.0"); - Assert.Equal("1.5.0", Entry()!.Version); - - var outcome = await Shelve(current, "1.4.0"); - Assert.True(outcome.ShelfOnly); - - var after = Entry()!; - Assert.Equal("1.5.0", after.Version); - Assert.Equal("1.5.0", MarkerIn(after)); - } - - // ── 2. an unreadable record changes nothing — the module is dropped, and says so ──────── - - private static void Corrupt(string path) => File.WriteAllText(path, "{ this is not a record"); - - private async Task<(ModuleLandingService Replica, List Faults)> LandedAndUninstalled() - { - var replica = Replica(); - await Shelve(replica, "1.7.0"); - replica.RemoveCore(Module); - return (replica, []); - } - - /// - /// 🚨 An SMB sharing violation on the tombstone at boot used to load an UNINSTALLED module: the - /// unreadable tombstone was skipped, the landing before it counted again, and the module came - /// back. An unreadable event must never produce a different answer — the module is dropped - /// (not loaded) and the read says why; and a module set is never proposed from such a read. - /// - [Fact] - public async Task AnUnreadableTombstone_DropsTheModule_AndNothingIsProposedFromThePartialRead() - { - var (replica, faults) = await LandedAndUninstalled(); - using var _ = replica; - var tombstone = Assert.Single(Directory.GetFiles( - ModuleActivationSidecar.LandingRecordsDirectory(root, Module), "*" + ModuleActivationSidecar.UninstallSuffix)); - Corrupt(tombstone); - - var read = ModuleActivationSidecar.Read(root, faults.Add); - - Assert.DoesNotContain(read.Entries, e => e.Name == Module); - Assert.Contains(faults, f => f.Contains(Module, StringComparison.Ordinal)); - await Assert.ThrowsAsync(async () => - await replica.ProposeModuleSet().Timeout(TestTimeouts.Convergence).Await()); - } - - /// An unreadable HEAD record used to promote an older generation to the head — a - /// different answer, which boot would load and a wave would propose. Dropped instead. - [Fact] - public async Task AnUnreadableHeadRecord_DropsTheModule_NeverFallsBackToAnOlderGeneration() - { - using var replica = Replica(); - await Shelve(replica, "1.6.0"); - await Shelve(replica, "1.7.0"); - var head = Entry()!; - Assert.Equal("1.7.0", head.Version); - var headRecord = Assert.Single(Directory.GetFiles( - ModuleActivationSidecar.LandingRecordsDirectory(root, Module), head.Directory + ".*.json")); - Corrupt(headRecord); - var faults = new List(); - - var read = ModuleActivationSidecar.Read(root, faults.Add); - - Assert.DoesNotContain(read.Entries, e => e.Name == Module); - Assert.Contains(faults, f => f.Contains(Module, StringComparison.Ordinal)); - } - - /// - /// The per-module file itself unreadable, with a stale legacy-aggregate row behind it: the - /// row used to be taken as an older image's install stamped with the UNREADABLE file's recent - /// write time — newer than the uninstall — and the module came back. The module is dropped. - /// - [Fact] - public async Task AnUnreadableEntryFile_DoesNotLetAStaleAggregateRowDecide() - { - var (replica, faults) = await LandedAndUninstalled(); - using var _ = replica; - var stale = ModuleActivationSidecar.ReadStored(root).Entries.Single(e => e.Name == Module) with - { - Enabled = true, - Directory = Module + "@0123456789abcdef", - Version = "1.7.0", - ProjectionOf = null, - }; - File.WriteAllText(ModuleActivationSidecar.SidecarPath(root), JsonSerializer.Serialize( - new ModuleActivationList { Entries = [stale] }, new JsonSerializerOptions(JsonSerializerDefaults.Web))); - File.SetLastWriteTimeUtc(ModuleActivationSidecar.SidecarPath(root), DateTime.UtcNow.AddDays(-30)); - Corrupt(ModuleActivationSidecar.EntryPath(root, Module)); - - var read = ModuleActivationSidecar.Read(root, faults.Add); - - Assert.DoesNotContain(read.Entries, e => e.Name == Module && e.Enabled); - Assert.Contains(faults, f => f.Contains(Module, StringComparison.Ordinal)); - } - - // ── 3. a platform no replica has measured recently stops protecting its fallback ─────── - - /// - /// 🚨 Verdicts are per platform build, and the platform key changes with most builds — so a - /// rule that protected every platform's fallback kept every old build's fallback record and - /// bytes for the life of the module. Only the platforms that measured the module most recently - /// (and the GC's own, and the one with no verdicts) protect their fallback; an older build's - /// verdicts are retired, and the generation only IT would fall back to is reclaimed. - /// - /// build-1 measured 1.6.0 as not loading, so ITS fallback is 1.5.0 while every later - /// build's is 1.6.0. Four builds later, nothing should still hold 1.5.0. - /// - [Fact] - public async Task APlatformThatNoLongerMeasuresTheModule_StopsProtectingItsFallback() - { - using var build4 = Replica("build-4"); - await Shelve(build4, "1.5.0"); - var onlyBuild1Needs = Entry()!.Directory!; - await Shelve(build4, "1.6.0"); - var newestFallback = Entry()!.Directory!; - await Shelve(build4, "1.7.0"); - var head = Entry()!.Directory!; - - var now = DateTime.UtcNow; - void Measured(string platform, string generation, bool linkable, DateTime at) - { - Assert.True(ModuleActivationSidecar.WriteVerdict(root, Module, generation, platform, linkable)); - var file = Directory.GetFiles(ModuleActivationSidecar.LandingRecordsDirectory(root, Module), - generation + ".*" + ModuleActivationSidecar.VerdictSuffix) - .Single(path => File.ReadAllText(path).Contains("\"" + platform + "\"", StringComparison.Ordinal)); - File.SetLastWriteTimeUtc(file, at); - } - Measured("build-1", onlyBuild1Needs, true, now.AddHours(-4)); - Measured("build-1", newestFallback, false, now.AddHours(-4)); - Measured("build-1", head, true, now.AddHours(-4)); - foreach (var (platform, age) in new[] { ("build-2", -3), ("build-3", -2) }) - foreach (var generation in new[] { onlyBuild1Needs, newestFallback, head }) - Measured(platform, generation, true, now.AddHours(age)); - Assert.True(Directory.Exists(Path.Combine(root, "modules", onlyBuild1Needs))); - - ModuleLandingService.CollectGarbage(root, minAge: TimeSpan.Zero, nowUtc: now.AddHours(1)); - - Assert.False(Directory.Exists(Path.Combine(root, "modules", onlyBuild1Needs)), - "only build-1 — four builds ago — would fall back to 1.5.0; it no longer pins those bytes"); - var platformsLeft = Directory.GetFiles(ModuleActivationSidecar.LandingRecordsDirectory(root, Module), - "*" + ModuleActivationSidecar.VerdictSuffix) - .Select(path => JsonDocument.Parse(File.ReadAllText(path)).RootElement.GetProperty("platform").GetString()) - .Distinct() - .OrderBy(p => p, StringComparer.Ordinal) - .ToArray(); - Assert.Equal(["build-3", "build-4"], platformsLeft); - var after = Entry()!; - Assert.Equal(head, after.Directory); - Assert.Equal(newestFallback, after.PreviousDirectory); - Assert.True(ModuleActivationBoot.PreviousLandedModuleDllExists(root, after)); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleActivationStoredLayerTest.cs b/test/Memex.Portal.Shared.Test/ModuleActivationStoredLayerTest.cs deleted file mode 100644 index b5eb22e4b0..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleActivationStoredLayerTest.cs +++ /dev/null @@ -1,186 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #4026 — Copilot's review of #4438: the STORED layer (the legacy aggregate and the per-module -/// entry files) is an input to the derivation like any record, so it must be read WHOLE before -/// anything is derived, projected or tombstoned from it; and an older image's event needs an -/// identity an uninstall can name, or a timestamp tie decides the order by accident. -/// -public class ModuleActivationStoredLayerTest : IDisposable -{ - private const string Module = "MeshWeaver.Test.StoredLayer"; - private const string MarkerAsset = "wwwroot/build.txt"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-activation-stored-layer-" + Guid.NewGuid().ToString("N")); - - /// Creates the landing root. - public ModuleActivationStoredLayerTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - private static (IReadOnlyList<(string, byte[])> Assemblies, IReadOnlyList<(string, byte[])> Assets) Bundle(string content) => - ([(Module + ".dll", File.ReadAllBytes(typeof(BundleReader).Assembly.Location))], - [(MarkerAsset, Encoding.UTF8.GetBytes(content))]); - - private static async Task Shelve(ModuleLandingService replica, string version) - { - var (assemblies, assets) = Bundle(version); - await replica.ShelveModule( - Module, assemblies, frameworkMvid: "test-build", packagePath: "Plugins/StoredLayer", - version: version, staticAssets: assets) - .Timeout(TestTimeouts.Convergence).Await(); - } - - private ModuleLandingService Replica() => new(null, root, beforeRecording: null); - - private int Tombstones() => - Directory.GetFiles(ModuleActivationSidecar.LandingRecordsDirectory(root, Module), - "*" + ModuleActivationSidecar.UninstallSuffix).Length; - - // ── 1. a stored-layer fault makes the modules it can hide not-whole ─────────────────── - - /// - /// 🚨 The legacy aggregate cannot be read, and this module has no per-module entry file — so - /// its stored entry may be IN the aggregate, and nothing that reads the stored layer knows what - /// it says. Before this change only an unreadable per-module file counted: the module was - /// derived from its records as if the aggregate said nothing, and an uninstall wrote its - /// tombstone over that partial read. Now the module is dropped from the read, reported, and an - /// uninstall refuses — no tombstone. - /// - [Fact] - public async Task AnUnreadableAggregate_ThatCouldHoldAModulesOnlyEntry_DropsIt_AndNothingIsWrittenOverTheRead() - { - using var replica = Replica(); - await Shelve(replica, "1.7.0"); - File.Delete(ModuleActivationSidecar.EntryPath(root, Module)); - File.WriteAllText(ModuleActivationSidecar.SidecarPath(root), "{ this is not an activation list"); - var faults = new List(); - - var read = ModuleActivationSidecar.Read(root, faults.Add); - - Assert.DoesNotContain(read.Entries, e => e.Name == Module); - Assert.Contains(faults, f => f.Contains(Module, StringComparison.Ordinal)); - Assert.Throws(() => replica.RemoveCore(Module)); - Assert.Equal(0, Tombstones()); - } - - /// - /// The control that keeps the rule precise rather than total: a module WITH its own per-module - /// file cannot be hidden by the aggregate — that file wins by name — so an unreadable aggregate - /// (which is never rewritten, and could stay unreadable for good) must not drop it. - /// - [Fact] - public async Task AnUnreadableAggregate_CannotHideAModuleWithItsOwnEntryFile() - { - using var replica = Replica(); - await Shelve(replica, "1.7.0"); - File.WriteAllText(ModuleActivationSidecar.SidecarPath(root), "{ this is not an activation list"); - - var entry = Assert.Single(ModuleActivationSidecar.Read(root).Entries, e => e.Name == Module); - - Assert.True(entry.Enabled); - Assert.Equal("1.7.0", entry.Version); - replica.RemoveCore(Module); - Assert.Equal(1, Tombstones()); - } - - // ── 2. an uninstall that observed an older image's install wins a timestamp tie ───────── - - /// - /// 🚨 A current image uninstalls a module an OLDER image installed, and the file server stamps - /// the tombstone with the same tick as that image's entry file. The uninstall observed the - /// install — so the install precedes it and the module is uninstalled. Before this change an - /// older-image event had no identity the uninstall's after could name, and at a tie it - /// counted as AFTER the uninstall: the module stayed enabled. - /// - [Fact] - public async Task AnUninstallThatObservedAnOlderImagesInstall_PrecedesItAtATimestampTie() - { - using var current = Replica(); - await Shelve(current, "1.7.0"); - var landed = Assert.Single(ModuleActivationSidecar.Read(root).Entries, e => e.Name == Module); - // An older image installs 1.8.0: its bytes, and its per-module entry with no projectionOf. - var (assemblies, assets) = Bundle("1.8.0"); - var generation = $"{Module}@{ModuleLandingService.GenerationIdOf(assemblies, assets)}"; - var directory = Path.Combine(root, "modules", generation); - Directory.CreateDirectory(Path.Combine(directory, "wwwroot")); - File.WriteAllBytes(Path.Combine(directory, Module + ".dll"), assemblies[0].Item2); - File.WriteAllBytes(Path.Combine(directory, "wwwroot", "build.txt"), assets[0].Item2); - ModuleActivationSidecar.WriteEntry(root, landed with - { - Directory = generation, - Version = "1.8.0", - PreviousDirectory = landed.Directory, - PreviousVersion = landed.Version, - ProjectionOf = null, - }); - var installedAt = File.GetLastWriteTimeUtc(ModuleActivationSidecar.EntryPath(root, Module)); - Assert.Equal("1.8.0", Assert.Single(ModuleActivationSidecar.Read(root).Entries, e => e.Name == Module).Version); - - current.RemoveCore(Module); - // The file server stamps the tombstone in the very tick the older image's entry was written. - var tombstone = Assert.Single(Directory.GetFiles( - ModuleActivationSidecar.LandingRecordsDirectory(root, Module), "*" + ModuleActivationSidecar.UninstallSuffix)); - File.SetLastWriteTimeUtc(tombstone, installedAt); - Assert.Equal(installedAt, File.GetLastWriteTimeUtc(tombstone)); - - var entry = Assert.Single(ModuleActivationSidecar.Read(root).Entries, e => e.Name == Module); - Assert.False(entry.Enabled, "the uninstall observed the older image's install, so it follows it — tie or not"); - Assert.Null(entry.Directory); - } - - // ── 3. an entry file that does not hold the entry its name addresses is corrupt ───────── - - /// - /// 🚨 A per-module entry file that deserializes to nothing (JSON null), to an entry with - /// no name, or to an entry naming a DIFFERENT module is not the entry its name addresses — the - /// record readers already treat exactly that as corrupt. It used to fall through silently: the - /// module was derived from its records as if the file were absent (and the misaddressed entry - /// even surfaced as a module of its own). Now it is reported and keyed by the FILE's name, so - /// the module that has records is dropped. - /// - [Theory] - [InlineData("json-null")] - [InlineData("blank-name")] - [InlineData("misaddressed")] - public async Task AnEntryFileNotHoldingItsOwnEntry_IsCorrupt_AndDropsTheModule(string shape) - { - using var replica = Replica(); - await Shelve(replica, "1.7.0"); - var entryFile = ModuleActivationSidecar.EntryPath(root, Module); - File.WriteAllText(entryFile, shape switch - { - "json-null" => "null", - "blank-name" => """{ "name": "", "directory": "x@0123456789abcdef", "enabled": true }""", - _ => JsonSerializer.Serialize( - new ModuleActivationEntry { Name = "MeshWeaver.Test.SomeOtherModule", Directory = "x@0123456789abcdef" }, - new JsonSerializerOptions(JsonSerializerDefaults.Web)), - }); - var faults = new List(); - - var read = ModuleActivationSidecar.Read(root, faults.Add); - - Assert.DoesNotContain(read.Entries, e => e.Name == Module); - Assert.DoesNotContain(read.Entries, e => e.Name == "MeshWeaver.Test.SomeOtherModule"); - Assert.Contains(faults, f => f.Contains(Module, StringComparison.Ordinal)); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleEndpointsSwapLiveTest.cs b/test/Memex.Portal.Shared.Test/ModuleEndpointsSwapLiveTest.cs deleted file mode 100644 index de837c53bb..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleEndpointsSwapLiveTest.cs +++ /dev/null @@ -1,287 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Net; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.AspNetCore; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Threading; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.TestHost; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 HTTP endpoints of a module go live too (policy module-live-update-default). A module -/// held in its own load context maps its routes through , which -/// re-maps them from the module's CURRENT generation when it is swapped — so Mcp, Teams, Mail, WhatsApp -/// and Courses no longer need a restart for their endpoints. Real ASP.NET Core routing on a TestServer, -/// real emitted modules. -/// -public sealed class ModuleEndpointsSwapLiveTest : IDisposable -{ - private const string Module = "MeshWeaver.Test.LiveEndpoints"; - private static readonly TimeSpan Budget = TestTimeouts.Convergence; - private readonly string root = Path.Combine(Path.GetTempPath(), "live-endpoints-" + Guid.NewGuid().ToString("N")); - - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch (IOException) { } - catch (UnauthorizedAccessException) { } - } - - [Fact] - public async Task AModulesEndpoints_AreRemappedFromTheNewGeneration_OnALiveSwap() - { - var unloads = new CollectibleContextUnloads(); - using var contexts = new ModuleContexts().Attach(unloads, null); - var first = contexts.Load(Write("g1", ModuleSource(1))); - contexts.Commit(first); - await using var app = await Start(contexts); - var client = app.GetTestClient(); - - (await client.GetStringAsync("/live-endpoint")).Should().Be("v1"); - - var second = contexts.Load(Write("g2", ModuleSource(2))); - var replaced = contexts.Commit(second); - - (await client.GetStringAsync("/live-endpoint")).Should().Be("v2", - "the route must answer from the new generation, in the running process — no restart"); - - (await contexts.Retire(replaced ?? throw new InvalidOperationException("committing g2 must hand back the replaced g1"), Budget).Timeout(Budget).Await()).Should().BeTrue(); - replaced = null; - first = null; - var drained = await CollectibleUnloadDrain.WaitUntilCollectedAsync(unloads); - drained.Collected.Should().BeTrue($"no routing structure may keep the old generation alive ({drained})"); - } - - /// A held module that maps NO endpoints at boot and ADDS them in a later generation (#6128 - /// review): the dynamic source must exist anyway, or the new routes are silently absent while the - /// swap reports Live. - [Fact] - public async Task AGenerationThatAddsEndpoints_IsMappedLive_EvenWhenBootMappedNone() - { - using var contexts = new ModuleContexts(); - contexts.Commit(contexts.Load(Write("g0", NoEndpointsSource))); - await using var app = await Start(contexts); - var client = app.GetTestClient(); - - (await client.GetAsync("/live-endpoint")).StatusCode.Should().Be(HttpStatusCode.NotFound, - "the boot generation maps nothing"); - - contexts.Commit(contexts.Load(Write("g1", ModuleSource(1)))); - - (await client.GetStringAsync("/live-endpoint")).Should().Be("v1", - "the generation that adds the attribute must be mapped in the running process"); - } - - private const string NoEndpointsSource = """ - namespace MeshWeaver.Test.LiveEndpoints; - public sealed class Placeholder { } - """; - - /// The NEGATIVE CONTROL for the refusal: a new generation whose route collides with one the - /// host already serves is NOT published — the previous endpoints keep serving. - [Fact] - public async Task ASwapWhoseRouteCollidesWithTheHost_IsNotPublished_AndThePreviousRouteKeepsServing() - { - using var contexts = new ModuleContexts(); - contexts.Commit(contexts.Load(Write("g1", ModuleSource(1)))); - await using var app = await Start(contexts); - var client = app.GetTestClient(); - - contexts.Commit(contexts.Load(Write("g2", ModuleSource(2, collide: true)))); - - (await client.GetStringAsync("/live-endpoint")).Should().Be("v1", - "a colliding re-map must not be published — never two registrations on one (verb, pattern)"); - (await client.GetStringAsync("/host-route")).Should().Be("host"); - } - - /// #6128 review: the refusal is scoped PER MODULE. Module B's new generation collides with - /// the host and is refused; module A's later swap must STILL go live. With the old all-or-nothing - /// re-map, B's colliding current generation refused every later map, so A's swap stayed unpublished. - [Fact] - public async Task OneModulesRefusedSwap_DoesNotFreezeAnotherModulesEndpoints() - { - using var contexts = new ModuleContexts(); - contexts.Commit(contexts.Load(Write("g1", ModuleSource(1)))); - contexts.Commit(contexts.Load(Write("g1", ModuleSource(1, route: "/other-endpoint", module: OtherModule), OtherModule))); - await using var app = await Start(contexts); - var client = app.GetTestClient(); - (await client.GetStringAsync("/live-endpoint")).Should().Be("v1"); - (await client.GetStringAsync("/other-endpoint")).Should().Be("v1"); - - contexts.Commit(contexts.Load(Write("g2", ModuleSource(2, collide: true, module: OtherModule), OtherModule))); - (await client.GetStringAsync("/other-endpoint")).Should().Be("v1", - "the colliding module's swap is refused and its previous route keeps serving"); - - contexts.Commit(contexts.Load(Write("g2", ModuleSource(2)))); - (await client.GetStringAsync("/live-endpoint")).Should().Be("v2", - "another module's refused generation must not freeze this module's live swap"); - (await client.GetStringAsync("/other-endpoint")).Should().Be("v1", - "the refused module still serves its previous route"); - (await client.GetStringAsync("/host-route")).Should().Be("host"); - } - - private const string OtherModule = "MeshWeaver.Test.LiveEndpointsOther"; - - /// #6128 review: two modules that EXCHANGE routes across swap waves. A's new map takes B's - /// route, which B still serves, so A is refused. B's new map then takes A's old route. Measured - /// one at a time against each other's stale served map, both would be refused on every later swap, - /// for good. Decided together, the combined set has no collision, so both go live. - [Fact] - public async Task TwoModulesExchangingRoutes_AreBothPublished_OnceTheExchangeCompletes() - { - using var contexts = new ModuleContexts(); - contexts.Commit(contexts.Load(Write("g1", ModuleSource(1, route: "/route-a")))); - contexts.Commit(contexts.Load(Write("g1", ModuleSource(1, route: "/route-b", module: OtherModule), OtherModule))); - await using var app = await Start(contexts); - var client = app.GetTestClient(); - - contexts.Commit(contexts.Load(Write("g2", ModuleSource(2, route: "/route-b")))); - (await client.GetStringAsync("/route-b")).Should().Be("v1", - "half-way through the exchange A's new route still collides with B's served one, so A is refused"); - - contexts.Commit(contexts.Load(Write("g2", ModuleSource(2, route: "/route-a", module: OtherModule), OtherModule))); - (await client.GetStringAsync("/route-a")).Should().Be("v2", - "once the exchange completes, B now serves A's old route"); - (await client.GetStringAsync("/route-b")).Should().Be("v2", - "and A serves B's old route — the pending maps are decided together, so neither is refused for good"); - } - - /// #6128 review: the swap subscription is bound to the HOST, not the module registry. The - /// registry can outlive a host, so once the host stops, a later swap must not re-map this source - /// (which would fire change tokens no matcher reads, and root the source through the registry's - /// subject). The control: while the host runs, the same swap does re-map. - [Fact] - public async Task AStoppedHostsSource_NoLongerFollowsSwaps_WhileARunningOneDoes() - { - using var contexts = new ModuleContexts(); - contexts.Commit(contexts.Load(Write("g1", ModuleSource(1)))); - await using var app = await Start(contexts); - var source = ((IEndpointRouteBuilder)app).DataSources.OfType().Single(); - - var boot = source.Endpoints; - contexts.Commit(contexts.Load(Write("g2", ModuleSource(2)))); - var running = source.Endpoints; - running.Should().NotBeSameAs(boot, "the control: a running host's source re-maps on a swap"); - - await app.StopAsync(); - contexts.Commit(contexts.Load(Write("g3", ModuleSource(3)))); - source.Endpoints.Should().BeSameAs(running, - "a stopped host's source must have ended its subscription to the registry's swaps"); - } - - /// #6128 review: a live-swap commit landing between the source's boot snapshot and its - /// subscription to the registry's (non-replaying) swap signal must still be published — never the - /// pre-commit endpoints served silently until some later swap. - [Fact] - public async Task ACommitBetweenTheSnapshotAndTheSubscription_IsStillPublished() - { - using var contexts = new ModuleContexts(); - contexts.Commit(contexts.Load(Write("g1", ModuleSource(1)))); - // A host that does NOT map the module endpoints itself, so the source under test is the only one - // serving the module's routes (two sources on one host would collide with each other). - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(contexts); - builder.Services.AddRouting(); - await using var app = builder.Build(); - - using var source = new ModuleEndpointDataSource( - app.Services, contexts, ((IEndpointRouteBuilder)app).CreateApplicationBuilder, logger: null, - afterSnapshot: () => contexts.Commit(contexts.Load(Write("g2", ModuleSource(2))))); - - var endpoint = source.Endpoints.OfType() - .Single(e => e.RoutePattern.RawText == "/live-endpoint"); - (await Answer(endpoint, app.Services)).Should().Be("v2", - "the generation committed between the snapshot and the subscription must be the one served"); - } - - private static async Task Answer(RouteEndpoint endpoint, IServiceProvider services) - { - var context = new Microsoft.AspNetCore.Http.DefaultHttpContext { RequestServices = services }; - context.Request.Method = "GET"; - using var body = new MemoryStream(); - context.Response.Body = body; - await (endpoint.RequestDelegate ?? throw new InvalidOperationException("the route has no request delegate"))(context); - return System.Text.Encoding.UTF8.GetString(body.ToArray()); - } - - /// #6128 review: the dynamic source publishes its BOOT map without its own collision - /// check, so the boot routes of a held module must be caught by the host's startup refusal, which - /// reads the composite endpoint table the dynamic source is part of. The control: the same host - /// with a non-colliding held module keeps running. - [Fact] - public async Task AHeldModuleWhoseBootRouteCollidesWithTheHost_IsRefusedAtStartup() - { - using var colliding = new ModuleContexts(); - colliding.Commit(colliding.Load(Write("g1", ModuleSource(1, collide: true)))); - await using (var refused = await Start(colliding)) - refused.Lifetime.ApplicationStopping.IsCancellationRequested.Should().BeTrue( - "a held module's boot route on the host's (verb, pattern) must take the app down at startup, " - + "never be resolved by registration order at request time"); - - using var clean = new ModuleContexts(); - clean.Commit(clean.Load(Write("g2", ModuleSource(1)))); - await using var served = await Start(clean); - served.Lifetime.ApplicationStopping.IsCancellationRequested.Should().BeFalse( - "the control: a held module whose routes collide with nothing starts and serves"); - } - - private static async Task Start(ModuleContexts contexts) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(contexts); - builder.Services.AddAuthorization(); - builder.Services.AddRouting(); - var app = builder.Build(); - app.UseRouting(); - app.UseAuthorization(); - app.MapGet("/host-route", () => "host").AllowAnonymous(); - app.MapMeshModuleEndpoints(); - await app.StartAsync(); - return app; - } - - private static string ModuleSource(int version, bool collide = false, string route = "/live-endpoint", string module = Module) => $$""" - using Microsoft.AspNetCore.Builder; - using Microsoft.AspNetCore.Routing; - [assembly: {{module}}.Endpoints] - namespace {{module}}; - public sealed class EndpointsAttribute : MeshWeaver.Hosting.AspNetCore.MeshEndpointProviderAttribute - { - public override System.Collections.Generic.IEnumerable> EndpointConfigurations => - [ - routes => routes.MapGet("{{(collide ? "/host-route" : route)}}", () => "v{{version}}").AllowAnonymous(), - ]; - } - """; - - private string Write(string generation, string source, string module = Module) - { - var references = PlatformReferences.Platform() - .Add(MetadataReference.CreateFromFile(typeof(MeshEndpointProviderAttribute).Assembly.Location)); - var compilation = CSharpCompilation.Create(module, [CSharpSyntaxTree.ParseText(source)], references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - using var buffer = new MemoryStream(); - var result = compilation.Emit(buffer); - result.Success.Should().BeTrue(string.Join(Environment.NewLine, - result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); - var directory = Path.Combine(root, "modules", $"{module}@{generation}"); - Directory.CreateDirectory(directory); - var path = Path.Combine(directory, module + ".dll"); - File.WriteAllBytes(path, buffer.ToArray()); - return path; - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleFloorAdvisoryFunnelTest.cs b/test/Memex.Portal.Shared.Test/ModuleFloorAdvisoryFunnelTest.cs deleted file mode 100644 index a658cee636..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleFloorAdvisoryFunnelTest.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System; -using System.IO; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using PackagingManifest = MeshWeaver.Plugin.Packaging.PluginManifest; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The consumer's land half — PluginBundleClient.LandFromBundle — no longer DECLINES -/// a bundle whose declared floor exceeds this platform (#3648). -/// -/// This was the second of the three places the floor refused on the module lane, and the -/// one whose log line named the 2026-09-07 outage: "Module bundle for {Plugin} DECLINED: the -/// module requires platform 3.0.0-rc8 or newer but this deployment runs 3.0.0-ci.8055 — nothing -/// landed". The archive's manifest still carries the floor; the client now LOGS it (Information, -/// naming both versions) and hands the bytes to the landing, whose link probe decides. The -/// name-drift refusal — a bundle declaring a different module than the package — is untouched. -/// -/// Driven through a REAL monolith mesh because the client resolves its landing service, I/O -/// pool and logger off the hub, and the pin is about what reaches the disk: the Plugins-repo -/// counterpart (ModuleFunnelTest.ABundleWhoseFloorExceedsThisPlatform_IsRefused_NothingReachesDisk) -/// asserted the opposite and flips with the platform pin. -/// -public class ModuleFloorAdvisoryFunnelTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Module = "MeshWeaver.Social"; - private const string Plugin = "SocialMedia"; - - private readonly string landingRoot = - Path.Combine(Path.GetTempPath(), "mw-flooradvisory-funnel-" + Guid.NewGuid().ToString("N")); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - Directory.CreateDirectory(landingRoot); - return ConfigureMeshBase(builder) - .AddPluginCatalog() - // Land into a per-test temp tree, never the test host's own bin folder — the sidecar - // is a persistent file, and writing it beside the testhost would bleed across tests. - .ConfigureServices(services => - services.AddSingleton(new ModuleLandingService(baseDirectory: landingRoot))); - } - - /// - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - try - { - if (Directory.Exists(landingRoot)) - Directory.Delete(landingRoot, recursive: true); - } - catch - { - // Best-effort: a cleanup hiccup must never turn a green test red. - } - } - - /// - /// A bundle whose manifest declares a floor no build of this line satisfies LANDS: one file on - /// disk, the entry recorded with the floor (for the index and the status row to SAY), the - /// restart raised. Before #3648 this returned 0 and wrote nothing. - /// - [Fact(Timeout = 120_000)] - public async Task ABundleWhoseFloorExceedsThisPlatform_IsLanded_WhenItLinks() - { - // The precondition that makes this able to fail: the floor ranks above the platform this - // test process runs, so the old client would have declined it. - const string floor = "999.0.0"; - Assert.NotNull(ModulePlatformFloor.DeclineReason(floor)); - - var client = new PluginBundleClient(Mesh, "http://registry.invalid"); - - var landed = await client - .LandFromBundle(Plugin, Module, "Plugins/" + Plugin, "1.2.0", - ModuleBundle(minMeshVersion: floor), advertisedFrameworkMvid: "s-advertised") - .FirstAsync().Await(TestContext.Current.CancellationToken); - - Assert.Equal(1, landed); - var list = ModuleActivationSidecar.Read(landingRoot); - var entry = Assert.Single(list.Entries); - Assert.Equal(Module, entry.Name); - Assert.Equal("1.2.0", entry.Version); - Assert.Equal(floor, entry.MinMeshVersion); - Assert.Equal("s-advertised", entry.FrameworkMvid); - Assert.True(entry.Enabled); - Assert.True(list.PendingRestart, "restart-as-activation — nothing loads into the running process"); - Assert.True(File.Exists(Path.Combine( - ModuleLandingService.ModuleDirectoryFor(landingRoot, Module, entry), Module + ".dll"))); - } - - /// The refusal that stays: a bundle declaring a module other than the one the - /// package declares lands nothing — the floor change removed one gate, not this one. - [Fact(Timeout = 120_000)] - public async Task ABundleDeclaringAnotherModule_IsStillRefused() - { - var client = new PluginBundleClient(Mesh, "http://registry.invalid"); - - var landed = await client - .LandFromBundle(Plugin, "MeshWeaver.SomethingElse", "Plugins/" + Plugin, "1.2.0", - ModuleBundle(minMeshVersion: null), advertisedFrameworkMvid: "s-advertised") - .FirstAsync().Await(TestContext.Current.CancellationToken); - - Assert.Equal(0, landed); - Assert.Empty(ModuleActivationSidecar.Read(landingRoot).Entries); - } - - /// A packed module bundle the client can read, declaring the given floor, carrying - /// REAL assembly bytes — the landing measures them (#3538), so a byte stand-in would be refused - /// as unreadable and the test would be about that gate instead of the floor. - private static byte[] ModuleBundle(string? minMeshVersion) - { - var manifestJson = JsonSerializer.Serialize(new - { - plugin = Plugin, - version = "1.2.0", - frameworkMvid = "s-requested-lane", - module = new - { - assemblyName = Module, - assemblies = new[] { Module + ".dll" }, - minMeshVersion, - }, - }); - - var buffer = new MemoryStream(); - NuGetPackageWriter.Write( - buffer, - new PackagingManifest(Plugin, "MeshWeaver.Plugin." + Plugin, "1.2.0", Plugin, null, []), - "3.0.0", - [ - new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleEntryPathFor(Module + ".dll"), - () => new MemoryStream(File.ReadAllBytes(typeof(BundleReader).Assembly.Location))), - ], - manifestJson); - return buffer.ToArray(); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleFloorAdvisoryTest.cs b/test/Memex.Portal.Shared.Test/ModuleFloorAdvisoryTest.cs deleted file mode 100644 index 416d64a96a..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleFloorAdvisoryTest.cs +++ /dev/null @@ -1,464 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The 2026-09-07 floor pair never refuses, holds or skips (#3648). -/// -/// Re-scoped by policy package-min-mesh-version. Until then every declared floor -/// was advisory at runtime. A floor that is COMPARABLE with the running platform and strictly above -/// it now HOLDS an update (MinMeshVersionHoldsTheUpdateTest); what this fixture keeps pinning -/// is that the pair below — an rc floor against a ci build, UNORDERED — is advisory -/// under that decision too, at every decision point, so the trap that held every portal stays -/// closed. The reconcile tests pass the production floorHold for exactly that reason. -/// -/// The incident these pin — 2026-09-07. memex-cloud ran 3.0.0-ci.8055. Every -/// installed Plugins/* record carried an rc floor — 3.0.0-rc8 for most — and -/// the comparator ranks ci < rc < clean, so no ci build could ever satisfy -/// them. The self-updater declined all 11 candidate releases ("77 plugins required … every one -/// declined"), the boot union would have skipped every one of those modules had a candidate -/// rolled, and the reconcile answered SkipPlatformBelowFloor for every served bundle. Every -/// production portal sat on its morning build for the whole day, and every module concerned would -/// have LOADED — the measured link probe (#3552) had nothing against any of them. -/// -/// What makes these able to FAIL. The pair is the production pair — floor -/// against running — and the first test asserts that the -/// comparator still ranks them the old way, so a comparator "fix" that made the pair satisfied -/// would red that test rather than let the others pass vacuously. Every other test then asks the -/// exact decision point that used to refuse, hold or skip on that pair, and expects it to proceed -/// — with the floor WORDED into its reason, log or status row, since an advisory that is not -/// surfaced is indistinguishable from a floor that was never read. -/// -/// The landing-service tests run against the REAL running version (the comparison there is -/// bound to ), which CI stamps as a clean -/// 3.0.0 — so they use a floor no build of this line satisfies, and assert that -/// precondition first for the same reason. -/// -public class ModuleFloorAdvisoryTest : IDisposable -{ - /// The floor every rc-line module declared on 2026-09-07. - private const string Floor = "3.0.0-rc8"; - - /// The build memex-cloud ran that day — the one no rc floor could be satisfied by. - private const string Running = "3.0.0-ci.8055"; - - private const string Module = "MeshWeaver.Graph.Views"; - private const string PackagePath = "Plugins/DefaultViews"; - - /// The production wording of the floor, bound to the deadlock's running version. - private static string? Gate(string? floor) => ModulePlatformFloor.DeclineReason(floor, Running); - - /// The production floor DECISION bound to the deadlock's running version (policy - /// package-min-mesh-version): PlatformFloor, under which the 2026-09-07 pair is - /// unordered — advisory — and therefore never holds. - private static string? Hold(string? floor) => MeshWeaver.Plugin.Packaging.PlatformFloor.HoldReason(floor, Running); - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-flooradvisory-" + Guid.NewGuid().ToString("N")); - - private readonly ModuleLandingService landing; - - /// Creates the per-test deployment root and the REAL landing service over it. - public ModuleFloorAdvisoryTest() - { - Directory.CreateDirectory(root); - landing = new ModuleLandingService(baseDirectory: root); - } - - /// - public void Dispose() - { - landing.Dispose(); - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - GC.SuppressFinalize(this); - } - - // ───────────────────────────────────────────── the pair, and why it is the pair - - /// - /// 🚨 The precondition every other test rests on: the comparator still says 3.0.0-rc8 - /// is NOT satisfied by 3.0.0-ci.8055. That is the pack-time lint's rule (pinned against - /// the script by ModulePlatformFloorScriptParityTest) and it is unchanged — what #3648 - /// changes is that no runtime decision reads the answer as a gate. If this ever flips, the - /// tests below stop testing anything and must be re-pointed at a pair that does not satisfy. - /// - [Fact] - public void TheDeadlockPair_IsStillAnUnsatisfiedFloor_ByTheComparator() - { - var advisory = Gate(Floor); - - Assert.NotNull(advisory); - Assert.Contains(Floor, advisory); - Assert.Contains(Running, advisory); - Assert.Contains("advisory", advisory); - } - - // ───────────────────────────────────────────── decision point 1: the reconcile - - /// - /// ModuleUpdateDecision.Decide used to answer SkipPlatformBelowFloor here — step 2 - /// of the decision — and that is the verdict the reconcile logged for every module on - /// 2026-09-07. A served bundle whose floor exceeds the platform now proceeds to LAND, with the - /// floor worded into the reason, and the landing's link probe decides. - /// - [Fact] - public void TheReconcile_LandsABundleWhoseFloorExceedsThePlatform() - { - // 🚨 With the production floor DECISION passed (policy package-min-mesh-version re-armed the - // floor): the 2026-09-07 pair is unordered, so it still LANDS — the trap stays closed. - var neverLanded = ModuleUpdateDecision.Decide( - "1.2.0", Floor, Gate, landed: null, policyDecline: null, _ => true, "s-new", floorHold: Hold); - var upgrade = ModuleUpdateDecision.Decide( - "1.2.0", Floor, Gate, Landed("1.1.0", "s-old"), policyDecline: null, _ => true, "s-new", floorHold: Hold); - - Assert.Equal(ModuleUpdateAction.Land, neverLanded.Action); - Assert.Equal(ModuleUpdateAction.Land, upgrade.Action); - Assert.NotEqual(ModuleUpdateAction.SkipPlatformBelowFloor, neverLanded.Action); - // The claim rides the reason: an advisory nobody can read is a gate nobody removed. - Assert.Contains(Floor, neverLanded.Reason); - Assert.Contains(Running, neverLanded.Reason); - Assert.Contains(Floor, upgrade.Reason); - } - - /// The floor does not override the OTHER skips either — an up-to-date module stays - /// up to date, an uninstalled one stays uninstalled; the floor merely stops being a step. - [Fact] - public void TheReconcile_KeepsEveryOtherVerdict_WhateverTheFloorSays() - { - Assert.Equal(ModuleUpdateAction.SkipUpToDate, ModuleUpdateDecision.Decide( - "1.1.0", Floor, Gate, Landed("1.1.0", "s-old"), null, _ => true, "s-old", floorHold: Hold).Action); - Assert.Equal(ModuleUpdateAction.SkipUninstalled, ModuleUpdateDecision.Decide( - "1.2.0", Floor, Gate, Landed("1.1.0", "s-old", enabled: false), null, _ => true, "s-new", floorHold: Hold).Action); - Assert.Equal(ModuleUpdateAction.SkipOlder, ModuleUpdateDecision.Decide( - "1.0.0", Floor, Gate, Landed("1.1.0", "s-old"), null, _ => true, "s-old", floorHold: Hold).Action); - Assert.Equal(ModuleUpdateAction.SkipNoBundle, ModuleUpdateDecision.Decide( - null, Floor, Gate, Landed("1.1.0", "s-old"), null, _ => true, null, floorHold: Hold).Action); - } - - // ───────────────────────────────────────────── decision point 2: the boot union - - /// - /// ModuleActivationBoot.ComputeEffectiveModuleEntries used to SKIP an entry whose - /// recorded floor the running platform did not satisfy — "the platform rolled back below the - /// module's requirement" was the story, and on 2026-09-07 it would have skipped every store - /// module on every ci-built portal. The entry is handed to the loader now; the claim goes out - /// on the advisory channel, not the skip channel, and the link probe in - /// MeshBuilder.InstallAssemblies decides on the bytes. - /// - [Fact] - public void TheBootUnion_HandsAFlooredEntryToTheLoader_AndAnnouncesTheClaim() - { - var skips = new List<(string Module, string Reason)>(); - var advisories = new List<(string Module, string Reason)>(); - - var effective = ModuleActivationBoot.ComputeEffectiveModuleEntries( - baselineEntries: [], - new ModuleActivationList { Entries = [Entry(Floor)] }, - Gate, - _ => true, - (m, r) => skips.Add((m, r)), - (m, r) => advisories.Add((m, r))); - - var module = Assert.Single(effective); - Assert.Equal(Module + ".dll", module.Entry); - Assert.Same(Floor, module.Landed?.MinMeshVersion); - Assert.Empty(skips); - var advisory = Assert.Single(advisories); - Assert.Equal(Module, advisory.Module); - Assert.Contains(Floor, advisory.Reason); - Assert.Contains(Running, advisory.Reason); - } - - /// The one skip that stays: a landed DLL that is gone. The floor is not consulted for - /// a skipped entry — its line already names it, and an advisory about bytes that are not there - /// would be noise. - [Fact] - public void TheBootUnion_StillSkipsAMissingDll_AndSaysNothingAboutItsFloor() - { - var skips = new List<(string Module, string Reason)>(); - var advisories = new List<(string Module, string Reason)>(); - - var effective = ModuleActivationBoot.ComputeEffectiveModuleEntries( - [], new ModuleActivationList { Entries = [Entry(Floor)] }, Gate, _ => false, - (m, r) => skips.Add((m, r)), (m, r) => advisories.Add((m, r))); - - Assert.Empty(effective); - Assert.Contains("does not exist", Assert.Single(skips).Reason); - Assert.Empty(advisories); - } - - // ───────────────────────────────────────────── decision point 3: the release gate - - /// - /// ReleaseAvailability.IsUpdatable used to answer ModuleFloorExceedsTarget — a - /// BLOCKER — for every package whose floor the target did not satisfy, and that is the exact - /// verdict behind "77 plugins required … every one declined". The floor is an ADVISORY beside - /// the verdict now: IsUpdatable is true, Blockers is empty, and the sentence is - /// on Advisories so the log can still say what the modules claim. - /// - [Fact] - public void TheReleaseGate_ReportsAFlooredModuleAsAnAdvisory_NeverAsABlocker() - { - var target = new ReleaseTarget(Running, "s8055"); - var verdict = ReleaseAvailability.IsUpdatable( - target, - [ - new RequiredPackage("DefaultViews", "DefaultViews", Floor, HasContent: true), - new RequiredPackage("Speech", "Speech", Floor, HasContent: false), - new RequiredPackage("Store", "Store", null, HasContent: true), - ], - ReleaseArtifacts.Of(["DefaultViews.zip", "Store.zip"])); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Null(verdict.HoldReason); - Assert.Empty(verdict.Blockers); - Assert.DoesNotContain(verdict.Packages, p => p.Kind == PackageAvailabilityKind.ModuleFloorExceedsTarget); - Assert.All(verdict.Packages, p => Assert.Equal(PackageAvailabilityKind.Available, p.Kind)); - - Assert.Equal(2, verdict.Advisories.Length); - Assert.All(verdict.Advisories, a => - { - Assert.Contains(Floor, a); - Assert.Contains(Running, a); - }); - Assert.Contains(verdict.Advisories, a => a.StartsWith("DefaultViews:", StringComparison.Ordinal)); - Assert.Contains(verdict.Advisories, a => a.StartsWith("Speech:", StringComparison.Ordinal)); - } - - /// - /// A missing bake is a COST beside the floor advisory since #3651 — the two answer different - /// questions and neither hides the other, and neither holds. Under - /// Modules:RequirePrebuilt the bake is still the hold it was, and the floor still rides - /// beside it as an advisory. - /// - [Fact] - public void TheReleaseGate_ReportsAMissingBakeAsACost_WithTheFloorAdvisoryBesideIt() - { - var verdict = ReleaseAvailability.IsUpdatable( - new ReleaseTarget(Running, "s8055"), - [new RequiredPackage("DefaultViews", "DefaultViews", Floor, HasContent: true)], - ReleaseArtifacts.Of([])); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Empty(verdict.Blockers); - Assert.Equal(["DefaultViews"], verdict.BootCompiles); - Assert.Equal(2, verdict.Advisories.Length); - Assert.Contains(verdict.Advisories, a => a.Contains("would recompile at boot", StringComparison.Ordinal)); - Assert.Contains(verdict.Advisories, a => a.Contains(Floor, StringComparison.Ordinal)); - - var strict = ReleaseAvailability.IsUpdatable( - new ReleaseTarget(Running, "s8055"), - [new RequiredPackage("DefaultViews", "DefaultViews", Floor, HasContent: true)], - ReleaseArtifacts.Of([]), - new ReleaseGatePolicy(RequirePrebuilt: true)); - - Assert.False(strict.IsUpdatable); - Assert.Equal(PackageAvailabilityKind.ContentBakeMissing, Assert.Single(strict.Blockers).Kind); - Assert.Contains(Floor, Assert.Single(strict.Advisories)); - } - - // ───────────────────────────────────────────── decision point 4: the required-module probe - - /// - /// RequiredModuleStatus.Classify used to report a landed entry whose floor exceeded the - /// platform as "HELD above this platform … a platform update satisfies the floor and that - /// boot loads it". Boot loads it now, so the honest sentence is the ordinary one — landed, - /// awaiting a restart — with the claim appended, never a hold. - /// - [Fact] - public void TheRequiredModuleProbe_ReportsAFlooredLandedModuleAsAwaitingRestart_NotHeld() - { - var verdicts = RequiredModuleStatus.Classify( - requiredEntries: [Module + ".dll"], - baselineEntries: [], - loadedAssemblyNames: new HashSet(StringComparer.OrdinalIgnoreCase), - resolvesFromDeployment: _ => false, - activation: new ModuleActivationList { Entries = [Entry(Floor)] }, - landedDllExists: _ => true, - platformGate: Gate, - incompatibleModules: []); - - var expected = Assert.Single(RequiredModuleStatus.ExpectedLater(verdicts)); - Assert.Equal(Module, expected.Name); - Assert.Contains("a restart activates it", expected.Reason); - Assert.DoesNotContain("HELD", expected.Reason); - Assert.Contains(Floor, expected.Reason); - Assert.Contains(Running, expected.Reason); - Assert.Empty(RequiredModuleStatus.Absent(verdicts)); - Assert.Empty(RequiredModuleStatus.Incompatible(verdicts)); - } - - // ───────────────────────────────────────────── decision point 5: the activation report - - /// - /// ModuleActivationStatus.NotYetLoaded used to EXCLUDE an entry whose floor the gate - /// refused — "a restart cannot activate a held module" — which was true only because boot - /// skipped it. It is pending now, with the claim on the record. - /// - [Fact] - public void TheActivationReport_CountsAFlooredEntryAsPending_WithTheAdvisory() - { - var list = new ModuleActivationList { Entries = [Entry(Floor)] }; - var loaded = new HashSet(StringComparer.OrdinalIgnoreCase); - - var pending = Assert.Single(ModuleActivationStatus.NotYetLoaded(list, loaded, Gate, _ => true)); - Assert.Equal(Module, pending.Name); - Assert.Contains(Floor, pending.Advisory); - Assert.Contains(Running, pending.Advisory); - - // And a floored entry whose bytes are gone is UNRESOLVABLE, exactly like one without a - // floor — the floor moves nothing between the two buckets. - Assert.Empty(ModuleActivationStatus.NotYetLoaded(list, loaded, Gate, _ => false)); - Assert.Equal(Module, Assert.Single( - ModuleActivationStatus.Unresolvable(list, loaded, Gate, _ => false)).Name); - } - - /// An entry whose floor the platform satisfies carries no advisory — the record says - /// nothing when there is nothing to say. - [Fact] - public void ASatisfiedFloor_CarriesNoAdvisory() - { - var pending = Assert.Single(ModuleActivationStatus.NotYetLoaded( - new ModuleActivationList { Entries = [Entry("3.0.0-ci.100")] }, - new HashSet(StringComparer.OrdinalIgnoreCase), Gate, _ => true)); - - Assert.Null(pending.Advisory); - } - - // ───────────────────────────────────────────── decision points 6 and 7: the landing itself - - /// - /// ModuleLandingService.LandCore used to REFUSE — "Module 'X' refused: the module - /// requires platform … or newer but this deployment runs …" — before the link probe even ran. - /// The floor is recorded and the bytes are measured: a module that links LANDS whatever its - /// author wrote, restart raised, floor on the entry for the status row to say. - /// - [Fact] - public async Task TheLanding_LandsAModuleWhoseFloorExceedsThePlatform_AndRecordsTheClaim() - { - var floor = FloorAboveTheRunningPlatform(); - - await landing.LandModule( - Module, [(Module + ".dll", LinkableBytes)], - frameworkMvid: "s-built-against", packagePath: PackagePath, version: "1.2.0", - minMeshVersion: floor) - .Timeout(TestTimeouts.Convergence).Await(); - - var list = ModuleActivationSidecar.Read(root); - var entry = Assert.Single(list.Entries); - Assert.Equal(Module, entry.Name); - Assert.True(entry.Enabled); - Assert.Equal(floor, entry.MinMeshVersion); - Assert.Equal("1.2.0", entry.Version); - Assert.True(list.PendingRestart, "an unheld landing loads at the next restart"); - Assert.True(ModuleActivationBoot.LandedModuleDllExists(root, entry)); - } - - /// - /// The SHELF path (the registry's publish endpoint) used to HOLD the same landing — bytes on - /// the shelf, no restart, boot skipping the entry until a platform update. Bytes that link are - /// not held by a string any more; only what the probe cannot load is - /// (). - /// - [Fact] - public async Task TheShelf_LandsAModuleWhoseFloorExceedsThePlatform_Unheld() - { - var floor = FloorAboveTheRunningPlatform(); - - var outcome = await landing.ShelveModule( - Module, [(Module + ".dll", LinkableBytes)], - frameworkMvid: "s-built-against", packagePath: PackagePath, version: "1.2.0", - minMeshVersion: floor) - .Timeout(TestTimeouts.Convergence).Await(); - - Assert.False(outcome.Held, outcome.HoldReason); - Assert.Null(outcome.HoldReason); - Assert.True(ModuleActivationSidecar.Read(root).PendingRestart); - } - - /// - /// The whole report, read off the disk the landing wrote: the floored module is PENDING (a - /// restart activates it), and it is listed as an advisory with both versions — for the health - /// payload and for the package card, which looks it up by install-record path. - /// - [Fact] - public async Task TheActivationReportOnDisk_ListsTheFlooredModuleAsPendingAndAsAnAdvisory() - { - var floor = FloorAboveTheRunningPlatform(); - await landing.LandModule( - Module, [(Module + ".dll", LinkableBytes)], - packagePath: PackagePath, version: "1.2.0", minMeshVersion: floor) - .Timeout(TestTimeouts.Convergence).Await(); - - var report = new PendingModuleActivations(root).Read( - loadedAssemblyNames: new HashSet(StringComparer.OrdinalIgnoreCase), - loadedModuleGenerations: ImmutableDictionary.Empty); - - Assert.False(report.IsUndetermined, report.UndeterminedReason); - Assert.True(report.HasPending, "boot loads the entry, so a restart genuinely activates it"); - Assert.Equal(Module, Assert.Single(report.Pending).Name); - Assert.True(report.IsPendingForPackage(PackagePath)); - Assert.False(report.HasQuarantined, "a floor is not a refusal — only the link probe quarantines"); - Assert.Empty(report.Unresolvable); - - Assert.True(report.HasFloorAdvisories); - var advisory = Assert.Single(report.FloorAdvisories); - Assert.Equal(Module, advisory.Name); - Assert.Equal(floor, advisory.DeclaredFloor); - Assert.Equal(ModulePlatformFloor.RunningVersion, advisory.RunningVersion); - Assert.Same(advisory, report.FloorAdvisoryForPackage(PackagePath)); - Assert.Null(report.FloorAdvisoryForPackage(null)); - Assert.Contains("advisory only", report.Describe()); - Assert.Contains(floor, report.Describe()); - } - - // ───────────────────────────────────────────── harness - - private static ModuleActivationEntry Entry(string? floor) => new() - { - Name = Module, - PackagePath = PackagePath, - Version = "1.2.0", - MinMeshVersion = floor, - FrameworkMvid = "s-built-against", - Enabled = true, - Directory = Module + "@advisory1", - }; - - private static ModuleActivationEntry Landed(string version, string frameworkMvid, bool enabled = true) => - Entry(Floor) with { Version = version, FrameworkMvid = frameworkMvid, Enabled = enabled }; - - /// - /// A floor the REAL running platform does not satisfy — asserted, not assumed: the landing - /// tests compare against , which CI stamps as - /// a clean 3.0.0 (above every rc), so the production pair cannot serve there. A - /// floor nothing on this line reaches does, and the assertion is what keeps these tests from - /// passing because the floor happened to be satisfied. - /// - private static string FloorAboveTheRunningPlatform() - { - const string floor = "999.0.0"; - Assert.NotNull(ModulePlatformFloor.RunningVersion); - Assert.NotNull(ModulePlatformFloor.DeclineReason(floor)); - return floor; - } - - /// - /// Real, loadable managed-assembly bytes — the landing MEASURES the module's link requirements - /// against this platform (#3538), so a byte stand-in would be refused as unreadable and these - /// tests would be about that gate instead of the floor. The packaging assembly links against - /// nothing this process lacks; the same choice ModuleSetConvergenceTest makes. - /// - private static byte[] LinkableBytes => - File.ReadAllBytes(typeof(MeshWeaver.Plugin.Packaging.BundleReader).Assembly.Location); -} diff --git a/test/Memex.Portal.Shared.Test/ModuleGcOffReadinessPathTest.cs b/test/Memex.Portal.Shared.Test/ModuleGcOffReadinessPathTest.cs deleted file mode 100644 index 93a7e38e9c..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleGcOffReadinessPathTest.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Reactive.Linq; -using Memex.Portal.Shared; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Hosting.Internal; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// #2684, pinned: the portal boot path must NEVER run the modules/ generations GC before -/// the host listens. On an Azure Files (CIFS) /data the rename-then-recursive-delete of -/// orphaned generations is one SMB round-trip per file — minutes of uninterruptible IO — and it -/// reclaims nothing the portal needs in order to SERVE. When it ran synchronously in -/// ConfigureMemexMesh, rollout time became a function of how much garbage the previous -/// generation left on a network volume: memex-cloud's roll to ci.6559 sat as PID 1 in Dsl -/// deleting a .trash-* generation, never bound :8080, blew the 300 s startup probe (whose -/// kill cannot land on a process parked in uninterruptible IO), and looped. -/// -/// This test drives the REAL boot path (ConfigureMemexMesh) over a module root that -/// contains a genuine orphan generation and asserts the three phases in order: configuring the -/// mesh does not collect it, starting the hosted services (everything that happens before the -/// listener is up) does not collect it, and only ApplicationStarted — the signal that fires -/// strictly after the host listens — triggers the pass that does. Reclaim is deferred, never -/// dropped: the orphan IS collected afterwards. -/// -public class ModuleGcOffReadinessPathTest -{ - [Fact] - public async Task TheBootPathDefersGenerationsGc_UntilTheHostListens_ThenStillReclaims() - { - var root = Directory.CreateTempSubdirectory("mw-2684-").FullName; - try - { - // A genuine orphan: a generation directory no activation entry references, backdated - // past the #2303 grace window so only the WHEN of the pass — not the grace period — - // decides its fate. - var modules = Path.Combine(root, "modules"); - var orphan = Path.Combine(modules, "MeshWeaver.Widget@dead0001"); - Directory.CreateDirectory(orphan); - File.WriteAllText(Path.Combine(orphan, "MeshWeaver.Widget.dll"), "ORPHAN"); - Directory.SetLastWriteTimeUtc(orphan, - DateTime.UtcNow - ModuleLandingService.DefaultGarbageMinAge - TimeSpan.FromMinutes(1)); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Graph:Storage:Type"] = "FileSystem", - ["Graph:Storage:BasePath"] = root, - ["Modules:Root"] = root, - }) - .Build(); - var services = new ServiceCollection(); - services.AddLogging(); - // The REAL host-lifetime implementation (the generic host's own), so ApplicationStarted - // has production semantics: registered callbacks run when NotifyStarted fires — which - // the host does strictly AFTER every hosted service (the listener included) started. - var lifetime = new ApplicationLifetime(NullLogger.Instance); - services.AddSingleton(lifetime); - - new MeshBuilder(configure => configure(services), new Address("mesh", "test")) - .ConfigureMemexMesh(configuration); - - // Phase 1 — the boot path itself. This is where the synchronous CollectGarbage call - // used to live; the orphan surviving it is the fix. - Assert.True(Directory.Exists(orphan), - "ConfigureMemexMesh must not collect garbage synchronously — that pass on a CIFS " - + "/data is what blew the 300 s startup probe (#2684)"); - - await using var provider = services.BuildServiceProvider(); - var gc = provider.GetRequiredService(); - - // Phase 2 — hosted-service StartAsync: everything that runs BEFORE the host listens. - await gc.StartAsync(CancellationToken.None); - Assert.True(Directory.Exists(orphan), - "StartAsync runs before the listener is bound, so it must only register the " - + "ApplicationStarted callback — never sweep"); - - // Phase 3 — the host is listening. Reclaim is deferred, not dropped. - lifetime.NotifyStarted(); - var removed = await gc.Completed - .Timeout(TimeSpan.FromSeconds(30)) - .FirstAsync() - .Await(TestContext.Current.CancellationToken); - Assert.Equal(1, removed); - Assert.False(Directory.Exists(orphan), - "the post-start pass must still reclaim the orphan — off the readiness path is " - + "not off the roster"); - Assert.Empty(Directory.EnumerateDirectories(modules, ".trash-*")); - - await gc.StopAsync(CancellationToken.None); - } - finally - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleGenerationDivergenceTest.cs b/test/Memex.Portal.Shared.Test/ModuleGenerationDivergenceTest.cs deleted file mode 100644 index 210548fedd..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleGenerationDivergenceTest.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System.Reflection; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A replica running a SUPERSEDED module generation must be reported as pending (#3395). -/// -/// The observation. #3395 recorded two NodeType compiles on memex-cloud that -/// resolved DIFFERENT module sets — MeshWeaver.Payments.Stripe present in one and absent in -/// the other — and asked why ONE PROCESS would resolve two. It does not. Measured 2026-09-06: -/// three pods of one Deployment on ONE image (3.0.0-rc9.ci.7693) booted 11:33:39, 11:41:04 -/// and 12:51:21 around a module landing wave at 12:18–12:27, and -/// /tmp/meshweaver-pinned-modules/*/ held 39 of 40 generations different between the -/// first two pods and the third (MeshWeaver.Payments.Stripe@8f251f57 vs -/// …@458afe55, the shared sidecar naming …@458afe55). The two compiles ran on two of -/// those pods — Store/Order at 12:53:21 on the 12:51 pod, Store/Plugin at 13:09:01 on -/// the 11:41 pod — and stamped two different module fingerprints into the ONE shared compile -/// record. Two processes, two module sets, one record. -/// -/// The defect these pin. Nothing could SEE that. The per-process restart-as-activation -/// signal — the seam /health's PendingModuleActivationHealthCheck reads — compared -/// the activation record against the loaded assembly SIMPLE NAMES, so it answers the INSTALL case -/// (name absent) and is blind to the UPDATE case (name present, generation moved). Both stale pods -/// answered /health → Healthy while running a 90-minute-old module set. That is the -/// gate-that-cannot-fail shape: a promise ("a restart activates them") that never fires for the -/// change a deployment makes continuously. -/// -/// Pure: no filesystem, no host, no mesh — the derivation takes the activation list and what -/// the process loaded, and both are supplied here. -/// -public class ModuleGenerationDivergenceTest -{ - private const string Module = "MeshWeaver.Payments.Stripe"; - - /// The generation the deployment's sidecar activates — read off memex-cloud's - /// /data/modules/activation.d/MeshWeaver.Payments.Stripe.json on 2026-09-06. - private const string Activated = "MeshWeaver.Payments.Stripe@458afe55"; - - /// The generation the 11:33 and 11:41 pods had pinned and loaded at the same moment. - private const string Superseded = "MeshWeaver.Payments.Stripe@8f251f57"; - - private static readonly Func FloorSatisfied = _ => null; - - private static readonly Func BytesPresent = _ => true; - - private static readonly Func BytesGone = _ => false; - - private static ModuleActivationList Activation(string? directory = Activated) => new() - { - Entries = - [ - new ModuleActivationEntry - { - Name = Module, - PackagePath = "Plugins/Stripe", - Version = "1.0.0", - Directory = directory, - }, - ], - }; - - private static IReadOnlySet Loaded(params string[] names) => - names.ToHashSet(StringComparer.OrdinalIgnoreCase); - - private static IReadOnlyDictionary Generations( - params (string Name, string Directory)[] pairs) => - pairs.ToDictionary(p => p.Name, p => p.Directory, StringComparer.OrdinalIgnoreCase); - - /// - /// 🚨 THE assertion. The module's NAME is loaded, so the name-only rule called this pod current; - /// the generation it is running was superseded, so a restart genuinely changes what this process - /// resolves — which is precisely what "pending" promises. - /// - [Fact] - public void APodRunningASupersededGeneration_IsPending() - { - var pending = ModuleActivationStatus.NotYetLoaded( - Activation(), - Loaded(Module), - Generations((Module, Superseded)), - FloorSatisfied, BytesPresent); - - pending.Should().ContainSingle( - "the deployment activates " + Activated + " and this process is running " - + Superseded + " — a restart is what closes that gap") - .Subject.Name.Should().Be(Module); - } - - /// - /// The other half, so the rule cannot pass by always answering "pending": the pod that DID boot - /// after the landing is on the activated generation and has nothing to wait for. - /// - [Fact] - public void APodRunningTheActivatedGeneration_IsNotPending() - { - ModuleActivationStatus.NotYetLoaded( - Activation(), - Loaded(Module), - Generations((Module, Activated)), - FloorSatisfied, BytesPresent) - .Should().BeEmpty("this process loaded exactly the generation the sidecar activates"); - } - - /// - /// The two pods of one deployment, from one activation record, must give DIFFERENT answers — - /// the divergence #3395 measured has to be visible from inside a replica, because there is no - /// other place it can be seen. - /// - [Fact] - public void TwoReplicasOfOneDeployment_DisagreeAboutTheSameActivationRecord() - { - var record = Activation(); - - var behind = ModuleActivationStatus.NotYetLoaded( - record, Loaded(Module), Generations((Module, Superseded)), FloorSatisfied, BytesPresent); - var current = ModuleActivationStatus.NotYetLoaded( - record, Loaded(Module), Generations((Module, Activated)), FloorSatisfied, BytesPresent); - - behind.Should().NotBeEmpty(); - current.Should().BeEmpty(); - behind.Count.Should().NotBe(current.Count, - "one record read by two processes with different pinned generations is exactly the " - + "state that let two NodeType compiles stamp two module fingerprints into one node"); - } - - /// - /// 🚨 Absence of evidence is not a mismatch. A process that cannot say WHERE it loaded a module - /// from must not print a restart prompt: the promise would be unfalsifiable and nothing would - /// clear it. Under-reporting costs a signal; over-reporting costs the signal's credibility. - /// - [Fact] - public void AnUnknownLoadedGeneration_IsNotAMismatch() - { - ModuleActivationStatus.NotYetLoaded( - Activation(), - Loaded(Module), - Generations(), - FloorSatisfied, BytesPresent) - .Should().BeEmpty("nothing here establishes that a different generation is loaded"); - } - - /// An entry with no recorded generation is the legacy fixed modules/<name>/ - /// folder: it names nothing to compare against, so the name-only rule still governs it. - [Fact] - public void AnEntryWithNoRecordedGeneration_IsNotAMismatch() - { - ModuleActivationStatus.NotYetLoaded( - Activation(directory: null), - Loaded(Module), - Generations((Module, Superseded)), - FloorSatisfied, BytesPresent) - .Should().BeEmpty("a legacy entry activates a folder, not a generation"); - } - - /// - /// The pending/unresolvable split survives the update case, and the buckets stay disjoint: when - /// the ACTIVATED generation's bytes are not on the volume, no restart moves this process onto - /// it, so the remedy is re-install and the entry must not read as "wait for a restart". - /// - [Fact] - public void ASupersededPodWhoseActivatedBytesAreGone_IsUnresolvableNotPending() - { - var record = Activation(); - var generations = Generations((Module, Superseded)); - - ModuleActivationStatus.NotYetLoaded( - record, Loaded(Module), generations, FloorSatisfied, BytesGone) - .Should().BeEmpty("no restart loads a generation directory that is not there"); - ModuleActivationStatus.Unresolvable( - record, Loaded(Module), generations, FloorSatisfied, BytesGone) - .Should().ContainSingle().Subject.Name.Should().Be(Module); - } - - /// - /// The pre-#3395 overloads keep their exact behaviour — they forward an EMPTY generation map, - /// so a host compiled against the previous platform binds a method that still exists and still - /// answers the install question. (The binary-compat rule - /// RequiredModuleStatus.Classify already states, applied here.) - /// - [Fact] - public void TheNameOnlyOverload_StillAnswersTheInstallQuestionUnchanged() - { - ModuleActivationStatus.NotYetLoaded( - Activation(), Loaded(Module), FloorSatisfied, BytesPresent) - .Should().BeEmpty("name-only cannot see a generation move — that is why it is not the " - + "overload production calls"); - ModuleActivationStatus.NotYetLoaded( - Activation(), Loaded(), FloorSatisfied, BytesPresent) - .Should().ContainSingle("an absent name is still pending on the old overload"); - } - - /// - /// The live-process reader takes the generation from the assembly's own directory leaf — which - /// is what makes the comparison possible at all: ModuleGenerationPin copies a landed - /// generation directory WITH its leaf into process-local storage, so a pinned module's location - /// ends …/<name>@<id>/<name>.dll exactly as the shared one does. - /// - [Fact] - public void LoadedModuleGenerations_ReadsTheDirectoryLeafOfEachLoadedAssembly() - { - var generations = ModuleActivationStatus.LoadedModuleGenerations(AppDomain.CurrentDomain); - var self = typeof(ModuleGenerationDivergenceTest).Assembly; - var expected = Path.GetFileName(Path.GetDirectoryName(self.Location))!; - - generations.TryGetValue(self.GetName().Name!, out var leaf).Should().BeTrue( - "this assembly has an on-disk location, so its generation is knowable"); - leaf.Should().Be(expected, - "the leaf of the containing directory IS the generation identity"); - } - - /// An assembly with no on-disk location says nothing about a generation — and silence - /// is the correct answer, not a guess. - [Fact] - public void LoadedModuleGenerations_OmitsAnAssemblyWithNoLocation() - { - var generations = ModuleActivationStatus.LoadedModuleGenerations(AppDomain.CurrentDomain); - - generations.Keys.Should().OnlyContain( - name => AppDomain.CurrentDomain.GetAssemblies() - .Any(a => string.Equals(a.GetName().Name, name, StringComparison.OrdinalIgnoreCase) - && HasLocation(a)), - "a name in the map must be backed by an assembly that has a readable location"); - } - - private static bool HasLocation(Assembly assembly) - { - try - { - return !string.IsNullOrEmpty(assembly.Location); - } - catch (NotSupportedException) - { - return false; - } - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleLandingRecordsTest.cs b/test/Memex.Portal.Shared.Test/ModuleLandingRecordsTest.cs deleted file mode 100644 index dffebe6004..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleLandingRecordsTest.cs +++ /dev/null @@ -1,320 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Text; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #4026's landing RECORDS beyond the race itself (): -/// the common same-bundle case stays one file, the on-disk shapes older images wrote keep their -/// meaning (a stored head is the first landing's fallback; a stored uninstall beats every record), -/// the adopt lane keeps "an older version is an operator who asked for it", and retention can -/// never change what answers. -/// -public class ModuleLandingRecordsTest : IDisposable -{ - private const string Module = "MeshWeaver.Test.LandingRecords"; - private const string MarkerAsset = "wwwroot/build.txt"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-landing-records-" + Guid.NewGuid().ToString("N")); - - /// Creates the landing root both replicas share. - public ModuleLandingRecordsTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - /// A module whose bytes LINK here (a real platform assembly) and whose marker asset - /// makes each version a distinct content address — without it two versions would resolve to - /// ONE generation directory and every head claim below would be vacuous. - private static (IReadOnlyList<(string, byte[])> Assemblies, IReadOnlyList<(string, byte[])> Assets) Bundle(string content) => - ([(Module + ".dll", File.ReadAllBytes(typeof(BundleReader).Assembly.Location))], - [(MarkerAsset, Encoding.UTF8.GetBytes(content))]); - - /// A replica's landing through its public publish-route entry point. - private static async Task Shelve(ModuleLandingService replica, string? version, string content) - { - var (assemblies, assets) = Bundle(content); - return await replica.ShelveModule( - Module, assemblies, frameworkMvid: "test-build", packagePath: "Plugins/ConcurrentShelf", - version: version, staticAssets: assets) - .Timeout(TestTimeouts.Convergence).Await(); - } - - /// A replica's WHOLE shelf landing, run synchronously — what the other replica's - /// recording window executes. Another process's landing does not queue on this one's pool. - private static ModuleLandingOutcome ShelveNow(ModuleLandingService replica, string version, string content) - { - var (assemblies, assets) = Bundle(content); - return replica.LandCore( - Module, assemblies, "test-build", "Plugins/ConcurrentShelf", version, minMeshVersion: null, - assets, sourceCommit: null, nativeAssets: null, holdUnloadable: true, keepNewerHead: true); - } - - private static async Task Adopt(ModuleLandingService replica, string version, string content) - { - var (assemblies, assets) = Bundle(content); - await replica.LandModule( - Module, assemblies, frameworkMvid: "test-build", packagePath: "Plugins/ConcurrentShelf", - version: version, staticAssets: assets) - .Timeout(TestTimeouts.Convergence).Await(); - } - - /// WHICH bytes a generation holds, read off the disk rather than inferred from the - /// version the entry claims. - private string MarkerIn(ModuleActivationEntry entry) => - File.ReadAllText(Path.Combine( - ModuleLandingService.ModuleDirectoryFor(root, Module, entry), - MarkerAsset.Replace('/', Path.DirectorySeparatorChar))); - - private string[] RecordFiles() => - Directory.Exists(ModuleActivationSidecar.LandingRecordsDirectory(root, Module)) - ? Directory.GetFiles(ModuleActivationSidecar.LandingRecordsDirectory(root, Module), "*.json") - : []; - - private void CollectGarbage() => - ModuleLandingService.CollectGarbage(root, minAge: TimeSpan.Zero, nowUtc: DateTime.UtcNow.AddHours(1)); - - /// - /// The COMMON shape, and the one that must stay free: every replica reconciles the same feed, - /// so two replicas landing the SAME bundle at once happens all day. Content addressing makes it - /// one generation (#3656) — and, one level down, one landing RECORD: the second writes nothing. - /// A record store that grew by one file per replica per landing would trade a race for - /// unbounded growth. - /// - [Fact] - public async Task TwoReplicasLandingTheSameBundleInOneWindow_WriteOneRecord() - { - using var replicaA = new ModuleLandingService(null, root, beforeRecording: null); - var ran = false; - using var replicaB = new ModuleLandingService(null, root, beforeRecording: _ => - { - if (!ran) - { - ran = true; - ShelveNow(replicaA, "1.7.0", "1.7.0"); - } - }); - - var outcome = await Shelve(replicaB, "1.7.0", "1.7.0"); - - Assert.True(ran); - Assert.False(outcome.ShelfOnly); - Assert.Single(RecordFiles()); - var head = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.Equal("1.7.0", head.Version); - Assert.Null(head.PreviousDirectory); - } - - /// - /// 🚨 BACKWARD COMPATIBILITY, read side: a module landed by an image that predates the records - /// has only its stored entry. It must read exactly as before, and the first landing on a - /// current image must keep that stored head as the new head's FALLBACK — or the upgrade would - /// silently drop the one generation known to load (#3649) and the GC would reclaim it. - /// - [Fact] - public async Task AStoredEntryFromAnOlderImage_ReadsUnchanged_AndBecomesTheFirstLandingsFallback() - { - using var replica = new ModuleLandingService(null, root, beforeRecording: null); - await Shelve(replica, "1.6.0", "1.6.0"); - // What an older image leaves behind: the per-module entry exactly as IT writes one — no - // ProjectionOf, a field it does not know — and no landing records at all. - ModuleActivationSidecar.RemoveLandingRecords(root, Module); - Assert.Empty(RecordFiles()); - var stored = Assert.Single(ModuleActivationSidecar.ReadStored(root).Entries) with { ProjectionOf = null }; - ModuleActivationSidecar.WriteEntry(root, stored); - Assert.Equal(stored, Assert.Single(ModuleActivationSidecar.Read(root).Entries)); - - await Shelve(replica, "1.7.0", "1.7.0"); - - var head = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.Equal("1.7.0", head.Version); - Assert.Equal(stored.Directory, head.PreviousDirectory); - Assert.Equal("1.6.0", head.PreviousVersion); - CollectGarbage(); - Assert.True(ModuleActivationBoot.PreviousLandedModuleDllExists(root, head), - "the pre-upgrade generation is the fallback now, so the GC must keep it"); - } - - /// - /// 🚨 BACKWARD COMPATIBILITY, write side: an image that predates the records uninstalls by - /// writing a DISABLED per-module entry and never touches the records. That uninstall must win - /// — the stored layers decide WHETHER a module is installed, the records only WHICH generation - /// it runs. Ranking the records above it would resurrect a module an operator removed. - /// - [Fact] - public async Task AnUninstallWrittenByAnOlderImage_BeatsTheLandingRecords() - { - using var replica = new ModuleLandingService(null, root, beforeRecording: null); - await Shelve(replica, "1.7.0", "1.7.0"); - var landed = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.NotEmpty(RecordFiles()); - - // Exactly what the pre-#4026 RemoveCore writes, and all it writes. - ModuleActivationSidecar.WriteEntry(root, landed with - { - Enabled = false, - Directory = null, - PreviousDirectory = null, - PreviousVersion = null, - PreviousFrameworkMvid = null, - }); - - var entry = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.False(entry.Enabled); - Assert.Null(entry.Directory); - } - - /// - /// The documented registry rollback — "uninstall the module first, then publish the older - /// build" (#3996) — needs the uninstall to forget the landings, or the newer build's record - /// would still outrank the older one and the rollback would be refused as shelf-only. - /// - [Fact] - public async Task UninstallThenPublishingAnOlderBuild_IsAFirstLanding() - { - using var replica = new ModuleLandingService(null, root, beforeRecording: null); - await Shelve(replica, "1.7.0", "1.7.0"); - await replica.RemoveModule(Module).Timeout(TestTimeouts.Convergence).Await(); - Assert.False(Assert.Single(ModuleActivationSidecar.Read(root).Entries).Enabled); - - var outcome = await Shelve(replica, "1.6.1", "1.6.1"); - - Assert.False(outcome.ShelfOnly); - var head = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.True(head.Enabled); - Assert.Equal("1.6.1", head.Version); - Assert.Equal("1.6.1", MarkerIn(head)); - } - - /// - /// The ADOPT lane keeps its own rule: an older version there is an operator who asked for it. - /// Re-installing the generation this deployment ran before — the Store's rollback — lands bytes - /// whose record is ALREADY on the volume, and it must still take the head: an identical record - /// is idempotent, but a landing that would move the head records a re-arrival of its own. A - /// second identical re-install, with the head already there, writes nothing. - /// - [Fact] - public async Task AnAdoptReinstallOfThePreviousGeneration_TakesTheHeadAgain() - { - using var replica = new ModuleLandingService(null, root, beforeRecording: null); - await Adopt(replica, "1.6.0", "1.6.0"); - await Adopt(replica, "1.7.0", "1.7.0"); - Assert.Equal("1.7.0", Assert.Single(ModuleActivationSidecar.Read(root).Entries).Version); - - await Adopt(replica, "1.6.0", "1.6.0"); - - var head = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.Equal("1.6.0", head.Version); - Assert.Equal("1.6.0", MarkerIn(head)); - Assert.Equal("1.7.0", head.PreviousVersion); - var records = RecordFiles().Length; - - await Adopt(replica, "1.6.0", "1.6.0"); - - Assert.Equal(records, RecordFiles().Length); - Assert.Equal(head, Assert.Single(ModuleActivationSidecar.Read(root).Entries)); - } - - /// - /// The other half of re-arrival: a landing whose record is already on the volume and which - /// would NOT change the derived entry writes nothing. The head's own bytes re-published under a - /// lower label (the late gate lane re-sending a build the newer lane already published) fold to - /// the entry already derived — a record per such publish would be growth for nothing. - /// - [Fact] - public async Task ARepeatedLowerLabelOfTheHeadsOwnBytes_WritesNoFurtherRecord() - { - using var replica = new ModuleLandingService(null, root, beforeRecording: null); - await Shelve(replica, "1.7.0", "same-bytes"); - await Shelve(replica, "1.6.1", "same-bytes"); - var records = RecordFiles().Length; - var head = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.Equal("1.7.0", head.Version); - - var outcome = await Shelve(replica, "1.6.1", "same-bytes"); - - Assert.True(outcome.ShelfOnly); - Assert.Equal(records, RecordFiles().Length); - Assert.Equal(head, Assert.Single(ModuleActivationSidecar.Read(root).Entries)); - } - - /// - /// 🚨 RETENTION can never change the answer. A record is retired only when the entry derived - /// without it is identical; a superseded build's record goes, its generation is then - /// unreferenced and the GC reclaims it, and the head and fallback are untouched. - /// - [Fact] - public async Task Retention_RetiresARecordTheDerivationNoLongerNeeds_AndNothingElse() - { - using var replica = new ModuleLandingService(null, root, beforeRecording: null); - await Shelve(replica, "1.5.0", "1.5.0"); - var superseded = Assert.Single(ModuleActivationSidecar.Read(root).Entries).Directory; - await Shelve(replica, "1.6.0", "1.6.0"); - await Shelve(replica, "1.7.0", "1.7.0"); - Assert.Equal(3, RecordFiles().Length); - var before = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.Equal("1.7.0", before.Version); - Assert.Equal("1.6.0", before.PreviousVersion); - - CollectGarbage(); - - Assert.Equal(2, RecordFiles().Length); - Assert.Equal(before, Assert.Single(ModuleActivationSidecar.Read(root).Entries)); - Assert.False(Directory.Exists(Path.Combine(root, "modules", superseded!)), - "the superseded generation is referenced by nothing, so the GC reclaims it"); - Assert.True(ModuleActivationBoot.LandedModuleDllExists(root, before)); - Assert.True(ModuleActivationBoot.PreviousLandedModuleDllExists(root, before)); - } - - /// - /// 🚨 The case a "keep the head and the fallback" retention rule gets WRONG. An UNVERSIONED - /// landing moves the head whatever it follows (rule R2), so a later OLDER shelf landing takes - /// the head from it — and the head therefore depends on a record that is neither the head nor - /// the fallback. Retiring that record would let 1.0.0 displace 0.9.0 five minutes after the - /// last landing, with nothing having landed. Retention keeps it, because the derivation without - /// it differs. - /// - [Fact] - public async Task Retention_KeepsARecordTheHeadDependsOn_EvenWhenItIsNeitherHeadNorFallback() - { - using var replica = new ModuleLandingService(null, root, beforeRecording: null); - await Shelve(replica, "1.0.0", "1.0.0"); - await Shelve(replica, null, "unversioned"); - await Shelve(replica, "0.9.0", "0.9.0"); - // Arrival is the files' own write time; state it rather than trust the clock's resolution. - var arrival = DateTime.UtcNow.AddMinutes(-30); - foreach (var file in RecordFiles()) - { - var record = System.Text.Json.JsonDocument.Parse(File.ReadAllText(file)).RootElement; - var version = record.TryGetProperty("version", out var v) ? v.GetString() : null; - File.SetLastWriteTimeUtc(file, arrival.AddSeconds(version switch - { - "1.0.0" => 1, - null => 2, - _ => 3, - })); - } - var before = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.Equal("0.9.0", before.Version); - Assert.Equal("1.0.0", before.PreviousVersion); - - CollectGarbage(); - - Assert.Equal(3, RecordFiles().Length); - Assert.Equal(before, Assert.Single(ModuleActivationSidecar.Read(root).Entries)); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleLinkVersionTest.cs b/test/Memex.Portal.Shared.Test/ModuleLinkVersionTest.cs deleted file mode 100644 index 36153bdb77..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleLinkVersionTest.cs +++ /dev/null @@ -1,510 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json.Nodes; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Mesh; -using MeshWeaver.PluginCatalog; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The link probe compares assembly VERSIONS, asymmetrically (#4083). -/// -/// The incident these reproduce. On 2026-09-11 core #4012 bumped YamlDotNet -/// 16.3.0 → 18.1.0. MeshWeaver.AI references YamlDotNet directly and versionless, was built -/// on an image carrying 18, and landed through the registry on portal pods whose image shipped 16. -/// The probe said Linkable — every type name the module uses exists in both majors — and -/// every new pod crash-looped at hub construction on -/// FileLoadException 0x80131040: the platform's 16.3.0.0 copy is what the loader binds, and -/// .NET never binds a reference to a LOWER version than it asks for. -/// -/// Two causes, both pinned here. (a) the bundle shipped its own YamlDotNet, so the -/// reference was excluded from the denominator under the "built together" rule — a premise that is -/// false exactly when the platform carries the same simple name, because the platform's copy wins; -/// (b) no version was ever compared, and ModuleLinkState had no state to put a -/// FileLoadException verdict in. -/// -/// The rule is asymmetric on purpose. A reference HIGHER than the platform's copy is -/// refused by the loader and is therefore a hard verdict (); -/// a reference LOWER than the platform's copy rolls forward and is an ADVISORY — patch drift is -/// ordinary, and a gate that reds on it is switched off within the week. A public key token -/// mismatch under the same name is hard whatever the versions. A surface that records no version -/// is UNKNOWN, and unknown is an advisory, never a verdict. -/// -/// What makes these able to FAIL. Every one compiles REAL assemblies with Roslyn — -/// two stand-in YamlDotNet builds with identical type names and different manifest versions, -/// exactly the 09-11 shape — and drives the REAL probe, the REAL surface document and the REAL -/// landing service. Restoring the closure short-circuit flips -/// ; -/// dropping the version comparison flips every hard verdict below; making the comparison symmetric -/// flips . -/// -public class ModuleLinkVersionTest : IDisposable -{ - /// The third-party assembly of the incident. The stand-ins carry a type the real one - /// has too, so on the RUNNING process the type walk passes and only the version can refuse. - private const string Yaml = "YamlDotNet"; - private const string YamlType = "YamlDotNet.Serialization.Deserializer"; - private const string Identity = "s4083surface00000000000000000000"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-4083-" + Guid.NewGuid().ToString("N")); - - private readonly ModuleLandingService landing; - - public ModuleLinkVersionTest() - { - Directory.CreateDirectory(root); - landing = new ModuleLandingService(baseDirectory: root); - } - - /// - public void Dispose() - { - landing.Dispose(); - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - GC.SuppressFinalize(this); - } - - // ───────────────────────────────────────────────── the incident, against a file surface - - /// - /// 🚨 THE repro of 2026-09-11. A module compiled against YamlDotNet 18.1.0.0, shipping - /// its own copy (so YamlDotNet is in its closure), checked against a platform whose - /// YamlDotNet is 16.3.0.0 with IDENTICAL type names. Type-name existence says fine; the loader - /// says FileLoadException. The verdict must be the hard one, name the assembly and both - /// versions, and MayLoad must be false — both against the file surface (the running - /// direction) and against the published document read back (the roll-gate direction). - /// - [Fact] - public void TheIncidentShape_AModuleBoundToYamlDotNet18_OnAPlatformCarrying16_CannotBind() - { - var platform = ModulePlatformSurface.OfFiles([Write(Yaml, YamlStandIn("16.3.0.0"))]); - var module = ModuleAgainst(YamlStandIn("18.1.0.0"), "MeshWeaver.Test.AiPack"); - var closure = Closure("MeshWeaver.Test.AiPack", Yaml); - - var onFiles = ModulePlatformLink.Check(module, "MeshWeaver.Test.AiPack", closure, platform); - var onDocument = ModulePlatformLink.Check(module, "MeshWeaver.Test.AiPack", closure, - ModulePlatformSurface.FromJson(platform.ToJson(Identity))); - - foreach (var verdict in new[] { onFiles, onDocument }) - { - Assert.Equal(ModuleLinkState.BindingConflict, verdict.State); - Assert.False(verdict.MayLoad); - var conflict = Assert.Single(verdict.BindingConflicts); - Assert.StartsWith(Yaml + " 18.1.0.0", conflict, StringComparison.Ordinal); - Assert.Contains("16.3.0.0", conflict, StringComparison.Ordinal); - Assert.Contains("FileLoadException", verdict.Report(), StringComparison.Ordinal); - Assert.Empty(verdict.MissingTypes); - Assert.True(verdict.ComparedAssemblyReferences > 0, verdict.Report()); - } - // The document reaches the same verdict as the files: the roll gate and the boot probe - // cannot disagree about this. - Assert.Equal(onFiles.BindingConflicts, onDocument.BindingConflicts); - - // The status-line form — what the refusal marker, the package card and /health carry. - Assert.Equal("held: references YamlDotNet 18.1.0.0, platform provides 16.3.0.0", onFiles.HoldSummary()); - Assert.Equal("YamlDotNet 18.1.0.0", onFiles.Needs()); - Assert.Equal("16.3.0.0", onFiles.Provides()); - var conflict18 = Assert.Single(onFiles.Conflicts); - Assert.Equal(new AssemblyBindingConflict(Yaml, "18.1.0.0", "16.3.0.0"), conflict18); - } - - /// - /// The other direction of the same skew: a module bound to 16.3.0.0 on a platform carrying - /// 18.1.0.0 binds — the loader rolls forward — and the drift is on the verdict as an ADVISORY. - /// Never a refusal: patch drift is ordinary, and this is the arm a symmetric comparison gets - /// wrong. - /// - [Fact] - public void AReferenceBelowThePlatformsVersion_IsLinkable_WithAnAdvisory() - { - var platform = ModulePlatformSurface.OfFiles([Write(Yaml, YamlStandIn("18.1.0.0"))]); - var module = ModuleAgainst(YamlStandIn("16.3.0.0"), "MeshWeaver.Test.OlderAiPack"); - - var verdict = ModulePlatformLink.Check( - module, "MeshWeaver.Test.OlderAiPack", Closure("MeshWeaver.Test.OlderAiPack", Yaml), platform); - - Assert.Equal(ModuleLinkState.Linkable, verdict.State); - Assert.True(verdict.MayLoad); - Assert.Empty(verdict.BindingConflicts); - var advisory = Assert.Single(verdict.Advisories); - Assert.StartsWith(Yaml + ":", advisory, StringComparison.Ordinal); - Assert.Contains("16.3.0.0", advisory, StringComparison.Ordinal); - Assert.Contains("18.1.0.0", advisory, StringComparison.Ordinal); - Assert.Contains("rolls forward", advisory, StringComparison.Ordinal); - Assert.Contains("Advisory", verdict.Report(), StringComparison.Ordinal); - } - - /// Equal versions: as today — Linkable, nothing to report. - [Fact] - public void AnEqualVersion_IsLinkable_WithNothingToReport() - { - var platform = ModulePlatformSurface.OfFiles([Write(Yaml, YamlStandIn("16.3.0.0"))]); - var module = ModuleAgainst(YamlStandIn("16.3.0.0"), "MeshWeaver.Test.SameAiPack"); - - var verdict = ModulePlatformLink.Check( - module, "MeshWeaver.Test.SameAiPack", Closure("MeshWeaver.Test.SameAiPack", Yaml), platform); - - Assert.Equal(ModuleLinkState.Linkable, verdict.State); - Assert.Empty(verdict.BindingConflicts); - Assert.Empty(verdict.Advisories); - Assert.Equal(1, verdict.ComparedAssemblyReferences); - } - - /// - /// The same simple name under a DIFFERENT public key token is a different assembly to the - /// loader, whatever the versions say — hard, in both version directions. - /// - [Fact] - public void APublicKeyTokenMismatch_IsABindingConflict_WhateverTheVersions() - { - var keyA = typeof(object).Assembly.GetName().GetPublicKey()!; - var keyB = typeof(Compilation).Assembly.GetName().GetPublicKey()!; - Assert.True(keyA.Length > 0 && keyB.Length > 0 && !keyA.SequenceEqual(keyB), - "precondition: two distinct real public keys to sign the stand-ins with"); - - var platform = ModulePlatformSurface.OfFiles([Write(Yaml, YamlStandIn("16.3.0.0", keyA))]); - var module = ModuleAgainst(YamlStandIn("16.3.0.0", keyB), "MeshWeaver.Test.KeyedAiPack"); - - var verdict = ModulePlatformLink.Check( - module, "MeshWeaver.Test.KeyedAiPack", Closure("MeshWeaver.Test.KeyedAiPack", Yaml), platform); - - Assert.Equal(ModuleLinkState.BindingConflict, verdict.State); - var conflict = Assert.Single(verdict.BindingConflicts); - Assert.Contains("public key token", conflict, StringComparison.Ordinal); - Assert.Contains(Yaml, conflict, StringComparison.Ordinal); - } - - /// - /// 🚨 A surface that records NO versions — the document every bake wrote before #4083 — cannot - /// answer a version question, and "unknown" is an ADVISORY, never a hard verdict: the roll gate - /// would otherwise hold every roll on a publication that simply predates the section. Even the - /// incident shape is Linkable-with-advisory against such a document. - /// - [Fact] - public void ASurfaceWithoutVersions_IsAdvisoryOnly_NeverHard() - { - var withVersions = ModulePlatformSurface.OfFiles([Write(Yaml, YamlStandIn("16.3.0.0"))]).ToJson(Identity); - var document = JsonNode.Parse(withVersions)!.AsObject(); - Assert.True(document.Remove("identities"), "precondition: the document carried identities to strip"); - var legacy = ModulePlatformSurface.FromJson(document.ToJsonString()); - Assert.Null(legacy.IdentityOf(Yaml)); - Assert.True(legacy.Carries(Yaml)); - - var module = ModuleAgainst(YamlStandIn("18.1.0.0"), "MeshWeaver.Test.LegacyAiPack"); - var verdict = ModulePlatformLink.Check( - module, "MeshWeaver.Test.LegacyAiPack", Closure("MeshWeaver.Test.LegacyAiPack", Yaml), legacy); - - Assert.Equal(ModuleLinkState.Linkable, verdict.State); - Assert.Empty(verdict.BindingConflicts); - var advisory = Assert.Single(verdict.Advisories); - Assert.Contains("records no version", advisory, StringComparison.Ordinal); - Assert.Contains("18.1.0.0", advisory, StringComparison.Ordinal); - } - - // ───────────────────────────────────────────── the closure rule, and its one exception - - /// - /// 🚨 Cause (a): a copy travelling in the module's own bundle no longer hides a reference the - /// platform carries. The SAME module bytes, the same surface — with YamlDotNet in the - /// closure (the bundle ships it) and without — reach the same hard verdict, because the - /// platform's copy is what loads either way. - /// - [Fact] - public void TheBundlesOwnCopy_DoesNotHideAPlatformCarriedAssembly() - { - var platform = ModulePlatformSurface.OfFiles([Write(Yaml, YamlStandIn("16.3.0.0"))]); - var module = ModuleAgainst(YamlStandIn("18.1.0.0"), "MeshWeaver.Test.ShippingAiPack"); - - var shipping = ModulePlatformLink.Check(module, "MeshWeaver.Test.ShippingAiPack", - Closure("MeshWeaver.Test.ShippingAiPack", Yaml), platform); - var consuming = ModulePlatformLink.Check(module, "MeshWeaver.Test.ShippingAiPack", - Closure("MeshWeaver.Test.ShippingAiPack"), platform); - - Assert.True(platform.IsPlatformBound(Yaml)); - Assert.Equal(ModuleLinkState.BindingConflict, shipping.State); - Assert.Equal(ModuleLinkState.BindingConflict, consuming.State); - Assert.Equal(consuming.BindingConflicts, shipping.BindingConflicts); - } - - /// - /// The exception that keeps the rule honest: a sibling the surface carries ONLY because a - /// superseded generation of this same module landed it earlier is NOT platform-bound — the - /// next boot binds the sibling this bundle brings, not the one it replaces. So a module - /// re-landing its own siblings, with a new type on the new sibling, stays Linkable. The - /// contrast (the sibling NOT in the closure — some other module's, which the loader would - /// resolve from the landed directory) is measured and refused, which shows the predicate is - /// what does the work. - /// - [Fact] - public void AClosureSiblingCarriedOnlyByALandedDirectory_IsStillTheModulesOwnBusiness() - { - const string sibling = "MeshWeaver.Test.OwnSibling"; - var older = Emit(sibling, "16.0.0.0", "namespace MeshWeaver.Test; public static class OldApi { public static int A => 1; }"); - var newer = Emit(sibling, "16.0.0.0", "namespace MeshWeaver.Test; public static class OldApi { public static int A => 1; } public static class NewApi { public static int B => 2; }"); - var landedDirectory = Path.GetDirectoryName(Write(sibling, older))!; - var module = Emit("MeshWeaver.Test.ReLandingPack", "1.0.0.0", - "public static class Views { public static int Show() => MeshWeaver.Test.NewApi.B; }", - MetadataReference.CreateFromImage(newer)); - var surface = ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory, landedDirectory); - Assert.True(surface.Carries(sibling), "precondition: the landed directory is on the surface"); - Assert.False(surface.IsPlatformBound(sibling), "a landed directory's copy is not what the platform binds"); - - var reLanding = ModulePlatformLink.Check(module, "MeshWeaver.Test.ReLandingPack", - Closure("MeshWeaver.Test.ReLandingPack", sibling), surface); - var foreign = ModulePlatformLink.Check(module, "MeshWeaver.Test.ReLandingPack", - Closure("MeshWeaver.Test.ReLandingPack"), surface); - - Assert.Equal(ModuleLinkState.Linkable, reLanding.State); - Assert.Equal(ModuleLinkState.Unlinkable, foreign.State); - Assert.Contains(foreign.MissingTypes, m => m.StartsWith("MeshWeaver.Test.NewApi", StringComparison.Ordinal)); - } - - // ────────────────────────────────── both directions of the incident, on the real paths - - /// - /// Direction (ii), the landing path, on the RUNNING process: this test process carries the - /// real YamlDotNet in its application directory; a module bound to a stand-in at 99.0.0.0 — - /// shipping that stand-in beside it, as the 09-11 bundle did — is REFUSED at landing, before - /// any byte touches the disk, with a sentence that names the assembly and the exception. - /// - [Fact] - public async Task AModuleBoundAboveTheRunningPlatformsVersion_IsRefusedAtLanding() - { - var running = ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory); - Assert.True(running.Carries(Yaml), "precondition: this process's application directory carries YamlDotNet"); - Assert.True(running.IsPlatformBound(Yaml)); - var carried = running.IdentityOf(Yaml); - Assert.NotNull(carried?.Version); - var above = new Version(carried!.Version!.Major + 1, 0, 0, 0).ToString(4); - - const string name = "MeshWeaver.Test.LandingAiPack"; - const string packagePath = "InstalledPackages/AI"; - var standIn = YamlStandIn(above); - var refusal = await Assert.ThrowsAsync(async () => - await landing.LandModule(name, [(name + ".dll", ModuleAgainst(standIn, name)), (Yaml + ".dll", standIn)], - packagePath: packagePath, version: "1.9.0") - .Timeout(TestTimeouts.Convergence).Await()); - - Assert.Contains(Yaml + " " + above, refusal.Message, StringComparison.Ordinal); - Assert.Contains("FileLoadException", refusal.Message, StringComparison.Ordinal); - Assert.Empty(ModuleActivationSidecar.Read(root).Entries); - - // 🚨 LEGIBLE (#4083): the refusal is on the module's own marker, in the words a person - // reads — not only in a log line — and the activation report renders it for the package. - var provided = carried!.Version!.ToString(4); - var marker = ModuleActivationSidecar.ReadRefused(root, name); - Assert.NotNull(marker); - Assert.Equal($"held: references {Yaml} {above}, platform provides {provided}", marker.Reason); - Assert.Equal($"{Yaml} {above}", marker.Needs); - Assert.Equal(provided, marker.Provides); - Assert.Equal(packagePath, marker.PackagePath); - Assert.Equal("1.9.0", marker.Version); - - var report = new PendingModuleActivations(root).Read(); - Assert.False(report.IsUndetermined, report.UndeterminedReason); - var row = Assert.Single(report.Refused); - Assert.Equal(name, row.Name); - Assert.Same(row, report.RefusalForPackage(packagePath)); - Assert.Contains("REFUSED at landing", report.Describe(), StringComparison.Ordinal); - Assert.Contains($"{name} (1.9.0): held: references {Yaml} {above}, platform provides {provided}", - report.Describe(), StringComparison.Ordinal); - Assert.DoesNotContain(report.Unresolvable, p => string.Equals(p.Name, name, StringComparison.Ordinal)); - - // A later landing of the same module that LANDS (bound below the platform's copy) clears - // the marker: the state it described is gone. - var older = YamlStandIn("1.0.0.0"); - await landing.LandModule(name, [(name + ".dll", ModuleAgainst(older, name)), (Yaml + ".dll", older)], - packagePath: packagePath, version: "1.9.1") - .Timeout(TestTimeouts.Convergence).Await(); - Assert.Null(ModuleActivationSidecar.ReadRefused(root, name)); - Assert.Empty(new PendingModuleActivations(root).Read().Refused); - } - - /// The positive control on the same path: a module bound BELOW the running copy's - /// version lands — the loader rolls forward, and the drift is a report, not a refusal. - [Fact] - public async Task AModuleBoundBelowTheRunningPlatformsVersion_Lands() - { - var running = ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory); - Assert.True(running.Carries(Yaml), "precondition: this process's application directory carries YamlDotNet"); - - const string name = "MeshWeaver.Test.RollingForwardAiPack"; - var standIn = YamlStandIn("1.0.0.0"); - await landing.LandModule(name, [(name + ".dll", ModuleAgainst(standIn, name)), (Yaml + ".dll", standIn)]) - .Timeout(TestTimeouts.Convergence).Await(); - - var entry = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.Equal(name, entry.Name); - Assert.True(entry.Enabled); - } - - /// - /// The boot probe's shape (Check(path, surface), the closure being the DLLs beside the - /// entry): the module's own YamlDotNet copy sits beside it on disk and the running process - /// carries the real one — the verdict is the hard one, and it is what - /// MeshBuilder.TryLoad parks the generation on. - /// - [Fact] - public void ALandedGenerationBoundAboveTheRunningPlatformsVersion_IsParkedAtBoot() - { - var running = ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory); - Assert.True(running.Carries(Yaml), "precondition: this process's application directory carries YamlDotNet"); - var above = new Version(running.IdentityOf(Yaml)!.Version!.Major + 1, 0, 0, 0).ToString(4); - - const string name = "MeshWeaver.Test.BootAiPack"; - var standIn = YamlStandIn(above); - var entry = Write(name, ModuleAgainst(standIn, name)); - File.WriteAllBytes(Path.Combine(Path.GetDirectoryName(entry)!, Yaml + ".dll"), standIn); - - var verdict = ModulePlatformLink.Check(entry, running); - - Assert.Equal(ModuleLinkState.BindingConflict, verdict.State); - Assert.False(verdict.MayLoad); - var parked = IncompatibleModule.FromLinkRefusal(entry, verdict); - Assert.Contains(Yaml + " " + above, parked.Report(), StringComparison.Ordinal); - Assert.True(parked.RefusedBeforeLoad); - } - - // ──────────────────────────────────────────── the loader itself, as the rule's control - - /// - /// 🚨 The rule is the LOADER's, measured — not a policy. An UNSIGNED dependency at 1.0.0.0 - /// already loaded in a context, and a consumer whose bytes ask for 9.0.0.0: the loaded copy is - /// REFUSED — the first call that touches it throws, never returns the copy's answer. The - /// spelling of the refusal is the context's: in the Default context, where the copy is the - /// TPA's, it is 0x80131040 (the 09-11 pod logs); in a - /// custom context the refused candidate is skipped, nothing else is found, and it is - /// naming the 9.0.0.0 request. The other way round - /// (9.0.0.0 loaded, 1.0.0.0 asked for) binds and runs. The probe's asymmetry is exactly this, - /// and a strong name is not what triggers it — which is why the comparison applies to every - /// carried assembly and not only to signed ones. - /// - [Fact] - public void TheLoaderRefusesAHigherReference_AndRollsForwardALowerOne_UnsignedIncluded() - { - const string dep = "MeshWeaver.Test.VersionedDep"; - var v1 = Emit(dep, "1.0.0.0", "namespace MeshWeaver.Test; public static class Api { public static int Answer() => 42; }"); - var v9 = Emit(dep, "9.0.0.0", "namespace MeshWeaver.Test; public static class Api { public static int Answer() => 42; }"); - var askingFor9 = Emit("MeshWeaver.Test.AskingFor9", "1.0.0.0", - "public static class View { public static int Render() => MeshWeaver.Test.Api.Answer(); }", - MetadataReference.CreateFromImage(v9)); - var askingFor1 = Emit("MeshWeaver.Test.AskingFor1", "1.0.0.0", - "public static class View { public static int Render() => MeshWeaver.Test.Api.Answer(); }", - MetadataReference.CreateFromImage(v1)); - - var refused = Invoke(v1, askingFor9); - var rolledForward = Invoke(v9, askingFor1); - - var refusal = Assert.IsAssignableFrom(refused); - Assert.True(refusal is FileLoadException or FileNotFoundException, - $"the loader must REFUSE the 1.0.0.0 copy for a 9.0.0.0 reference, not answer from it: {refusal}"); - Assert.Contains("9.0.0.0", refusal.Message, StringComparison.Ordinal); - Assert.Equal(42, rolledForward); - - // And the probe agrees with the loader on both, from the same bytes. - var above = ModulePlatformLink.Check(askingFor9, "MeshWeaver.Test.AskingFor9", - Closure("MeshWeaver.Test.AskingFor9", dep), ModulePlatformSurface.OfFiles([Write(dep, v1)])); - var below = ModulePlatformLink.Check(askingFor1, "MeshWeaver.Test.AskingFor1", - Closure("MeshWeaver.Test.AskingFor1", dep), ModulePlatformSurface.OfFiles([Write(dep, v9)])); - Assert.Equal(ModuleLinkState.BindingConflict, above.State); - Assert.Equal(ModuleLinkState.Linkable, below.State); - Assert.Single(below.Advisories); - } - - /// Loads into a fresh collectible context, then - /// , and invokes View.Render(): the value it returns, or the - /// exception the loader threw (unwrapped from reflection's envelope). - private static object? Invoke(byte[] dependency, byte[] consumer) - { - var context = new System.Runtime.Loader.AssemblyLoadContext( - "loader-control-" + Guid.NewGuid().ToString("N"), isCollectible: true); - try - { - using var dependencyStream = new MemoryStream(dependency); - context.LoadFromStream(dependencyStream); - using var consumerStream = new MemoryStream(consumer); - var render = context.LoadFromStream(consumerStream).GetType("View")!.GetMethod("Render")!; - try - { - return render.Invoke(null, null); - } - catch (Exception exception) - { - while (exception is System.Reflection.TargetInvocationException { InnerException: { } inner }) - exception = inner; - return exception; - } - } - finally { context.Unload(); } - } - - // ───────────────────────────────────────────────────────────────────────── harness - - private static HashSet Closure(params string[] names) => - new(names, StringComparer.OrdinalIgnoreCase); - - /// A stand-in YamlDotNet carrying — a type name the - /// real one has too — at the given manifest version, optionally signed with a public key - /// (public-sign: the key goes into the manifest, no private half needed). - private static byte[] YamlStandIn(string version, byte[]? publicKey = null) => - Emit(Yaml, version, """ - namespace YamlDotNet.Serialization; - public sealed class Deserializer { public T Deserialize(string yaml) => default!; } - """, publicKey: publicKey); - - /// A module compiled against the given stand-in, using . - private static byte[] ModuleAgainst(byte[] yamlStandIn, string moduleName) => - Emit(moduleName, "1.0.0.0", $$""" - public static class SkillMarkdown - { - public static object Parse(string yaml) => new {{YamlType}}().Deserialize(yaml); - } - """, MetadataReference.CreateFromImage(yamlStandIn), excludeReference: Yaml); - - /// Compiles one source file against this process's reference set, stamping the - /// manifest version and (optionally) a public key. - private static byte[] Emit( - string assemblyName, string version, string source, - MetadataReference? extra = null, string? excludeReference = null, byte[]? publicKey = null) - { - // The process-wide platform set, read once — see PlatformReferences for why (#4127). - var platform = PlatformReferences.Platform(excludeReference); - if (extra is not null) - platform = platform.Add(extra); - - var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary); - if (publicKey is not null) - options = options.WithCryptoPublicKey([.. publicKey]).WithPublicSign(true); - - var compilation = CSharpCompilation.Create( - assemblyName, - [ - CSharpSyntaxTree.ParseText($"[assembly: System.Reflection.AssemblyVersion(\"{version}\")]"), - CSharpSyntaxTree.ParseText(source), - ], - platform, - options); - - return StandInCompile.Emit(compilation); - } - - /// Writes an assembly into its OWN directory under the test root. - private string Write(string assemblyName, byte[] bytes) - { - var directory = Path.Combine(root, "emitted", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - var path = Path.Combine(directory, assemblyName + ".dll"); - File.WriteAllBytes(path, bytes); - return path; - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleOnlyBundleIsNotAMissTest.cs b/test/Memex.Portal.Shared.Test/ModuleOnlyBundleIsNotAMissTest.cs deleted file mode 100644 index 3dcded7bcc..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleOnlyBundleIsNotAMissTest.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.IO; -using System.IO.Compression; -using System.Text.Json; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A bundle that carries no NodeType assemblies means two OPPOSITE things, and until #3768 both -/// were — a miss, rendered on /health as -/// "content the registry was meant to serve is compiled here instead". -/// -/// Measured 2026-09-12 on memex.systemorph.com: 25 adoption attempt(s), 25 adopted, -/// 13 MISS(es) — and all thirteen (AI, Anthropic, AppleIntelligence, -/// Maps, AzureBlob, AzureFoundry, Chat, Mcp, -/// Notifications, OpenAI, …) were module packages whose modules had landed -/// correctly through the separate ModuleLandingService path. The portal read Degraded -/// permanently with nothing wrong with it, on the one instrument the delivery issues are triaged -/// with. -/// -/// 🚨 The falsification arms are the point of this file: the benign reading is taken ONLY -/// from a positive declaration. An empty archive that declares nothing stays a miss. -/// -public class ModuleOnlyBundleIsNotAMissTest -{ - private static BundleReader.Manifest Manifest( - BundleReader.ModuleRef? module = null, - IReadOnlyList? content = null, - IReadOnlyList? misses = null) => - new("AI", "1.7.1", "s3e3c80238a740a8cb895a72b0bfb9cd6", - Assemblies: [], Module: module, Architecture: "linux-x64", - Misses: misses, Content: content); - - [Fact] - public void AModuleOnlyBundle_HasNothingToAdopt_AndIsNotAMiss() - { - var manifest = Manifest(module: new BundleReader.ModuleRef("MeshWeaver.AI", ["MeshWeaver.AI.dll"])); - - BundleOffering.ClassifyEmpty(manifest).Should().Be(BundleAdoptionKind.NothingToAdopt, - "a package that DECLARES a module has no NodeTypes to offer and is complete as delivered"); - BundleOffering.OfferingOf(manifest).Should().Be("module 'MeshWeaver.AI'"); - - new BundleAdoptionOutcome("AI", BundleAdoptionKind.NothingToAdopt, "https://registry") - .IsMiss.Should().BeFalse("nothing was meant to be served, so nothing compiles here instead"); - } - - [Fact] - public void AContentOnlyBundle_HasNothingToAdopt() - { - BundleOffering.ClassifyEmpty(Manifest(content: ["Doc/Readme.md"])) - .Should().Be(BundleAdoptionKind.NothingToAdopt); - } - - // ---- falsification arms: the benign reading must NOT be reachable by absence ---- - - [Fact] - public void AnEmptyArchiveThatDeclaresNothing_STAYS_AMiss() - { - BundleOffering.ClassifyEmpty(Manifest()).Should().Be(BundleAdoptionKind.NoAssemblies, - "a producer that shipped an empty archive is a real defect — inferring 'nothing was " - + "expected' from emptiness is exactly what would silence it"); - - new BundleAdoptionOutcome("AI", BundleAdoptionKind.NoAssemblies, "https://registry") - .IsMiss.Should().BeTrue(); - } - - [Fact] - public void UnresolvedProducerMisses_DominateEveryDeclaration_AndStayAMiss() - { - var shortBundle = Manifest( - module: new BundleReader.ModuleRef("MeshWeaver.AI", ["MeshWeaver.AI.dll"]), - content: ["Doc/Readme.md"], - misses: ["AI/Agent: no artifact for this lane"]); - - BundleOffering.ClassifyEmpty(shortBundle).Should().Be(BundleAdoptionKind.NoAssemblies, - "the bake could not resolve a type it was asked for — the bundle is SHORT whatever " - + "else it declares, and that is the miss #1751 exists to keep countable"); - } - - [Fact] - public void AnUnreadableManifest_StaysAMiss() - { - BundleOffering.ClassifyEmpty(null).Should().Be(BundleAdoptionKind.NoAssemblies); - } - - /// - /// 🚨 The review finding on #4079. A MIXED package declares NodeType assemblies AND a module. - /// skips a declared assembly whose archive entry is - /// absent — silently, recording no miss — so a torn bundle reaches the empty branch looking - /// exactly like a module-only one. Reading it as "nothing to adopt" would hide a genuinely - /// missing NodeType, and would hide it from Modules:RequirePrebuilt, which exists to - /// refuse precisely that. - /// - [Fact] - public void ADeclaredNodeTypeThatDidNotArrive_IsAMiss_EvenWhenTheBundleAlsoShipsAModule() - { - var manifest = new BundleReader.Manifest("Social", "1.2.0", "sframework", - Assemblies: [new BundleReader.AssemblyRef("SocialMedia/Post", "Post.dll")], - Module: new BundleReader.ModuleRef("MeshWeaver.Social", ["MeshWeaver.Social.dll"]), - Content: ["Post/Readme.md"]); - - BundleOffering.ClassifyEmpty(manifest).Should().Be(BundleAdoptionKind.NoAssemblies, - "the manifest PROMISED NodeType bytes and none arrived — a torn bundle, not a " - + "module-only one, however the package describes the rest of itself"); - } - - /// The same thing end to end: a real archive whose declared assembly entry is absent. - [Fact] - public void ARealBundleMissingItsDeclaredAssemblyEntry_ReadsEmpty_AndIsStillAMiss() - { - var declared = new BundleReader.Manifest("Social", "1.2.0", "sframework", - Assemblies: [new BundleReader.AssemblyRef("SocialMedia/Post", "Post.dll")], - Module: new BundleReader.ModuleRef("MeshWeaver.Social", ["MeshWeaver.Social.dll"])); - - using var buffer = new MemoryStream(); - using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true)) - { - using var entry = new StreamWriter( - archive.CreateEntry(NuGetPackageWriter.ManifestEntry).Open()); - entry.Write(JsonSerializer.Serialize(declared, new JsonSerializerOptions(JsonSerializerDefaults.Web))); - } - // NOTE: meshweaver/assemblies/Post.dll is deliberately NOT written. - - var (manifest, assemblies) = BundleReader.Read(buffer.ToArray()); - - assemblies.Should().BeEmpty("BundleReader skips a declared assembly with no archive entry"); - manifest!.Assemblies.Should().ContainSingle("…while the manifest still declares it"); - (manifest.Misses?.Count ?? 0).Should().Be(0, "and the producer recorded no miss for it"); - - BundleOffering.ClassifyEmpty(manifest).Should().Be(BundleAdoptionKind.NoAssemblies, - "which is exactly the shape that must NOT read as 'nothing to adopt'"); - } - - // ---- the rendered sentence keeps its denominator ---- - - [Fact] - public void TheLedgerNamesHowManyHadNothingToOffer_SoTheRestIsReadable() - { - var ledger = new BundleAdoptionLedger(); - ledger.Record(new BundleAdoptionOutcome( - "Store", BundleAdoptionKind.Adopted, "https://registry", Adopted: 3, Offered: 3)); - ledger.Record(new BundleAdoptionOutcome( - "AI", BundleAdoptionKind.NothingToAdopt, "https://registry")); - ledger.Record(new BundleAdoptionOutcome( - "Maps", BundleAdoptionKind.NothingToAdopt, "https://registry")); - - ledger.Misses.Should().BeEmpty("two module packages and one fully adopted package is a clean lane"); - - var line = ledger.Describe(); - line.Should().Contain("3 adoption attempt(s)"); - line.Should().Contain("no misses"); - line.Should().Contain("2 carried no NodeTypes to adopt", - "'3 attempts, 3 adopted, no misses' would invite the reader to conclude three packages " - + "were served — the split is what makes the number readable"); - } - - [Fact] - public void ARealMissIsStillLoud_AlongsideThePackagesThatOfferedNothing() - { - var ledger = new BundleAdoptionLedger(); - ledger.Record(new BundleAdoptionOutcome("AI", BundleAdoptionKind.NothingToAdopt, "https://registry")); - ledger.Record(new BundleAdoptionOutcome( - "SocialMedia", BundleAdoptionKind.FrameworkDeclined, "https://registry", - Reason: "built against framework s72c27af, live framework is s414bfb2")); - - ledger.Misses.Should().ContainSingle().Which.PluginId.Should().Be("SocialMedia"); - ledger.Describe().Should().Contain("1 MISS(es)").And.Contain("FrameworkDeclined"); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModulePlatformFloorScriptParityTest.cs b/test/Memex.Portal.Shared.Test/ModulePlatformFloorScriptParityTest.cs deleted file mode 100644 index 9d733c412a..0000000000 --- a/test/Memex.Portal.Shared.Test/ModulePlatformFloorScriptParityTest.cs +++ /dev/null @@ -1,208 +0,0 @@ -using System.Diagnostics; -using System.Text; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The CI gate and the runtime must answer the SAME question the same way -/// (Systemorph/MeshWeaver#3554). -/// -/// ModulePlatformFloor.DeclineReason decides at RUNTIME whether a module may land on -/// the platform it finds itself on. .github/scripts/check-module-platform-floor.py decides at -/// BUILD time whether the floor a package declares is satisfiable by the platform the bundle is being -/// compiled against — the check whose absence let 42 packages declare rc-line floors that no -/// 3.0.0-ci.<n> image can ever meet (SemVer §11.4 ranks pre-release identifiers as text, -/// so "ci" < "rc"), holding every self-update candidate on both AKS portals with a message -/// naming two versions that look like they are in the right order. -/// -/// Why a parity test and not two careful implementations. Two call sites computing the -/// same fold differently either never converge or never fire, and both are silent — the gate would -/// pass a floor the runtime then refuses, or red a floor that is actually fine, and nothing would -/// say which. So the ordering is written once in -/// src/MeshWeaver.Plugin.Packaging/NuGetVersionComparer.cs, mirrored in the script, and pinned -/// here: for every case, the script's EXIT CODE must equal "the runtime found no reason to decline". -/// The table deliberately includes all four floor shapes measured on the live portals on 2026-09-07 -/// (rc8, rc9, the never-published rc14, and a clean 3.0.0), the fix -/// MeshWeaver.Plugins#1447 moved them to, and the numeric-vs-text trap -/// (ci.900 vs ci.3758) the comparer exists for. -/// -/// 🚨 The fixture asserts its own inputs: the script must exist and python3 must run it. -/// A parity test that silently skips when its subject is missing is the skip-trapdoor shape -/// AGENTS.md bans — it would pass having compared nothing, exactly while the gate it pins was -/// unreachable. -/// -public class ModulePlatformFloorScriptParityTest -{ - private static string ScriptPath - { - get - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - Assert.NotNull(dir); - var script = Path.Combine(dir!.FullName, ".github", "scripts", "check-module-platform-floor.py"); - Assert.True(File.Exists(script), - $"{script} is missing — this test would compare the runtime against nothing while " - + "the module-pack lane's floor gate is unreachable. Follow the script if it moved."); - return script; - } - } - - /// The script's verdict: true = the floor is satisfied (exit 0). - private static bool ScriptSaysSatisfied(string? floor, string? platform) - { - var psi = new ProcessStartInfo("python3") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - UseShellExecute = false, - }; - psi.ArgumentList.Add(ScriptPath); - psi.ArgumentList.Add("--package"); - psi.ArgumentList.Add("ParityFixture"); - psi.ArgumentList.Add("--floor"); - psi.ArgumentList.Add(floor ?? string.Empty); - psi.ArgumentList.Add("--platform-version"); - psi.ArgumentList.Add(platform ?? string.Empty); - psi.ArgumentList.Add("--quiet"); - - using var process = Process.Start(psi); - Assert.NotNull(process); - // 🚨 DRAIN BOTH PIPES CONCURRENTLY, THROUGH THE EVENT-BASED READER (#4734) — never - // `StandardOutput.ReadToEnd()` then `StandardError.ReadToEnd()`. There is a deadlock on - // EITHER side of the bounded wait, and only a concurrent drain misses both: - // • Reading BEFORE the wait — what this did — cannot honour the bound it advertises. - // `ReadToEnd()` returns at EOF, and the child produces EOF by EXITING. So on the one - // failure the 30 s exists to catch, a wedged check-module-platform-floor.py, the read - // blocks for ever and `WaitForExit` is never reached: the cap was reachable only when - // it was not needed, and the assertion beneath it could never report the hang it - // names. A cap that cannot fire is not a cap. - // • Reading AFTER the wait deadlocks the other way: a redirected stream nobody reads - // fills its OS buffer and blocks the child in `write`, so the wait times out on a - // process that had already finished its work. - // So reordering is not the fix — draining both pipes while the wait runs is. The same - // analysis and the same remedy sit in MsBuildPropertyProbe and StartupFailureProcessTest; - // MemexLocalDiscoversNodeRepoSiblingsGuard takes the other sanctioned route, redirecting - // to FILES so there is no pipe at all. Event-based reading needs no TaskCompletionSource - // and no async bridge, so it stays inside the house rules for `test/`. - var outBuffer = new StringBuilder(); - var errBuffer = new StringBuilder(); - process!.OutputDataReceived += (_, e) => { if (e.Data is not null) outBuffer.AppendLine(e.Data); }; - process.ErrorDataReceived += (_, e) => { if (e.Data is not null) errBuffer.AppendLine(e.Data); }; - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - // Close stdin immediately. The script reads none, so a regression that started waiting on - // it (an `input()`, an argparse prompt) would otherwise park until the bound; with the - // pipe closed it gets EOF at once and fails as a named exit code instead of as a timeout. - process.StandardInput.Close(); - - if (!process.WaitForExit(30_000)) - { - // Kill the TREE: python3 is the launcher, and a guard that leaks a spinning child on - // every timeout degrades the machine it is measuring on. The race where the process - // exits between the wait returning false and this call is the NON-event — catching it - // keeps the named failure below, rather than replacing it with an opaque - // InvalidOperationException. Nothing is suppressed: Assert.Fail runs either way. - try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { /* already gone */ } - // 🚨 JOIN THE READERS BEFORE READING THE BUFFERS (Copilot review). `Kill` only requests - // termination; it does not stop the OutputDataReceived/ErrorDataReceived callbacks, so - // without this the interpolation below would call StringBuilder.ToString() while a - // threadpool thread is still appending — not thread-safe, and the way it fails is an - // opaque ArgumentOutOfRangeException standing WHERE THE NAMED FAILURE SHOULD BE. That - // is the very defect this change exists to remove. The wait cannot hang: the whole - // tree is dead, so both pipes are at EOF and the readers complete. Joining also beats - // locking the snapshot here — it yields everything the child wrote before it died, - // which on a wedged script is the most useful part of the message. - process.WaitForExit(); - Assert.Fail( - "python3 did not answer within 30s — the gate's own logic is a pure comparison, so a " - + "hang here is the environment, not the rule. Do not raise the bound.\n" - + $"stdout so far: {outBuffer} stderr so far: {errBuffer}"); - } - // 🚨 The TIMED overload waits for the PROCESS only; the parameterless one is what joins the - // async readers started above. Reading the buffers straight after `WaitForExit(ms)` can - // therefore catch a TRUNCATED stream, which would make this test fail on a half-read - // message instead of on its subject. The process has already exited here, so this returns - // as soon as the readers drain. - process.WaitForExit(); - - var stdout = outBuffer.ToString(); - var stderr = errBuffer.ToString(); - // A python that could not START (argparse error, syntax error) exits 2 with text on stderr. - // That must never read as "the floor is not satisfied": it is a broken fixture, named. - Assert.True(process.ExitCode is 0 or 1, - $"the script exited {process.ExitCode} rather than 0/1 — it did not run. " - + $"stdout: {stdout} stderr: {stderr}"); - return process.ExitCode == 0; - } - - [Theory] - // The four shapes measured on memex + memex-cloud, 2026-09-07, while both ran 3.0.0-ci.7989. - [InlineData("3.0.0-rc8", "3.0.0-ci.7989")] - [InlineData("3.0.0-rc9", "3.0.0-ci.7989")] - [InlineData("3.0.0-rc14", "3.0.0-ci.7989")] // names a platform that never existed - [InlineData("3.0.0", "3.0.0-ci.7989")] // a release outranks every pre-release - [InlineData("3.0.0-rc4", "3.0.0-ci.1")] - [InlineData("3.0.0-rc4", "3.0.0-ci.999999999")] // no build number rescues an rc floor - // The floor MeshWeaver.Plugins#1447 moved all 42 packages to, against every platform line it - // has to survive. - [InlineData("3.0.0-ci.7845", "3.0.0-ci.7845")] - [InlineData("3.0.0-ci.7845", "3.0.0-ci.7989")] - [InlineData("3.0.0-ci.7845", "3.0.0-rc9.ci.7534")] - [InlineData("3.0.0-ci.7845", "3.0.0")] - [InlineData("3.0.0-ci.7845", "3.1.0-ci.1")] - // The trap NuGetVersionComparer exists for: string order puts ci.3758 below ci.900. - [InlineData("3.0.0-ci.900", "3.0.0-ci.3758")] - [InlineData("3.0.0-ci.3758", "3.0.0-ci.900")] - // 🚨 Int32 OVERFLOW, on both sides and in both positions (Copilot review). `int.TryParse` - // REFUSES past 2147483647, so the runtime reads such a segment as zero in the core and as - // ALPHANUMERIC in the pre-release — the one place an unbounded Python `int()` was free to - // disagree. 2147483647 is the last value both accept; 2147483648 is the first neither does. - [InlineData("3.0.0-ci.2147483647", "3.0.0-ci.2147483647")] - [InlineData("3.0.0-ci.2147483647", "3.0.0-ci.2147483648")] - [InlineData("3.0.0-ci.2147483648", "3.0.0-ci.2147483647")] - [InlineData("3.0.0-ci.2147483648", "3.0.0-ci.9999999999999999999999")] - [InlineData("3.0.0-ci.7845", "3.0.0-ci.99999999999999999999")] - [InlineData("99999999999999999999.0.0", "3.0.0")] - [InlineData("3.0.0", "99999999999999999999.0.0")] - // Non-ASCII digits: str.isdigit() accepts them, NumberStyles.None does not. - [InlineData("3.0.0-ci.٣", "3.0.0-ci.3")] - [InlineData("3.0.0-ci.3", "3.0.0-ci.٣")] - // Boundaries: no declared floor is no constraint; an unknown platform is refused, not waved. - [InlineData("", "3.0.0-ci.7989")] - [InlineData(null, "3.0.0-ci.7989")] - [InlineData("3.0.0-ci.7845", "")] - [InlineData("3.0.0-ci.7845", null)] - public void TheGateAgreesWithTheRuntimeGate(string? floor, string? platform) - { - var runtimeAllows = ModulePlatformFloor.DeclineReason(floor, platform) is null; - var scriptAllows = ScriptSaysSatisfied(floor, platform); - - Assert.True(runtimeAllows == scriptAllows, - $"floor '{floor ?? "(null)"}' against platform '{platform ?? "(null)"}': the runtime " - + $"{(runtimeAllows ? "ALLOWS" : "DECLINES")} it and check-module-platform-floor.py " - + $"{(scriptAllows ? "ALLOWS" : "DECLINES")} it. The two must be one rule — a gate that " - + "disagrees with the runtime either passes a floor that will hold for ever or reds one " - + "that is fine, and says nothing about which (#3554)."); - } - - /// - /// The defect itself, stated as a claim rather than inferred from the table: on the ci line, an - /// rc floor is unsatisfiable no matter how large the build number gets. This is what makes the - /// build-time check necessary at all — the runtime cannot distinguish "not yet" from "never". - /// - [Fact] - public void AnRcFloorIsUnreachableFromTheCiLine_ForEveryBuildNumber() - { - foreach (var build in new[] { "1", "7845", "7989", "999999999" }) - { - var running = $"3.0.0-ci.{build}"; - Assert.NotNull(ModulePlatformFloor.DeclineReason("3.0.0-rc8", running)); - Assert.False(ScriptSaysSatisfied("3.0.0-rc8", running)); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/ModulePlatformLinkTest.cs b/test/Memex.Portal.Shared.Test/ModulePlatformLinkTest.cs deleted file mode 100644 index 4968541077..0000000000 --- a/test/Memex.Portal.Shared.Test/ModulePlatformLinkTest.cs +++ /dev/null @@ -1,899 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Reflection; -using System.Runtime.Loader; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A module is adopted on what it CAN LOAD, never on a version string (#3538, #3518). -/// -/// The incident these reproduce. memex-cloud ran a core of 2026-09-03 and adopted -/// DefaultViews' MeshWeaver.Graph.Views, whose bytes were compiled on 09-06 against a -/// MeshWeaver.Mesh.Contract carrying CodeOutputCurrency — a type added on 09-04. The -/// only gate was the module's declared minMeshVersion: 3.0.0-rc8 floor, which the running -/// 3.0.0-rc9.ci.7693 satisfied, so the bytes landed and loaded. They installed CLEANLY — -/// the module's MeshNodeProviderAttribute touches none of the missing surface, so #2234's -/// per-module install isolation saw nothing — and then EVERY render of every code cell threw -/// TypeLoadException: Could not load type 'MeshWeaver.Mesh.CodeOutputCurrency', for every -/// user, until a human read a pod log. -/// -/// Why the declared floor can never answer it. A floor is a CLAIM about API -/// compatibility that an author writes by hand. The module's real requirement is not a version at -/// all — it is the SET OF TYPES its bytes are linked against, and that set is stated exactly, by -/// the compiler, in the assembly's own metadata. So it is MEASURED against the platform copies -/// this process would actually bind to (), metadata only, with no -/// Assembly.Load and no type loading. -/// -/// What makes these able to FAIL. Every one compiles REAL assemblies with Roslyn and -/// runs the REAL gate. The landing tests compile a module against a stand-in -/// MeshWeaver.Mesh.Contract that carries a type the platform running this test does NOT -/// have — the memex-cloud shape verbatim, same assembly simple name, missing type — and drive the -/// REAL . Neutralising the gate (making -/// ModuleLinkVerdict.MayLoad true for every state) flips -/// , -/// and -/// . Removing the positive -/// controls' counterpart — and -/// — is what stops the gate from passing -/// by refusing everything, or by checking nothing. -/// -public class ModulePlatformLinkTest : IDisposable -{ - /// The platform assembly the stand-in impersonates: a REAL one this process has - /// loaded, so the test measures against the copy a module would actually bind to. - private const string ContractAssembly = "MeshWeaver.Mesh.Contract"; - - /// A type no build of has ever carried — the stand-in - /// for CodeOutputCurrency as it looked from a platform three days behind. - private const string FutureType = "MeshWeaver.Mesh.CodeOutputCurrencyFromTheFuture"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-linkprobe-" + Guid.NewGuid().ToString("N")); - - private readonly ModuleLandingService landing; - - /// Creates the per-test deployment root and the REAL landing service over it. - public ModulePlatformLinkTest() - { - Directory.CreateDirectory(root); - landing = new ModuleLandingService(baseDirectory: root); - } - - /// - public void Dispose() - { - landing.Dispose(); - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - GC.SuppressFinalize(this); - } - - // ───────────────────────────────────────────────────── the floor is the BUILD platform - - /// - /// 🚨 THE repro of #3538, at the landing. A module compiled against a - /// MeshWeaver.Mesh.Contract that has a type this platform's copy does not is REFUSED, - /// and the refusal NAMES the type — the sentence that was missing from the memex-cloud - /// incident, where the only evidence was a TypeLoadException per render. - /// - /// The declared floor is deliberately left absent, so the ONLY thing that can refuse - /// this is the measured one. Before the fix, an absent floor was "no constraint" and these - /// bytes landed. - /// - [Fact] - public async Task AModuleBuiltAgainstANewerPlatform_IsRefusedAtLanding_NamingTheMissingType() - { - var module = ModuleBuiltAgainstAFuturePlatform("MeshWeaver.Test.FutureViewPack"); - - var refusal = await Assert.ThrowsAsync(async () => - await landing.LandModule( - "MeshWeaver.Test.FutureViewPack", - [("MeshWeaver.Test.FutureViewPack.dll", module)], - minMeshVersion: null) - .Timeout(TestTimeouts.Convergence).Await()); - - Assert.Contains(FutureType, refusal.Message, StringComparison.Ordinal); - Assert.Contains(ContractAssembly, refusal.Message, StringComparison.Ordinal); - // 🚨 And nothing landed: the previous generation is what keeps serving. A refusal that - // still wrote the bytes would leave the next boot to load them. - Assert.Empty(ModuleActivationSidecar.Read(root).Entries); - } - - /// - /// The positive control, and the reason the gate above is a gate rather than a wall: a module - /// compiled against THIS platform's real contract lands exactly as it always did. - /// - [Fact] - public async Task AModuleBuiltAgainstThisPlatform_LandsAsBefore() - { - var module = ModuleBuiltAgainstThisPlatform("MeshWeaver.Test.CurrentViewPack"); - - await landing.LandModule( - "MeshWeaver.Test.CurrentViewPack", - [("MeshWeaver.Test.CurrentViewPack.dll", module)]) - .Timeout(TestTimeouts.Convergence).Await(); - - var entry = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.Equal("MeshWeaver.Test.CurrentViewPack", entry.Name); - Assert.True(entry.Enabled); - } - - /// - /// 🚨 The SHELF path is unchanged in kind: a registry stocking a module for OTHER platforms - /// must still carry bytes IT cannot load. They land and serve; this process's boot refuses - /// them, which is exactly what the floor already did (2026-08-22's three-way deadlock). - /// - [Fact] - public async Task AnUnloadableModule_IsShelvedRatherThanRefused_OnThePublishPath() - { - var module = ModuleBuiltAgainstAFuturePlatform("MeshWeaver.Test.ShelvedViewPack"); - - var outcome = await landing.ShelveModule( - "MeshWeaver.Test.ShelvedViewPack", - [("MeshWeaver.Test.ShelvedViewPack.dll", module)]) - .Timeout(TestTimeouts.Convergence).Await(); - - Assert.True(outcome.Held); - Assert.Contains(FutureType, outcome.HoldReason!, StringComparison.Ordinal); - // The bytes ARE on the shelf — consumers fetch them and apply this same measurement - // against THEIR platform. - Assert.Single(ModuleActivationSidecar.Read(root).Entries); - } - - /// - /// 🚨 #3996 — an older shelf-only upload is retained only when it is a useful fallback. - /// A numerically newer fallback that this platform cannot load must not displace the working - /// one: fallback ordering is loadability first, version second, just like activation itself. - /// - [Fact] - public async Task AnOlderUnloadablePublish_DoesNotReplaceTheWorkingFallback() - { - const string name = "MeshWeaver.Test.OrderedShelf"; - var oldLoadable = ModuleBuiltAgainstThisPlatform(name); - var newestLoadable = ModuleBuiltAgainstThisPlatform(name); - var middleUnloadable = ModuleBuiltAgainstAFuturePlatform(name); - - await landing.ShelveModule( - name, [(name + ".dll", oldLoadable)], version: "1.5.0") - .Timeout(TestTimeouts.Convergence).Await(); - await landing.ShelveModule( - name, [(name + ".dll", newestLoadable)], version: "1.7.0") - .Timeout(TestTimeouts.Convergence).Await(); - var outcome = await landing.ShelveModule( - name, [(name + ".dll", middleUnloadable)], version: "1.6.0") - .Timeout(TestTimeouts.Convergence).Await(); - - Assert.True(outcome.ShelfOnly); - Assert.True(outcome.Held); - Assert.False(outcome.RetainedAsFallback, - "a working fallback is never displaced by bytes this platform measured as unloadable"); - var head = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.Equal("1.7.0", head.Version); - Assert.Equal("1.5.0", head.PreviousVersion); - var fallback = ModuleActivationBoot.PreviousGeneration(head); - Assert.NotNull(fallback); - Assert.True(ModulePlatformLink.Check( - ModuleActivationBoot.LandedDllPath(root, fallback), - ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory)).MayLoad); - } - - /// - /// 🚨 #3996 — a NEWER head this registry cannot load ITSELF is still the head. The shelf carries - /// modules for platforms newer than the registry serving them, and boot runs the fallback when - /// the head does not link here (#3649, rule R1), so an older upload that DOES link here takes the - /// FALLBACK slot, never the head. Were loadability-here a condition on the head, 1.6.1 would - /// become the head, and the first restart after this registry's own platform caught up would load - /// 1.6.1 over 1.7.0 — #3996 by another road; with a loadable fallback already recorded, the newer - /// generation would not even be kept. - /// - [Fact] - public async Task ANewerHeadThatDoesNotLinkHere_StaysTheHead_AndTheOlderLoadablePublishBecomesItsFallback() - { - const string name = "MeshWeaver.Test.WarehousedHead"; - var first = await landing.ShelveModule( - name, [(name + ".dll", ModuleBuiltAgainstAFuturePlatform(name))], version: "1.7.0") - .Timeout(TestTimeouts.Convergence).Await(); - Assert.True(first.Held, - "precondition: the 1.7.0 head does NOT link on this platform — without it this proves nothing"); - - var outcome = await landing.ShelveModule( - name, [(name + ".dll", ModuleBuiltAgainstThisPlatform(name))], version: "1.6.1") - .Timeout(TestTimeouts.Convergence).Await(); - - Assert.False(outcome.Held, "precondition: 1.6.1 links here"); - Assert.True(outcome.ShelfOnly, - "a newer head that does not link HERE is still the newest landed generation on this shelf"); - Assert.Equal("1.7.0", outcome.HeadVersion); - Assert.True(outcome.RetainedAsFallback); - Assert.True(outcome.RestartRequired, - "the fallback moved while the head does not load here, so boot runs 1.6.1 at the next restart"); - - var list = ModuleActivationSidecar.Read(root); - var head = Assert.Single(list.Entries); - Assert.Equal("1.7.0", head.Version); - Assert.Equal("1.6.1", head.PreviousVersion); - Assert.True(list.PendingRestart); - } - - /// - /// A version that is not SemVer at all is UNKNOWN, not "older". NuGetVersionComparer reads - /// an unparseable part as 0, so without the check a "nightly" label would rank below every real - /// version and be shelved for good — a string deciding what the bytes should (rule R2). - /// - [Fact] - public async Task ANonSemVerVersion_IsUnknown_AndMovesTheHeadAsBefore() - { - const string name = "MeshWeaver.Test.NightlyLabel"; - Assert.True(MeshWeaver.Plugin.Packaging.NuGetVersionComparer.Instance.Compare("nightly", "1.7.0") < 0, - "precondition: the comparer ranks a non-SemVer label below every real version"); - await landing.ShelveModule( - name, [(name + ".dll", ModuleBuiltAgainstThisPlatform(name))], version: "1.7.0") - .Timeout(TestTimeouts.Convergence).Await(); - - var outcome = await landing.ShelveModule( - name, [(name + ".dll", ModuleBuiltAgainstThisPlatform(name))], version: "nightly") - .Timeout(TestTimeouts.Convergence).Await(); - - Assert.False(outcome.ShelfOnly, - "a non-SemVer label is no evidence of order — it moves the head as an unversioned upload does"); - Assert.Equal("nightly", Assert.Single(ModuleActivationSidecar.Read(root).Entries).Version); - } - - /// - /// A head that LINKS but that the boot already failed to load is running its fallback, which a - /// static link probe cannot see — only the boot's unloadable marker says so. A shelf-only upload - /// that moves that fallback therefore changes what the next restart loads, and must say so. - /// - [Fact] - public async Task AShelfOnlyUploadMovingTheFallback_UnderAHeadTheBootFailedToLoad_RequiresARestart() - { - const string name = "MeshWeaver.Test.BootFailedHead"; - await landing.ShelveModule( - name, [(name + ".dll", ModuleBuiltAgainstThisPlatform(name))], version: "1.7.0") - .Timeout(TestTimeouts.Convergence).Await(); - var head = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - ModuleActivationSidecar.SetUnloadable( - root, name, head.Directory!, "boot-measured", "the boot failed to load it"); - Assert.NotNull(Assert.Single(ModuleActivationSidecar.Read(root).Entries).UnloadableFrameworkMvid); - - var outcome = await landing.ShelveModule( - name, [(name + ".dll", ModuleBuiltAgainstThisPlatform(name))], version: "1.6.1") - .Timeout(TestTimeouts.Convergence).Await(); - - Assert.True(outcome.ShelfOnly); - Assert.True(outcome.RetainedAsFallback); - Assert.True(outcome.RestartRequired, - "the boot already failed to load the 1.7.0 head, so this process runs its fallback — and " - + "the fallback just moved to 1.6.1, which the next restart loads"); - } - - /// - /// 🚨 The third state, and it fails CLOSED. Bytes that are not a readable managed - /// assembly answer — "I could not determine - /// whether this loads" — and that is NEVER folded into "it loads". A gate whose unknown reads - /// as a pass is a gate that cannot fail. - /// - [Fact] - public async Task UnreadableBytes_AreRefused_NotWavedThroughAsUnknown() - { - var verdict = ModulePlatformLink.Check( - [0x4D, 0x5A, 0x90], "MeshWeaver.Test.Garbage", - new HashSet(StringComparer.OrdinalIgnoreCase) { "MeshWeaver.Test.Garbage" }, - ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory)); - - Assert.Equal(ModuleLinkState.Indeterminate, verdict.State); - Assert.False(verdict.MayLoad); - - var refusal = await Assert.ThrowsAsync(async () => - await landing.LandModule( - "MeshWeaver.Test.Garbage", [("MeshWeaver.Test.Garbage.dll", [0x4D, 0x5A, 0x90])]) - .Timeout(TestTimeouts.Convergence).Await()); - Assert.Contains("could NOT be determined", refusal.Message, StringComparison.Ordinal); - } - - /// - /// 🚨 A module referencing a SIBLING MODULE is not the defect, and must not be refused as - /// one. A wave lands its modules one at a time, and the sibling this one needs may have - /// landed thirty seconds ago and not be loaded here at all (restart-as-activation). Measuring - /// against the application closure alone would find no MeshWeaver.Test.SiblingLib - /// anywhere, hit the platform-prefix rule, and refuse a module that is perfectly fine — a - /// false refusal, which on a real deployment means a feature silently missing after an - /// upgrade. - /// - /// The surface therefore carries the ACTIVE generation of every landed module, rebuilt - /// per landing. Pinning the sibling's landing FIRST is what makes this able to fail: measure - /// against /app only, and the second landing throws. - /// - [Fact] - public async Task AModuleReferencingASiblingModuleLandedMomentsEarlier_IsNotRefused() - { - var sibling = Emit("MeshWeaver.Test.SiblingLib", """ - namespace MeshWeaver.Test; - public static class SiblingApi { public static int Answer => 42; } - """); - await landing.LandModule( - "MeshWeaver.Test.SiblingLib", [("MeshWeaver.Test.SiblingLib.dll", sibling)]) - .Timeout(TestTimeouts.Convergence).Await(); - - // Compiled against the sibling — and the sibling is NOT in the application closure and is - // NOT loaded in this process. Only the landed generation can answer for it. - var dependent = Emit("MeshWeaver.Test.DependentPack", """ - using MeshWeaver.Test; - public static class DependentViews - { - public static int Show() => SiblingApi.Answer; - } - """, extra: MetadataReference.CreateFromImage(sibling)); - - await landing.LandModule( - "MeshWeaver.Test.DependentPack", [("MeshWeaver.Test.DependentPack.dll", dependent)]) - .Timeout(TestTimeouts.Convergence).Await(); - - var landedNames = ModuleActivationSidecar.Read(root).Entries.Select(e => e.Name).ToArray(); - Assert.Contains("MeshWeaver.Test.SiblingLib", landedNames); - Assert.Contains("MeshWeaver.Test.DependentPack", landedNames); - } - - // ───────────────────────────────────────────────────── the probe itself, and its denominator - - /// - /// 🚨 The denominator, printed. A clean verdict over ZERO checked references is - /// indistinguishable from a check that never ran — the sweep-with-no-denominator shape this - /// codebase keeps meeting. A linkable module must therefore report that it actually resolved - /// references against real platform assemblies. - /// - [Fact] - public void ALinkableModule_ReportsANonZeroDenominator() - { - var path = Write("MeshWeaver.Test.CountedViewPack", - ModuleBuiltAgainstThisPlatform("MeshWeaver.Test.CountedViewPack")); - - var verdict = ModulePlatformLink.Check( - path, ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory)); - - Assert.Equal(ModuleLinkState.Linkable, verdict.State); - Assert.True(verdict.CheckedTypeReferences > 0, - "a Linkable verdict over zero checked references is a check that never ran: " - + verdict.Report()); - Assert.Contains(ContractAssembly, verdict.CheckedAssemblies); - } - - /// - /// The coarse-grained half of the same defect: a module referencing a whole PLATFORM assembly - /// this deployment does not carry. Refused, because that is a certain load failure — while a - /// non-platform assembly it does not carry is a private dependency, NAMED as unchecked rather - /// than guessed about. - /// - [Fact] - public void AWholePlatformAssemblyThisDeploymentLacks_IsRefused() - { - var path = Write("MeshWeaver.Test.OrphanViewPack", - ModuleBuiltAgainstAFuturePlatform("MeshWeaver.Test.OrphanViewPack")); - - // A surface with NO probe directory and NO loaded assemblies carries nothing at all. - var verdict = ModulePlatformLink.Check( - path, ModulePlatformSurface.Of(Array.Empty())); - - Assert.Equal(ModuleLinkState.Unlinkable, verdict.State); - Assert.Contains(verdict.MissingTypes, - m => m.Contains("no such platform assembly", StringComparison.Ordinal)); - // System.* is not the platform's, so it is reported as UNCHECKED rather than missing. - Assert.Contains(verdict.UncheckedAssemblies, - a => a.StartsWith("System.", StringComparison.Ordinal)); - } - - // ───────────────────────────────────────────────────── the generation is parked, not the portal - - /// - /// 🚨 THE repro of #3518's blast radius. An unloadable module costs THAT module and - /// nothing else: it is never loaded, it is recorded as - /// naming the type it wanted, the module beside it installs - /// normally, and InstallAssemblies does not throw. That is the difference between "one - /// view pack is absent" and "every render on the portal throws". - /// - [Fact] - public void AnUnloadableModule_IsParked_AndTheOthersStillInstall() - { - var bad = Write("MeshWeaver.Test.ParkedViewPack", - ModuleBuiltAgainstAFuturePlatform("MeshWeaver.Test.ParkedViewPack")); - // A real, loadable assembly standing in for the module that must keep working. - var goodName = typeof(MeshWeaver.Plugin.Packaging.BundleReader).Assembly.GetName().Name!; - var good = Write(goodName, - File.ReadAllBytes(typeof(MeshWeaver.Plugin.Packaging.BundleReader).Assembly.Location)); - - var services = new ServiceCollection(); - var builder = new MeshBuilder(configure => configure(services), new Address("mesh", "test")); - - // Does not throw: a module that cannot load is a reason to lose THAT module. - builder.InstallAssemblies(bad, good); - - var provider = services.BuildServiceProvider(); - var parked = Assert.Single(provider.GetServices()); - Assert.Equal("MeshWeaver.Test.ParkedViewPack", parked.Name); - Assert.Contains(FutureType, parked.Report(), StringComparison.Ordinal); - Assert.Contains("CONTRIBUTING NOTHING", parked.Report(), StringComparison.Ordinal); - - Assert.Contains(provider.GetServices(), - m => string.Equals(m.Assembly.GetName().Name, goodName, StringComparison.Ordinal)); - } - - /// - /// A parked module reads as QUARANTINED on every surface, never as PENDING. "Restart required" - /// would be a promise no restart can keep — a restart re-runs the same measurement on the same - /// bytes and refuses again — which is the same false-prompt rule a held entry and a missing - /// DLL already follow. - /// - [Fact] - public async Task AParkedModule_ReadsAsQuarantined_NeverAsRestartRequired() - { - await landing.LandModule( - "MeshWeaver.Test.CurrentViewPack", - [("MeshWeaver.Test.CurrentViewPack.dll", - ModuleBuiltAgainstThisPlatform("MeshWeaver.Test.CurrentViewPack"))]) - .Timeout(TestTimeouts.Convergence).Await(); - - var report = new PendingModuleActivations(root) - { - QuarantinedModules = ImmutableHashSet.Create( - StringComparer.OrdinalIgnoreCase, "MeshWeaver.Test.CurrentViewPack"), - }.Read( - loadedAssemblyNames: new HashSet(StringComparer.OrdinalIgnoreCase), - loadedModuleGenerations: ImmutableDictionary.Empty); - - Assert.False(report.HasPending); - Assert.True(report.HasQuarantined); - Assert.Equal("MeshWeaver.Test.CurrentViewPack", Assert.Single(report.Quarantined).Name); - Assert.Contains("REFUSED against this platform build", report.Describe(), - StringComparison.Ordinal); - // A blank package path matches nothing — never a wildcard that would put another - // package's quarantine on this card. - Assert.False(report.IsQuarantinedForPackage(null)); - } - - /// - /// 🚨 A REQUIRED module refused because it needs another platform must NOT stall a rollout. - /// That is the declared floor's rule, and it exists because no rollout of this deployment can - /// conjure the platform the module was built for — in the rollback direction, stalling would - /// hold the very roll that resolves it (the 2026-08-22 three-way deadlock, one lane over). - /// - /// An IMAGE-shipped module that cannot link stays Incompatible and DOES stall: - /// that is a build defect the previous generation does not share. - /// - [Fact] - public void ARequiredStoreModuleRefusedForItsPlatform_DoesNotStallARollout() - { - var refused = IncompatibleModule.FromLinkRefusal( - "/data/modules/MeshWeaver.Test.ParkedViewPack@1/MeshWeaver.Test.ParkedViewPack.dll", - ModulePlatformLink.Check( - ModuleBuiltAgainstAFuturePlatform("MeshWeaver.Test.ParkedViewPack"), - "MeshWeaver.Test.ParkedViewPack", - new HashSet(StringComparer.OrdinalIgnoreCase) - { "MeshWeaver.Test.ParkedViewPack" }, - ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory))); - - Assert.True(refused.RefusedBeforeLoad); - - // 🚨 The entries carry '.dll'. `Classify` derives the module name with - // Path.GetFileNameWithoutExtension, which on a suffix-less DOTTED assembly name strips the - // last segment ("MeshWeaver.Test.ParkedViewPack" → "MeshWeaver.Test") — so a suffix-less - // entry matches no module and every verdict here would be ExpectedLater by accident. The - // Name assertions below are what make that failure visible instead of green. - var storeDelivered = RequiredModuleStatus.Classify( - requiredEntries: ["MeshWeaver.Test.ParkedViewPack.dll"], - baselineEntries: [], - loadedAssemblyNames: new HashSet(StringComparer.OrdinalIgnoreCase), - resolvesFromDeployment: _ => false, - activation: null, - landedDllExists: _ => false, - platformGate: _ => null, - incompatibleModules: [refused]); - Assert.Empty(RequiredModuleStatus.Incompatible(storeDelivered)); - var expected = Assert.Single(RequiredModuleStatus.ExpectedLater(storeDelivered)); - Assert.Equal("MeshWeaver.Test.ParkedViewPack", expected.Name); - // And it says WHY — the link refusal, not a generic "not installed". - Assert.Contains(FutureType, expected.Reason, StringComparison.Ordinal); - - // The image's OWN pack, unlinkable against the image it shipped in, still stalls. - var imageShipped = RequiredModuleStatus.Classify( - requiredEntries: ["MeshWeaver.Test.ParkedViewPack.dll"], - baselineEntries: ["MeshWeaver.Test.ParkedViewPack.dll"], - loadedAssemblyNames: new HashSet(StringComparer.OrdinalIgnoreCase), - resolvesFromDeployment: _ => false, - activation: null, - landedDllExists: _ => false, - platformGate: _ => null, - incompatibleModules: [refused]); - var stalls = Assert.Single(RequiredModuleStatus.Incompatible(imageShipped)); - Assert.Equal("MeshWeaver.Test.ParkedViewPack", stalls.Name); - } - - /// - /// 🚨 #3649 — a REQUIRED module running its PREVIOUS generation is Present. The loader - /// registers a for it, never an : - /// its assembly is loaded and its features work, so the readiness probe stays Healthy and a - /// rollout is not stalled on a module that is merely one version behind. - /// - [Fact] - public void ARequiredModuleRunningItsPreviousGeneration_IsPresent_NeverIncompatible() - { - var fallback = new FallbackModule( - "MeshWeaver.Test.FallbackPack", - "/data/modules/MeshWeaver.Test.FallbackPack@b/MeshWeaver.Test.FallbackPack.dll", - "/data/modules/MeshWeaver.Test.FallbackPack@a/MeshWeaver.Test.FallbackPack.dll", - "it references " + FutureType) - { - Version = "1.3.0", - PreviousVersion = "1.2.3", - }; - Assert.Equal("MeshWeaver.Test.FallbackPack@b", fallback.Generation); - Assert.Equal("MeshWeaver.Test.FallbackPack@a", fallback.PreviousGeneration); - Assert.StartsWith( - "runs v1.2.3 (MeshWeaver.Test.FallbackPack@a); v1.3.0 (MeshWeaver.Test.FallbackPack@b) " - + "landed but does not load here:", fallback.Describe(), StringComparison.Ordinal); - - // What the loader hands the probe for such a module: the name IS loaded, and the - // incompatible set does NOT carry it. - var verdicts = RequiredModuleStatus.Classify( - requiredEntries: ["MeshWeaver.Test.FallbackPack.dll"], - baselineEntries: [], - loadedAssemblyNames: new HashSet(StringComparer.OrdinalIgnoreCase) - { "MeshWeaver.Test.FallbackPack" }, - resolvesFromDeployment: _ => false, - activation: null, - landedDllExists: _ => true, - platformGate: _ => null, - incompatibleModules: []); - - var verdict = Assert.Single(verdicts); - Assert.Equal("MeshWeaver.Test.FallbackPack", verdict.Name); - Assert.Equal(RequiredModuleState.Present, verdict.State); - Assert.Empty(RequiredModuleStatus.Incompatible(verdicts)); - Assert.Empty(RequiredModuleStatus.ExpectedLater(verdicts)); - } - - // ───────────────────────────────────────────────────────────── harness - - /// Different assembly versions and additive APIs do not remove a referenced type. - /// Exercise the real metadata probe with separately compiled contracts. 🚨 ONE direction since - /// #4083: a module built against a LOWER version than the platform runs rolls forward (the - /// skew is an advisory); built against a HIGHER one, the loader refuses the bind whatever the - /// API looks like — ModuleLinkVersionTest measures that on the loader itself, and - /// below is - /// the arm this theory used to assert as Linkable. - [Theory] - [InlineData("1.0.0.0", "9.0.0.0")] - [InlineData("1.0.0.0", "1.0.0.0")] - public void CompatibleApiAcrossPlatformVersions_IsLinkable(string builtVersion, string runningVersion) - { - const string contractName = "MeshWeaver.Test.VersionedContract"; - const string moduleName = "MeshWeaver.Test.VersionTolerantPack"; - var builtContract = Emit(contractName, $$""" - [assembly: System.Reflection.AssemblyVersion("{{builtVersion}}")] - namespace MeshWeaver.Test; - public class StableApi { public int Answer() => 1; } - """); - var module = Emit(moduleName, """ - public class View { - public int Render(MeshWeaver.Test.StableApi api) => api.Answer(); - } - """, extra: MetadataReference.CreateFromImage(builtContract)); - var runningContract = Emit(contractName, $$""" - [assembly: System.Reflection.AssemblyVersion("{{runningVersion}}")] - namespace MeshWeaver.Test; - public class StableApi { public int Answer() => 42; public string Extra() => "new"; } - public class UnusedAddition { } - """); - - var surface = ModulePlatformSurface.OfFiles([Write(contractName, runningContract)]); - var verdict = ModulePlatformLink.Check(module, moduleName, new HashSet { moduleName }, surface); - - Assert.Equal(ModuleLinkState.Linkable, verdict.State); - Assert.True(verdict.MayLoad, verdict.Report()); - Assert.True(verdict.CheckedTypeReferences > 0); - Assert.Contains(contractName, verdict.CheckedAssemblies); - Assert.Empty(verdict.MissingTypes); - } - - /// - /// 🚨 #4083 narrows the 2026-09-09 rule: an API-compatible platform copy at a LOWER manifest - /// version than the module was bound to is not something the loader will bind — every type is - /// present, and the bind is still FileLoadException. The verdict names the assembly and - /// both versions; MayLoad is false. - /// - [Fact] - public void AModuleBuiltAgainstAHigherPlatformVersion_CannotBind_WhateverTheApi() - { - const string contractName = "MeshWeaver.Test.VersionedContract"; - const string moduleName = "MeshWeaver.Test.VersionAheadPack"; - var builtContract = Emit(contractName, """ - [assembly: System.Reflection.AssemblyVersion("9.0.0.0")] - namespace MeshWeaver.Test; - public class StableApi { public int Answer() => 1; } - """); - var module = Emit(moduleName, """ - public class View { - public int Render(MeshWeaver.Test.StableApi api) => api.Answer(); - } - """, extra: MetadataReference.CreateFromImage(builtContract)); - var runningContract = Emit(contractName, """ - [assembly: System.Reflection.AssemblyVersion("1.0.0.0")] - namespace MeshWeaver.Test; - public class StableApi { public int Answer() => 42; public string Extra() => "new"; } - """); - - var verdict = ModulePlatformLink.Check(module, moduleName, new HashSet { moduleName }, - ModulePlatformSurface.OfFiles([Write(contractName, runningContract)])); - - Assert.Equal(ModuleLinkState.BindingConflict, verdict.State); - Assert.False(verdict.MayLoad); - Assert.Empty(verdict.MissingTypes); - var conflict = Assert.Single(verdict.BindingConflicts); - Assert.Contains(contractName + " 9.0.0.0", conflict, StringComparison.Ordinal); - Assert.Contains("1.0.0.0", conflict, StringComparison.Ordinal); - } - - /// The same version can contain incompatible bytes. Version equality must never - /// replace the type measurement, even when an unrelated type remains in the assembly. - [Fact] - public void IdenticalPlatformVersionWithRemovedApi_IsUnlinkable() - { - const string contractName = "MeshWeaver.Test.SameVersionContract"; - const string moduleName = "MeshWeaver.Test.RemovedApiPack"; - var builtContract = Emit(contractName, """ - [assembly: System.Reflection.AssemblyVersion("1.0.0.0")] - namespace MeshWeaver.Test; - public class RequiredApi { } - """); - var module = Emit(moduleName, """ - public class View { public MeshWeaver.Test.RequiredApi Render() => new(); } - """, extra: MetadataReference.CreateFromImage(builtContract)); - var runningContract = Emit(contractName, """ - [assembly: System.Reflection.AssemblyVersion("1.0.0.0")] - namespace MeshWeaver.Test; - public class UnrelatedApi { } - """); - - var verdict = ModulePlatformLink.Check(module, moduleName, new HashSet { moduleName }, - ModulePlatformSurface.OfFiles([Write(contractName, runningContract)])); - - Assert.Equal(ModuleLinkState.Unlinkable, verdict.State); - Assert.False(verdict.MayLoad); - Assert.Contains(verdict.MissingTypes, missing => missing.Contains("MeshWeaver.Test.RequiredApi", StringComparison.Ordinal)); - Assert.Contains(contractName, verdict.Report(), StringComparison.Ordinal); - } - - /// Exercise real method binding as well as type metadata. A changed method body or - /// added overload keeps the used signature working; removing/changing it fails when invoked. - /// The type probe alone deliberately makes no member-compatibility claim. - [Theory] - [InlineData("public int Answer() => 42; public int Answer(int extra) => extra;", true)] - [InlineData("public long Answer() => 42;", false)] - [InlineData("public int Answer(int extra) => extra;", false)] - public void MemberCompatibility_IsMeasuredByBindingTheUsedSignature(string runtimeMember, bool compatible) - { - const string contractName = "MeshWeaver.Test.MemberContract"; - const string moduleName = "MeshWeaver.Test.MemberConsumer"; - var builtContract = Emit(contractName, - "namespace MeshWeaver.Test; public class Api { public int Answer() => 1; }"); - var consumer = Emit(moduleName, - "public static class View { public static int Render() => new MeshWeaver.Test.Api().Answer(); }", - extra: MetadataReference.CreateFromImage(builtContract)); - var runtimeContract = Emit(contractName, - "namespace MeshWeaver.Test; public class Api { " + runtimeMember + " }"); - - var typeVerdict = ModulePlatformLink.Check(consumer, moduleName, new HashSet { moduleName }, - ModulePlatformSurface.OfFiles([Write(contractName, runtimeContract)])); - Assert.Equal(ModuleLinkState.Linkable, typeVerdict.State); - - var context = new AssemblyLoadContext("member-contract-" + Guid.NewGuid().ToString("N"), isCollectible: true); - try - { - using var runtime = new MemoryStream(runtimeContract); - context.LoadFromStream(runtime); - using var module = new MemoryStream(consumer); - var render = context.LoadFromStream(module).GetType("View")!.GetMethod("Render")!; - if (compatible) - Assert.Equal(42, render.Invoke(null, null)); - else - { - var error = Assert.Throws(() => render.Invoke(null, null)); - var missing = Assert.IsType(error.InnerException); - Assert.Contains("Answer", missing.Message, StringComparison.Ordinal); - } - } - finally { context.Unload(); } - } - - /// An incompatible incoming generation must not replace the working activation. - /// Run both real landings and verify the previous bytes and activation pointer survive. - [Fact] - public async Task MissingApiInUpgrade_PreservesWorkingModuleAndItsBytes() - { - const string name = "MeshWeaver.Test.ContinuityPack"; - var good = ModuleBuiltAgainstThisPlatform(name); - await landing.LandModule(name, [(name + ".dll", good)], version: "1.0.0") - .Timeout(TestTimeouts.Convergence).Await(); - var before = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - var goodPath = Path.Combine(ModuleLandingService.ModuleDirectoryFor(root, name, before), name + ".dll"); - - var refusal = await Assert.ThrowsAsync(async () => - await landing.LandModule(name, [(name + ".dll", ModuleBuiltAgainstAFuturePlatform(name))], - version: "2.0.0", minMeshVersion: "0.0.1") - .Timeout(TestTimeouts.Convergence).Await()); - - Assert.Contains(FutureType, refusal.Message, StringComparison.Ordinal); - var after = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.Equal(before.Directory, after.Directory); - Assert.Equal(before.Version, after.Version); - Assert.True(after.Enabled); - Assert.Equal(good, File.ReadAllBytes(goodPath)); - } - - // ──────────────────────────────── the member half at the runtime sites (MeshWeaver#6007) - - /// A member no build of has ever carried, on a type every - /// build carries ( itself) — the shape of #6007: OpenAI 1.4.0 - /// called ReasoningEffortLevels.IsBuiltIn(IEnumerable<string>), the type was there and - /// the method was not. - private const string FutureMember = "MemberFromTheFuture"; - - /// - /// 🚨 THE repro of #6007, at the landing. A module whose bytes call a MEMBER this - /// process's copy of the platform lacks — on a type it does have — is REFUSED, naming the member, - /// and nothing lands. Before, the landing measured types only: these bytes landed, loaded, and - /// threw MissingMethodException at the first call, which on the OpenAI wire was every chat - /// round. In-test negative control: the type-only verdict over the same bytes and the same - /// surface admits them. - /// - [Fact] - public async Task AModuleCallingAMemberThisPlatformLacks_IsRefusedAtLanding_NamingTheMember() - { - const string name = "MeshWeaver.Test.FutureMemberWire"; - var module = ModuleCallingAFutureMember(name); - - var typeOnly = ModulePlatformLink.Check(module, name, new HashSet { name }, - ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory)); - Assert.True(typeOnly.MayLoad, "negative control: the type half alone admits these bytes — " + typeOnly.Report()); - - var refusal = await Assert.ThrowsAsync(async () => - await landing.LandModule(name, [(name + ".dll", module)], minMeshVersion: null) - .Timeout(TestTimeouts.Convergence).Await()); - - Assert.Contains("ModulePlatformLink::" + FutureMember, refusal.Message, StringComparison.Ordinal); - Assert.Contains(ContractAssembly, refusal.Message, StringComparison.Ordinal); - Assert.Empty(ModuleActivationSidecar.Read(root).Entries); - } - - /// - /// 🚨 The same at BOOT — the copy an instance already had when the floor that admitted it - /// turned out wrong (the customer instance on 3.0.0-ci.9606 kept OpenAI 1.4.0). The module is - /// parked as naming the member, the module beside it installs, - /// and InstallAssemblies does not throw. - /// - [Fact] - public void AModuleCallingAMemberThisPlatformLacks_IsParkedAtBoot() - { - var bad = Write("MeshWeaver.Test.FutureMemberPack", ModuleCallingAFutureMember("MeshWeaver.Test.FutureMemberPack")); - var goodName = typeof(MeshWeaver.Plugin.Packaging.BundleReader).Assembly.GetName().Name!; - var good = Write(goodName, - File.ReadAllBytes(typeof(MeshWeaver.Plugin.Packaging.BundleReader).Assembly.Location)); - - var services = new ServiceCollection(); - var builder = new MeshBuilder(configure => configure(services), new Address("mesh", "test")); - builder.InstallAssemblies(bad, good); - - var provider = services.BuildServiceProvider(); - var parked = Assert.Single(provider.GetServices()); - Assert.Equal("MeshWeaver.Test.FutureMemberPack", parked.Name); - Assert.Contains("ModulePlatformLink::" + FutureMember, parked.Report(), StringComparison.Ordinal); - Assert.Contains(provider.GetServices(), - m => string.Equals(m.Assembly.GetName().Name, goodName, StringComparison.Ordinal)); - } - - /// A module compiled against a stand-in whose - /// ModulePlatformLink carries : the type exists on this - /// platform, the member does not. - private static byte[] ModuleCallingAFutureMember(string moduleName) - { - var future = Emit(ContractAssembly, $$""" - namespace MeshWeaver.Mesh; - public static class ModulePlatformLink { public static int {{FutureMember}}(System.Collections.Generic.IEnumerable levels) => 1; } - """, excludeReference: ContractAssembly); - return Emit(moduleName, $$""" - public static class Wire - { - public static int Rewrite() => MeshWeaver.Mesh.ModulePlatformLink.{{FutureMember}}(new[] { "high" }); - } - """, - excludeReference: ContractAssembly, - extra: MetadataReference.CreateFromImage(future)); - } - - /// - /// A module compiled against a STAND-IN MeshWeaver.Mesh.Contract that carries - /// — the assembly a platform three days ahead would have shipped. The - /// bytes therefore reference a type by the same assembly simple name this process has loaded, - /// and that copy does not have it: memex-cloud's exact shape, and one no version comparison - /// can see. - /// - private static byte[] ModuleBuiltAgainstAFuturePlatform(string moduleName) - { - var future = Emit(ContractAssembly, """ - namespace MeshWeaver.Mesh; - /// The type a newer platform has and this one does not. - public enum CodeOutputCurrencyFromTheFuture { Unknown, Current, Stale } - """, excludeReference: ContractAssembly); - - return Emit(moduleName, """ - using MeshWeaver.Mesh; - public static class CodeViews - { - public static string BuildContent(CodeOutputCurrencyFromTheFuture currency) - => currency.ToString(); - } - """, - excludeReference: ContractAssembly, - extra: MetadataReference.CreateFromImage(future)); - } - - /// A module compiled against the REAL contract this process is running, using a type - /// it genuinely has — the control that proves the gate does not simply refuse everything. - private static byte[] ModuleBuiltAgainstThisPlatform(string moduleName) => - Emit(moduleName, """ - using MeshWeaver.Mesh; - public static class CodeViews - { - public static string BuildContent(MeshNode node) => node.Id; - } - """); - - /// Compiles one source file into an assembly named , - /// against this process's own reference set. - /// The emitted assembly's simple name. - /// The C# to compile. - /// A reference assembly to LEAVE OUT — how a stand-in of the - /// same simple name is substituted for the platform's own. - /// References to add, e.g. the stand-in. - private static byte[] Emit( - string assemblyName, string source, string? excludeReference = null, - MetadataReference? extra = null) - { - // The process-wide platform set, read once — see PlatformReferences for why (#4127). - var platform = PlatformReferences.Platform(excludeReference); - if (extra is not null) - platform = platform.Add(extra); - - var compilation = CSharpCompilation.Create( - assemblyName, - [CSharpSyntaxTree.ParseText(source)], - platform, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - return StandInCompile.Emit(compilation); - } - - /// Writes an assembly into its OWN directory under the test root, so its "siblings" - /// are exactly its own closure — the shape a landed generation directory has. - private string Write(string assemblyName, byte[] bytes) - { - var directory = Path.Combine(root, "emitted", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - var path = Path.Combine(directory, assemblyName + ".dll"); - File.WriteAllBytes(path, bytes); - return path; - } -} diff --git a/test/Memex.Portal.Shared.Test/ModulePlatformMemberLinkTest.cs b/test/Memex.Portal.Shared.Test/ModulePlatformMemberLinkTest.cs deleted file mode 100644 index 0c6699f6dc..0000000000 --- a/test/Memex.Portal.Shared.Test/ModulePlatformMemberLinkTest.cs +++ /dev/null @@ -1,569 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Runtime.Loader; -using MeshWeaver.Mesh; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The API-break catalogue — what a platform change does to a plugin compiled against the -/// previous platform, caught BEFORE deploy (policy platform-backwards-compatibility, -/// Doc/Architecture/PlatformCompatibilityLadder). -/// -/// A plugin compiled against platform P1 must run, unchanged, on P2 of the same major. Each -/// case below builds a small platform assembly P1 with Roslyn, compiles a plugin against it ONCE, -/// then mutates the platform in exactly one way (P2) and measures TWO things: -/// -/// The static verdict — with -/// , the same probe the CI compatibility gate runs over -/// the deployed plugin set against a candidate platform. -/// The ground truth — the plugin's bytes are LOADED next to P2 in a -/// collectible load context and executed. A static verdict that disagreed with this column would be -/// either a false alarm (a hold on a plugin that runs) or a miss (a portal that throws), so every -/// case asserts both, and the static column may never be greener than the runtime one. -/// -/// -/// What makes these able to FAIL. Real assemblies, the real probe, the real loader; -/// nothing mocked, no rule re-derived. The in-class negative control -/// -/// measures the removal case with the member half OFF and requires Linkable — so neutralising the -/// member walk turns every member-shaped break red while that control stays green (demonstrated -/// when this suite was written: six of the break cases failed with the walk's one reporting line -/// removed). -/// -public class ModulePlatformMemberLinkTest : IDisposable -{ - /// The stand-in platform assembly. It carries the platform prefix on purpose: member - /// checking covers the platform's own MeshWeaver.* assemblies. - private const string Api = "MeshWeaver.Test.LadderApi"; - - /// The assembly a type MOVES to in the forwarder case. - private const string ApiCore = "MeshWeaver.Test.LadderApi.Core"; - - private const string Plugin = "MeshWeaver.Test.LadderPlugin"; - - /// Platform P1 — what the plugin is compiled against. - private const string V1 = """ - [assembly: System.Reflection.AssemblyVersion("3.0.0.0")] - namespace MeshWeaver.Test.Ladder; - public class Calculator - { - public static int Version = 1; - public int Compute(int x) => x + 1; - public static T Echo(T value) => value; - } - public class Widget - { - public Widget(string name) { Name = name; } - public string Name { get; } - public string Tag { get; init; } = ""; - } - public class Box { public T Put(T value) => value; } - public interface IShape { double Area(); } - public class Square : IShape { public double Area() => 4; } - public enum Kind { A, B } - public static class Describer { public static string Describe(Kind kind) => kind.ToString(); } - public interface IHook { string Name(); } - public abstract class ViewBase { public abstract string Render(); protected string Frame(string s) => "[" + s + "]"; } - public class Panel { } - public class Greeter { public string Greet() => "hello"; } - """; - - /// The plugin: calls a method, a static generic method, a constructor, a getter, an - /// init-only setter, a static field, a member of a generic instantiation, an interface method - /// and an enum-typed parameter — and IMPLEMENTS a platform interface, OVERRIDES a platform - /// abstract class and DERIVES from a platform class. The shapes a compiled plugin has. - private const string PluginSource = """ - using MeshWeaver.Test.Ladder; - public sealed class MyHook : IHook { public string Name() => "hook"; } - public sealed class MyView : ViewBase { public override string Render() => Frame("view"); } - public sealed class MyPanel : Panel { } - public static class LadderPlugin - { - /// Runs every shape once. - public static string Run() - { - var total = new Calculator().Compute(Calculator.Version) + Calculator.Echo(2); - var widget = new Widget("w") { Tag = "t" }; - var boxed = new Box().Put(total); - IShape shape = new Square(); - IHook hook = new MyHook(); - var panel = new MyPanel(); - return widget.Name + widget.Tag + boxed + shape.Area() + Describer.Describe(Kind.B) - + hook.Name() + new MyView().Render() + (panel is Panel) + new Greeter().Greet(); - } - } - """; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-memberlink-" + Guid.NewGuid().ToString("N")); - - /// Creates the per-test root. - public ModulePlatformMemberLinkTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - GC.SuppressFinalize(this); - } - - // ═════════════════════════════════════════════════════════════════ BREAKS — must be CAUGHT - - /// - /// 🚨 Every BREAKING platform change the catalogue covers: (P1 text, P2 text, what the verdict - /// must name). Each is caught by the static probe AND genuinely fails at run time — the - /// catalogue's two columns agree. - /// - public static TheoryData Breaks => new() - { - // A removed public type — the type half. - { "removed public type", "public class Box { public T Put(T value) => value; }", "", "MeshWeaver.Test.Ladder.Box`1" }, - // A renamed / moved namespace — every type in it is a different type now. - { "renamed namespace", "namespace MeshWeaver.Test.Ladder;", "namespace MeshWeaver.Test.LadderRenamed;", "MeshWeaver.Test.Ladder.Calculator" }, - // A removed member on a type that stays — MissingMethodException. - { "removed member", "public int Compute(int x) => x + 1;", "", "Calculator::Compute" }, - // A changed parameter TYPE. - { "changed parameter type", "public int Compute(int x) => x + 1;", "public int Compute(long x) => (int)x + 1;", "Calculator::Compute" }, - // A changed parameter COUNT — an OPTIONAL parameter appended. Every SOURCE caller still - // compiles; every COMPILED caller binds the old signature and throws. The shape a reviewer - // waves through. - { "optional parameter appended", "public int Compute(int x) => x + 1;", "public int Compute(int x, int step = 1) => x + step;", "Calculator::Compute" }, - // A changed RETURN type. - { "changed return type", "public int Compute(int x) => x + 1;", "public long Compute(int x) => x + 1;", "Calculator::Compute" }, - // static ↔ instance. - { "static made instance", "public static T Echo(T value) => value;", "public T Echo(T value) => value;", "Calculator::Echo" }, - // A member made non-public — it still exists by name; MethodAccessException. - { "member made internal", "public int Compute(int x) => x + 1;", "internal int Compute(int x) => x + 1;", "Calculator::Compute" }, - // A TYPE made non-public — it still exists by name; TypeAccessException. - { "type made internal", "public class Greeter { public string Greet() => \"hello\"; }", "internal class Greeter { public string Greet() => \"hello\"; }", "Greeter" }, - // A field turned into a property — MissingFieldException. - { "field made property", "public static int Version = 1;", "public static int Version { get; set; } = 1;", "Calculator::Version" }, - // init → set: the IsExternalInit modreq is part of the setter's signature. - { "init setter made set", "public string Tag { get; init; } = \"\";", "public string Tag { get; set; } = \"\";", "Widget::set_Tag" }, - // A removed constructor overload. - { "constructor re-signed", "public Widget(string name) { Name = name; }", "public Widget(string name, int size) { Name = name; }", "Widget::.ctor" }, - // A member of a GENERIC type, referenced through its instantiation, re-signed. - { "generic member re-signed", "public T Put(T value) => value;", "public T Put(T value, bool replace = false) => value;", "Box`1::Put" }, - // enum → open string constants (policy open-vocabulary-string-constants) WITHOUT a - // forwarder: the enum-typed signature a compiled caller names is gone. - { "enum made string constants", - "public enum Kind { A, B }\npublic static class Describer { public static string Describe(Kind kind) => kind.ToString(); }", - "public static class Kind { public const string A = \"A\"; public const string B = \"B\"; }\npublic static class Describer { public static string Describe(string kind) => kind; }", - "Describer::Describe" }, - // An interface member added WITHOUT a default implementation, on an interface a plugin - // IMPLEMENTS — TypeLoadException when the plugin type loads (#3465's shape at run time). - { "interface member added", "public interface IHook { string Name(); }", "public interface IHook { string Name(); string Describe(); }", "MyHook does not implement MeshWeaver.Test.Ladder.IHook::Describe" }, - // An abstract member added to a class a plugin derives from. - { "abstract member added", "public abstract class ViewBase { public abstract string Render(); protected string Frame(string s) => \"[\" + s + \"]\"; }", "public abstract class ViewBase { public abstract string Render(); protected string Frame(string s) => \"[\" + s + \"]\"; public abstract string Title(); }", "MyView does not implement MeshWeaver.Test.Ladder.ViewBase::Title" }, - // A base class a plugin derives from made sealed. - { "base class sealed", "public class Panel { }", "public sealed class Panel { }", "MyPanel derives from MeshWeaver.Test.Ladder.Panel" }, - // public → protected: still exists, reachable only from a DERIVED type — the plugin's - // ordinary call throws MethodAccessException. - { "member made protected", "public int Compute(int x) => x + 1;", "protected int Compute(int x) => x + 1;", "Calculator::Compute" }, - // An interface member RE-SIGNED with the same name and arity (return type): the plugin's - // implementation no longer implements it. - { "interface member re-signed", "public interface IHook { string Name(); }", "public interface IHook { object Name(); }", "MyHook does not implement MeshWeaver.Test.Ladder.IHook::Name" }, - // A STATIC ABSTRACT interface member added — owed by every implementer just the same. - { "static abstract member added", "public interface IHook { string Name(); }", "public interface IHook { string Name(); static abstract string Kind(); }", "MyHook does not implement MeshWeaver.Test.Ladder.IHook::Kind" }, - }; - - /// - /// 🚨 THE catalogue's break half: the static probe names the break (so the platform pull request - /// that introduced it goes red before anything deploys), and the plugin's bytes, loaded next to - /// the mutated platform, genuinely fail — the static column is not a false alarm. - /// - [Theory] - [MemberData(nameof(Breaks))] - public void ABreakingPlatformChange_IsCaughtStatically_AndReallyFailsAtRunTime( - string @case, string p1Text, string p2Text, string named) - { - var p2 = Mutate(p1Text, p2Text); - - var verdict = Check(Platform(p2)); - var runtime = RunAgainst(Platform(p2)); - - Assert.False(verdict.MayLoad, $"[{@case}] the static probe must refuse: {verdict.Report()}"); - Assert.Equal(ModuleLinkState.Unlinkable, verdict.State); - Assert.Contains(verdict.MissingTypes.Concat(verdict.MissingMembers), - m => m.Contains(named, StringComparison.Ordinal)); - Assert.True(runtime is not null, - $"[{@case}] the ground truth: the plugin's bytes must genuinely fail next to this platform, " - + "or the static verdict above is a false alarm"); - } - - // ═════════════════════════════════════════════════════════════ COMPATIBLE — must be ALLOWED - - /// - /// Every NON-breaking platform change the catalogue covers: the static probe lets it through - /// and the plugin runs. A gate that refused these would be switched off within the week. - /// - public static TheoryData Compatible => new() - { - // The plugin against the platform it was built on. - { "unchanged", "public class Panel { }", "public class Panel { }" }, - // An added member and an added overload (the overload's SOURCE-side trap, CS0419, is its - // own fact below). - { "added member and overload", "public int Compute(int x) => x + 1;", "public int Compute(int x) => x + 1;\n public long Compute(long x) => x + 2;\n public int Twice(int x) => 2 * x;" }, - // An added type. - { "added type", "public class Panel { }", "public class Panel { }\npublic class AddedInTheNextMinor { }" }, - // A member moved to a base class — the runtime's member resolution walks the hierarchy. - { "member moved to a base class", - "public class Calculator\n{\n public static int Version = 1;\n public int Compute(int x) => x + 1;", - "public class CalculatorBase { public int Compute(int x) => x + 1; }\npublic class Calculator : CalculatorBase\n{\n public static int Version = 1;" }, - // An interface member added WITH a default implementation. - { "default interface member added", "public interface IHook { string Name(); }", "public interface IHook { string Name(); string Describe() => Name(); }" }, - // A virtual (non-abstract) member added to a class a plugin derives from. - { "virtual member added", "public abstract class ViewBase { public abstract string Render(); protected string Frame(string s) => \"[\" + s + \"]\"; }", "public abstract class ViewBase { public abstract string Render(); protected string Frame(string s) => \"[\" + s + \"]\"; public virtual string Title() => \"\"; }" }, - // A rename that keeps an [Obsolete] FORWARDER under the old signature — the sanctioned way - // to retire a member inside a major. - { "renamed with an [Obsolete] forwarder", "public int Compute(int x) => x + 1;", "public int Calculate(int x) => x + 1;\n [System.Obsolete(\"Use Calculate.\")] public int Compute(int x) => Calculate(x);" }, - // The minor version bump itself: the loader rolls a 3.0.0.0 reference FORWARD to 3.1.0.0. - { "minor version bump", "[assembly: System.Reflection.AssemblyVersion(\"3.0.0.0\")]", "[assembly: System.Reflection.AssemblyVersion(\"3.1.0.0\")]" }, - }; - - /// The catalogue's compatible half — Linkable with a non-zero member denominator, and - /// the plugin genuinely runs next to the changed platform. - [Theory] - [MemberData(nameof(Compatible))] - public void ACompatiblePlatformChange_IsAllowed_AndThePluginRuns(string @case, string p1Text, string p2Text) - { - var p2 = Mutate(p1Text, p2Text); - - var verdict = Check(Platform(p2)); - var runtime = RunAgainst(Platform(p2)); - - Assert.True(verdict.MayLoad, $"[{@case}] {verdict.Report()}"); - Assert.Empty(verdict.MissingMembers); - Assert.True(verdict.CheckedMemberReferences >= 9, - $"[{@case}] every member shape the plugin uses must be resolved; checked {verdict.CheckedMemberReferences}"); - Assert.True(runtime is null, $"[{@case}] the plugin must run next to this platform: {runtime}"); - } - - /// - /// The minor bump is drift the loader rolls FORWARD over (MayBind: running ≥ compiled) — - /// reported as an advisory, never refused. - /// - [Fact] - public void AMinorVersionBump_IsReportedAsForwardDrift_NeverRefused() - { - var verdict = Check(Platform(Mutate("\"3.0.0.0\"", "\"3.1.0.0\""))); - - Assert.True(verdict.MayLoad, verdict.Report()); - Assert.Contains(verdict.Advisories, a => a.Contains(Api, StringComparison.Ordinal) - && a.Contains("3.1.0.0", StringComparison.Ordinal)); - } - - /// - /// A type MOVED to another platform assembly behind [TypeForwardedTo] is still found — - /// the probe follows the forwarder to the assembly that defines it, exactly as the loader does. - /// - [Fact] - public void ATypeMovedBehindAForwarder_StillLinks_AndRuns() - { - const string moved = "public class Widget\n{\n public Widget(string name) { Name = name; }\n public string Name { get; }\n public string Tag { get; init; } = \"\";\n}"; - Assert.Contains(moved, V1, StringComparison.Ordinal); - var coreSource = "[assembly: System.Reflection.AssemblyVersion(\"3.1.0.0\")]\nnamespace MeshWeaver.Test.Ladder;\n" + moved; - var core = Emit(ApiCore, coreSource); - var apiSource = Mutate(moved, "") - .Replace("\"3.0.0.0\")]", "\"3.1.0.0\")]\n[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(MeshWeaver.Test.Ladder.Widget))]", StringComparison.Ordinal); - var directory = NewDirectory("platform-forwarded"); - var corePath = Path.Combine(directory, ApiCore + ".dll"); - File.WriteAllBytes(corePath, core); - var apiPath = Path.Combine(directory, Api + ".dll"); - File.WriteAllBytes(apiPath, Emit(Api, apiSource, MetadataReference.CreateFromImage(core))); - - var verdict = ModulePlatformLink.Check(PluginPath(), ModulePlatformSurface.OfFiles([apiPath, corePath]), ModuleLinkOptions.WithMembers); - var runtime = RunAgainst(apiPath, corePath); - - Assert.True(verdict.MayLoad, verdict.Report()); - Assert.Empty(verdict.MissingMembers); - Assert.Null(runtime); - } - - /// - /// 🚨 An added OVERLOAD is binary-compatible — the compiled plugin runs — but it is a SOURCE - /// break for a dependent whose documentation names the member without a parameter list: - /// <see cref="Calculator.Compute"/> becomes ambiguous (CS0419), which a - /// -warnaserror build refuses. The static probe cannot see a source break and must not - /// pretend to; it bites at the ladder's NEXT rung — the plugin's rebuild against the running - /// platform — and this names it there. The fix is on the dependent's side: spell the cref with - /// its parameter list. - /// - [Fact] - public void AnAddedOverload_IsBinaryCompatible_ButTheCrefAmbiguityBitesAtThePluginsRebuild() - { - var p2 = Platform(Mutate("public int Compute(int x) => x + 1;", - "public int Compute(int x) => x + 1;\n public long Compute(long x) => x + 2;")); - const string documented = """ - using MeshWeaver.Test.Ladder; - /// Wraps . - public static class Documented - { - /// Calls it. - public static int Call() => new Calculator().Compute(1); - } - """; - - Assert.True(Check(p2).MayLoad, "binary: the compiled plugin still links"); - Assert.Contains(Diagnostics(documented, p2), d => d.Id == "CS0419"); - Assert.DoesNotContain(Diagnostics(documented.Replace("Calculator.Compute\"", "Calculator.Compute(int)\"", StringComparison.Ordinal), p2), - d => d.Id == "CS0419"); - } - - /// - /// 🚨 THE BLIND SPOT, stated as a test so it can never be read as covered. A behaviour - /// change behind an UNCHANGED signature — a method that returns something else, a service a - /// plugin resolves that is no longer registered, a default that moved — is invisible to any - /// surface check by construction: the bytes link and run. Only EXECUTING the deployed plugin set - /// against the candidate platform (the ladder's runtime rung) can see it. - /// - [Fact] - public void ABehaviourChangeBehindAnUnchangedSignature_IsInvisibleToTheSurfaceCheck() - { - var p2 = Platform(Mutate("public string Greet() => \"hello\";", "public string Greet() => \"bye\";")); - - var verdict = Check(p2); - var output = Invoke(p2); - - Assert.True(verdict.MayLoad, verdict.Report()); - Assert.EndsWith("bye", output, StringComparison.Ordinal); - } - - // ═══════════════════════════════════════════════════════════════════ the probe's own rules - - /// - /// 🚨 THE NEGATIVE CONTROL. The removal, measured type-only: Linkable. This is the hole the - /// member half closes — and the proof the break cases are measuring the member walk. If the - /// member half is deleted, they go red and this one does not change. - /// - [Fact] - public void ARemovedMember_IsInvisibleToTheTypeLevelVerdict_WhichIsWhyTheMemberHalfExists() - { - var platform = Platform(Mutate("public int Compute(int x) => x + 1;", "")); - - var typeOnly = ModulePlatformLink.Check(PluginPath(), Surface(platform), ModuleLinkOptions.TypesOnly); - - Assert.True(typeOnly.MayLoad, typeOnly.Report()); - Assert.Equal(0, typeOnly.CheckedMemberReferences); - Assert.Contains("member references NOT checked", typeOnly.Report(), StringComparison.Ordinal); - } - - /// The removal names the member WITH its signature and assembly, and the report names - /// the runtime exception and the remedy. - [Fact] - public void ARemovedMethod_IsNamedWithItsSignatureAndAssembly() - { - var verdict = Check(Platform(Mutate("public int Compute(int x) => x + 1;", ""))); - - var missing = Assert.Single(verdict.MissingMembers); - Assert.Contains("MeshWeaver.Test.Ladder.Calculator::Compute", missing, StringComparison.Ordinal); - Assert.Contains("System.Int32", missing, StringComparison.Ordinal); - Assert.Contains(Api, missing, StringComparison.Ordinal); - Assert.Contains("MissingMethodException", verdict.Report(), StringComparison.Ordinal); - Assert.Contains("[Obsolete] forwarder", verdict.Report(), StringComparison.Ordinal); - } - - /// - /// A published surface DOCUMENT describes type names only. Asked for members against one, the - /// probe says it could not measure — never a type-level pass wearing a member-level label. - /// - [Fact] - public void MembersAgainstAPublishedDocument_AreIndeterminate_NeverASilentTypeLevelPass() - { - var document = ModulePlatformSurface.FromJson(Surface(Platform(V1)).ToJson("c003e001")); - - var verdict = ModulePlatformLink.Check(PluginPath(), document, ModuleLinkOptions.WithMembers); - - Assert.Equal(ModuleLinkState.Indeterminate, verdict.State); - Assert.False(verdict.MayLoad); - } - - /// - /// The JUDGED scope: a core pull request builds the core platform only, so a plugin's - /// references into an assembly the portal host ships from another repository are reported - /// unchecked, not refused as "absent" — and inside the scope the refusal is unchanged. - /// - [Fact] - public void AnAssemblyOutsideTheJudgedScope_IsReportedUnchecked_NeverRefused() - { - var surface = ModulePlatformSurface.OfFiles([]); - - var outOfScope = ModulePlatformLink.Check(PluginPath(), surface, - ModuleLinkOptions.WithMembers with - { - JudgedAssemblies = new HashSet(StringComparer.OrdinalIgnoreCase) { "MeshWeaver.Mesh.Contract" }, - }); - var inScope = ModulePlatformLink.Check(PluginPath(), surface, ModuleLinkOptions.WithMembers); - - Assert.True(outOfScope.MayLoad, outOfScope.Report()); - Assert.Contains(Api, outOfScope.UncheckedAssemblies); - Assert.Equal(ModuleLinkState.Unlinkable, inScope.State); - } - - /// - /// 🚨 A protected member reached through a chain of GENERIC platform bases - /// (MeshWeaver#6007 follow-up). TextView : InputBase<string>, where the platform's - /// InputBase`1 : FormBase`1 : ViewCore`1 are generic bases defined in the platform - /// assembly itself — the shape of every MeshWeaver.Blazor form view - /// (InputBase`3 : FormComponentBase`3 : … : BlazorView`2). The derivation walk used to stop - /// at the first such base (a TypeSpec over a TypeDefinition), so Frame read as "no longer - /// accessible" and the probe refused a plugin that runs: measured on image 3.0.0-ci.9984, the - /// image's own MeshWeaver.Blazor.EntityViews was refused for 8 of 265 member references. - /// Static verdict and ground truth must agree: Linkable, and it runs. - /// - [Fact] - public void AProtectedMemberThroughGenericPlatformBases_IsAccessible_AndRuns() - { - const string platformSource = """ - [assembly: System.Reflection.AssemblyVersion("3.0.0.0")] - namespace MeshWeaver.Test.Ladder; - public abstract class ViewCore { protected string Frame(T value) => "[" + value + "]"; } - public abstract class FormBase : ViewCore { } - public abstract class InputBase : FormBase { } - """; - const string pluginSource = """ - using MeshWeaver.Test.Ladder; - public sealed class TextView : InputBase { public string Render() => Frame("text"); } - public static class LadderPlugin { public static string Run() => new TextView().Render(); } - """; - var platform = Platform(platformSource); - var plugin = Path.Combine(NewDirectory("plugin"), Plugin + ".dll"); - File.WriteAllBytes(plugin, Emit(Plugin, pluginSource, MetadataReference.CreateFromFile(platform))); - - var verdict = ModulePlatformLink.Check(plugin, Surface(platform), ModuleLinkOptions.WithMembers); - - Assert.True(verdict.MayLoad, verdict.Report()); - Assert.True(verdict.CheckedMemberReferences > 0, verdict.Report()); - Assert.Empty(verdict.MissingMembers); - var context = new PlatformContext([platform]); - try - { - var run = context.LoadFromAssemblyPath(plugin).GetType("LadderPlugin", throwOnError: true)!.GetMethod("Run")!; - Assert.Equal("[text]", (string)run.Invoke(null, null)!); - } - finally - { - context.Unload(); - } - } - - // ═══════════════════════════════════════════════════════════════════════════════ helpers - - private static string Mutate(string p1Text, string p2Text) - { - Assert.Contains(p1Text, V1, StringComparison.Ordinal); - return V1.Replace(p1Text, p2Text, StringComparison.Ordinal); - } - - private ModuleLinkVerdict Check(string platformPath) => - ModulePlatformLink.Check(PluginPath(), Surface(platformPath), ModuleLinkOptions.WithMembers); - - private static ModulePlatformSurface Surface(string platformPath) => - ModulePlatformSurface.OfFiles([platformPath]); - - private string? pluginPath; - - /// The plugin, compiled ONCE against P1 and written into its own directory — the bytes - /// that must keep running on every later platform of the major. - private string PluginPath() - { - if (pluginPath is not null) - return pluginPath; - var bytes = Emit(Plugin, PluginSource, MetadataReference.CreateFromImage(Emit(Api, V1))); - pluginPath = Path.Combine(NewDirectory("plugin"), Plugin + ".dll"); - File.WriteAllBytes(pluginPath, bytes); - return pluginPath; - } - - /// Compiles a platform variant into its own directory and returns the DLL path. - private string Platform(string source) - { - var path = Path.Combine(NewDirectory("platform"), Api + ".dll"); - File.WriteAllBytes(path, Emit(Api, source)); - return path; - } - - private string NewDirectory(string prefix) - { - var directory = Path.Combine(root, prefix + "-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - return directory; - } - - /// - /// THE GROUND TRUTH: loads the plugin's P1-compiled bytes next to - /// in a collectible load context — the platform assemblies resolve to THOSE files, whatever - /// version the plugin asked for, which is the running-platform binding the ladder requires — and - /// executes it. Null when it ran; the exception that stopped it otherwise. - /// - private Exception? RunAgainst(params string[] platformPaths) - { - try - { - Invoke(platformPaths); - return null; - } - catch (Exception exception) - { - return exception is TargetInvocationException { InnerException: { } inner } ? inner : exception; - } - } - - private string Invoke(params string[] platformPaths) - { - var context = new PlatformContext(platformPaths); - try - { - var plugin = context.LoadFromAssemblyPath(PluginPath()); - var run = plugin.GetType("LadderPlugin", throwOnError: true)!.GetMethod("Run")!; - return (string)run.Invoke(null, null)!; - } - finally - { - context.Unload(); - } - } - - /// A collectible load context that binds each platform simple name to the given file - /// and everything else to the default context. - private sealed class PlatformContext(string[] platformPaths) : AssemblyLoadContext(isCollectible: true) - { - protected override Assembly? Load(AssemblyName assemblyName) - { - var match = platformPaths.FirstOrDefault(p => - string.Equals(Path.GetFileNameWithoutExtension(p), assemblyName.Name, StringComparison.OrdinalIgnoreCase)); - return match is null ? null : LoadFromAssemblyPath(match); - } - } - - private static IEnumerable Diagnostics(string source, string platformPath) - { - var compilation = CSharpCompilation.Create( - "MeshWeaver.Test.LadderPluginRebuild", - [CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose))], - PlatformReferences.Platform().Add(MetadataReference.CreateFromFile(platformPath)), - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - return compilation.GetDiagnostics(); - } - - private static byte[] Emit(string assemblyName, string source, MetadataReference? extra = null) - { - var references = PlatformReferences.Platform(); - if (extra is not null) - references = references.Add(extra); - var compilation = CSharpCompilation.Create( - assemblyName, - [CSharpSyntaxTree.ParseText(source)], - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - return StandInCompile.Emit(compilation); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModulePlatformSurfaceJsonTest.cs b/test/Memex.Portal.Shared.Test/ModulePlatformSurfaceJsonTest.cs deleted file mode 100644 index 969ac4efc0..0000000000 --- a/test/Memex.Portal.Shared.Test/ModulePlatformSurfaceJsonTest.cs +++ /dev/null @@ -1,237 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; -using MeshWeaver.Mesh; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The platform surface travels as a document (#3651), and the document must reach the same -/// verdict the live process does. -/// -/// A platform roll is held only by a module that provably cannot load on the target. Proving -/// it needs the target's type surface at gate time, and the target is not running anywhere the -/// gate can reach — so the bake, which runs inside the target image, writes -/// and the gate reads it back. These pin the -/// two halves: writes exactly what -/// answers, -/// reads it back into a surface the REAL drives to the same -/// verdict — and a document that is not a surface is refused rather than read as an empty one, -/// because an empty surface refuses every module (the platform-prefix rule) and that would be a -/// confidently wrong hold, not a missing measurement. -/// -public class ModulePlatformSurfaceJsonTest -{ - private const string ContractAssembly = "MeshWeaver.Mesh.Contract"; - private const string Identity = "s3651roundtrip000000000000000000"; - - [Fact] - public void ToJson_ThenFromJson_CarriesTheIdentityAndEveryAssemblysTypes() - { - var live = ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory); - - var json = live.ToJson(Identity); - var declared = ModulePlatformSurface.FromJson(json); - - Assert.Equal(Identity, declared.Identity); - Assert.True(declared.IsDeclared); - Assert.Null(live.Identity); - Assert.False(live.IsDeclared); - - // The contract assembly is the one every module binds; the document must say exactly what - // the process says about it — definitions AND forwarded types, nested as Outer+Inner. - Assert.True(declared.Carries(ContractAssembly)); - var liveTypes = live.TypesOf(ContractAssembly); - var declaredTypes = declared.TypesOf(ContractAssembly); - Assert.NotNull(liveTypes); - Assert.NotNull(declaredTypes); - Assert.True(liveTypes.SetEquals(declaredTypes), - $"the document lost or invented types: only-live={string.Join(",", liveTypes.Except(declaredTypes).Take(5))} " - + $"only-declared={string.Join(",", declaredTypes.Except(liveTypes).Take(5))}"); - Assert.Contains(typeof(MeshNode).FullName!, declaredTypes); - Assert.Contains(typeof(ModulePlatformSurface).FullName!, declaredTypes); - - // The shape is the documented one, and nothing else: identity, assemblies, and — since - // #4083 — the identities SIBLING, keyed by the same names. - using var document = JsonDocument.Parse(json); - Assert.Equal(Identity, document.RootElement.GetProperty("identity").GetString()); - var assemblies = document.RootElement.GetProperty("assemblies"); - Assert.Equal(JsonValueKind.Object, assemblies.ValueKind); - var identities = document.RootElement.GetProperty("identities"); - Assert.Equal(JsonValueKind.Object, identities.ValueKind); - Assert.Equal(3, document.RootElement.EnumerateObject().Count()); - Assert.True(assemblies.EnumerateObject().Count() > 10, - "a surface of the running process names more than a handful of assemblies"); - Assert.All(identities.EnumerateObject(), - entry => Assert.True(assemblies.TryGetProperty(entry.Name, out _), - $"identities names '{entry.Name}', which assemblies does not list")); - } - - /// - /// 🚨 The VERSION half of the document (#4083): per assembly, the manifest version and public - /// key token the loader binds by, round-tripped exactly — the contract assembly's version is - /// the line's pinned 3.0.0.0, and the document's identity of it equals the live one. - /// Both YamlDotNet majors carry the same type names; only this section can refuse the bind. - /// - [Fact] - public void ToJson_ThenFromJson_CarriesEveryAssemblysVersionAndPublicKeyToken() - { - var live = ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory); - var declared = ModulePlatformSurface.FromJson(live.ToJson(Identity)); - - var liveIdentity = live.IdentityOf(ContractAssembly); - var declaredIdentity = declared.IdentityOf(ContractAssembly); - Assert.NotNull(liveIdentity); - Assert.Equal(typeof(MeshNode).Assembly.GetName().Version, liveIdentity.Version); - Assert.Equal(liveIdentity, declaredIdentity); - - // A strong-named framework assembly round-trips its token too. - var corelib = typeof(object).Assembly.GetName().Name!; - Assert.True(live.Carries(corelib)); - Assert.Equal(PlatformAssemblyIdentity.Of(typeof(object).Assembly.GetName()), declared.IdentityOf(corelib)); - Assert.NotNull(declared.IdentityOf(corelib)!.PublicKeyToken); - - // And an assembly the document lists no identity for is UNKNOWN, not zero. - Assert.Null(declared.IdentityOf("MeshWeaver.NoSuchAssembly")); - } - - /// A document from before #4083 — no identities — still reads, with every - /// identity unknown; a malformed section is refused like any other shape defect. - [Fact] - public void ADocumentWithoutIdentities_Reads_WithEveryIdentityUnknown() - { - var legacy = ModulePlatformSurface.FromJson( - """{"identity":"x","assemblies":{"MeshWeaver.Something":["MeshWeaver.Something.Thing"]}}"""); - Assert.True(legacy.Carries("MeshWeaver.Something")); - Assert.Null(legacy.IdentityOf("MeshWeaver.Something")); - - var versioned = ModulePlatformSurface.FromJson( - """{"identity":"x","assemblies":{"YamlDotNet":["YamlDotNet.Serialization.Deserializer"]},"identities":{"YamlDotNet":{"version":"16.3.0.0","publicKeyToken":"ec19458f3c15af5e"}}}"""); - Assert.Equal(new PlatformAssemblyIdentity(new Version(16, 3, 0, 0), "ec19458f3c15af5e"), versioned.IdentityOf("YamlDotNet")); - - Assert.ThrowsAny(() => ModulePlatformSurface.FromJson( - """{"identity":"x","assemblies":{"A":["A.T"]},"identities":[]}""")); - Assert.ThrowsAny(() => ModulePlatformSurface.FromJson( - """{"identity":"x","assemblies":{"A":["A.T"]},"identities":{"A":{"version":"not-a-version"}}}""")); - } - - /// - /// 🚨 THE EQUIVALENCE that makes the gate honest: the same module, checked against the live - /// surface and against the document read back, reaches the same verdict with the same - /// denominator. A document that changed the answer would make the roll gate and the boot - /// probe disagree — and the direction that matters is the document being more lenient. - /// - [Fact] - public void ARealModule_LinksIdenticallyAgainstTheLiveSurfaceAndTheDocument() - { - var live = ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory); - var declared = ModulePlatformSurface.FromJson(live.ToJson(Identity)); - var module = Emit("MeshWeaver.Test.RoundTripPack", """ - using MeshWeaver.Mesh; - public static class Views { public static string Show(MeshNode node) => node.Id; } - """); - var closure = new HashSet(StringComparer.OrdinalIgnoreCase) { "MeshWeaver.Test.RoundTripPack" }; - - var onLive = ModulePlatformLink.Check(module, "MeshWeaver.Test.RoundTripPack", closure, live); - var onDocument = ModulePlatformLink.Check(module, "MeshWeaver.Test.RoundTripPack", closure, declared); - - Assert.Equal(ModuleLinkState.Linkable, onLive.State); - Assert.Equal(ModuleLinkState.Linkable, onDocument.State); - Assert.True(onDocument.CheckedTypeReferences > 0, onDocument.Report()); - Assert.Equal(onLive.CheckedTypeReferences, onDocument.CheckedTypeReferences); - Assert.Equal(onLive.CheckedAssemblies, onDocument.CheckedAssemblies); - Assert.Equal(onLive.UncheckedAssemblies, onDocument.UncheckedAssemblies); - } - - /// - /// The document describing an OLDER platform — one that does not carry a type this module - /// binds — refuses the module by name. This is the whole point of publishing the surface: the - /// gate answers "would it load THERE" for a platform that is not running here. - /// - [Fact] - public void ADocumentMissingATypeTheModuleBinds_IsUnlinkable_NamingTheType() - { - var live = ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory); - var older = ModulePlatformSurface.FromJson(Without(live.ToJson(Identity), ContractAssembly, typeof(MeshNode).FullName!)); - var module = Emit("MeshWeaver.Test.NewerPack", """ - using MeshWeaver.Mesh; - public static class Views { public static string Show(MeshNode node) => node.Id; } - """); - - var verdict = ModulePlatformLink.Check( - module, "MeshWeaver.Test.NewerPack", - new HashSet(StringComparer.OrdinalIgnoreCase) { "MeshWeaver.Test.NewerPack" }, - older); - - Assert.Equal(ModuleLinkState.Unlinkable, verdict.State); - Assert.False(verdict.MayLoad); - Assert.Contains(verdict.MissingTypes, m => m.StartsWith(typeof(MeshNode).FullName!, StringComparison.Ordinal)); - Assert.Contains(ContractAssembly, verdict.Report(), StringComparison.Ordinal); - } - - /// - /// 🚨 Fails CLOSED on shape. A surface with no assemblies would refuse every module that binds - /// a MeshWeaver.* assembly ("no such platform assembly") — a hold that looks measured - /// and is not. So a document that is not the documented shape is an exception the reader turns - /// into Indeterminate, never a surface. - /// - [Theory] - [InlineData("{}")] - [InlineData("[]")] - [InlineData("""{"identity":"x"}""")] - [InlineData("""{"identity":"x","assemblies":{}}""")] - [InlineData("""{"identity":"x","assemblies":{"A":"not-an-array"}}""")] - [InlineData("""{"identity":"x","assemblies":{"A":[1,2]}}""")] - [InlineData("not json at all")] - public void ADocumentThatIsNotASurface_IsRefused_NeverReadAsAnEmptySurface(string json) - { - Assert.ThrowsAny(() => ModulePlatformSurface.FromJson(json)); - } - - /// An assembly the document does not name is not carried — and for a platform-named - /// one that is the coarse-grained refusal, exactly as against a live surface. - [Fact] - public void AnAssemblyTheDocumentDoesNotName_IsNotCarried() - { - var surface = ModulePlatformSurface.FromJson( - """{"identity":"x","assemblies":{"MeshWeaver.Something":["MeshWeaver.Something.Thing"]}}"""); - - Assert.True(surface.Carries("MeshWeaver.Something")); - Assert.True(surface.Carries("meshweaver.something")); - Assert.False(surface.Carries(ContractAssembly)); - Assert.Null(surface.TypesOf(ContractAssembly)); - Assert.Equal(["MeshWeaver.Something.Thing"], surface.TypesOf("MeshWeaver.Something")!.ToArray()); - } - - // ───────────────────────────────────────────────────────────── harness - - /// The same document with one type removed from one assembly — an older platform. - internal static string Without(string json, string assemblyName, string typeName) - { - var node = JsonNode.Parse(json)!.AsObject(); - var assemblies = node["assemblies"]!.AsObject(); - var types = assemblies[assemblyName]!.AsArray(); - var index = types.Select((t, i) => (t, i)).First(x => x.t!.GetValue() == typeName).i; - types.RemoveAt(index); - return node.ToJsonString(); - } - - /// Compiles one source file against this process's reference set. - internal static byte[] Emit(string assemblyName, string source) - { - // The process-wide platform set, read once — see PlatformReferences for why (#4127). - var platform = PlatformReferences.Platform(); - var compilation = CSharpCompilation.Create( - assemblyName, - [CSharpSyntaxTree.ParseText(source)], - platform, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - return StandInCompile.Emit(compilation); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleProbeInputsCostTest.cs b/test/Memex.Portal.Shared.Test/ModuleProbeInputsCostTest.cs deleted file mode 100644 index 034316d3c8..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleProbeInputsCostTest.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The REQUIRED-modules probe's disk cost must not grow with what it enumerates, and must not -/// be paid again on a volume that has not changed (MeshWeaver#4608 — the sibling #3664 missed). -/// -/// #3664 memoised behind a fingerprint of three -/// directory timestamps because it was walking the module volume on every startup and readiness -/// probe. The REQUIRED-modules probe asks the same volume the same three questions — the activation -/// sidecar, one existence probe per landed DLL, and one per declared entry — and was left doing all -/// of them per call. Measured on memex.systemorph.com 2026-09-17 across all three replicas: -/// 6.5, 6.5, 6.7, 6.8, 7.1, 7.3, 7.3, 7.5, 7.7, 8.0, 8.4, 9.0, 9.9 and 10.1 seconds per -/// probe, against a startupProbe that waits 5 s — so no replica rolled onto the -/// candidate image could record a single startup success, and each was killed at its three-hour -/// budget and started over. -/// -/// Why these assertions rather than a stopwatch. A timing assertion on a developer -/// machine measures the machine, not the rule: the local filesystem answers in microseconds where -/// the shared Azure Files volume answers in milliseconds, so the defect is invisible to a clock -/// here and obvious to one there. What is identical in both places is the NUMBER OF QUESTIONS -/// asked, so that is what is pinned — the snapshot is read once per change, and the two per-entry -/// predicates are one memo shared by every probe taken against it. Each memo is a -/// , so at-most-once is a property of the type and not of the timing: -/// ConcurrentDictionary.GetOrAdd promises one stored VALUE, never one factory call. -/// -/// A memo that always answered false would satisfy every cost assertion here while -/// being completely wrong, so both predicates also get a positive and a negative case — and one -/// test pins that they answer about DIFFERENT trees, which is what keeps the required-modules -/// classification able to tell "the image has it" from "it landed and the landing did not -/// finish". -/// -/// 🚨 These readers run on an IMMEDIATE pool (MeshWeaver#4655). The probe surface no -/// longer takes a reading on the caller's thread — it schedules one on its IIoPool and -/// answers from the last one taken — so a test about WHAT the reading costs would otherwise be a -/// test about when a pool thread got scheduled. Running the pool inline keeps every assertion here -/// about the memo and leaves the scheduling property to -/// , which is the test that would fail if the -/// walk ever came back onto a probe thread. -/// -public class ModuleProbeInputsCostTest : IDisposable -{ - private const string ModuleA = "MeshWeaver.Payments.Stripe"; - private const string ModuleB = "MeshWeaver.Social"; - private const string ModuleC = "MeshWeaver.Speech"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-probe-inputs-" + Guid.NewGuid().ToString("N")); - private readonly ModuleLandingService landing; - - public ModuleProbeInputsCostTest() - { - Directory.CreateDirectory(root); - landing = new ModuleLandingService(baseDirectory: root); - } - - public void Dispose() - { - landing.Dispose(); - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - GC.SuppressFinalize(this); - } - - /// - /// 🚨 The property: however many probes arrive against an unchanged volume, the sidecar is read - /// ONCE — and both per-entry predicates are the SAME delegate, so the memo behind them is one - /// memo. Reference identity is the whole assertion: two probes holding one memo ask the - /// filesystem about a given entry at most once in total; two probes holding two memos ask once - /// EACH, which is the defect, and no timing is needed to tell them apart. - /// - [Fact] - public async Task AnUnchangedVolume_IsReadOnce_AndEveryProbeSharesOneMemo() - { - await LandWave(ModuleA, ModuleB, ModuleC); - var pending = Reader(); - - var first = pending.ReadProbeInputs(); - var second = pending.ReadProbeInputs(); - var third = pending.ReadProbeInputs(); - - Assert.Equal(1, pending.DiskReads); - Assert.Same(first.ResolvesFromDeployment, second.ResolvesFromDeployment); - Assert.Same(first.ResolvesFromDeployment, third.ResolvesFromDeployment); - Assert.Same(first.LandedDllExists, second.LandedDllExists); - Assert.Same(first.LandedDllExists, third.LandedDllExists); - } - - /// - /// 🚨 The NEGATIVE CONTROL, without which the assertion above would be satisfied by a probe - /// that simply never looks again: a landing moves the fingerprint, so the next probe reads the - /// volume, reports the new module, and carries a FRESH memo — the old answers are not carried - /// past the change that invalidates them. - /// - [Fact] - public async Task ALanding_IsSeen_AndRetiresTheMemoWithIt() - { - await LandWave(ModuleA); - var pending = Reader(); - - var before = pending.ReadProbeInputs(); - Assert.Equal(1, pending.DiskReads); - Assert.Single(EnabledNames(before)); - - await LandWave(ModuleB, ModuleC); - - var after = pending.ReadProbeInputs(); - Assert.Equal(2, pending.DiskReads); - Assert.Equal(3, EnabledNames(after).Count); - Assert.Contains(ModuleC, EnabledNames(after), StringComparer.OrdinalIgnoreCase); - Assert.NotSame(before.ResolvesFromDeployment, after.ResolvesFromDeployment); - Assert.NotSame(before.LandedDllExists, after.LandedDllExists); - } - - /// - /// 🚨 BOTH predicates answer, and they answer about the filesystem rather than about - /// nothing. Without this the cost assertions above would pass over a pair of memos that - /// always returned false — cheap, stable, reference-identical and completely wrong. - /// Each gets a positive AND a negative case, because only the pair rules that out. - /// - [Fact] - public async Task BothPredicatesAnswerAboutTheFilesystem() - { - await LandWave(ModuleA); - var pending = Reader(); - - var inputs = pending.ReadProbeInputs(); - var landed = inputs.Activation!.Entries.Single(m => - string.Equals(m.Name, ModuleA, StringComparison.OrdinalIgnoreCase)); - - Assert.True(inputs.LandedDllExists(landed), - "the landed DLL of a module this test just landed was not found on the volume — the " - + "memo is answering about something other than the module root"); - Assert.False( - inputs.LandedDllExists(new ModuleActivationEntry - { - Name = "MeshWeaver.NeverLanded", - Directory = "does-not-exist", - }), - "a generation nothing ever landed was reported present"); - - // The IMAGE-side question, which is a different tree from the landed one on purpose: this - // assembly's own closure is what MeshBuilder.ResolveModulePath falls back to, so a file - // sitting beside the test host is the positive case an always-false memo cannot fake. - var shipped = Path.GetFileName(typeof(MeshWeaver.Plugin.Packaging.BundleReader).Assembly.Location); - Assert.True(inputs.ResolvesFromDeployment(shipped), - $"'{shipped}' is in this host's own directory and the resolver did not find it — the " - + "image-side predicate is answering about nothing, which would make every declared " - + "module read as absent while costing nothing to compute"); - Assert.False(inputs.ResolvesFromDeployment("MeshWeaver.NeverShipped.dll"), - "a module no image ever carried was reported as resolving from this deployment"); - } - - /// - /// 🚨 The two predicates ask about DIFFERENT trees, and the classification depends on it: the - /// image-side resolver must NOT answer for a module that merely landed on the volume, or - /// RequiredModuleStatus.Classify would report it Present and swallow the store branches - /// that tell an operator a landing did not complete. This is the assertion that would fail if - /// the memo were ever "tidied up" to the root-aware overload. - /// - [Fact] - public async Task TheImageSideResolver_DoesNotAnswerForAMerelyLandedModule() - { - await LandWave(ModuleA); - var inputs = Reader().ReadProbeInputs(); - var landed = inputs.Activation!.Entries.Single(m => - string.Equals(m.Name, ModuleA, StringComparison.OrdinalIgnoreCase)); - - Assert.True(inputs.LandedDllExists(landed), "the landed DLL should be on the volume"); - Assert.False(inputs.ResolvesFromDeployment(ModuleA + ".dll"), - "a module that only LANDED was reported as resolving from the image. Classify asks the " - + "image question first and answers Present on a yes, so this would hide 'landed, not " - + "yet loaded', 'its landed assembly is ABSENT' and the plan-tier refusal — every " - + "reason that tells an operator what to do."); - } - - /// - /// A sidecar that cannot be read is never an EMPTY one. Every unreadable file is named - /// separately, because a probe that folds them into one sentence loses the file name an - /// operator has to go and look at. - /// - [Fact] - public async Task AnUnreadableEntry_IsNamed_NotSwallowed() - { - await LandWave(ModuleA); - var entries = Directory.GetFiles( - ModuleActivationSidecar.EntriesDirectory(root), "*.json").Single(); - await File.WriteAllTextAsync(entries, "{ this is not json", - TestContext.Current.CancellationToken); - - var inputs = Reader().ReadProbeInputs(); - - Assert.NotEmpty(inputs.Unreadable); - Assert.Contains(inputs.Unreadable, reason => - reason.Contains(Path.GetFileName(entries), StringComparison.OrdinalIgnoreCase)); - } - - /// - /// A reader whose pool runs its work inline, so a reading requested by a probe is TAKEN by the - /// time that probe returns. See the class remarks: the scheduling is pinned elsewhere; what is - /// pinned here is how many questions the volume is asked. - /// - private PendingModuleActivations Reader() => - new(root) { IoPool = InlineIoPool.Instance }; - - private static IReadOnlyList EnabledNames(ModuleProbeInputs inputs) => - (inputs.Activation?.Entries ?? []).Select(m => m.Name).ToList(); - - private async Task Land(string name) => - await landing.LandModule(name, [(name + ".dll", RealAssemblyBytes)]) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - private static byte[] RealAssemblyBytes => - File.ReadAllBytes(typeof(MeshWeaver.Plugin.Packaging.BundleReader).Assembly.Location); - - private async Task LandWave(params string[] names) - { - foreach (var name in names) - await Land(name); - await landing.ProposeModuleSet().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModulePublishShelfTest.cs b/test/Memex.Portal.Shared.Test/ModulePublishShelfTest.cs deleted file mode 100644 index a2927630db..0000000000 --- a/test/Memex.Portal.Shared.Test/ModulePublishShelfTest.cs +++ /dev/null @@ -1,594 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using PackagingManifest = MeshWeaver.Plugin.Packaging.PluginManifest; - -namespace Memex.Portal.Shared.Test; - -/// -/// The registry SHELF over the publish route (2026-08-22): a module whose declared platform floor -/// exceeds the registry's own version is ACCEPTED, never 409'd — and since #3648 not even held: -/// the floor is advisory, and what the shelf holds is what the link probe measures as unloadable -/// here (). -/// -/// The deadlock the old behaviour produced (2026-08-22). Modules extracted from the -/// platform image declared minMeshVersion: 3.0.0-rc7 while the registry ran rc6, so the -/// publish endpoint answered every upload 409 — and the registry could not update to rc7 because -/// its Modules:Required gate held the rollout for exactly those absent modules. Image -/// doesn't ship them → only the registry can deliver them → the registry refuses to CARRY them -/// until it updates → it can't update without them. The shelf semantics break the cycle: the -/// warehouse carries modules for platforms newer than itself and serves them to consumers, whose -/// own landing measures them against THEIR platform. -/// -/// Driven over a real HTTP pipeline (TestServer) like , -/// because the claim under test is the ROUTE's answer — a publisher must be able to tell -/// "shelved, will serve" (200, held: true) apart from "activated here" (200, -/// held: false) and from a real refusal (409). -/// -public class ModulePublishShelfTest : IDisposable -{ - private const string Token = "shelf-test-token"; - - private const string Module = "MeshWeaver.Speech"; - - /// The module-relative asset every bundle here carries. Its BODY is what makes two - /// uploads differ in content — the generation leaf is a SHA-256 content address (#3656), so - /// without it a "second" publish would resolve to the first one's directory and every - /// head-pointer claim below would be vacuous. - private const string MarkerAsset = "wwwroot/build.txt"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-shelf-" + Guid.NewGuid().ToString("N")); - - private WebApplication? registry; - - /// Creates the per-test landing root the shelf writes into. - public ModulePublishShelfTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - (registry as IDisposable)?.Dispose(); - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - /// A packed module bundle the route can read, declaring the given floor. A null - /// omits the field entirely — the shape a producer on a lane - /// older than #3211 uploads, and the one the registry refuses since #3240. - /// The module's declared platform floor — advisory since #3648. - /// The producer's framework identity. - /// The package version this bundle is packed at — what the head rule of - /// #3996 orders uploads by. - /// The marker asset's body, i.e. what makes these bytes distinct. - /// Defaults to , so two versions are automatically two - /// generations; pass it explicitly to publish DIFFERENT bytes at the SAME version. - private static byte[] Bundle( - string? minMeshVersion, - string? frameworkMvid = "test-build", - string version = "1.0.0", - string? content = null) - { - var body = content ?? version; - var manifestJson = JsonSerializer.Serialize(new - { - plugin = "SpeechPkg", - version, - frameworkMvid, - module = new - { - assemblyName = Module, - assemblies = new[] { Module + ".dll" }, - minMeshVersion, - staticAssets = new[] { MarkerAsset }, - }, - }); - - var buffer = new MemoryStream(); - NuGetPackageWriter.Write( - buffer, - new PackagingManifest("SpeechPkg", "MeshWeaver.Plugin.SpeechPkg", version, "SpeechPkg", null, []), - "3.0.0", - [ - new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleEntryPathFor(Module + ".dll"), - // 🚨 REAL assembly bytes since #3538: the landing MEASURES the module's link - // requirements against this platform, so a short literal is refused as - // unreadable metadata — correctly, and these tests are about the FLOOR. - () => new MemoryStream( - File.ReadAllBytes(typeof(BundleReader).Assembly.Location))), - new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleAssetEntryPathFor(MarkerAsset), - () => new MemoryStream(Encoding.UTF8.GetBytes(body))), - ], - manifestJson); - return buffer.ToArray(); - } - - /// - /// The registry host, created once per test and REUSED across publishes — two uploads of one - /// module are the whole subject of the #3996 controls, and they have to reach the same landing - /// root through the same route the CI publishers use. - /// - private async Task Registry() - { - if (registry is null) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - // The publish route is mapped only when a token is configured; the shelf lands into this - // test's own temp root, never the testhost's bin (the sidecar is a persistent file). - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [ModulePublish.TokenConfigKey] = Token, - }); - builder.Services.AddSingleton(new ModuleLandingService(baseDirectory: root)); - - registry = builder.Build(); - registry.MapPluginBundles(); - await registry.StartAsync(); - } - - return registry.GetTestClient(); - } - - private async Task Publish( - string? minMeshVersion, - string? frameworkMvid = "test-build", - string version = "1.0.0", - string? content = null) - { - var client = await Registry(); - using var request = new HttpRequestMessage( - HttpMethod.Post, - PluginBundleEndpoints.RoutePrefix + "/SpeechPkg?packagePath=Plugins/SpeechPkg"); - request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token); - request.Content = new ByteArrayContent(Bundle(minMeshVersion, frameworkMvid, version, content)); - return await client.SendAsync(request); - } - - /// The publish route's JSON body, as the publishing CI job reads it. - private static async Task Body(HttpResponseMessage response) - => JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone(); - - /// The marker body inside one landed generation — WHICH bytes a generation holds, - /// read off the disk rather than inferred from the version the record claims. - private string MarkerIn(ModuleActivationEntry entry) => - File.ReadAllText(Path.Combine( - ModuleLandingService.ModuleDirectoryFor(root, Module, entry), - MarkerAsset.Replace('/', Path.DirectorySeparatorChar))); - - /// The marker body the SERVE side hands a consumer — the same - /// the index and the download route resolve through, - /// so the claim is about what this registry actually serves, not about a record. - private string ServedMarker(ModuleActivationList activation, string? version = null) - { - var (_, assets, decline) = ModuleBundleSource.CollectVersion(root, Module, activation, version); - Assert.Null(decline); - var asset = Assert.Single(assets, a => a.RelativePath == MarkerAsset); - return File.ReadAllText(asset.FullPath); - } - - /// - /// The fallback slot keeps the BETTER older generation too: a still OLDER late upload must not - /// push 1.6.1 out from behind the 1.7.0 head — otherwise repeated lagging publications would keep - /// the head and quietly walk its fallback backwards. The losing upload is not retained, and the - /// response has to say so: calling it "shelved" would be a claim the modules GC falsifies five - /// minutes later. - /// - [Fact] - public async Task AStillOlderPublish_DoesNotMoveTheRetainedFallbackBackwards() - { - using (var newest = await Publish(minMeshVersion: null, version: "1.7.0")) - Assert.Equal(HttpStatusCode.OK, newest.StatusCode); - using (var fallback = await Publish(minMeshVersion: null, version: "1.6.1")) - Assert.True((await Body(fallback)).GetProperty("retainedAsFallback").GetBoolean(), - "the first older upload behind a head takes the empty fallback slot"); - - using var oldest = await Publish(minMeshVersion: null, version: "1.5.0"); - - Assert.Equal(HttpStatusCode.OK, oldest.StatusCode); - var body = await Body(oldest); - Assert.True(body.GetProperty("shelfOnly").GetBoolean()); - Assert.False(body.GetProperty("retainedAsFallback").GetBoolean(), - "1.6.1 is the better fallback, so 1.5.0 is not kept"); - Assert.Contains("NOT retained", body.GetProperty("shelfOnlyReason").GetString()!, - StringComparison.Ordinal); - - var head = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.Equal("1.7.0", head.Version); - Assert.Equal("1.7.0", MarkerIn(head)); - Assert.Equal("1.6.1", head.PreviousVersion); - Assert.Equal("1.6.1", MarkerIn(ModuleActivationBoot.PreviousGeneration(head)!)); - } - - /// - /// A higher VERSION string does not protect a head whose bytes are GONE. Only a landed - /// generation is protected: a record naming a directory without its entry DLL is not a version - /// this registry holds, and refusing the one valid upload that could heal it would turn the - /// anti-rollback rule into a self-sealing outage. - /// - [Fact] - public async Task AnOlderPublish_ReplacesANewerHeadWhoseBytesAreMissing() - { - using (var newer = await Publish(minMeshVersion: null, version: "1.7.0")) - Assert.Equal(HttpStatusCode.OK, newer.StatusCode); - var broken = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - File.Delete(ModuleActivationBoot.LandedDllPath(root, broken)); - Assert.False(ModuleActivationBoot.LandedModuleDllExists(root, broken)); - - using var healing = await Publish(minMeshVersion: null, version: "1.6.1"); - - Assert.Equal(HttpStatusCode.OK, healing.StatusCode); - var body = await Body(healing); - Assert.False(body.GetProperty("shelfOnly").GetBoolean(), - "a head whose entry DLL is gone is no version this registry holds — the valid upload heals it"); - Assert.Equal("1.6.1", body.GetProperty("headVersion").GetString()); - - var list = ModuleActivationSidecar.Read(root); - var head = Assert.Single(list.Entries); - Assert.Equal("1.6.1", head.Version); - Assert.Equal("1.6.1", MarkerIn(head)); - Assert.Equal("1.6.1", ServedMarker(list)); - } - - /// - /// 🚨 The SAME-BYTES case of a lost head (#4031 review). The generation directory is a CONTENT - /// address that ignores the version label, so re-publishing the head's own bytes resolves to the - /// very directory that lost its entry DLL — and the landing used to adopt that directory as-is, - /// recording a broken head and changing nothing. The files it lost are restored from the - /// identical staged copy, and the head keeps its higher label. - /// - [Fact] - public async Task ARepublishOfTheHeadsOwnBytes_RestoresTheFilesItsGenerationLost() - { - using (var first = await Publish(minMeshVersion: null, version: "1.7.0", content: "same-bytes")) - Assert.Equal(HttpStatusCode.OK, first.StatusCode); - var broken = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - File.Delete(ModuleActivationBoot.LandedDllPath(root, broken)); - Assert.False(ModuleActivationBoot.LandedModuleDllExists(root, broken)); - - using var again = await Publish(minMeshVersion: null, version: "1.6.1", content: "same-bytes"); - - Assert.Equal(HttpStatusCode.OK, again.StatusCode); - var list = ModuleActivationSidecar.Read(root); - var head = Assert.Single(list.Entries); - Assert.Equal(broken.Directory, head.Directory); - Assert.True(ModuleActivationBoot.LandedModuleDllExists(root, head), - "the re-published identical bytes restore the entry DLL the head generation lost"); - Assert.Equal("1.7.0", head.Version); - Assert.Equal("same-bytes", ServedMarker(list)); - } - - /// - /// 🚨 An above-floor publish is neither 409'd nor HELD (#3648) — it lands, unheld, with - /// the floor recorded as an advisory. The upload that used to 409 into the 2026-08-22 deadlock - /// answered held: true from then until #3648; on 2026-09-07 that same string comparison - /// held every production portal on its morning build. The declared floor decides nothing now: - /// the bytes LINK against this platform (real assembly bytes, measured by the landing), so - /// they land exactly as a floor-satisfied publish does — PendingRestart raised, entry enabled, - /// the floor on the record for the index and the status row to SAY, never to gate on. - /// - [Fact] - public async Task AnAboveFloorPublish_LandsUnheld_TheFloorIsAdvisory() - { - // The precondition that makes this able to fail: 999.0.0 ranks ABOVE whatever this test - // process runs, so the old gate would have held it. A comparator that stopped saying so - // would turn every assertion below into a vacuous pass. - Assert.NotNull(ModulePlatformFloor.DeclineReason("999.0.0")); - - using var response = await Publish(minMeshVersion: "999.0.0"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - Assert.False(body.RootElement.GetProperty("held").GetBoolean(), - "a declared floor above the running platform is advisory — the bytes link, so nothing holds them"); - Assert.Equal(JsonValueKind.Null, body.RootElement.GetProperty("holdReason").ValueKind); - Assert.True(body.RootElement.GetProperty("pendingRestart").GetBoolean(), - "an unheld landing loads at the next restart, so the restart IS pending"); - - // The state on disk: bytes in a generation directory, entry recorded WITH its floor (the - // index and the status row say what the module claims), restart flag raised. - var list = ModuleActivationSidecar.Read(root); - var entry = Assert.Single(list.Entries); - Assert.Equal("MeshWeaver.Speech", entry.Name); - Assert.True(entry.Enabled); - Assert.Equal("999.0.0", entry.MinMeshVersion); - // 🚨 The PRODUCER's framework identity survives the publish, verbatim (Plugins#931). The - // index projects exactly this per bundle, and a consumer compares it against what it has - // landed to tell a rebuild of unchanged source from a no-op — a rebuild republishes under - // the SAME version, so if the shelf drops the identity here the whole comparison downstream - // silently reads "unknown" and the defect is back. - Assert.Equal("test-build", entry.FrameworkMvid); - Assert.True(list.PendingRestart); - Assert.True(File.Exists(Path.Combine( - ModuleLandingService.ModuleDirectoryFor(root, "MeshWeaver.Speech", entry), - "MeshWeaver.Speech.dll"))); - - // …and the SERVE side lists exactly this landing for consumers — the same Collect the - // index and the download route resolve through, against the state the route wrote. - var (files, _, decline) = ModuleBundleSource.Collect(root, "MeshWeaver.Speech", list); - Assert.Null(decline); - Assert.Single(files); - } - - /// A publish whose floor this platform satisfies is the unchanged behaviour: - /// activated here (restart-as-activation), not held. - [Fact] - public async Task AFloorSatisfiedPublish_LandsAsBefore() - { - using var response = await Publish(minMeshVersion: "0.0.1"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - Assert.False(body.RootElement.GetProperty("held").GetBoolean()); - Assert.True(body.RootElement.GetProperty("pendingRestart").GetBoolean()); - - var list = ModuleActivationSidecar.Read(root); - Assert.True(list.PendingRestart, "an activated landing loads at the next restart"); - Assert.Equal("0.0.1", Assert.Single(list.Entries).MinMeshVersion); - } - - /// - /// 🚨 #3240 — THE REGISTRY'S OWN 400, armed 2026-09-05. A bundle that states no framework - /// identity is REFUSED, by name, instead of shelving a null that the index then advertises. - /// - /// Why the registry checks something its producers already refuse: a null on the SERVED - /// side is the one unknown landing cannot heal. ModuleUpdateDecision (#3154) compares - /// (version, framework identity) on every reconcile of every installation, and a module's - /// version encodes CONTENT only — so a rebuild of unchanged source against a new platform - /// republishes under the SAME version and the identity is the only thing that distinguishes it - /// from a no-op. Shelve one null and every consumer of that module answers "already landed, the - /// identity could not be checked" forever. A registry that trusts its publishers is not a - /// registry. - /// - /// The refusal must stay shape-AGNOSTIC — `g<sha>`, a 32-hex MVID and `s<hash>` - /// are all identities a lane legitimately states. Only ABSENCE is refused, which is what - /// pins from the other side. - /// - [Fact] - public async Task ABundleStatingNoFrameworkIdentity_IsRefused_AndNothingIsShelved() - { - using var response = await Publish(minMeshVersion: null, frameworkMvid: null); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - var error = body.RootElement.GetProperty("error").GetString(); - Assert.Contains("states no framework identity", error); - // The remedy is named, not merely the fault — the publisher has to know what to change. - Assert.Contains("node-repo-module-pack.yml", error); - - // 🚨 NOTHING was shelved. A refusal that still wrote the bytes would leave the null on the - // shelf and only change the status code the publisher sees. - Assert.Empty(ModuleActivationSidecar.Read(root).Entries); - } - - /// - /// The other side of the same refusal: an identity of a shape the from-source lane produces (a - /// bare 32-hex MVID) is accepted and survives verbatim. Without this, narrowing the check to - /// one token shape would look like a passing test while redding a lane that states its identity - /// perfectly well. - /// - [Fact] - public async Task AStatedIdentityOfAnyShape_IsAccepted() - { - const string mvid = "cef92e9759e940ea8a1aa73173b12227"; - using var response = await Publish(minMeshVersion: null, frameworkMvid: mvid); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var entry = Assert.Single(ModuleActivationSidecar.Read(root).Entries); - Assert.Equal(mvid, entry.FrameworkMvid); - } - - // ─────────────────── #3996: the head is a VERSION order, never an arrival order ─────────────────── - - /// - /// 🚨 THE DEFECT (#3996). The publish route moved the head pointer for every accepted - /// upload, so which version a registry SERVED — and which one its own next restart loaded — - /// was decided by CI queue timing. Measured on memex.meshweaver.cloud 2026-09-11: - /// MeshWeaver.Mail.MicrosoftGraph 1.7.0 landed at 02:49Z and 1.6.1 displaced it at - /// 03:00Z; the 03:30Z activation entry read Version=1.6.1 PreviousVersion=1.7.0, so the - /// next restart would have silently un-shipped a merged change. Two lanes publish the same - /// module (#3461) and core CD runs take 40–50 minutes, so the older gate routinely finished - /// last. - /// - /// The older upload is still ACCEPTED and still SHELVED — its bytes are on the volume and - /// its generation is the entry's fallback, which is what keeps the modules GC off it. An upload - /// that merely failed to become head and was recorded NOWHERE would be reclaimed five minutes - /// later, trading one silent loss for another, so the GC pass below is part of the claim rather - /// than decoration. - /// - [Fact] - public async Task AnOlderUpload_ArrivingAfterANewerOne_DoesNotBecomeTheHead_AndIsStillShelvedAndResolvable() - { - using (var newer = await Publish(minMeshVersion: null, version: "1.7.0")) - { - Assert.Equal(HttpStatusCode.OK, newer.StatusCode); - Assert.False((await Body(newer)).GetProperty("shelfOnly").GetBoolean(), - "the FIRST publish of a module has no head to rank below — it IS the head"); - } - - var head = Assert.Single(ModuleActivationSidecar.Read(root).Entries).Directory; - - using var older = await Publish(minMeshVersion: null, version: "1.6.1"); - - // 🚨 200, never 409: the publisher did its job — these bytes ARE on the shelf. The reason - // they are not the head is the other lane's timing, which no build job can see, and redding - // a repo's CI for it would be a band-aid pointed at the wrong repo. - Assert.Equal(HttpStatusCode.OK, older.StatusCode); - var body = await Body(older); - Assert.True(body.GetProperty("shelfOnly").GetBoolean(), - "an upload ranking below the landed head is shelved WITHOUT becoming the head"); - var reason = body.GetProperty("shelfOnlyReason").GetString(); - Assert.Contains("1.6.1", reason!, StringComparison.Ordinal); - Assert.Contains("1.7.0", reason!, StringComparison.Ordinal); - Assert.False(body.GetProperty("held").GetBoolean(), - "shelf-only is not the link probe's hold — these bytes load here perfectly well"); - Assert.False(body.GetProperty("pendingRestart").GetBoolean(), - "the head did not move, so a restart would load exactly what is already running — " - + "'restart required' would be a prompt no restart can clear"); - Assert.Equal("1.7.0", body.GetProperty("headVersion").GetString()); - Assert.True(body.GetProperty("retainedAsFallback").GetBoolean(), - "the fallback slot was empty, so the older upload takes it"); - - var list = ModuleActivationSidecar.Read(root); - var entry = Assert.Single(list.Entries); - - // ── the head did NOT regress ────────────────────────────────────────────────────────── - Assert.Equal("1.7.0", entry.Version); - Assert.Equal(head, entry.Directory); - Assert.Equal("1.7.0", MarkerIn(entry)); - Assert.Equal("1.7.0", ServedMarker(list)); - - // ── and the older upload is STILL SHELVED, and RESOLVABLE ───────────────────────────── - Assert.Equal("1.6.1", entry.PreviousVersion); - var previous = ModuleActivationBoot.PreviousGeneration(entry); - Assert.NotNull(previous); - Assert.True(ModuleActivationBoot.LandedModuleDllExists(root, previous!), - "a shelf-only upload that cannot be resolved is not shelved, it is lost"); - Assert.Equal("1.6.1", MarkerIn(previous!)); - // …and a consumer asking for 1.6.1 BY VERSION is served 1.6.1's bytes, while the - // unversioned read (and 1.7.0) still serve the head. - Assert.Equal("1.6.1", ServedMarker(list, "1.6.1")); - Assert.Equal("1.7.0", ServedMarker(list, "1.7.0")); - - // 🚨 Reachable means SURVIVES THE SWEEP. The modules GC reclaims every generation directory - // no entry and no module set references, and the grace window is the only thing that would - // otherwise hide the loss for five minutes — so the pass runs here with the window collapsed - // and the clock pushed forward, exactly as the other generation-lifetime tests run it. - ModuleLandingService.CollectGarbage(root, minAge: TimeSpan.Zero, nowUtc: DateTime.UtcNow.AddHours(1)); - Assert.True(ModuleActivationBoot.LandedModuleDllExists(root, previous!), - "the shelf-only generation is referenced as the entry's fallback, so GC must not reclaim it"); - Assert.True(ModuleActivationBoot.LandedModuleDllExists(root, entry), - "and the head it did not displace is untouched"); - } - - /// - /// 🚨 THE POSITIVE CONTROL, and the test without which the one above is worthless. "The - /// head never moves" satisfies every assertion in - /// - /// while breaking the registry completely — no module would ever be updated again. A genuinely - /// NEWER upload must move the head, raise the restart, and become what the serve side hands out. - /// - /// The two tests together state the fix in one sentence: whichever order the two lanes - /// finish in, the registry converges on the SAME head — 1.7.0 — because the ordering is the - /// artifacts' and not the build queue's. - /// - [Fact] - public async Task ANewerUpload_ArrivingAfterAnOlderOne_BecomesTheHead() - { - using (var older = await Publish(minMeshVersion: null, version: "1.6.1")) - Assert.Equal(HttpStatusCode.OK, older.StatusCode); - - var displaced = Assert.Single(ModuleActivationSidecar.Read(root).Entries).Directory; - - using var newer = await Publish(minMeshVersion: null, version: "1.7.0"); - - Assert.Equal(HttpStatusCode.OK, newer.StatusCode); - var body = await Body(newer); - Assert.False(body.GetProperty("shelfOnly").GetBoolean(), - "a newer version MUST take the head — a rule that never moved it would pass the " - + "older-arrives-last control and ship a registry that can never be updated again"); - Assert.Equal(JsonValueKind.Null, body.GetProperty("shelfOnlyReason").ValueKind); - Assert.Equal("1.7.0", body.GetProperty("headVersion").GetString()); - Assert.False(body.GetProperty("retainedAsFallback").GetBoolean()); - Assert.True(body.GetProperty("pendingRestart").GetBoolean(), - "the head moved, so this instance genuinely loads something else at its next restart"); - - var list = ModuleActivationSidecar.Read(root); - var entry = Assert.Single(list.Entries); - Assert.Equal("1.7.0", entry.Version); - Assert.NotEqual(displaced, entry.Directory); - Assert.Equal("1.7.0", MarkerIn(entry)); - Assert.Equal("1.7.0", ServedMarker(list)); - Assert.True(list.PendingRestart); - - // #3649 is unchanged by #3996: the generation the landing displaced becomes the fallback. - Assert.Equal(displaced, entry.PreviousDirectory); - Assert.Equal("1.6.1", entry.PreviousVersion); - } - - /// - /// 🚨 The comparison is STRICTLY BELOW, never at-or-below — and this is the test that says why. - /// A module's version encodes CONTENT only, so a rebuild of unchanged source against a new - /// platform republishes under the SAME version (Plugins#931/#723); that artifact is exactly the - /// one consumers in fallback are waiting for. Writing <= 0 instead of < 0 - /// would leave a registry permanently unable to serve a rebuild, with nothing anywhere saying so. - /// - [Fact] - public async Task ARebuildRepublishedAtTheSameVersion_StillBecomesTheHead() - { - using (var first = await Publish(minMeshVersion: null, version: "1.7.0", content: "built-against-A")) - Assert.Equal(HttpStatusCode.OK, first.StatusCode); - - var before = Assert.Single(ModuleActivationSidecar.Read(root).Entries).Directory; - - using var rebuild = await Publish(minMeshVersion: null, version: "1.7.0", content: "built-against-B"); - - Assert.Equal(HttpStatusCode.OK, rebuild.StatusCode); - Assert.False((await Body(rebuild)).GetProperty("shelfOnly").GetBoolean(), - "an equal version is not an older version — a rebuild for a new platform ships under " - + "the same version and must reach the shelf's head"); - - var list = ModuleActivationSidecar.Read(root); - var entry = Assert.Single(list.Entries); - Assert.NotEqual(before, entry.Directory); - Assert.Equal("built-against-B", MarkerIn(entry)); - Assert.Equal("built-against-B", ServedMarker(list)); - } - - /// - /// The ordering itself, stated as a table, through the SAME - /// ModuleUpdateDecision uses for . That - /// identity is load-bearing rather than tidy: a registry whose head ranked by a different order - /// than its consumers' would serve a version every one of them refuses as older, and nothing - /// would ever converge. - /// - /// The ci.900 / ci.3758 pair is the case NuGetVersionComparer exists - /// for — as TEXT "900" sorts above "3758", and a string comparison here would pin - /// a registry to a build thousands of runs stale with nothing red anywhere. - /// - [Theory] - // second becomes the head - [InlineData("1.6.1", "1.7.0", true)] - [InlineData("1.7.0-rc1", "1.7.0", true)] // the stable outranks the pre-release it follows - [InlineData("3.0.0-ci.900", "3.0.0-ci.3758", true)] // numeric identifiers, not text - // second is shelf-only - [InlineData("1.7.0", "1.6.1", false)] - [InlineData("1.7.0", "1.7.0-rc1", false)] // a pre-release published after its stable - [InlineData("3.0.0-ci.3758", "3.0.0-ci.900", false)] - public async Task TheHeadFollowsSemVerOrder_NeverArrivalOrder( - string first, string second, bool secondBecomesTheHead) - { - using (var one = await Publish(minMeshVersion: null, version: first)) - Assert.Equal(HttpStatusCode.OK, one.StatusCode); - - using var two = await Publish(minMeshVersion: null, version: second); - - Assert.Equal(HttpStatusCode.OK, two.StatusCode); - Assert.Equal( - !secondBecomesTheHead, - (await Body(two)).GetProperty("shelfOnly").GetBoolean()); - - var list = ModuleActivationSidecar.Read(root); - var entry = Assert.Single(list.Entries); - var head = secondBecomesTheHead ? second : first; - Assert.Equal(head, entry.Version); - Assert.Equal(head, MarkerIn(entry)); - Assert.Equal(head, ServedMarker(list)); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleReloadTest.cs b/test/Memex.Portal.Shared.Test/ModuleReloadTest.cs deleted file mode 100644 index 838aa5b0bd..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleReloadTest.cs +++ /dev/null @@ -1,1303 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using PackagingManifest = MeshWeaver.Plugin.Packaging.PluginManifest; - -namespace Memex.Portal.Shared.Test; - -/// -/// The pure rules of a module reload (Doc/Architecture/ModuleReload): what a request may say, -/// which replica reports count, and how a process states what it has loaded. Each rule with its -/// negative control beside it. -/// -public class ModuleReloadRulesTest -{ - private static ModuleReloadRequest Request(params ModuleReloadItem[] items) => new() - { - Module = "M", - Reason = "test", - Items = [.. items], - }; - - private static ModuleReloadItem Item(string target) => new() { Module = "M", TargetVersion = target }; - - private static ModuleReloadReplica Replica(string process, DateTimeOffset started, string loaded, string? swapFailure = null) => new() - { - Process = process, - StartedAt = started, - ReportedAt = started.AddSeconds(5), - Loaded = ImmutableDictionary.Empty.Add("M", loaded), - SwapFailure = swapFailure, - }; - - [Fact] - public void AReload_NeedsAReason_AndAModuleNameThatIsAName() - { - ModuleReload.Validate(new ModuleReloadRequest { Module = "MeshWeaver.AI", Reason = "x" }).Should().BeNull(); - ModuleReload.Validate(new ModuleReloadRequest { Module = null, Reason = "x" }).Should().BeNull("blank means every module"); - ModuleReload.Validate(new ModuleReloadRequest { Module = "MeshWeaver.AI", Reason = " " }).Should().Contain("reason"); - ModuleReload.Validate(new ModuleReloadRequest { Module = "Plugins/AI", Reason = "x" }).Should().Contain("not a module"); - } - - [Fact] - public void AfterARestart_OnlyAProcessBootedAfterIt_Counts() - { - var restart = new DateTimeOffset(2026, 10, 5, 6, 0, 0, TimeSpan.Zero); - var request = Request(Item("1.21.0")) with { RestartRequestedAt = restart }; - - // The old pod, reporting the old version on its way out — not evidence of anything. - var onlyOld = request with - { - Replicas = ImmutableDictionary.Empty - .Add("old", Replica("old", restart.AddHours(-3), "1.20.4")), - }; - ModuleReload.Evaluate(onlyOld, _ => true).Should().BeNull("nothing booted after the restart has reported yet"); - - var booted = onlyOld with { Replicas = onlyOld.Replicas.Add("new", Replica("new", restart.AddMinutes(2), "1.21.0")) }; - var loaded = ModuleReload.Evaluate(booted, _ => true)!; - loaded.Loaded.Should().BeTrue(); - loaded.Detail.Should().Contain("M 1.21.0"); - - // Negative control: a NEW process that still loads the old version is a named failure. - var stale = onlyOld with { Replicas = onlyOld.Replicas.Add("new", Replica("new", restart.AddMinutes(2), "1.20.4")) }; - var red = ModuleReload.Evaluate(stale, _ => true)!; - red.Loaded.Should().BeFalse(); - red.Detail.Should().Contain("new loads M 1.20.4, not 1.21.0"); - } - - [Fact] - public void Loaded_WaitsForEveryRunningProcess_ButAFailureIsDecisiveAtOnce() - { - var swap = DateTimeOffset.UtcNow.AddMinutes(-1); - string[] roster = ["a", "b"]; - var first = Request(Item("2.0.0")) with - { - LiveSwapRequestedAt = swap, - Replicas = ImmutableDictionary.Empty - .Add("a", Replica("a", swap.AddHours(-1), "2.0.0") with { ReportedAt = swap.AddSeconds(3) }), - }; - ModuleReload.Evaluate(first, _ => true, roster) - .Should().BeNull("b is running and has not reported — its swap may yet fail and need the restart"); - ModuleReload.Evaluate(first, _ => true)!.Loaded - .Should().BeTrue("the control: without a roster the one counted report is all there is"); - - var both = first with - { - Replicas = first.Replicas.Add("b", Replica("b", swap.AddHours(-1), "2.0.0") with { ReportedAt = swap.AddSeconds(5) }), - }; - ModuleReload.Evaluate(both, _ => true, roster)!.Loaded.Should().BeTrue(); - - var bFailed = first with - { - Replicas = first.Replicas.Add("b", Replica("b", swap.AddHours(-1), "1.0.0", swapFailure: "M: would not load") with { ReportedAt = swap.AddSeconds(5) }), - }; - ModuleReload.Evaluate(bFailed, _ => true, roster)!.SwapFailed.Should().BeTrue(); - - var restart = swap; - var oldStillUp = Request(Item("2.0.0")) with - { - RestartRequestedAt = restart, - Replicas = ImmutableDictionary.Empty - .Add("new", Replica("new", restart.AddMinutes(1), "2.0.0")) - .Add("old", Replica("old", restart.AddHours(-2), "1.0.0")), - }; - ModuleReload.Evaluate(oldStillUp, _ => true, ["new", "old"]) - .Should().BeNull("an old pod still running after the restart serves the old version"); - ModuleReload.Evaluate(oldStillUp, _ => true, ["new"])!.Loaded.Should().BeTrue("once it is gone, the restart is complete"); - } - - [Fact] - public void AReportFromAGoneProcess_DoesNotCount() - { - var restart = DateTimeOffset.UtcNow.AddMinutes(-10); - var request = Request(Item("2.0.0")) with - { - RestartRequestedAt = restart, - Replicas = ImmutableDictionary.Empty - .Add("gone", Replica("gone", restart.AddMinutes(1), "1.0.0")), - }; - ModuleReload.Evaluate(request, p => p != "gone").Should().BeNull(); - ModuleReload.Evaluate(request, _ => true)!.Loaded.Should().BeFalse("the control: counted, it is a mismatch"); - } - - [Fact] - public void AFailedLiveSwap_IsTheFallbackSignal() - { - var swap = DateTimeOffset.UtcNow.AddMinutes(-1); - var request = Request(Item("2.0.0")) with - { - LiveSwapRequestedAt = swap, - Replicas = ImmutableDictionary.Empty - .Add("p", Replica("p", swap.AddHours(-1), "1.0.0", swapFailure: "M: the new context would not load") with { ReportedAt = swap.AddSeconds(3) }), - }; - var verdict = ModuleReload.Evaluate(request, _ => true)!; - verdict.SwapFailed.Should().BeTrue(); - verdict.Detail.Should().Contain("the new context would not load"); - } - - [Fact] - public void LoadedVersions_ReadTheGenerationThisProcessLoaded_AgainstTheRecord() - { - var activation = new ModuleActivationList - { - Entries = - [ - new ModuleActivationEntry { Name = "A", Directory = "A@new", Version = "2.0.0", PreviousDirectory = "A@old", PreviousVersion = "1.0.0" }, - new ModuleActivationEntry { Name = "B", Directory = "B@new", Version = "3.0.0" }, - ], - }; - var names = new HashSet(StringComparer.OrdinalIgnoreCase) { "A", "B", "C" }; - - var head = ModuleReloadAgent.LoadedVersions(["A"], activation, - new Dictionary { ["A"] = "A@new" }, names); - head["A"].Should().Be("2.0.0"); - var previous = ModuleReloadAgent.LoadedVersions(["A"], activation, - new Dictionary { ["A"] = "A@old" }, names); - previous["A"].Should().Be("1.0.0", "the previous generation is still a version, named as itself"); - var image = ModuleReloadAgent.LoadedVersions(["B", "C", "D"], activation, new Dictionary(), names); - image["B"].Should().Contain("image's own copy"); - image["D"].Should().Be("not loaded"); - } - - /// - /// 🚨 A CRASH is Faulted, a decided answer is Failed — and only Faulted is ever retried. The - /// rule is over the items: one crashed module makes the whole request Faulted, because its - /// answer is still unknown. - /// - [Fact] - public void ACrashedItem_IsFaulted_ADecidedFailure_IsFailed() - { - var crashed = new ModuleReloadItem { Module = "M", Failure = "landing failed: 503", Transient = true }; - var declined = new ModuleReloadItem { Module = "N", Failure = "declined — floor above the platform" }; - var fine = new ModuleReloadItem { Module = "O", TargetVersion = "1.0.0" }; - - ModuleReload.OutcomeOf([fine], failure: null).Should().Be(ModuleReloadStatus.Done); - ModuleReload.OutcomeOf([declined, fine], "N: declined").Should().Be(ModuleReloadStatus.Failed); - ModuleReload.OutcomeOf([crashed], "M: landing failed").Should().Be(ModuleReloadStatus.Faulted); - ModuleReload.OutcomeOf([declined, crashed], "both").Should().Be(ModuleReloadStatus.Faulted, - "the crashed module's answer is unknown, so the request is retried"); - ModuleReloadStatus.IsTerminal(ModuleReloadStatus.Faulted).Should().BeFalse("a crash is never final"); - ModuleReloadStatus.IsTerminal(ModuleReloadStatus.Failed).Should().BeTrue(); - } - - /// The retry is due on the pass cadence, doubled per attempt and capped in the doubling - /// only; nothing but Faulted is ever due. - [Fact] - public void AFaultedRequest_IsDue_OnThePassCadence_DoubledPerAttempt() - { - var at = new DateTimeOffset(2026, 10, 5, 20, 0, 0, TimeSpan.Zero); - var unit = TimeSpan.FromMinutes(30); - var faulted = new ModuleReloadRequest { Reason = "r", Status = ModuleReloadStatus.Faulted, FaultedAt = at }; - - ModuleReload.RetryDueAt(faulted, unit).Should().Be(at + unit); - ModuleReload.RetryDueAt(faulted with { Attempt = 1 }, unit).Should().Be(at + 2 * unit); - ModuleReload.RetryDueAt(faulted with { Attempt = 3 }, unit).Should().Be(at + 8 * unit); - ModuleReload.RetryDueAt(faulted with { Attempt = 50 }, unit) - .Should().Be(at + (1 << ModuleReload.MaxBackoffDoublings) * unit, "the doubling stops; the retries do not"); - - // Negative controls: a decided failure, a finished or a running request is never due. - ModuleReload.RetryDueAt(faulted with { Status = ModuleReloadStatus.Failed }, unit).Should().BeNull(); - ModuleReload.RetryDueAt(faulted with { Status = ModuleReloadStatus.Done }, unit).Should().BeNull(); - ModuleReload.RetryDueAt(faulted with { Status = ModuleReloadStatus.Landing }, unit).Should().BeNull(); - } - - /// The re-arm runs the request again from the top: next attempt, the faulted attempt's - /// executor-owned fields cleared, the audit log kept. - [Fact] - public void Rearm_StartsTheNextAttempt_FromRequested_KeepingTheLog() - { - var at = DateTimeOffset.UtcNow; - var faulted = new ModuleReloadRequest - { - Module = "M", - Reason = "r", - Status = ModuleReloadStatus.Faulted, - Attempt = 2, - FaultedAt = at, - Failure = "landing failed: 503", - Items = [new ModuleReloadItem { Module = "M", Failure = "landing failed: 503", Transient = true }], - RestartRequestedAt = at, - LiveSwapRequestedAt = at, - Activation = ModuleReloadActivation.Restart, - Replicas = ImmutableDictionary.Empty.Add("p", Replica("p", at, "1.0.0")), - Log = ["first line"], - }; - - var next = ModuleReload.Rearm(faulted, at.AddMinutes(30)); - - next.Status.Should().Be(ModuleReloadStatus.Requested); - next.Attempt.Should().Be(3); - next.Items.Should().BeEmpty(); - next.Failure.Should().BeNull(); - next.FaultedAt.Should().BeNull(); - next.RestartRequestedAt.Should().BeNull(); - next.LiveSwapRequestedAt.Should().BeNull(); - next.Activation.Should().BeNull(); - next.Replicas.Should().BeEmpty(); - next.Module.Should().Be("M"); - next.Reason.Should().Be("r"); - next.Log.Should().HaveCount(2).And.Contain("first line"); - next.Log[^1].Should().Contain("retry 3").And.Contain("landing failed: 503"); - } -} - -/// -/// 🚨 "Reload M on this instance", end to end through the real lanes — the registry's bundle -/// index and download route (a fake HTTP registry), the real , -/// and on a temp module root, -/// the real request node, executor and agent, and the real as -/// the restart path (only its Kubernetes updater is recorded instead of called). -/// -/// What is simulated, and only that: which generation a PROCESS has loaded (a test process -/// cannot load the landed bytes as module M), and the process that boots after the restart. -/// Everything the reload decides and writes is real. -/// -public abstract class ModuleReloadScenario(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected const string RegistryHost = "registry.module-reload.test"; - protected const string RegistryUrl = "https://" + RegistryHost; - protected const string Token = "mwi_module_reload_test"; - protected const string Package = "ReloadPkg"; - protected const string Module = "MeshWeaver.ReloadModule"; - - /// A second installed package, served only when - /// is set — for a reload that covers more than one module. - protected const string SecondPackage = "ReloadPkgTwo"; - protected const string SecondModule = "MeshWeaver.ReloadModuleTwo"; - - private readonly string landingRoot = Path.Combine(Path.GetTempPath(), "mw-reload-" + Guid.NewGuid().ToString("N")); - - protected ReloadRegistry Registry { get; } = new(); - - protected RecordingUpdater Updater { get; } = new(); - - /// What the test PROCESS has loaded: module → generation leaf. - private ImmutableDictionary loaded = ImmutableDictionary.Empty; - - protected void SetLoaded(string module, string generation) => - ImmutableInterlocked.Update(ref loaded, l => l.SetItem(module, generation)); - - protected virtual IModuleLiveActivation? LiveActivation => null; - - /// The restart lane, when a test needs one that answers on its own terms; null takes the - /// real self-updater over . - protected virtual IModuleActivationRestart? RestartLane => null; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - Directory.CreateDirectory(landingRoot); - return base.ConfigureMesh(builder) - .AddGitHubSyncTypes() - .AddPluginCatalog() - .ConfigureServices(services => - { - services.AddGitHubSyncServices(); - services - .AddSingleton(new HostRoutingClientFactory(Registry)) - .AddSingleton(FloorFixture.Pin) - .AddSingleton(new ModuleLandingService(baseDirectory: landingRoot)) - .AddSingleton(new PluginCatalogOptions - { - Registries = [new PluginRegistryReference { Name = "test", Url = RegistryUrl, Token = Token }], - }) - .AddSingleton(new ModuleReloadAgent - { - LoadedGenerations = () => loaded, - LoadedNames = () => loaded.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase), - }) - .AddSingleton(sp => RestartLane ?? new SelfUpdateHostedService( - sp.GetRequiredService(), new NoTags(), Updater, new SelfUpdateOptions())); - if (LiveActivation is { } live) - services.AddSingleton(live); - return services; - }); - } - - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - try - { - if (Directory.Exists(landingRoot)) - Directory.Delete(landingRoot, recursive: true); - } - catch (IOException) - { - // A temp directory that outlives the test is harmless. - } - } - - protected AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - protected ModuleActivationEntry Head() => HeadOf(Module); - - protected ModuleActivationEntry HeadOf(string module) => - ModuleActivationSidecar.Read(landingRoot).Entries.Single(e => e.Name == module); - - /// The head's generation leaf — a readable failure, never a null, when the head names none. - protected string HeadDirectory() => - Head().Directory is { Length: > 0 } directory - ? directory - : throw new InvalidOperationException($"the activation record's head for {Module} names no generation directory"); - - /// The instance runs M@1.1.0: installed, landed, and loaded by this process. - protected async Task RunningVersionOne(CancellationToken ct, Func? shape = null) - { - FloorFixture.AssertTheFloorHolds(Mesh); - Registry.Serve("1.1.0", floor: null); - await InstallRunning(Package, Module, ct, shape); - } - - /// Installs declaring , lands the - /// version the registry serves for it (1.1.0), and records this process as loading it. - protected async Task InstallRunning(string package, string module, CancellationToken ct, - Func? shape = null) - { - var manifest = new PackageManifest - { - Id = package, - Name = package, - Version = "1.1.0", - TargetPartition = package, - Module = module, - // Notify: the UNATTENDED lane would decline an update — the reload is attended and must not. - AutoUpdate = false, - }; - var record = MeshNode.FromPath($"{PackageInstaller.InstalledPartition}/{package}") with - { - NodeType = PackageInstaller.PackageNodeType, - Name = package, - State = MeshNodeState.Active, - Content = shape is null ? manifest : shape(manifest), - }; - await Access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode(record)).Timeout(TestTimeouts.Convergence).Await(ct); - - (await new PluginBundleClient(Mesh, RegistryUrl, Token).AdoptModule(package, module, record.Path) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)).Should().Be(1, $"the premise: {package} 1.1.0 is landed"); - var head = HeadOf(module); - head.Version.Should().Be("1.1.0"); - SetLoaded(module, head.Directory ?? throw new InvalidOperationException($"{module} landed without a directory")); - } - - protected async Task Reload(CancellationToken ct) - { - var ticket = await ModuleReload.Request(Mesh, new ModuleReloadRequest - { - Module = Module, - Reason = "AI 1.21 is needed by Hosting (the 2026-10-05 incident shape)", - RequestedBy = "test", - }) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - ticket.Refusal.Should().BeNull(); - return ticket.Path!; - } - - protected Task AwaitRequest(string path, Func until, CancellationToken ct) => - Access.RunAsSystem(() => Mesh.GetMeshNodeStream(path)) - .Select(node => node.ContentAs(Mesh.JsonSerializerOptions)) - .Where(r => r is not null && until(r)) - .Select(r => r!) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - private sealed class NoTags : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult>([]); - } - - /// The Kubernetes seam, recording every same-image restart. - protected sealed class RecordingUpdater : IDeploymentUpdater - { - private int restarts; - - public int Restarts => Volatile.Read(ref restarts); - public bool CanPatch => true; - - public Task LastRolledAtAsync(CancellationToken ct) => - // Rolled a minute ago — INSIDE the poller's one-hour floor: an explicit reload must not - // be deferred by it (it asks once, stamped on its node). - Task.FromResult(DateTimeOffset.UtcNow.AddMinutes(-1)); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) => Task.CompletedTask; - - public Task RestartAsync(CancellationToken ct) - { - Interlocked.Increment(ref restarts); - return Task.FromResult(true); - } - - /// The portal Deployment's rollout strategy an instance reboot reads — non-disruptive by default. - public RolloutStrategyReading? Strategy { get; set; } = new("RollingUpdate", "1", "0", 2); - - public Task ReadRolloutStrategyAsync(CancellationToken ct) => Task.FromResult(Strategy); - } - - /// The plugin registry: an empty feed, and ONE module bundle at a settable version and - /// floor — each version carrying different real assembly bytes, so each lands as its own - /// generation. - protected sealed class ReloadRegistry : HttpMessageHandler, IHostHandler - { - private ImmutableList downloads = ImmutableList.Empty; - private (string Version, string? Floor) served = ("1.0.0", null); - - /// What the registry FEED lists (`GET /api/plugins`) — empty unless a test publishes. - public IReadOnlyList Feed { get; set; } = []; - - public ImmutableList Downloads => downloads; - - public void Serve(string version, string? floor) => served = (version, floor); - - /// While true, the bundle index answers 503 — a registry that is briefly down, the - /// TRANSIENT fault a reload must survive. - public bool IndexDown { get; set; } - - private volatile int parkIndex; - private System.Reactive.Subjects.AsyncSubject indexParked = new(); - - /// Parks the NEXT bundle-index request until — the park is the - /// subject: a step in flight at a moment the test chooses. completes once - /// that request is parked. - public void ParkNextIndex() - { - indexParked = new System.Reactive.Subjects.AsyncSubject(); - parkIndex = 1; - } - - /// Completes when the request armed for is parked. - public IObservable IndexParked => indexParked; - - /// Releases the parked index request; call it in a finally. - public void ReleaseIndex() => parkIndex = 0; - - /// When set, the index also lists at the served version. - public bool ServesSecond { get; set; } - - /// The package whose bundle download crashes (throws), or null for none. - public string? CrashDownloadOf { get; set; } - - /// When set, the bundle DOWNLOAD answers this status instead of the bytes — the index - /// still advertises the version, so the reload reaches the download and meets the answer. - public HttpStatusCode? DownloadStatus { get; set; } - - /// When true, the bundle download times out (the transfer never reaches an answer). - public bool DownloadTimesOut { get; set; } - - public bool Serves(string host) => string.Equals(host, RegistryHost, StringComparison.OrdinalIgnoreCase); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var path = request.RequestUri!.AbsolutePath; - var (version, floor) = served; - if (request.Method == HttpMethod.Get && path == "/api/plugins") - return Ok(PluginRegistryPayloads.List(Feed)); - if (request.Method == HttpMethod.Get && path == $"{PluginBundleClient.RoutePrefix}/index.json" && parkIndex == 1) - { - var parked = indexParked; - parked.OnNext(System.Reactive.Unit.Default); - parked.OnCompleted(); - SpinWait.SpinUntil(() => parkIndex == 0, TestTimeouts.Convergence); - } - if (request.Method == HttpMethod.Get && path == $"{PluginBundleClient.RoutePrefix}/index.json" && IndexDown) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { - Content = new StringContent("registry briefly down", Encoding.UTF8, "text/plain"), - }); - if (request.Method == HttpMethod.Get && path == $"{PluginBundleClient.RoutePrefix}/index.json") - { - var listed = ServesSecond - ? new[] { (Package, Module), (SecondPackage, SecondModule) } - : new[] { (Package, Module) }; - return Ok(JsonSerializer.Serialize(new - { - frameworkMvid = "s1234567890abcdef1234567890abcdef", - bundles = listed.Select(b => new - { - plugin = b.Item1, - version, - url = $"{RegistryUrl}{PluginBundleClient.RoutePrefix}/{b.Item1}/{version}", - module = b.Item2, - minMeshVersion = floor, - frameworkMvid = "s1234567890abcdef1234567890abcdef", - }).ToArray(), - }, PluginRegistryPayloads.Json)); - } - if (request.Method == HttpMethod.Get && path.StartsWith($"{PluginBundleClient.RoutePrefix}/", StringComparison.Ordinal)) - { - var plugin = Uri.UnescapeDataString(path[(PluginBundleClient.RoutePrefix.Length + 1)..].Split('/')[0]); - // A transfer that CRASHES (the connection dropped), not a refusal: an exception, so - // the adopt reports it as transient — what a reload must treat as Faulted. - if (string.Equals(plugin, CrashDownloadOf, StringComparison.Ordinal)) - throw new HttpRequestException($"the connection to the registry was reset while downloading {plugin}"); - if (DownloadTimesOut) - throw new TimeoutException($"the download of {plugin} timed out"); - if (DownloadStatus is { } status) - return Task.FromResult(new HttpResponseMessage(status) - { - Content = new StringContent($"answered {(int)status}", Encoding.UTF8, "text/plain"), - }); - var module = plugin == SecondPackage ? SecondModule : Module; - ImmutableInterlocked.Update(ref downloads, d => d.Add(version)); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(Bundle(plugin, module, version, floor)) }); - } - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - private static Task Ok(string body) => - Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }); - - private static byte[] Bundle(string package, string module, string version, string? floor) - { - // Different real assemblies per version: landing is content-addressed, so identical - // bytes would land as the SAME generation and "which generation is loaded" could not - // tell N from N+1. - var bytesFrom = version == "1.1.0" - ? typeof(BundleReader).Assembly.Location - : typeof(ModuleAdoptOutcome).Assembly.Location; - var manifestJson = JsonSerializer.Serialize(new - { - plugin = package, - version, - frameworkMvid = "s1234567890abcdef1234567890abcdef", - module = new { assemblyName = module, assemblies = new[] { module + ".dll" }, minMeshVersion = floor }, - }); - var buffer = new MemoryStream(); - NuGetPackageWriter.Write( - buffer, - new PackagingManifest(package, "MeshWeaver.Plugin." + package, version, package, null, []), - "3.0.0", - [ - new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleEntryPathFor(module + ".dll"), - () => new MemoryStream(File.ReadAllBytes(bytesFrom))), - ], - manifestJson); - return buffer.ToArray(); - } - } -} - -/// The restart lane: a process that cannot swap live — N+1 activates by exactly ONE restart. -public class ModuleReloadByRestartTest(ITestOutputHelper output) : ModuleReloadScenario(output) -{ - /// - /// 🚨 THE ACCEPTANCE SCENARIO. M@1.1.0 is running, 1.2.0 is published with a floor this platform - /// meets, and a reload is requested: 1.2.0 is resolved and landed, exactly one restart is - /// requested through the self-update restart path (inside the poller's roll floor, which a - /// reload does not wait on), the request waits for a process booted after it — and once that - /// process reports 1.2.0 loaded, the request is Done with the per-replica version on the node. - /// - [Fact(Timeout = 240_000)] - public async Task ARunningVersion_ReloadsToTheNewestCompatible_ByExactlyOneRestart() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - Registry.Serve("1.2.0", floor: FloorFixture.Below); - - var path = await Reload(ct); - var waiting = await AwaitRequest(path, r => r.Status == ModuleReloadStatus.AwaitingRestart && r.ActivationDetail?.StartsWith(ModuleRestartKinds.Restarted) == true, ct); - - var item = waiting.Items.Single(); - item.FoundVersion.Should().Be("1.2.0"); - item.FoundFloor.Should().Be(FloorFixture.Below); - item.TargetVersion.Should().Be("1.2.0"); - item.RunningVersion.Should().Be("1.1.0"); - item.Landed.Should().BeTrue(); - item.Failure.Should().BeNull(); - waiting.Activation.Should().Be(ModuleReloadActivation.Restart); - waiting.RestartRequestedAt.Should().NotBeNull(); - Updater.Restarts.Should().Be(1, "exactly one restart, and the roll floor does not defer an explicit reload"); - Head().Version.Should().Be("1.2.0", "the activation record names N+1 for the boot that follows"); - Registry.Downloads.Should().Equal("1.1.0", "1.2.0"); - - // ── The pod the restart created boots and reports what IT loaded. ──────────────────── - var restarted = new ModuleReloadAgent - { - StartedAt = waiting.RestartRequestedAt!.Value.AddSeconds(30), - LoadedGenerations = () => ImmutableDictionary.Empty.Add(Module, Head().Directory!), - }; - await restarted.Report(Mesh, path, waiting, "restarted-pod", swapFailure: null) - .DefaultIfEmpty().Timeout(TestTimeouts.Convergence).Await(ct); - - var done = await AwaitRequest(path, r => ModuleReloadStatus.IsTerminal(r.Status), ct); - done.Status.Should().Be(ModuleReloadStatus.Done, done.Failure ?? ""); - done.Replicas["restarted-pod"].Loaded[Module].Should().Be("1.2.0"); - Updater.Restarts.Should().Be(1, "the report completes the request; it never asks for a second restart"); - } - - /// - /// The negative control of the acceptance scenario: the pod that booted after the restart still - /// loads N (its previous generation) — the request is RED, naming the replica and both versions. - /// - [Fact(Timeout = 240_000)] - public async Task AReplicaThatStillLoadsTheOldVersionAfterTheRestart_TurnsTheRequestRed() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - var oldGeneration = Head().Directory!; - Registry.Serve("1.2.0", floor: FloorFixture.Below); - - var path = await Reload(ct); - var waiting = await AwaitRequest(path, r => r.Status == ModuleReloadStatus.AwaitingRestart && r.RestartRequestedAt is not null && r.ActivationDetail is not null, ct); - Head().PreviousDirectory.Should().Be(oldGeneration, "the premise: N is kept as N+1's fallback"); - - var restarted = new ModuleReloadAgent - { - StartedAt = waiting.RestartRequestedAt!.Value.AddSeconds(30), - LoadedGenerations = () => ImmutableDictionary.Empty.Add(Module, oldGeneration), - }; - await restarted.Report(Mesh, path, waiting, "restarted-pod", swapFailure: null) - .DefaultIfEmpty().Timeout(TestTimeouts.Convergence).Await(ct); - - var red = await AwaitRequest(path, r => ModuleReloadStatus.IsTerminal(r.Status), ct); - red.Status.Should().Be(ModuleReloadStatus.Failed); - red.Failure.Should().Contain("restarted-pod loads MeshWeaver.ReloadModule 1.1.0, not 1.2.0"); - Updater.Restarts.Should().Be(1); - } - - /// - /// 🚨 A floor ABOVE the running platform: declined by name, nothing downloaded, no restart, and - /// N keeps serving (the activation record still names it). - /// - [Fact(Timeout = 240_000)] - public async Task ANewerVersionAboveTheFloor_IsDeclinedByName_AndTheRunningVersionKeepsServing() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - Registry.Serve("1.2.0", floor: FloorFixture.Above); - - var path = await Reload(ct); - var red = await AwaitRequest(path, r => ModuleReloadStatus.IsTerminal(r.Status), ct); - - red.Status.Should().Be(ModuleReloadStatus.Failed); - var item = red.Items.Single(); - item.FoundVersion.Should().Be("1.2.0"); - item.FoundFloor.Should().Be(FloorFixture.Above); - item.Failure.Should().StartWith("declined").And.Contain(FloorFixture.Above).And.Contain(FloorFixture.Running); - red.Failure.Should().Contain(FloorFixture.Above); - red.Activation.Should().Be(ModuleReloadActivation.NotNeeded); - Updater.Restarts.Should().Be(0, "nothing landed, so nothing is restarted"); - Registry.Downloads.Should().Equal(["1.1.0"], "the held bundle is not even downloaded"); - Head().Version.Should().Be("1.1.0", "N keeps serving"); - } - - /// The control for "nothing to do": the newest compatible version is the one running — - /// Done at once, NotNeeded, no download, no restart. - [Fact(Timeout = 240_000)] - public async Task AReloadOfTheVersionAlreadyRunning_IsDoneWithoutARestart() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - - var path = await Reload(ct); - var done = await AwaitRequest(path, r => ModuleReloadStatus.IsTerminal(r.Status), ct); - - done.Status.Should().Be(ModuleReloadStatus.Done, done.Failure ?? ""); - done.Activation.Should().Be(ModuleReloadActivation.NotNeeded); - done.Items.Single().TargetVersion.Should().Be("1.1.0"); - Updater.Restarts.Should().Be(0); - Registry.Downloads.Should().Equal(["1.1.0"]); - } - - /// A module nobody installed is refused by name, with nothing asked of the registry. - [Fact(Timeout = 240_000)] - public async Task AModuleThatIsNotInstalled_IsRefusedByName() - { - var ct = TestContext.Current.CancellationToken; - var ticket = await ModuleReload.Request(Mesh, new ModuleReloadRequest { Module = "MeshWeaver.NotHere", Reason = "test" }) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - var red = await AwaitRequest(ticket.Path!, r => ModuleReloadStatus.IsTerminal(r.Status), ct); - red.Status.Should().Be(ModuleReloadStatus.Failed); - red.Failure.Should().Contain("'MeshWeaver.NotHere' is not an installed module here"); - Registry.Downloads.Should().BeEmpty(); - } -} - -/// The live lane: a process whose loader can swap the module — no restart at all; and a -/// swap that fails falls back to exactly one restart. -public class ModuleReloadLiveTest(ITestOutputHelper output) : ModuleReloadScenario(output) -{ - private readonly SwappableModuleLoader loader = new(); - - protected override IModuleLiveActivation? LiveActivation => loader; - - [Fact(Timeout = 240_000)] - public async Task ASwappableModule_GoesLive_WithoutARestart() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - Registry.Serve("1.2.0", floor: FloorFixture.Below); - loader.OnSwap = () => SetLoaded(Module, Head().Directory!); - - var path = await Reload(ct); - var done = await AwaitRequest(path, r => ModuleReloadStatus.IsTerminal(r.Status), ct); - - done.Status.Should().Be(ModuleReloadStatus.Done, done.Failure ?? ""); - done.Activation.Should().Be(ModuleReloadActivation.Live); - done.Replicas.Values.Should().ContainSingle().Which.Loaded[Module].Should().Be("1.2.0"); - loader.Swaps.Should().Be(1); - Updater.Restarts.Should().Be(0, "a live swap needs no restart"); - } - - [Fact(Timeout = 240_000)] - public async Task AFailedLiveSwap_FallsBackToExactlyOneRestart() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - Registry.Serve("1.2.0", floor: FloorFixture.Below); - loader.Failure = "the new context would not load"; - - var path = await Reload(ct); - var waiting = await AwaitRequest(path, r => r.Status == ModuleReloadStatus.AwaitingRestart && r.RestartRequestedAt is not null && r.ActivationDetail?.StartsWith(ModuleRestartKinds.Restarted) == true, ct); - - waiting.Activation.Should().Be(ModuleReloadActivation.Restart); - waiting.Log.Should().Contain(line => line.Contains("the new context would not load")); - Updater.Restarts.Should().Be(1); - } -} - -/// -/// 🚨 The instance report names the module code that RUNS, not only what was installed. A live -/// reload moves the activation record and the loaded generation and leaves the install record -/// Plugins/{id} alone (it also describes the content install) — so a report reading only the -/// install record says 1.1.0 for a module running 1.2.0. is -/// read off the activation record against the generation this process loaded. -/// -public class ModuleReloadInstanceReportTest(ITestOutputHelper output) : ModuleReloadScenario(output) -{ - private readonly SwappableModuleLoader loader = new(); - - protected override IModuleLiveActivation? LiveActivation => loader; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => - base.ConfigureMesh(builder).ConfigureServices(services => services.AddSingleton( - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [DeploymentReportService.DeploymentKey] = "reload-dep", - // A control url and NO signing secret: the report is composed in full and handed back - // as Skipped — nothing leaves the test process. - [DeploymentReportService.ReportToKey] = "https://control.module-reload.test", - }).Build())); - - [Fact(Timeout = 240_000)] - public async Task AfterALiveReload_TheReportSaysRunning120_WhileTheInstallRecordStillSays110() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct, m => m with { ReleasedVersion = "1.1.0", ModuleVersion = "h-1.1.0" }); - var reporter = Mesh.ServiceProvider.GetRequiredService(); - - var before = await reporter.Report().Timeout(TestTimeouts.Convergence).Await(ct); - var reportBefore = before.Report ?? throw new InvalidOperationException($"no report before the reload: {before.Detail}"); - var rowBefore = reportBefore.Modules.Single(m => m.Id == Package); - rowBefore.RunningVersion.Should().Be("1.1.0", "the premise: this process runs the generation 1.1.0 landed"); - - Registry.Serve("1.2.0", floor: FloorFixture.Below); - loader.OnSwap = () => SetLoaded(Module, HeadDirectory()); - var path = await Reload(ct); - var done = await AwaitRequest(path, r => ModuleReloadStatus.IsTerminal(r.Status), ct); - done.Status.Should().Be(ModuleReloadStatus.Done, done.Failure ?? ""); - Head().Version.Should().Be("1.2.0", "the premise: the activation record moved"); - - var after = await reporter.Report().Timeout(TestTimeouts.Convergence).Await(ct); - var reportAfter = after.Report ?? throw new InvalidOperationException($"no report after the reload: {after.Detail}"); - var row = reportAfter.Modules.Single(m => m.Id == Package); - Output.WriteLine($"report row after Done reload: version={row.Version} moduleVersion={row.ModuleVersion} runningVersion={row.RunningVersion}"); - row.Version.Should().Be("1.1.0", "the install record is not re-stamped by a reload — it also describes the content install"); - row.ModuleVersion.Should().Be("h-1.1.0"); - row.RunningVersion.Should().Be("1.2.0", - "the module code this process runs is the generation the reload swapped in, read off the activation record"); - } - - [Fact] - public void RunningVersion_IsTheLoadedGeneration_NeverTheHeadAloneNorTheInstallRecord() - { - var combo = new InstanceCombo - { - Modules = - [ - new ModuleCoordinate { ModuleId = "P", Package = new PackageCoordinate { PackageId = "P", CompiledModule = "M", ReleasedVersion = "1.0.0" } }, - new ModuleCoordinate { ModuleId = "Content", Package = new PackageCoordinate { PackageId = "Content", ReleasedVersion = "4.0.0" } }, - ], - }; - var report = new DeploymentReport - { - Modules = [new ModuleReport { Id = "P", Version = "1.0.0" }, new ModuleReport { Id = "Content", Version = "4.0.0" }], - }; - var activation = new ModuleActivationList - { - Entries = [new ModuleActivationEntry { Name = "M", Directory = "M@new", Version = "2.0.0", PreviousDirectory = "M@old", PreviousVersion = "1.0.0" }], - }; - - string? Running(IReadOnlyDictionary loaded) => - DeploymentReportService.WithRunning(report, combo, activation, loaded).Modules.Single(m => m.Id == "P").RunningVersion; - - Running(new Dictionary { ["M"] = "M@new" }).Should().Be("2.0.0"); - Running(new Dictionary { ["M"] = "M@old" }) - .Should().Be("1.0.0", "a restart-path reload lands N+1 as head while this process still runs N"); - Running(new Dictionary()).Should().BeNull("the image's own copy or not loaded — not known, never guessed"); - Running(new Dictionary { ["M"] = "M@gone" }).Should().BeNull("a generation the record no longer names"); - DeploymentReportService.WithRunning(report, combo, null, new Dictionary { ["M"] = "M@new" }) - .Modules.Single(m => m.Id == "P").RunningVersion.Should().BeNull("an unreadable record states nothing"); - DeploymentReportService.WithRunning(report, combo, activation, new Dictionary { ["M"] = "M@new" }) - .Modules.Single(m => m.Id == "Content").RunningVersion.Should().BeNull("a content-only package has no module code"); - } -} - -/// -/// 🚨 A crashed reload step is never final (Plugins#2893 review): a registry that is briefly -/// down makes the request , the reconcile pass's retry -/// () re-arms it, and the next attempt finishes — while a -/// DECIDED failure stays and is never retried. -/// -public class ModuleReloadFaultedTest(ITestOutputHelper output) : ModuleReloadScenario(output) -{ - [Fact(Timeout = 240_000)] - public async Task ATransientFault_IsFaulted_TheNextPassRetriesIt_AndItSucceeds() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - Registry.IndexDown = true; - - var path = await Reload(ct); - var faulted = await AwaitRequest(path, r => r.Status is ModuleReloadStatus.Faulted || ModuleReloadStatus.IsTerminal(r.Status), ct); - - faulted.Status.Should().Be(ModuleReloadStatus.Faulted, faulted.Failure ?? ""); - faulted.Failure.Should().Contain("503"); - faulted.Attempt.Should().Be(0); - faulted.FaultedAt.Should().NotBeNull(); - var faultedAt = faulted.FaultedAt ?? throw new InvalidOperationException("a Faulted request carries FaultedAt"); - faulted.CompletedAt.Should().BeNull("a fault is not an end"); - faulted.Items.Single().Transient.Should().BeTrue(); - - // The pass before the backoff is due re-arms nothing — the backoff is honoured. - var unit = TimeSpan.FromMinutes(30); - (await ModuleReload.RetryFaulted(Mesh, unit, faultedAt.AddMinutes(1)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().BeEmpty("the first retry is due one pass interval after the fault"); - - // The registry is back, and the next pass is due: the request is re-armed and finishes. - Registry.IndexDown = false; - (await ModuleReload.RetryFaulted(Mesh, unit, faultedAt + unit) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().Equal([path]); - - var done = await AwaitRequest(path, r => ModuleReloadStatus.IsTerminal(r.Status), ct); - done.Status.Should().Be(ModuleReloadStatus.Done, done.Failure ?? ""); - done.Attempt.Should().Be(1); - done.Items.Single().TargetVersion.Should().Be("1.1.0"); - done.Log.Should().Contain(line => line.Contains("retry 1")); - Updater.Restarts.Should().Be(0); - } - - [Fact(Timeout = 240_000)] - public async Task ADecidedFailure_IsFailed_AndIsNeverRetried() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - Registry.Serve("1.2.0", floor: FloorFixture.Above); - - var path = await Reload(ct); - var red = await AwaitRequest(path, r => r.Status is ModuleReloadStatus.Faulted || ModuleReloadStatus.IsTerminal(r.Status), ct); - red.Status.Should().Be(ModuleReloadStatus.Failed, "a floor above the platform is a decided answer"); - red.Items.Single().Transient.Should().BeFalse(); - - (await ModuleReload.RetryFaulted(Mesh, TimeSpan.Zero, DateTimeOffset.UtcNow.AddDays(1)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().BeEmpty("a decided failure is final — retrying it would get the same answer"); - var after = await AwaitRequest(path, _ => true, ct); - after.Status.Should().Be(ModuleReloadStatus.Failed); - after.Attempt.Should().Be(0); - } -} - -/// -/// 🚨 A crash beside an activation is still Faulted (MeshWeaver#6172 review). One module's -/// adopt CRASHES while another lands a new version and goes live. The request's end state is -/// decided in Evaluate from the items the activation write persisted, so the crashed item's -/// flag must survive that write. If it did not, the end -/// state would read , and a crash would be final. -/// -public class ModuleReloadFaultedBesideActivationTest(ITestOutputHelper output) : ModuleReloadScenario(output) -{ - private readonly SwapLoader loader = new(); - - protected override IModuleLiveActivation? LiveActivation => loader; - - [Fact(Timeout = 240_000)] - public async Task OneModulesCrashedAdopt_BesideAnotherModulesLiveActivation_IsFaulted() - { - var ct = TestContext.Current.CancellationToken; - FloorFixture.AssertTheFloorHolds(Mesh); - Registry.ServesSecond = true; - Registry.Serve("1.1.0", floor: null); - await InstallRunning(Package, Module, ct); - await InstallRunning(SecondPackage, SecondModule, ct); - - // N+1 for both: the first lands and swaps live, the second's download crashes. - Registry.Serve("1.2.0", floor: FloorFixture.Below); - Registry.CrashDownloadOf = SecondPackage; - loader.OnSwap = module => SetLoaded(module, HeadOf(module).Directory - ?? throw new InvalidOperationException($"{module} has no landed directory")); - - var ticket = await ModuleReload.Request(Mesh, new ModuleReloadRequest - { - Reason = "one module crashes beside another's activation (#6172 review)", - RequestedBy = "test", - }) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - ticket.Refusal.Should().BeNull(); - var path = ticket.Path ?? throw new InvalidOperationException("an accepted request carries its path"); - - var end = await AwaitRequest(path, r => r.Status is ModuleReloadStatus.Faulted || ModuleReloadStatus.IsTerminal(r.Status), ct); - - end.Activation.Should().Be(ModuleReloadActivation.Live, "the premise: the other module activated live"); - loader.Swapped.Should().Equal([Module], "only the module that landed is swapped"); - var crashed = end.Items.Single(i => i.Module == SecondModule); - crashed.Failure.Should().Contain("reset"); - crashed.Transient.Should().BeTrue("the activation write must persist the crashed item's flag"); - end.Items.Single(i => i.Module == Module).Failure.Should().BeNull(); - end.Status.Should().Be(ModuleReloadStatus.Faulted, - "a crash beside a successful activation is still a crash — never final: " + (end.Failure ?? "")); - end.FaultedAt.Should().NotBeNull(); - end.CompletedAt.Should().BeNull("a fault is not an end"); - } - - private sealed class SwapLoader : IModuleLiveActivation - { - private ImmutableList swapped = ImmutableList.Empty; - - public ImmutableList Swapped => swapped; - public Action? OnSwap { get; set; } - - public bool CanSwap(string module) => true; - - public IObservable Swap(string module, string reason) => Observable.Defer(() => - { - ImmutableInterlocked.Update(ref swapped, s => s.Add(module)); - OnSwap?.Invoke(module); - return Observable.Return(new ModuleReloadSwapOutcome(true)); - }); - } -} - -/// -/// 🚨 The per-process agent reads the commit the feed ANNOUNCED, never whatever its mirror holds. -/// The invalidation feed fires post-commit; this process's mirror receives the owner's echo on its -/// own path and, under load, after the feed. A handler that read the mirror unfloored then saw the -/// state BEFORE the announced commit — Landing for the event that announced Activating — -/// swapped nothing, reported nothing, and no later write ever came to wake it: the request sat in -/// Activating until timed out in CI. -/// -/// Here the "mirror behind the announcement" moment is made deterministic: the handler is -/// subscribed BEFORE the commit is written, so its mirror can only hold the earlier state. With the -/// announced version as its floor it waits for the commit and reports; the negative control — the -/// same handler with no floor, i.e. the old read — answers from the earlier state and reports nothing. -/// -public class ModuleReloadAgentReadsTheAnnouncedCommitTest(ITestOutputHelper output) : ModuleReloadScenario(output) -{ - private readonly SwappableModuleLoader loader = new(); - - protected override IModuleLiveActivation? LiveActivation => loader; - - [Fact(Timeout = 240_000)] - public async Task AHandlerAheadOfItsMirror_WaitsForTheAnnouncedCommit_AndReports() - { - var ct = TestContext.Current.CancellationToken; - - // A request the executor has already decided, so the only writer below is this test. - var ticket = await ModuleReload.Request(Mesh, new ModuleReloadRequest { Module = "MeshWeaver.NotHere", Reason = "test" }) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - var path = ticket.Path ?? throw new InvalidOperationException("an accepted request carries its path"); - var decided = await Access.RunAsSystem(() => Mesh.GetMeshNodeStream(path)) - .Where(n => n.ContentAs(Mesh.JsonSerializerOptions) is { } r && ModuleReloadStatus.IsTerminal(r.Status)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - var announced = decided.Version + 1; - - ModuleReloadAgent Process() => new() - { - LoadedGenerations = () => ImmutableDictionary.Empty.Add(Module, "gen-1.2.0"), - LoadedNames = () => new HashSet(StringComparer.OrdinalIgnoreCase) { Module }, - }; - - // Both handlers are subscribed before the commit exists — the moment a feed event can reach a - // process whose mirror has not yet received the owner's echo. - var floored = Process().Handle(Mesh, path, logger: null, committed: announced).ToList().Replay(1); - var unfloored = Process().Handle(Mesh, path, logger: null).ToList().Replay(1); - using var flooredConnection = floored.Connect(); - using var unflooredConnection = unfloored.Connect(); - - await Access.RunAsSystem(() => Mesh.GetMeshNodeStream(path).Update(r => r with - { - Status = ModuleReloadStatus.Activating, - Activation = ModuleReloadActivation.Live, - LiveSwapRequestedAt = DateTimeOffset.UtcNow, - Items = [new ModuleReloadItem { Module = Module, TargetVersion = "1.2.0" }], - })) - .Take(1).Timeout(TestTimeouts.Convergence).Await(ct); - - (await floored.Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().ContainSingle("the handler floored at the announced version reads the Activating commit, swaps and reports"); - (await unfloored.Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().BeEmpty("the control: the unfloored read answers from the state before the commit and acts on nothing"); - } -} - -/// -/// 🚨 A transient DOWNLOAD answer is Faulted, never Failed (MeshWeaver#6172). The index -/// advertises N+1 and the bundle download itself answers: a 503, 429, 502, 504 or a timeout may -/// clear on its own, so the reload records and the next -/// reconcile pass retries it; a 404 or 403 is the registry's decided answer, so the reload is -/// and never retried. The classification lives in ONE place -/// (). -/// -public class ModuleReloadTransientDownloadTest(ITestOutputHelper output) : ModuleReloadScenario(output) -{ - private static readonly TimeSpan PassInterval = TimeSpan.FromMinutes(30); - - /// The instance runs 1.1.0, the index advertises 1.2.0, and the DOWNLOAD meets - /// 's answer. Returns the request's path and its first settled state. - private async Task<(string Path, ModuleReloadRequest Settled)> ReloadMeeting(Action download, CancellationToken ct) - { - await RunningVersionOne(ct); - Registry.Serve("1.2.0", floor: FloorFixture.Below); - download(Registry); - var path = await Reload(ct); - var settled = await AwaitRequest(path, r => r.Status is ModuleReloadStatus.Faulted - || r.Status == ModuleReloadStatus.AwaitingRestart - || ModuleReloadStatus.IsTerminal(r.Status), ct); - return (path, settled); - } - - [Fact(Timeout = 240_000)] - public async Task ADownloadAnswering503_IsFaulted_TheNextPassRetriesIt_AndItLands() - { - var ct = TestContext.Current.CancellationToken; - var (path, faulted) = await ReloadMeeting(r => r.DownloadStatus = HttpStatusCode.ServiceUnavailable, ct); - - faulted.Status.Should().Be(ModuleReloadStatus.Faulted, "a 503 may clear on its own: " + (faulted.Failure ?? "")); - faulted.Failure.Should().Contain("503"); - faulted.Items.Single().Transient.Should().BeTrue(); - faulted.CompletedAt.Should().BeNull("a fault is not an end"); - var faultedAt = faulted.FaultedAt ?? throw new InvalidOperationException("a Faulted request carries FaultedAt"); - - // The registry recovers; the next due pass re-arms the request and it lands N+1. - Registry.DownloadStatus = null; - (await ModuleReload.RetryFaulted(Mesh, PassInterval, faultedAt + PassInterval) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().Equal([path]); - - var landed = await AwaitRequest(path, - r => r.Attempt == 1 && (r.Status == ModuleReloadStatus.AwaitingRestart || ModuleReloadStatus.IsTerminal(r.Status)), ct); - landed.Status.Should().Be(ModuleReloadStatus.AwaitingRestart, landed.Failure ?? ""); - landed.Items.Single().TargetVersion.Should().Be("1.2.0"); - landed.Items.Single().Failure.Should().BeNull(); - Registry.Downloads.Should().Contain("1.2.0", "the retry fetched the bundle the 503 withheld"); - } - - [Theory(Timeout = 240_000)] - [InlineData(HttpStatusCode.TooManyRequests)] - [InlineData(HttpStatusCode.BadGateway)] - [InlineData(HttpStatusCode.GatewayTimeout)] - public async Task ADownloadAnsweringAStatusThatMayClear_IsFaulted(HttpStatusCode status) - { - var ct = TestContext.Current.CancellationToken; - var (_, end) = await ReloadMeeting(r => r.DownloadStatus = status, ct); - - end.Status.Should().Be(ModuleReloadStatus.Faulted, $"{(int)status} may clear on its own: {end.Failure}"); - end.Items.Single().Transient.Should().BeTrue(); - } - - [Fact(Timeout = 240_000)] - public async Task ADownloadThatTimesOut_IsFaulted() - { - var ct = TestContext.Current.CancellationToken; - var (_, end) = await ReloadMeeting(r => r.DownloadTimesOut = true, ct); - - end.Status.Should().Be(ModuleReloadStatus.Faulted, "a timeout never reached an answer: " + (end.Failure ?? "")); - end.Items.Single().Transient.Should().BeTrue(); - } - - [Theory(Timeout = 240_000)] - [InlineData(HttpStatusCode.NotFound)] - [InlineData(HttpStatusCode.Forbidden)] - public async Task ADownloadAnsweringADecidedStatus_IsFailed_AndNeverRetried(HttpStatusCode status) - { - var ct = TestContext.Current.CancellationToken; - var (path, end) = await ReloadMeeting(r => r.DownloadStatus = status, ct); - - end.Status.Should().Be(ModuleReloadStatus.Failed, $"{(int)status} is the registry's decided answer: {end.Failure}"); - end.Items.Single().Transient.Should().BeFalse(); - (await ModuleReload.RetryFaulted(Mesh, TimeSpan.Zero, DateTimeOffset.UtcNow.AddDays(1)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().BeEmpty("a decided failure is final — retrying it would get the same answer"); - var after = await AwaitRequest(path, _ => true, ct); - after.Status.Should().Be(ModuleReloadStatus.Failed); - after.Attempt.Should().Be(0); - } -} - -/// -/// 🚨 A failed restart ATTEMPT is Faulted; "this install cannot restart" is Failed -/// (MeshWeaver#6172). The restart lane answers when the -/// path exists but this attempt failed (a refused or unreachable hand-over, a call that threw) — -/// retried — and when the install has no way to -/// restart at all, which a retry would answer identically. -/// -public class ModuleReloadRestartAttemptTest(ITestOutputHelper output) : ModuleReloadScenario(output) -{ - private readonly ScriptedRestart lane = new(); - - protected override IModuleActivationRestart? RestartLane => lane; - - [Fact(Timeout = 240_000)] - public async Task AFailedRestartAttempt_IsFaulted_AndTheRetryAsksAgain() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - Registry.Serve("1.2.0", floor: FloorFixture.Below); - lane.Answers = [ModuleRestartKinds.Faulted, ModuleRestartKinds.Restarted]; - - var path = await Reload(ct); - var faulted = await AwaitRequest(path, r => r.Status is ModuleReloadStatus.Faulted || ModuleReloadStatus.IsTerminal(r.Status), ct); - faulted.Status.Should().Be(ModuleReloadStatus.Faulted, "a failed hand-over may get through next time: " + (faulted.Failure ?? "")); - faulted.Failure.Should().Contain(ModuleRestartKinds.Faulted); - var faultedAt = faulted.FaultedAt ?? throw new InvalidOperationException("a Faulted request carries FaultedAt"); - - (await ModuleReload.RetryFaulted(Mesh, TimeSpan.FromMinutes(30), faultedAt + TimeSpan.FromMinutes(30)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().Equal([path]); - // AwaitingRestart is written BEFORE the lane is asked; the lane's answer lands on ActivationDetail. - var waiting = await AwaitRequest(path, r => r.Attempt == 1 && r.Status == ModuleReloadStatus.AwaitingRestart - && r.ActivationDetail?.StartsWith(ModuleRestartKinds.Restarted) == true, ct); - waiting.Failure.Should().BeNull(); - lane.Asked.Should().Be(2, "the retry asks for the restart again — the failed attempt delivered nothing"); - } - - [Fact(Timeout = 240_000)] - public async Task AnInstallThatCannotRestart_IsFailed_AndNeverRetried() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - Registry.Serve("1.2.0", floor: FloorFixture.Below); - lane.Answers = [ModuleRestartKinds.Unavailable]; - - var path = await Reload(ct); - var end = await AwaitRequest(path, r => r.Status is ModuleReloadStatus.Faulted || ModuleReloadStatus.IsTerminal(r.Status), ct); - end.Status.Should().Be(ModuleReloadStatus.Failed, "no restart path is a decided answer: " + (end.Failure ?? "")); - (await ModuleReload.RetryFaulted(Mesh, TimeSpan.Zero, DateTimeOffset.UtcNow.AddDays(1)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().BeEmpty(); - lane.Asked.Should().Be(1); - } - - /// A restart lane that answers a scripted kind per call (the last one repeats). - private sealed class ScriptedRestart : IModuleActivationRestart - { - private int asked; - - public ImmutableList Answers { get; set; } = [ModuleRestartKinds.Restarted]; - - public int Asked => Volatile.Read(ref asked); - - public IObservable RequestRestart(string reason) => Observable.Defer(() => - { - var n = Interlocked.Increment(ref asked) - 1; - var kind = Answers[Math.Min(n, Answers.Count - 1)]; - return Observable.Return(new ModuleRestartOutcome(kind, $"scripted answer {n + 1}: {kind}")); - }); - } -} - -/// -/// The ONE classifier (): only the registry clients' -/// is a decided refusal; a bare -/// from incidental code is a crash, retried (#6172 review). -/// -public class TransientRegistryFailureTest -{ - [Fact] - public void ARegistryRefusal_IsDecided_AForeignInvalidOperation_IsACrash() - { - TransientRegistryFailure.IsTransient(new RegistryRefusedException("no manifest")).Should().BeFalse(); - TransientRegistryFailure.IsTransient(new InvalidOperationException("Sequence contains no elements")).Should().BeTrue(); - TransientRegistryFailure.IsTransient(new RegistryResponseException(HttpStatusCode.NotFound, "404")).Should().BeFalse(); - TransientRegistryFailure.IsTransient(new RegistryResponseException(HttpStatusCode.ServiceUnavailable, "503")).Should().BeTrue(); - TransientRegistryFailure.IsTransient(new AggregateException(new RegistryRefusedException("x"), new TimeoutException())).Should().BeFalse(); - TransientRegistryFailure.IsTransient(new AggregateException(new TimeoutException(), new InvalidOperationException("y"))).Should().BeTrue(); - } - - [Fact] - public void TheFeedRead_AsksTheSameClassifier() - { - RegistryUpdateReconciler.ShouldRetryFeedRead(new RegistryRefusedException("no repository")).Should().BeFalse(); - RegistryUpdateReconciler.ShouldRetryFeedRead(new InvalidOperationException("incidental")).Should().BeTrue(); - RegistryUpdateReconciler.ShouldRetryFeedRead(new RegistryResponseException(HttpStatusCode.TooManyRequests, "429")).Should().BeTrue(); - RegistryUpdateReconciler.ShouldRetryFeedRead(new RegistryResponseException(HttpStatusCode.Forbidden, "403")).Should().BeFalse(); - } - - /// - /// The HTTP bundle route's fault outcome asks the same classifier — the seam a reload reads - /// Transient from. A refusal must come out decided (never retried); a 503/timeout/crash - /// transient. Fails if the route marks every fault transient by itself again. - /// - [Fact] - public void TheHttpBundleRoute_AsksTheSameClassifier() - { - PluginBundleClient.LandingFaultOutcome("https://r", new RegistryRefusedException("no bundle layer")).Transient.Should().BeFalse(); - PluginBundleClient.LandingFaultOutcome("https://r", new RegistryResponseException(HttpStatusCode.NotFound, "404")).Transient.Should().BeFalse(); - PluginBundleClient.LandingFaultOutcome("https://r", new RegistryResponseException(HttpStatusCode.ServiceUnavailable, "503")).Transient.Should().BeTrue(); - PluginBundleClient.LandingFaultOutcome("https://r", new TimeoutException()).Transient.Should().BeTrue(); - PluginBundleClient.LandingFaultOutcome("https://r", new InvalidOperationException("incidental")).Transient.Should().BeTrue(); - } -} - -/// A live loader whose swap the test drives: it records swaps, can be told to fail, and -/// runs to simulate the process now holding the new generation. -internal sealed class SwappableModuleLoader : IModuleLiveActivation -{ - private int swaps; - - public int Swaps => Volatile.Read(ref swaps); - public Action? OnSwap { get; set; } - public string? Failure { get; set; } - - public bool CanSwap(string module) => true; - - public IObservable Swap(string module, string reason) => Observable.Defer(() => - { - Interlocked.Increment(ref swaps); - if (Failure is { } failure) - return Observable.Return(new ModuleReloadSwapOutcome(false, failure)); - OnSwap?.Invoke(); - return Observable.Return(new ModuleReloadSwapOutcome(true)); - }); -} diff --git a/test/Memex.Portal.Shared.Test/ModuleSeedStampProducerTest.cs b/test/Memex.Portal.Shared.Test/ModuleSeedStampProducerTest.cs deleted file mode 100644 index 78113ce8e6..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleSeedStampProducerTest.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System.Diagnostics; -using System.Text; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The image-seed stamp's PRODUCER and READER agree (MeshWeaver#6044). -/// -/// Boot prefers the image's own copy of a module over a store copy that is not a strictly -/// newer release — and the only record of what the image's copy WAS is the -/// modules/<Name>/module.seed.json the closure lane writes at image build -/// (memex/MeshModulesPublish.targets, WriteMeshModuleSeedStamps). A producer that -/// writes a field the reader does not look for, finds no package, or never runs at all leaves the -/// rule silently INERT: every image boots exactly as before #6044 and nothing says so. So this runs -/// the REAL task out of process, over a node-repository layout of the shape MeshWeaver.Plugins has -/// (src/<Module>/ beside <Package>/index.json + -/// manifest.lock), and reads the result back through the production reader -/// (). -/// -/// The second half is the stale case: a module no package declares gets NO stamp, and one left -/// over from an earlier publish into the same directory (publish never cleans) is removed — a stale -/// stamp would decide with a version the image no longer ships. -/// -/// 🚨 Fails RED, never skips, when dotnet cannot run the task: "could not measure" -/// must never read as "measured and fine". -/// -public class ModuleSeedStampProducerTest : IDisposable -{ - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-seedstamp-" + Guid.NewGuid().ToString("N")); - - public ModuleSeedStampProducerTest() => Directory.CreateDirectory(root); - - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - GC.SuppressFinalize(this); - } - - [Fact] - public void TheClosureLaneStamp_IsReadBackByTheBootReader_AndAStaleOneIsRemoved() - { - // A node repository: the package folder declares the module; the module's project lives - // under src/, exactly the Plugins layout (src/MeshWeaver.AI ↔ AI/index.json). - var package = Directory.CreateDirectory(Path.Combine(root, "repo", "AcmeWidgets")).FullName; - File.WriteAllText(Path.Combine(package, "index.json"), - """{ "$type": "MeshNode", "id": "AcmeWidgets", "content": { "$type": "PluginContent", "module": "Acme.Widgets", "version": "1.16" } }"""); - File.WriteAllText(Path.Combine(package, "manifest.lock"), - """{ "files": {}, "module": "AcmeWidgets", "moduleVersion": "0123456789abcdef", "schema": "mw-manifest/1", "version": "1.16.3" }"""); - var declaredProject = Path.Combine(root, "repo", "src", "Acme.Widgets", "Acme.Widgets.csproj"); - var undeclaredProject = Path.Combine(root, "repo", "src", "Acme.Orphan", "Acme.Orphan.csproj"); - foreach (var project in new[] { declaredProject, undeclaredProject }) - { - Directory.CreateDirectory(Path.GetDirectoryName(project)!); - File.WriteAllText(project, ""); - } - - var appDir = Path.Combine(root, "app") + Path.DirectorySeparatorChar; - // A stale stamp from an earlier publish into the same directory, for the module no package - // declares any more — it must not survive to decide with a version this image does not ship. - var staleDir = Directory.CreateDirectory(Path.Combine(appDir, "modules", "Acme.Orphan")).FullName; - File.WriteAllText(Path.Combine(staleDir, ImageModuleSeed.FileName), - """{ "module": "Acme.Orphan", "version": "9.9.9" }"""); - - var probe = Path.Combine(root, "probe.proj"); - File.WriteAllText(probe, $""" - - - - <_Probe Include="{declaredProject}" /> - <_Probe Include="{undeclaredProject}" /> - - - - - - """); - - var output = RunMsBuild(probe, "Probe"); - - var seed = ImageModuleSeed.Read(Path.Combine(appDir, "modules", "Acme.Widgets"), "Acme.Widgets"); - Assert.True(seed is not null, - $"the closure lane wrote no stamp the boot reader accepts — the #6044 rule would be inert. msbuild said:\n{output}"); - Assert.Equal("1.16.3", seed!.Version); - Assert.Equal("AcmeWidgets", seed.Package); - Assert.Equal("0123456789abcdef", seed.ModuleVersion); - - Assert.False(File.Exists(Path.Combine(staleDir, ImageModuleSeed.FileName)), - "a module no package declares must carry no stamp — a stale one decides with a version the image does not ship"); - } - - private static string TargetsPath() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - Assert.NotNull(dir); - var targets = Path.Combine(dir!.FullName, "memex", "MeshModulesPublish.targets"); - Assert.True(File.Exists(targets), $"{targets} is missing — follow the closure lane if it moved."); - return targets; - } - - /// Runs one target out of process. Both pipes are drained concurrently through the - /// event-based reader (the MsBuildPropertyProbe analysis: a sequential drain deadlocks), and a - /// wedge is a named failure, never a hang. - private static string RunMsBuild(string project, string target) - { - var psi = new ProcessStartInfo("dotnet") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - psi.ArgumentList.Add("msbuild"); - psi.ArgumentList.Add(project); - psi.ArgumentList.Add($"-t:{target}"); - psi.ArgumentList.Add("-nologo"); - psi.ArgumentList.Add("-warnaserror"); - - using var process = Process.Start(psi); - Assert.NotNull(process); - var outBuffer = new StringBuilder(); - var errBuffer = new StringBuilder(); - process!.OutputDataReceived += (_, e) => { if (e.Data is not null) outBuffer.AppendLine(e.Data); }; - process.ErrorDataReceived += (_, e) => { if (e.Data is not null) errBuffer.AppendLine(e.Data); }; - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - // Compiling an inline task and running it is a few seconds cold; two minutes is a wedge. - if (!process.WaitForExit(120_000)) - { - try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { /* already gone */ } - process.WaitForExit(); - Assert.Fail($"`dotnet msbuild -t:{target}` did not finish within 120s — a wedged MSBuild. Do not raise the bound.\n{outBuffer}\n{errBuffer}"); - } - process.WaitForExit(); - var output = outBuffer + "\n" + errBuffer; - Assert.True(process.ExitCode == 0, $"`dotnet msbuild -t:{target}` exited {process.ExitCode}:\n{output}"); - return output; - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleSetConvergenceTest.cs b/test/Memex.Portal.Shared.Test/ModuleSetConvergenceTest.cs deleted file mode 100644 index f121fb33ae..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleSetConvergenceTest.cs +++ /dev/null @@ -1,701 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 Every replica of one mesh runs ONE module set (#3395). -/// -/// The mechanism these pin. Landing moves each module's activation entry -/// INDEPENDENTLY, the moment that module's bytes are on disk, and a process pins whatever the -/// entries said at ITS boot instant. Measured on memex-cloud 2026-09-06: three pods of one -/// ReplicaSet, one image (3.0.0-rc9.ci.7693), booted 11:33:39 / 11:41:04 / 12:51:21 around a -/// landing wave at 12:18–12:27, and 39 of 40 pinned generations differed between the two -/// older pods and the newest. They share ONE NodeType node, so each stamped the module set IT -/// resolved and each read the other's stamp as stale: the pair ping-pongs recompiles, and where one -/// replica's set lacks a module the sources need, a healthy NodeType FAILS with no source change. -/// The issue was filed as "one boot, two module sets 15 s apart"; it is three replicas, 16 minutes -/// apart — a rolling replacement across a landing wave, not a process racing itself. -/// -/// The fix these prove. A landing wave no longer moves what the mesh RUNS. It stages -/// bytes, and when the whole wave is done it PROPOSES one immutable, sequenced set -/// (); boot loads the mesh's newest proposal -/// (), never its own read of the moving -/// per-module entries. Two replicas booting at any two instants between two wave completions -/// therefore load identical bytes, and a boot mid-wave cannot observe a half-landed mix at all. -/// -/// What makes each of these able to FAIL. Every one drives the REAL -/// against a real temp volume and composes the boot exactly as -/// MemexConfiguration.ConfigureMemexMesh does — nothing is mocked and nothing re-derives the -/// rule locally. Neutralising the convergence (making -/// return its input) flips -/// , -/// and -/// — the three that ARE the -/// divergence. -/// -public class ModuleSetConvergenceTest : IDisposable -{ - private const string ModuleA = "MeshWeaver.Payments.Stripe"; - private const string ModuleB = "MeshWeaver.Social"; - private const string ModuleC = "MeshWeaver.Speech"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-moduleset-" + Guid.NewGuid().ToString("N")); - - private readonly ModuleLandingService landing; - - /// Creates the per-test deployment root and the REAL landing service over it. - public ModuleSetConvergenceTest() - { - Directory.CreateDirectory(root); - landing = new ModuleLandingService(baseDirectory: root); - } - - /// - public void Dispose() - { - landing.Dispose(); - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - GC.SuppressFinalize(this); - } - - // ───────────────────────────────────────────────────────────── the mechanism - - /// - /// 🚨 THE repro. Three replicas boot across one landing wave — before it, in the middle - /// of it, and after it — exactly the memex-cloud shape. All three must run the SAME generation - /// of every module: the two that boot before the wave PROPOSES load the previous set, and the - /// one that boots after loads the proposed one, so the mesh is on at most ONE set at a time - /// and the replicas that share a set share it exactly. - /// - /// Without the convergence the middle replica takes whatever half of the wave had landed - /// by its boot instant — a THIRD set, distinct from both — which is the 39-of-40 divergence. - /// - [Fact] - public async Task ReplicasBootingAroundOneWave_LoadTheIdenticalModuleSet() - { - await LandWave(ModuleA, ModuleB, ModuleC); - var first = BootReplica(); - - // The second wave starts: ModuleA's bytes are down and its ENTRY has already moved, which - // is the state the middle replica of the incident booted into. - await Land(ModuleA); - var second = BootReplica(); - - // …and only now does the wave finish and propose. - await Land(ModuleB); - await Land(ModuleC); - await ProposeWave(); - var third = BootReplica(); - - Assert.Equal(first, second); - Assert.NotEqual(first, third); - // The whole set moved together — a wave is all-or-nothing, never per module. - Assert.All(first.Keys, name => Assert.NotEqual(first[name], third[name])); - } - - /// - /// A replica booting while a wave is landing must never see the TORN combination — some - /// modules from the new wave, the rest from the old. That combination is one no wave ever - /// produced and nothing was ever tested against; it is what makes a NodeType compile fail - /// against a module set that "resolved fine fifteen seconds earlier". - /// - [Fact] - public async Task AReplicaBootingMidWave_NeverLoadsAHalfLandedMix() - { - await LandWave(ModuleA, ModuleB, ModuleC); - var before = BootReplica(); - - await Land(ModuleA); - var midWave = BootReplica(); - - Assert.Equal(before[ModuleA], midWave[ModuleA]); - Assert.Equal(before, midWave); - } - - /// - /// 🚨 A wave that dies half-landed is a FAILURE, not drift. The mesh stays on the set it - /// was on — nothing half-landed is ever adopted by anybody — and the modules whose bytes DID - /// land are named as stranded on every surface that reads the report. The old behaviour had no - /// such state: those modules simply ran on whichever pods happened to boot after their own - /// landing and not on the others, with every surface reporting Healthy. - /// - [Fact] - public async Task AWaveThatNeverCompletes_LeavesTheMeshOnTheSetItWasOn() - { - await LandWave(ModuleA, ModuleB); - var settled = BootReplica(); - - // A wave lands a module the mesh has never carried, and dies — no ProposeWave(). - await Land(ModuleC); - - Assert.Equal(settled, BootReplica()); - - var report = new PendingModuleActivations(root).Read( - loadedAssemblyNames: settled.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase), - loadedModuleGenerations: settled); - Assert.True(report.HasDeferred); - Assert.Equal(ModuleC, Assert.Single(report.Deferred).Name); - Assert.Contains("landed but are in NO proposed module set", report.Describe()); - // 🚨 And NOT reported as pending: "a restart activates this" would be a promise no restart - // can keep, because boot loads the mesh's set and the stranded module is not in it. - Assert.False(report.HasPending); - } - - /// - /// The convergence window, stated positively: it OPENS when a wave proposes a set nothing is - /// serving yet and CLOSES at the first replica that boots onto it. That is what makes "the old - /// replica is still serving the previous set" a known, bounded, visible state rather than - /// silence — and a window that stays open names a wave whose bytes no replica runs. - /// - [Fact] - public async Task TheConvergenceWindow_OpensOnTheProposalAndClosesOnTheFirstBoot() - { - await LandWave(ModuleA); - BootReplica(); - Assert.False(ModuleSetStore.Read(root).ConvergencePending); - - await LandWave(ModuleA); - var afterProposal = ModuleSetStore.Read(root); - Assert.True(afterProposal.ConvergencePending); - Assert.Contains("NO replica has booted onto it yet", ModuleSetStore.Describe(afterProposal)); - - BootReplica(); - var afterRestart = ModuleSetStore.Read(root); - Assert.False(afterRestart.ConvergencePending); - Assert.Equal(afterProposal.Proposed!.Id, afterRestart.Current!.Id); - } - - // ───────────────────────────────────────────────────────────── the store's own rules - - /// - /// A wave that changed nothing proposes nothing, so sequences count waves that moved bytes - /// rather than boots. Without this the auto-update reconcile — which runs at EVERY boot and - /// normally lands nothing — would mint a fresh set on every restart and re-open the - /// convergence window forever. - /// - [Fact] - public async Task AWaveThatLandedNothing_ProposesNothing() - { - await LandWave(ModuleA, ModuleB); - var first = ModuleSetStore.Read(root).Proposed!; - - Assert.Null(await ProposeWave()); - Assert.Equal(first.Sequence, ModuleSetStore.Read(root).Proposed!.Sequence); - } - - /// - /// 🚨 Two replicas can finish a wave at the same moment and each write sequence N+1. Both - /// records survive — nothing here is ever renamed over a live file — so every reader must - /// resolve the tie the SAME way without coordinating, and the conflict must be reported rather - /// than absorbed. Nothing is lost either: the proposal is derived from the activation record, - /// so the next wave carries everything both replicas landed. - /// - [Fact] - public async Task TwoReplicasProposingOneSequence_ResolveToTheSameSetAndSaySo() - { - await LandWave(ModuleA); - var landed = ModuleActivationSidecar.Read(root); - - // The second replica's wave also landed ModuleB, so its set for the SAME sequence differs. - await Land(ModuleB); - var rival = ModuleSetStore.Propose(root, ModuleActivationSidecar.Read(root), "replica-2"); - Assert.NotNull(rival); - WriteRivalProposalAtSameSequence(rival!.Sequence, landed); - - var reported = new List(); - var index = ModuleSetStore.Read(root, reported.Add); - - Assert.Equal([rival.Sequence], index.ConflictingSequences); - Assert.Contains(reported, m => m.Contains("proposed by more than one replica")); - // Deterministic: the ordinally smallest id, so two replicas reading this pick one set. - Assert.Equal( - ProposalIds(rival.Sequence).OrderBy(id => id, StringComparer.Ordinal).First(), - index.Proposed!.Id); - Assert.Equal(index.Proposed.Id, ModuleSetStore.Read(root).Proposed!.Id); - } - - /// - /// 🚨 THE repro of #3656. Two replicas that both decide to land the SAME published - /// bundle — the normal shape of a reconcile wave, because every replica reconciles the same - /// feed at boot and on every ModulePublished broadcast — must land ONE generation and - /// derive ONE set. The second one's proposal is then the no-op it should always have been, and - /// no conflicting record is ever written. - /// - /// Before the fix the generation leaf was name@<8 random hex>, so the two - /// identical landings wrote the identical bytes into two DIFFERENT directories; the set each - /// derived from the activation record named a different generation for that module, both were - /// proposed at the same sequence, and the loser's record then sat at the mesh's newest sequence - /// being re-read and re-reported by every pod's sweep on every boot — forever, because - /// only reaches BELOW the current set. Measured on - /// memex-cloud 2026-09-08: 100 duplicate sequences, 687 set records, 843 generation - /// directories. - /// - /// is the - /// control that keeps every assertion here honest: the single-generation and no-conflict claims - /// FAIL there, so neither can pass by nothing ever landing. - /// - [Fact] - public async Task TwoReplicasLandingOneBundle_LandOneGenerationAndProposeOneSet() - { - var build = await Land(ModuleA); // replica 1's wave lands the published bundle - var afterFirst = await ProposeWave(); - Assert.NotNull(afterFirst); - - // Replica 2 read the same feed and decided to land the same bundle: identical bytes. - await LandBuild(ModuleA, build); - var afterSecond = await ProposeWave(); - - Assert.Null(afterSecond); // the set did not move — there was nothing new - Assert.Equal( - Path.GetFileName(Assert.Single(GenerationDirectories(ModuleA))), - ModuleSetStore.Read(root).Proposed!.Generations[ModuleA]); - var index = ModuleSetStore.Read(root); - Assert.Empty(index.ConflictingSequences); - Assert.Equal(afterFirst!.Id, index.Proposed!.Id); - Assert.Equal(afterFirst.Sequence, index.Proposed.Sequence); - // …and the entry did not record a fallback to ITSELF: nothing was displaced. - Assert.Null(ModuleActivationSidecar.Read(root).Entries - .Single(e => e.Name == ModuleA).PreviousDirectory); - } - - /// - /// The control for - /// — and the half of #3656 that must NOT change. Two replicas landing DIFFERENT content is a - /// genuine divergence: two generations, two sets, and the conflict the design exists to resolve - /// and report. Without this, "one generation, no conflict" would pass on a harness where - /// nothing ever lands twice. - /// - [Fact] - public async Task TwoReplicasLandingDifferentBuilds_StillDeriveTwoGenerations() - { - await Land(ModuleA); - var first = await ProposeWave(); - Assert.NotNull(first); - - await Land(ModuleA); // a DIFFERENT build of the same module - var second = await ProposeWave(); - - Assert.NotNull(second); - Assert.NotEqual(first!.Id, second!.Id); - Assert.Equal(2, GenerationDirectories(ModuleA).Count); - Assert.NotEqual( - first.Generations[ModuleA], ModuleSetStore.Read(root).Proposed!.Generations[ModuleA]); - } - - /// - /// 🚨 The other half of #3656: a duplicate that HAS been produced must be reported once and - /// then retired, never re-read and re-reported by every pod's sweep on every boot. The loser is - /// housekeeping the moment the conflict is decided — every reader resolves to the same winner, - /// and the next proposal is derived from the ACTIVATION RECORD, so the loser's landings ride the - /// following wave whether its record survives or not. - /// - /// The first read is the POSITIVE CONTROL: it proves the notice can fire on this volume, - /// so the silence after the sweep is the retirement and not an assertion that could never have - /// been made. The winner is asserted UNCHANGED across the retirement — the one thing this must - /// never do is remove the record the mesh resolves to. - /// - [Fact] - public async Task ADecidedDuplicate_IsRetiredSoNoLaterSweepCanReReportIt() - { - await LandWave(ModuleA); - BootReplica(); // sequence 1 is adopted - var landed = ModuleActivationSidecar.Read(root); - await Land(ModuleB); - var rival = ModuleSetStore.Propose(root, ModuleActivationSidecar.Read(root), "replica-2")!; - WriteRivalProposalAtSameSequence(rival.Sequence, landed); - BootReplica(); - - // Positive control: with both records on the volume the conflict IS reported. - var before = new List(); - var winner = ModuleSetStore.Read(root, onCorrupt: null, before.Add).Proposed!; - Assert.Contains(before, m => m.Contains("proposed by more than one replica")); - Assert.Equal(2, ProposalIds(rival.Sequence).Count); - - var retired = new List(); - var removed = ModuleSetStore.PruneDuplicateProposals(root, retired.Add); - - Assert.Equal(1, removed); - Assert.Contains(retired, m => m.Contains("decided") && m.Contains(winner.Id)); - // One record left, the SAME winner, and nothing left for a later sweep to re-report. - var after = new List(); - var index = ModuleSetStore.Read(root, onCorrupt: null, after.Add); - Assert.Empty(after); - Assert.Empty(index.ConflictingSequences); - Assert.Equal(winner.Id, index.Proposed!.Id); - Assert.Equal([winner.Id], ProposalIds(rival.Sequence)); - } - - /// - /// 🚨 Fail closed, the #2509 rule: a proposal record whose id cannot be READ makes the winner - /// unknown, so nothing at that sequence is retired. The one thing this pass must never do is - /// delete the record the mesh resolves to. - /// - [Fact] - public async Task ADuplicateWithAnUnreadableRecord_RetiresNothing() - { - await LandWave(ModuleA); - var landed = ModuleActivationSidecar.Read(root); - await Land(ModuleB); - var rival = ModuleSetStore.Propose(root, ModuleActivationSidecar.Read(root), "replica-2")!; - WriteRivalProposalAtSameSequence(rival.Sequence, landed); - Assert.Equal(2, ProposalIds(rival.Sequence).Count); - - // A THIRD record at the same sequence that cannot be read at all. - File.WriteAllText( - Path.Combine(ModuleSetStore.SetsDirectory(root), - $"{rival.Sequence:D9}-garbage000000000.proposed.json"), - "{ not json"); - - var warnings = new List(); - Assert.Equal(0, ModuleSetStore.PruneDuplicateProposals(root, onWarn: warnings.Add)); - - Assert.Contains(warnings, m => m.Contains("garbage000000000")); - Assert.Equal(3, ProposalFileCount(rival.Sequence)); - } - - /// Every generation directory on the volume for one module. - private IReadOnlyList GenerationDirectories(string name) => - [.. Directory.EnumerateDirectories(Path.Combine(root, "modules"), - name + "@*", SearchOption.TopDirectoryOnly)]; - - /// How many proposal records sit at one sequence — counted from the NAMES, so a - /// record that cannot be parsed still counts. - private int ProposalFileCount(long sequence) => - Directory.EnumerateFiles(ModuleSetStore.SetsDirectory(root), "*.proposed.json") - .Count(f => Path.GetFileName(f) - .StartsWith(sequence.ToString("D9") + "-", StringComparison.Ordinal)); - - /// - /// 🚨 #3675: a sequence two replicas proposed is a decided outcome, not a record that could not - /// be read. The three-argument - /// keeps the two apart — the notice never reaches the fault channel — while the two-argument - /// overload still surfaces it to every caller that read it there before (the health report's - /// notes, the boot log), so nothing that used to be said falls silent. - /// - [Fact] - public async Task ADuplicateProposal_IsANotice_NeverAFault() - { - await LandWave(ModuleA); - var landed = ModuleActivationSidecar.Read(root); - await Land(ModuleB); - var rival = ModuleSetStore.Propose(root, ModuleActivationSidecar.Read(root), "replica-2")!; - WriteRivalProposalAtSameSequence(rival.Sequence, landed); - - var faults = new List(); - var notices = new List(); - var index = ModuleSetStore.Read(root, faults.Add, notices.Add); - - Assert.Empty(faults); - Assert.Contains(notices, m => m.Contains("proposed by more than one replica")); - Assert.Equal([rival.Sequence], index.ConflictingSequences); - - // Compatibility: the two-argument overload still says it, where every caller heard it. - var reported = new List(); - ModuleSetStore.Read(root, reported.Add); - Assert.Contains(reported, m => m.Contains("proposed by more than one replica")); - - // And a record that genuinely cannot be read IS a fault, on the three-argument overload too. - // At the NEWEST sequence, so it is a record the reader has to open (#3676 opens only the - // deciding records; an unreadable one below them is not consulted and not a fault). - var garbage = Path.Combine(ModuleSetStore.SetsDirectory(root), $"{rival.Sequence + 1:D9}-garbage000000000.proposed.json"); - File.WriteAllText(garbage, "{ not json"); - faults.Clear(); - ModuleSetStore.Read(root, faults.Add, notices.Add); - Assert.Contains(faults, m => m.Contains("garbage000000000")); - } - - /// - /// 🚨 #3675, the consequence that mattered: the GC used to count the duplicate-proposal notice as - /// a read fault and fail closed on EVERY pass for as long as one such pair existed — on - /// memex-cloud, 100 duplicate sequences kept 687 set records and 843 generation directories - /// alive that no pass ever reclaimed, and reading that pile is what put the /health probe over - /// its timeout. With a duplicate pair on the volume the sweep must still prune the records - /// below the current set and still reclaim an orphan generation — and a record that really - /// cannot be read must still stop it (#2509 is untouched). - /// - [Fact] - public async Task GarbageCollection_StillSweepsWithADuplicateProposalOnTheVolume() - { - await LandWave(ModuleA); - BootReplica(); // sequence 1 adopted — the mesh is on it - var landed = ModuleActivationSidecar.Read(root); - await Land(ModuleB); - var rival = ModuleSetStore.Propose(root, ModuleActivationSidecar.Read(root), "replica-2")!; - WriteRivalProposalAtSameSequence(rival.Sequence, landed); - BootReplica(); // the winner at sequence 2 is adopted - var current = ModuleSetStore.Read(root).Current!.Sequence; - Assert.Equal(rival.Sequence, current); - - var orphan = Path.Combine(root, "modules", "MeshWeaver.Orphan@deadbeef"); - Directory.CreateDirectory(orphan); - File.WriteAllText(Path.Combine(orphan, "MeshWeaver.Orphan.dll"), "not a module"); - var below = SetRecordsBelow(current); - Assert.NotEmpty(below); - - ModuleLandingService.CollectGarbage(root, minAge: TimeSpan.Zero, nowUtc: DateTime.UtcNow.AddHours(1)); - - Assert.False(Directory.Exists(orphan), "an unreferenced generation survived a pass that had nothing unreadable to fail closed on"); - Assert.Empty(SetRecordsBelow(current)); - // 🚨 #3656: the duplicate pair is DECIDED, so the same pass retires the loser — one record - // is left at the sequence and it is the winner every reader resolves to. Before #3656 both - // survived, and every pod's next sweep re-read and re-reported the same decided conflict. - Assert.Equal([ModuleSetStore.Read(root).Proposed!.Id], ProposalIds(current)); - - // #2509 still holds: one record that cannot be read, and the pass reclaims nothing. - Directory.CreateDirectory(orphan); - File.WriteAllText(Path.Combine(orphan, "MeshWeaver.Orphan.dll"), "not a module"); - File.WriteAllText(Path.Combine(ModuleSetStore.SetsDirectory(root), $"{current + 1:D9}-garbage000000000.proposed.json"), "{ not json"); - ModuleLandingService.CollectGarbage(root, minAge: TimeSpan.Zero, nowUtc: DateTime.UtcNow.AddHours(1)); - Assert.True(Directory.Exists(orphan), "a pass with an unreadable set record must not reclaim anything"); - } - - private IReadOnlyList SetRecordsBelow(long sequence) => - [.. Directory.EnumerateFiles(ModuleSetStore.SetsDirectory(root), "*.json") - .Select(f => Path.GetFileName(f)) - .Where(f => long.TryParse(f[..f.IndexOf('-')], out var seq) && seq < sequence)]; - - /// - /// 🚨 The generations the mesh's set PINS are referenced even when the activation entries have - /// moved past them — otherwise the convergence would re-open the 2026-08-27 outage from the - /// other side, with GC reclaiming the very bytes every replica is executing. - /// - [Fact] - public async Task GarbageCollection_KeepsTheGenerationsTheMeshSetPins() - { - await LandWave(ModuleA); - var pinned = ModuleSetStore.Read(root).Proposed!.Generations[ModuleA]; - - // A wave lands a newer generation and has not proposed: the entry has moved off `pinned`, - // so the activation record alone no longer references it — but every replica runs it. - await Land(ModuleA); - Assert.NotEqual(pinned, ModuleActivationSidecar.Read(root).Entries.Single(e => e.Name == ModuleA).Directory); - - ModuleLandingService.CollectGarbage(root, minAge: TimeSpan.Zero, nowUtc: DateTime.UtcNow.AddHours(1)); - - Assert.True(Directory.Exists(Path.Combine(root, "modules", pinned)), - "the generation the mesh's module set pins was reclaimed — every replica is running it"); - } - - /// - /// 🚨 The one degradation, and it is REPORTED. If the generation the mesh's set pins is not on - /// the volume — a pod on the PREVIOUS platform build sweeping by the entries alone during this - /// change's own rollout, a manual deletion, a partial restore — the two candidates are "run the - /// generation the entry names" and "run nothing", and running nothing is the WORSE half of - /// #3395: a missing module is what turns a healthy NodeType into a failed one. So it falls back - /// and says so, rather than handing boot's own existence gate a generation it would then skip. - /// - [Fact] - public async Task WhenTheSetsGenerationIsGone_ItFallsBackToTheEntryAndSaysSo() - { - await LandWave(ModuleA); - var pinned = ModuleSetStore.Read(root).Proposed!.Generations[ModuleA]; - - // A newer generation lands and is not proposed; then the pinned one is reclaimed. - await Land(ModuleA); - Directory.Delete(Path.Combine(root, "modules", pinned), recursive: true); - - var degraded = new List<(string Module, string Reason)>(); - var projected = ModuleActivationBoot.ProjectOntoMeshSet( - ModuleActivationSidecar.Read(root), - ModuleSetStore.Read(root).Proposed, - onDeferred: null, - landedDllExists: entry => ModuleActivationBoot.LandedModuleDllExists(root, entry), - onSetGenerationMissing: (module, reason) => degraded.Add((module, reason))); - - var entry = Assert.Single(projected.Entries); - Assert.NotEqual(pinned, entry.Directory); - Assert.True(ModuleActivationBoot.LandedModuleDllExists(root, entry)); - Assert.Equal(ModuleA, Assert.Single(degraded).Module); - Assert.Contains("whose bytes are NOT on the volume", degraded[0].Reason); - } - - /// - /// 🚨 A LEGACY fixed-folder entry — one with no Directory, resolving to - /// modules/<name>/ — names no generation, so GenerationsOf excludes it by - /// design and the set can say nothing about it. Asking "does the set name it?" would therefore - /// DEFER it, i.e. silently disable every such module on every deployment that still has one. - /// It must pass through, exactly as a disabled entry does. (Copilot review, #3444.) - /// - [Fact] - public async Task ALegacyFixedFolderEntry_IsNeverDeferredByTheMeshSet() - { - await LandWave(ModuleA); - - // The pre-generation shape: an entry with no Directory, bytes in modules//. - Directory.CreateDirectory(Path.Combine(root, "modules", ModuleB)); - File.WriteAllBytes(Path.Combine(root, "modules", ModuleB, ModuleB + ".dll"), [0x4D, 0x5A]); - ModuleActivationSidecar.WriteEntry(root, new ModuleActivationEntry { Name = ModuleB }); - - var deferred = new List(); - var projected = ModuleActivationBoot.ProjectOntoMeshSet( - ModuleActivationSidecar.Read(root), - ModuleSetStore.Read(root).Proposed, - (module, _) => deferred.Add(module), - entry => ModuleActivationBoot.LandedModuleDllExists(root, entry)); - - Assert.Empty(deferred); - var legacy = Assert.Single(projected.Entries, e => e.Name == ModuleB); - Assert.Null(legacy.Directory); - Assert.Contains( - ModuleActivationBoot.ComputeEffectiveModuleEntries( - baselineEntries: null, projected, ModulePlatformFloor.DeclineReason, - entry => ModuleActivationBoot.LandedModuleDllExists(root, entry)), - m => m.Landed?.Name == ModuleB); - } - - /// - /// 🚨 A deterministically-RESOLVED set conflict is a handled condition with a valid index - /// behind it, not an absence of evidence. Folding the store's notes into - /// UndeterminedReason would make the whole activation report — and the health check - /// reading it — go unknown for a state every replica resolves identically. The notes belong on - /// the mesh-set line. (Copilot review, #3444.) - /// - [Fact] - public async Task AResolvedSetConflict_IsReportedWithoutMakingTheStateUndetermined() - { - await LandWave(ModuleA); - var landed = ModuleActivationSidecar.Read(root); - await Land(ModuleB); - var rival = ModuleSetStore.Propose(root, ModuleActivationSidecar.Read(root), "replica-2")!; - WriteRivalProposalAtSameSequence(rival.Sequence, landed); - - var report = new PendingModuleActivations(root).Read( - loadedAssemblyNames: new HashSet(StringComparer.OrdinalIgnoreCase), - loadedModuleGenerations: ImmutableDictionary.Empty); - - Assert.False(report.IsUndetermined); - Assert.Contains("proposed by more than one replica", report.MeshModuleSet); - } - - // ───────────────────────────────────────────────────────────── harness - - /// Lands a NEW BUILD of one module through the REAL landing service — a fresh - /// generation plus the activation entry, exactly as an auto-update or an install does. Returns - /// the build number, so a test can hand the SAME build to and model the - /// second replica landing the bundle the first one just landed. - /// - /// 🚨 Every call lands DIFFERENT bytes, and that is load-bearing since #3656. The generation - /// leaf is now the CONTENT ADDRESS of the landing, so re-landing identical bytes resolves to - /// the generation that is already there — correctly, and deliberately. A helper that landed one - /// fixed byte array over and over would therefore model a RE-LAND, not an update, and every - /// "the wave moved the mesh's set" assertion below would silently be asserting the opposite of - /// what it says. Before #3656 the random leaf hid the difference: identical bytes still minted - /// a new generation, which is precisely the defect (two replicas landing one bundle produced - /// two generations, two sets and a permanent conflict record). - /// - private async Task Land(string name) - { - var build = ++landings; - await LandBuild(name, build); - return build; - } - - /// The number of builds this test has landed — the content differentiator. - private int landings; - - /// - /// Lands ONE identified build of a module. The build number rides in a static asset, so two - /// calls with the same number write byte-identical bundles — what two replicas adopting one - /// published bundle actually do — and two calls with different numbers write different ones. - /// - /// - /// 🚨 REAL assembly bytes, not a three-byte MZ stand-in. Since #3538 the landing MEASURES the - /// module's link requirements against this platform's surface, so bytes that are not a managed - /// assembly are refused — correctly, and this test is about generations, not about that gate. - /// Any assembly whose references this process carries works; the packaging assembly is the - /// same one ServedModuleBytesTest uses for the same reason. - /// - private async Task LandBuild(string name, int build) => - await landing.LandModule( - name, - [(name + ".dll", RealAssemblyBytes)], - version: $"1.0.{build}", - staticAssets: [("wwwroot/build.txt", Encoding.UTF8.GetBytes($"build {build}"))]) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - /// A real, loadable managed assembly's bytes — see . - private static byte[] RealAssemblyBytes => - File.ReadAllBytes(typeof(MeshWeaver.Plugin.Packaging.BundleReader).Assembly.Location); - - /// Closes a landing wave — the coordination step that moves the mesh's set. - private async Task ProposeWave() => - await landing.ProposeModuleSet().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - /// A COMPLETE wave: land every module, then propose once. - private async Task LandWave(params string[] names) - { - foreach (var name in names) - await Land(name); - await ProposeWave(); - } - - /// - /// One replica's boot, composed EXACTLY as MemexConfiguration.ConfigureMemexMesh does — - /// read the record, read the mesh's sets, project, compute the union, record the adoption. - /// Re-deriving any of it here would let this test agree with itself while the portal diverges. - /// - /// Module name → the generation directory this replica loaded it from. - private ImmutableSortedDictionary BootReplica() - { - var persisted = ModuleActivationSidecar.Read(root); - var sets = ModuleSetStore.Read(root); - var onMeshSet = ModuleActivationBoot.ProjectOntoMeshSet( - persisted, - sets.Proposed, - onDeferred: null, - landedDllExists: entry => ModuleActivationBoot.LandedModuleDllExists(root, entry)); - var effective = ModuleActivationBoot.ComputeEffectiveModuleEntries( - baselineEntries: null, - onMeshSet, - ModulePlatformFloor.DeclineReason, - entry => ModuleActivationBoot.LandedModuleDllExists(root, entry)); - if (sets.Proposed is { } adopted) - ModuleSetStore.RecordAdoption(root, adopted, adoptedBy: "replica"); - return effective - .Where(m => m.Landed is { Directory.Length: > 0 }) - .ToImmutableSortedDictionary( - m => m.Landed!.Name, m => m.Landed!.Directory!, StringComparer.OrdinalIgnoreCase); - } - - private static readonly JsonSerializerOptions SetJson = new(JsonSerializerDefaults.Web); - - /// The ids of every proposal file recorded at one sequence — read off the volume, so - /// the conflict test asserts on what is actually there rather than what it expected. - private ImmutableList ProposalIds(long sequence) - { - var prefix = sequence.ToString("D9") + "-"; - return [.. Directory - .EnumerateFiles(ModuleSetStore.SetsDirectory(root), "*.proposed.json") - .Where(f => Path.GetFileName(f).StartsWith(prefix, StringComparison.Ordinal)) - .Select(f => JsonSerializer.Deserialize(File.ReadAllText(f), SetJson)!.Id)]; - } - - /// - /// Writes a SECOND proposal at from a different activation record — - /// the two-replicas-one-sequence race, arranged rather than waited for. - /// - private void WriteRivalProposalAtSameSequence(long sequence, ModuleActivationList other) - { - var generations = ModuleSetStore.GenerationsOf(other); - var set = new ModuleSet(sequence, ModuleSetStore.IdOf(generations), generations, DateTime.UtcNow, "replica-1"); - File.WriteAllText( - Path.Combine( - ModuleSetStore.SetsDirectory(root), - $"{sequence:D9}-{set.Id[..Math.Min(16, set.Id.Length)]}.proposed.json"), - JsonSerializer.Serialize(set, SetJson)); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleSetStoreReadsOnlyDecidingRecordsTest.cs b/test/Memex.Portal.Shared.Test/ModuleSetStoreReadsOnlyDecidingRecordsTest.cs deleted file mode 100644 index f18482d766..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleSetStoreReadsOnlyDecidingRecordsTest.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Text.Json; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// decides from the record NAMES and opens only the records the -/// decision needs. Measured on memex-cloud, 2026-09-08: 687 set records under modules/sets -/// on Azure Files, every one read on every call, 10 s per call — inside a health check that the -/// startup probe asks every 10 s with a 5 s timeout. No new pod could pass the probe, so the 8059 -/// rollout sat for hours on two ageing replicas and KEDA could add nothing. -/// -/// Each test here plants records that the OLD reader would have opened and the new one must -/// not: a corrupt superseded proposal is the falsifier — reading it reports it, and the assertion -/// is that nothing is reported. The records are written in the store's own on-disk shape -/// (<sequence:D9>-<id16>.proposed.json, web-cased JSON) so the reader is measured -/// against the format it will meet on a volume, not against a mock. -/// -public sealed class ModuleSetStoreReadsOnlyDecidingRecordsTest : IDisposable -{ - private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web) { WriteIndented = true }; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-setstore-read-" + Guid.NewGuid().ToString("N")); - - public ModuleSetStoreReadsOnlyDecidingRecordsTest() - { - Directory.CreateDirectory(ModuleSetStore.SetsDirectory(root)); - } - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - private static ModuleSet Set(long sequence, string idSeed, string generation = "g") - => new( - sequence, - Id: Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData( - System.Text.Encoding.UTF8.GetBytes(idSeed))), - Generations: ImmutableSortedDictionary.Empty - .WithComparers(StringComparer.OrdinalIgnoreCase) - .Add("MeshWeaver.Speech", generation + sequence.ToString("D4")), - ProposedAtUtc: new DateTime(2026, 9, 8, 0, 0, 0, DateTimeKind.Utc).AddSeconds(sequence), - ProposedBy: "replica-" + idSeed); - - /// Writes a proposal exactly where and how the store writes one. - private string WriteProposal(ModuleSet set, string? fileNameOverride = null) - { - var name = fileNameOverride ?? (set.Sequence.ToString("D9") + "-" + set.Id[..16] + ".proposed.json"); - var path = Path.Combine(ModuleSetStore.SetsDirectory(root), name); - File.WriteAllText(path, JsonSerializer.Serialize(set, Web)); - return path; - } - - private static void Corrupt(string path) => File.WriteAllText(path, "{"); - - [Fact] - public void ACorruptSupersededRecordIsNeverOpened_SoItIsNeverReported() - { - // 300 sequences, one proposal each; the one before the newest is the adopted (current) set. - var files = new Dictionary(); - for (var sequence = 1L; sequence <= 300; sequence++) - files[sequence] = WriteProposal(Set(sequence, "seed-" + sequence)); - var adopted = Set(299, "seed-299"); - ModuleSetStore.RecordAdoption(root, adopted, adopted.Generations, "replica-x"); - - // Every record no decision depends on is made unreadable. The old reader opened all of - // them and reported each; this reader must not touch a single one. - foreach (var (sequence, path) in files) - if (sequence < 299) - Corrupt(path); - - var reported = new List(); - var index = ModuleSetStore.Read(root, reported.Add); - - Assert.Empty(reported); - Assert.Equal(300, index.Proposed!.Sequence); - Assert.Equal(299, index.Current!.Sequence); - Assert.Equal(adopted.Id, index.Current.Id); - Assert.Equal("g0299", index.RunningGenerations["meshweaver.speech"]); - Assert.True(index.ConvergencePending); - } - - [Fact] - public void ConflictsAreReportedOnlyForTheSequencesDecidedOn() - { - // Two replicas proposed sequence 100 (history) and two proposed sequence 300 (the newest). - WriteProposal(Set(100, "a-100")); - WriteProposal(Set(100, "b-100")); - for (var sequence = 101L; sequence < 300; sequence++) - WriteProposal(Set(sequence, "seed-" + sequence)); - var alpha = Set(300, "a-300"); - var beta = Set(300, "b-300"); - WriteProposal(alpha); - WriteProposal(beta); - - var reported = new List(); - var index = ModuleSetStore.Read(root, reported.Add); - - Assert.Equal([300L], index.ConflictingSequences); - var winner = string.CompareOrdinal(alpha.Id, beta.Id) < 0 ? alpha : beta; - Assert.Equal(winner.Id, index.Proposed!.Id); - var notice = Assert.Single(reported); - Assert.Contains("sequence 300", notice); - Assert.DoesNotContain("sequence 100", notice); - } - - [Fact] - public void ANewestSequenceThatCannotBeRead_FallsToTheNextReadableOne_AndReportsExactlyThat() - { - for (var sequence = 1L; sequence <= 299; sequence++) - WriteProposal(Set(sequence, "seed-" + sequence)); - var broken = WriteProposal(Set(300, "seed-300")); - Corrupt(broken); - - var reported = new List(); - var index = ModuleSetStore.Read(root, reported.Add); - - Assert.Equal(299, index.Proposed!.Sequence); - var fault = Assert.Single(reported); - Assert.Contains(Path.GetFileName(broken), fault); - } - - [Fact] - public void ACorruptAdoptionFallsThroughToTheNextAdoptedSequence() - { - for (var sequence = 1L; sequence <= 300; sequence++) - WriteProposal(Set(sequence, "seed-" + sequence)); - var older = Set(250, "seed-250"); - var newer = Set(299, "seed-299"); - ModuleSetStore.RecordAdoption(root, older, older.Generations, "replica-x"); - ModuleSetStore.RecordAdoption(root, newer, newer.Generations, "replica-y"); - var newerAdoption = Directory - .EnumerateFiles(ModuleSetStore.SetsDirectory(root), "000000299-*.adopted.json") - .Single(); - Corrupt(newerAdoption); - - var reported = new List(); - var index = ModuleSetStore.Read(root, reported.Add); - - Assert.Equal(300, index.Proposed!.Sequence); - Assert.Equal(250, index.Current!.Sequence); - Assert.Equal("g0250", index.RunningGenerations["MeshWeaver.Speech"]); - var fault = Assert.Single(reported); - Assert.Contains(Path.GetFileName(newerAdoption), fault); - } - - [Fact] - public void ARecordUnderAForeignName_IsStillRead() - { - for (var sequence = 1L; sequence <= 10; sequence++) - WriteProposal(Set(sequence, "seed-" + sequence)); - // Not a name this store ever writes — placed by hand, it still counts, by its content. - WriteProposal(Set(400, "hand-placed"), fileNameOverride: "hand-placed.proposed.json"); - - var index = ModuleSetStore.Read(root); - - Assert.Equal(400, index.Proposed!.Sequence); - } - - [Fact] - public void AnEmptyStore_ReadsAsEmpty() - { - Assert.Same(ModuleSetIndex.Empty, ModuleSetStore.Read(root)); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleUnloadableMarkerTest.cs b/test/Memex.Portal.Shared.Test/ModuleUnloadableMarkerTest.cs deleted file mode 100644 index 245704c949..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleUnloadableMarkerTest.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using MeshWeaver.Mesh; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The unloadable-head marker (#3650) — the sidecar half, pinned pure over a temp directory: the -/// boot's measurement is a per-module marker FILE beside the entries, attached to the entry at -/// read time only while the entry still heads the generation it measured, never stored in the -/// entry file, and written only on a transition. The whole chain through the real loader is -/// ConfiguredModuleActivationTest (MeshWeaver.Compiler.Pipeline.Test); this file is the -/// contract each half of that chain relies on. -/// -public sealed class ModuleUnloadableMarkerTest : IDisposable -{ - private const string Module = "MeshWeaver.Test.Marker"; - private const string Head = Module + "@head0000"; - private const string Previous = Module + "@prev0000"; - private const string HeadMvid = "s1111111111111111111111111111111a"; - - private readonly string root = Path.Combine(Path.GetTempPath(), "mw-marker-" + Guid.NewGuid().ToString("N")); - - public ModuleUnloadableMarkerTest() => Directory.CreateDirectory(root); - - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch (IOException) { /* temp cleanup is the OS's problem */ } - } - - private void WriteHead(string? frameworkMvid = HeadMvid) => - ModuleActivationSidecar.WriteEntry(root, new ModuleActivationEntry - { - Name = Module, Directory = Head, Version = "1.0.0", FrameworkMvid = frameworkMvid, - PreviousDirectory = Previous, PreviousVersion = "1.0.0", PreviousFrameworkMvid = "s0", - }); - - private ModuleActivationEntry ReadEntry() => - ModuleActivationSidecar.Read(root).Entries.Single(e => e.Name == Module); - - /// The marker for the head reads back onto the entry; one for another generation is - /// inert — a landing that moved the head on needs no write to retire it. - [Fact] - public void AMarker_AttachesOnlyWhileTheEntryHeadsTheGenerationItMeasured() - { - WriteHead(); - Assert.Null(ReadEntry().UnloadableFrameworkMvid); - - ModuleActivationSidecar.SetUnloadable(root, Module, Head, HeadMvid, "missing type"); - Assert.Equal(HeadMvid, ReadEntry().UnloadableFrameworkMvid); - - ModuleActivationSidecar.SetUnloadable(root, Module, Module + "@stale000", HeadMvid, "missing type"); - Assert.Null(ReadEntry().UnloadableFrameworkMvid); - Assert.NotNull(ModuleActivationSidecar.ReadUnloadable(root, Module)); - } - - /// 🚨 The identity is derived, never persisted: an entry written WITH the field reads - /// back without it unless the marker file says so, and a marker without an identity attaches - /// nothing (an unknown identity cannot be compared). - [Fact] - public void TheEntryFile_NeverCarriesTheMarker() - { - ModuleActivationSidecar.WriteEntry(root, new ModuleActivationEntry - { - Name = Module, Directory = Head, FrameworkMvid = HeadMvid, UnloadableFrameworkMvid = HeadMvid, - }); - Assert.Null(ReadEntry().UnloadableFrameworkMvid); - Assert.DoesNotContain("nloadable", File.ReadAllText(ModuleActivationSidecar.EntryPath(root, Module)), - StringComparison.Ordinal); - - ModuleActivationSidecar.SetUnloadable(root, Module, Head, frameworkMvid: null, "missing type"); - Assert.Null(ReadEntry().UnloadableFrameworkMvid); - } - - /// A marker is not an entry: the enumeration of entry files never sees it, so a - /// deployment with markers reads exactly its modules. - [Fact] - public void AMarker_IsNeverReadAsAnEntry() - { - WriteHead(); - ModuleActivationSidecar.SetUnloadable(root, Module, Head, HeadMvid, "missing type"); - var corrupt = 0; - - var list = ModuleActivationSidecar.Read(root, _ => corrupt++); - - Assert.Single(list.Entries); - Assert.Equal(0, corrupt); - } - - [Fact] - public void ClearUnloadable_RemovesIt_AndTolerates_Absence() - { - WriteHead(); - ModuleActivationSidecar.SetUnloadable(root, Module, Head, HeadMvid, "missing type"); - ModuleActivationSidecar.ClearUnloadable(root, Module); - ModuleActivationSidecar.ClearUnloadable(root, Module); - - Assert.Null(ModuleActivationSidecar.ReadUnloadable(root, Module)); - Assert.Null(ReadEntry().UnloadableFrameworkMvid); - } - - /// The measurement: a fallback or a refusal naming the TRIED generation says - /// unloadable; a record naming another generation of the same module says nothing. - [Fact] - public void MeasureLoadability_ReadsTheLoadersRecordsOntoTheTriedGeneration() - { - var tried = new ModuleActivationEntry { Name = Module, Directory = Head, FrameworkMvid = HeadMvid }; - var fellBack = new FallbackModule(Module, $"/pin/{Head}/{Module}.dll", $"/pin/{Previous}/{Module}.dll", "no type"); - var parked = new IncompatibleModule($"/pin/{Head}/{Module}.dll", Module, "no type either", null); - var elsewhere = new FallbackModule(Module, $"/pin/{Module}@other000/{Module}.dll", $"/pin/{Previous}/{Module}.dll", "no type"); - - var viaFallback = Assert.Single(ModuleActivationBoot.MeasureLoadability([tried], [fellBack], [])); - Assert.True(viaFallback.Unloadable); - Assert.Equal("no type", viaFallback.Reason); - Assert.Equal(HeadMvid, viaFallback.FrameworkMvid); - - var viaRefusal = Assert.Single(ModuleActivationBoot.MeasureLoadability([tried], [], [parked])); - Assert.True(viaRefusal.Unloadable); - Assert.Equal("no type either", viaRefusal.Reason); - - var loaded = Assert.Single(ModuleActivationBoot.MeasureLoadability([tried], [elsewhere], [])); - Assert.False(loaded.Unloadable); - - // A disabled entry and one without a generation are not measured at all. - Assert.Empty(ModuleActivationBoot.MeasureLoadability( - [tried with { Enabled = false }, tried with { Directory = null }], [fellBack], [parked])); - } - - /// Only transitions write: the same measurement twice costs nothing, a changed one - /// rewrites, a loaded head clears — and each transition is reported once. - [Fact] - public void RecordMeasuredLoadability_WritesOnTransitionsOnly() - { - WriteHead(); - var tried = new[] { ReadEntry() }; - var fellBack = new FallbackModule(Module, $"/pin/{Head}/{Module}.dll", $"/pin/{Previous}/{Module}.dll", "no type"); - var reports = 0; - - var first = ModuleActivationBoot.RecordMeasuredLoadability(root, tried, [fellBack], [], _ => reports++); - Assert.Single(first); - Assert.Equal(HeadMvid, ReadEntry().UnloadableFrameworkMvid); - - var again = ModuleActivationBoot.RecordMeasuredLoadability(root, tried, [fellBack], [], _ => reports++); - Assert.Empty(again); - - var cleared = ModuleActivationBoot.RecordMeasuredLoadability(root, tried, [], [], _ => reports++); - Assert.Single(cleared); - Assert.False(cleared[0].Unloadable); - Assert.Null(ReadEntry().UnloadableFrameworkMvid); - - Assert.Empty(ModuleActivationBoot.RecordMeasuredLoadability(root, tried, [], [], _ => reports++)); - Assert.Equal(2, reports); - } - - /// - /// 🚨 THE THIRD ANSWER (MeshWeaver#3911): a boot that did not LOOK writes nothing and clears - /// nothing. When the default load context already held the module's simple name, - /// Assembly.LoadFrom handed back that copy and the head generation's bytes never reached - /// the loader — so this boot has no verdict on them. - /// - /// Both collapses are wrong, in opposite directions, and each is asserted here. - /// Reading the fallback as UNLOADABLE writes a marker that makes the update reconcile - /// permanently SkipUnloadable every rebuild of a version nobody executed; reading it as - /// LOADED clears a marker an earlier boot wrote from a real measurement. The two assertions - /// below are that pair — a not-measured boot over a clean sidecar leaves it clean, and over an - /// existing marker leaves the marker exactly as it found it. - /// - [Fact] - public void ABootThatNeverReachedTheHeadsBytes_NeitherWritesNorClearsItsMarker() - { - WriteHead(); - var tried = new[] { ReadEntry() }; - var substituted = new FallbackModule( - Module, $"/pin/{Head}/{Module}.dll", $"/pin/{Previous}/{Module}.dll", - "this process had already loaded an assembly of that name") - { - RunsAlreadyLoadedCopy = true, - }; - - // The measurement says so in as many words: not unloadable, and not a measurement. - var verdict = Assert.Single(ModuleActivationBoot.MeasureLoadability([tried[0]], [substituted], [])); - Assert.True(verdict.HeadNotMeasured); - Assert.False(verdict.Unloadable, - "nothing executed these bytes, so calling them unloadable would permanently skip every rebuild"); - - // Direction 1 — nothing to clear: the boot writes no marker. - var overClean = ModuleActivationBoot.RecordMeasuredLoadability(root, tried, [substituted], []); - Assert.Empty(overClean); - Assert.Null(ReadEntry().UnloadableFrameworkMvid); - - // Direction 2 — a marker a REAL measurement wrote survives untouched. - var measured = new FallbackModule( - Module, $"/pin/{Head}/{Module}.dll", $"/pin/{Previous}/{Module}.dll", "no type"); - Assert.Single(ModuleActivationBoot.RecordMeasuredLoadability(root, tried, [measured], [])); - Assert.Equal(HeadMvid, ReadEntry().UnloadableFrameworkMvid); - - var overMarked = ModuleActivationBoot.RecordMeasuredLoadability(root, tried, [substituted], []); - Assert.Empty(overMarked); - Assert.Equal(HeadMvid, ReadEntry().UnloadableFrameworkMvid); - - // 🚨 The negative control on the control: the SAME sidecar, the SAME entry, one boot that - // genuinely measured the head as loading — and the marker goes. Without this the two - // assertions above would also pass if RecordMeasuredLoadability had simply stopped - // clearing markers at all. - Assert.Single(ModuleActivationBoot.RecordMeasuredLoadability(root, tried, [], [])); - Assert.Null(ReadEntry().UnloadableFrameworkMvid); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleUpdateIdentityTest.cs b/test/Memex.Portal.Shared.Test/ModuleUpdateIdentityTest.cs deleted file mode 100644 index d3fc1c0ddf..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleUpdateIdentityTest.cs +++ /dev/null @@ -1,425 +0,0 @@ -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 "Already landed" must mean THIS CONTENT AGAINST THIS FRAMEWORK — the consumer half of -/// Systemorph/MeshWeaver.Plugins#931, pinned pure (no registry, no filesystem, no mesh). -/// -/// The defect. A module's published version encodes its CONTENT only. Rebuild the same -/// source against a new platform and it republishes under the same version — so -/// , which compared the version alone, answered -/// for an artifact the deployment does not hold, and -/// nothing ever looked again. -/// -/// It is not hypothetical. Plugins#723: after a platform identity flip, core CD baked -/// all 53 packages, the portals' updater landed ~12 rebuilt modules whose versions had moved — and -/// then went quiet with no new MeshWeaver.AI.OpenAI build, because OpenAI's version had not. -/// Rolling the image anyway crash-looped deterministically (the pre-flip build cannot resolve -/// ProviderModelLister on the new platform, whose registration had moved) and the fleet was -/// held on an old image. The updater "went quiet" for exactly the reason pinned below. -/// -/// What the producer half writes. The registry records the framework identity of the -/// module bytes when their owning repo's CI publishes them (ModulePublish.Accepted → -/// ModuleLandingService.ShelveModule) and advertises it per bundle on the index -/// (PluginBundleClient.BundleRef.FrameworkMvid); the consumer records what it landed on -/// . These tests compare exactly those two. -/// -/// 🚨 The FLOOR is untouched and is still never MVID equality — a differing identity makes a -/// bundle NEWER, never UNINSTALLABLE. ModuleUpdateDecisionTest (MeshWeaver.Plugins) keeps -/// the floor, policy and ordering rules; this fixture is only about the identity half. -/// -public class ModuleUpdateIdentityTest -{ - private const string Running = "3.2.0"; - - /// The build these bytes came from — the flip's "before". - private const string OldFramework = "s2b488317c0de0000c0de0000c0de0000"; - - /// The build the platform flipped TO — same source, different bytes. - private const string NewFramework = "se0f09bc8f00d0000f00d0000f00d0000"; - - /// The production gate's shape, bound to a fixed running platform version. - private static string? Gate(string? floor) => ModulePlatformFloor.DeclineReason(floor, Running); - - /// A third build of the same source — the one a rebuild for THIS platform produces - /// after the newest landed generation turned out not to load. - private const string RebuiltFramework = "s0c0ffee0c0ffee0c0ffee0c0ffee0c0"; - - private static ModuleActivationEntry Landed( - string? version, string? frameworkMvid, bool enabled = true) => new() - { - Name = "MeshWeaver.AI.OpenAI", - FrameworkMvid = frameworkMvid, - Version = version, - Enabled = enabled, - }; - - /// - /// An entry in FALLBACK (#3649/#3650): the newest landed generation — built against - /// — could not be loaded at the last boot and the previous one - /// (built against ) is what runs. What #3649's boot writes; the - /// decision under test only reads it. - /// - private static ModuleActivationEntry InFallback( - string version, string running, string unloadable, bool enabled = true) => - Landed(version, running, enabled) with { UnloadableFrameworkMvid = unloadable }; - - private static bool BytesPresent(ModuleActivationEntry _) => true; - - private static bool BytesGone(ModuleActivationEntry _) => false; - - private static ModuleUpdateVerdict Decide( - string? bundleVersion, - ModuleActivationEntry? landed, - string? bundleFrameworkMvid, - Func? bytes = null) => - ModuleUpdateDecision.Decide( - bundleVersion, bundleMinMeshVersion: "3.0.0", Gate, landed, policyDecline: null, - bytes ?? BytesPresent, bundleFrameworkMvid); - - /// The no-op that must stay a no-op: same content, same framework, bytes on disk. - [Fact] - public void SameVersion_SameIdentity_Skips() - { - var verdict = Decide("1.1.0", Landed("1.1.0", OldFramework), OldFramework); - - Assert.Equal(ModuleUpdateAction.SkipUpToDate, verdict.Action); - Assert.Contains("1.1.0", verdict.Reason); - // The reason states the framework it agreed on — a verdict that merely says "already - // landed" is what hid the mismatch in the first place. - Assert.Contains(OldFramework, verdict.Reason); - } - - /// - /// 🚨 #723, reproduced. The registry serves the SAME version built against a DIFFERENT - /// platform: the bytes on this deployment are the pre-flip build, and skipping is what left the - /// fleet unable to converge. This is the one case the whole change exists for. - /// - [Fact] - public void SameVersion_DifferentIdentity_Lands() - { - var verdict = Decide("1.1.0", Landed("1.1.0", OldFramework), NewFramework); - - Assert.Equal(ModuleUpdateAction.Land, verdict.Action); - // 🚨 The reason must NAME WHICH HALF DIFFERED. "version 1.1.0 is already landed" concealing - // a framework mismatch is the shape of the original bug; a reason that names both - // identities is what makes the next incident readable from one log line. - Assert.Contains(OldFramework, verdict.Reason); - Assert.Contains(NewFramework, verdict.Reason); - Assert.Contains("1.1.0", verdict.Reason); - } - - /// - /// 🚨 THE LADDER (policy platform-backwards-compatibility). Module bytes now state the - /// platform COMPATIBILITY KEY, identical for every platform build of one major and epoch — so - /// the same version packed against a LATER build of the same epoch is the SAME artifact to this - /// decision: a platform roll does not re-download, re-land or re-seal a single module. Its - /// negative control is the epoch bump below, which is exactly #723's shape, declared. - /// - [Fact] - public void SameVersion_SameCompatibilityKey_FromAnotherPlatformBuild_Skips() - { - var key = MeshWeaver.Compiler.PlatformCompatibility.KeyOf(3, 1); - Assert.Equal(ModuleUpdateAction.SkipUpToDate, Decide("1.1.0", Landed("1.1.0", key), key).Action); - } - - /// The negative control: a DECLARED break (epoch bump) re-keys the artifact, and the - /// same version packed for the new epoch lands, naming both keys. - [Fact] - public void SameVersion_AnEpochBump_Lands() - { - var before = MeshWeaver.Compiler.PlatformCompatibility.KeyOf(3, 1); - var after = MeshWeaver.Compiler.PlatformCompatibility.KeyOf(3, 2); - var verdict = Decide("1.1.0", Landed("1.1.0", before), after); - Assert.Equal(ModuleUpdateAction.Land, verdict.Action); - Assert.Contains(before, verdict.Reason); - Assert.Contains(after, verdict.Reason); - } - - /// - /// The convergence proof for the case above: landing records the identity that was compared, so - /// the very next reconcile agrees. Without this the fix would trade a silent skip for an - /// unbounded re-download. - /// - [Fact] - public void AfterLanding_TheNextReconcileSkips() - { - var landed = Landed("1.1.0", OldFramework); - Assert.Equal(ModuleUpdateAction.Land, Decide("1.1.0", landed, NewFramework).Action); - - // What PluginBundleClient.LandFromBundle writes: the ADVERTISED identity, not the archive's - // requested-lane stamp. That is the whole reason the two sides can ever come to agree. - var afterLanding = landed with { FrameworkMvid = NewFramework }; - - Assert.Equal( - ModuleUpdateAction.SkipUpToDate, Decide("1.1.0", afterLanding, NewFramework).Action); - } - - /// - /// An entry written before the identity was recorded. Unknown is NOT a match — landing once - /// records it and the reconcile after that is stable, which is the same shape - /// already has for a pre-field entry. - /// - [Fact] - public void SameVersion_LandedIdentityUnknown_Lands() - { - var verdict = Decide("1.1.0", Landed("1.1.0", frameworkMvid: null), NewFramework); - - Assert.Equal(ModuleUpdateAction.Land, verdict.Action); - Assert.Contains("unrecorded", verdict.Reason); - Assert.Contains(NewFramework, verdict.Reason); - } - - /// - /// 🚨 The asymmetry, and the reason it is not symmetric. A registry that states no - /// identity offers no evidence a rebuild happened — and landing could never turn that into - /// evidence, because the registry would state nothing next time either. Answering Land here - /// would re-download every module on every reconcile, forever, on every deployment pointed at a - /// pre-#931 registry: the infinite loop, bought for a comparison that still has nothing to - /// compare. So it skips — and the verdict SAYS the identity could not be checked, because a - /// bare "already landed" is the sentence that hid the defect. The blind spot is closed where it - /// is created (part 3 of the agreed shape: a bundle that cannot state what it was built against - /// is not publishable), never by churning consumers. - /// - [Fact] - public void SameVersion_ServedIdentityUnknown_SkipsAndSaysItCouldNotBeChecked() - { - var verdict = Decide("1.1.0", Landed("1.1.0", OldFramework), bundleFrameworkMvid: null); - - Assert.Equal(ModuleUpdateAction.SkipUpToDate, verdict.Action); - Assert.Contains("states no framework identity", verdict.Reason); - } - - /// Neither side states one: the same answer, for the same reason. - [Fact] - public void SameVersion_NeitherSideStatesAnIdentity_SkipsButSaysSo() - { - var verdict = Decide( - "1.1.0", Landed("1.1.0", frameworkMvid: null), bundleFrameworkMvid: null); - - Assert.Equal(ModuleUpdateAction.SkipUpToDate, verdict.Action); - Assert.Contains("states no framework identity", verdict.Reason); - } - - /// - /// An empty or blank string is a recorded nothing, not an identity — on EITHER side it must - /// read exactly like an absent one. A producer that writes "" would otherwise turn the gate - /// back off while looking populated, and a landed "" would land on every reconcile. - /// - [Theory] - [InlineData("", null, ModuleUpdateAction.SkipUpToDate)] - [InlineData(null, "", ModuleUpdateAction.SkipUpToDate)] - [InlineData(" ", " ", ModuleUpdateAction.SkipUpToDate)] - [InlineData(OldFramework, " ", ModuleUpdateAction.SkipUpToDate)] - [InlineData("", NewFramework, ModuleUpdateAction.Land)] - [InlineData(" ", NewFramework, ModuleUpdateAction.Land)] - public void BlankIdentitiesReadAsUnknown_OnBothSides( - string? landedMvid, string? servedMvid, ModuleUpdateAction expected) - => Assert.Equal(expected, Decide("1.1.0", Landed("1.1.0", landedMvid), servedMvid).Action); - - /// A version that moved lands regardless of identity — the ordinary update path is - /// untouched by this change. - [Fact] - public void ANewerVersion_LandsWhateverTheIdentitySays() - { - Assert.Equal( - ModuleUpdateAction.Land, - Decide("1.2.0", Landed("1.1.0", OldFramework), OldFramework).Action); - Assert.Equal( - ModuleUpdateAction.Land, - Decide("1.2.0", Landed("1.1.0", OldFramework), NewFramework).Action); - } - - /// - /// 🚨 An identity difference must never become a DOWNGRADE. The registry serving an older - /// version is an operator situation, and a differing framework does not make rolling back - /// unattended any more this lane's call than it was before. - /// - [Fact] - public void AnOlderServedVersion_IsStillNeverRolledBack() - { - var verdict = Decide("1.0.0", Landed("1.1.0", OldFramework), NewFramework); - - Assert.Equal(ModuleUpdateAction.SkipOlder, verdict.Action); - } - - /// - /// Ordering: the ABSENT-assembly diagnosis (#2417) wins over the identity one. Both answer - /// Land, so nothing is lost — but "its assembly is ABSENT" is the actionable sentence, and a - /// half-installed module must not be reported as a framework mismatch. - /// - [Fact] - public void AbsentBytes_AreReportedAsAbsent_NotAsAnIdentityMismatch() - { - var verdict = Decide("1.1.0", Landed("1.1.0", OldFramework), NewFramework, BytesGone); - - Assert.Equal(ModuleUpdateAction.Land, verdict.Action); - Assert.Contains("ABSENT", verdict.Reason); - } - - /// - /// A deliberately uninstalled module stays uninstalled. The identity check sits INSIDE the - /// same-version branch, below the uninstall check, so an operator's choice is never overturned - /// by a platform rebuild. - /// - [Fact] - public void AnUninstalledModule_IsNotReinstalledByAnIdentityDifference() - { - var verdict = Decide( - "1.1.0", Landed("1.1.0", OldFramework, enabled: false), NewFramework); - - Assert.Equal(ModuleUpdateAction.SkipUninstalled, verdict.Action); - } - - /// - /// A FIRST landing is unaffected: there is no recorded identity to disagree with, and the - /// never-landed branch already lands. - /// - [Fact] - public void ANeverLandedModule_LandsAsBefore() - { - var verdict = Decide("1.1.0", landed: null, NewFramework); - - Assert.Equal(ModuleUpdateAction.Land, verdict.Action); - Assert.Contains("never landed here", verdict.Reason); - } - - // ══════════════════════════════════════════════════════════════════════════ - // The fallback is re-examined on every reconcile (#3650, policy rule R3) - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The acceptance criterion of #3650's first gap. The newest landed generation of - /// 1.1.0 (built against ) could not be loaded and the previous one - /// runs; the registry now serves 1.1.0 built against a THIRD identity. That is a build for - /// this platform appearing under an unchanged version — and it lands NOW, not at whatever boot - /// happens to follow a publication. The reason names the build that would not load and the - /// one that replaces it, so the next reader of the log knows why a same-version landing ran. - /// - [Fact] - public void InFallback_SameVersion_ADifferentBuildThanTheUnloadableOne_Lands() - { - var verdict = Decide("1.1.0", InFallback("1.1.0", OldFramework, NewFramework), RebuiltFramework); - - Assert.Equal(ModuleUpdateAction.Land, verdict.Action); - Assert.Contains(NewFramework, verdict.Reason); - Assert.Contains(RebuiltFramework, verdict.Reason); - Assert.Contains("could not be loaded", verdict.Reason); - Assert.Contains("appeared", verdict.Reason); - } - - /// - /// The registry still serves the build that would not load. Landing it again would only - /// re-park it at the next boot, so nothing travels — and the verdict says the deployment is - /// in fallback rather than "already landed", which is the sentence that hid every identity - /// defect on this lane. Distinct action, so a surface can render the state. - /// - [Fact] - public void InFallback_SameVersion_StillTheUnloadableBuild_SkipsAndSaysSo() - { - var verdict = Decide("1.1.0", InFallback("1.1.0", OldFramework, NewFramework), NewFramework); - - Assert.Equal(ModuleUpdateAction.SkipUnloadable, verdict.Action); - Assert.Contains(NewFramework, verdict.Reason); - Assert.Contains("still serves that same build", verdict.Reason); - Assert.DoesNotContain("already landed", verdict.Reason); - } - - /// - /// 🚨 The rule is "different from the UNLOADABLE generation's", literally — the identity of the - /// generation that RUNS is not consulted. The registry going back to serving the previous - /// build is a build that loads, and landing it costs one download for a module that then - /// boots off its newest generation again instead of its fallback. - /// - [Fact] - public void InFallback_SameVersion_TheRunningGenerationsOwnBuild_StillLands() - { - var verdict = Decide("1.1.0", InFallback("1.1.0", OldFramework, NewFramework), OldFramework); - - Assert.Equal(ModuleUpdateAction.Land, verdict.Action); - } - - /// The same asymmetry as outside fallback: an unstated served identity is no - /// evidence of a new build, so nothing lands — but the verdict still names the fallback, - /// never "up to date". - [Fact] - public void InFallback_SameVersion_ServedIdentityUnknown_SkipsAsUnloadable() - { - var verdict = Decide( - "1.1.0", InFallback("1.1.0", OldFramework, NewFramework), bundleFrameworkMvid: null); - - Assert.Equal(ModuleUpdateAction.SkipUnloadable, verdict.Action); - Assert.Contains("states no framework identity", verdict.Reason); - Assert.Contains("could not be loaded", verdict.Reason); - } - - /// A blank marker is no marker: the ordinary identity rule decides, exactly as for - /// an entry written before the field existed. - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - public void ABlankFallbackMarker_ReadsAsNotInFallback(string? marker) - { - var entry = Landed("1.1.0", OldFramework) with { UnloadableFrameworkMvid = marker }; - - Assert.Equal(ModuleUpdateAction.SkipUpToDate, Decide("1.1.0", entry, OldFramework).Action); - Assert.Equal(ModuleUpdateAction.Land, Decide("1.1.0", entry, NewFramework).Action); - } - - /// Ordering: absent bytes are still the more actionable diagnosis, fallback or not. - [Fact] - public void InFallback_AbsentBytes_AreStillReportedAsAbsent() - { - var verdict = Decide( - "1.1.0", InFallback("1.1.0", OldFramework, NewFramework), RebuiltFramework, BytesGone); - - Assert.Equal(ModuleUpdateAction.Land, verdict.Action); - Assert.Contains("ABSENT", verdict.Reason); - } - - /// An operator's uninstall still wins over everything, fallback included. - [Fact] - public void InFallback_AnUninstalledModule_StaysUninstalled() - { - var verdict = Decide( - "1.1.0", InFallback("1.1.0", OldFramework, NewFramework, enabled: false), RebuiltFramework); - - Assert.Equal(ModuleUpdateAction.SkipUninstalled, verdict.Action); - } - - /// A newer version lands as before; the fallback rule lives inside the same-version - /// branch and never widens or narrows the version comparison. - [Fact] - public void InFallback_ANewerVersion_LandsAsBefore_AndAnOlderOneIsNeverRolledBack() - { - var entry = InFallback("1.1.0", OldFramework, NewFramework); - - Assert.Equal(ModuleUpdateAction.Land, Decide("1.2.0", entry, NewFramework).Action); - Assert.Equal(ModuleUpdateAction.SkipOlder, Decide("1.0.0", entry, RebuiltFramework).Action); - } - - /// - /// 🚨 The omitted argument is the LEGACY shape, stated on purpose so nobody reads the default - /// as a safety net. Omitting it is indistinguishable from a registry that states no identity, - /// which is exactly the pre-#931 answer — so every caller must pass it, and the one production - /// caller (PluginBundleClient.AdoptModule, off BundleRef.FrameworkMvid) does. - /// The parameter is optional rather than required only because the six-argument form is called - /// from a suite in another repository (ModuleUpdateDecisionTest, MeshWeaver.Plugins) and - /// a platform change must not red a satellite's whole CI to land. - /// - [Fact] - public void OmittingTheIdentityArgument_IsTheLegacyAnswer_NotASafetyNet() - { - var legacy = ModuleUpdateDecision.Decide( - "1.1.0", "3.0.0", Gate, Landed("1.1.0", OldFramework), policyDecline: null, - BytesPresent); - - Assert.Equal(ModuleUpdateAction.SkipUpToDate, legacy.Action); - Assert.Equal( - Decide("1.1.0", Landed("1.1.0", OldFramework), bundleFrameworkMvid: null).Reason, - legacy.Reason); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleUpdatesGoLiveTest.cs b/test/Memex.Portal.Shared.Test/ModuleUpdatesGoLiveTest.cs deleted file mode 100644 index 94a6056f39..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleUpdatesGoLiveTest.cs +++ /dev/null @@ -1,413 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 "Running, update coming for a module, schedule restart" — live first (policy -/// module-live-update-default, Doc/Architecture/LiveModuleUpdate). -/// -/// An instance RUNS module M at generation N (landed, activation-recorded, loaded in its own -/// context at boot). Generation N+1 ARRIVES through the real install path -/// ( — bytes on the volume, the activation entry moved, -/// the pending-restart marker raised). Then the instance's own self-update check runs — the path -/// that until this change ALWAYS scheduled a restart: -/// -/// a live-updatable M goes live in the process and NO restart is scheduled; -/// an M whose contributions cannot be re-applied, or whose live swap FAILS at runtime, keeps N -/// serving and schedules exactly ONE automatic restart, with the reason recorded by name; -/// two updates before the restart still schedule one restart, and the record names the -/// newest generation — the one that restart loads; -/// an update that arrives while the restart is in flight schedules no second one. -/// -/// And the negative control: with the fallback suppressed (no check runs), the module that -/// did not go live stays landed-not-loaded, and the guard names it. -/// -/// Only the documented IO seams are faked (the ACR listing and the k8s updater). The mesh, -/// the landing service, the module registry, the live updater and the self-update decision path are -/// real. -/// -public sealed class ModuleUpdatesGoLiveTest : MonolithMeshTestBase -{ - private const string Module = "MeshWeaver.Test.GoesLive"; - private const string ProbePath = "GoesLiveProbe"; - private static TimeSpan Budget => TestTimeouts.Convergence; - - private static string Installed => ShippedReleaseSeed.InstalledPlatformVersion; - private static string[] NothingNewer => ["1.9.0-ci.0", "2.0.0-ci.0", Installed.Split('+')[0]]; - - private readonly string root = Path.Combine(Path.GetTempPath(), "mw-golive-" + Guid.NewGuid().ToString("N")); - private string? bootEntry; - - public ModuleUpdatesGoLiveTest(ITestOutputHelper output) : base(output) - { - } - - /// The instance RUNNING M@N: generation g1 landed on the volume with its activation - /// entry, and loaded at boot from that landed path — before the base builds the mesh. - private string BootEntry - { - get - { - if (bootEntry is not null) - return bootEntry; - var directory = Path.Combine(root, "modules", $"{Module}@g1"); - Directory.CreateDirectory(directory); - File.WriteAllBytes(Path.Combine(directory, Module + ".dll"), Emit(ModuleSource(1))); - ModuleActivationSidecar.Write(root, new ModuleActivationList - { - Entries = [new ModuleActivationEntry { Name = Module, Directory = $"{Module}@g1", Version = "1" }], - }); - return bootEntry = Path.Combine(directory, Module + ".dll"); - } - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => - base.ConfigureMesh(builder) - .AddUpdatePolicyType() - .AddGitHubSyncTypes() - .AddPluginCatalog() - .InstallAssemblies(BootEntry) - .ConfigureServices(services => - { - // The landing service and the activation reader rooted at THIS instance's volume. - services.RemoveAll(); - services.AddSingleton(_ => new ModuleLandingService(baseDirectory: root)); - services.RemoveAll(); - services.AddSingleton(sp => new PendingModuleActivations(root) - { - IoPool = sp.GetRequiredService().Get(IoPoolNames.FileSystem), - ModuleContexts = sp.GetRequiredService(), - }); - return services; - }); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - private ModuleLandingService Landing => Mesh.ServiceProvider.GetRequiredService(); - private ModuleContexts Contexts => Mesh.ServiceProvider.GetRequiredService(); - - // ═════════════════════════════════════════ live: no restart - - [Fact(Timeout = 300_000)] - public async Task ALiveUpdatableModuleUpdate_GoesLiveInTheProcess_AndSchedulesNoRestart() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - (await NameAt(ProbePath, ct)).Should().Be("v1", "the arrangement: the instance runs M@N"); - - await Land(ModuleSource(2), "2", ct); - var updater = new RecordingUpdater(); - var verdict = await RunOneCheck(updater, ct); - - updater.Restarts.Should().Be(0, "a live-updatable module's update must schedule NO restart"); - verdict.Should().Contain("went LIVE"); - (await NameAt(ProbePath, ct)).Should().Be("v2", "M@N+1 serves in the running process"); - (Contexts.Current(Module)?.Location).Should().Contain($"{Module}@", "the landed generation is the current one"); - AssertNothingStuck(); - } - - /// - /// #6620: a check that finds the landed generation marked for activation while NOTHING is pending - /// in this process swaps nothing — it must never report "went LIVE". Arranged by running a second - /// check after the live swap: the activation record still marks the generation, the per-process - /// reader finds nothing pending. Before the fix this second check read "went LIVE without a - /// restart (nothing landed is waiting to be activated here)" — the exact production sentence. - /// - [Fact(Timeout = 300_000)] - public async Task ACheckWithNothingPendingHere_NeverReportsWentLive_AndSaysNothingWasSwapped() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - await Land(ModuleSource(2), "2", ct); - var updater = new RecordingUpdater(); - (await RunOneCheck(updater, ct)).Should().Contain("went LIVE", "the arrangement: the first check swaps M@N+1 in"); - - var marker = (await Landing.GetActivation().Timeout(Budget).Await(ct)).PendingRestart; - var second = await RunOneCheck(updater, ct); - - marker.Should().BeTrue("the arrangement: the record still marks the generation (cleared only by the next boot)"); - second.Should().NotContain("went LIVE", "a pass that swapped nothing must not claim a swap"); - second.Should().Contain("nothing landed is pending activation in this process", - "the marker still names a pending generation, so the verdict must say what the reader found"); - second.Should().Contain("no module activation pending", - "the reader's own diagnosis (ModuleActivationReport.Describe) must travel into the verdict"); - updater.Restarts.Should().Be(0, "nothing pending here — a restart would load nothing new"); - } - - // ═════════════════════════════════════════ the fallback: exactly one automatic restart - - [Fact(Timeout = 300_000)] - public async Task ARestartRequiredUpdate_SchedulesExactlyOneAutomaticRestart_AndNKeepsServing() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - (await NameAt(ProbePath, ct)).Should().Be("v1"); - - await Land(ModuleSource(2, configuresMeshHub: true), "2", ct); - var updater = new RecordingUpdater(); - var verdict = await RunOneCheck(updater, ct); - - updater.Restarts.Should().Be(1, "a module that cannot be swapped in-process is activated by ONE automatic restart"); - verdict.Should().Contain("RESTARTED"); - (await NameAt(ProbePath, ct)).Should().Be("v1", "N keeps serving until the restart — never a half-swapped state"); - } - - [Fact(Timeout = 300_000)] - public async Task AnInjectedLiveFailure_FallsBackToExactlyOneRestart_WithTheReasonRecorded() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - (await NameAt(ProbePath, ct)).Should().Be("v1"); - - await Land(ModuleSource(2, throwingNodes: true), "2", ct); - var live = await Mesh.ServiceProvider.GetRequiredService() - .ActivatePending("test: inspect the failure").Timeout(Budget).Await(ct); - live.NeedsRestart.Should().BeTrue(); - live.Describe().Should().Contain(Module).And.Contain("cannot be built", - "the reason the live swap failed is recorded by name"); - - var updater = new RecordingUpdater(); - await RunOneCheck(updater, ct); - - updater.Restarts.Should().Be(1, "a live swap that fails at runtime falls back to ONE automatic restart"); - (await NameAt(ProbePath, ct)).Should().Be("v1", "N keeps serving until the restart"); - } - - [Fact(Timeout = 300_000)] - public async Task TwoUpdatesBeforeTheRestart_ScheduleOneRestart_AndTheRecordNamesTheNewest() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - await Land(ModuleSource(2, configuresMeshHub: true), "2", ct); - await Land(ModuleSource(3, configuresMeshHub: true), "3", ct); - - var updater = new RecordingUpdater(); - await RunOneCheck(updater, ct); - - updater.Restarts.Should().Be(1, "two pending generations of one module are ONE restart"); - var entry = (await Landing.GetActivation().Timeout(Budget).Await(ct)).Entries.Single(e => e.Name == Module); - entry.Version.Should().Be("3", "the restart loads the NEWEST generation the record names"); - } - - [Fact(Timeout = 300_000)] - public async Task AnUpdateArrivingWhileTheRestartIsInFlight_SchedulesNoSecondRestart() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - await Land(ModuleSource(2, configuresMeshHub: true), "2", ct); - var updater = new RecordingUpdater(); - await RunOneCheck(updater, ct, TimeSpan.FromHours(1)); - updater.Restarts.Should().Be(1); - - // The restart is in flight (rolled moments ago); another generation lands. - await Land(ModuleSource(3, configuresMeshHub: true), "3", ct); - var verdict = await RunOneCheck(updater, ct, TimeSpan.FromHours(1)); - - updater.Restarts.Should().Be(1, "the in-flight restart loads whatever the record names — never a second one"); - verdict.Should().Contain("deferring the restart"); - } - - // ═════════════════════════════════════════ the negative control - - /// - /// 🚨 THE NEGATIVE CONTROL: suppress the fallback — no self-update check runs after a landing - /// that cannot go live — and the module stays landed-not-loaded. The guard the positive tests end - /// with must then FAIL, naming the stuck module; if it did not, its silence in those tests would - /// prove nothing. - /// - [Fact(Timeout = 300_000)] - public async Task WithTheFallbackSuppressed_TheStuckLandedNotLoadedModuleIsNamed() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - await Land(ModuleSource(2, configuresMeshHub: true), "2", ct); - - var guard = () => AssertNothingStuck(); - - guard.Should().Throw("nothing activated the landed generation") - .WithMessage($"*{Module}*"); - } - - // ═════════════════════════════════════════ harness - - /// Fails naming every module that has landed and is not serving in this process. - private void AssertNothingStuck() - { - var report = Mesh.ServiceProvider.GetRequiredService().Read(); - if (report.IsUndetermined) - throw new InvalidOperationException("the activation state could not be read: " + report.UndeterminedReason); - if (report.HasPending) - throw new InvalidOperationException( - "landed but not loaded in the running process (no restart and no live swap activated it): " - + string.Join(", ", report.Pending.Select(p => p.Name))); - } - - private async Task Land(string source, string version, CancellationToken ct) => - await Landing.LandModule(Module, [(Module + ".dll", Emit(source))], version: version) - .Timeout(Budget).Await(ct); - - private async Task NameAt(string path, CancellationToken ct) => - (await ReadNode(path).Timeout(Budget).Await(ct))?.Name; - - private static string ModuleSource(int version, bool throwingNodes = false, bool configuresMeshHub = false) => $$""" - [assembly: MeshWeaver.Test.GoesLive.Module] - namespace MeshWeaver.Test.GoesLive; - public sealed class ModuleAttribute : MeshWeaver.Mesh.MeshNodeProviderAttribute - { - public override System.Collections.Generic.IEnumerable Nodes => - {{(throwingNodes - ? "throw new System.InvalidOperationException(\"contributions of this generation cannot be built\")" - : "[new MeshWeaver.Mesh.MeshNode(\"" + ProbePath + "\") { Name = \"v" + version + "\", NodeType = \"Markdown\" }]")}}; - {{(configuresMeshHub - ? "public override System.Collections.Generic.IEnumerable> HubConfigurations => [c => c with { }];" - : "")}} - } - """; - - private static byte[] Emit(string source) - { - var compilation = CSharpCompilation.Create( - Module, - [CSharpSyntaxTree.ParseText(source)], - PlatformReferences.Platform(), - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - return StandInCompile.Emit(compilation); - } - - private sealed class FakeAcrTagLister(IReadOnlyList tags) : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult(tags); - } - - /// The k8s seam: records every restart, and a restart IS a roll for the floor. - private sealed class RecordingUpdater : IDeploymentUpdater - { - private int restarts; - private long lastRolledTicks; - - public int Restarts => Volatile.Read(ref restarts); - public bool CanPatch => true; - - public Task LastRolledAtAsync(CancellationToken ct) - { - var ticks = Interlocked.Read(ref lastRolledTicks); - return Task.FromResult(ticks == 0 ? null : new DateTimeOffset(ticks, TimeSpan.Zero)); - } - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) => Task.CompletedTask; - - public Task RestartAsync(CancellationToken ct) - { - Interlocked.Increment(ref restarts); - Interlocked.Exchange(ref lastRolledTicks, DateTimeOffset.UtcNow.UtcTicks); - return Task.FromResult(true); - } - } - - private sealed class AlwaysAvailable(IMessageHub hub, IConfiguration configuration) - : ReleaseAvailabilityService(hub, configuration) - { - public override IObservable IsUpdatable(string? targetVersion) => - Observable.Return(UpdatabilityVerdict.NotEnforced("this test host consumes no CI bakes")); - } - - private sealed class SeamedSelfUpdateService( - IMessageHub hub, IAcrTagLister acr, IDeploymentUpdater updater, SelfUpdateOptions options, - ILogger? logger, ReleaseAvailabilityService gate) - : SelfUpdateHostedService(hub, acr, updater, options, logger) - { - public IObservable Evaluations => ChecksReported; - - protected override ReleaseAvailabilityService? ResolveAvailabilityGate() => gate; - - protected override ComboVerificationGate? ResolveComboGate() => null; - } - - private async Task RunOneCheck(IDeploymentUpdater updater, CancellationToken ct, TimeSpan? floor = null) - { - var options = new SelfUpdateOptions - { - RetryInterval = TimeSpan.FromMilliseconds(500), - EventCoalesceWindow = TimeSpan.FromMilliseconds(50), - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - MinRollInterval = floor ?? TimeSpan.Zero, - }; - var service = new SeamedSelfUpdateService( - Mesh, new FakeAcrTagLister(NothingNewer), updater, options, - Mesh.ServiceProvider.GetService>(), - new AlwaysAvailable(Mesh, new ConfigurationBuilder().Build())); - var before = DateTimeOffset.UtcNow; - await service.StartAsync(CancellationToken.None); - try - { - await service.Evaluations.FirstAsync().Timeout(Budget).Await(ct); - return await Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace() - .GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .Subscribe(observer); - }) - .Where(c => c.LastCheckedAt >= before) - .Select(c => c.LastCheckVerdict) - .OfType() - .FirstAsync() - .Timeout(Budget) - .Await(ct); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - } - - private Task Seed(CancellationToken ct) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, Pattern = "*-ci*" }, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(ct); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModuleVersionLabelTruthTest.cs b/test/Memex.Portal.Shared.Test/ModuleVersionLabelTruthTest.cs deleted file mode 100644 index 3db9a49b43..0000000000 --- a/test/Memex.Portal.Shared.Test/ModuleVersionLabelTruthTest.cs +++ /dev/null @@ -1,236 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#6067 — the pure and file-level halves of "a module's bytes are never labelled -/// with a version they are not, and a set whose declared dependency floor is not met is never -/// proposed". The measured shape, 2026-10-04: the registry advertised AI 1.20.1 for the -/// 1.19.4 bytes, consumers landed them stamped 1.20.1, and Hosting (requires AI@^1.20.0) -/// passed every check and threw MissingMethodException at run time. -/// -public sealed class ModuleVersionLabelTruthTest : IDisposable -{ - private const string Ai = "MeshWeaver.AI"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-labeltruth-" + Guid.NewGuid().ToString("N")); - - private readonly ModuleLandingService landing; - - public ModuleVersionLabelTruthTest() - { - Directory.CreateDirectory(root); - landing = new ModuleLandingService(baseDirectory: root); - } - - public void Dispose() - { - landing.Dispose(); - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - // ─────────────────────────────── the publish side: the shelf is labelled by the bundle - - /// An upload whose ?version= disagrees with the version the bundle declares is - /// refused, naming both — before #6067 the query won and the bytes were shelved under it. - [Fact] - public void Publish_RefusesAnUploadLabelledWithAVersionItsBundleDoesNotDeclare() - { - var (accepted, decline) = ModulePublish.Validate( - "AI", PublishManifest("1.19.4"), [new BundleReader.ModuleFile(Ai + ".dll", [1])], - version: "1.20.1"); - - Assert.Null(accepted); - Assert.NotNull(decline); - Assert.Contains("1.20.1", decline); - Assert.Contains("1.19.4", decline); - } - - /// The control: the pack lane's real shape — query and manifest agree — shelves at - /// the declared version, and an upload with no query takes the manifest's. - [Fact] - public void Publish_ShelvesAtTheDeclaredVersion_WhenQueryAndBundleAgree() - { - var files = new[] { new BundleReader.ModuleFile(Ai + ".dll", [1]) }; - var (agreeing, _) = ModulePublish.Validate("AI", PublishManifest("1.20.1"), files, version: "1.20.1"); - var (unstated, _) = ModulePublish.Validate("AI", PublishManifest("1.20.1"), files); - - Assert.Equal("1.20.1", agreeing!.Version); - Assert.Equal("1.20.1", unstated!.Version); - } - - // ─────────────────────────────── the consumer side: the landing compares the label - - /// The registry-served shape: the archive's top-level version is the URL's, the - /// module section states the version its bytes were shelved at. A disagreement is a mislabel. - [Fact] - public void Landing_NamesAMislabel_WhenTheModuleSectionDeclaresAnotherVersion() - { - var served = new BundleReader.Manifest("AI", "1.20.1", "s-id", [], - new BundleReader.ModuleRef(Ai, [Ai + ".dll"]) { Version = "1.19.4" }); - - var mislabel = PluginBundleClient.VersionMislabel("1.20.1", served); - - Assert.NotNull(mislabel); - Assert.Contains("1.20.1", mislabel); - Assert.Contains("1.19.4", mislabel); - Assert.Equal("1.19.4", PluginBundleClient.DeclaredModuleVersion(served)); - } - - /// A producer's bundle states its version only at the top level — compared there. - [Fact] - public void Landing_NamesAMislabel_WhenTheManifestVersionDisagrees() - { - var packed = new BundleReader.Manifest("AI", "1.19.4", "s-id", [], - new BundleReader.ModuleRef(Ai, [Ai + ".dll"])); - - Assert.NotNull(PluginBundleClient.VersionMislabel("1.20.1", packed)); - Assert.Null(PluginBundleClient.VersionMislabel("1.19.4", packed)); - } - - /// A bundle that states no version cannot disagree — it lands as before. - [Fact] - public void Landing_HasNothingToCompare_WhenTheBundleStatesNoVersion() - { - var unversioned = new BundleReader.Manifest("AI", null, "s-id", [], - new BundleReader.ModuleRef(Ai, [Ai + ".dll"])); - - Assert.Null(PluginBundleClient.VersionMislabel("1.20.1", unversioned)); - Assert.Null(PluginBundleClient.DeclaredModuleVersion(unversioned)); - } - - // ─────────────────────────────── the range rule - - [Theory] - [InlineData("^1.20.0", "1.20.1", true)] - [InlineData("^1.20.0", "1.20.0", true)] - [InlineData("^1.20.0", "1.21.3", true)] - [InlineData("^1.20.0", "1.19.4", false)] - [InlineData("^1.20.0", "2.0.0", false)] - [InlineData("^0.3.0", "0.3.9", true)] - [InlineData("^0.3.0", "0.4.0", false)] - [InlineData("~1.20.0", "1.20.7", true)] - [InlineData("~1.20.0", "1.21.0", false)] - [InlineData(">=1.20.0", "3.0.0", true)] - [InlineData(">=1.20.0", "1.19.9", false)] - [InlineData("1.20.1", "1.20.1", true)] - [InlineData("1.20.1", "1.20.2", false)] - [InlineData("", "0.0.1", true)] - public void Satisfies_ReadsTheDeclaredRange(string range, string version, bool expected) => - Assert.Equal(expected, ModuleDependencyFloor.Satisfies(range, version)); - - /// A shape the rule does not understand is UNVERIFIABLE — never met, never refused. - [Theory] - [InlineData("1.x")] - [InlineData("^latest")] - [InlineData("<2.0.0 || >=3")] - public void Satisfies_IsNull_ForARangeItCannotRead(string range) => - Assert.Null(ModuleDependencyFloor.Satisfies(range, "1.20.1")); - - // ─────────────────────────────── the set: a declared floor must be met by what loads - - /// - /// 🚨 THE repro, on the set: Hosting requires AI@^1.20.0 and the landed AI module is - /// 1.19.4. The unmet requirement is named with both packages and both versions. - /// - [Fact] - public async Task Unmet_NamesTheDependentTheDependencyAndBothVersions() - { - await LandAi("1.19.4"); - - var unmet = Assert.Single(ModuleDependencyFloor.Unmet(Installed(), ModuleActivationSidecar.Read(root))); - - Assert.Equal("Hosting", unmet.Dependent); - Assert.Equal("AI@^1.20.0", unmet.Requirement); - Assert.Equal(Ai, unmet.Module); - Assert.Equal("1.19.4", unmet.LoadedVersion); - var sentence = unmet.Describe(); - Assert.Contains("Hosting", sentence); - Assert.Contains("1.55.3", sentence); - Assert.Contains("AI@^1.20.0", sentence); - Assert.Contains("1.19.4", sentence); - } - - /// The control: the same set with AI 1.20.1 landed has nothing unmet. - [Fact] - public async Task Unmet_IsEmpty_WhenTheLandedDependencySatisfiesTheFloor() - { - await LandAi("1.20.1"); - - Assert.Empty(ModuleDependencyFloor.Unmet(Installed(), ModuleActivationSidecar.Read(root))); - } - - /// A requirement whose dependency lands no module here is not this check's to judge. - [Fact] - public async Task Unmet_IgnoresARequirementWithNoLandedModule() - { - await LandAi("1.19.4"); - var installed = ImmutableList.Create( - new PackageManifest { Id = "Hosting", Requires = ["Store@^1.0.0"] }); - - Assert.Empty(ModuleDependencyFloor.Unmet(installed, ModuleActivationSidecar.Read(root))); - } - - /// - /// 🚨 A wave that would propose a set violating a declared floor proposes NOTHING: the call - /// faults naming the floor, and no set record is written — the mesh stays on the set it runs. - /// - [Fact] - public async Task CheckedProposal_RefusesASetWhoseFloorIsNotMet_AndWritesNoSet() - { - await LandAi("1.19.4"); - - var refusal = await Assert.ThrowsAsync(async () => - await landing.ProposeCheckedModuleSet(Installed()) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken)); - - Assert.Contains("AI@^1.20.0", refusal.Message); - Assert.Contains("1.19.4", refusal.Message); - Assert.Null(ModuleSetStore.Read(root).Proposed); - } - - /// The control: the satisfying set is proposed exactly as the unchecked call would. - [Fact] - public async Task CheckedProposal_ProposesASetWhoseFloorsAreMet() - { - await LandAi("1.20.1"); - - var proposed = await landing.ProposeCheckedModuleSet(Installed()) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - Assert.NotNull(proposed); - Assert.Contains(Ai, proposed!.Generations.Keys); - } - - private static ImmutableList Installed() => - [ - new PackageManifest - { - Id = "Hosting", ReleasedVersion = "1.55.3", Module = "MeshWeaver.SelfUpdate.Aks", - Requires = ["Store@^1.0.0", "AI@^1.20.0"], - }, - new PackageManifest { Id = "AI", ReleasedVersion = "1.20.1", Module = Ai }, - ]; - - private async Task LandAi(string version) => - await landing.LandModule( - Ai, - [(Ai + ".dll", File.ReadAllBytes(typeof(BundleReader).Assembly.Location))], - packagePath: "Plugins/AI", - version: version) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - private static BundleReader.Manifest PublishManifest(string version) => - new("AI", version, "test-build", [], new BundleReader.ModuleRef(Ai, [Ai + ".dll"])); -} diff --git a/test/Memex.Portal.Shared.Test/ModulesGoLiveWhileARollIsHandedOverTest.cs b/test/Memex.Portal.Shared.Test/ModulesGoLiveWhileARollIsHandedOverTest.cs deleted file mode 100644 index d17e590030..0000000000 --- a/test/Memex.Portal.Shared.Test/ModulesGoLiveWhileARollIsHandedOverTest.cs +++ /dev/null @@ -1,401 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#6829 — a landed module set is not left unloaded because a platform roll was -/// handed over. Measured on memex.systemorph.com 2026-10-16: three replicas booted onto module set -/// 1986, set 1987 was proposed at 08:38:57Z, and every check those replicas ran from then on was a -/// BuildCompletion that found a newer platform build and HANDED it to the control lane. A -/// hand-over skipped the module half of the check ENTIRELY — not only the restart (which would race the -/// routed Roll) but the live swap too, which races nothing. So the replicas reported "20 module(s) are -/// landed but not yet loaded" until the roll re-created them; behind a roll that waits for an approval -/// they would have reported it indefinitely. -/// -/// The arrangement is the production one with only the IO seams faked (the registry listing, the -/// k8s updater, the control inbox): an instance on the control lane RUNS module M at generation N, the -/// registry lists a newer platform build, and generation N+1 lands through the real -/// . One check runs. -/// -/// a live-updatable M goes LIVE in the process, the release is still handed over, and NO -/// restart is announced (the roll is the restart); -/// an M that cannot be swapped in-process keeps N serving and announces NO separate restart — -/// the verdict names the handed-over roll as what activates it. -/// -/// Negative control: the same check with NO newer platform build takes the module half as it -/// always did — which is what makes the first bullet a statement about the hand-over and not about -/// the live updater. -/// -public sealed class ModulesGoLiveWhileARollIsHandedOverTest : MonolithMeshTestBase -{ - private const string Module = "MeshWeaver.Test.HandedOverGoesLive"; - private const string ProbePath = "HandedOverGoesLiveProbe"; - private const string CandidateTag = "9999.0.0-ci.1"; - private const string Deployment = "unit-fleet-instance"; - private const string InboxUrl = "https://control.example/api/hooks/Hosting/PlatformBuilds"; - private const string Secret = "unit-control-inbox-secret"; - private static TimeSpan Budget => TestTimeouts.Convergence; - - private static string Installed => ShippedReleaseSeed.InstalledPlatformVersion; - private static string[] NewerPlatform => ["1.9.0-ci.0", Installed.Split('+')[0], CandidateTag]; - private static string[] NothingNewer => ["1.9.0-ci.0", Installed.Split('+')[0]]; - - private readonly string root = Path.Combine(Path.GetTempPath(), "mw-6829-" + Guid.NewGuid().ToString("N")); - private string? bootEntry; - - public ModulesGoLiveWhileARollIsHandedOverTest(ITestOutputHelper output) : base(output) - { - } - - /// The instance RUNNING M@N, landed and loaded at boot — before the base builds the mesh. - private string BootEntry - { - get - { - if (bootEntry is not null) - return bootEntry; - var directory = Path.Combine(root, "modules", $"{Module}@g1"); - Directory.CreateDirectory(directory); - File.WriteAllBytes(Path.Combine(directory, Module + ".dll"), Emit(ModuleSource(1))); - ModuleActivationSidecar.Write(root, new ModuleActivationList - { - Entries = [new ModuleActivationEntry { Name = Module, Directory = $"{Module}@g1", Version = "1" }], - }); - return bootEntry = Path.Combine(directory, Module + ".dll"); - } - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => - base.ConfigureMesh(builder) - .AddUpdatePolicyType() - .AddGitHubSyncTypes() - .AddPluginCatalog() - .InstallAssemblies(BootEntry) - .ConfigureServices(services => - { - // The secret the hand-over signs with is read off the mesh's configuration at - // delivery time, exactly as in production. - services.AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection(ImmutableDictionary.Empty.Add(SelfUpdateHandover.SecretKey, Secret)) - .Build()); - services.RemoveAll(); - services.AddSingleton(_ => new ModuleLandingService(baseDirectory: root)); - services.RemoveAll(); - services.AddSingleton(sp => new PendingModuleActivations(root) - { - IoPool = sp.GetRequiredService().Get(IoPoolNames.FileSystem), - ModuleContexts = sp.GetRequiredService(), - }); - return services; - }); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - private ModuleLandingService Landing => Mesh.ServiceProvider.GetRequiredService(); - - [Fact(Timeout = 300_000)] - public async Task ALiveUpdatableModule_GoesLive_WhileThePlatformRollIsHandedOver() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - (await NameAt(ProbePath, ct)).Should().Be("v1", "the arrangement: the instance runs M@N"); - - await Land(ModuleSource(2), "2", ct); - var inbox = new FakeControlInbox(); - var updater = new RecordingUpdater(); - var verdict = await RunOneCheck(updater, inbox, NewerPlatform, ct); - - verdict.Should().Contain("handed to the control lane", "the arrangement: a newer platform build was handed over"); - verdict.Should().Contain("went LIVE", "a hand-over is no reason to leave a swappable module unloaded"); - (await NameAt(ProbePath, ct)).Should().Be("v2", "M@N+1 serves in the running process"); - inbox.Events.Should().Equal(new[] { SelfUpdateHandover.ReleaseEvent }, - "the release is handed over and NO restart is announced — the roll is the restart"); - updater.Restarts.Should().Be(0); - updater.Tags.Should().BeEmpty("a control-lane install never patches itself"); - AssertNothingStuck(); - } - - /// - /// The activation marker outlives a successful live swap until the next boot, so every LATER - /// handed-over check still finds it set — with nothing pending in this process. That check must - /// say nothing is pending here (still ), never that an - /// activation is still pending, and must announce nothing. - /// - [Fact(Timeout = 300_000)] - public async Task ALaterHandedOverCheck_AfterTheLiveSwap_SaysNothingIsPendingHere() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - await Land(ModuleSource(2), "2", ct); - var first = await RunOneCheck(new RecordingUpdater(), new FakeControlInbox(), NewerPlatform, ct); - first.Should().Contain("went LIVE", "the arrangement: the first handed-over check swapped M@N+1 in"); - AssertNothingStuck(); - - var inbox = new FakeControlInbox(); - var updater = new RecordingUpdater(); - var verdict = await RunOneCheck(updater, inbox, NewerPlatform, ct); - - verdict.Should().Contain("handed to the control lane", "the outcome is still the hand-over"); - verdict.Should().Contain("nothing landed is pending in this process", - "the live swap already happened — the marker alone is not a pending activation"); - verdict.Should().NotContain("A landed module generation is pending activation", "nothing is pending in this process"); - verdict.Should().NotContain("went LIVE"); - inbox.Events.Should().Equal(new[] { SelfUpdateHandover.ReleaseEvent }); - updater.Restarts.Should().Be(0); - (await NameAt(ProbePath, ct)).Should().Be("v2"); - } - - [Fact(Timeout = 300_000)] - public async Task ARestartRequiredModule_WaitsForTheHandedOverRoll_AndNoSecondRestartIsAnnounced() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - await Land(ModuleSource(2, configuresMeshHub: true), "2", ct); - var inbox = new FakeControlInbox(); - var updater = new RecordingUpdater(); - var verdict = await RunOneCheck(updater, inbox, NewerPlatform, ct); - - inbox.Events.Should().Equal(new[] { SelfUpdateHandover.ReleaseEvent }, - "a restart request would only race the routed Roll the hand-over opened"); - verdict.Should().Contain("activated by the handed-over roll", - "the verdict names what activates the module that could not go live"); - (await NameAt(ProbePath, ct)).Should().Be("v1", "N keeps serving until the roll — never a half-swapped state"); - } - - /// - /// The negative control: no newer platform build, nothing handed over — the module half runs as it - /// always did and the module goes live. Together with the first test this pins the hand-over as the - /// variable: before #6829's fix, that test's check skipped the live swap and M@N kept serving. - /// - [Fact(Timeout = 300_000)] - public async Task WithNothingHandedOver_TheModuleHalfRunsAsBefore() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - await Land(ModuleSource(2), "2", ct); - var inbox = new FakeControlInbox(); - var verdict = await RunOneCheck(new RecordingUpdater(), inbox, NothingNewer, ct); - - verdict.Should().Contain("no newer release").And.Contain("went LIVE"); - inbox.Events.Should().BeEmpty("nothing to hand over, and nothing needed a restart"); - (await NameAt(ProbePath, ct)).Should().Be("v2"); - } - - // ═════════════════════════════════════════ harness - - private void AssertNothingStuck() - { - var report = Mesh.ServiceProvider.GetRequiredService().Read(); - if (report.IsUndetermined) - throw new InvalidOperationException("the activation state could not be read: " + report.UndeterminedReason); - if (report.HasPending) - throw new InvalidOperationException( - "landed but not loaded in the running process: " + string.Join(", ", report.Pending.Select(p => p.Name))); - } - - private async Task Land(string source, string version, CancellationToken ct) => - await Landing.LandModule(Module, [(Module + ".dll", Emit(source))], version: version) - .Timeout(Budget).Await(ct); - - private async Task NameAt(string path, CancellationToken ct) => - (await ReadNode(path).Timeout(Budget).Await(ct))?.Name; - - private static string ModuleSource(int version, bool configuresMeshHub = false) => $$""" - [assembly: MeshWeaver.Test.HandedOverGoesLive.Module] - namespace MeshWeaver.Test.HandedOverGoesLive; - public sealed class ModuleAttribute : MeshWeaver.Mesh.MeshNodeProviderAttribute - { - public override System.Collections.Generic.IEnumerable Nodes => - [new MeshWeaver.Mesh.MeshNode("{{ProbePath}}") { Name = "v{{version}}", NodeType = "Markdown" }]; - {{(configuresMeshHub - ? "public override System.Collections.Generic.IEnumerable> HubConfigurations => [c => c with { }];" - : "")}} - } - """; - - private static byte[] Emit(string source) - { - var compilation = CSharpCompilation.Create( - Module, - [CSharpSyntaxTree.ParseText(source)], - PlatformReferences.Platform(), - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - return StandInCompile.Emit(compilation); - } - - /// The network, substituted: records the event of every delivery and accepts it, verified. - private sealed class FakeControlInbox : HttpMessageHandler - { - private ImmutableList events = ImmutableList.Empty; - - public ImmutableList Events => events; - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - var body = await request.Content!.ReadAsStringAsync(ct); - using var doc = JsonDocument.Parse(body); - var name = doc.RootElement.GetProperty("event").GetString() ?? ""; - ImmutableInterlocked.Update(ref events, current => current.Add(name)); - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"status\":\"accepted\",\"signature\":\"verified\"}"), - }; - } - } - - private sealed class SeamedHandover(IMessageHub hub, HttpMessageHandler inbox) - : SelfUpdateHandover(hub, null, new HttpClient(inbox)) - { - public override Settings ReadSettings() => - new(Deployment, InboxUrl, SecretPresent: true, LocalTargetListed: false, LocalSecretPresent: false, - "https://unit.example"); - } - - private sealed class FakeAcrTagLister(IReadOnlyList tags) : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult(tags); - } - - /// The k8s seam: an updater that COULD patch, so only the chart's declaration decides. - private sealed class RecordingUpdater : IDeploymentUpdater - { - private ImmutableList tags = ImmutableList.Empty; - private int restarts; - - public ImmutableList Tags => tags; - public int Restarts => Volatile.Read(ref restarts); - public bool CanPatch => true; - - public Task LastRolledAtAsync(CancellationToken ct) => Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) - { - ImmutableInterlocked.Update(ref tags, current => current.Add(versionTag)); - return Task.CompletedTask; - } - - public Task RestartAsync(CancellationToken ct) - { - Interlocked.Increment(ref restarts); - return Task.FromResult(true); - } - } - - private sealed class AlwaysAvailable(IMessageHub hub, IConfiguration configuration) - : ReleaseAvailabilityService(hub, configuration) - { - public override IObservable IsUpdatable(string? targetVersion) => - Observable.Return(UpdatabilityVerdict.NotEnforced("this test host consumes no CI bakes")); - } - - private sealed class SeamedSelfUpdateService( - IMessageHub hub, IAcrTagLister acr, IDeploymentUpdater updater, SelfUpdateOptions options, - ILogger? logger, ReleaseAvailabilityService gate, SelfUpdateHandover handover) - : SelfUpdateHostedService(hub, acr, updater, options, logger) - { - public IObservable Evaluations => ChecksReported; - - protected override ReleaseAvailabilityService? ResolveAvailabilityGate() => gate; - - protected override ComboVerificationGate? ResolveComboGate() => null; - - protected override SelfUpdateHandover ResolveHandover() => handover; - } - - private async Task RunOneCheck(IDeploymentUpdater updater, FakeControlInbox inbox, string[] registry, CancellationToken ct) - { - var options = new SelfUpdateOptions - { - RetryInterval = TimeSpan.FromMilliseconds(500), - EventCoalesceWindow = TimeSpan.FromMilliseconds(50), - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - MinRollInterval = TimeSpan.Zero, - CanPatch = false, - Registry = "cr.example", - }; - var service = new SeamedSelfUpdateService( - Mesh, new FakeAcrTagLister(registry), updater, options, - Mesh.ServiceProvider.GetService>(), - new AlwaysAvailable(Mesh, new ConfigurationBuilder().Build()), - new SeamedHandover(Mesh, inbox)); - var before = DateTimeOffset.UtcNow; - // 🚨 Subscribe BEFORE StartAsync: Await subscribes on call, ChecksReported is a non-replaying - // Subject, and StartAsync schedules the startup check on the task pool and returns at once — a - // check that finished before a later subscription would be lost and the wait would time out. - var evaluated = service.Evaluations.FirstAsync().Timeout(Budget).Await(ct); - await service.StartAsync(CancellationToken.None); - try - { - await evaluated; - return await Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace() - .GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .Subscribe(observer); - }) - .Where(c => c.LastCheckedAt >= before) - .Select(c => c.LastCheckVerdict) - .OfType() - .FirstAsync() - .Timeout(Budget) - .Await(ct); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - } - - private Task Seed(CancellationToken ct) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, Pattern = "*-ci*" }, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(ct); - } -} diff --git a/test/Memex.Portal.Shared.Test/ModulesUpdateIndependentlyOfThePlatformTest.cs b/test/Memex.Portal.Shared.Test/ModulesUpdateIndependentlyOfThePlatformTest.cs deleted file mode 100644 index 3f72a31a93..0000000000 --- a/test/Memex.Portal.Shared.Test/ModulesUpdateIndependentlyOfThePlatformTest.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 "Modules must update independently from platform" (policy module-live-update-default). -/// The running platform P stays FIXED — one process, one image — while module M goes N → N+1 → N+2 -/// live, each arriving through the real landing path () -/// and activated by the real live-first pass (): -/// -/// N+1 is an ordinary update; -/// N+2 was built against an OLDER platform build (a different recorded framework identity, a floor -/// below P) — compatible, so it lands and goes live: no identity-equality gate, no seal, no roll; -/// N+3 declares a floor ABOVE P — declined BY NAME by the same decision the reconciler takes -/// ( with , called directly: the decline -/// comes BEFORE anything lands, so no above-floor bundle reaches the landing path). The reconciler's -/// wiring of the decision is exercised end to end from a real registry by -/// (unattended pass), -/// (attended reload) and (the adopt). The next -/// activation pass then takes only the SIBLING module S's landed update, while M keeps serving N+2. -/// -/// Plus the landing refusal: a bundle that carries a platform assembly is refused, naming it (negative -/// control: the same bundle without it lands). -/// -public sealed class ModulesUpdateIndependentlyOfThePlatformTest : MonolithMeshTestBase -{ - private const string M = "MeshWeaver.Test.IndependentM"; - private const string S = "MeshWeaver.Test.IndependentS"; - private const string RunningPlatform = "3.0.0"; - private static TimeSpan Budget => TestTimeouts.Convergence; - - private readonly string root = Path.Combine(Path.GetTempPath(), "mw-independent-" + Guid.NewGuid().ToString("N")); - private (string M, string S)? boot; - - public ModulesUpdateIndependentlyOfThePlatformTest(ITestOutputHelper output) : base(output) - { - } - - private (string M, string S) Boot => boot ??= (Seed(M, 1), Seed(S, 1)); - - private string Seed(string module, int version) - { - var directory = Path.Combine(root, "modules", $"{module}@g1"); - Directory.CreateDirectory(directory); - File.WriteAllBytes(Path.Combine(directory, module + ".dll"), Emit(module, version)); - var existing = ModuleActivationSidecar.Read(root).Entries; - ModuleActivationSidecar.Write(root, new ModuleActivationList - { - Entries = existing.Add(new ModuleActivationEntry { Name = module, Directory = $"{module}@g1", Version = "1" }), - }); - return Path.Combine(directory, module + ".dll"); - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => - base.ConfigureMesh(builder) - .AddPluginCatalog() - .InstallAssemblies(Boot.M, Boot.S) - .ConfigureServices(services => - { - services.RemoveAll(); - services.AddSingleton(_ => new ModuleLandingService(baseDirectory: root)); - services.RemoveAll(); - services.AddSingleton(sp => new PendingModuleActivations(root) - { - IoPool = sp.GetRequiredService().Get(IoPoolNames.FileSystem), - ModuleContexts = sp.GetRequiredService(), - }); - return services; - }); - - private ModuleLandingService Landing => Mesh.ServiceProvider.GetRequiredService(); - private ModuleLiveActivation Live => Mesh.ServiceProvider.GetRequiredService(); - private ModuleContexts Contexts => Mesh.ServiceProvider.GetRequiredService(); - - [Fact(Timeout = 300_000)] - public async Task ThePlatformStaysFixed_WhileAModuleGoesNToNPlus2Live_AndAFloorAboveItIsDeclinedWhileASiblingUpdates() - { - var ct = TestContext.Current.CancellationToken; - (await Probe(M, ct)).Should().Be("M v1"); - - // N → N+1: an ordinary update. - await Land(M, 2, ct); - (await Activate(ct)).WentLive.Select(o => o.Module).Should().Equal([M]); - (await Probe(M, ct)).Should().Be("M v2"); - - // N+1 → N+2: built against an OLDER platform build — another framework identity, a floor below P. - await Land(M, 3, ct, frameworkMvid: "0123456789abcdef0123456789abcdef", minMeshVersion: "2.9.0"); - var compatible = await Activate(ct); - compatible.NeedsRestart.Should().BeFalse(compatible.Describe()); - (await Probe(M, ct)).Should().Be("M v3", - "a module built against an older compatible platform goes live with no new image, no roll and no seal"); - - // N+3: a floor ABOVE P — declined by name BEFORE it lands (the reconciler's decision, called - // directly); the next activation pass takes only the sibling S, and M stays on N+2. - var held = ModuleUpdateDecision.Decide( - bundleVersion: "4", - bundleMinMeshVersion: "99.0.0", - platformGate: _ => null, - landed: (await Landing.GetActivation().Timeout(Budget).Await(ct)).Entries.Single(e => e.Name == M), - policyDecline: null, - landedBytesPresent: _ => true, - floorHold: floor => PlatformFloor.HoldReason(floor, RunningPlatform)); - held.Action.Should().Be(ModuleUpdateAction.SkipPlatformBelowFloor); - held.Reason.Should().Contain("99.0.0").And.Contain(RunningPlatform); - - await Land(S, 2, ct); - var sibling = await Activate(ct); - sibling.WentLive.Select(o => o.Module).Should().Equal([S], "the sibling keeps updating"); - (await Probe(S, ct)).Should().Be("S v2"); - (await Probe(M, ct)).Should().Be("M v3", "M keeps serving the newest generation that may run here"); - } - - [Fact(Timeout = 300_000)] - public async Task ABundleThatCarriesAPlatformAssembly_IsRefusedByName_AndTheSameBundleWithoutItLands() - { - var ct = TestContext.Current.CancellationToken; - var platform = typeof(MeshNode).Assembly; - var platformFile = Path.GetFileName(platform.Location); - - var refused = async () => await Landing.LandModule(M, - [(M + ".dll", Emit(M, 2)), (platformFile, await File.ReadAllBytesAsync(platform.Location, ct))], - version: "2") - .Timeout(Budget).Await(ct); - (await refused.Should().ThrowAsync()) - .WithMessage($"*{Path.GetFileNameWithoutExtension(platformFile)}*"); - - await Land(M, 2, ct); - (await Landing.GetActivation().Timeout(Budget).Await(ct)).Entries.Single(e => e.Name == M).Version - .Should().Be("2", "the control: the same module without the platform assembly lands"); - } - - private async Task Land(string module, int version, CancellationToken ct, string? frameworkMvid = null, string? minMeshVersion = null) => - await Landing.LandModule(module, [(module + ".dll", Emit(module, version))], - frameworkMvid: frameworkMvid, version: version.ToString(), minMeshVersion: minMeshVersion) - .Timeout(Budget).Await(ct); - - private async Task Activate(CancellationToken ct) => - await Live.ActivatePending("test: an update arrived").Timeout(Budget).Await(ct); - - private async Task Probe(string module, CancellationToken ct) - { - var path = module == M ? "IndependentMProbe" : "IndependentSProbe"; - return (await ReadNode(path).Timeout(Budget).Await(ct))?.Name; - } - - private static byte[] Emit(string module, int version) - { - var letter = module == M ? "M" : "S"; - var source = $$""" - [assembly: {{module}}.Module] - namespace {{module}}; - public sealed class ModuleAttribute : MeshWeaver.Mesh.MeshNodeProviderAttribute - { - public override System.Collections.Generic.IEnumerable Nodes => - [new MeshWeaver.Mesh.MeshNode("Independent{{letter}}Probe") { Name = "{{letter}} v{{version}}", NodeType = "Markdown" }]; - } - """; - var compilation = CSharpCompilation.Create(module, [CSharpSyntaxTree.ParseText(source)], PlatformReferences.Platform(), - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - using var buffer = new MemoryStream(); - var result = compilation.Emit(buffer); - result.Success.Should().BeTrue(string.Join(Environment.NewLine, - result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); - return buffer.ToArray(); - } -} diff --git a/test/Memex.Portal.Shared.Test/MountedCheckoutReassertsOnEveryBootTest.cs b/test/Memex.Portal.Shared.Test/MountedCheckoutReassertsOnEveryBootTest.cs deleted file mode 100644 index 5ce95fc8a8..0000000000 --- a/test/Memex.Portal.Shared.Test/MountedCheckoutReassertsOnEveryBootTest.cs +++ /dev/null @@ -1,233 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// MeshWeaver#3359 — a self-registry memex-local mounts the developer's checkouts and -/// serves packages from them, and the mounted repos are selected by the operator's -/// PluginCatalog:InstallByDefault patterns. That lane SEEDS: it installs once, records the -/// package on Plugins/_DefaultInstallLedger, and never re-asserts it — right for a -/// deployment seeding itself, wrong for a portal whose purpose is to MIRROR a working tree. Measured -/// on 2026-09-05: a portal served a course as of 2026-08-31 while the mount had moved on for a week, -/// every boot logging "0 written, 0 unchanged". Neither auto-update mechanism covers the shape -/// (webhooks a local install never receives; a registry reconciler with no registry). -/// -/// The fix is a lane, not a script: a package the operator's patterns select out of a -/// is RECONCILED — exempt from the seed -/// ledger, re-asserted on every boot, at no cost when nothing changed. This fixture is the -/// production decision end to end: a real directory in node-repo format, configured the way -/// memex-local configures a mount (PluginCatalog:Sources:N with a local -/// RepoPath), selected by InstallByDefault, through the very -/// the boot pass runs. -/// -public class MountedCheckoutReassertsOnEveryBootTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - // Written BEFORE the mesh is built: a derived class's field initializers run ahead of the base - // constructor, which is what calls ConfigureMesh. - private readonly string checkout = MountedRepo.Write("mw-mount-", "A course, as of its first install."); - - private IConfiguration Environment() => - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - // Exactly what memex-local writes for a mounted repo: a local RepoPath, node-repo format, - // the repo's name as the source name. - ["PluginCatalog:Sources:0:Name"] = "Education", - ["PluginCatalog:Sources:0:RepoPath"] = checkout, - ["PluginCatalog:Sources:0:Format"] = "node-repo", - }).Build(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(Environment()) - // The baseline is off and no flag is declared: only the operator's pattern over the - // mount can select anything here, so what re-asserts proves the local-checkout lane - // and nothing else. - .AddSingleton(new PluginCatalogOptions - { - InstallPreInstalledPackages = false, - InstallByDefault = ["Education/*"], - })); - - public override async ValueTask DisposeAsync() - { - try { await base.DisposeAsync(); } - finally { MountedRepo.Delete(checkout); } - } - - private InstanceAutoRegistrationService Installer => - Mesh.ServiceProvider.GetRequiredService(); - - [Fact(Timeout = 240_000)] - public async Task ThePortalKeepsMirroringTheMount_AfterItWasSeededOnce() - { - // Boot 1: the seed installs the course and ledgers it — the state every populated - // self-registry portal sits in. - var first = await Installer.Completed.FirstAsync().Timeout(TimeSpan.FromSeconds(180)).Await(TestContext.Current.CancellationToken); - first.Packages.Should().Equal(new[] { "Course" }); - first.Failed.Should().Be(0); - (await Read("Course/Lesson"))!.ContentAs(Mesh.JsonSerializerOptions).Should().Be("# Lesson, as of its first install."); - - // The developer keeps working: the lesson changes and a new node appears in the checkout. - MountedRepo.Write(checkout, "A course, as of its first install.", lesson: "# Lesson, rewritten since."); - MountedRepo.WriteNode(checkout, "Quiz", "# A quiz that did not exist at the first install."); - - // Boot 2: the same production decision, unchanged configuration. Seeded once, this pass - // skipped the package on the ledger and the portal kept serving the first install forever — - // the defect. A mounted checkout is standing operator intent, so it re-asserts. - var second = await Installer.RunDefaultInstall().Timeout(TimeSpan.FromSeconds(120)).Await(TestContext.Current.CancellationToken); - second.Packages.Should().Equal(new[] { "Course" }, - "a package selected out of a LOCAL checkout is reconciled on every boot, never seeded once (MeshWeaver#3359)"); - second.Failed.Should().Be(0); - second.Installed.Should().Be(1, "the checkout changed, so the pass wrote it"); - - (await Read("Course/Lesson"))!.ContentAs(Mesh.JsonSerializerOptions).Should().Be("# Lesson, rewritten since."); - (await Read("Course/Quiz")).Should().NotBeNull("a node added to the checkout after the first install reaches the portal"); - - // Boot 3: nothing changed in the checkout — the reconcile is one listing and no writes. - var third = await Installer.RunDefaultInstall().Timeout(TimeSpan.FromSeconds(120)).Await(TestContext.Current.CancellationToken); - third.Packages.Should().Equal(new[] { "Course" }); - third.Installed.Should().Be(0, "the content-hash gate turns an unchanged mount into a no-op"); - third.UpToDate.Should().Be(1); - } - - /// Authoritative single-node read straight off storage (never the lagging index). - private Task Read(string path) => - Mesh.ServiceProvider.GetRequiredService() - .Read(path, Mesh.JsonSerializerOptions) - .Take(1).Timeout(TimeSpan.FromSeconds(30)).Await(TestContext.Current.CancellationToken); -} - -/// -/// The control for : the SAME patterns over a -/// source that is NOT a local checkout keep seeding once. A registered -/// (the shape a remote registry or a DI-provided source takes — no RepoPath, so never a -/// checkout) is installed on boot 1, ledgered, and left alone on boot 2 even though its content -/// moved. That is the seed contract every deployed portal relies on ("the seed must not fight an -/// operator"), and the local-checkout lane must not widen it. -/// -public class FetchedSourceStillSeedsOnceTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private readonly string checkout = MountedRepo.Write("mw-fetched-", "A course, fetched."); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - // A DI-registered source is named `registered-0` by the service and carries no - // RepoPath: the fetched-source shape, whatever it reads from — here the same files, - // so the only difference between the two fixtures is the source's kind. - .AddSingleton(_ => new NodeRepoPackageSource( - (_, _, _, _) => MountedRepo.Snapshot(checkout), "https://example.invalid/education")) - .AddSingleton(new PluginCatalogOptions - { - InstallPreInstalledPackages = false, - InstallByDefault = ["registered-0/*"], - })); - - public override async ValueTask DisposeAsync() - { - try { await base.DisposeAsync(); } - finally { MountedRepo.Delete(checkout); } - } - - private InstanceAutoRegistrationService Installer => - Mesh.ServiceProvider.GetRequiredService(); - - [Fact(Timeout = 240_000)] - public async Task ASeededPackageFromAFetchedSource_IsNotReasserted() - { - var first = await Installer.Completed.FirstAsync().Timeout(TimeSpan.FromSeconds(180)).Await(TestContext.Current.CancellationToken); - first.Packages.Should().Equal(new[] { "Course" }); - - MountedRepo.Write(checkout, "A course, fetched.", lesson: "# Lesson, rewritten since."); - - var second = await Installer.RunDefaultInstall().Timeout(TimeSpan.FromSeconds(120)).Await(TestContext.Current.CancellationToken); - second.Packages.Should().BeEmpty( - "a fetched source's package is a SEED: ledgered on the first boot and never re-asserted, " - + "so an operator who removes it is not fought by the next restart"); - - (await Read("Course/Lesson"))!.ContentAs(Mesh.JsonSerializerOptions).Should().Be("# Lesson, fetched.", - "the seed did not touch it again"); - } - - private Task Read(string path) => - Mesh.ServiceProvider.GetRequiredService() - .Read(path, Mesh.JsonSerializerOptions) - .Take(1).Timeout(TimeSpan.FromSeconds(30)).Await(TestContext.Current.CancellationToken); -} - -/// One package, Course, in node-repo format on disk — the shape memex-local mounts. -internal static class MountedRepo -{ - private const string Root = - """ - {"$type":"MeshNode","id":"Course","namespace":"","path":"Course","mainNode":"Course", - "name":"Course","nodeType":"Space","state":"Active", - "content":{"$type":"PluginManifest","description":"DESCRIPTION","minMeshVersion":"1.0.0"}} - """; - - private const string Node = - """ - {"$type":"MeshNode","id":"ID","namespace":"Course","path":"Course/ID","mainNode":"Course/ID", - "name":"ID","nodeType":"Markdown","state":"Active","content":"CONTENT"} - """; - - public static string Write(string prefix, string description) - { - var root = Path.Combine(Path.GetTempPath(), prefix + Guid.NewGuid().ToString("N")[..8]); - Write(root, description, lesson: "# Lesson, " + description.Split(", ")[1]); - return root; - } - - /// (Re)writes the package root and its lesson; a longer lesson changes the - /// directory's content hash, which is what the mirror has to notice. - public static void Write(string root, string description, string lesson) - { - Directory.CreateDirectory(Path.Combine(root, "Course")); - File.WriteAllText(Path.Combine(root, "Course", "index.json"), - Root.Replace("DESCRIPTION", description, StringComparison.Ordinal)); - WriteNode(root, "Lesson", lesson); - } - - public static void WriteNode(string root, string id, string content) => - File.WriteAllText(Path.Combine(root, "Course", id + ".json"), - Node.Replace("ID", id, StringComparison.Ordinal).Replace("CONTENT", content, StringComparison.Ordinal)); - - /// The directory as a fetched snapshot — what a remote source would answer. - public static IObservable Snapshot(string root) - { - var files = Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) - .OrderBy(p => p, StringComparer.Ordinal) - .Select(p => new MeshWeaver.GitSync.RepoFile( - Path.GetRelativePath(root, p).Replace('\\', '/'), File.ReadAllText(p))) - .ToList(); - // The snapshot id is a hash of paths AND contents, so any change — even one that keeps a - // file's length — is a new snapshot. - var sha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData( - System.Text.Encoding.UTF8.GetBytes(string.Join("\n", files.Select(f => f.Path + "\0" + f.Content))))); - return Observable.Return(new MeshWeaver.GitSync.RepoSnapshot(sha, files)); - } - - public static void Delete(string root) - { - try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } - } -} diff --git a/test/Memex.Portal.Shared.Test/NewlyListedPackageWaitsForGovernedProvisionTest.cs b/test/Memex.Portal.Shared.Test/NewlyListedPackageWaitsForGovernedProvisionTest.cs deleted file mode 100644 index 99299e1766..0000000000 --- a/test/Memex.Portal.Shared.Test/NewlyListedPackageWaitsForGovernedProvisionTest.cs +++ /dev/null @@ -1,43 +0,0 @@ -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the GOVERNED-PROVISION HOLD of the boot default install: once an instance has been seeded, -/// a package newly listed in a source it seeds with a whole-source pattern (Plugins/*) is -/// AVAILABLE but not installed. Measured 2026-09-29: a package merged into MeshWeaver.Plugins -/// main was installed on two instances within 33 minutes because "never seeded" read as -/// "install it". It now lands through a governed package.provision or an explicit entry. -/// -public class NewlyListedPackageWaitsForGovernedProvisionTest -{ - [Fact] - public void ANewPackageCoveredOnlyByAWildcard_IsHeld_OnASeededInstance() => - InstanceAutoRegistrationService.HoldsForGovernedProvision( - wildcardOnly: true, reconciled: false, recordedInLedger: false, freshInstance: false).Should().BeTrue(); - - /// A fresh deployment still seeds everything its patterns cover — that is what the seed is for. - [Fact] - public void AFreshInstance_StillSeedsItsWildcards() => - InstanceAutoRegistrationService.HoldsForGovernedProvision( - wildcardOnly: true, reconciled: false, recordedInLedger: false, freshInstance: true).Should().BeFalse(); - - /// An entry that names the package exactly is a reviewed decision (a deployment PR) — ClientA's explicit list. - [Fact] - public void AnExactlyNamedPackage_Installs() => - InstanceAutoRegistrationService.HoldsForGovernedProvision( - wildcardOnly: false, reconciled: false, recordedInLedger: false, freshInstance: false).Should().BeFalse(); - - /// The platform baseline and this environment's flags are reconciled lanes and re-assert as before. - [Fact] - public void AReconciledLane_Installs() => - InstanceAutoRegistrationService.HoldsForGovernedProvision( - wildcardOnly: true, reconciled: true, recordedInLedger: false, freshInstance: false).Should().BeFalse(); - - /// Nothing already seeded changes: the ledger filter owns that case and is untouched. - [Fact] - public void AnAlreadySeededPackage_IsNotTheHoldsBusiness() => - InstanceAutoRegistrationService.HoldsForGovernedProvision( - wildcardOnly: true, reconciled: false, recordedInLedger: true, freshInstance: false).Should().BeFalse(); -} diff --git a/test/Memex.Portal.Shared.Test/NoOpEmailSenderRefusalTest.cs b/test/Memex.Portal.Shared.Test/NoOpEmailSenderRefusalTest.cs deleted file mode 100644 index f1cc22f9e0..0000000000 --- a/test/Memex.Portal.Shared.Test/NoOpEmailSenderRefusalTest.cs +++ /dev/null @@ -1,250 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Email; -using MeshWeaver.Mesh; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 Email:Enabled=true with a sender that cannot deliver must NEVER produce a -/// Sent record (#2023). -/// -/// The defect: once the Graph sender moved into the MeshWeaver.Mail.MicrosoftGraph -/// module, an install could have Email:Enabled=true and still resolve -/// — whose contract was to report success without sending. Every -/// queued mail was then stamped New → Sending → Sent while nothing left the process: node -/// data saying delivered, no error, and no log (the send lines are Information under a Warning -/// default). Hours went into inboxes, junk folders and message traces because Sent was -/// trusted. -/// -/// These cases pin the whole refusal, at the three places it has to hold: the sender itself, -/// the send state machine that writes the status, and the watcher that would have claimed the mail -/// in the first place. The last one is what keeps mail recoverable — mail left New goes out -/// by itself once the module lands, mail stamped Sent is indistinguishable from mail that -/// really was sent and can never be re-driven. -/// -public class NoOpEmailSenderRefusalTest -{ - private static readonly TimeSpan TestBudget = TimeSpan.FromSeconds(10); - - private static EmailOptions Configured(bool enabled) => new() - { - Enabled = enabled, - MailboxAddress = "no-reply@example.test", - TenantId = "tenant", - ClientId = "client", - ClientSecret = "secret", - }; - - /// - /// The one case the no-op is FOR: mail is switched off, nothing queues, and a caller's chain - /// completes normally. Kept as the control — without it "refuses everything" would satisfy the - /// test below while breaking every local dev run and test host. - /// - [Fact] - public async Task Disabled_TheNoOpStillReportsSuccess_SoLocalDevAndTestsAreUnaffected() - { - IEmailSender sender = new NoOpEmailSender(Configured(enabled: false)); - - Assert.False(sender.DeliversMail); - Assert.True(await sender.SendEmail("x@example.test", "s", "

b

").FirstAsync().Await()); - } - - /// - /// 🚨 The regression pin. Enabled + a sender that does not deliver is a MISCONFIGURATION, and - /// the send must surface as a failure rather than emit true — a caller that records an - /// outcome then records the truth. - /// - [Fact] - public async Task Enabled_ButTheSenderCannotDeliver_TheSendFails_ItNeverReportsSuccess() - { - IEmailSender sender = new NoOpEmailSender(Configured(enabled: true)); - - var failure = await Assert.ThrowsAsync( - () => sender.SendEmail("x@example.test", "s", "

b

").FirstAsync().Await()); - - // The message must name what to provision. "Email is broken" sends an operator hunting; - // the module name is the whole diagnosis. - Assert.Contains(EmailDeliveryGuard.SenderModule, failure.Message, StringComparison.Ordinal); - Assert.Contains("Email:Enabled=true", failure.Message, StringComparison.Ordinal); - } - - /// - /// 🚨 End to end through the code that writes the status: the real - /// on an Enabled=true install, driven by the real - /// . The mail must end Failed — visible and - /// re-queueable — and Sent must never appear in the status history at all. Asserting - /// the FINAL status alone would be weaker: the defect wrote Sent, and a later correction to - /// Failed would still have left a window in which the node claimed delivery. - /// - [Fact] - public async Task Enabled_ButTheSenderCannotDeliver_QueuedMailEndsFailed_AndIsNeverStampedSent() - { - var sender = new NoOpEmailSender(Configured(enabled: true)); - var store = new ConcurrentDictionary(); - var statusHistory = new List(); - - using var queue = new OutboundSendQueue( - readCurrent: path => Observable.Defer(() => Observable.Return( - store.TryGetValue(path, out var mail) ? mail : null)), - writeStatus: (node, current, status) => Observable.Defer(() => - { - lock (statusHistory) statusHistory.Add(status); - store[node.Path] = current with { Status = status }; - return Observable.Return(node); - }), - send: mail => sender.SendEmail(mail.To!, mail.Subject, mail.Body)); - - var email = new MeshWeaver.Mesh.Email - { - Direction = EmailDirection.Outbound, - Status = EmailStatus.New, - To = "recipient@example.test", - Subject = "s", - Body = "b", - }; - var node = new MeshNode("m1", "T") { NodeType = "Email", Name = "m1", Content = email }; - store[node.Path] = email; - - var processed = queue.Processed.Take(1).Timeout(TestBudget).FirstAsync().Await(); - queue.Enqueue(node, email); - await processed; - - Assert.Equal(EmailStatus.Failed, store[node.Path].Status); - lock (statusHistory) - Assert.DoesNotContain(EmailStatus.Sent, statusHistory); - } - - /// - /// 🚨 The watcher must not even ARM on this configuration. This is the half that keeps the - /// mail RECOVERABLE: a watcher that starts and declines per-item has already claimed - /// (New → Sending) whatever it picked up, whereas one that never starts leaves every - /// queued mail at New, so delivery resumes on its own once the module lands — no data - /// repair, no re-queueing by hand. - /// - /// Observed through the provider, because "did it arm" has no other outward sign: - /// BeginWatching's first act is rootServices.CreateScope(), which asks the - /// provider for an . Zero requests after - /// ApplicationStarted has fired means the watch was never established. Its own body is - /// wrapped in a try/catch, so a "did it throw" assertion would prove nothing. - /// - [Fact] - public async Task Enabled_ButTheSenderCannotDeliver_TheOutboundWatcherDoesNotStart() - { - var options = Configured(enabled: true); - var services = new ServiceCollection() - .AddSingleton(new NoOpEmailSender(options)) - .BuildServiceProvider(); - var recording = new RecordingProvider(services); - using var lifetime = new StartableLifetime(); - - var watcher = new OutboundEmailSender(recording, lifetime, options); - await watcher.StartAsync(CancellationToken.None); - lifetime.NotifyStarted(); - - Assert.Equal(0, recording.ScopeFactoryRequests); - watcher.Dispose(); - } - - /// - /// The control for the case above: a sender that DOES deliver must still arm the watcher. - /// Without this the previous test is satisfied by a watcher that never starts at all, which is - /// the failure mode that would silently stop all outbound mail on a correctly configured - /// install. - /// - [Fact] - public async Task Enabled_WithADeliveringSender_TheOutboundWatcherStarts() - { - var options = Configured(enabled: true); - var services = new ServiceCollection() - .AddSingleton(new DeliveringSender()) - .BuildServiceProvider(); - var recording = new RecordingProvider(services); - using var lifetime = new StartableLifetime(); - - var watcher = new OutboundEmailSender(recording, lifetime, options); - await watcher.StartAsync(CancellationToken.None); - lifetime.NotifyStarted(); - - Assert.True(recording.ScopeFactoryRequests > 0, - "a delivering sender must leave the outbound watch armed — otherwise the refusal above " - + "would be satisfied by a watcher that never runs for anyone."); - watcher.Dispose(); - } - - /// The guard's verdict table, stated once so every caller reads the same rule. - [Theory] - [InlineData(false, false, false)] // disabled + no-op: the intended configuration - [InlineData(false, true, false)] // disabled + a real sender: nothing to refuse - [InlineData(true, true, false)] // enabled + a real sender: the working install - [InlineData(true, false, true)] // 🚨 enabled + no-op: the refused configuration - public void TheGuard_RefusesExactlyTheEnabledButUndeliverableCombination( - bool enabled, bool delivers, bool refused) - { - IEmailSender sender = delivers - ? new DeliveringSender() - : new NoOpEmailSender(Configured(enabled)); - - Assert.Equal(refused, EmailDeliveryGuard.RefusesDelivery(Configured(enabled), sender)); - } - - /// - /// A sender resolving to nothing at all certainly cannot deliver. Pinned because the tempting - /// reading — "could not resolve, assume it is fine" — is the skip-trapdoor the guard exists to - /// remove. - /// - [Fact] - public void TheGuard_TreatsAnAbsentSenderAsUndeliverable() - => Assert.True(EmailDeliveryGuard.RefusesDelivery(Configured(enabled: true), sender: null)); - - /// A stand-in for the module's Graph sender: it claims delivery, nothing more. - private sealed class DeliveringSender : IEmailSender - { - public IObservable SendEmail(string toAddress, string subject, string htmlBody) - => Observable.Return(true); - - public IObservable SendEmail( - string toAddress, string subject, string htmlBody, - IReadOnlyCollection attachments) - => Observable.Return(true); - } - - /// Counts scope-factory requests — the outward sign that the watch was established. - private sealed class RecordingProvider(IServiceProvider inner) : IServiceProvider - { - private int scopeFactoryRequests; - - public int ScopeFactoryRequests => Volatile.Read(ref scopeFactoryRequests); - - public object? GetService(Type serviceType) - { - if (serviceType == typeof(IServiceScopeFactory)) - Interlocked.Increment(ref scopeFactoryRequests); - return inner.GetService(serviceType); - } - } - - /// - /// A lifetime whose can be fired on - /// demand — the real token the watcher registers its BeginWatching on. - /// - private sealed class StartableLifetime : IHostApplicationLifetime, IDisposable - { - private readonly CancellationTokenSource started = new(); - - public CancellationToken ApplicationStarted => started.Token; - public CancellationToken ApplicationStopping => CancellationToken.None; - public CancellationToken ApplicationStopped => CancellationToken.None; - public void StopApplication() { } - public void NotifyStarted() => started.Cancel(); - public void Dispose() => started.Dispose(); - } -} diff --git a/test/Memex.Portal.Shared.Test/NodeFaviconBackplateTest.cs b/test/Memex.Portal.Shared.Test/NodeFaviconBackplateTest.cs deleted file mode 100644 index 8dbec34b07..0000000000 --- a/test/Memex.Portal.Shared.Test/NodeFaviconBackplateTest.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.Linq; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Graph; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 THE NODE FAVICON GOES THROUGH THE BACKPLATE POLICY — and here it is not a matter of taste: -/// it is the difference between an icon you can see and a black hairline on nothing, served -/// behind a 200 so that nothing reports it (#4350). -/// -/// The chain. produces the markup; two -/// consumers render it on a ground this process does not control — the -/// data:image/svg+xml href in the page head, and -/// behind /api/icon/{node}.png, which Safari needs -/// because it reads no SVG favicon at all. -/// -/// What went wrong. That producer handed the AUTHORED markup on unchanged. An icon in -/// the ordinary house form — a currentColor outline with no plate — has no color of its own, -/// and in a data: URI or a rasterizer there is no surrounding text to inherit one from, so -/// it paints in the initial color: black. The rasterizer, in turn, leaves its ground TRANSPARENT -/// precisely BECAUSE its doc comment was told every mark paints its own plate. A black hairline on -/// a transparent ground is the result — invisible in a dark tab strip, invisible on a dark -/// link-preview card, and served behind a 200 so nothing reports it. The plate at the producer is -/// what makes the rasterizer's premise true. -/// -public class NodeFaviconBackplateTest -{ - /// The shape node icons are normally authored in — and the shape that vanished. - private const string MonochromeOutline = - ""; - - private static MeshNode Node(string? icon) => - new("Ifrs17", "Store") { NodeType = "Store/Plugin", Icon = icon }; - - [Fact] - public void APlatelessOutline_IsPlatedBeforeItLeavesTheResolver() - { - var svg = SeoResolver.ResolveIconSvg(Node(MonochromeOutline)); - - Assert.NotNull(svg); - Assert.Contains("The head's data: URI carries exactly what the rasterizer rasterizes — one - /// resolution, two consumers, so the tab icon and the PNG can never be different pictures. - [Fact] - public void TheDataUriInTheHead_CarriesThatSamePlatedMarkup() - { - var icon = SeoResolver.ResolveIcon(Node(MonochromeOutline)); - - Assert.NotNull(icon); - Assert.StartsWith("data:image/svg+xml,", icon.Href); - Assert.Equal( - SeoResolver.ResolveIconSvg(Node(MonochromeOutline)), - Uri.UnescapeDataString(icon.Href["data:image/svg+xml,".Length..])); - } - - /// - /// 🚨 THE USER-VISIBLE HALF, read off the pixels rather than off the markup: the icon now - /// rasterizes to an OPAQUE, coloured tile — something a tab strip of either shade can show — - /// instead of the black hairline the next test pins. The rasterizer is two steps downstream of - /// the resolver, so asserting on the resolver's string alone would not have measured it. - /// - [Fact] - public void ThatPlatedMarkup_RasterizesToAVisibleIcon() - { - var png = IconRasterizer.Render(SeoResolver.ResolveIconSvg(Node(MonochromeOutline))!, 64); - - Assert.NotNull(png); - using var bitmap = SkiaSharp.SKBitmap.Decode(png); - var corner = bitmap.GetPixel(6, 6); // inside the plate, outside the inset glyph - Assert.Equal(255, corner.Alpha); - Assert.True(corner.Red + corner.Green + corner.Blue > 60, - $"the plate is a mid-tone hue a dark tab strip can show, not near-black; found {corner}"); - } - - /// - /// 🚨 The hazard itself, pinned so the reason for the plate cannot be optimised away — and - /// pinned on what actually happens rather than on what is easy to assume. The unplated icon - /// does NOT fail to render: it renders, and that is the problem. currentColor in a - /// standalone document has no surrounding text to inherit from, so it resolves to the initial - /// color — BLACK — and the rasterizer's deliberately transparent ground leaves nothing else. - /// A black hairline on nothing is invisible in a dark tab strip and in any dark link-preview - /// card, and it comes back behind a 200, so nothing anywhere reports a problem. - /// - [Fact] - public void TheSameIconUnplated_RasterizesToABlackHairlineOnNothing() - { - var png = IconRasterizer.Render(MonochromeOutline, 64); - - Assert.NotNull(png); - using var bitmap = SkiaSharp.SKBitmap.Decode(png); - var painted = Enumerable - .Range(0, bitmap.Width) - .SelectMany(x => Enumerable.Range(0, bitmap.Height).Select(y => bitmap.GetPixel(x, y))) - .Where(pixel => pixel.Alpha > 0) - .ToImmutableArray(); - - Assert.NotEmpty(painted); - Assert.All(painted, p => Assert.True(p.Red < 40 && p.Green < 40 && p.Blue < 40, - $"unplated, every painted pixel is near-black; found {p}")); - Assert.Equal(0, bitmap.GetPixel(32, 32).Alpha); // the ground a plate would paint is absent - } - - /// An icon that paints its own full-bleed plate reaches the head byte-identical — the - /// promise the resolver's doc comment makes, and the reason this change is safe for every mark - /// already in the store. - [Fact] - public void AnIconWithItsOwnPlate_IsUnchanged() - { - const string authored = - ""; - - Assert.Equal(authored, SeoResolver.ResolveIconSvg(Node(authored))); - } -} diff --git a/test/Memex.Portal.Shared.Test/NodeIconEndpointTest.cs b/test/Memex.Portal.Shared.Test/NodeIconEndpointTest.cs deleted file mode 100644 index ec5b1553b4..0000000000 --- a/test/Memex.Portal.Shared.Test/NodeIconEndpointTest.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using System.Linq; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Mesh; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// What /api/icon/{node}.png actually answers. These drive -/// — the endpoint's OWN decision, reached from the route — -/// rather than a re-implementation of it beside the route, which could agree with itself while the -/// shipped answer is wrong. -/// -/// The permission decision is not re-tested here on purpose: the route reaches this only -/// through , the same fail-closed AnonymousGate pass the -/// page head and the share card go through, so there is no second permission rule to drift. -/// -public class NodeIconEndpointTest -{ - private const string AuthoredMark = - "" - + "" - + ""; - - private static MeshNode Node(string? icon, string path = "Chess") => - new(path) { NodeType = "Store/Plugin", Name = "Chess", Icon = icon }; - - private static (int Width, int Height) PngSize(byte[] png) - { - Assert.Equal(new byte[] { 0x89, 0x50, 0x4E, 0x47 }, png.Take(4).ToArray()); - int Be(int at) => (png[at] << 24) | (png[at + 1] << 16) | (png[at + 2] << 8) | png[at + 3]; - return (Be(16), Be(20)); - } - - /// The node's authored mark, served as a real PNG of the requested size. - [Theory] - [InlineData(32)] - [InlineData(180)] - public void ANodeWithAnAuthoredMark_IsServedAsAPngOfThatSize(int size) - { - var http = new DefaultHttpContext(); - - var result = SeoEndpoints.IconResult(http, Node(AuthoredMark), size); - - var file = Assert.IsType(result); - Assert.Equal("image/png", file.ContentType); - var (width, height) = PngSize(file.FileContents.ToArray()); - Assert.Equal(size, width); - Assert.Equal(size, height); - } - - /// - /// 🚨 THE FALLBACK, STATED: a node that carries no mark of its own gets a 404, and nothing ever - /// requests it — emits no icon link for such a node, - /// so the portal favicon simply stays. Redirecting to the site favicon here would look like a - /// fix while telling every consumer that this node's mark IS the portal's, and synthesising a - /// letter tile would put a picture in the head that the node never chose. - /// - [Fact] - public void ANodeWithNoMarkOfItsOwn_Is404_AndItsHeadAdvertisesNothingToAskFor() - { - var http = new DefaultHttpContext(); - - Assert.IsType(SeoEndpoints.IconResult(http, Node(null), 32)); - Assert.IsType(SeoEndpoints.IconResult(http, Node(" "), 32)); - // An emoji and a legacy Fluent NAME are characters, not pictures — same answer. - Assert.IsType(SeoEndpoints.IconResult(http, Node("📊"), 32)); - Assert.IsType(SeoEndpoints.IconResult(http, Node("Document"), 32)); - - Assert.Empty(SeoResolver.ResolveIconLinks(Node(null))); - Assert.Empty(SeoResolver.ResolveIconLinks(Node("📊"))); - } - - /// - /// A mark that is already a RASTER image needs no help — Safari reads those — so this route - /// does not duplicate it, and the head declares that one URL exactly as it always did. - /// - [Fact] - public void AMarkThatIsAlreadyRaster_IsNotRedrawnHere() - { - var node = Node("https://cdn.example.org/mark.png"); - - Assert.IsType(SeoEndpoints.IconResult(new DefaultHttpContext(), node, 32)); - var link = Assert.Single(SeoResolver.ResolveIconLinks(node)); - Assert.Equal("https://cdn.example.org/mark.png", link.Href); - } - - /// - /// Inline svg that will not parse is a CONTENT defect. The route answers 404 like any other - /// "no icon", but the fault is reported — otherwise a broken authored mark and a node with no - /// mark are indistinguishable from outside, and the broken one is the only one anyone can fix. - /// - [Fact] - public void MalformedAuthoredMarkup_Is404_ButIsReported() - { - Exception? reported = null; - - var result = SeoEndpoints.IconResult( - new DefaultHttpContext(), Node(""), 32, ex => reported = ex); - - Assert.IsType(result); - Assert.NotNull(reported); - } - - /// - /// The strong ETag and the shared cache directive: crawlers and browsers refetch favicons - /// aggressively, and the tag is the render's own hash, so a node that changes its mark produces - /// a new icon rather than a stale one. - /// - [Fact] - public void TheResponse_CarriesAStrongEtagAndIsSharedCacheable() - { - var http = new DefaultHttpContext(); - - SeoEndpoints.IconResult(http, Node(AuthoredMark), 32); - - var etag = http.Response.Headers.ETag.ToString(); - Assert.StartsWith("\"", etag); - Assert.Equal("public, max-age=86400", http.Response.Headers.CacheControl.ToString()); - } - - /// A conditional GET whose tag still matches is answered 304, not re-sent. - [Fact] - public void AMatchingIfNoneMatch_Is304() - { - var first = new DefaultHttpContext(); - SeoEndpoints.IconResult(first, Node(AuthoredMark), 32); - var etag = first.Response.Headers.ETag.ToString(); - - var second = new DefaultHttpContext(); - second.Request.Headers.IfNoneMatch = etag; - - var result = SeoEndpoints.IconResult(second, Node(AuthoredMark), 32); - - Assert.Equal(StatusCodes.Status304NotModified, Assert.IsType(result).StatusCode); - } -} diff --git a/test/Memex.Portal.Shared.Test/NonAdminUpdateStatusTest.cs b/test/Memex.Portal.Shared.Test/NonAdminUpdateStatusTest.cs deleted file mode 100644 index b108644ea3..0000000000 --- a/test/Memex.Portal.Shared.Test/NonAdminUpdateStatusTest.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// The access question behind the About tab's "is this build current?" line, answered against a REAL -/// mesh rather than assumed. -/// -/// The finding: an ordinary user CANNOT read Admin/UpdatePolicy — the per-user read -/// gate in MeshNodeStreamCache evaluates the caller's effective permissions on the path and -/// throws when Read is absent. So the running-vs-latest -/// comparison could not simply be moved onto the ungated page: it had to become a PROJECTION -/// () that reads the node as System and lets only the verdict -/// out — never the policy, the CI-green flag or the poller's bookkeeping. -/// -/// 🚨 This class deliberately builds on ConfigureMeshBase, NOT base.ConfigureMesh. -/// The latter chains TestUsers.PublicAdminAccess(), which grants Public the Admin role in the -/// Admin namespace — under it every identity can read Admin/* and every assertion here -/// would pass vacuously. -/// -public class NonAdminUpdateStatusTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// A tag far above any real platform version, so "newer" is unambiguous — far above in - /// BOTH keys. 🚨 The run number was ci.1 until #3542: a 9999.0.0 LABEL over a build - /// published BEFORE the running one, which is the version slip that rolled memex-cloud three days - /// backwards, written into a fixture. "Newer" is now the publishing run number, and a real tag - /// can never carry a lower one than a build that already exists. - private const string NewerTag = "9999.0.0-ci.99999"; - - /// The build the comparison runs against — explicit, because under a test host the entry - /// assembly's informational version is the runner's, not a platform version. - private const string RunningVersion = "3.0.0-ci.2340"; - - private static readonly TimeSpan ReadBudget = TimeSpan.FromSeconds(30); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - // No PublicAdminAccess — see the class remarks. - => ConfigureMeshBase(builder).AddUpdatePolicyType(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - /// - /// Seeds Admin/UpdatePolicy with a newer tag recorded, as System — the same identity the - /// poller writes it under (SelfUpdateHostedService.RecordAvailable). - /// - /// 🚨 The System scope is opened and closed SYNCHRONOUSLY around the subscribe, the same - /// shape uses and for the same reason: impersonation is - /// an AsyncLocal, and Observable.Using would dispose it on whichever thread the - /// create terminates on — never returning the calling thread's Context to what it was. - /// A seed that leaks system-security onto the test thread makes every later assertion run - /// as System and pass while proving nothing, which is exactly what the first draft of this class - /// did. (MonolithMeshTestBase.SeedTopLevel still has the leaky shape — worth a look.) - /// - private Task SeedPolicy(UpdatePolicyKind policy = UpdatePolicyKind.Continuous, - string? latestAvailableTag = NewerTag, CancellationToken cancellationToken = default) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = policy, LatestAvailableTag = latestAvailableTag }, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(ReadBudget) - .Await(cancellationToken); - } - - /// - /// Switches to a user with NO grants anywhere — the ordinary-viewer shape. - /// - /// 🚨 BOTH contexts, deliberately. Readers resolve identity as - /// Context ?? CircuitContext, so the request-scoped Context outranks the circuit — - /// a circuit-only switch can be shadowed by whatever last wrote Context and leave the test - /// asserting against the wrong user. Setting both makes the identity unambiguous no matter what - /// ran before; the guard in each test proves it took. - /// - private void BecomeOrdinaryUser() => Become(new AccessContext - { - ObjectId = "ordinary-viewer", - Name = "Ordinary Viewer", - }); - - /// Back to the harness admin (root grant) — same both-contexts reasoning as above. - private void BecomeAdmin() => Become(TestUsers.Admin); - - private void Become(AccessContext user) - { - Access.SetContext(user); - Access.SetHostIdentity(user); - } - - /// The identity the mesh actually sees, so no test here can assert against the wrong user. - private Task EffectivePermissions(string userId) => - Mesh.GetEffectivePermissions(UpdatePolicyNodeType.NodePath, userId) - .FirstAsync() - .Timeout(ReadBudget) - .Await(TestContext.Current.CancellationToken); - - /// Reads the policy node the way any consumer would, capturing whichever way it fails. - private async Task<(MeshNode? Node, Exception? Error)> TryReadPolicyNode() - { - try - { - var node = await Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(n => n is not null) - .FirstAsync() - .Timeout(ReadBudget) - .Await(TestContext.Current.CancellationToken); - return (node, null); - } - catch (Exception ex) - { - return (null, ex); - } - } - - /// - /// Positive control: the node really is there and really does carry the tag — so the denial in the - /// next test is a denial, not an empty mesh. - /// - [Fact(Timeout = 60000)] - public async Task An_admin_can_read_the_policy_node() - { - await SeedPolicy(cancellationToken: TestContext.Current.CancellationToken); - BecomeAdmin(); - - (await EffectivePermissions(TestUsers.Admin.ObjectId!)).Should().HaveFlag(Permission.Read); - - var (node, error) = await TryReadPolicyNode(); - - error.Should().BeNull(); - UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions) - .LatestAvailableTag.Should().Be(NewerTag); - } - - /// - /// The finding this feature had to be designed around: the Admin partition is platform-scoped, so - /// an ordinary user's read of the policy node is REFUSED. Two assertions — the security property - /// (the tag never reaches them) and the shape of the refusal (it errors rather than hanging, which - /// is what lets degrade instead of stalling the page). - /// - [Fact(Timeout = 60000)] - public async Task An_ordinary_user_cannot_read_the_policy_node() - { - await SeedPolicy(cancellationToken: TestContext.Current.CancellationToken); - BecomeOrdinaryUser(); - - // Non-vacuity guard: prove the identity in force really holds nothing on this path. Without - // it, a leaked System scope would make every assertion below pass while testing nobody. - (await EffectivePermissions("ordinary-viewer")).Should().Be(Permission.None); - - var (node, error) = await TryReadPolicyNode(); - - UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions) - .LatestAvailableTag.Should().NotBe(NewerTag, - "the Admin partition holds platform data an ordinary user has no grant on"); - error.Should().BeOfType( - "the read gate refuses rather than emitting an empty node — a hang would stall the About page"); - } - - /// - /// …and yet that same ordinary user gets the answer, because the About tab consumes a PROJECTION - /// read as System. The tag is what they see; nothing else on the node is. - /// - [Fact(Timeout = 60000)] - public async Task An_ordinary_user_sees_the_derived_update_status() - { - await SeedPolicy(cancellationToken: TestContext.Current.CancellationToken); - BecomeOrdinaryUser(); - - (await EffectivePermissions("ordinary-viewer")).Should().Be(Permission.None); - - var status = await PlatformUpdateStatus.Observe(Mesh, RunningVersion) - .FirstAsync() - .Timeout(ReadBudget) - .Await(TestContext.Current.CancellationToken); - - status.Availability.Should().Be(PlatformUpdateAvailability.UpdateAvailable); - status.LatestVersion.Should().Be(NewerTag); - } - - /// - /// An install that never wired self-update has no policy node at all. That must resolve to - /// — promptly, via the empty-on-absent existence - /// query, never a point-read of the missing path (which NotFound-resubscribe-storms) and never a - /// claim of currency nobody checked. - /// - [Fact(Timeout = 60000)] - public async Task With_no_policy_node_the_status_is_unknown() - { - BecomeOrdinaryUser(); - - var status = await PlatformUpdateStatus.Observe(Mesh, RunningVersion) - .FirstAsync() - .Timeout(ReadBudget) - .Await(TestContext.Current.CancellationToken); - - status.Should().Be(PlatformUpdateStatus.Unknown); - } -} diff --git a/test/Memex.Portal.Shared.Test/NonexistentHostIsNotRetriedTest.cs b/test/Memex.Portal.Shared.Test/NonexistentHostIsNotRetriedTest.cs deleted file mode 100644 index 562d44910d..0000000000 --- a/test/Memex.Portal.Shared.Test/NonexistentHostIsNotRetriedTest.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Net.Http; -using System.Net.Sockets; -// The namespace and the class share a name, so the class needs an alias to be nameable. -using Defaults = Memex.Portal.ServiceDefaults.ServiceDefaults; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A HOSTNAME THAT DOES NOT EXIST IS NOT A TRANSIENT FAULT — Systemorph/MeshWeaver#4613. -/// -/// The standard resilience handler's predicate treats every -/// as retryable, so a request to a name that resolves nowhere was attempted three times — and no -/// retry can make a name exist. Measured on the control instance 2026-09-17: two agent web fetches -/// of www.boss-software.ch and www.bosssw.ch spent three attempts each and logged -/// every one at Error under category Polly. Those Error lines are what the fleet's log -/// watcher folds into a LogIncident and a ticket, so a URL in somebody's data manufactured a -/// platform defect report. All four spellings — both www. hosts and both apex domains — -/// answer NXDOMAIN from the public internet, so the DNS half is a fact about the data and not about -/// cluster DNS, egress or a missing route. -/// -/// The predicate is pure, and it is asserted here rather than through a socket: a predicate -/// that can only be exercised by a real failed connection is a predicate nobody checks. -/// -public class NonexistentHostIsNotRetriedTest -{ - /// The shape the failure actually arrives in: HttpClient wraps the resolver's error. - private static Exception Wrapped(SocketError error) => - new HttpRequestException( - $"Name or service not known (www.example.invalid:443)", - new SocketException((int)error)); - - [Fact] - public void ANameThatDoesNotExistIsRecognised_ThroughTheWrapperHttpClientAdds() - { - Assert.True(Defaults.NameDoesNotResolve(Wrapped(SocketError.HostNotFound)), - "an NXDOMAIN reaches the predicate wrapped in an HttpRequestException — unwrapping it " - + "is the whole job, and a predicate that only matched the bare SocketException would " - + "match nothing that ever happens"); - - // …and through a second wrap, which a handler pipeline adds. - Assert.True( - Defaults.NameDoesNotResolve( - new InvalidOperationException("pipeline", Wrapped(SocketError.HostNotFound))), - "a handler pipeline may wrap the request exception again; the walk must reach the " - + "socket error however deep it is"); - } - - /// - /// 🚨 THE CONTROL, and the reason the set is exactly one error code. A nameserver that failed to - /// answer (TryAgain / EAI_AGAIN) IS transient and must keep being retried — excluding it - /// would turn a DNS hiccup into a hard failure, which is the opposite defect. - /// - [Fact] - public void EveryOtherTransportFailureIsStillRetried() - { - Assert.False(Defaults.NameDoesNotResolve(Wrapped(SocketError.TryAgain)), - "EAI_AGAIN is a nameserver that did not answer — genuinely transient"); - Assert.False(Defaults.NameDoesNotResolve(Wrapped(SocketError.ConnectionReset))); - Assert.False(Defaults.NameDoesNotResolve(Wrapped(SocketError.TimedOut))); - Assert.False(Defaults.NameDoesNotResolve(Wrapped(SocketError.ConnectionRefused)), - "a host that exists and refuses the connection is about the SERVICE, not the name"); - Assert.False(Defaults.NameDoesNotResolve( - new HttpRequestException("TLS handshake failed")), - "a request failure carrying no socket error at all says nothing about the name"); - Assert.False(Defaults.NameDoesNotResolve(null), - "a successful outcome carries no exception, and must not be read as a dead name"); - } -} diff --git a/test/Memex.Portal.Shared.Test/OAuthAbandonedExchangeMintsNothingTest.cs b/test/Memex.Portal.Shared.Test/OAuthAbandonedExchangeMintsNothingTest.cs deleted file mode 100644 index fbc21b8cc8..0000000000 --- a/test/Memex.Portal.Shared.Test/OAuthAbandonedExchangeMintsNothingTest.cs +++ /dev/null @@ -1,316 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A /token exchange the client ABANDONED leaves no live credential behind -/// (MeshWeaver.Feedback#13). -/// -/// Why. ExchangeToken bridges its chain with .ObserveCompletion(…, ct), -/// which cancels the WAIT and deliberately not the source — so when the client gives up (the -/// request's abort token fires), the response is gone but the exchange keeps running: it minted a -/// year-long mw_ token that no client ever received. The one-live-credential supersede -/// removes such a row only on the NEXT successful exchange for the same client_id, which for -/// a client whose id changes per registration never comes. Measured on memex as a late fault -/// ("faulted after the response had already been sent"), raised by the collaborators being -/// resolved lazily from the request's disposed scope. -/// -/// Two windows, one test each: -/// -/// abandoned BEFORE the mint — nothing is minted at all; -/// abandoned WHILE the mint is in flight — the minted token is revoked, and the client's -/// previous credential is NOT superseded (it is the one the client still holds). -/// -/// -/// SHOULD-FAIL-IF the exchange mints (or keeps) a token for a client that is gone: -/// against the unfixed controller both tests observe the Issued OAuth access token line. -/// -public class OAuthAbandonedExchangeMintsNothingTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string RedirectUri = "https://claude.ai/cb"; - private const string UserEmail = "erin@example.com"; - private const string UserId = "erin"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddOAuthCodeType(); - - private CapturingLogger Log { get; } = new(); - - private IStorageAdapter Storage => Mesh.ServiceProvider.GetRequiredService(); - - private ApiTokenService TokenService(ILogger? logger = null) => new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Storage, - logger ?? Mesh.ServiceProvider.GetRequiredService>()); - - private OAuthConnectController Controller(ApiTokenService tokens, ILogger? log = null) - { - var services = new ServiceCollection(); - services.AddSingleton(new OAuthCodeStore( - Storage, Mesh, Mesh.ServiceProvider.GetRequiredService>())); - services.AddSingleton(tokens); - // Pinned at ONE live credential per client: this test is about the supersede's mechanics, - // which the bound only changes in how many older rows it keeps (see - // OAuthBoundedLiveCredentialsTest for the bound itself). - services.AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection([new(OAuthCredentialBound.ConfigKey, "1")]) - .Build()); - var provider = services.BuildServiceProvider(); - - var identity = new ClaimsIdentity( - [ - new Claim(ClaimTypes.Email, UserEmail), - new Claim(ClaimTypes.Name, "Erin"), - new Claim("preferred_username", UserEmail), - ], - authenticationType: "Test"); - - return new OAuthConnectController(provider, log ?? Log) - { - ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext - { - User = new ClaimsPrincipal(identity), - Request = { Scheme = "https", Host = new HostString("memex.test") }, - }, - }, - }; - } - - private static string TokenPath(string rawToken) => - $"{UserId}/ApiToken/{ApiTokenService.HashToken(rawToken)[..12]}"; - - private static (string Verifier, string Challenge) Pkce() - { - var verifier = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - var challenge = Convert.ToBase64String(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - return (verifier, challenge); - } - - private static async Task<(string Code, string Verifier)> Authorize(OAuthConnectController controller, string clientId) - { - var (verifier, challenge) = Pkce(); - var redirect = await controller.Authorize( - response_type: "code", - client_id: clientId, - redirect_uri: RedirectUri, - state: "s", - scope: "mcp", - code_challenge: challenge, - code_challenge_method: "S256", - ct: TestContext.Current.CancellationToken) - .ToObservable() - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - var url = ((RedirectResult)redirect).Url; - var code = Uri.UnescapeDataString(url[(RedirectUri + "?code=").Length..url.IndexOf("&state=", StringComparison.Ordinal)]); - return (code, verifier); - } - - private static TokenRequest Request(string clientId, string code, string verifier) => new() - { - grant_type = "authorization_code", - code = code, - client_id = clientId, - redirect_uri = RedirectUri, - code_verifier = verifier, - }; - - /// Waits for the exchange's own terminal line — issued, or abandoned — whichever it wrote. - private Task WaitForTheExchangeToSettle(string clientId) => - Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .Select(_ => Log.Lines(LogLevel.Information)) - .Should().Within(TestTimeouts.Convergence) - .Match(lines => lines.Any(l => l.Contains(clientId) - && (l.Contains("Issued OAuth access token") || l.Contains("abandoned"))), - cancellationToken: TestContext.Current.CancellationToken); - - [Fact] - public async Task AnExchangeAbandonedBeforeTheMint_MintsNothing() - { - var mints = new CapturingLogger(); - var controller = Controller(TokenService(mints)); - var clientId = "gone-early-" + Guid.NewGuid().ToString("N")[..8]; - var (code, verifier) = await Authorize(controller, clientId); - - // The client has already given up when the exchange starts. - using var aborted = new CancellationTokenSource(); - aborted.Cancel(); - _ = controller.ExchangeToken(Request(clientId, code, verifier), aborted.Token); - - await WaitForTheExchangeToSettle(clientId); - - Log.Lines(LogLevel.Information).Should().NotContain( - l => l.Contains("Issued OAuth access token") && l.Contains(clientId), - "a client that has gone receives nothing — minting for it leaves a live credential nobody holds"); - Log.Lines(LogLevel.Information).Should().Contain( - l => l.Contains(clientId) && l.Contains("abandoned the exchange before a token was minted")); - // The invariant itself, read from the TOKEN SERVICE rather than the controller's own words: - // it logs "Creating API token {Label} …" for every row it writes, and wrote none. - mints.Lines(LogLevel.Information).Should().NotContain( - l => l.Contains("Creating API token") && l.Contains(clientId), - "no token row may be written for a client that has already gone"); - } - - [Fact] - public async Task AnExchangeAbandonedDuringTheMint_RevokesTheTokenAndKeepsThePreviousCredential() - { - var clientId = "gone-midway-" + Guid.NewGuid().ToString("N")[..8]; - var label = $"OAuth: {clientId}"; - - // First: an ordinary, completed authorization — the credential the client holds. - var held = Controller(TokenService()); - var (code1, verifier1) = await Authorize(held, clientId); - var first = await held.ExchangeToken(Request(clientId, code1, verifier1), TestContext.Current.CancellationToken) - .ToObservable() - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - var firstBody = ((OkObjectResult)first).Value!; - var firstToken = (string)firstBody.GetType().GetProperty("access_token")!.GetValue(firstBody)!; - - // Wait until the listing (which the supersede reads) carries the held token, so a supersede - // WOULD find it — otherwise "not superseded" would pass by the listing's lag alone. - var listing = TokenService(); - await Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .SelectMany(_ => listing.GetTokensForUser(UserId).Take(1)) - .Should().Within(TestTimeouts.Convergence) - .Match(all => all.Any(t => t.Label == label && t.NodePath == TokenPath(firstToken)), - cancellationToken: TestContext.Current.CancellationToken); - - // Second: the client gives up WHILE the token is being minted — the abort fires the moment - // the token service reports it is creating the token. - using var aborted = new CancellationTokenSource(); - var midway = Controller(TokenService(new AbortOnMint(aborted, - Mesh.ServiceProvider.GetRequiredService>()))); - var (code2, verifier2) = await Authorize(midway, clientId); - _ = midway.ExchangeToken(Request(clientId, code2, verifier2), aborted.Token); - - // The held exchange already wrote its "Issued" line, so wait on THIS exchange's own terminal - // line — the undelivered-token report — rather than on "any terminal line". - await Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .Select(_ => Log.Lines(LogLevel.Information)) - .Should().Within(TestTimeouts.Convergence) - .Match(lines => lines.Any(l => l.Contains(clientId) && l.Contains("undelivered token")), - cancellationToken: TestContext.Current.CancellationToken); - - Log.Lines(LogLevel.Information).Count(l => l.Contains("Issued OAuth access token") && l.Contains(clientId)) - .Should().Be(1, "only the completed exchange issued a token"); - Log.Lines(LogLevel.Information).Should().Contain( - l => l.Contains(clientId) && l.Contains("revoked the undelivered token")); - Log.Lines(LogLevel.Information).Should().NotContain( - l => l.Contains("superseding") && l.Contains(label), - "the abandoned exchange must not retire the credential the client still holds"); - await AssertStored(TokenPath(firstToken), present: true, "the credential the client holds is still live"); - await AssertStored(RevokedPath(clientId), present: false, "the undelivered token's row is gone"); - } - - [Fact] - public async Task AnExchangeAbandonedDuringTheSupersede_DeletesNothingTheClientHolds() - { - var clientId = "gone-at-supersede-" + Guid.NewGuid().ToString("N")[..8]; - var label = $"OAuth: {clientId}"; - - var held = Controller(TokenService()); - var (code1, verifier1) = await Authorize(held, clientId); - var first = await held.ExchangeToken(Request(clientId, code1, verifier1), TestContext.Current.CancellationToken) - .ToObservable() - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - var firstBody = ((OkObjectResult)first).Value!; - var firstToken = (string)firstBody.GetType().GetProperty("access_token")!.GetValue(firstBody)!; - - var listing = TokenService(); - await Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .SelectMany(_ => listing.GetTokensForUser(UserId).Take(1)) - .Should().Within(TestTimeouts.Convergence) - .Match(all => all.Any(t => t.Label == label && t.NodePath == TokenPath(firstToken)), - cancellationToken: TestContext.Current.CancellationToken); - - // The client leaves AFTER the mint and the listing, at the moment the supersede has chosen - // its candidates — i.e. after the check that precedes the supersede, before any delete. - using var aborted = new CancellationTokenSource(); - var midway = Controller(TokenService(), new AbortOn("OAuth: superseding", aborted, Log)); - var (code2, verifier2) = await Authorize(midway, clientId); - _ = midway.ExchangeToken(Request(clientId, code2, verifier2), aborted.Token); - - await Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .Select(_ => Log.Lines(LogLevel.Information)) - .Should().Within(TestTimeouts.Convergence) - .Match(lines => lines.Any(l => l.Contains(clientId) && l.Contains("undelivered token")), - cancellationToken: TestContext.Current.CancellationToken); - - Log.Lines(LogLevel.Information).Should().Contain( - l => l.Contains("superseded 0 of 1") && l.Contains(label), - "the candidate was chosen, and then NOT deleted, because the client had left"); - await AssertStored(TokenPath(firstToken), present: true, "the credential the client holds is still live"); - await AssertStored(RevokedPath(clientId), present: false, "the undelivered token's row is gone"); - } - - /// The path the revoke line names for this client. - private string RevokedPath(string clientId) - { - var line = Log.Lines(LogLevel.Information).Single(l => l.Contains(clientId) && l.Contains("undelivered token ")); - var tail = line[(line.IndexOf("undelivered token ", StringComparison.Ordinal) + "undelivered token ".Length)..]; - return tail[..tail.IndexOf(';')]; - } - - /// Reads the row straight from the store — the authority, not the lagging listing. - private async Task AssertStored(string path, bool present, string because) - { - var node = await Storage.Read(path, Mesh.JsonSerializerOptions) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - (node is not null).Should().Be(present, because + $" ({path})"); - } - - /// A controller logger that records every line and fires the abort on one of them. - private sealed class AbortOn(string marker, CancellationTokenSource abort, CapturingLogger sink) - : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - sink.Log(logLevel, eventId, state, exception, formatter); - if (formatter(state, exception).StartsWith(marker, StringComparison.Ordinal)) - abort.Cancel(); - } - } - - /// Forwards to the real logger and fires the abort when the mint is in flight. - private sealed class AbortOnMint(CancellationTokenSource abort, ILogger inner) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => inner.BeginScope(state); - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - var text = formatter(state, exception); - if (text.StartsWith("Creating API token", StringComparison.Ordinal)) - abort.Cancel(); - inner.Log(logLevel, eventId, state, exception, formatter); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/OAuthAuthorizeReturnUrlIsLocalTest.cs b/test/Memex.Portal.Shared.Test/OAuthAuthorizeReturnUrlIsLocalTest.cs deleted file mode 100644 index f443b6913a..0000000000 --- a/test/Memex.Portal.Shared.Test/OAuthAuthorizeReturnUrlIsLocalTest.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The returnUrl that /authorize hands to /login must be a LOCAL path. -/// -/// The defect (#5074). Every returnUrl SINK in the portal validates -/// local-only — here, the GUI's own single copy of the -/// same rule on the login page — because an unvalidated redirect target is an open redirect. -/// Those rules are right. What was wrong was this SOURCE: /authorize minted -/// {Scheme}://{Host}{Path}{QueryString}, an absolute URL that no sink can accept. The -/// login page dropped it silently (its local-or-null helper substitutes nothing rather than a -/// destination), so the provider link carried no returnUrl at all, the user landed on -/// "/" after signing in, and the /authorize request was gone. No authorization -/// code was ever issued, so an MCP client waited forever for an "authentication successful" -/// that could not arrive — and the user, who HAD signed in successfully, read it as the token -/// having expired again. -/// -/// Why the assertion is stated as a round-trip through the policy rather than as a -/// second hand-written "is it local?" check: there is exactly one such rule per assembly, and a -/// re-implementation is the bug the sink guard exists to prevent — a hand-written copy that was -/// subtly weaker than the shared rule has already shipped once. Asking the real policy whether it -/// would keep this value is the same question every sink downstream asks. -/// -/// SHOULD-FAIL-IF the absolute form comes back: -/// goes red, because -/// pins that the -/// policy sends that shape to "/". The pair is the control on each side of the change: a -/// policy that accepted everything would fail the second, and one that accepted nothing would -/// fail the first, so no constant satisfies both. -/// -public class OAuthAuthorizeReturnUrlIsLocalTest -{ - /// - /// The unauthenticated branch of /authorize resolves neither the code store nor the - /// token service (both are lazily-read properties reached only after the identity check), so - /// the action needs no services at all to exercise it. - /// - private sealed class NoServices : IServiceProvider - { - public object? GetService(Type serviceType) => null; - } - - private const string ClientId = "aB3-derived-client-id"; - private const string RedirectUri = "http://localhost:8765/callback"; - - /// The query an MCP client actually sends, escaped as it arrives on the wire. - private const string AuthorizeQuery = - "?response_type=code" - + "&client_id=" + ClientId - + "&redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fcallback" - + "&state=6ff1c0de&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" - + "&code_challenge_method=S256"; - - private static OAuthConnectController Controller() => - new(new NoServices(), NullLogger.Instance) - { - ControllerContext = new ControllerContext - { - // No authenticated identity — DefaultHttpContext's principal has an - // unauthenticated identity, which is the branch under test. - HttpContext = new DefaultHttpContext - { - Request = - { - Scheme = "https", - Host = new HostString("memex.systemorph.com"), - Path = "/authorize", - QueryString = new QueryString(AuthorizeQuery), - }, - }, - }, - }; - - private static async Task LoginRedirect() - { - var result = await Controller().Authorize( - response_type: "code", - client_id: ClientId, - redirect_uri: RedirectUri, - state: "6ff1c0de", - scope: null, - code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", - code_challenge_method: "S256"); - - var redirect = Assert.IsType(result); - return redirect.Url; - } - - /// The returnUrl value as the login page will read it. - private static async Task CapturedReturnUrl() - { - var url = await LoginRedirect(); - const string prefix = "/login?returnUrl="; - Assert.StartsWith(prefix, url, StringComparison.Ordinal); - return Uri.UnescapeDataString(url[prefix.Length..]); - } - - [Fact] - public async Task TheLoginReturnUrlSurvivesThePolicyEverySinkApplies() - { - var returnUrl = await CapturedReturnUrl(); - - Assert.Equal(returnUrl, ReturnUrlPolicy.Sanitize(returnUrl)); - } - - [Fact] - public async Task TheLoginReturnUrlCarriesTheWholeAuthorizeRequest() - { - var returnUrl = await CapturedReturnUrl(); - - // Path AND query: dropping the query would send the user back to a /authorize with no - // client_id, which 400s — a different way of never issuing a code. - Assert.Equal("/authorize" + AuthorizeQuery, returnUrl); - } - - [Theory] - [InlineData("https://memex.systemorph.com/authorize?response_type=code&client_id=x")] - [InlineData("http://memex.systemorph.com/authorize")] - public void TheAbsoluteFormThisEndpointUsedToMint_IsRefusedByTheSamePolicy(string absolute) - // The control: this is why an absolute returnUrl never reached the login provider. - // It is not that the policy is wrong — an absolute target IS how an open redirect is - // spelled — it is that the source had to mint something the policy can keep. - => Assert.Equal("/", ReturnUrlPolicy.Sanitize(absolute)); -} diff --git a/test/Memex.Portal.Shared.Test/OAuthBoundedLiveCredentialsTest.cs b/test/Memex.Portal.Shared.Test/OAuthBoundedLiveCredentialsTest.cs deleted file mode 100644 index bd1b56493e..0000000000 --- a/test/Memex.Portal.Shared.Test/OAuthBoundedLiveCredentialsTest.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #5074 defect 2: several processes of ONE installation share a client_id (Claude Code keeps -/// one registration per machine), so a one-live-credential-per-client rule made every new sign-in -/// revoke every sibling session's token. The exchange now keeps a BOUNDED number of live credentials -/// per client — the newest N — and evicts only the oldest beyond that, logging each eviction. -/// -/// Driven end to end through the real controller, token service and mesh store, with a bound of -/// TWO so three authorizations cross it. -/// -/// SHOULD-FAIL-IF the bound reverts to one (the second authorization would already evict -/// the first — the half asserted between the second and third exchange), or the eviction takes -/// anything but the oldest, or it is not logged by path. -/// -public class OAuthBoundedLiveCredentialsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string RedirectUri = "https://claude.ai/cb"; - private const string UserEmail = "erin@example.com"; - private const string UserId = "erin"; - private const int Bound = 2; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddOAuthCodeType(); - - private CapturingLogger Log { get; } = new(); - - private IStorageAdapter Storage => Mesh.ServiceProvider.GetRequiredService(); - - private ApiTokenService TokenService() => new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Storage, - Mesh.ServiceProvider.GetRequiredService>()); - - private OAuthConnectController Controller() - { - var services = new ServiceCollection(); - services.AddSingleton(new OAuthCodeStore( - Storage, Mesh, Mesh.ServiceProvider.GetRequiredService>())); - services.AddSingleton(TokenService()); - services.AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection([new(OAuthCredentialBound.ConfigKey, Bound.ToString())]) - .Build()); - var provider = services.BuildServiceProvider(); - - var identity = new ClaimsIdentity( - [ - new Claim(ClaimTypes.Email, UserEmail), - new Claim(ClaimTypes.Name, "Erin"), - new Claim("preferred_username", UserEmail), - ], - authenticationType: "Test"); - - return new OAuthConnectController(provider, Log) - { - ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext - { - User = new ClaimsPrincipal(identity), - Request = { Scheme = "https", Host = new HostString("memex.test") }, - }, - }, - }; - } - - private static string TokenPath(string rawToken) => - $"{UserId}/ApiToken/{ApiTokenService.HashToken(rawToken)[..12]}"; - - private static async Task AuthorizeAndExchange(OAuthConnectController controller, string clientId) - { - var verifier = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - var challenge = Convert.ToBase64String(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - - var redirect = await controller.Authorize( - response_type: "code", - client_id: clientId, - redirect_uri: RedirectUri, - state: "s", - scope: "mcp", - code_challenge: challenge, - code_challenge_method: "S256", - ct: TestContext.Current.CancellationToken) - .ToObservable() - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - - var url = ((RedirectResult)redirect).Url; - var code = Uri.UnescapeDataString(url[(RedirectUri + "?code=").Length..url.IndexOf("&state=", StringComparison.Ordinal)]); - - var result = await controller.ExchangeToken(new TokenRequest - { - grant_type = "authorization_code", - code = code, - client_id = clientId, - redirect_uri = RedirectUri, - code_verifier = verifier, - }, TestContext.Current.CancellationToken) - .ToObservable() - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - - var body = ((OkObjectResult)result).Value!; - return (string)body.GetType().GetProperty("access_token")!.GetValue(body)!; - } - - /// Waits until the listing the eviction reads carries , so the - /// next exchange is guaranteed to see it — the actual condition, never a delay. - private async Task WaitListed(string label, string rawToken) - { - var listing = TokenService(); - await Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .SelectMany(_ => listing.GetTokensForUser(UserId).Take(1)) - .Should().Within(TestTimeouts.Convergence) - .Match(all => all.Any(t => t.Label == label && t.NodePath == TokenPath(rawToken)), - cancellationToken: TestContext.Current.CancellationToken); - } - - /// Reads the row straight from the store — the authority, not the lagging listing. - private async Task AssertStored(string path, bool present, string because) - { - var node = await Storage.Read(path, Mesh.JsonSerializerOptions) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - (node is not null).Should().Be(present, because + $" ({path})"); - } - - [Fact] - public async Task SiblingSessionsKeepTheirCredentials_UpToTheBound_ThenTheOldestIsEvicted() - { - var controller = Controller(); - var clientId = "one-machine-" + Guid.NewGuid().ToString("N")[..8]; - var label = $"OAuth: {clientId}"; - - // Session A signs in, then session B of the same installation signs in. - var a = await AuthorizeAndExchange(controller, clientId); - await WaitListed(label, a); - var b = await AuthorizeAndExchange(controller, clientId); - await WaitListed(label, b); - - Log.Lines(LogLevel.Information).Should().NotContain( - l => l.Contains("superseding") && l.Contains(label), - "two credentials are within a bound of two — B's sign-in must not cost A its session " - + "(the one-per-client rule evicted A right here)"); - await AssertStored(TokenPath(a), present: true, "session A still holds a live credential"); - - // Session C crosses the bound: the OLDEST (A) goes, B and C stay. - var c = await AuthorizeAndExchange(controller, clientId); - - var evicted = Log.Lines(LogLevel.Information) - .Where(l => l.Contains("evicted credential") && l.Contains(label)).ToArray(); - evicted.Should().ContainSingle("exactly one credential falls below a bound of two"); - evicted.Single().Should().Contain(TokenPath(a), - "the eviction line names the removed row, whose last segment is the hash prefix A's 401 is logged under"); - - Log.Lines(LogLevel.Information).Should().Contain( - l => l.Contains("superseded 1 of 1 previous token(s)") && l.Contains($"removed {TokenPath(a)}"), - "the outcome line reports what the delete actually removed"); - - await AssertStored(TokenPath(a), present: false, "the oldest credential was evicted"); - await AssertStored(TokenPath(b), present: true, "session B keeps its credential across C's sign-in"); - await AssertStored(TokenPath(c), present: true, "the newly minted credential is live"); - } -} diff --git a/test/Memex.Portal.Shared.Test/OAuthClientIdDerivationTest.cs b/test/Memex.Portal.Shared.Test/OAuthClientIdDerivationTest.cs deleted file mode 100644 index 40a3a0e223..0000000000 --- a/test/Memex.Portal.Shared.Test/OAuthClientIdDerivationTest.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Memex.Portal.Shared.Authentication; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The client_id handed out by Dynamic Client Registration must be STABLE for a given client. -/// It used to be random, which meant an MCP client — which re-registers on every reconnect — presented -/// as a brand-new application each time, so the user was asked to consent again on every connection. -/// -public class OAuthClientIdDerivationTest -{ - private const string Origin = "https://memex.meshweaver.cloud"; - - [Fact] - public void SameClientRegisteringTwiceGetsTheSameId() - { - var a = OAuthConnectController.DeriveClientId(Origin, "claude-code", ["http://localhost:8765/callback"]); - var b = OAuthConnectController.DeriveClientId(Origin, "claude-code", ["http://localhost:8765/callback"]); - Assert.Equal(a, b); - } - - [Fact] - public void RedirectUriOrderDoesNotChangeTheId() - { - var a = OAuthConnectController.DeriveClientId(Origin, "c", ["https://x/cb", "https://y/cb"]); - var b = OAuthConnectController.DeriveClientId(Origin, "c", ["https://y/cb", "https://x/cb"]); - Assert.Equal(a, b); - } - - [Fact] - public void ADifferentClientNameGivesADifferentId() - { - var a = OAuthConnectController.DeriveClientId(Origin, "claude-code", ["http://localhost:8765/callback"]); - var b = OAuthConnectController.DeriveClientId(Origin, "some-other-client", ["http://localhost:8765/callback"]); - Assert.NotEqual(a, b); - } - - [Fact] - public void ADifferentRedirectUriGivesADifferentId() - { - var a = OAuthConnectController.DeriveClientId(Origin, "claude-code", ["http://localhost:8765/callback"]); - var b = OAuthConnectController.DeriveClientId(Origin, "claude-code", ["http://localhost:9999/callback"]); - Assert.NotEqual(a, b); - } - - [Fact] - public void TheSameClientGetsDifferentIdsOnDifferentDeployments() - { - var a = OAuthConnectController.DeriveClientId("https://memex.meshweaver.cloud", "c", ["https://x/cb"]); - var b = OAuthConnectController.DeriveClientId("https://memex.systemorph.com", "c", ["https://x/cb"]); - Assert.NotEqual(a, b); - } - - [Fact] - public void TheIdIsUrlSafeAndNonEmpty() - { - var id = OAuthConnectController.DeriveClientId(Origin, "claude-code", ["http://localhost:8765/callback"]); - Assert.False(string.IsNullOrWhiteSpace(id)); - Assert.DoesNotContain("+", id); - Assert.DoesNotContain("/", id); - Assert.DoesNotContain("=", id); - } -} diff --git a/test/Memex.Portal.Shared.Test/OAuthCodeStoreTest.cs b/test/Memex.Portal.Shared.Test/OAuthCodeStoreTest.cs deleted file mode 100644 index ef4eb83a5a..0000000000 --- a/test/Memex.Portal.Shared.Test/OAuthCodeStoreTest.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Mesh-backed OAuthCodeStore semantics: single-use consume-first codes, and the exact -/// failure reason for every rejection branch (unknown/consumed, expired, client_id, -/// redirect_uri, PKCE). The reason string is what the /token endpoint logs at Warning — -/// the bare "invalid or expired" line made real-world MCP-login failures unattributable -/// (2026-07-02: a failed exchange on memex-cloud could not be told apart from a -/// duplicate-callback burn or a PKCE mismatch). -/// -/// -/// The store persists codes STRAIGHT to the shared storage adapter at -/// Admin/OAuthCode/{hashPrefix} — replica-deterministic by construction (the -/// 2026-07-23 in-memory-dictionary outage and the 2026-07-25 cross-silo mesh-read -/// wedge were both "pod B can't see pod A's code" failures). -/// pins exactly that -/// scenario: two store INSTANCES sharing one storage (two replicas sharing one PG) -/// must exchange each other's codes, and the single-use consume (first delete wins, -/// via the atomic DeleteIfExists) must hold across instances. -/// -/// -public class OAuthCodeStoreTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string ClientId = "client-1"; - private const string RedirectUri = "http://localhost:12345/callback"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - // Same registration the portal wires in ConfigureMemexMesh — the OAuthCode - // NodeType + AuthorizationCode content type the store persists. - => base.ConfigureMesh(builder).AddOAuthCodeType(); - - /// - /// Each call builds an independent store instance on the SAME shared storage - /// adapter — the same relationship two KEDA replicas have to the shared PG. - /// - private OAuthCodeStore NewStore(TimeSpan? codeLifetime = null) => new( - Storage, - Mesh, - Mesh.ServiceProvider.GetRequiredService>()) - { - CodeLifetime = codeLifetime ?? TimeSpan.FromMinutes(5), - }; - - /// - /// The shared store both "replicas" read/write — the in-memory twin of the - /// shared Postgres the production stores hit directly. - /// - private IStorageAdapter Storage => Mesh.ServiceProvider.GetRequiredService(); - - private static string Challenge(string verifier) - { - var hash = SHA256.HashData(Encoding.ASCII.GetBytes(verifier)); - return Convert.ToBase64String(hash).Replace("+", "-").Replace("/", "_").TrimEnd('='); - } - - /// - /// Issues a code. GenerateCode emits only after the storage write committed — - /// the row is deterministically readable on every "replica" the moment the - /// code exists (no visibility poll needed; the single read assert pins that). - /// - private async Task Issue(OAuthCodeStore store, string? challenge = null, string? method = null) - { - var code = await store.GenerateCode("rbuergi", "Roland", "rbuergi@systemorph.com", - ClientId, RedirectUri, challenge, method) - .Should().Within(30.Seconds()).Emit(cancellationToken: TestContext.Current.CancellationToken); - - var stored = await Storage - .Read(OAuthCodeStore.PathForCode(code), Mesh.JsonSerializerOptions) - .Should().Within(30.Seconds()).Emit(cancellationToken: TestContext.Current.CancellationToken); - stored.Should().NotBeNull("GenerateCode must not emit before the row is committed"); - - return code; - } - - private Task Exchange( - OAuthCodeStore store, string code, - string clientId = ClientId, string redirectUri = RedirectUri, string? verifier = null) - => store.ExchangeCode(code, clientId, redirectUri, verifier) - .Should().Within(30.Seconds()).Emit(cancellationToken: TestContext.Current.CancellationToken); - - [Fact] - public async Task Exchange_WithMatchingParameters_ReturnsEntry_NoReason() - { - var store = NewStore(); - var code = await Issue(store); - - var result = await Exchange(store, code); - - result.Entry.Should().NotBeNull(); - result.FailureReason.Should().BeNull(); - result.Entry!.UserId.Should().Be("rbuergi"); - result.Entry.UserEmail.Should().Be("rbuergi@systemorph.com"); - } - - [Fact] - public async Task Exchange_UnknownCode_ReportsUnknown() - { - var store = NewStore(); - - var result = await Exchange(store, "no-such-code"); - - result.Entry.Should().BeNull(); - result.FailureReason.Should().Contain("unknown or already consumed"); - } - - [Fact] - public async Task Exchange_SecondAttempt_IsConsumed() - { - var store = NewStore(); - var code = await Issue(store); - var first = await Exchange(store, code); - first.Entry.Should().NotBeNull(); - - var second = await Exchange(store, code); - - second.Entry.Should().BeNull(); - second.FailureReason.Should().Contain("unknown or already consumed"); - } - - [Fact] - public async Task Exchange_ExpiredCode_ReportsExpired() - { - // Zero lifetime → the code is already expired by the time the exchange - // validates it (deterministic — no sleeping). The consume-first delete has - // already removed the node, so an expired code is rejected AND gone. - var store = NewStore(codeLifetime: TimeSpan.Zero); - var code = await Issue(store); - - var result = await Exchange(store, code); - - result.Entry.Should().BeNull(); - result.FailureReason.Should().Contain("expired"); - - // reject + delete: the expired code's row was consumed by the rejection. - var after = await Storage - .Read(OAuthCodeStore.PathForCode(code), Mesh.JsonSerializerOptions) - .Should().Within(30.Seconds()).Emit(); - after.Should().BeNull(); - } - - [Fact] - public async Task Exchange_ClientIdMismatch_ReportsClientId() - { - var store = NewStore(); - var code = await Issue(store); - - var result = await Exchange(store, code, clientId: "other-client"); - - result.Entry.Should().BeNull(); - result.FailureReason.Should().Contain("client_id mismatch"); - } - - [Fact] - public async Task Exchange_RedirectUriMismatch_ReportsRedirectUri() - { - var store = NewStore(); - var code = await Issue(store); - - var result = await Exchange(store, code, redirectUri: "http://localhost:9/other"); - - result.Entry.Should().BeNull(); - result.FailureReason.Should().Contain("redirect_uri mismatch"); - } - - [Fact] - public async Task Exchange_S256Pkce_RoundTrips() - { - var store = NewStore(); - const string verifier = "the-verifier-string-with-enough-entropy"; - var code = await Issue(store, Challenge(verifier), "S256"); - - var result = await Exchange(store, code, verifier: verifier); - - result.Entry.Should().NotBeNull(); - result.FailureReason.Should().BeNull(); - } - - [Fact] - public async Task Exchange_PlainPkce_RoundTrips() - { - // plain method: challenge == verifier, compared verbatim. - var store = NewStore(); - const string verifier = "plain-verifier-with-enough-entropy-0123456789"; - var code = await Issue(store, verifier, "plain"); - - var result = await Exchange(store, code, verifier: verifier); - - result.Entry.Should().NotBeNull(); - result.FailureReason.Should().BeNull(); - } - - [Fact] - public async Task Exchange_MissingVerifier_ReportsMissing() - { - var store = NewStore(); - var code = await Issue(store, Challenge("v"), "S256"); - - var result = await Exchange(store, code); - - result.Entry.Should().BeNull(); - result.FailureReason.Should().Contain("code_verifier missing"); - } - - [Fact] - public async Task Exchange_WrongVerifier_ReportsPkceFailure() - { - var store = NewStore(); - var code = await Issue(store, Challenge("right-verifier"), "S256"); - - var result = await Exchange(store, code, verifier: "wrong-verifier"); - - result.Entry.Should().BeNull(); - result.FailureReason.Should().Contain("PKCE verification failed"); - } - - [Fact] - public async Task TwoStoreInstances_GenerateOnOne_ExchangeOnOther() - { - // The prod 2026-07-23 outage shape: /authorize on replica A, /token on replica B. - // Two independent store instances share the mesh the way two pods share PG — - // B must be able to exchange A's code, and the single-use consume must then - // hold on EVERY replica (a replay against A is a lost first-delete-wins race, - // never a second success). - var replicaA = NewStore(); - var replicaB = NewStore(); - - var code = await Issue(replicaA); - - var onB = await Exchange(replicaB, code); - onB.Entry.Should().NotBeNull(); - onB.FailureReason.Should().BeNull(); - onB.Entry!.UserId.Should().Be("rbuergi"); - - var replayOnA = await Exchange(replicaA, code); - replayOnA.Entry.Should().BeNull(); - replayOnA.FailureReason.Should().Contain("unknown or already consumed"); - } -} diff --git a/test/Memex.Portal.Shared.Test/OAuthCredentialEvictionTest.cs b/test/Memex.Portal.Shared.Test/OAuthCredentialEvictionTest.cs deleted file mode 100644 index b5933f6af4..0000000000 --- a/test/Memex.Portal.Shared.Test/OAuthCredentialEvictionTest.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System; -using System.Linq; -using Microsoft.Extensions.Configuration; -using Memex.Portal.Shared.Authentication; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The pure eviction rule behind the /token exchange (#5074): a client keeps its N newest live -/// credentials and a fresh authorization evicts only what falls below them. -/// -/// SHOULD-FAIL-IF the rule reverts to one credential per client (every sibling of the new -/// token evicted), or evicts a token NEWER than the evaluating exchange (the concurrent-exchange case), -/// or lets a revoked row hold one of the kept slots. -/// -public class OAuthCredentialEvictionTest -{ - private const string Label = "OAuth: shared-client"; - private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); - - private static ApiTokenInfo Token(int minute, string? label = null, bool revoked = false) => new() - { - NodePath = $"u/ApiToken/t{minute:D2}", - Label = label ?? Label, - CreatedAt = T0.AddMinutes(minute), - ExpiresAt = T0.AddYears(1), - IsRevoked = revoked, - }; - - [Fact] - public void BelowTheBound_NothingIsEvicted() - { - // Four older credentials + the new one = five = the default bound. - var tokens = Enumerable.Range(1, 5).Select(m => Token(m)).ToArray(); - - OAuthCredentialEviction.Evict(tokens, Label, "u/ApiToken/t05", T0.AddMinutes(5), - OAuthCredentialBound.Default) - .Should().BeEmpty("five live credentials is within a bound of five — the sibling sessions keep theirs"); - } - - [Fact] - public void AboveTheBound_TheOldestAreEvicted_OldestFirst() - { - var tokens = Enumerable.Range(1, 7).Select(m => Token(m)).ToArray(); - - OAuthCredentialEviction.Evict(tokens, Label, "u/ApiToken/t07", T0.AddMinutes(7), 5) - .Should().Equal(["u/ApiToken/t01", "u/ApiToken/t02"], - "seven credentials under a bound of five evicts exactly the two oldest"); - } - - [Fact] - public void ABoundOfOne_IsTheOldOnePerClientRule() - { - var tokens = Enumerable.Range(1, 3).Select(m => Token(m)).ToArray(); - - OAuthCredentialEviction.Evict(tokens, Label, "u/ApiToken/t03", T0.AddMinutes(3), 1) - .Should().Equal(["u/ApiToken/t01", "u/ApiToken/t02"]); - OAuthCredentialEviction.Evict(tokens, Label, "u/ApiToken/t03", T0.AddMinutes(3), 0) - .Should().Equal(["u/ApiToken/t01", "u/ApiToken/t02"], "a bound below one is treated as one"); - } - - [Fact] - public void ANewerToken_IsNeverEvicted_EvenByAnExchangeThatSeesIt() - { - // A concurrent exchange minted t09 while t08's exchange was listing. t08's evaluation sees - // t09 and must leave it alone — only strictly-older rows are candidates. - var tokens = Enumerable.Range(1, 9).Select(m => Token(m)).ToArray(); - - var evicted = OAuthCredentialEviction.Evict(tokens, Label, "u/ApiToken/t08", T0.AddMinutes(8), 2); - - evicted.Should().NotContain("u/ApiToken/t09"); - evicted.Should().Equal(Enumerable.Range(1, 6).Select(m => $"u/ApiToken/t{m:D2}"), - "t08's exchange keeps itself and t07 (a bound of two) and evicts t01..t06"); - } - - [Fact] - public void ConcurrentExchanges_Converge_OnTheNewestN() - { - // Every exchange evaluates against the full listing; the union of what they evict must leave - // exactly the N newest, whatever order they run in. - var tokens = Enumerable.Range(1, 8).Select(m => Token(m)).ToArray(); - const int bound = 3; - - var evictedByAll = tokens - .SelectMany(t => OAuthCredentialEviction.Evict(tokens, Label, t.NodePath, t.CreatedAt, bound)) - .ToHashSet(); - - tokens.Select(t => t.NodePath).Where(p => !evictedByAll.Contains(p)) - .Should().Equal(["u/ApiToken/t06", "u/ApiToken/t07", "u/ApiToken/t08"]); - } - - [Fact] - public void ALaggingListing_EvictsLess_NeverANewerToken() - { - // The listing trails the store: t03 and t04 are not in it yet. The exchange for t05 may then - // keep an older row it would otherwise evict, but must never evict anything newer than it. - var listing = new[] { Token(1), Token(2), Token(5) }; - - OAuthCredentialEviction.Evict(listing, Label, "u/ApiToken/t05", T0.AddMinutes(5), 2) - .Should().Equal(["u/ApiToken/t01"], "only the older rows it SEES beyond the one kept slot"); - } - - [Fact] - public void ARevokedRow_TakesNoKeptSlot_AndIsRemoved() - { - var tokens = new[] { Token(1), Token(2), Token(3, revoked: true), Token(4) }; - - // A bound of two keeps ONE older credential beside t04. The newest older row, t03, is revoked, - // so the slot goes to t02; t03 is cleaned up and t01 falls below the bound. - OAuthCredentialEviction.Evict(tokens, Label, "u/ApiToken/t04", T0.AddMinutes(4), 2) - .Should().Equal(["u/ApiToken/t01", "u/ApiToken/t03"], - "a revoked row must not hold the kept slot — had it, t02 would have been evicted instead"); - OAuthCredentialEviction.Evict(tokens, Label, "u/ApiToken/t04", T0.AddMinutes(4), 3) - .Should().Equal(["u/ApiToken/t03"], "below the bound only the dead row goes"); - } - - [Fact] - public void ADeadRowNewerThanTheExchange_IsRemovedToo() - { - // A concurrent exchange minted t05 after t04's, and it was revoked since. It opens nothing, so - // t04's exchange removes it even though it is newer; a newer LIVE row stays untouched. - var tokens = new[] { Token(1), Token(4), Token(5, revoked: true), Token(6) }; - - OAuthCredentialEviction.Evict(tokens, Label, "u/ApiToken/t04", T0.AddMinutes(4), 5) - .Should().Equal(["u/ApiToken/t05"]); - } - - [Theory] - [InlineData(null, 5)] - [InlineData("3", 3)] - [InlineData("0", 1)] - [InlineData("-4", 1)] - [InlineData("many", 5)] - public void TheBound_ComesFromConfiguration_WithADefaultOfFive(string? configured, int expected) - { - var configuration = new Microsoft.Extensions.Configuration.ConfigurationBuilder() - .AddInMemoryCollection(configured is null - ? [] - : [new(OAuthCredentialBound.ConfigKey, configured)]) - .Build(); - - OAuthCredentialBound.From(configuration).Should().Be(expected); - } - - [Fact] - public void NoConfigurationAtAll_IsTheDefault() - // The shape of a caller that builds the controller's provider without IConfiguration. - => OAuthCredentialBound.From(null).Should().Be(OAuthCredentialBound.Default); - - [Fact] - public void AnotherClientsTokens_AreNeverTouched() - { - var tokens = new[] { Token(1, "OAuth: other"), Token(2, "OAuth: other"), Token(3) }; - - OAuthCredentialEviction.Evict(tokens, Label, "u/ApiToken/t03", T0.AddMinutes(3), 1) - .Should().BeEmpty("the label is the client identity; another client's rows are not candidates"); - } - - [Fact] - public void ASameTickTie_IsBrokenByPath() - { - var a = Token(1) with { NodePath = "u/ApiToken/aaa" }; - var b = Token(1) with { NodePath = "u/ApiToken/bbb" }; - - OAuthCredentialEviction.Evict([a, b], Label, b.NodePath, b.CreatedAt, 1) - .Should().Equal(["u/ApiToken/aaa"]); - OAuthCredentialEviction.Evict([a, b], Label, a.NodePath, a.CreatedAt, 1) - .Should().BeEmpty("aaa sorts below bbb on the tie, so bbb is not older than aaa"); - } -} diff --git a/test/Memex.Portal.Shared.Test/OAuthSupersedeLineNamesTheRowsTest.cs b/test/Memex.Portal.Shared.Test/OAuthSupersedeLineNamesTheRowsTest.cs deleted file mode 100644 index cff391f525..0000000000 --- a/test/Memex.Portal.Shared.Test/OAuthSupersedeLineNamesTheRowsTest.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The /token supersede line names the row it removed and the row that replaced it. -/// -/// Why a log line is worth a test. One live credential per (user, client_id) -/// is deliberate (#1493): a re-authorization DELETES the client's previous token. The holder of -/// that previous token then gets a 401, which the validator logs under the token's HASH PREFIX -/// — and that prefix is the last segment of the token row's path. The supersede line used to say -/// only "superseding 1 previous token(s) for user U, client label L": true, and useless -/// for tying it to the 401 it caused. #5074's alternation — two processes of one installation -/// sharing a client_id and evicting each other's credential — is attributable inside a log -/// window only if the supersede names the path whose prefix the 401 names. -/// -/// SHOULD-FAIL-IF the paths leave the line again. The control on the other side is -/// the FIRST exchange, which has nothing to supersede and must log no supersede line at all — an -/// implementation that printed the line on every exchange would fail that half. -/// -public class OAuthSupersedeLineNamesTheRowsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string RedirectUri = "https://claude.ai/cb"; - private const string UserEmail = "dora@example.com"; - - /// The mesh user id the controller resolves from the claims (email local part). - private const string UserId = "dora"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - // Same registration the portal wires in ConfigureMemexMesh — the OAuthCode - // NodeType + AuthorizationCode content type the code store persists. - => base.ConfigureMesh(builder).AddOAuthCodeType(); - - private CapturingLogger Log { get; } = new(); - - private IStorageAdapter Storage => Mesh.ServiceProvider.GetRequiredService(); - - private ApiTokenService TokenService() => new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Storage, - Mesh.ServiceProvider.GetRequiredService>()); - - private OAuthConnectController Controller() - { - // The controller resolves its two collaborators lazily from the request's provider; the - // portal registers both as root singletons over the mesh's storage adapter, and so does - // this. - var services = new ServiceCollection(); - services.AddSingleton(new OAuthCodeStore( - Storage, Mesh, Mesh.ServiceProvider.GetRequiredService>())); - services.AddSingleton(TokenService()); - // Pinned at ONE live credential per client: this test is about the supersede's mechanics, - // which the bound only changes in how many older rows it keeps (see - // OAuthBoundedLiveCredentialsTest for the bound itself). - services.AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection([new(OAuthCredentialBound.ConfigKey, "1")]) - .Build()); - var provider = services.BuildServiceProvider(); - - var identity = new ClaimsIdentity( - [ - new Claim(ClaimTypes.Email, UserEmail), - new Claim(ClaimTypes.Name, "Dora"), - new Claim("preferred_username", UserEmail), - ], - authenticationType: "Test"); - - return new OAuthConnectController(provider, Log) - { - ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext - { - User = new ClaimsPrincipal(identity), - Request = { Scheme = "https", Host = new HostString("memex.test") }, - }, - }, - }; - } - - private static string TokenPath(string rawToken) => - $"{UserId}/ApiToken/{ApiTokenService.HashToken(rawToken)[..12]}"; - - /// One full authorization: /authorize → code → /token → raw token. - private static async Task AuthorizeAndExchange(OAuthConnectController controller, string clientId) - { - var verifier = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - var challenge = Convert.ToBase64String(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - - var redirect = await controller.Authorize( - response_type: "code", - client_id: clientId, - redirect_uri: RedirectUri, - state: "s", - scope: "mcp", - code_challenge: challenge, - code_challenge_method: "S256", - ct: TestContext.Current.CancellationToken) - .ToObservable() - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - - var url = ((RedirectResult)redirect).Url; - var code = Uri.UnescapeDataString(url[(RedirectUri + "?code=").Length..url.IndexOf("&state=", StringComparison.Ordinal)]); - - var result = await controller.ExchangeToken(new TokenRequest - { - grant_type = "authorization_code", - code = code, - client_id = clientId, - redirect_uri = RedirectUri, - code_verifier = verifier, - }, TestContext.Current.CancellationToken) - .ToObservable() - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - - var body = ((OkObjectResult)result).Value!; - return (string)body.GetType().GetProperty("access_token")!.GetValue(body)!; - } - - [Fact] - public async Task TheSupersedeLineNamesTheRowItRemovedAndTheRowThatReplacedIt() - { - var controller = Controller(); - var clientId = "one-installation-" + Guid.NewGuid().ToString("N")[..8]; - var label = $"OAuth: {clientId}"; - - var first = await AuthorizeAndExchange(controller, clientId); - - Log.Lines(LogLevel.Information).Should().NotContain( - l => l.Contains("superseding"), - "the first authorization has nothing to supersede — a line here would be noise, " - + "and the control that the line is tied to an actual removal"); - - // The supersede reads the user's token listing through the synced query, which trails the - // mint. Wait for the FIRST token to be listed before authorizing again, so the second - // exchange's listing is guaranteed to see it — waiting on the actual condition, not a delay. - var listing = TokenService(); - await Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .SelectMany(_ => listing.GetTokensForUser(UserId).Take(1)) - .Should().Within(TestTimeouts.Convergence) - .Match(all => all.Any(t => t.Label == label && t.NodePath == TokenPath(first)), - cancellationToken: TestContext.Current.CancellationToken); - - var second = await AuthorizeAndExchange(controller, clientId); - second.Should().NotBe(first, "each authorization mints its own credential"); - - // Two lines, two facts. The first is INTENT (the candidate set before any delete ran); - // the second is the OUTCOME (what each delete actually removed). A reader tying a 401 to - // a removal reads the second, so that is the one the paths are asserted on. - var intent = Log.Lines(LogLevel.Information).Where(l => l.Contains("superseding ")).ToArray(); - intent.Should().ContainSingle( - "the second authorization sets out to retire exactly the first credential, once"); - intent.Single().Should() - .Contain("superseding 1 previous token(s)") - .And.Contain(label) - .And.Contain($"candidates {TokenPath(first)}") - .And.Contain($"keeping {TokenPath(second)}"); - - var outcome = Log.Lines(LogLevel.Information).Where(l => l.Contains("superseded ")).ToArray(); - outcome.Should().ContainSingle( - "one removal report per supersede, emitted after every delete has answered"); - outcome.Single().Should() - .Contain("superseded 1 of 1 previous token(s)", - "the count is what the deletes RETURNED, not what was attempted") - .And.Contain(label) - .And.Contain($"removed {TokenPath(first)}", - "the removed row's path carries the hash prefix the holder's 401 will be logged under — " - + "without it the supersede and the 401 it caused cannot be tied together") - .And.Contain($"kept {TokenPath(second)}", - "the replacement is the other half of the same event"); - } -} diff --git a/test/Memex.Portal.Shared.Test/OAuthTokensExpireWhenIdleTest.cs b/test/Memex.Portal.Shared.Test/OAuthTokensExpireWhenIdleTest.cs deleted file mode 100644 index 393295c836..0000000000 --- a/test/Memex.Portal.Shared.Test/OAuthTokensExpireWhenIdleTest.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// An OAuth-minted token (label OAuth: {client_id}) expires once it has gone unused for -/// longer than . Every validator refuses it: the HTTP -/// authentication handler's and the shared -/// , which the auth middleware's direct-store path and the -/// ApiToken/{hashPrefix} hub run. The expiry sweep deletes it. A token a person minted by -/// hand is never subject to the rule, and no manual surface may mint into the reserved label. -/// -/// Why (Plugins#2772). Supersession keys on client_id, which a loopback client -/// derives from its redirect URI, so every re-registration on a new port opened a fresh slot and -/// left the previous one-year token live with nothing able to reach it. -/// -/// Controls. Each refusal is paired with the case on the other side of the line, so -/// no constant verdict passes the class: a recently used OAuth token stays valid (the bound runs -/// from the LAST use), and a hand-minted token far older than the bound stays valid (the rule is -/// scoped by label, not by age). Every fixture is minted and its own start-of-mint sweep has -/// finished BEFORE any row is aged, so the one sweep the test then triggers is the only one that -/// can delete the idle row. -/// -public class OAuthTokensExpireWhenIdleTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string User = "idleoauthuser"; - - private CapturingLogger Log { get; } = new(); - - private IStorageAdapter Storage => Mesh.ServiceProvider.GetRequiredService(); - - private ApiTokenService Service() => new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Storage, - Log); - - private static readonly TimeSpan Days31 = TimeSpan.FromDays(31); - private static readonly TimeSpan Days29 = TimeSpan.FromDays(29); - private static string Namespace => $"{User}/ApiToken"; - - private Task Within(IObservable source, string because = "") => - source.Should().Within(TestTimeouts.Convergence).Emit(because, TestContext.Current.CancellationToken); - - /// Rewrites a stored token row so it was created and last - /// used before now. - private async Task Age(MeshNode minted, TimeSpan createdAgo, TimeSpan? lastUsedAgo) - { - var now = DateTimeOffset.UtcNow; - var token = minted.ContentAs(Mesh.JsonSerializerOptions)!; - await Within(Storage.Write(minted with - { - Content = token with - { - CreatedAt = now - createdAgo, - LastUsedAt = lastUsedAgo is { } ago ? now - ago : null, - }, - }, Mesh.JsonSerializerOptions)); - } - - private Task SharedVerdict(string raw) => - Within(ApiTokenVerdict.Decide(raw, path => Storage.Read(path, Mesh.JsonSerializerOptions), Mesh.JsonSerializerOptions)); - - [Fact] - public void TheIdleRule_IsScopedToOAuthLabels_AndMeasuredFromLastUse() - { - var now = DateTimeOffset.UtcNow; - var oauth = new ApiToken { Label = OAuthTokenLifetime.LabelFor("abc"), CreatedAt = now - TimeSpan.FromDays(300) }; - - OAuthTokenLifetime.IsIdle(oauth with { LastUsedAt = now - Days31 }, now).Should().BeTrue(); - OAuthTokenLifetime.IsIdle(oauth with { LastUsedAt = null }, now) - .Should().BeTrue("a never-used OAuth token is measured from its creation"); - OAuthTokenLifetime.IsIdle(oauth with { LastUsedAt = now - Days29 }, now) - .Should().BeFalse("recent use keeps an old OAuth token alive"); - OAuthTokenLifetime.IsIdle(oauth with { CreatedAt = now - Days29, LastUsedAt = null }, now) - .Should().BeFalse("a fresh, not-yet-used OAuth token is inside the bound"); - OAuthTokenLifetime.IsIdle(new ApiToken { Label = "My laptop", CreatedAt = now - TimeSpan.FromDays(400) }, now) - .Should().BeFalse("a hand-minted token is never subject to the idle rule"); - } - - [Theory] - [InlineData("OAuth: my-client")] - [InlineData("oauth: lower")] - [InlineData(" OAuth:spaced")] - public async Task AManualMint_MayNotWearTheReservedOAuthLabel(string label) - { - OAuthTokenLifetime.IsReservedLabel(label).Should().BeTrue(); - var refused = await Service().CreateToken(User, User, $"{User}@example.com", label) - .Select(_ => (Exception?)null) - .Catch((Exception ex) => Observable.Return(ex)) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - refused.Should().BeOfType( - "a hand-minted token must not opt itself into the OAuth idle rule by its name"); - OAuthTokenLifetime.IsReservedLabel("My OAuth laptop").Should().BeFalse("only the prefix is reserved"); - } - - [Fact(Timeout = 180_000)] - public async Task AnIdleOAuthToken_IsRefusedByEveryValidator_AndSwept_WhileLiveAndHandMintedTokensSurvive() - { - TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); - var service = Service(); - var sweeps = service.ExpirySweepCompleted.Where(ns => ns == Namespace).Replay(); - using var connection = sweeps.Connect(); - - // 1. Mint every fixture, and let each mint's own sweep finish, BEFORE anything is aged. - var idle = await Within(service.CreateOAuthToken(User, User, $"{User}@example.com", "idle-client", null)); - var active = await Within(service.CreateOAuthToken(User, User, $"{User}@example.com", "active-client", null)); - var manual = await Within(service.CreateToken(User, User, $"{User}@example.com", "My laptop")); - await Within(sweeps.Take(3).LastAsync(), "the three mint-time sweeps finish before any row is aged"); - - // 2. Age them: idle unused for 31 days, active used 29 days ago, manual 400 days old. - await Age(idle.Node, TimeSpan.FromDays(90), Days31); - await Age(active.Node, TimeSpan.FromDays(90), Days29); - await Age(manual.Node, TimeSpan.FromDays(400), null); - - // 3. The HTTP authentication handler's validator. - (await Within(service.Validate(idle.RawToken))).Status.Should().Be(TokenValidationStatus.Invalid, - "an OAuth token unused for 31 days has expired"); - Log.Lines(LogLevel.Warning).Should().Contain(l => l.Contains("oauth-idle-expired"), - "the refusal names its stage, so a 401 is diagnosable"); - (await Within(service.Validate(active.RawToken))).Status.Should().Be(TokenValidationStatus.Valid, - "negative control: an OAuth token used 29 days ago is still live"); - (await Within(service.Validate(manual.RawToken))).Status.Should().Be(TokenValidationStatus.Valid, - "negative control: a hand-minted token without expiry is never idle-expired"); - - // 4. The shared verdict — what the auth middleware's direct-store path and the ApiToken hub run. - var idleVerdict = await SharedVerdict(idle.RawToken); - idleVerdict.Success.Should().BeFalse("the middleware and the hub refuse the idle token too"); - idleVerdict.IsUnavailable.Should().BeFalse("it is a definitive refusal, not a store hiccup"); - (await SharedVerdict(active.RawToken)).Success.Should().BeTrue("negative control on the shared verdict"); - (await SharedVerdict(manual.RawToken)).Success.Should().BeTrue("negative control on the shared verdict"); - - // 5. Exactly one more sweep — triggered by the next mint — deletes the idle row and only it. - var nextSweep = Within(sweeps.Skip(3).Take(1), "the trigger mint's sweep completes"); - await Within(service.CreateToken(User, User, $"{User}@example.com", "trigger sweep")); - await nextSweep; - - var remaining = (await Within(Storage.ListChildPaths(Namespace).Take(1))).NodePaths.ToArray(); - remaining.Should().NotContain(idle.Node.Path, "the next mint's sweep deletes the idle OAuth token"); - remaining.Should().Contain(active.Node.Path, "the live OAuth token is kept"); - remaining.Should().Contain(manual.Node.Path, "the hand-minted token is kept"); - } -} diff --git a/test/Memex.Portal.Shared.Test/OciRegistryClientTest.cs b/test/Memex.Portal.Shared.Test/OciRegistryClientTest.cs deleted file mode 100644 index 03f5ccf104..0000000000 --- a/test/Memex.Portal.Shared.Test/OciRegistryClientTest.cs +++ /dev/null @@ -1,203 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Net.Http; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The fleet's one OCI client (Doc/Architecture/PluginBundlesInTheRegistry): the bearer -/// handshake with the instance credential as Basic instance:key, ONE exchange serving a -/// manifest and its blob, a manifest fetched by digest verified against that digest, a blob -/// verified against the digest the manifest names — and a registry that serves OTHER bytes under -/// a sealed digest being REFUSED, for the manifest and for the blob alike. -/// -/// The fake substitutes the NETWORK, not a MeshWeaver interface; the hub and its I/O pools -/// are real. -/// -public class OciRegistryClientTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Host = "cr.example.test"; - private const string Key = "mwi_the-consumers-instance-key"; - private const string Repository = "plugins/Plugins/SocialMedia"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - private static readonly byte[] BundleBytes = System.Text.Encoding.UTF8.GetBytes( - "not a real zip — the client verifies bytes, it does not open them: " + new string('x', 200_000)); - - private readonly FakeOciRegistry registry = new(Host, Key, Repository, BundleBytes); - - /// A host that would accept the key if it ever got it — the witness that it never does. - private readonly FakeOciRegistry hostile = new("evil.example", Key, Repository, BundleBytes); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .ConfigureServices(s => s.AddSingleton(new HostRoutingClientFactory(registry, hostile))); - - private OciRegistryClient Client(string key = Key) => new( - Mesh, Host, Observable.Return(key), - logger: Mesh.ServiceProvider.GetService>()); - - [Fact(Timeout = 120_000)] - public async Task AManifestAndItsBlob_ArriveVerified_ThroughOneExchange() - { - var ct = TestContext.Current.CancellationToken; - var client = Client(); - - var manifest = await client.GetManifest(Repository, registry.ManifestDigest) - .FirstAsync().Timeout(Budget).Await(ct); - - manifest.Digest.Should().Be(registry.ManifestDigest, "a manifest fetched by digest IS that digest"); - manifest.MediaType.Should().Be(OciRegistryClient.ImageManifestMediaType); - var layer = manifest.Layers.Should().ContainSingle().Subject; - layer.MediaType.Should().Be(OciRegistryClient.BundleLayerMediaType); - layer.Digest.Should().Be(registry.BundleDigest); - manifest.Config!.MediaType.Should().Be(OciRegistryClient.PublicationConfigMediaType); - - var buffer = new MemoryStream(); - var receipt = await client.GetBlob(Repository, layer.Digest, () => buffer) - .FirstAsync().Timeout(Budget).Await(ct); - - receipt.Digest.Should().Be(registry.BundleDigest); - receipt.Size.Should().Be(BundleBytes.Length); - buffer.ToArray().Should().Equal(BundleBytes, "the destination holds exactly the layer's bytes"); - - registry.PresentedUser.Should().Be(OciRegistryClient.DefaultUsername, - "the registry edge ignores the user name, and every instance presents the same one"); - registry.PresentedSecret.Should().Be(Key, - "the credential is the plugin-registry token this installation already holds, as the Basic password"); - registry.TokenRequests.Should().Be(1, - "the bearer from the manifest's challenge is reused for the blob — one exchange per client, per scope"); - } - - [Fact(Timeout = 120_000)] - public async Task AManifestByTag_IsVerifiedAgainstTheDigestTheRegistryStates() - { - var ct = TestContext.Current.CancellationToken; - - var manifest = await Client().GetManifest(Repository, registry.Tag) - .FirstAsync().Timeout(Budget).Await(ct); - - manifest.Digest.Should().Be(registry.ManifestDigest); - } - - [Fact(Timeout = 120_000)] - public async Task ATamperedBlob_IsRefused() - { - var ct = TestContext.Current.CancellationToken; - registry.TamperBlob = true; - var buffer = new MemoryStream(); - - var fault = await Assert.ThrowsAsync(() => - Client().GetBlob(Repository, registry.BundleDigest, () => buffer) - .FirstAsync().Timeout(Budget).Await(ct)); - - fault.Expected.Should().Be(registry.BundleDigest); - fault.Actual.Should().NotBe(registry.BundleDigest); - fault.Message.Should().Contain("REFUSED"); - registry.BlobRequests.Should().Be(1, "a digest mismatch is never retried — the bytes are wrong, not late"); - } - - [Fact(Timeout = 120_000)] - public async Task ATamperedManifest_IsRefused() - { - var ct = TestContext.Current.CancellationToken; - registry.TamperManifest = true; - - var fault = await Assert.ThrowsAsync(() => - Client().GetManifest(Repository, registry.ManifestDigest) - .FirstAsync().Timeout(Budget).Await(ct)); - - fault.Expected.Should().Be(registry.ManifestDigest); - } - - /// - /// 🚨 MeshWeaver#4123 — the token realm is named by the registry's own 401, so a registry approved - /// for the key could otherwise send Basic instance:<key> to any host it names. The key - /// goes ONLY to the registry's own authority and scheme: a realm on another host, or the same host - /// downgraded to http, is refused BEFORE anything is sent there. The positive control is every - /// other case in this class, whose realm is the registry's own https://{Host}/v2/token. - /// - [Theory(Timeout = 120_000)] - [InlineData("https://evil.example/v2/token")] - [InlineData("https://cr.example.test.evil.example/v2/token")] - [InlineData("http://cr.example.test/v2/token")] - public async Task ATokenRealmOffTheRegistrysOwnAuthority_NeverReceivesTheKey(string realm) - { - var ct = TestContext.Current.CancellationToken; - registry.Realm = realm; - - var fault = await Assert.ThrowsAsync(() => - Client().GetManifest(Repository, registry.ManifestDigest) - .FirstAsync().Timeout(Budget).Await(ct)); - - fault.Message.Should().Contain("not sent there"); - hostile.Requests.Should().BeEmpty("the key is never presented at a realm the registry merely names"); - hostile.PresentedSecret.Should().BeNull(); - registry.TokenRequests.Should().Be(0, "a downgraded realm on the same host is refused too"); - registry.PresentedSecret.Should().BeNull("the key was presented nowhere"); - } - - [Fact(Timeout = 120_000)] - public async Task ARefusedCredential_Faults_NamingTheHost() - { - var ct = TestContext.Current.CancellationToken; - - var fault = await Assert.ThrowsAsync(() => - Client("mwi_not-this-registrys-key").GetManifest(Repository, registry.ManifestDigest) - .FirstAsync().Timeout(Budget).Await(ct)); - - fault.Message.Should().Contain("refused").And.Contain(Host); - registry.ManifestRequests.Should().Be(0, "nothing is served before the realm accepts the key"); - } - - [Fact(Timeout = 120_000)] - public async Task EveryTagPage_IsFollowed() - { - var ct = TestContext.Current.CancellationToken; - - var tags = await Client().ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct); - - tags.Should().Equal(registry.Tags); - registry.TokenRequests.Should().Be(1, "one exchange serves every page of one listing"); - } - - [Theory] - [InlineData("cr.meshweaver.cloud/plugins/Plugins/SocialMedia@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - "cr.meshweaver.cloud", "plugins/Plugins/SocialMedia", null, - "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")] - [InlineData("localhost:5000/plugins/Plugins:1.2.3-s1", "localhost:5000", "plugins/Plugins", "1.2.3-s1", null)] - [InlineData("cr.meshweaver.cloud/plugins/_releases", "cr.meshweaver.cloud", "plugins/_releases", null, null)] - public void AReferenceParses(string reference, string registry, string repository, string? tag, string? digest) - { - OciReference.TryParse(reference, out var parsed).Should().BeTrue(); - parsed!.Registry.Should().Be(registry); - parsed.Repository.Should().Be(repository); - parsed.Tag.Should().Be(tag); - parsed.Digest.Should().Be(digest); - parsed.ToString().Should().Be(reference); - } - - [Theory] - [InlineData("")] - [InlineData("no-slash")] - [InlineData("host/")] - [InlineData("host/repo@sha256:short")] - [InlineData("host/repo@md5:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")] - public void AMalformedReference_DoesNotParse(string reference) - { - OciReference.TryParse(reference, out _).Should().BeFalse(); - } -} diff --git a/test/Memex.Portal.Shared.Test/OciTagListerTest.cs b/test/Memex.Portal.Shared.Test/OciTagListerTest.cs deleted file mode 100644 index c169d0620f..0000000000 --- a/test/Memex.Portal.Shared.Test/OciTagListerTest.cs +++ /dev/null @@ -1,1090 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Reactive; -using System.Reactive.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The self-updater listing tags on the read-through MIRROR instead of ACR (#3353): the bearer -/// handshake the mirror speaks, walked end to end against an in-process registry; the -/// credential being this installation's own plugin-registry instance key and nothing new; every -/// page of a paginated listing being followed; and a refused or misconfigured credential being an -/// ERROR — never an empty list, which the poller would read as "nothing to roll to". -/// -/// The fake substitutes the NETWORK, not a MeshWeaver interface: it holds the protocol's -/// invariants (a 401 without a bearer, a 401 on a wrong key, a relative Link continuation) -/// rather than replaying recorded answers. The hub, the token resolver and the poller's decision -/// path are real. -/// -public class OciTagListerTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string MirrorHost = "mirror.example.test"; - private const string MirrorUrl = "https://" + MirrorHost; - private const string InstanceKey = "mwi_this-installations-own-plugin-registry-key"; - private const string Repository = "memex-portal-ai"; - - /// - /// The fleet's OTHER shape (#4093): a container registry on its own host, which is NOT a plugin - /// registry and never will be — cr.meshweaver.cloud decides a pull by forwarding the - /// caller's key to memex.meshweaver.cloud, so the two hosts differ by design. - /// - private const string RegistryHost = "cr.example.test"; - - /// The validator declaration that pairs with the plugin - /// registry this installation actually holds a key for — a registry record's - /// validationUrl, copied verbatim. - private const string DeclaredValidator = MirrorUrl + "/api/instances/token"; - - /// The key an AUTO-REGISTERED installation holds: issued at first boot against - /// PluginCatalog:BootstrapKey, stored at Admin/PluginRegistryCredential/…, and - /// configured NOWHERE — the zero-touch shape InstanceProvisioningPlan documents. Distinct - /// from so a presented secret proves WHERE it came from. - private const string StoredKey = "mwi_issued-at-first-boot-and-stored-in-the-admin-partition"; - - /// What the plugin registry's exchange route mints for a stored key — the short-lived - /// token RegistryTokenResolver.ResolveToken would present, which the fleet registry's - /// validator refuses BY DESIGN (a token may never mint its successor). - private const string ExchangedToken = "mwa_short-lived-token-the-validator-refuses"; - - /// A SECOND plugin registry this installation holds a key for, so that "the key held for - /// the DECLARED validator" and "some other registry's key" are different values. - private const string OtherRegistryHost = "a.example.test"; - private const string OtherRegistryUrl = "https://" + OtherRegistryHost; - private const string OtherRegistryKey = "mwi_issued-by-a-example-test-and-not-the-validator"; - - /// Strictly newer than anything this test host can run as, so the roll it drives is - /// the ordinary forward roll (the same shape ComboGateRollTest uses) — and the highest CD build - /// number in the fixture, because lineage orders by ci.N, not by the SemVer prefix. - private const string NewestTag = "9999.0.0-ci.3"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - private readonly FakeMirror mirror = new(); - - /// - /// This installation's plugin-catalog configuration — the singleton the lister resolves. The - /// default is the fleet's common shape (ONE registry, the mirror host, its key configured); a - /// test that needs another shape assigns Registries before it lists, and the lister reads - /// the instance at listing time, so no test depends on when the container first resolved it. - /// - private readonly PluginCatalogOptions catalog = new() - { - Registries = - [ - new PluginRegistryReference { Name = "Plugins", Url = MirrorUrl, Token = InstanceKey }, - ], - }; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddUpdatePolicyType() - // The credential STORE's node type, and only that — the same definition - // AddPluginCatalog registers, without the catalog's hosted services (the boot reconcile - // and the auto-registration pass would reach the fake at startup and count as requests - // the negatives assert are zero). The resolver reads it; the auto-registered test writes it. - .AddMeshNodes(new MeshNode(PluginRegistryCredentials.NodeType) - { - Name = "Plugin Registry Credential", - IsSatelliteType = false, - HubConfiguration = config => config - .AddMeshDataSource(s => s.WithContentType()), - }) - .ConfigureServices(s => s - .AddSingleton(new FakeMirrorClientFactory(mirror)) - // The resolver the Store uses — real, so "the same credential" is measured on the - // same code path rather than assumed. - .AddSingleton() - // The plugin registry this installation installs modules from IS the mirror host, - // and the key it holds for it is the credential the lister must present. - .AddSingleton(catalog)); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - private OciTagLister Lister(string registry = MirrorHost, string validator = "") => new( - Mesh, - new SelfUpdateOptions { Registry = registry, RegistryValidationUrl = validator }, - Mesh.ServiceProvider.GetService>()); - - // ══════════════════════════════════════════════════════════════════════════ - // The handshake, the credential, the pages - // ══════════════════════════════════════════════════════════════════════════ - - [Fact(Timeout = 120_000)] - public async Task ListsEveryPage_ThroughTheBearerHandshake_WithThePluginRegistryKey() - { - var ct = TestContext.Current.CancellationToken; - - var tags = await Lister().ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct); - - tags.Should().Equal(FakeMirror.Tags, - "the listing is paginated and every page must be followed — ACR pages at 100 in lexical " - + "order, so a lister that stops after the first page sees the OLDEST builds only"); - mirror.PresentedSecret.Should().Be(InstanceKey, - "the credential is the plugin-registry instance key this installation already holds, " - + "presented as Basic user:key at the realm the challenge names — no second secret"); - mirror.TokenRequests.Should().Be(1, "one exchange serves every page of one listing"); - mirror.PagesServed.Should().BeGreaterThan(1, "the fixture pages so that pagination is observable"); - } - - [Fact(Timeout = 120_000)] - public async Task ARefusedKey_FaultsTheListing_NeverAnEmptyList() - { - var ct = TestContext.Current.CancellationToken; - mirror.RefuseKey = true; - - var fault = await Assert.ThrowsAsync(() => - Lister().ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - fault.Message.Should().Contain("refused", - "an empty listing would read as 'nothing to roll to' — the one silent state #2553 forbids"); - fault.Message.Should().Contain(MirrorHost); - } - - [Fact(Timeout = 120_000)] - public async Task NoPluginRegistryOnThatHost_FaultsNamingTheHost() - { - var ct = TestContext.Current.CancellationToken; - - var fault = await Record.ExceptionAsync(() => - Lister("other.example.test").ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - // 🚨 Disclosure FIRST, so a falsification reports WHAT LEAKED rather than "no exception". - mirror.CredentialsSeen.Should().BeEmpty( - "the guard is a disclosure control: an arbitrary host named in SelfUpdate:Registry must " - + "never be handed this installation's instance key"); - mirror.Requests.Should().Be(0, "a listing with no credential could only ever be a 401"); - var message = fault.Should().BeOfType().Which.Message; - message.Should().Contain("other.example.test"); - message.Should().Contain("PluginCatalog", - "the fix is a configuration change, and the message has to say which one"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // #4093 — the registry whose validator is ANOTHER portal - // - // The fleet's registry decides a pull by forwarding the caller's key to a portal - // (cr.meshweaver.cloud → memex.meshweaver.cloud/api/instances/token), so the image registry - // and the plugin registry are DIFFERENT hosts by design. These three pin the whole rule: a - // DECLARED pairing resolves the key, and the two ways of not declaring one still refuse. - // Delete the host check in ResolveCredential and the two negatives below go red. - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 THE FIX. The container registry is on its own host and is no plugin registry; the - /// installation DECLARES the portal that validates its key there, and the key it already holds - /// for that portal is what the listing presents. - /// - [Fact(Timeout = 120_000)] - public async Task ADeclaredValidator_PresentsTheKeyHeldForThatPortal_AndListsTheRegistry() - { - var ct = TestContext.Current.CancellationToken; - - var tags = await Lister(RegistryHost, DeclaredValidator) - .ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct); - - tags.Should().Equal(FakeMirror.Tags, - "an instance provisioned on the fleet registry must be able to see what it can roll to"); - mirror.PresentedSecret.Should().Be(InstanceKey, - "the credential is the plugin-registry instance key this installation already holds for " - + "the DECLARED VALIDATOR — the registry forwards it there to decide the pull, which is " - + "exactly why the two hosts differ"); - mirror.ReachedHosts.Should().OnlyContain(h => h == RegistryHost, - "the listing goes to the container registry; the validator is named in configuration " - + "and never contacted by the lister — and this is EVERY host reached, recorded before " - + "the fake decides what it serves, so a request to a third host cannot hide"); - } - - /// - /// 🚨 THE FIX, for the installation the fix was FOR (#4094 review). A zero-touch consumer — - /// PluginCatalog:BootstrapKey, no configured Token — holds its mwi_ key only in the - /// store, and the Store's resolver EXCHANGES that key for a short-lived mwa_ token before - /// presenting it. The fleet registry decides a pull by forwarding the presented secret to the - /// declared validator, which IS the exchange endpoint and refuses a token by design — so the - /// lister must present the durable key itself, unexchanged. A lister that resolved through - /// ResolveToken here presents , is refused on every check, and - /// fixes only the installations with a raw Token configured. - /// - [Fact(Timeout = 120_000)] - public async Task AnAutoRegisteredInstallation_PresentsItsStoredDurableKey_NeverAnExchangedToken() - { - var ct = TestContext.Current.CancellationToken; - // The zero-touch shape: the registry is configured, its key is NOT — it lives in the store. - catalog.Registries = [new PluginRegistryReference { Name = "Plugins", Url = MirrorUrl }]; - await StoreCredential(MirrorUrl, StoredKey); - // The registry's validator knows the key it issued, and nothing else — in particular not a - // token minted FROM it, which `InstanceTokenEndpoints` refuses by shape. - mirror.AcceptedSecrets = [StoredKey]; - - IReadOnlyList? tags = null; - var fault = await Record.ExceptionAsync(async () => - tags = await Lister(RegistryHost, DeclaredValidator) - .ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - // 🚨 The MECHANISM first, so a regression reports what was presented rather than only the - // 401 it earns: a lister resolving through ResolveToken exchanges the stored key (one - // exchange request) and presents the mwa_ token, which the registry's validator refuses. - mirror.ExchangeRequests.Should().Be(0, - "the declared validator IS the key-to-token exchange; exchanging first would hand the " - + "registry a token its validator refuses by design, on every check"); - mirror.PresentedSecret.Should().Be(StoredKey, - "the credential is the DURABLE mwi_ key from the store — configured nowhere, so it can " - + "only have come from there — and never the mwa_ token the Store would present"); - mirror.PresentedSecret.Should().StartWith("mwi_"); - fault.Should().BeNull("the listing must succeed for an auto-registered instance"); - tags.Should().Equal(FakeMirror.Tags, - "an auto-registered instance on the fleet registry must be able to see what it can roll to"); - mirror.ReachedHosts.Should().OnlyContain(h => h == RegistryHost, - "the key goes to the container registry only; the store is read in-process"); - } - - /// - /// 🚨 SELECTION, not merely resolution (#4094 review). With ONE registry configured nothing - /// distinguishes "the key held for the DECLARED validator" from "some registry's key", so a - /// lister that took the first configured registry would pass the positive above. Two - /// registries, two keys, the validator declared as the SECOND: the key presented must be B's. - /// - [Fact(Timeout = 120_000)] - public async Task ADeclaredValidator_SelectsTheKeyHeldForThatPortal_NotAnotherRegistrys() - { - var ct = TestContext.Current.CancellationToken; - catalog.Registries = - [ - new PluginRegistryReference { Name = "A", Url = OtherRegistryUrl, Token = OtherRegistryKey }, - new PluginRegistryReference { Name = "B", Url = MirrorUrl, Token = InstanceKey }, - ]; - // The fake accepts BOTH keys on purpose: a wrong selection must report as the wrong SECRET, - // not as a refusal the reader could attribute to the fixture. - mirror.AcceptedSecrets = [OtherRegistryKey, InstanceKey]; - - var tags = await Lister(RegistryHost, DeclaredValidator) - .ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct); - - tags.Should().Equal(FakeMirror.Tags); - mirror.PresentedSecret.Should().Be(InstanceKey, - "the key presented is the one held for the DECLARED validator (B), selected by host — " - + "never the first configured registry's, never 'a key this installation happens to hold'"); - mirror.PresentedSecret.Should().NotBe(OtherRegistryKey); - mirror.ReachedHosts.Should().OnlyContain(h => h == RegistryHost); - } - - /// - /// 🚨 NEGATIVE CONTROL, and the one that proves the guard still guards: the SAME registry host - /// as the test above, differing only in that nothing declares the pairing. It must refuse, and - /// the key must not leave. A rule that merely dropped or loosened the host comparison — "the - /// sole mount carries a key", "same registrable domain" — turns this green. - /// - [Fact(Timeout = 120_000)] - public async Task TheSameRegistry_WithNoDeclaredValidator_IsStillRefused() - { - var ct = TestContext.Current.CancellationToken; - - var fault = await Record.ExceptionAsync(() => - Lister(RegistryHost).ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - mirror.CredentialsSeen.Should().BeEmpty( - "an undeclared pairing is not permission — the key never goes out"); - mirror.Requests.Should().Be(0); - var message = fault.Should().BeOfType().Which.Message; - message.Should().Contain(RegistryHost); - message.Should().Contain("SelfUpdate:RegistryValidationUrl", - "an absent declaration is refused AND the message says what would declare it"); - } - - /// - /// 🚨 NEGATIVE CONTROL: a declaration ALONE grants nothing. The validator named is a host this - /// installation holds no plugin-registry key for, so there is still nothing to present — the - /// pairing needs BOTH statements, and the refusal names the host that is missing. - /// - [Fact(Timeout = 120_000)] - public async Task ADeclaredValidatorThisInstallationHoldsNoKeyFor_IsRefused() - { - var ct = TestContext.Current.CancellationToken; - - var fault = await Record.ExceptionAsync(() => - Lister(RegistryHost, "https://someone-elses-portal.example.test/api/instances/token") - .ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - mirror.CredentialsSeen.Should().BeEmpty( - "the key is only ever presented to a host this installation was issued one for"); - mirror.Requests.Should().Be(0); - var message = fault.Should().BeOfType().Which.Message; - message.Should().Contain("someone-elses-portal.example.test"); - message.Should().Contain("PluginCatalog:Registries"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // #4093 — a refusal that NAMES the host the operator has to declare - // - // The rule is unchanged and stays unchanged: nothing below presents a key. What changes is - // that the refusal stops sending an operator to look up a value their own PluginCatalog - // already holds. Every test here asserts the disclosure control FIRST, so a change that turned - // "we know which host you hold" into "so we will use it" — the rule rejected for #4093 — goes - // red on the credential, not on a string. - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The fleet's shape with nothing declared: the refusal NAMES the one plugin registry this - /// installation is configured for, so the operator can write - /// SelfUpdate:RegistryValidationUrl without going to look the value up — and still - /// refuses. The two halves are the whole point: naming a host is not presenting a key to it. - /// - [Fact(Timeout = 120_000)] - public async Task AnUndeclaredPairing_NamesTheOnePluginRegistryHeld_AndStillRefuses() - { - var ct = TestContext.Current.CancellationToken; - - var fault = await Record.ExceptionAsync(() => - Lister(RegistryHost).ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - // 🚨 The control FIRST — a falsification must report the DISCLOSURE, never a missing word. - mirror.CredentialsSeen.Should().BeEmpty( - "knowing which host this installation holds a key for is not permission to present it — " - + "the message names the host, the check still refuses"); - mirror.Requests.Should().Be(0); - var message = fault.Should().BeOfType().Which.Message; - message.Should().Contain(MirrorHost, - "the operator is told to declare a validator, and the host they would declare is in " - + "their own PluginCatalog — a refusal that withholds it sends them off to find it"); - message.Should().Contain("SelfUpdate:RegistryValidationUrl", - "naming the host must not read as 'so we will use it': the key that closes this is still named"); - } - - /// - /// 🚨 BOUNDED AT TWO. With more than one registry configured the refusal names them ALL and - /// selects none — "there is exactly one, so present it" is the rule #4093 proposed and the - /// maintainer declined, and an installation holding two keys is precisely where selecting one - /// would hand registry A's credential to a host only B vouches for. - /// - [Fact(Timeout = 120_000)] - public async Task TwoConfiguredRegistries_AreBothNamed_AndNeitherIsPresented() - { - var ct = TestContext.Current.CancellationToken; - catalog.Registries = - [ - new PluginRegistryReference { Name = "A", Url = OtherRegistryUrl, Token = OtherRegistryKey }, - new PluginRegistryReference { Name = "B", Url = MirrorUrl, Token = InstanceKey }, - ]; - - var fault = await Record.ExceptionAsync(() => - Lister(RegistryHost).ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - mirror.CredentialsSeen.Should().BeEmpty("neither key is selected, so neither leaves"); - mirror.Requests.Should().Be(0); - var message = fault.Should().BeOfType().Which.Message; - message.Should().Contain(MirrorHost).And.Contain(OtherRegistryHost, - "with two candidates the operator picks — the refusal lists them rather than guessing"); - } - - /// - /// 🚨 THE BOUND IS EXERCISED (review of #4745). The cap on how many hosts a refusal names was - /// introduced with this reader and every other case here has at most two, so nothing observed - /// it: a regression that truncated SILENTLY, or dropped the overflow instead of counting it, - /// passed them all — *"a verification step that cannot fail is not a verification step"*. - /// - /// Nine readable registries, asserted from both sides of the boundary: the first eight are - /// named, the ninth is NOT, and the count of what was left out is in the message. A list that - /// looks complete when it is not is the same defect class this whole change is about, and this - /// string lands in Loki and on Admin/UpdatePolicy. - /// - [Fact(Timeout = 120_000)] - public async Task MoreHostsThanTheRefusalNames_AreCounted_NeverSilentlyDropped() - { - var ct = TestContext.Current.CancellationToken; - // Nine, named so that ordinal-case-insensitive ordering is the obvious r1…r9 and the test - // reads the same way the message does. - catalog.Registries = Enumerable.Range(1, 9) - .Select(i => new PluginRegistryReference { Name = $"R{i}", Url = $"https://r{i}.example.test" }) - .ToList(); - - var fault = await Record.ExceptionAsync(() => - Lister(RegistryHost).ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - mirror.CredentialsSeen.Should().BeEmpty("nine candidates select nothing, exactly as two do"); - var message = fault.Should().BeOfType().Which.Message; - message.Should().Contain("r1.example.test").And.Contain("r8.example.test", - "the hosts the message names are the first eight in a deterministic order"); - message.Should().NotContain("r9.example.test", - "past the cap the hosts are counted, not named — otherwise a pathological configuration " - + "grows a log line without bound"); - message.Should().Contain("(and 1 more)", - "🚨 the overflow is COUNTED. Dropped silently, the operator reads a complete-looking list " - + "that is not one — which is the defect class this change exists to remove"); - } - - /// - /// 🚨 ZERO configured registries is its OWN sentence, not an empty list. "Declare the validator" - /// is the wrong instruction for an installation that holds no key at all: the first act is to - /// configure PluginCatalog, and a message naming neither leaves the operator nothing to do. - /// - [Fact(Timeout = 120_000)] - public async Task NoConfiguredRegistryAtAll_SaysSo_RatherThanNamingNothing() - { - var ct = TestContext.Current.CancellationToken; - catalog.Registries = []; - - var fault = await Record.ExceptionAsync(() => - Lister(RegistryHost).ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - mirror.CredentialsSeen.Should().BeEmpty(); - mirror.Requests.Should().Be(0); - var message = fault.Should().BeOfType().Which.Message; - message.Should().Contain("no plugin registry whose URL names a host", - "an installation holding nothing is told that, not told to declare a pairing it cannot hold"); - message.Should().Contain("PluginCatalog:Registries"); - } - - /// - /// 🚨 SPOT THE TYPO. A declaration resolving to a host this installation holds no registry for is - /// one character away from working, and the refusal now shows both sides — what was declared and - /// what is actually configured — so "add a registry" and "fix the declaration" stop looking - /// alike. #4093's own two values differ only by a path (/api/instances/token), which - /// HostOf drops, so a mismatch that reaches here is always a real one. - /// - [Fact(Timeout = 120_000)] - public async Task ADeclarationNamingAnUnheldHost_AlsoNamesWhatThisInstallationDoesHold() - { - var ct = TestContext.Current.CancellationToken; - - var fault = await Record.ExceptionAsync(() => - Lister(RegistryHost, "https://mirrror.example.test/api/instances/token") - .ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - mirror.CredentialsSeen.Should().BeEmpty( - "a declaration naming a host this installation holds no key for grants nothing"); - mirror.Requests.Should().Be(0); - var message = fault.Should().BeOfType().Which.Message; - message.Should().Contain("mirrror.example.test", "what was declared"); - message.Should().Contain(MirrorHost, "and what is actually configured, beside it"); - message.Should().Contain("SelfUpdate:RegistryValidationUrl"); - } - - /// - /// 🚨 A MOUNT WHOSE URL CARRIES A CREDENTIAL IS NEVER NAMED. The listed hosts are read through - /// SelfUpdateOptions.HostOf, which refuses userinfo — so such a registry contributes - /// NOTHING to the message rather than being echoed with its key in it. The mount here is for a - /// THIRD host, so the existing credential-in-URL diagnosis (which fires only for the target or - /// its declared validator) cannot mask this: the message reaching the assertion is the one this - /// reader composed. - /// - [Fact(Timeout = 120_000)] - public async Task AMountWhoseUrlCarriesACredential_IsNeverEchoed_AndIsNotNamed() - { - var ct = TestContext.Current.CancellationToken; - const string secretInUrl = "mwi_never-this-value-in-a-message"; - catalog.Registries = - [ - new PluginRegistryReference - { - Name = "Third", Url = $"https://instance:{secretInUrl}@third.example.test", - }, - new PluginRegistryReference { Name = "Plugins", Url = MirrorUrl, Token = InstanceKey }, - ]; - - var fault = await Record.ExceptionAsync(() => - Lister(RegistryHost).ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - mirror.CredentialsSeen.Should().BeEmpty(); - var message = fault.Should().BeOfType().Which.Message; - message.Should().NotContain(secretInUrl, - "this string reaches Loki and Admin/UpdatePolicy — a credential may never be in it"); - message.Should().NotContain("third.example.test", - "a URL carrying userinfo names no host anywhere in this platform, so it names none here " - + "either — printing the host would be one edit away from printing the URL"); - message.Should().Contain(MirrorHost, "the readable mount is still named"); - } - - /// - /// 🚨 NEGATIVE CONTROL for the DIAGNOSIS (#4094 review). A declaration that is SET but names no - /// http(s) host — a userinfo-bearing value, a typo'd scheme — used to read as ABSENT: the - /// refusal told the operator to declare the key they had already set. It is refused as - /// MALFORMED, the message names the key and the shape, and the value is never echoed, because - /// the userinfo is where a key would be. - /// - [Theory(Timeout = 120_000)] - [InlineData("https://memex.example.test@evil.example.test/api/instances/token")] - [InlineData("mailto:ops@example.test")] - [InlineData("htps://memex.example.test/api/instances/token")] - public async Task ADeclaredButUnreadableValidator_IsRefusedAsMalformed_NeverAsAbsent(string declared) - { - var ct = TestContext.Current.CancellationToken; - - var fault = await Record.ExceptionAsync(() => - Lister(RegistryHost, declared).ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - mirror.CredentialsSeen.Should().BeEmpty(); - mirror.Requests.Should().Be(0); - var message = fault.Should().BeOfType().Which.Message; - message.Should().Contain("SelfUpdate:RegistryValidationUrl is set but does not name an http(s) host", - "a set-but-unreadable declaration is a misconfiguration to NAME, never 'nothing declared'"); - message.Should().NotContain("no validator is declared", - "telling the operator to set a key they already set is a fail-closed fallback forging a " - + "correct-looking bug"); - message.Should().NotContain("evil.example.test", "the value is not echoed"); - message.Should().NotContain("ops@example.test"); - } - - /// - /// 🚨 NEGATIVE CONTROL for the OTHER diagnosis (#4094 review): a plugin registry that EXISTS on - /// the qualifying host but whose URL carries credentials (https://instance:mwi_…@host). - /// HostOf refuses userinfo, so such a registry stops matching on BOTH branches — the - /// pre-#4094 reader tolerated it — and without this control the refusal would claim "no plugin - /// registry is configured on that host" about a registry the catalog is talking to. The cause - /// is named, the fix is named, and the URL is not echoed: the credential is in it. - /// - [Theory(Timeout = 120_000)] - [InlineData(MirrorHost, "")] // the registry's OWN host - [InlineData(RegistryHost, DeclaredValidator)] // the declared validator — the fleet's shape - public async Task APluginRegistryWhoseUrlCarriesCredentials_IsRefusedNamingTheCause_NeverAsAbsent( - string registry, string validator) - { - var ct = TestContext.Current.CancellationToken; - catalog.Registries = - [ - new PluginRegistryReference - { - Name = "Plugins", Url = $"https://instance:{InstanceKey}@{MirrorHost}", Token = InstanceKey, - }, - ]; - - var fault = await Record.ExceptionAsync(() => - Lister(registry, validator).ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - mirror.CredentialsSeen.Should().BeEmpty( - "a registry whose URL is not read as naming a host is not a credential source either"); - mirror.Requests.Should().Be(0); - var message = fault.Should().BeOfType().Which.Message; - message.Should().Contain("carries credentials", - "the registry exists; what is wrong is where its credential sits, and the message says so"); - message.Should().Contain("PluginCatalog:Registries:N:Token", "the fix is named"); - message.Should().NotContain("no plugin registry", - "'no registry configured' must never be the diagnosis for a registry that exists"); - message.Should().NotContain(InstanceKey); - message.Should().NotContain("mwi_"); - } - - /// - /// 🚨 NEGATIVE CONTROL for the TARGET, not the declaration (Copilot review on #4094). A - /// `SelfUpdate:Registry` carrying userinfo is a valid URI whose host is the LAST one — - /// `OciRegistryClient` would build `https://instance:mwi_…@evil…/` and hand it the Basic - /// credential — and the raw value is interpolated into that client's errors and this class's - /// audit line. Host equality could never match such a value, so it is the declared-validator - /// branch that would make it reachable; this refusal is what stops that branch opening a - /// disclosure path. The refusal must ALSO not echo the value, which is where the key would be. - /// - [Fact(Timeout = 120_000)] - public async Task ARegistryTargetCarryingUserinfo_IsRefused_AndNeverEchoed() - { - var ct = TestContext.Current.CancellationToken; - const string smuggled = "instance:mwi_smuggled-in-the-registry-value"; - - var fault = await Record.ExceptionAsync(() => - Lister($"{smuggled}@evil.example.test", DeclaredValidator) - .ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - mirror.CredentialsSeen.Should().BeEmpty( - "a declared validator must never make an unparseable target reachable — and this " - + "assertion is recorded before the fake's host check, so it can see a credential sent " - + "to evil.example.test, which PresentedSecret by construction could not"); - mirror.Requests.Should().Be(0); - var message = fault.Should().BeOfType().Which.Message; - message.Should().NotContain(smuggled, - "the refusal must not echo a value of this shape — the userinfo is where a key would be"); - message.Should().NotContain("mwi_"); - message.Should().Contain("SelfUpdate:Registry"); - } - - /// - /// The same normalization closes an ASYMMETRY: the target was compared raw against plugin - /// hosts parsed by HostOf, so a URL form missed a registry that IS the same host. It is - /// refused rather than silently accepted, because the identical value is interpolated into the - /// portal and migration image references, which a URL form makes malformed. - /// - [Theory(Timeout = 120_000)] - [InlineData("https://" + MirrorHost)] - [InlineData(MirrorHost + "/v2")] - [InlineData("https://" + MirrorHost + ":443/v2/")] - public async Task AUrlFormRegistryTarget_IsRefused_NamingTheBareHostToUse(string target) - { - var ct = TestContext.Current.CancellationToken; - - var fault = await Record.ExceptionAsync(() => - Lister(target).ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct)); - - mirror.Requests.Should().Be(0, "the refusal is decided before a byte is sent"); - mirror.CredentialsSeen.Should().BeEmpty(); - var message = fault.Should().BeOfType().Which.Message; - message.Should().Contain(MirrorHost, "the message names the value to set instead"); - message.Should().Contain("bare registry host"); - message.Should().Contain("carries a scheme or a path", "and the message must be TRUE of the value"); - } - - /// - /// The documentation promises host or host:port, and 443 is a port (#4094 review). - /// HostOf drops a scheme-default port, so a bare-host check written as equality with the - /// parsed host refused host:443 — with a message claiming it "carries a scheme or a - /// path", which it does not. The check is textual on the configured value; the client and the - /// credential match then use the normalized host, which is what the plugin registry is - /// configured under. - /// - [Fact(Timeout = 120_000)] - public async Task ABareHostNamingTheDefaultPort_IsAccepted_AndListed() - { - var ct = TestContext.Current.CancellationToken; - - var tags = await Lister($"{MirrorHost}:443").ListTags(Repository).FirstAsync().Timeout(Budget).Await(ct); - - tags.Should().Equal(FakeMirror.Tags, "'host:port' is the documented shape, whatever the port"); - mirror.PresentedSecret.Should().Be(InstanceKey, - "the plugin registry configured as https://host is the same host as host:443"); - mirror.ReachedHosts.Should().OnlyContain(h => h == MirrorHost); - } - - /// The declaration is read WHOLE-HOST or not at all — never a suffix, a registrable - /// domain or anything else that could make a coincidence of naming look like a grant. - [Fact] - public void TheDeclaredValidatorHost_IsReadWholeOrNotAtAll() - { - new SelfUpdateOptions().RegistryValidatorHost.Should().BeNull( - "nothing declared is never permission"); - new SelfUpdateOptions { RegistryValidationUrl = " " }.RegistryValidatorHost.Should().BeNull(); - new SelfUpdateOptions { RegistryValidationUrl = "https://memex.meshweaver.cloud/api/instances/token" } - .RegistryValidatorHost.Should().Be("memex.meshweaver.cloud", "only the host is read"); - new SelfUpdateOptions { RegistryValidationUrl = "MEMEX.meshweaver.cloud" } - .RegistryValidatorHost.Should().Be("memex.meshweaver.cloud", - "a bare host means the same portal, and host names fold case"); - new SelfUpdateOptions { RegistryValidationUrl = "https://memex.meshweaver.cloud:8443/x" } - .RegistryValidatorHost.Should().Be("memex.meshweaver.cloud:8443", - "a non-default port is part of the host"); - new SelfUpdateOptions { RegistryValidationUrl = "https://memex.meshweaver.cloud@evil.example.test/x" } - .RegistryValidatorHost.Should().BeNull( - "the host there is evil.example.test and it reads to a human as the opposite — a " - + "value that can be misread that way declares no pairing at all"); - new SelfUpdateOptions { RegistryValidationUrl = "mailto:ops@example.test" } - .RegistryValidatorHost.Should().BeNull("a value that names no http host declares nothing"); - } - - /// "Declared" and "readable" are two questions (#4094 review): a set-but-unreadable - /// value is DECLARED and yields no host, and a caller refusing on the null host must be able to - /// say which of the two it is refusing on. - [Fact] - public void ADeclaration_IsToldApartFromAReadableOne() - { - new SelfUpdateOptions().RegistryValidatorDeclared.Should().BeFalse(); - new SelfUpdateOptions { RegistryValidationUrl = " " }.RegistryValidatorDeclared.Should().BeFalse( - "whitespace is not a declaration"); - var malformed = new SelfUpdateOptions { RegistryValidationUrl = "https://memex.example.test@evil.example.test/x" }; - malformed.RegistryValidatorDeclared.Should().BeTrue("the operator SET it"); - malformed.RegistryValidatorHost.Should().BeNull("and it names no host — malformed, not absent"); - var declared = new SelfUpdateOptions { RegistryValidationUrl = DeclaredValidator }; - declared.RegistryValidatorDeclared.Should().BeTrue(); - declared.RegistryValidatorHost.Should().Be(MirrorHost); - } - - [Fact] - public void AnAzureContainerRegistryHost_KeepsTheAcrLister() - { - new SelfUpdateOptions().RegistryIsAzureContainerRegistry.Should().BeTrue("the default is ACR"); - new SelfUpdateOptions { Registry = "MESHWEAVER.AZURECR.IO" }.RegistryIsAzureContainerRegistry - .Should().BeTrue("host names are case-insensitive"); - new SelfUpdateOptions { Registry = "memex.meshweaver.cloud" }.RegistryIsAzureContainerRegistry - .Should().BeFalse("the mirror is an OCI Distribution registry, listed through /v2"); - new SelfUpdateOptions { Registry = "memex.meshweaver.cloud/" }.RegistryIsAzureContainerRegistry - .Should().BeFalse(); - } - - // ══════════════════════════════════════════════════════════════════════════ - // The poller's selection: a mirror install never talks to ACR - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The integration claim. With SelfUpdate:Registry naming the mirror, ONE check - /// lists through the mirror, finds the newer release and rolls to it — and the ACR seam is - /// never touched (it throws if it is). The roll names the image on the mirror host, which is - /// what makes the pull secret on the pod spec matter. - /// - [Fact(Timeout = 240_000)] - public async Task AMirrorInstall_RollsFromTheMirrorListing_AndNeverCallsAcr() - { - var ct = TestContext.Current.CancellationToken; - await Seed(); - var updater = new RecordingUpdater(); - var options = new SelfUpdateOptions - { - Registry = MirrorHost, - RetryInterval = TimeSpan.FromMilliseconds(500), - EventCoalesceWindow = TimeSpan.FromMilliseconds(50), - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - }; - var service = new SeamedSelfUpdateService( - Mesh, new AcrMustNotBeCalled(), updater, options, - Mesh.ServiceProvider.GetService>(), - new AlwaysAvailable(Mesh, new ConfigurationBuilder().Build()), - new OciTagLister(Mesh, options, Mesh.ServiceProvider.GetService>())); - - await service.StartAsync(CancellationToken.None); - try - { - await service.Evaluations.FirstAsync().Timeout(Budget).Await(ct); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - - updater.Tags.Should().Contain(NewestTag, - "the newest release on the mirror is what a mirror-consuming install rolls to"); - updater.Images.Should().Contain($"{MirrorHost}/{Repository}:{NewestTag}", - "the image the updater rolls to is named on the mirror host — that is the pull the pod's pull secret exists for"); - mirror.PresentedSecret.Should().Be(InstanceKey); - } - - /// - /// 🚨 NEGATIVE CONTROL for the BOOT LINE (#4094 review, the serious one). The poller's startup - /// line is Information — it leaves the pod for Loki on every start, BEFORE the lister's - /// refusal (which deliberately does not echo the value) has ever run. Logging - /// SelfUpdate:Registry verbatim there shipped a value of the shape - /// instance:mwi_…@evil.example, key included, to Loki at boot. The line names the HOST - /// read from the value, or says it is unreadable — and it names the THIRD validator state (set - /// but unreadable) as such, never as "NO validator declared". - /// - [Fact(Timeout = 120_000)] - public async Task TheBootLine_NamesTheRegistryHost_NeverTheConfiguredValue() - { - const string smuggled = "instance:mwi_smuggled-in-the-registry-value"; - var log = new CapturingLogger(); - var options = new SelfUpdateOptions - { - Registry = $"{smuggled}@evil.example.test", - RegistryValidationUrl = "https://memex.example.test@evil.example.test/api/instances/token", - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - }; - var service = new SeamedSelfUpdateService( - Mesh, new AcrMustNotBeCalled(), new RecordingUpdater(), options, log, - new AlwaysAvailable(Mesh, new ConfigurationBuilder().Build()), - new OciTagLister(Mesh, options, Mesh.ServiceProvider.GetService>())); - - await service.StartAsync(TestContext.Current.CancellationToken); - try - { - var boot = log.Lines.Should().ContainSingle(l => l.Contains("[SelfUpdate] starting"), - "the startup line is written synchronously in StartAsync").Which; - boot.Should().NotContain(smuggled, - "the configured value is never logged — this line ships to Loki at boot, before any refusal"); - boot.Should().NotContain("mwi_"); - boot.Should().Contain("(unreadable", "an unreadable value is named as such, not printed"); - boot.Should().Contain("is SET but does not name an http(s) host", - "a declaration that is set but unreadable is the THIRD state, named as a misconfiguration"); - boot.Should().NotContain("NO validator declared", - "which is what it used to say, sending the operator to set a key they had already set"); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - - log.Lines.Should().NotContain(l => l.Contains("mwi_"), - "no line this service wrote while it ran carries anything shaped like the key"); - mirror.CredentialsSeen.Should().BeEmpty(); - } - - // ══════════════════════════════════════════════════════════════════════════ - // The fake mirror - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// The mirror's pull surface for tags/list, as its own tests pin it: a bare 401 with a - /// Bearer challenge until a bearer is presented, /v2/token answering the caller's OWN key - /// back for Basic user:key, and a listing paginated with a relative Link. - /// - private sealed class FakeMirror : HttpMessageHandler - { - public static readonly string[] Tags = ["3.0.0-ci.1", "3.0.0-ci.2", NewestTag, "latest"]; - - /// Two per page, whatever ?n= asks, so pagination is always exercised. - private const int PageSize = 2; - - public bool RefuseKey; - public int Requests; - public int TokenRequests; - public int ExchangeRequests; - public int PagesServed; - public string? PresentedSecret; - - /// - /// The secrets the registry's token realm accepts — what its validator would answer 200 for. - /// The fleet registry forwards the presented secret to /api/instances/token, which - /// accepts a durable mwi_ key it knows and REFUSES a token by shape, so a minted - /// is never in this set however it got there. - /// - public ImmutableHashSet AcceptedSecrets = [InstanceKey]; - - private ImmutableList reachedHosts = ImmutableList.Empty; - private ImmutableList credentialsSeen = ImmutableList.Empty; - - /// - /// 🚨 EVERY host a request reached, recorded UNCONDITIONALLY before the fake decides what it - /// serves. It used to be appended only for the hosts the fake knows, so a positive asserting - /// "only the container registry was contacted" could not see an un-credentialed request to a - /// third host — the same vacuity class as the credential one below, on the other assertion - /// (#4094 review). The listing must go to the CONTAINER registry, never to the portal whose - /// key authenticates it. - /// - public ImmutableList ReachedHosts => reachedHosts; - - /// - /// 🚨 Every host that was sent ANY credential, recorded BEFORE the host check — which is - /// what makes the disclosure assertions non-vacuous. is only - /// assigned inside the /v2/token branch, reached solely for the two hosts this fake - /// serves, so on its own it would read null even if the key HAD been sent to - /// evil.example.test — an assertion that cannot fail for exactly the host the - /// negatives exist to catch. This one can. - /// - public ImmutableList CredentialsSeen => credentialsSeen; - - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - Interlocked.Increment(ref Requests); - var uri = request.RequestUri!; - ImmutableInterlocked.Update(ref reachedHosts, current => current.Add(uri.Host)); - if (request.Headers.Authorization is { } offered) - ImmutableInterlocked.Update(ref credentialsSeen, - current => current.Add($"{offered.Scheme} to {uri.Host}")); - - // The plugin registry's key→token exchange (SyncTokenPayloads.Route): a durable key is - // exchanged for a short-lived token, exactly as RegistryTokenResolver.ResolveToken does - // for a STORED key. Served so that a lister which exchanges before presenting is SEEN to - // — it then presents the token, which the registry's realm below refuses, as the fleet's - // validator would. A lister that must not exchange leaves ExchangeRequests at zero. - if (uri.AbsolutePath == SyncTokenPayloads.Route && request.Method == HttpMethod.Post) - { - Interlocked.Increment(ref ExchangeRequests); - return Task.FromResult(request.Headers.Authorization is { Scheme: "Bearer", Parameter: { } key } - && key.StartsWith("mwi_", StringComparison.Ordinal) - ? Json($$"""{"accessToken":"{{ExchangedToken}}","tokenType":"Bearer","expiresIn":300,"scope":[]}""") - : new HttpResponseMessage(HttpStatusCode.Unauthorized)); - } - - // 🚨 AN UNKNOWN HOST BEHAVES LIKE A HOSTILE REGISTRY, NOT LIKE A 404. - // - // This is what makes the disclosure assertions real. The client presents Basic only - // AFTER a 401 Bearer challenge, so a fake that answered 404 here could never observe a - // leaked credential — and a negative asserting "no credential was seen" would pass by - // construction whatever the code did. (Measured: with the target guard deleted, that - // shape of fixture reported a clean PASS.) A real attacker-controlled host issues the - // challenge, so this one does too: it challenges, it serves /v2/token, and it records - // whatever secret is handed over. Only the tags listing stays restricted to the two - // hosts this fixture legitimately serves. - var known = uri.Host is MirrorHost or RegistryHost; - - if (uri.AbsolutePath == "/v2/token") - { - Interlocked.Increment(ref TokenRequests); - var header = request.Headers.Authorization; - string? secret = null; - if (header is { Scheme: "Basic", Parameter: { } basic }) - { - var pair = Encoding.UTF8.GetString(Convert.FromBase64String(basic)); - secret = pair[(pair.IndexOf(':') + 1)..]; - } - PresentedSecret = secret; - if (RefuseKey || secret is null || !AcceptedSecrets.Contains(secret)) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)); - // The mirror mints nothing: the bearer IS the caller's key. - return Task.FromResult(Json($$"""{"token":"{{secret}}"}""")); - } - - if (request.Headers.Authorization is not { Scheme: "Bearer", Parameter: { } bearer } - || !AcceptedSecrets.Contains(bearer)) - return Task.FromResult(Challenge(uri.Host)); - - if (known && uri.AbsolutePath == $"/v2/{Repository}/tags/list") - { - Interlocked.Increment(ref PagesServed); - var query = System.Web.HttpUtility.ParseQueryString(uri.Query); - var last = query["last"]; - var remaining = last is null ? Tags : Tags.SkipWhile(t => t != last).Skip(1).ToArray(); - var page = remaining.Take(PageSize).ToArray(); - var response = Json( - $$"""{"name":"{{Repository}}","tags":[{{string.Join(",", page.Select(t => $"\"{t}\""))}}]}"""); - if (page.Length < remaining.Length) - response.Headers.TryAddWithoutValidation( - "Link", $"; rel=\"next\""); - return Task.FromResult(response); - } - - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - private static HttpResponseMessage Challenge(string host) - { - var response = new HttpResponseMessage(HttpStatusCode.Unauthorized); - response.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", - $"realm=\"https://{host}/v2/token\",service=\"{host}\"")); - return response; - } - - private static HttpResponseMessage Json(string body) => - new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; - } - - private sealed class FakeMirrorClientFactory(HttpMessageHandler handler) : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); - } - - // ══════════════════════════════════════════════════════════════════════════ - // The poller harness (the seams SelfUpdateStrandRecoveryTest documents) - // ══════════════════════════════════════════════════════════════════════════ - - /// The ACR seam of a mirror install: reaching it IS the failure. - private sealed class AcrMustNotBeCalled : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - throw new InvalidOperationException( - "the ACR lister was called on an install whose SelfUpdate:Registry is the mirror"); - } - - private sealed class RecordingUpdater : IDeploymentUpdater - { - private ImmutableList tags = ImmutableList.Empty; - private ImmutableList images = ImmutableList.Empty; - - public ImmutableList Tags => tags; - public ImmutableList Images => images; - public bool CanPatch => true; - - public Task LastRolledAtAsync(CancellationToken ct) => - Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) - { - ImmutableInterlocked.Update(ref tags, current => current.Add(versionTag)); - ImmutableInterlocked.Update(ref images, current => current.Add( - new SelfUpdateOptions { Registry = MirrorHost }.PortalImage(versionTag))); - return Task.CompletedTask; - } - } - - private sealed class AlwaysAvailable(IMessageHub hub, IConfiguration configuration) - : ReleaseAvailabilityService(hub, configuration) - { - public override IObservable IsUpdatable(string? targetVersion) => - Observable.Return(UpdatabilityVerdict.NotEnforced("this test host consumes no CI bakes")); - } - - private sealed class SeamedSelfUpdateService( - IMessageHub hub, - IAcrTagLister acr, - IDeploymentUpdater updater, - SelfUpdateOptions options, - ILogger? logger, - ReleaseAvailabilityService gate, - OciTagLister oci) - : SelfUpdateHostedService(hub, acr, updater, options, logger, oci: oci) - { - public IObservable Evaluations => ChecksReported; - - protected override ReleaseAvailabilityService? ResolveAvailabilityGate() => gate; - - protected override ComboVerificationGate? ResolveComboGate() => null; - } - - /// Records every formatted line — instance state, owned by the test. - private sealed class CapturingLogger : ILogger - { - private ImmutableList lines = ImmutableList.Empty; - - public ImmutableList Lines => lines; - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - => ImmutableInterlocked.Update(ref lines, current => current.Add(formatter(state, exception))); - } - - /// - /// What auto-registration leaves behind: the issued key at - /// Admin/PluginRegistryCredential/{host-slug}, which RegistryTokenResolver reads as - /// System. Stored in the clear here — the protector passes a value without the enc: tag - /// through unchanged, and what this test measures is which SHAPE is presented, not the envelope. - /// - private Task StoreCredential(string registryUrl, string key) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var path = PluginRegistryCredentials.Path(registryUrl); - var node = new MeshNode(path.Split('/').Last(), PluginRegistryCredentials.Namespace) - { - NodeType = PluginRegistryCredentials.NodeType, - Name = "Registry credential (auto-registered)", - State = MeshNodeState.Active, - Content = new PluginRegistryCredential - { - RegistryUrl = registryUrl, - InstanceId = "this-installation", - ProtectedKey = key, - RegisteredAt = DateTimeOffset.UtcNow, - }, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - } - - private Task Seed() - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - // 2026-09-08: the newest tag is a ci build, eligible only under a pattern that admits it. - Content = new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, Pattern = "*-ci*" }, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - } -} diff --git a/test/Memex.Portal.Shared.Test/OgCardRendererTest.cs b/test/Memex.Portal.Shared.Test/OgCardRendererTest.cs deleted file mode 100644 index 105a758ad0..0000000000 --- a/test/Memex.Portal.Shared.Test/OgCardRendererTest.cs +++ /dev/null @@ -1,440 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Text.Json; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Mesh; -using SkiaSharp; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The fallback share card. These assert the properties a crawler and a CDN actually depend on — -/// it is a real PNG, it is exactly 1200×630, and the same node always renders byte-identically so -/// the endpoint's strong ETag is meaningful. -/// -public class OgCardRendererTest -{ - private static OgCardRenderer NewRenderer() => new("Memex"); - - /// PNG magic + IHDR width/height, read straight out of the bytes. - private static (int Width, int Height) PngSize(byte[] png) - { - Assert.True(png.Length > 24, "a card that short cannot be a PNG"); - Assert.Equal(new byte[] { 0x89, 0x50, 0x4E, 0x47 }, png.Take(4).ToArray()); - int Be(int at) => (png[at] << 24) | (png[at + 1] << 16) | (png[at + 2] << 8) | png[at + 3]; - return (Be(16), Be(20)); - } - - [Fact] - public void Card_IsA1200x630Png() - { - using var renderer = NewRenderer(); - - var png = renderer.Render( - "Claims", "Claims, moved to the age of agents.", "Insurance", "Claims"); - - var (width, height) = PngSize(png); - Assert.Equal(1200, width); - Assert.Equal(630, height); - } - - /// - /// The endpoint serves a strong ETag computed from these bytes, so an unchanged node must - /// render identically — otherwise every crawler refetch is a cache miss and the 304 path is - /// dead code. - /// - [Fact] - public void SameNode_RendersByteIdentically() - { - using var renderer = NewRenderer(); - - var first = renderer.Render("Underwriting", "The governed workflow.", "Insurance", "Underwriting"); - var second = renderer.Render("Underwriting", "The governed workflow.", "Insurance", "Underwriting"); - - Assert.Equal(first, second); - } - - /// A renamed node must produce a different card, or the ETag would serve the old one - /// forever. - [Fact] - public void ADifferentTitle_ChangesTheBytes() - { - using var renderer = NewRenderer(); - - var before = renderer.Render("Claims", "Same description.", "Insurance", "Claims"); - var after = renderer.Render("Claims Deepfield", "Same description.", "Insurance", "Claims"); - - Assert.NotEqual(before, after); - } - - /// - /// The inputs that actually occur in the mesh: no description, no category, a title long - /// enough to need shrinking, and a single unbreakable token. None may throw or produce a - /// degenerate image. - /// - [Theory] - [InlineData("Claims", null, null)] - [InlineData("Claims", "", "")] - [InlineData( - "A course about business rules that explain themselves to auditors, accountants and regulators alike", - "Business logic that a business person can read, an auditor can follow, and a developer can change without fear — the calculation behind an IFRS 17 analysis of change.", - "Education")] - [InlineData("Supercalifragilisticexpialidociousnesslikeaverylongunbrokentoken", "x", "Space")] - public void AwkwardContent_StillRendersAValidCard(string title, string? description, string? eyebrow) - { - using var renderer = NewRenderer(); - - var png = renderer.Render(title, description, eyebrow, title); - - var (width, height) = PngSize(png); - Assert.Equal(1200, width); - Assert.Equal(630, height); - } - - // ── The picture: the node's mark, or the default badge ───────────────────────────────── - - private const string RedTile = - ""; - - /// The pixel at the centre of the icon square, where the mark or the badge is drawn. - private static SKColor IconCentre(byte[] png) - { - using var bitmap = SKBitmap.Decode(png) ?? throw new InvalidOperationException("not a decodable PNG"); - return bitmap.GetPixel(OgCardRenderer.IconLeft + (OgCardRenderer.IconSize / 2), (int)OgCardRenderer.ContentCentreY); - } - - private static int Luminance(SKColor c) => ((c.Red * 299) + (c.Green * 587) + (c.Blue * 114)) / 1000; - - private static OgCardContent Content(string? iconSvg = null, string? price = null, string title = "Store") => - new() - { - Title = title, - Description = "Courses, domain plugins, agents and tools.", - Eyebrow = "Platform", - IconSvg = iconSvg, - Price = price, - Path = "Store", - AccentSeed = "Store", - }; - - /// - /// 🚨 THE POINT OF THE CARD. The Store shared into iMessage as a bare title beside the tiny - /// site favicon (2026-09-18): the card had no picture on it. A node's own mark is drawn LARGE - /// on the right — a red tile lands red pixels where the icon square is. - /// - [Fact] - public void WithAnIcon_TheMarkIsDrawnLarge() - { - using var renderer = NewRenderer(); - - var centre = IconCentre(renderer.Render(Content(iconSvg: RedTile))); - - Assert.True(centre.Red > 200 && centre.Green < 60 && centre.Blue < 60, - $"expected the red tile at the icon square's centre, got {centre}"); - } - - /// A node with no mark still shares with a picture: the default badge — a bright - /// accent tile carrying the page's initial — not the dark ground. - [Fact] - public void WithoutAnIcon_ADefaultBadgeIsDrawn() - { - using var renderer = NewRenderer(); - var png = renderer.Render(Content()); - - var centre = IconCentre(png); - using var bitmap = SKBitmap.Decode(png); - var ground = bitmap.GetPixel(OgCardRenderer.IconLeft + (OgCardRenderer.IconSize / 2), OgCardRenderer.Height - 6); - - Assert.Equal(255, centre.Alpha); - Assert.True(Luminance(centre) > 100, $"badge centre {centre} is as dark as the ground"); - Assert.True(Luminance(ground) < 60, $"the ground {ground} should be dark"); - } - - /// An authored icon that does not parse is a content defect, not a card failure: - /// the badge is drawn instead and the card is still a valid PNG. - [Theory] - [InlineData("")] - public void AnUnusableIcon_FallsBackToTheBadge(string svg) - { - using var renderer = NewRenderer(); - - var png = renderer.Render(Content(iconSvg: svg)); - - var (width, height) = PngSize(png); - Assert.Equal(1200, width); - Assert.Equal(630, height); - Assert.True(Luminance(IconCentre(png)) > 100, "no picture at all on the card"); - } - - [Fact] - public void APrice_AndAnIcon_ChangeTheBytes() - { - using var renderer = NewRenderer(); - - var plain = renderer.Render(Content()); - var priced = renderer.Render(Content(price: "CHF 490")); - var marked = renderer.Render(Content(iconSvg: RedTile)); - - Assert.NotEqual(plain, priced); - Assert.NotEqual(plain, marked); - } - - /// The instance card a non-node page shares with: a valid card that names the - /// instance and nothing else. - [Fact] - public void SiteCard_IsA1200x630Png_AndDiffersByHost() - { - using var renderer = NewRenderer(); - - var one = renderer.RenderSite("memex.meshweaver.cloud"); - var (width, height) = PngSize(one); - Assert.Equal(1200, width); - Assert.Equal(630, height); - Assert.NotEqual(one, renderer.RenderSite("memex.systemorph.com")); - Assert.NotEmpty(renderer.RenderSite(null)); - } - - /// An instance's own colour (Portal:InstanceColor) colours the mark on its cards — - /// violet pixels in the icon square where the public instance has cyan — and a malformed - /// colour keeps the brand cyan rather than failing the card. - [Theory] - [InlineData("#7b61ff", true)] - [InlineData("purple", false)] - [InlineData(null, false)] - public void TheInstanceColour_ColoursTheMark(string? colour, bool violet) - { - using var renderer = new OgCardRenderer("Systemorph", colour); - var png = renderer.RenderSite("memex.systemorph.com"); - using var bitmap = SKBitmap.Decode(png); - var left = OgCardRenderer.IconLeft; - var top = (int)(OgCardRenderer.ContentCentreY - (OgCardRenderer.IconSize / 2f)); - int violets = 0, cyans = 0; - for (var y = top; y < top + OgCardRenderer.IconSize; y += 2) - for (var x = left; x < left + OgCardRenderer.IconSize; x += 2) - { - var px = bitmap.GetPixel(x, y); - if (px.Blue > 200 && px.Red > 90 && px.Red < 150 && px.Green < 130) violets++; - if (px.Blue > 200 && px.Green > 170 && px.Red < 90) cyans++; - } - Assert.Equal(violet, violets > 200); - Assert.Equal(!violet, cyans > 200); - } - - /// The renderer's two mark drawings are the SAME drawing as the shipped icon - /// src/MeshWeaver.Graph/Icons/meshweaver-logo.svg (the portal's favicon and the tab icon - /// behind official third-party marks): the triangle path, the spokes path and the four node - /// centres. Whoever redraws the mark changes the icon and this renderer together, or this - /// goes red — the drift check Copilot asked for on #5441. - [Fact] - public void TheCardMark_IsTheShippedIconsDrawing() - { - var repoRoot = FindRepoRoot(); - var icon = System.IO.File.ReadAllText(System.IO.Path.Combine(repoRoot, "src", "MeshWeaver.Graph", "Icons", "meshweaver-logo.svg")); - foreach (var svg in new[] { OgCardRenderer.MeshWeaverMarkTile, OgCardRenderer.MeshWeaverMarkGlyph }) - { - foreach (var path in new[] { "M12 48 L32 14 L52 48 Z", "M32 38 L12 48 M32 38 L52 48 M32 38 L32 14" }) - { - Assert.Contains(path, icon); - Assert.Contains(path, svg); - } - foreach (var centre in new[] { "cx='12' cy='48'", "cx='52' cy='48'", "cx='32' cy='14'", "cx='32' cy='38'" }) - { - Assert.Contains(centre.Replace('\'', '"'), icon); - Assert.Contains(centre, svg); - } - } - } - - private static string FindRepoRoot() - { - var dir = new System.IO.DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !System.IO.File.Exists(System.IO.Path.Combine(dir.FullName, "MeshWeaver.slnx")) && !System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, ".git"))) - dir = dir.Parent; - return dir?.FullName ?? throw new InvalidOperationException("repository root not found above " + AppContext.BaseDirectory); - } - - /// Every card carries the mark in its footer, beside the instance name — a node's own - /// icon on the right does not take it away: cyan pixels sit in the footer's left corner of a - /// card drawn with an authored red icon. - [Fact] - public void EveryCard_CarriesTheMarkInItsFooter() - { - using var renderer = NewRenderer(); - var png = renderer.Render(Content() with - { - IconSvg = "", - }); - - using var bitmap = SKBitmap.Decode(png); - var cyan = 0; - for (var y = OgCardRenderer.Height - 90; y < OgCardRenderer.Height - 20; y++) - for (var x = 40; x < 120; x++) - { - var px = bitmap.GetPixel(x, y); - if (px.Blue > 200 && px.Green > 170 && px.Red < 90) cyan++; - } - Assert.True(cyan > 60, $"expected the cyan mark in the footer's left corner, found {cyan} cyan pixel(s)"); - } - - /// The instance card wears the MeshWeaver mark on its navy tile, not the default - /// badge with the site name's initial: the icon square's centre is the tile's navy, where the - /// badge would be a bright accent, and the mark's cyan is present inside the square. - [Fact] - public void SiteCard_CarriesTheMeshWeaverMark_NotTheInitialBadge() - { - using var renderer = NewRenderer(); - var png = renderer.RenderSite("memex.meshweaver.cloud"); - - using var bitmap = SKBitmap.Decode(png); - var left = OgCardRenderer.IconLeft; - var top = (int)(OgCardRenderer.ContentCentreY - (OgCardRenderer.IconSize / 2f)); - // Just inside the tile's top-left corner: navy, not the badge's accent gradient. - var corner = bitmap.GetPixel(left + 24, top + 24); - Assert.True(Luminance(corner) < 40, $"the tile corner {corner} should be the mark's navy ground"); - var cyan = 0; - for (var y = top; y < top + OgCardRenderer.IconSize; y += 2) - for (var x = left; x < left + OgCardRenderer.IconSize; x += 2) - { - var px = bitmap.GetPixel(x, y); - if (px.Blue > 200 && px.Green > 170 && px.Red < 90) cyan++; - } - Assert.True(cyan > 200, $"expected the cyan mark inside the icon square, found {cyan} cyan pixel(s)"); - } - - // ── The endpoint's mapping: what of the node reaches the card ────────────────────────── - - private static MeshNode Typed(string path, string nodeType, string? category, string? icon, object? content) => - new(path) { NodeType = nodeType, Category = category, Icon = icon, Content = content }; - - /// Everything the node can say reaches the card: name, description, category, its - /// backplated mark, a price with its currency, and the path. - [Fact] - public void CardContent_ReadsEverythingOffTheNode() - { - var node = Typed("Claims", "Store/Plugin", "Insurance", RedTile, - JsonSerializer.SerializeToElement(new { price = 490, currency = "CHF", description = "Claims, moved to the age of agents." })); - node = node with { Name = "Claims Deepfield" }; - - var card = SeoEndpoints.CardContent(node); - - Assert.Equal("Claims Deepfield", card.Title); - Assert.Equal("Claims, moved to the age of agents.", card.Description); - Assert.Equal("Insurance", card.Eyebrow); - Assert.Contains("fill='#ff0000'", card.IconSvg); - Assert.Equal("CHF 490", card.Price); - Assert.Equal("Claims", card.Path); - } - - /// Without a category the eyebrow is the type's LEAF — "Plugin", not "Store/Plugin"; - /// a free page carries no price chip; a URL icon is not inline markup and yields the badge. - [Fact] - public void CardContent_LeafType_NoPriceWhenFree_NoSvgForAUrlIcon() - { - var node = Typed("Edu", "Store/Plugin", null, "/api/content/Edu/icon.png", - JsonSerializer.SerializeToElement(new { price = 0 })); - - var card = SeoEndpoints.CardContent(node); - - Assert.Equal("Plugin", card.Eyebrow); - Assert.Null(card.Price); - Assert.Null(card.IconSvg); - Assert.Equal("Edu", card.Title); - } - - /// - /// The accent is the thing that makes a row of shared links read as one family: stable per - /// node (the same page always shares in the same colour) and drawn from the fixed palette, so - /// it can never land on mud. - /// - [Fact] - public void Accent_IsStablePerSeed_AndFromThePalette() - { - var once = OgCardRenderer.AccentFor("Claims"); - var twice = OgCardRenderer.AccentFor("Claims"); - Assert.Equal(once, twice); - - var seeds = new[] { "Claims", "Underwriting", "AgenticPrimer", "Edu", "Chess", "Pricing", "RolePlay" }; - var used = seeds.Select(OgCardRenderer.AccentFor).Distinct().Count(); - Assert.True(used > 1, "every page sharing in the same colour would defeat the point"); - } - - // ── The resolver side: which field the image comes from ──────────────────────────────── - - // Top-level node: Path is Id when Namespace is empty, which is the shape every store plugin has. - private static MeshNode Node(object content, string path = "Claims") => - new(path) { NodeType = "Store/Plugin", Content = content }; - - private sealed record PluginLike(string? OgImage = null, string? Poster = null, string? Thumbnail = null); - - /// - /// 🚨 THE BUG THIS FIXES. PluginContent declares ogImage; the resolver used to - /// read only poster/thumbnail, so every plugin's hand-made og.png was ignored - /// and no store page ever emitted an og:image at all. - /// - [Fact] - public void AnAuthoredOgImage_IsUsed() - { - var node = Node(new PluginLike(OgImage: "/api/content/Claims/content/og.png")); - - Assert.Equal("/api/content/Claims/content/og.png", SeoResolver.ExtractImage(node)); - } - - [Fact] - public void PosterAndThumbnail_StillWork_ForNonPluginNodes() - { - Assert.Equal("/api/content/X/poster.png", - SeoResolver.ExtractImage(Node(new PluginLike(Poster: "/api/content/X/poster.png")))); - Assert.Equal("/api/content/X/thumb.png", - SeoResolver.ExtractImage(Node(new PluginLike(Thumbnail: "/api/content/X/thumb.png")))); - } - - /// A page that authored nothing still shares with a card — that is the whole point. - [Fact] - public void WithNoAuthoredImage_ShareImageFallsBackToTheGeneratedCard() - { - var node = Node(new PluginLike(), "AgenticPrimer"); - - Assert.Null(SeoResolver.ExtractImage(node)); - Assert.Equal("/api/og/AgenticPrimer.png", SeoResolver.ShareImage(node)); - } - - /// An authored image always wins over the generated one. - [Fact] - public void AnAuthoredImage_BeatsTheGeneratedCard() - { - var node = Node(new PluginLike(OgImage: "/api/content/Claims/content/og.png")); - - Assert.Equal("/api/content/Claims/content/og.png", SeoResolver.ShareImage(node)); - } - - /// A bare filename cannot be a share image — it would resolve against whatever path - /// the crawler happened to fetch — so it falls through to the generated card. - /// The head declares a size only for the card the portal DRAWS — an authored image's - /// dimensions are unknown to it. - [Fact] - public void IsGeneratedCard_RecognisesTheDrawnCards_AndNotAuthoredImages() - { - Assert.True(SeoResolver.IsGeneratedCard("/api/og/Chess.png")); - Assert.True(SeoResolver.IsGeneratedCard("/api/og/Edu/Courses.png")); - Assert.True(SeoResolver.IsGeneratedCard(SeoResolver.SiteCard)); - Assert.False(SeoResolver.IsGeneratedCard("/api/content/Claims/content/og.png")); - // An authored ABSOLUTE url is never the portal's card, whatever its path says — declaring - // 1200×630 PNG for a banner on another host would be a lie the unfurler acts on. - Assert.False(SeoResolver.IsGeneratedCard("https://cdn.example/api/og/banner.jpg")); - Assert.False(SeoResolver.IsGeneratedCard("https://memex.meshweaver.cloud/api/og/Chess.png")); - Assert.False(SeoResolver.IsGeneratedCard(null)); - } - - [Fact] - public void ARelativeImage_IsRejected_AndFallsBack() - { - var node = Node(new PluginLike(OgImage: "og.png"), "Chess"); - - Assert.Null(SeoResolver.ExtractImage(node)); - Assert.Equal("/api/og/Chess.png", SeoResolver.ShareImage(node)); - } -} diff --git a/test/Memex.Portal.Shared.Test/OnboardingMiddlewareAbortedRequestTest.cs b/test/Memex.Portal.Shared.Test/OnboardingMiddlewareAbortedRequestTest.cs deleted file mode 100644 index a42c0616cb..0000000000 --- a/test/Memex.Portal.Shared.Test/OnboardingMiddlewareAbortedRequestTest.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Subjects; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #4859 — waits for its identity decision WITHOUT the request's -/// abort token (deliberately: the decision must not be abandoned half-way), so the request it resumes -/// may already be dead. On memex-cloud five such requests were handed to a JSON endpoint after -/// Kestrel's shutdown had aborted their connections and disposed its transport, and each response -/// write died on ObjectDisposedException: 'MemoryPool' with this middleware as the nearest -/// application frame. A dead request must not reach the endpoint, and an abort during host shutdown -/// must say so at Warning — the crash it replaces was the only trace a cut-short drain left. -/// -public class OnboardingMiddlewareAbortedRequestTest -{ - [Fact] - public async Task ARequestAbortedWhileItsDecisionIsInFlight_IsNotHandedToTheEndpoint() - { - var endpointRan = false; - var middleware = new OnboardingMiddleware( - _ => { endpointRan = true; return Task.CompletedTask; }, - new CapturingLogger()); - - using var aborted = new CancellationTokenSource(); - var context = new DefaultHttpContext { RequestAborted = aborted.Token }; - context.Request.Path = "/api/version"; - - await RunWithDecisionInFlight(middleware, context, aborted, abort: true); - - endpointRan.Should().BeFalse( - because: "the connection was aborted before the identity decision arrived, so there is " - + "nobody to answer — and when the abort was Kestrel's shutdown, the endpoint's " - + "response write lands in a disposed memory pool (#4859)"); - } - - [Fact] - public async Task ALiveRequestWhoseDecisionArrivesLate_IsStillHandedToTheEndpoint() - { - // The control for the test above: the SAME request, not aborted, must pass through — so the - // refusal above is about the abort and nothing else. - var endpointRan = false; - var middleware = new OnboardingMiddleware( - _ => { endpointRan = true; return Task.CompletedTask; }, - new CapturingLogger()); - - using var notAborted = new CancellationTokenSource(); - var context = new DefaultHttpContext { RequestAborted = notAborted.Token }; - context.Request.Path = "/api/version"; - - await RunWithDecisionInFlight(middleware, context, notAborted, abort: false); - - endpointRan.Should().BeTrue(because: "a request whose connection is alive must reach its endpoint"); - } - - [Fact] - public async Task AnAbortWhileTheHostIsStopping_IsReportedAtWarning() - { - var logger = new CapturingLogger(); - var middleware = new OnboardingMiddleware(_ => Task.CompletedTask, logger); - - using var lifetime = new StoppingLifetime(); - lifetime.Stop(); - using var aborted = new CancellationTokenSource(); - var context = new DefaultHttpContext - { - RequestAborted = aborted.Token, - RequestServices = new ServiceCollection() - .AddSingleton(lifetime) - .BuildServiceProvider(), - }; - context.Request.Path = "/api/version"; - - await RunWithDecisionInFlight(middleware, context, aborted, abort: true); - - logger.Lines(LogLevel.Warning).Should().ContainSingle( - line => line.Contains("ABORTED while the host is stopping") && line.Contains("/api/version"), - because: "an abort during shutdown is how Kestrel ends a drain that ran out of budget, and " - + "not answering the request must not also erase the only trace of that (#4859)"); - } - - [Fact] - public async Task AClientThatLeftOutsideShutdown_IsNotAWarning() - { - var logger = new CapturingLogger(); - var middleware = new OnboardingMiddleware(_ => Task.CompletedTask, logger); - - using var lifetime = new StoppingLifetime(); - using var aborted = new CancellationTokenSource(); - var context = new DefaultHttpContext - { - RequestAborted = aborted.Token, - RequestServices = new ServiceCollection() - .AddSingleton(lifetime) - .BuildServiceProvider(), - }; - context.Request.Path = "/api/version"; - - await RunWithDecisionInFlight(middleware, context, aborted, abort: true); - - logger.Lines(LogLevel.Warning).Should().BeEmpty( - because: "a client disconnecting from a running portal is routine, not a cut-short drain"); - logger.Lines(LogLevel.Debug).Any(line => line.Contains("client aborted")).Should().BeTrue( - because: "the routine case is still recorded, at Debug"); - } - - /// - /// Runs the middleware with an identity decision that is still IN FLIGHT, aborts the connection - /// (when asked) while it is pending, and only then lets the decision arrive — the #4859 race. - /// - private static async Task RunWithDecisionInFlight( - OnboardingMiddleware middleware, HttpContext context, CancellationTokenSource connection, bool abort) - { - var decision = new Subject(); - var running = middleware.Continue(context, decision); - running.IsCompleted.Should().BeFalse(because: "the decision has not arrived yet, so the request must be parked"); - if (abort) - connection.Cancel(); - decision.OnNext(OnboardingMiddleware.OnboardingDecision.PassThrough); - decision.OnCompleted(); - await running; - } - - /// A lifetime whose the test fires. - private sealed class StoppingLifetime : IHostApplicationLifetime, IDisposable - { - private readonly CancellationTokenSource stopping = new(); - - public CancellationToken ApplicationStarted => CancellationToken.None; - public CancellationToken ApplicationStopping => stopping.Token; - public CancellationToken ApplicationStopped => CancellationToken.None; - - public void Stop() => stopping.Cancel(); - public void StopApplication() => stopping.Cancel(); - public void Dispose() => stopping.Dispose(); - } -} diff --git a/test/Memex.Portal.Shared.Test/OnboardingMiddlewareExclusionTest.cs b/test/Memex.Portal.Shared.Test/OnboardingMiddlewareExclusionTest.cs deleted file mode 100644 index fa3a225120..0000000000 --- a/test/Memex.Portal.Shared.Test/OnboardingMiddlewareExclusionTest.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System; -using System.Security.Claims; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -public class OnboardingMiddlewareExclusionTest -{ - private static ClaimsPrincipal AuthenticatedUser => - new(new ClaimsIdentity("TestAuth")); - - [Theory] - [InlineData("/login")] - [InlineData("/auth/callback")] - [InlineData("/_framework/blazor.js")] - [InlineData("/_content/MeshWeaver.Blazor/css/app.css")] - [InlineData("/static/img.png")] - [InlineData("/favicon.ico")] - [InlineData("/mcp")] - [InlineData("/api/mcp")] - [InlineData("/signin-microsoft")] - public async Task ExcludedPrefixes_SkipOnboardingCheck(string path) - { - var nextCalled = false; - RequestDelegate next = _ => - { - nextCalled = true; - return Task.CompletedTask; - }; - - var middleware = new OnboardingMiddleware(next, NullLogger.Instance); - - var context = new DefaultHttpContext(); - context.Request.Path = path; - context.User = AuthenticatedUser; - - await middleware.InvokeAsync(context); - - nextCalled.Should().BeTrue(because: $"'{path}' should be excluded and pass through to next"); - } - - [Theory] - [InlineData("/signin-microsoft")] - [InlineData("/signin-google")] - [InlineData("/signin-oidc")] - public async Task SigninCallbackPaths_AreExcluded(string path) - { - var nextCalled = false; - RequestDelegate next = _ => - { - nextCalled = true; - return Task.CompletedTask; - }; - - var middleware = new OnboardingMiddleware(next, NullLogger.Instance); - - var context = new DefaultHttpContext(); - context.Request.Path = path; - context.User = AuthenticatedUser; - - await middleware.InvokeAsync(context); - - nextCalled.Should().BeTrue(because: $"'{path}' should be excluded by the /signin- prefix"); - } - - [Theory] - [InlineData("/ACME/Overview")] - [InlineData("/User/Alice")] - [InlineData("/")] - public async Task NonExcludedPaths_AttemptOnboardingCheck(string path) - { - RequestDelegate next = _ => Task.CompletedTask; - - var middleware = new OnboardingMiddleware(next, NullLogger.Instance); - - var context = new DefaultHttpContext(); - context.Request.Path = path; - context.User = AuthenticatedUser; - - // Non-excluded paths with authenticated user will attempt to resolve - // PortalApplication from DI. Since RequestServices isn't set up, this throws. - var act = () => middleware.InvokeAsync(context); - await act.Should().ThrowAsync( - because: "non-excluded paths should attempt onboarding check via PortalApplication"); - } - - [Fact] - public async Task OnboardingPage_IsResolved_NotBlanketExcluded() - { - // /onboarding is no longer blanket-excluded: it participates in the onboarded - // check so an ALREADY-onboarded user landing there gets redirected home. With no - // PortalApplication wired into RequestServices the resolution attempt surfaces - // (throws) — proving the path is no longer short-circuited to pass-through. - RequestDelegate next = _ => Task.CompletedTask; - - var middleware = new OnboardingMiddleware(next, NullLogger.Instance); - - var context = new DefaultHttpContext(); - context.Request.Path = "/onboarding"; - context.User = AuthenticatedUser; - - var act = () => middleware.InvokeAsync(context); - await act.Should().ThrowAsync( - because: "/onboarding now participates in the onboarded check to redirect onboarded users home"); - } - - [Theory] - [InlineData("/_FRAMEWORK/blazor.js")] - [InlineData("/LOGIN")] - [InlineData("/STATIC/img.png")] - public async Task ExcludedPrefixes_AreCaseInsensitive(string path) - { - var nextCalled = false; - RequestDelegate next = _ => - { - nextCalled = true; - return Task.CompletedTask; - }; - - var middleware = new OnboardingMiddleware(next, NullLogger.Instance); - - var context = new DefaultHttpContext(); - context.Request.Path = path; - context.User = AuthenticatedUser; - - await middleware.InvokeAsync(context); - - nextCalled.Should().BeTrue(because: "exclusion should be case-insensitive"); - } - - [Theory] - [InlineData("/ACME/Overview")] - [InlineData("/User/Alice")] - [InlineData("/")] - public async Task UnauthenticatedUser_SkipsEntireCheck(string path) - { - var nextCalled = false; - RequestDelegate next = _ => - { - nextCalled = true; - return Task.CompletedTask; - }; - - var middleware = new OnboardingMiddleware(next, NullLogger.Instance); - - var context = new DefaultHttpContext(); - context.Request.Path = path; - // No user set — unauthenticated - - await middleware.InvokeAsync(context); - - nextCalled.Should().BeTrue( - because: "unauthenticated users should skip the entire onboarding check"); - } -} diff --git a/test/Memex.Portal.Shared.Test/OnboardingPinsAreSeededOnceTest.cs b/test/Memex.Portal.Shared.Test/OnboardingPinsAreSeededOnceTest.cs deleted file mode 100644 index eff8efdb51..0000000000 --- a/test/Memex.Portal.Shared.Test/OnboardingPinsAreSeededOnceTest.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// is SEEDED at onboarding and never re-imposed: the first global -/// administrator is created with no learning path, an ordinary user with the four documentation -/// sections, and a second for a user who exists -/// leaves whatever they have pinned since exactly as it is. -/// -/// 🚨 Why the second call is the case that matters. BootstrapController.FirstAdmin is -/// documented as re-runnable — "already exists" is a successful step, because a re-run still wants -/// the Admin grants — and CreateUser lands the profile through the full-instance upsert, -/// whose update leg takes Content wholesale. Before this test, a re-run therefore replaced -/// the user's pins with the seed: [] for the first administrator, which silently erased every -/// pin added after the original onboarding (Copilot's finding on #5077). A test that names only the -/// FIRST call cannot see that, which is why the pure -/// did not. -/// -/// Real mesh, no doubles: the profile is read back through GetMeshNodeStream, and the -/// second write is proven to have LANDED (its FullName change is awaited on the node) before -/// the pins are asserted — otherwise "the pin is still there" would also be true of a write that -/// never arrived. -/// -public class OnboardingPinsAreSeededOnceTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private static readonly string[] LearningPath = - ["Doc/Architecture", "Doc/DataMesh", "Doc/GUI", "Doc/AI"]; - - private const string MyOwnPin = "something/i/pinned"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .ConfigureServices(services => - { - services.AddScoped(); - return services; - }); - - /// Pre-warm the User type hub so the partition-root write does not cold-start it. - protected override void PreWarmNodeTypeHubs() - { - base.PreWarmNodeTypeHubs(); - var typeNode = Mesh.ServiceProvider.FindStaticNode("User"); - if (typeNode?.HubConfiguration is { } cfg) - _ = Mesh.GetHostedHub(new Address("User"), cfg); - } - - // ---------------------------------------------------------------- the first call (controls) - - [Fact(Timeout = 60000)] - public async Task TheFirstAdministratorIsCreatedWithNoLearningPath() - { - var username = NewUsername("boot"); - await Onboard(FirstAdministrator(username), TestContext.Current.CancellationToken); - - var profile = await AwaitProfile(username, u => u.Email == Email(username), TestContext.Current.CancellationToken); - - profile.PinnedPaths.Should().BeEmpty( - "the first global administrator is setting the instance up, not learning it"); - } - - [Fact(Timeout = 60000)] - public async Task AnOrdinaryUserIsCreatedWithTheLearningPath() - { - var username = NewUsername("ord"); - await Onboard(Ordinary(username), TestContext.Current.CancellationToken); - - var profile = await AwaitProfile(username, u => u.Email == Email(username), TestContext.Current.CancellationToken); - - profile.PinnedPaths.Should().Equal(LearningPath, - "an ordinary new user's Pinned tab opens onto the four documentation sections"); - } - - // ---------------------------------------------------------------- the second call (the finding) - - [Fact(Timeout = 60000)] - public async Task ARerunOfTheRecoveryEndpointLeavesThePinsAlone() - { - // The recovery endpoint, run twice: once to materialise the first administrator, and again - // later — after they have pinned something — because an operator re-ran it for the grants. - var username = NewUsername("boot2"); - await Onboard(FirstAdministrator(username), TestContext.Current.CancellationToken); - await AwaitProfile(username, u => u.Email == Email(username), TestContext.Current.CancellationToken); - - await Pin(username, MyOwnPin, TestContext.Current.CancellationToken); - await AwaitProfile(username, u => u.PinnedPaths.Contains(MyOwnPin), TestContext.Current.CancellationToken); - - // The re-run carries a different display name so that its landing is OBSERVABLE: the pins - // are asserted only once the node shows the second write's name. - await Onboard(FirstAdministrator(username) with { FullName = "Re-run Name" }, TestContext.Current.CancellationToken); - var profile = await AwaitProfile(username, u => u.FullName == "Re-run Name", TestContext.Current.CancellationToken); - - profile.PinnedPaths.Should().Equal(new[] { MyOwnPin }, - "the seed applies when the profile is CREATED; a re-run of the recovery endpoint must not " - + "erase what the administrator pinned after the original onboarding"); - } - - [Fact(Timeout = 60000)] - public async Task ARerunForAnOrdinaryUserKeepsTheirOwnPinsToo() - { - // The same rule for an ordinary user, whose seed is the learning path: a second onboarding - // (a re-submitted form after a lagged existence check) must not re-impose the four sections - // over what they have curated since. - var username = NewUsername("ord2"); - await Onboard(Ordinary(username), TestContext.Current.CancellationToken); - await AwaitProfile(username, u => u.PinnedPaths.SequenceEqual(LearningPath), TestContext.Current.CancellationToken); - - await Unpin(username, "Doc/GUI", TestContext.Current.CancellationToken); - await Pin(username, MyOwnPin, TestContext.Current.CancellationToken); - await AwaitProfile(username, u => u.PinnedPaths.Contains(MyOwnPin), TestContext.Current.CancellationToken); - - await Onboard(Ordinary(username) with { FullName = "Re-run Name" }, TestContext.Current.CancellationToken); - var profile = await AwaitProfile(username, u => u.FullName == "Re-run Name", TestContext.Current.CancellationToken); - - profile.PinnedPaths.Should().Equal( - new[] { "Doc/Architecture", "Doc/DataMesh", "Doc/AI", MyOwnPin }, - "the learning path is a create-time seed, not a value every onboarding re-imposes"); - } - - // ---------------------------------------------------------------- helpers - - private static string NewUsername(string tag) => $"{tag}-{Guid.NewGuid():N}"[..16].ToLowerInvariant(); - - private static string Email(string username) => $"{username}@example.com"; - - private static UserOnboardingRequest Ordinary(string username) => - new(username, Email(username), FullName: "First Name"); - - private static UserOnboardingRequest FirstAdministrator(string username) => - Ordinary(username) with { IsPlatformBootstrap = true }; - - /// Drives exactly as the controller and - /// the onboarding page do — the service opens its own System scope for the write. - private Task Onboard(UserOnboardingRequest request, CancellationToken cancellationToken) - { - var service = Mesh.ServiceProvider.GetRequiredService(); - Output.WriteLine($"CreateUser {request.Username} bootstrap={request.IsPlatformBootstrap} name={request.FullName}"); - return service.CreateUser(request) - .FirstAsync() - .Timeout(TestTimeouts.WriteConvergence) - .Await(cancellationToken); - } - - /// What the user does between the two calls — the ordinary pin write, on the live node. - private Task Pin(string username, string path, CancellationToken cancellationToken) => - Edit(username, u => u with { PinnedPaths = [.. u.PinnedPaths, path] }, cancellationToken); - - private Task Unpin(string username, string path, CancellationToken cancellationToken) => - Edit(username, u => u with { PinnedPaths = [.. u.PinnedPaths.Where(p => p != path)] }, cancellationToken); - - private Task Edit(string username, Func change, CancellationToken cancellationToken) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - return access.RunAsSystem(() => Mesh.GetWorkspace().GetMeshNodeStream(username) - .Update(node => node.ContentAs(Mesh.JsonSerializerOptions) is { } u - ? node with { Content = change(u) } - : node)) - .FirstAsync() - .Timeout(TestTimeouts.WriteConvergence) - .Await(cancellationToken); - } - - private async Task AwaitProfile(string username, Func predicate, CancellationToken cancellationToken) - { - var node = await Mesh.GetWorkspace().GetMeshNodeStream(username) - .Where(n => n?.ContentAs(Mesh.JsonSerializerOptions) is { } u && predicate(u)) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(cancellationToken); - var profile = node.ContentAs(Mesh.JsonSerializerOptions)!; - Output.WriteLine($"profile {username}: name={profile.FullName} pins=[{string.Join(", ", profile.PinnedPaths)}]"); - return profile; - } -} diff --git a/test/Memex.Portal.Shared.Test/OnePartitionOneBookkeepingTest.cs b/test/Memex.Portal.Shared.Test/OnePartitionOneBookkeepingTest.cs deleted file mode 100644 index a6f24dbb6c..0000000000 --- a/test/Memex.Portal.Shared.Test/OnePartitionOneBookkeepingTest.cs +++ /dev/null @@ -1,675 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 ONE PARTITION, ONE BOOKKEEPING — Systemorph/MeshWeaver#4355. -/// -/// The defect. A partition can be BOTH a sync-governed space — a -/// {partition}/_GitSync whose imports are held to the commit sealed for the running framework -/// identity — AND the target of a registry-installed package with autoUpdate: true. The two -/// writers keep SEPARATE books, and neither can see the other's. The seal reconciler rewrites the -/// partition from git and leaves the install record untouched; the registry lane then computes its -/// next delta from that record, which has stopped describing the mesh, and writes only the files -/// whose hash moved SINCE THE RECORD — leaving every file it believes unchanged at the git tree's -/// content. The partition ends up a MIX of two commits that no CI ever compiled. -/// -/// Measured on memex.systemorph.com, 2026-09-14. Store/_GitSync held at the -/// sealed commit 627fb3cd (Store 1.10.3); at 14:23Z a boot sweep's declined adoptions drove -/// ReconcileAtProvenCommitFromGitHub to re-import the whole partition at it while -/// Plugins/Store still said 1.10.14; at 20:25Z the registry served 1.11.1 and the delta -/// against that record found Store/Core/Source/StoreTexts.cs byte-identical between 1.10.14 -/// and 1.11.1, declared it unchanged and never wrote it — while five sibling types WERE written at -/// 1.11.1. Store/Catalog recompiled against 1.10.3's texts and PARKED with -/// CS1061 'StoreTexts' does not contain a definition for 'ExploreCta'. -/// -/// The invariant pinned here. A partition has ONE content bookkeeping. Either the -/// installer owns the content — its record IS the description of the mesh, an unattended apply may -/// write and a delta may be diffed against it — or a sync source does, in which case the installer -/// never silently becomes the second writer and never diffs against a baseline it does not own. -/// The two halves are measured separately because they fail separately: -/// -/// — the -/// registry auto-update must not write into a synced partition at all (which is also what stops -/// it landing sources this instance has no proven bundle for), and must SAY so rather than skip -/// in silence. -/// — every other lane that -/// still writes such a partition by design (the seal-pinned boot install, a human's Update click) -/// must install in FULL, because its delta baseline is a claim about a mesh a second writer -/// maintains. This is the arm that reproduces the MIX itself. -/// -/// -/// Both arms carry their opposite as a control in the same mesh, differing in exactly one -/// fact — whether the target partition carries a _GitSync naming a repository. A test that -/// only asserted the hold would pass just as well against a lane that had stopped updating -/// anything. -/// -public class OnePartitionOneBookkeepingTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// - /// The mesh a deployment of this fleet actually runs: the catalog (so an install records - /// itself), GitHub sync (so is answered by the REAL - /// provider over a REAL config node rather than a stand-in — the seam the gate depends on is - /// part of what is under test) and AutoUpdateByDefault, which is what stamps an install - /// record with here as on every portal. - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddGitHubSyncTypes() - .AddPluginCatalog() - .ConfigureServices(services => - { - services.AddGitHubSyncServices(); - services.AddSingleton(new PluginCatalogOptions { AutoUpdateByDefault = true }); - return services; - }); - - private const string V1 = "1111111111111111"; - private const string V2 = "2222222222222222"; - - // ── Half 1: the unattended apply ──────────────────────────────────────────────────────────── - private const string SyncOwned = "SyncOwnedPkg"; - private const string InstallerOwned = "InstallerOwnedPkg"; - - // ── Half 2: the delta baseline ────────────────────────────────────────────────────────────── - private const string MixSynced = "MixSyncedPkg"; - private const string MixOwned = "MixOwnedPkg"; - - /// A partition whose only sync source is mesh → repo — configured, and unable to - /// write the mesh (MeshWeaver#4588). - private const string ExportOnlyPartition = "ExportOnlySpace"; - - /// The name a second writer puts on a node the installer believes it owns — the - /// content DRIFT that #4259's presence check cannot see, because the node is still there. - private const string DriftedName = "rewritten by the other writer"; - - private IMeshService MeshService => Mesh.ServiceProvider.GetRequiredService(); - - private ILogger Logger => Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - // ════════════════════════════════════════════════════════════════════════════════════════════ - // Half 1 — the unattended apply is not the second writer - // ════════════════════════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 THE REGRESSION, half one. Two installed packages that differ in exactly one fact: - /// one's target partition carries a _GitSync, the other's does not. The registry serves a - /// newer content identity for both, and both records are on the unattended policy. - /// - /// Fails on main. There the apply is decided by the package's update policy alone, - /// so BOTH packages are fetched and written — the sync-owned partition included, at a content - /// identity its sync source will revert at the next seal reconcile, and against a record its own - /// content no longer matches. The witness is the source's request log: which package's files - /// were asked for is the one thing this lane cannot fake. - /// - /// The installer-owned package is the control in both directions: it proves the pass - /// really ran (a green result cannot come from a reconcile that did nothing) and that the gate - /// discriminates rather than switching the whole lane off. - /// - [Fact(Timeout = 300_000)] - public async Task TheUnattendedApply_HoldsOnASyncOwnedPartition_AndLandsOnTheOneItOwns() - { - // ── The starting state: both packages installed at V1, by the installer, for real. ────── - var first = new RecordingSource(new Dictionary> - { - [SyncOwned] = Files(SyncOwned, V1, "ccc", "# Other\n\nGeneration one."), - [InstallerOwned] = Files(InstallerOwned, V1, "ccc", "# Other\n\nGeneration one."), - }); - await Install(first, SyncOwned, V1); - await Install(first, InstallerOwned, V1); - - (await Record(SyncOwned))!.EffectiveUpdatePolicy.Should().Be(PackageUpdatePolicy.Auto, - "the whole premise is an UNATTENDED apply — on any other policy this lane would not " - + "write even on main, and the assertion below would pass for the wrong reason"); - (await Record(InstallerOwned))!.EffectiveUpdatePolicy.Should().Be(PackageUpdatePolicy.Auto); - - // ── The one fact that differs: a sync source now keeps SyncOwned's partition current. ─── - await TrackPartition(SyncOwned); - await AssertOwnership(InstallerOwned, PartitionContentOwner.Installer); - - // ── The registry serves V2 of both. This is the exact call RegistryUpdateReconciler - // .ReconcileFromFeed makes on a boot pass, a drained deferral and a safety-net tick. ── - var second = new RecordingSource(new Dictionary> - { - [SyncOwned] = Files(SyncOwned, V2, "ddd", "# Other\n\nGeneration TWO."), - [InstallerOwned] = Files(InstallerOwned, V2, "ddd", "# Other\n\nGeneration TWO."), - }); - await PackageUpdateReconciler.ReconcileInstalled( - Mesh, second, "HEAD", - // SyncOwned FIRST: the candidates are reconciled sequentially, so the control's - // arrival strictly follows the decision under test and cannot race it. - [Candidate(SyncOwned, V2), Candidate(InstallerOwned, V2)], - "Served by registry 'test'", Logger) - .Should().Within(TestTimeouts.CrossSilo) - .Emit("the reconcile pass must complete before anything about it is asserted", - cancellationToken: TestContext.Current.CancellationToken); - - // ── The control: the partition the installer owns advanced, content and record. ───────── - second.Fetched.Should().Contain(r => r.PackageId == InstallerOwned, - "the installer-owned package must still auto-update — if it did not, the assertion " - + "below would be measuring a lane that had stopped working rather than a gate"); - (await Record(InstallerOwned))!.ModuleVersion.Should().Be(V2, - "and its record moved forward, which is what 'the apply landed' means"); - - // ── THE assertion. ────────────────────────────────────────────────────────────────────── - second.Fetched.Should().NotContain(r => r.PackageId == SyncOwned, - "THE assertion: an unattended apply must not fetch — let alone write — into a partition " - + "a sync source keeps current. On main it does, which is how memex.systemorph.com's " - + "Store partition became a mix of 1.10.3 and 1.11.1 (MeshWeaver#4355)"); - - var held = await Record(SyncOwned); - held!.ModuleVersion.Should().Be(V1, - "the record did not move either — an apply that was held must not stamp a version it " - + "did not install, or the next delta is computed against a second lie"); - held.NotifiedModuleVersion.Should().Be(V2, - "and the hold is TOLD, once per candidate: an auto-update that will never land here is " - + "exactly the quiet 'my plugin never updates' this lane must not become"); - } - - // ════════════════════════════════════════════════════════════════════════════════════════════ - // Half 2 — the delta baseline belongs to whoever owns the content - // ════════════════════════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 THE REGRESSION, half two — the MIX itself. Install V1; let a SECOND WRITER rewrite - /// one node (the shape a seal reconcile's re-import has, and the one #4259's presence check - /// cannot see: the node is still there, its CONTENT drifted); then update to V2, in which that - /// node's own file hash has NOT moved while a sibling's has. - /// - /// Fails on main. The incremental path fetches exactly - /// newManifest.DiffFrom(record.InstalledFiles) — two DECLARATIONS, neither of which - /// observes the mesh — so the drifted node is never fetched, never written, and the partition is - /// left half at the second writer's content and half at V2. That is - /// StoreTexts.cs at 1.10.3 beside Catalog at 1.11.1, verbatim. - /// - /// The control is the same package on a partition NOTHING syncs: there the installer IS - /// the only writer, its record does describe the mesh, and the delta stays a delta — so the - /// drift survives, exactly as today. One fact differs between the two arms, and only one. - /// - [Fact(Timeout = 300_000)] - public async Task AnUpdate_NeverDiffsAgainstABaselineItDoesNotOwn() - { - var first = new RecordingSource(new Dictionary> - { - [MixSynced] = Files(MixSynced, V1, "ccc", "# Other\n\nGeneration one."), - [MixOwned] = Files(MixOwned, V1, "ccc", "# Other\n\nGeneration one."), - }); - await Install(first, MixSynced, V1, TestContext.Current.CancellationToken); - await Install(first, MixOwned, V1, TestContext.Current.CancellationToken); - - (await Record(MixSynced))!.InstalledFiles.Should().NotBeNull() - .And.ContainKey($"{MixSynced}/Guide.md", - "the record's file map IS the incremental path's baseline — with no map the " - + "installer falls back to a full install, which repairs by accident and would " - + "make this test vacuous"); - - await TrackPartition(MixSynced); - await AssertOwnership(MixOwned, PartitionContentOwner.Installer); - - // ── The second writer. A seal reconcile re-imports the whole partition at the sealed - // commit; here one node is enough, and the node stays PRESENT, which is precisely why - // #4259's absence repair cannot reach this. - await Drift($"{MixSynced}/Guide"); - await Drift($"{MixOwned}/Guide"); - - // ── The update: the module hash moves, Other.md's content moves, Guide.md's does NOT. ─── - var second = new RecordingSource(new Dictionary> - { - [MixSynced] = Files(MixSynced, V2, "ddd", "# Other\n\nGeneration TWO."), - [MixOwned] = Files(MixOwned, V2, "ddd", "# Other\n\nGeneration TWO."), - }); - await Install(second, MixSynced, V2); - await Install(second, MixOwned, V2); - - // ── THE assertion: on a partition the installer does not own, the update is a FULL - // install, so every declared file travels and the partition is one content identity. ── - second.Fetched.Should().Contain(r => r.PackageId == MixSynced && r.Paths is null, - "THE assertion: an update whose baseline describes a mesh a second writer maintains " - + "must ask for the WHOLE package, not a delta. On main it asks for the changed file " - + "alone (MeshWeaver#4355)"); - (await NameOf($"{MixSynced}/Guide")).Should().NotBe(DriftedName, - "and the node whose file hash never moved is back at the package's content — the mix " - + "is what parked Store/Catalog with CS1061"); - (await NameOf($"{MixSynced}/Other")).Should().NotBe(DriftedName, - "the control inside the arm: the file that DID change landed too, so the full install " - + "really ran"); - (await Record(MixSynced))!.ModuleVersion.Should().Be(V2, - "and the record describes the partition again"); - - // ── The control arm: nothing syncs MixOwned, so the delta stays a delta. ──────────────── - second.Fetched.Should().Contain( - r => r.PackageId == MixOwned && r.Paths is not null - && r.Paths.Contains($"{MixOwned}/Other.md") && !r.Paths.Contains($"{MixOwned}/Guide.md"), - "the control: where the installer IS the only writer its record does describe the mesh, " - + "so the incremental path is kept — the gate discriminates, it does not turn the fast " - + "path off for everyone"); - (await NameOf($"{MixOwned}/Guide")).Should().Be(DriftedName, - "and the unfetched file's node is untouched there, which is today's behaviour and the " - + "proof that the arm above differs by the sync source and by nothing else"); - } - - // ════════════════════════════════════════════════════════════════════════════════════════════ - // The decision, driven offline — every arm, including the two that are not passes - // ════════════════════════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 "I could not tell" is not "the installer owns it". The three-way split is the whole point: - /// folding into - /// would make a seam that faulted read exactly - /// like a partition nothing syncs — and the caller would then write into a partition it has no - /// evidence about, which is the defect one level in. - /// - [Fact] - public void NotAnsweredIsNeverInstallerOwned() - { - PartitionContentOwnership.Decide("Store", tracked: true, providerCount: 1) - .Owner.Should().Be(PartitionContentOwner.SyncSource); - PartitionContentOwnership.Decide("Store", tracked: true, providerCount: 1) - .InstallerOwnsTheContent.Should().BeFalse(); - - PartitionContentOwnership.Decide("Store", tracked: false, providerCount: 1) - .InstallerOwnsTheContent.Should().BeTrue( - "a mesh WITH a sync layer that says this partition tracks nothing is a real " - + "negative — the one case where the installer may write"); - - PartitionContentOwnership.Decide("Store", tracked: null, providerCount: 2) - .Owner.Should().Be(PartitionContentOwner.Undetermined, - "a seam that faulted or did not answer inside its budget was NOT checked"); - PartitionContentOwnership.Decide("Store", tracked: null, providerCount: 2) - .InstallerOwnsTheContent.Should().BeFalse("and that is not a pass"); - - PartitionContentOwnership.Decide("Store", tracked: null, providerCount: 0) - .InstallerOwnsTheContent.Should().BeTrue( - "a mesh that registers NO tracking provider has no notion of a second writer — a " - + "local mesh, a CI mesh, the bake host — and must keep the pre-#4355 behaviour " - + "rather than hold every install on every such deployment"); - - PartitionContentOwnership.Decide("Store", tracked: true, providerCount: 1) - .Because.Should().Contain("Store", - "every arm's reason names the partition — a hold nobody can attribute is a hold " - + "nobody can act on"); - } - - /// - /// 🚨 Several providers into one answer, and the two halves are asymmetric on purpose. A - /// POSITIVE is decisive — one seam saying "a source tracks this" is knowledge, whatever a second - /// seam failed to say — while a NEGATIVE needs everyone to have answered, or "nobody said yes" - /// would be produced by a mesh where nobody said anything. - /// - [Fact] - public void ASilentProviderNeverTurnsIntoANegative() - { - PartitionContentOwnership.Fold([true, null]).Should().Be(true, - "a positive is knowledge — a second seam's silence cannot unsay it"); - PartitionContentOwnership.Fold([false, false]).Should().Be(false, - "everyone answered and nobody tracks it: a real negative"); - PartitionContentOwnership.Fold([false, null]).Should().BeNull( - "THE arm: one silent provider beside a negative is 'not established', never 'nothing " - + "tracks it' — otherwise a seam that stopped answering reads as a clean partition"); - PartitionContentOwnership.Fold([null, null]).Should().BeNull(); - PartitionContentOwnership.Fold([]).Should().Be(false, - "no providers to ask is decided by Decide's providerCount arm, not here"); - } - - /// - /// 🚨 The MODULE half no longer takes the content half's hold (policy - /// packages-auto-update, which retired #4355's gate 1b on the module lane): a published, - /// compatible module lands whoever owns the partition's content. What still declines it is the - /// package's OWN policy — an administrator's deliberate pin — and, inside the adopt, the floor. - /// - [Fact] - public void TheModuleLaneDeclinesOnlyForThePackagesOwnPolicy() - { - RegistryUpdateReconciler.PolicyDecline(new PackageManifest { Id = "X", UpdatePolicy = PackageUpdatePolicy.Auto, AutoUpdate = true }) - .Should().BeNull("an Auto record lands, sync-owned or not"); - RegistryUpdateReconciler.PolicyDecline(new PackageManifest { Id = "X", UpdatePolicy = PackageUpdatePolicy.None }) - .Should().NotBeNull("a deliberate pin is the one opt-out the policy keeps"); - } - - /// - /// 🚨 An EXPORT-ONLY source is not a second writer — Systemorph/MeshWeaver#4588, Copilot - /// review of #4619. - /// - /// SyncDirection.ExportOnly is mesh → repo and REJECTS imports, so nothing it - /// configures can overwrite or prune what an installer wrote: on such a partition the installer - /// IS the only writer, and holding an install there would be a false positive — the quiet "my - /// plugin never installs" this gate must not create. The one-bit seam the compile control plane - /// reads (IsTracked) deliberately stays WIDE for the same partition, because its question - /// is the opposite one: with the mesh as the source of truth its live source IS current, and a - /// type must compile there rather than park (MeshWeaver#3583). - /// - /// Both readings are taken from the same real provider over the same real config node, so - /// the arm fails if the two questions are ever collapsed back into one bit. - /// - [Fact(Timeout = 300_000)] - public async Task AnExportOnlySourceIsNotASecondWriter() - { - await MeshService.CreateNode(new MeshNode(GitHubSyncService.ConfigId, ExportOnlyPartition) - { - Name = "GitHub Sync", - NodeType = GitHubSyncService.ConfigNodeType, - State = MeshNodeState.Active, - Content = new GitHubSyncConfig - { - RepositoryUrl = "https://github.com/Systemorph/Example", - Branch = "main", - Direction = SyncDirection.ExportOnly, - }, - }) - .Should().Within(TestTimeouts.Convergence) - .Emit("the export-only source is the precondition of both readings", - cancellationToken: TestContext.Current.CancellationToken); - - var provider = Mesh.ServiceProvider.GetServices() - .Should().ContainSingle("the GitHub provider is this mesh's tracking layer").Subject; - - // The compile control plane's question, unchanged and still WIDE. - await provider.IsTracked(ExportOnlyPartition) - .Where(tracked => tracked) - .FirstAsync() - .Timeout(TestTimeouts.CrossSilo) - .Await(TestContext.Current.CancellationToken); - - // THE assertion: the installer's question. The verdict is polled because the seam reads a - // live listing, so the arm waits for the state it is about rather than the first snapshot. - var verdict = await Observable.Interval(TestTimeouts.Quick / 20).StartWith(0L) - .SelectMany(_ => PartitionContentOwnership.Observe(Mesh, ExportOnlyPartition)) - .Where(v => v.Owner != PartitionContentOwner.Undetermined) - .FirstAsync() - .Timeout(TestTimeouts.CrossSilo) - .Await(TestContext.Current.CancellationToken); - verdict.InstallerOwnsTheContent.Should().BeTrue( - "THE assertion: a mesh → repo source writes nothing into the mesh, so it cannot revert " - + "or prune an install — reading it as a second writer would hold every install on that " - + "partition for a writer that cannot write (MeshWeaver#4588)"); - - // And the control: the same partition, the same node, one field different. - await Mesh.GetMeshNodeStream(GitHubSyncService.ConfigPath(ExportOnlyPartition)) - .Update(node => node with - { - Content = new GitHubSyncConfig - { - RepositoryUrl = "https://github.com/Systemorph/Example", - Branch = "main", - Direction = SyncDirection.Bidirectional, - }, - }) - .Should().Within(TestTimeouts.WriteConvergence) - .Emit("flipping the direction is the control", cancellationToken: TestContext.Current.CancellationToken); - - await AssertOwnership(ExportOnlyPartition, PartitionContentOwner.SyncSource); - } - - // ── harness ───────────────────────────────────────────────────────────────────────────────── - - private static PackageManifest Candidate(string id, string moduleVersion) => new() - { - Id = id, - Name = id, - Kind = PackageKind.NodeRepo, - TargetPartition = id, - SourceFolder = id, - Version = "1.0.0", - ModuleVersion = moduleVersion, - }; - - /// - /// The package at one generation. / - /// are the ONLY things that move between generations — Guide.md's hash is deliberately - /// identical in both locks, which is the premise of half two: the diff cannot name it, so only - /// a full install reaches it. - /// - private static IReadOnlyList Files( - string id, string moduleVersion, string otherHash, string otherBody) => - [ - new PackageFile($"{id}/{ModuleManifest.FileName}", $$""" - { - "module": "{{id}}", - "moduleVersion": "{{moduleVersion}}", - "version": "1.0.0", - "files": { - "{{id}}/index.json": "aaa", - "{{id}}/Guide.md": "bbb", - "{{id}}/Other.md": "{{otherHash}}" - } - } - """), - new PackageFile($"{id}/index.json", $$""" - { - "id": "{{id}}", - "path": "{{id}}", - "nodeType": "Space", - "name": "{{id}}", - "state": "Active" - } - """), - new PackageFile($"{id}/Guide.md", "# Guide\n\nThe node a second writer rewrites. Its hash " - + "never moves, so the manifest diff never names it."), - new PackageFile($"{id}/Other.md", otherBody), - ]; - - /// The running test's token. A timed test passes it EXPLICITLY - /// so the reference sits lexically inside the test METHOD — xUnit1069 is satisfied by the - /// test's own body, never by a helper's — and it defaults to the same token for every other - /// caller (Doc/Architecture/WritingTests, Rule 2a). - private async Task Install(IPackageSource source, string id, string moduleVersion, - CancellationToken? cancellationToken = null) - { - var token = cancellationToken ?? TestContext.Current.CancellationToken; - await CatalogLayoutAreas.InstallOrUpdate(Mesh, source, "HEAD", Candidate(id, moduleVersion), Logger) - .Should().Within(TestTimeouts.CrossSilo) - .Emit($"installing {id} at {moduleVersion} is a precondition of what follows", - cancellationToken: token); - (await Record(id))!.ModuleVersion.Should().Be(moduleVersion, - "an install that did not stamp its record leaves every later measurement meaningless"); - } - - /// A real {partition}/_GitSync naming a repository — what the settings tab - /// writes when a Space is connected — read back through the very decision under test, so a - /// false negative in the arm cannot be mistaken for a defect in the gate. - private async Task TrackPartition(string partition) - { - await MeshService.CreateNode(new MeshNode(GitHubSyncService.ConfigId, partition) - { - Name = "GitHub Sync", - NodeType = GitHubSyncService.ConfigNodeType, - State = MeshNodeState.Active, - Content = new GitHubSyncConfig - { - RepositoryUrl = "https://github.com/Systemorph/Example", - Branch = "main", - }, - }) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - await AssertOwnership(partition, PartitionContentOwner.SyncSource); - } - - private async Task AssertOwnership(string partition, PartitionContentOwner expected) - { - var verdict = await Observable.Interval(TestTimeouts.Quick / 20).StartWith(0L) - .SelectMany(_ => PartitionContentOwnership.Observe(Mesh, partition)) - .Where(v => v.Owner == expected) - .FirstAsync() - .Timeout(TestTimeouts.CrossSilo) - .Await(TestContext.Current.CancellationToken); - verdict.Owner.Should().Be(expected); - } - - /// The SECOND writer: a node the installer believes it owns, rewritten behind its - /// back. The node stays present — the whole difference from #4259, whose repair keys on - /// absence. - private async Task Drift(string path) - { - // As SYSTEM: the partition is one-way synced, so its content is written by System alone - // (#5140) — the second writer this stands for is the platform's own, never a person. - using (Mesh.ServiceProvider.GetRequiredService().ImpersonateAsSystem()) - await Mesh.GetMeshNodeStream(path) - .Update(node => node with { Name = DriftedName }) - .Should().Within(TestTimeouts.WriteConvergence).Emit($"drifting {path} is a precondition", - cancellationToken: TestContext.Current.CancellationToken); - (await NameOf(path)).Should().Be(DriftedName, - "the drift must have landed, or the arm below measures nothing"); - } - - private async Task NameOf(string path) - { - var node = await Mesh.ServiceProvider.GetRequiredService() - .Read(path, Mesh.JsonSerializerOptions) - .Take(1) - .DefaultIfEmpty(null) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - node.Should().NotBeNull($"'{path}' must exist for its name to say anything"); - return node!.Name; - } - - private async Task Record(string packageId) - { - var node = await Mesh.ServiceProvider.GetRequiredService() - .Read($"{PackageInstaller.InstalledPartition}/{packageId}", Mesh.JsonSerializerOptions) - .Take(1) - .DefaultIfEmpty(null) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - return node?.ContentAs(Mesh.JsonSerializerOptions); - } - - /// What a fetch asked for. Paths is null for a FULL install and a set for the - /// incremental delta — the difference the second arm is entirely about. - private sealed record Fetch(string PackageId, IReadOnlySet? Paths); - - /// - /// The package source as a transport, recording what was asked of it — the witness both arms - /// turn on, and the one thing a lane under test cannot fake. Serves several packages, because - /// the reconcile pass visits a list. - /// - private sealed class RecordingSource(IReadOnlyDictionary> byPackage) - : IPackageSource - { - private ImmutableList fetched = ImmutableList.Empty; - - public ImmutableList Fetched => fetched; - - public IObservable> ListPackages(string gitRef) => - Observable.Return>( - byPackage.Keys.Select(id => Candidate(id, V1)).ToList()); - - public IObservable> FetchPackageFiles( - PackageManifest package, string gitRef) => - FetchPackageFiles(package, gitRef, null); - - public IObservable> FetchPackageFiles( - PackageManifest package, string gitRef, IReadOnlyCollection? paths) - { - var wanted = paths?.ToImmutableHashSet(StringComparer.Ordinal); - ImmutableInterlocked.Update(ref fetched, f => f.Add(new Fetch(package.Id, wanted))); - var files = byPackage.TryGetValue(package.Id, out var all) ? all : []; - return Observable.Return(wanted is null - ? files - : (IReadOnlyList)files.Where(f => wanted.Contains(f.RelativePath)).ToList()); - } - } -} - -/// -/// 🚨 The ownership seam must answer EXACTLY ONCE, whatever the providers do — -/// Systemorph/MeshWeaver#4355, Copilot review of #4364. -/// -/// Both callers of are -/// SelectManys. A sequence that completes WITHOUT a verdict therefore runs neither arm: no -/// install, no reminder, no decline — a silent skip wearing the colours of a decision. Three -/// separate provider behaviours produce exactly that unless the composition is right, and each one -/// gets its own arm here: -/// -/// a provider that COMPLETES WITHOUT EMITTING — one empty leg completes the whole -/// CombineLatest with no value, and Timeout does not fire on a completed -/// sequence; -/// a provider whose IsTracked THROWS SYNCHRONOUSLY — the call runs while the -/// sequence is being constructed, so without a per-leg Defer the throw escapes every -/// operator attached after it; -/// a provider that returns a faulting sequence. -/// -/// -/// The providers here are real implementations of an extension seam the platform expects -/// several of (one class per sync kind — the GitHub one is the shipped example), in the same spirit -/// as this suite's IPackageSource and IGitHubRepoClient stand-ins: the transport is -/// substituted, never a core service. What is under test is the composition, so a provider that -/// misbehaves in a way no shipped one does is exactly the input required. -/// -public class PartitionContentOwnershipSeamTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Partition = "SeamPartition"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .ConfigureServices(services => - { - // No GitSync here: these three ARE the mesh's tracking layer, so the verdict is a - // pure function of their behaviour. - services.AddSingleton(new SilentProvider()); - services.AddSingleton(new ThrowsSynchronouslyProvider()); - services.AddSingleton(new FaultingProvider()); - return services; - }); - - [Fact(Timeout = 300_000)] - public async Task ASeamThatDoesNotAnswer_StillProducesExactlyOneVerdict() - { - var verdicts = await PartitionContentOwnership.Observe(Mesh, Partition) - .ToList() - .Timeout(TestTimeouts.CrossSilo) - .Await(TestContext.Current.CancellationToken); - - verdicts.Should().HaveCount(1, - "THE assertion: both callers are SelectMany, so a sequence that completes with no " - + "verdict runs no arm at all — neither the install nor the hold. A silent skip is the " - + "one outcome this gate must never have (MeshWeaver#4355)"); - verdicts[0].Owner.Should().Be(PartitionContentOwner.Undetermined, - "no provider answered, and 'I could not tell' is never 'the installer owns it'"); - verdicts[0].InstallerOwnsTheContent.Should().BeFalse(); - verdicts[0].Because.Should().Contain(Partition, - "the reason names the partition it is about"); - } - - /// A provider that COMPLETES without emitting — the empty leg. Legal for any - /// and fatal to a CombineLatest that does not allow for - /// it. - private sealed class SilentProvider : IPartitionSourceTracking - { - public IObservable IsTracked(string partition) => Observable.Empty(); - } - - /// A provider whose IsTracked throws before it ever returns a sequence. - private sealed class ThrowsSynchronouslyProvider : IPartitionSourceTracking - { - public IObservable IsTracked(string partition) => - throw new InvalidOperationException("this seam cannot answer at all"); - } - - /// A provider whose sequence faults. - private sealed class FaultingProvider : IPartitionSourceTracking - { - public IObservable IsTracked(string partition) => - Observable.Throw(new InvalidOperationException("this seam failed while answering")); - } -} diff --git a/test/Memex.Portal.Shared.Test/OperationSentinelPrefixTest.cs b/test/Memex.Portal.Shared.Test/OperationSentinelPrefixTest.cs deleted file mode 100644 index 7287a0ec5a..0000000000 --- a/test/Memex.Portal.Shared.Test/OperationSentinelPrefixTest.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using MeshWeaver.AI; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The sentinel WRITERS and the sentinel CLASSIFIER agree — pinned at the source, because the -/// verbs write their sentinels as interpolated literals at some forty sites in -/// MeshOperations.cs and reads them by prefix. A writer that -/// drifted ("Error -", "Not Found:", a leading space) would ship a 200 with prose again, exactly -/// the shape MeshWeaver.Plugins#1699 measured, while every classifier test stayed green. -/// -public class OperationSentinelPrefixTest -{ - private static readonly Regex SentinelLiteral = new( - """(?\$?@?\$?")(?(?:Error|Not found|Unavailable|Refused)\b[^"]{0,40})""", RegexOptions.Compiled); - - [Fact] - public void Every_sentinel_literal_the_verbs_write_is_one_the_classifier_reads() - { - var source = File.ReadAllText(Path.Combine(FindRepoRoot(), "src", "MeshWeaver.Mesh.Operations", "MeshOperations.cs")); - var literals = SentinelLiteral.Matches(source) - .Select(m => m.Groups["text"].Value) - .Where(text => text.Contains(':') || text.StartsWith("Refused ", StringComparison.Ordinal)) // ": …", or the colon-less refusal - .Distinct(StringComparer.Ordinal) - .ToList(); - - literals.Should().NotBeEmpty("the census must see the writers, or it measures nothing"); - literals.Count.Should().BeGreaterThan(10, "MeshOperations writes its sentinels at dozens of sites"); - - var unclassified = literals - .Where(text => OperationSentinel.Classify(text.Replace("{", "x").Replace("}", "x")) is null) - .ToList(); - unclassified.Should().BeEmpty( - "every sentinel the verbs write must be read by the classifier, or the REST mirror ships it as a 200 again: {0}", - string.Join(" | ", unclassified)); - } - - [Fact] - public void The_constants_are_the_prefixes_the_writers_use() - { - OperationSentinel.ErrorPrefix.Should().Be("Error:"); - OperationSentinel.NotFoundPrefix.Should().Be("Not found:"); - OperationSentinel.UnavailablePrefix.Should().Be("Unavailable:"); - OperationSentinel.RefusedPrefix.Should().Be("Refused "); - } - - private static string FindRepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); - } -} diff --git a/test/Memex.Portal.Shared.Test/OperatorRecycleWaitsForTheInstallHoldingTheRootTest.cs b/test/Memex.Portal.Shared.Test/OperatorRecycleWaitsForTheInstallHoldingTheRootTest.cs deleted file mode 100644 index 5438f87562..0000000000 --- a/test/Memex.Portal.Shared.Test/OperatorRecycleWaitsForTheInstallHoldingTheRootTest.cs +++ /dev/null @@ -1,149 +0,0 @@ -#pragma warning disable CS1591 - -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 #3510 — the OPERATOR recycle (MeshOperations.Recycle, the operations/MCP verb) -/// waits while an install holds the root, and is a straight pass-through when none does. -/// -/// The hole this closes. Every automatic recycler has consulted -/// since #4009, and HubRecycleExtensions said so in -/// its own remarks — while naming the surface that did NOT: "MeshOperations.Recycle — the -/// operations/MCP recycle. It posts directly, so an operator recycling a package root mid-install -/// can still strand that install." That is the measured harm of CD 7950 reachable by hand: the -/// root's per-node children go down with it, the writes they owed acks for are never answered -/// ([UpdateQueue] ADVANCE_WITHOUT_HANDOFF … the owner never acknowledged this write), the -/// nodeops handler that owed its reply to one of those acks never replies, and the install -/// sits until its own ten-minute bound reports [FAIL] … install: TimeoutException. -/// -/// Why the gate sits where it does, and why both halves are pinned. It runs AFTER the -/// permission fold — a caller who may not recycle is refused at once and never waits on somebody -/// else's install — and BEFORE RecycleCore, because the release-request stamp -/// RecycleCore issues is itself a WRITE into the root the install is writing. Gating only -/// the DisposeRequest would leave that write racing the install, so the assertion below is -/// on the operation emitting NOTHING, not merely on the hub surviving. -/// -/// Negative control — measured, not asserted. Neutralising the production gate -/// (dropping the WhenNoInstallHoldsRoot hop in MeshOperations.Recycle so it calls -/// RecycleCore directly, as it did before this change) and rebuilding both projects makes -/// fail on its FIRST -/// assertion, verbatim: -/// -/// Expected the observable not to emit within 12s because an install is writing under this -/// root … but it emitted {"status":"Recycled","path":"TestData/op-recycle-held-root", -/// "message":"DisposeRequest posted + cache invalidation broadcast via MeshChangeFeed. …"} -/// -/// — i.e. the teardown went out in the middle of the install, which is the defect. The -/// pass-through case stays green throughout, so the pair discriminates rather than one of them -/// merely being hard to satisfy. Restored ⇒ 2/2. -/// -/// 🚨 Twin of RecycleNodeWaitsForTheInstallHoldingTheRootTest in -/// MeshWeaver.Graph.Test, which pins the same contract on the framework surface. The two -/// are deliberately separate tests rather than one parameterised one: they are different entry -/// points in different assemblies, and #3510's whole lesson is that a gate reaches only what is -/// routed through it — a shared harness would let one of them stand in for the other. -/// -public class OperatorRecycleWaitsForTheInstallHoldingTheRootTest(ITestOutputHelper output) - : MonolithMeshTestBase(output) -{ - private const string Holder = - "PackageInstaller: the install of package 'Hosting' is writing under this root"; - - /// - /// The verb's REAL origin — the same hub MCP, REST, gRPC and - /// the CLI all drive from. Not the bare client hub - /// RequestHub gives: RecycleCore resolves a workspace off its hub, which a - /// handler-less client does not carry, so a test on that hub answers status=Error from - /// the catch-all and never reaches the code under test. - /// - private IMessageHub SessionHub() => SessionHubFactory.Resolve( - Mesh, - "mcp", - "recycle-lease", - Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(nameof(OperatorRecycleWaitsForTheInstallHoldingTheRootTest))); - - private async Task SeedNode(string id) - { - var path = $"{TestPartition}/{id}"; - await NodeFactory - .CreateNode(new MeshNode(id, TestPartition) { Name = id, NodeType = "Markdown" }) - .Should().Emit("the node the recycle targets has to exist first", - cancellationToken: TestContext.Current.CancellationToken); - return path; - } - - /// - /// 🚨 THE REGRESSION CASE. While an install holds the root, the operator recycle produces no - /// answer at all — it has not stamped, and it has not posted its DisposeRequest, so the - /// hub stays up and the writes beneath it keep their owner. The second half is what makes this - /// a DEFERRAL rather than a silent drop: the moment the install releases the root, the recycle - /// runs and the operator gets the envelope they asked for. - /// - [Fact] - public async Task WhileAnInstallHoldsTheRoot_TheOperatorRecycleWaitsAndThenRuns() - { - var leases = Mesh.ServiceProvider.GetRequiredService(); - var path = await SeedNode("op-recycle-held-root"); - - var lease = leases.Hold(path, Holder); - leases.HeldBy(path).Should().Be(Holder, "the holder is named, never anonymous"); - - var recycled = new MeshOperations(SessionHub()).Recycle(path).Replay(1); - using var connection = recycled.Connect(); - - await recycled.Should().NotEmit( - TestTimeouts.Quick, - "an install is writing under this root — an operator recycling it now would strand " - + "that install's writes, which is #3510's whole measured harm, and the verb must not " - + "even stamp the release request because that stamp is itself a write into the root"); - - lease.Dispose(); - - var answer = await recycled.Should().Within(TestTimeouts.Convergence).Emit( - "the recycle is DEFERRED, never dropped: the install released the root, so the verb " - + "must now run and answer the operator"); - - using var envelope = JsonDocument.Parse(answer); - envelope.RootElement.GetProperty("status").GetString().Should().Be("Recycled", - "the deferral changes WHEN the operation runs, never WHAT it answers — an operator " - + "who waited must get the same envelope as one who did not"); - } - - /// - /// 🚨 THE POSITIVE CONTROL. #3965 measured that a root recycling with no install of its own is - /// the COMMON case, so a gate that blocked every operator recycle would pass the regression - /// case above and break the verb for everybody. With nothing held this is the pre-#3510 - /// composition exactly. - /// - [Fact] - public async Task WithNoInstallHoldingTheRoot_TheOperatorRecycleProceedsAtOnce() - { - var leases = Mesh.ServiceProvider.GetRequiredService(); - var path = await SeedNode("op-recycle-free-root"); - - leases.IsHeld(path).Should().BeFalse("no install is running"); - - var answer = await new MeshOperations(SessionHub()).Recycle(path) - .Should().Within(TestTimeouts.Convergence).Emit( - "an ordinary operator recycle must still proceed promptly — the deferral is scoped " - + "to a root an install actually holds"); - - using var envelope = JsonDocument.Parse(answer); - envelope.RootElement.GetProperty("status").GetString().Should().Be("Recycled", - "nothing held the root, so the verb ran end to end"); - } -} diff --git a/test/Memex.Portal.Shared.Test/OrphanedPackageRecordTest.cs b/test/Memex.Portal.Shared.Test/OrphanedPackageRecordTest.cs deleted file mode 100644 index 8135945d6d..0000000000 --- a/test/Memex.Portal.Shared.Test/OrphanedPackageRecordTest.cs +++ /dev/null @@ -1,181 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using System.Linq; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 An install record naming a package NO PUBLISHER PRODUCES must be NAMED, and must never -/// hold (#3706). -/// -/// memex's install records carried Plugins/Agent, Plugins/Skill and -/// Plugins/PlatformUI after the packages themselves had gone — Agent and Skill deliberately -/// (MeshWeaver.Plugins@7afbd745, "remove agents and skill package and serve from the main ai -/// package"; the AI engine's BuiltInAgentProvider/BuiltInSkillProvider are the live -/// master). The issue's premise was that their retired floors held every self-update for ever. -/// -/// Half of that premise was already false, and the other half is what this fixes. The -/// floors stopped deciding anything at #3648 and a missing bake stopped holding at #3651, so -/// nothing was actually blocking — the sentence quoted as the blocker came from a -/// heldReason written once, 36 hours before it was read, by a path that no longer runs. -/// Not holding was right. Saying NOTHING was not: an operator had no way to learn the records were -/// stale except by reading that frozen field and reaching the wrong conclusion. -/// -/// These cases pin both halves at once. Asserting only "it does not hold" would pass against -/// a gate that had never heard of the package — which is precisely the state that made #3706 hard -/// to diagnose — so every case that asserts IsUpdatable also asserts that the package is -/// NAMED, and is the -/// control that stops the advisory degrading into an unconditional sentence. -/// -public class OrphanedPackageRecordTest -{ - private const string Identity = "s8055"; - private static readonly ReleaseTarget Target = new("3.0.0-ci.8195", Identity); - - /// A module set that WAS read completely and carries only . - private static SealedModuleSet SetCarrying(params string[] modules) => - new(modules.ToImmutableDictionary(m => m, m => "mvid:" + new string('a', 32)), [], null); - - private static ReleaseArtifacts Artifacts(SealedModuleSet? modules, params string[] bundles) => - ReleaseArtifacts.Of([.. bundles]) with { Modules = modules }; - - /// The package this instance can never obtain: its module is not in the target's - /// sealed set, and no generation of it is landed here. - private static RequiredPackage Orphan(string name) => - new(name, name, MinMeshVersion: "3.0.0", HasContent: false) { ModuleName = "MeshWeaver." + name }; - - [Fact] - public void APackageNoPublisherProduces_IsNamed_AndDoesNotHold() - { - var verdict = ReleaseAvailability.IsUpdatable( - Target, - [Orphan("Agent"), Orphan("Skill"), Orphan("PlatformUI")], - Artifacts(SetCarrying("MeshWeaver.AI"))); - - // 🚨 Not holding is the point: a roll cannot conjure a build nobody publishes, so waiting - // for one waits for ever — the shape #3706 is named after. - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Null(verdict.HoldReason); - Assert.Empty(verdict.Blockers); - Assert.All(verdict.Packages, p => Assert.Equal(PackageAvailabilityKind.Available, p.Kind)); - - // 🚨 …and being SILENT about it was the other half of the defect. One orphan advisory per - // record, naming the package AND the module, so the operator can act on the record itself. - // - // Six advisories, not three: each record also declares a 3.0.0 floor the ci target does not - // satisfy, and that floor sentence rides BESIDE the orphan one. Asserted deliberately — - // the two answer different questions ("this release is older than the module asks for" vs - // "nobody publishes this module at all"), and a fix that swallowed the floor advisory while - // adding the orphan one would be a regression of #3648 that a total-count assertion of 3 - // would have called a pass. - Assert.Equal(6, verdict.Advisories.Length); - var orphanAdvisories = verdict.Advisories - .Where(a => a.Contains("no publisher produces it", StringComparison.Ordinal)) - .ToArray(); - Assert.Equal(3, orphanAdvisories.Length); - foreach (var name in new[] { "Agent", "Skill", "PlatformUI" }) - { - var advisory = Assert.Single( - orphanAdvisories, a => a.StartsWith(name + ":", StringComparison.Ordinal)); - Assert.Contains("MeshWeaver." + name, advisory, StringComparison.Ordinal); - // The floor sentence is still there for the same package, untouched. - Assert.Contains( - verdict.Advisories, - a => a.StartsWith(name + ":", StringComparison.Ordinal) - && a.Contains("3.0.0", StringComparison.Ordinal) - && !a.Contains("no publisher produces it", StringComparison.Ordinal)); - Assert.Contains("no publisher produces it", advisory, StringComparison.Ordinal); - Assert.Contains("never a hold", advisory, StringComparison.Ordinal); - // The remedy is on the record, not on the roll — the actionable half. - Assert.Contains("remove its install record", advisory, StringComparison.Ordinal); - } - } - - /// - /// 🚨 THE CONTROL that keeps the advisory honest. A module set that could not be read means the - /// module was never LOOKED for, and "we did not look" must not be worded as "it does not - /// exist" — the conflation #1754 forbids one severity up. Without this case the advisory could - /// degrade into an unconditional sentence and still pass everything above. - /// - [Fact] - public void AnUnreadableModuleSet_SaysNothing_BecauseNothingWasLookedFor() - { - var refused = ReleaseAvailability.IsUpdatable( - Target, [Orphan("Agent")], - Artifacts(new SealedModuleSet(ImmutableDictionary.Empty, [], "a torn module index"))); - - Assert.True(refused.IsUpdatable, refused.HoldReason); - Assert.DoesNotContain(refused.Advisories, a => a.Contains("no publisher", StringComparison.Ordinal)); - - var unobserved = ReleaseAvailability.IsUpdatable(Target, [Orphan("Agent")], Artifacts(null)); - - Assert.True(unobserved.IsUpdatable, unobserved.HoldReason); - Assert.DoesNotContain(unobserved.Advisories, a => a.Contains("no publisher", StringComparison.Ordinal)); - } - - /// - /// A package the target DOES carry is not an orphan, however retired its declared floor looks. - /// This is the case the advisory must not swallow: "3.0.0 floor on a ci target" is the string - /// comparison that declined every candidate on every portal on 2026-09-07 (#3648), and it must - /// stay a floor advisory rather than becoming a second, wronger sentence. - /// - [Fact] - public void APackageTheTargetCarries_GetsNoOrphanAdvisory() - { - var verdict = ReleaseAvailability.IsUpdatable( - Target, - [new RequiredPackage("AI", "AI", MinMeshVersion: "3.0.0", HasContent: false) { ModuleName = "MeshWeaver.AI" }], - Artifacts(SetCarrying("MeshWeaver.AI"))); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.DoesNotContain(verdict.Advisories, a => a.Contains("no publisher", StringComparison.Ordinal)); - // The floor still rides beside it, unchanged by this work. - Assert.Contains(verdict.Advisories, a => a.Contains("3.0.0", StringComparison.Ordinal)); - } - - /// - /// A content-only record has no module to look for, so it takes the bake path and its wording — - /// "would recompile at boot" — rather than the orphan sentence. Two different absences, two - /// different remedies; conflating them would tell an operator to delete a record that is fine. - /// - [Fact] - public void AContentOnlyRecordWithNoBake_KeepsTheBakeWording() - { - var verdict = ReleaseAvailability.IsUpdatable( - Target, - [new RequiredPackage("Store", "Store", null, HasContent: true)], - Artifacts(SetCarrying("MeshWeaver.AI"))); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Equal(["Store"], verdict.BootCompiles); - Assert.DoesNotContain(verdict.Advisories, a => a.Contains("no publisher", StringComparison.Ordinal)); - Assert.Contains(verdict.Advisories, a => a.Contains("would recompile at boot", StringComparison.Ordinal)); - } - - /// - /// A landed generation is NOT an orphan even when the target publishes no build of it: that is - /// the module lane's own case, measured on the bytes by the link probe - /// (ModuleLinkState), and it may legitimately end in a HOLD. The orphan advisory must - /// not intercept it — the two are told apart by whether anything is landed at all. - /// - [Fact] - public void ALandedModuleTheTargetDoesNotPublish_StaysWithTheLinkProbe() - { - var landed = new RequiredPackage("Edu", "Edu", null, HasContent: false) - { - ModuleName = "MeshWeaver.Edu", - LandedModulePath = "/app/modules/MeshWeaver.Edu@7/MeshWeaver.Edu.dll", - }; - - var verdict = ReleaseAvailability.IsUpdatable(Target, [landed], Artifacts(SetCarrying("MeshWeaver.AI"))); - - Assert.DoesNotContain(verdict.Advisories, a => a.Contains("no publisher", StringComparison.Ordinal)); - // Unmeasured here, so the link lane reports its own "could not be determined" and does not hold. - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Contains(verdict.Advisories, a => a.Contains("could not be determined", StringComparison.Ordinal)); - } -} diff --git a/test/Memex.Portal.Shared.Test/OutboundEmailWatchQueryTest.cs b/test/Memex.Portal.Shared.Test/OutboundEmailWatchQueryTest.cs deleted file mode 100644 index 64f36ca089..0000000000 --- a/test/Memex.Portal.Shared.Test/OutboundEmailWatchQueryTest.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Memex.Portal.Shared.Email; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the shape of against the omitted-default -/// serialization trap that stranded the Store contact form's notification email on memex -/// (2026-08-12): is the enum default, the serializer omits default -/// values from stored JSON, so a query POSITIVELY matching content.status:New matches -/// NOTHING that is actually new — proven live (the status-filtered query returned 0 rows while -/// the unfiltered one returned the queued email). NEGATIONS are safe AND keep the live set -/// bounded: a negation on an omitted field never excludes (also proven live — -/// -content.status:New still returned the status-omitted email), while explicitly stamped -/// terminal states drop out instead of accumulating forever. -/// -public class OutboundEmailWatchQueryTest -{ - [Fact] - public void WatchQuery_MustNotPositivelyMatchStatus_TheDefaultIsOmittedFromStoredJson() - { - // The trap: a positive match on the omittable default. Negated clauses are fine — - // strip them before asserting nothing positive remains. - var withoutNegations = OutboundEmailWatchQueryTestHelpers.StripNegatedClauses( - OutboundEmailSender.WatchQuery); - Assert.DoesNotContain("content.status", withoutNegations); - Assert.Contains("content.direction:Outbound", withoutNegations); - Assert.Contains($"nodeType:{EmailNodeType.NodeType}", withoutNegations); - } - - /// The bounded shape: every non-New status is negated away, so processed mail drops - /// out of the live set instead of re-emitting forever, while New — stored with the status - /// OMITTED, which no negation excludes — always matches. - [Fact] - public void WatchQuery_NegatesEveryTerminalStatus_SoTheLiveSetStaysBounded() - { - Assert.Contains("-content.status:Sending", OutboundEmailSender.WatchQuery); - Assert.Contains("-content.status:Sent", OutboundEmailSender.WatchQuery); - Assert.Contains("-content.status:Failed", OutboundEmailSender.WatchQuery); - } - - /// - /// The reasoning the query shape rests on, pinned so an enum reorder cannot silently - /// invalidate it: New IS the omittable default (which is why no positive status match - /// can ever see queued mail, and why the negations cannot exclude it), and Outbound is - /// NOT the direction default (which is why the positive direction match is safe — a queued - /// outbound email always serializes it). If either assertion fails, the watch query above has - /// to be rethought, not just this test. - /// - [Fact] - public void TheDefaults_AreWhatTheQueryShapeAssumes() - { - Assert.Equal(EmailStatus.New, default(EmailStatus)); - Assert.NotEqual(EmailDirection.Outbound, default(EmailDirection)); - } -} - -internal static class OutboundEmailWatchQueryTestHelpers -{ - /// Removes every -negated:clause token so a test can assert on the POSITIVE - /// clauses alone. - public static string StripNegatedClauses(string query) => - string.Join(' ', - query.Split(' ', System.StringSplitOptions.RemoveEmptyEntries) - .Where(token => !token.StartsWith('-'))); -} diff --git a/test/Memex.Portal.Shared.Test/OutboundSendQueueTest.cs b/test/Memex.Portal.Shared.Test/OutboundSendQueueTest.cs deleted file mode 100644 index 04b4ff195f..0000000000 --- a/test/Memex.Portal.Shared.Test/OutboundSendQueueTest.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System.Collections.Concurrent; -using System.Reactive.Linq; -using Memex.Portal.Shared.Email; -using MeshWeaver.Mesh; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the fix for the outbound DOUBLE-DELIVERY observed live on 2026-08-21: every queued mail -/// arrived twice, because the watch legitimately emits the same New snapshot more than once -/// (initial snapshot + change echo — the reason tests must never assert "exactly N change events") -/// and the old New → Sending "claim" was a plain last-write-wins update whose "claim failed" guard -/// was unreachable. serializes sends and re-reads the mail's -/// authoritative state before each one; these cases drive that state machine through delegate -/// seams over a tiny in-test store — deterministic, no hub, no timing sleeps. -/// -public class OutboundSendQueueTest -{ - private static readonly TimeSpan TestBudget = TimeSpan.FromSeconds(10); - - private sealed class Harness : IDisposable - { - public readonly ConcurrentDictionary Store = new(); - public readonly ConcurrentDictionary Sends = new(); - public Func>? SendOverride; - public Func>? ReadOverride; - public OutboundSendQueue Queue { get; } - - public Harness(TimeSpan? readBudget = null) - { - Queue = new OutboundSendQueue( - // Defer: the read must observe the store AS OF the moment the queue processes the - // item, not as of enqueue — that freshness is the very property under test. - readCurrent: path => ReadOverride?.Invoke(path) - ?? Observable.Defer(() => Observable.Return( - Store.TryGetValue(path, out var email) ? email : null)), - writeStatus: (node, current, status) => Observable.Defer(() => - { - Store[node.Path] = current with { Status = status }; - return Observable.Return(node); - }), - send: email => - { - Sends.AddOrUpdate(email.To ?? "", 1, (_, n) => n + 1); - return SendOverride?.Invoke(email) ?? Observable.Return(true); - }, - readBudget: readBudget); - } - - public MeshNode QueuedMail(string id, string to = "one@example.test") - { - var email = new MeshWeaver.Mesh.Email - { - Direction = EmailDirection.Outbound, - Status = EmailStatus.New, - To = to, - Subject = "s", - Body = "b", - }; - var node = new MeshNode(id, "T") { NodeType = "Email", Name = id, Content = email }; - Store[node.Path] = email; - return node; - } - - public Task> AwaitProcessed(int count) => - Queue.Processed.Take(count).ToList().Timeout(TestBudget).FirstAsync().Await(TestContext.Current.CancellationToken); - - public void Dispose() => Queue.Dispose(); - } - - [Fact] - public async Task DuplicateEmissions_SendExactlyOnce() - { - using var h = new Harness(); - var node = h.QueuedMail("m1"); - var email = (MeshWeaver.Mesh.Email)node.Content!; - - // The live failure mode: the SAME New snapshot delivered twice by the watch. The awaiter - // is armed FIRST: the seams are synchronous, so processing completes inside Enqueue. - var done = h.AwaitProcessed(2); - h.Queue.Enqueue(node, email); - h.Queue.Enqueue(node, email); - await done; - - Assert.Equal(1, h.Sends["one@example.test"]); - Assert.Equal(EmailStatus.Sent, h.Store[node.Path].Status); - } - - [Fact] - public async Task StaleEchoAfterCompletion_DoesNotResend() - { - using var h = new Harness(); - var node = h.QueuedMail("m1"); - var email = (MeshWeaver.Mesh.Email)node.Content!; - - var first = h.AwaitProcessed(1); - h.Queue.Enqueue(node, email); - await first; - // A late echo of the long-finished New snapshot — the re-read sees Sent and skips. - var echo = h.AwaitProcessed(1); - h.Queue.Enqueue(node, email); - await echo; - - Assert.Equal(1, h.Sends["one@example.test"]); - Assert.Equal(EmailStatus.Sent, h.Store[node.Path].Status); - } - - [Fact] - public async Task AlreadyClaimedElsewhere_IsSkipped() - { - using var h = new Harness(); - var node = h.QueuedMail("m1"); - var email = (MeshWeaver.Mesh.Email)node.Content!; - h.Store[node.Path] = email with { Status = EmailStatus.Sending }; - - var done = h.AwaitProcessed(1); - h.Queue.Enqueue(node, email); - await done; - - Assert.Empty(h.Sends); - Assert.Equal(EmailStatus.Sending, h.Store[node.Path].Status); - } - - [Fact] - public async Task FailedSend_MarksFailed() - { - using var h = new Harness(); - h.SendOverride = _ => Observable.Return(false); - var node = h.QueuedMail("m1"); - - var done = h.AwaitProcessed(1); - h.Queue.Enqueue(node, (MeshWeaver.Mesh.Email)node.Content!); - await done; - - Assert.Equal(1, h.Sends["one@example.test"]); - Assert.Equal(EmailStatus.Failed, h.Store[node.Path].Status); - } - - [Fact] - public async Task SendError_MarksFailed_AndTheQueueContinues() - { - using var h = new Harness(); - h.SendOverride = email => email.To == "boom@example.test" - ? Observable.Throw(new InvalidOperationException("graph down")) - : Observable.Return(true); - var poisoned = h.QueuedMail("m1", to: "boom@example.test"); - var healthy = h.QueuedMail("m2", to: "ok@example.test"); - - var done = h.AwaitProcessed(2); - h.Queue.Enqueue(poisoned, (MeshWeaver.Mesh.Email)poisoned.Content!); - h.Queue.Enqueue(healthy, (MeshWeaver.Mesh.Email)healthy.Content!); - await done; - - Assert.Equal(EmailStatus.Failed, h.Store[poisoned.Path].Status); - Assert.Equal(EmailStatus.Sent, h.Store[healthy.Path].Status); - Assert.Equal(1, h.Sends["ok@example.test"]); - } - - [Fact] - public async Task UnansweredStateRead_SkipsWithoutSending_AndTheQueueContinues() - { - using var h = new Harness(readBudget: TimeSpan.FromMilliseconds(100)); - var silent = h.QueuedMail("m1", to: "silent@example.test"); - var healthy = h.QueuedMail("m2", to: "ok@example.test"); - h.ReadOverride = path => path == silent.Path - ? Observable.Never() - : Observable.Defer(() => Observable.Return( - h.Store.TryGetValue(path, out var email) ? email : (MeshWeaver.Mesh.Email?)null)); - - var done = h.AwaitProcessed(2); - h.Queue.Enqueue(silent, (MeshWeaver.Mesh.Email)silent.Content!); - h.Queue.Enqueue(healthy, (MeshWeaver.Mesh.Email)healthy.Content!); - await done; - - // Fail CLOSED: no send on an unanswerable read — the mail stays New for a later retry. - Assert.False(h.Sends.ContainsKey("silent@example.test")); - Assert.Equal(EmailStatus.New, h.Store[silent.Path].Status); - Assert.Equal(EmailStatus.Sent, h.Store[healthy.Path].Status); - } -} diff --git a/test/Memex.Portal.Shared.Test/OutboundSubjectTest.cs b/test/Memex.Portal.Shared.Test/OutboundSubjectTest.cs deleted file mode 100644 index 7c925c6a6c..0000000000 --- a/test/Memex.Portal.Shared.Test/OutboundSubjectTest.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Memex.Portal.Shared.Email; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The subject an outbound mail leaves with. -/// -/// The rule is "add a reply marker unless one is already there", and the whole defect was in -/// what counts as already there. StartsWith("Re:") is right for the case this lane was built -/// for — the Email Router's reply, whose subject the agent writes as Re: <original> — -/// and wrong for every outbound mail that is not a reply. The forward action of -/// Systemorph/MeshWeaver#2656 is what made it visible: the inbox lane queues -/// Fwd: Quote for 200 seats and it went out as Re: Fwd: Quote for 200 seats, telling -/// the recipient the opposite of what happened. -/// -public class OutboundSubjectTest -{ - [Theory] - [InlineData("Re: Project status")] - [InlineData("RE: Project status")] - [InlineData("re: Project status")] - [InlineData("Re[2]: Project status")] - [InlineData("AW: Project status")] // German reply - public void ASubjectThatIsAlreadyAReply_IsSentUnchanged(string subject) - => OutboundEmailSender.OutboundSubject(subject).Should().Be(subject); - - [Theory] - [InlineData("Fwd: Quote for 200 seats")] - [InlineData("FW: Quote for 200 seats")] - [InlineData("WG: Quote for 200 seats")] // German forward - [InlineData(" Fwd: Quote for 200 seats")] // whatever the mail client left in front - public void ASubjectThatIsAlreadyAForward_IsNotTurnedIntoAReply(string subject) - => OutboundEmailSender.OutboundSubject(subject).Should().Be(subject, - "a forward that arrives as \"Re: Fwd: …\" tells the recipient the opposite of what " - + "happened to it"); - - [Fact] - public void APlainSubject_StillGetsTheReplyMarker() - => OutboundEmailSender.OutboundSubject("Project status") - .Should().Be("Re: Project status", - "the agent-reply case this lane was built for is unchanged"); - - [Fact] - public void AMarkerInTheMIDDLE_IsNotAPrefix() - => OutboundEmailSender.OutboundSubject("Question about the RE: header") - .Should().Be("Re: Question about the RE: header", - "only a LEADING marker says the subject is already a reply or a forward"); - - [Fact] - public void AnEmptyOrNullSubject_DoesNotThrow() - { - OutboundEmailSender.OutboundSubject("").Should().Be("Re: "); - OutboundEmailSender.OutboundSubject(null).Should().Be("Re: "); - } -} diff --git a/test/Memex.Portal.Shared.Test/PackageConfigRequirementTest.cs b/test/Memex.Portal.Shared.Test/PackageConfigRequirementTest.cs deleted file mode 100644 index 32b8a28d1d..0000000000 --- a/test/Memex.Portal.Shared.Test/PackageConfigRequirementTest.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Text.Json; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The manifest's configuration declaration — what a plugin says it must be TOLD before it works, -/// which the first-run setup wizard renders instead of hard-coding a form per plugin. -/// -/// The two properties worth pinning are that an AUTHORED declaration survives the round trip -/// a catalog makes it take, and that a manifest without one reads as "nothing to ask" — so adoption -/// is additive and a reader that predates the field is unaffected. -/// -public class PackageConfigRequirementTest -{ - [Fact] - public void AManifestWithoutADeclarationAsksNothing() - { - var manifest = JsonSerializer.Deserialize("""{"Id":"Essentials"}"""); - Assert.NotNull(manifest); - Assert.Empty(manifest!.Configuration); - } - - [Fact] - public void AnAuthoredDeclarationSurvivesTheRoundTrip() - { - // The shape an author writes, and the shape the registry serves, are the same shape. - var json = """ - { - "Id": "Reinsurance", - "Configuration": [ - { - "Key": "Reinsurance__ApiKey", - "Secret": true, - "Kind": "other", - "Purpose": "pricing calls fail without it", - "Validation": "^[A-Za-z0-9-]{20,}$", - "ResourceHelp": "az cognitiveservices account create …" - } - ] - } - """; - - var manifest = JsonSerializer.Deserialize(json)!; - var requirement = Assert.Single(manifest.Configuration); - - Assert.Equal("Reinsurance__ApiKey", requirement.Key); - Assert.True(requirement.Secret); - Assert.Equal("other", requirement.Kind); - Assert.Contains("pricing calls fail", requirement.Purpose); - Assert.Equal("^[A-Za-z0-9-]{20,}$", requirement.Validation); - Assert.Contains("az cognitiveservices", requirement.ResourceHelp); - - // And it serialises back to something a later reader parses identically. - var again = JsonSerializer.Deserialize(JsonSerializer.Serialize(manifest))!; - Assert.Equal(manifest.Configuration, again.Configuration); - } - - [Fact] - public void ADeclarationDefaultsToPlainAndUnexplained() - { - // Only the key is required: a plugin may declare a bare key and say no more. Defaulting - // Secret to FALSE is the safe direction — a plain value written to a vault is merely odd, - // while a secret treated as plain would be recorded on the deployment record, which is git. - var manifest = JsonSerializer.Deserialize( - """{"Id":"X","Configuration":[{"Key":"X__Endpoint"}]}""")!; - - var requirement = Assert.Single(manifest.Configuration); - Assert.False(requirement.Secret); - Assert.Null(requirement.Kind); - Assert.Null(requirement.Purpose); - Assert.Null(requirement.ResourceHelp); - } -} diff --git a/test/Memex.Portal.Shared.Test/PackageDependencyFloorHoldTest.cs b/test/Memex.Portal.Shared.Test/PackageDependencyFloorHoldTest.cs deleted file mode 100644 index 33d2c3bf4a..0000000000 --- a/test/Memex.Portal.Shared.Test/PackageDependencyFloorHoldTest.cs +++ /dev/null @@ -1,504 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#6192, reproduced on the lane that caused it — the unattended registry update. -/// -/// Measured on the control instance on 2026-10-10/11: Governance auto-updated to a version -/// declaring Essentials@^1.12.0 while the Essentials module that ran was 1.11.26, and -/// AppleWeather to one declaring AI@^1.27.0 beside AI 1.26.2. The install records then stated -/// a floor the running dependency did not meet, so every module-set proposal on the deployment was -/// refused ([ModuleSet] NOT proposing the landed module set, logged on every reconcile of -/// every replica). Neither package ships a module, so nothing on the module lane could withhold -/// them: the update itself has to be held. -/// -/// The fixture is the platform-floor one (): a package installed -/// at v1 whose v2 declares a requirement, driven through the real -/// , with the source's fetch log as the witness that the held -/// version never travelled. What this process "has loaded" is the -/// reading, set by the test because a test process cannot load two builds of one module. -/// -public class PackageDependencyFloorHoldTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Dependency = "DepPkg"; - private const string DependencyModule = "MeshWeaver.DepPkg"; - private const string Requirement = Dependency + "@^1.12.0"; - - private readonly SettableLoadedModules loaded = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false }) - // Registered AFTER the catalog's own reader, so this reading is the one resolved. - .AddSingleton(loaded)); - - private ILogger Logger => Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - private static PackageManifest V2Requiring(string requirement) => - FloorHoldFixture.V2(floor: null!) with { Requires = [requirement] }; - - private async Task InstallV1(PackageUpdatePolicy policy) - { - var ct = TestContext.Current.CancellationToken; - await Mesh.ServiceProvider.GetRequiredService().Completed - .FirstAsync().Timeout(180.Seconds()).Await(ct); - Mesh.ServiceProvider.GetRequiredService().Should().BeSameAs(loaded, - "the precondition of every arm: the gate reads the reading this test sets"); - await FloorHoldFixture.InstallV1(Mesh, Logger, ct); - await PackageInstaller.SetUpdatePolicy(Mesh, FloorHoldFixture.Package, policy, Logger) - .Timeout(60.Seconds()).Await(ct); - } - - /// - /// THE reproduction: the loaded dependency is below the candidate's floor → the update is held, - /// nothing is fetched, the installed sources and the record's requirements stay → ticked again, - /// one held state → a satisfying dependency is loaded → the same update lands and the hold clears. - /// - [Fact(Timeout = 300_000)] - public async Task AnAutoUpdate_IsHeldWhileTheLoadedDependencyIsBelowItsFloor_ThenLands() - { - var ct = TestContext.Current.CancellationToken; - await InstallV1(PackageUpdatePolicy.Auto); - loaded.Set(Dependency, DependencyModule, "1.11.26"); - - var source = new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()); - await FloorHoldFixture.Reconcile(Mesh, source, V2Requiring(Requirement), Logger, ct); - - var held = await FloorHoldFixture.AwaitRecord(Mesh, r => r.HeldUpdate is not null, ct); - source.Fetches.Should().Be(0, "the held version's sources are never fetched, so nothing can compile them"); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V1Text, "the installed version keeps serving"); - held.ModuleVersion.Should().Be(FloorHoldFixture.V1Hash, "the record still describes the installed version"); - held.Requires.Should().BeEmpty( - "THE property the module set depends on: the record does not take the floor the dependency does not meet"); - held.HeldUpdate.Should().Contain("held").And.Contain(FloorHoldFixture.V2Version).And.Contain(Requirement) - .And.Contain(DependencyModule).And.Contain("1.11.26"); - PackageDependencyFloorGate.IsDependencyHold(held.HeldUpdate).Should().BeTrue(); - PackagePlatformFloorGate.IsFloorHold(held.HeldUpdate).Should().BeFalse( - "a met platform floor must not clear a dependency hold"); - held.HeldSince.Should().NotBeNull("the fleet view's 'held since' reads it"); - - // ── the same held state, ticked again: nothing is fetched and the since-when stays ────── - await FloorHoldFixture.Reconcile(Mesh, source, V2Requiring(Requirement), Logger, ct); - await FloorHoldFixture.Reconcile(Mesh, source, V2Requiring(Requirement), Logger, ct); - source.Fetches.Should().Be(0); - var again = await FloorHoldFixture.AwaitRecord(Mesh, r => r.HeldUpdate is not null, ct); - again.HeldUpdate.Should().Be(held.HeldUpdate); - again.HeldSince.Should().Be(held.HeldSince, "an unchanged hold keeps the moment it began"); - again.ModuleVersion.Should().Be(FloorHoldFixture.V1Hash); - - // ── a satisfying dependency is loaded: the same update lands and the hold clears ──────── - loaded.Set(Dependency, DependencyModule, "1.12.0"); - await FloorHoldFixture.Reconcile(Mesh, source, V2Requiring(Requirement), Logger, ct); - var landed = await FloorHoldFixture.AwaitRecord(Mesh, r => r.ModuleVersion == FloorHoldFixture.V2Hash, ct); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V2Text, - "the positive control: with the requirement met, the same update lands"); - source.Fetches.Should().BeGreaterThan(0); - landed.HeldUpdate.Should().BeNull("an install of the candidate ends the hold"); - landed.HeldSince.Should().BeNull(); - } - - /// - /// The negative control of the hold, and the rule's other half: a dependency this process has - /// NOT loaded a module for is not judged, so the update lands as it did before the gate existed. - /// A gate that held everything fails here. - /// - [Fact(Timeout = 300_000)] - public async Task ADependencyWithNoLoadedModule_IsNotJudged_AndTheUpdateLands() - { - var ct = TestContext.Current.CancellationToken; - await InstallV1(PackageUpdatePolicy.Auto); - loaded.Set("SomeOtherPackage", "MeshWeaver.Other", "0.0.1"); - - var source = new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()); - await FloorHoldFixture.Reconcile(Mesh, source, V2Requiring(Requirement), Logger, ct); - - var landed = await FloorHoldFixture.AwaitRecord(Mesh, r => r.ModuleVersion == FloorHoldFixture.V2Hash, ct); - landed.HeldUpdate.Should().BeNull(); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V2Text); - } - - /// - /// The reminder lane: an update this instance cannot run is not OFFERED either, and it is offered - /// once the dependency is loaded. - /// - [Fact(Timeout = 300_000)] - public async Task ANotifyUpdate_IsNotOfferedWhileHeld_AndIsOfferedOnceTheDependencyIsLoaded() - { - var ct = TestContext.Current.CancellationToken; - await InstallV1(PackageUpdatePolicy.Notify); - loaded.Set(Dependency, DependencyModule, "1.11.26"); - - var source = new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()); - await FloorHoldFixture.Reconcile(Mesh, source, V2Requiring(Requirement), Logger, ct); - var held = await FloorHoldFixture.AwaitRecord(Mesh, r => r.HeldUpdate is not null, ct); - held.NotifiedModuleVersion.Should().NotBe(FloorHoldFixture.V2Hash, "a held update raises no reminder"); - PackageDependencyFloorGate.IsDependencyHold(held.HeldUpdate).Should().BeTrue(); - - loaded.Set(Dependency, DependencyModule, "1.12.4"); - await FloorHoldFixture.Reconcile(Mesh, source, V2Requiring(Requirement), Logger, ct); - var offered = await FloorHoldFixture.AwaitRecord( - Mesh, r => r.NotifiedModuleVersion == FloorHoldFixture.V2Hash, ct); - offered.HeldUpdate.Should().BeNull("the met requirement clears the hold before the reminder is raised"); - offered.ModuleVersion.Should().Be(FloorHoldFixture.V1Hash, "a reminder applies nothing"); - source.Fetches.Should().Be(0); - } - - /// - /// The ONE install orchestrator holds too — the boot install and the click go through it, not - /// through the reconciler — and the same call lands the update once the dependency is loaded. - /// - [Fact(Timeout = 300_000)] - public async Task InstallOrUpdate_HoldsTheUpdate_ReturnsNothingWritten_ThenLands() - { - var ct = TestContext.Current.CancellationToken; - await InstallV1(PackageUpdatePolicy.Auto); - loaded.Set(Dependency, DependencyModule, "1.11.26"); - - var source = new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()); - var result = await CatalogLayoutAreas - .InstallOrUpdate(Mesh, source, "HEAD", V2Requiring(Requirement), Logger) - .Timeout(120.Seconds()).Await(ct); - - result.Should().Be(new InstallResult(0, 0)); - source.Fetches.Should().Be(0); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V1Text); - var held = await FloorHoldFixture.AwaitRecord(Mesh, r => r.HeldUpdate is not null, ct); - held.ModuleVersion.Should().Be(FloorHoldFixture.V1Hash); - held.Requires.Should().BeEmpty(); - PackageDependencyFloorGate.IsDependencyHold(held.HeldUpdate).Should().BeTrue(); - - loaded.Set(Dependency, DependencyModule, "1.12.0"); - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, source, "HEAD", V2Requiring(Requirement), Logger) - .Timeout(180.Seconds()).Await(ct); - var landed = await FloorHoldFixture.AwaitRecord(Mesh, r => r.ModuleVersion == FloorHoldFixture.V2Hash, ct); - landed.HeldUpdate.Should().BeNull(); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V2Text); - } - - /// - /// A hold is DECIDED on a record read before it is written. When the candidate lands in between - /// (another replica whose loaded dependency meets the floor, or a click), the late hold must not - /// be stamped on the installed version: the next reconcile ends at the equal-hash gate, before - /// anything clears a hold, so it would stay for good. - /// - [Fact(Timeout = 300_000)] - public async Task AHoldDecidedOnAStaleRecord_IsNotStampedOnceTheCandidateHasLanded() - { - var ct = TestContext.Current.CancellationToken; - await InstallV1(PackageUpdatePolicy.Auto); - var staleRecord = await FloorHoldFixture.AwaitRecord(Mesh, r => r.ModuleVersion == FloorHoldFixture.V1Hash, ct); - var candidate = V2Requiring(Requirement); - - // The candidate lands while the hold is in flight (no loaded reading for the dependency here). - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()), "HEAD", candidate, Logger) - .Timeout(180.Seconds()).Await(ct); - await FloorHoldFixture.AwaitRecord(Mesh, r => r.ModuleVersion == FloorHoldFixture.V2Hash, ct); - - var unmet = new UnmetPackageDependency(Requirement, Dependency, DependencyModule, "1.11.26"); - await PackageDependencyFloorGate.RecordHold(Mesh, candidate, staleRecord, unmet, Logger) - .Timeout(60.Seconds()).Await(ct); - - var after = await FloorHoldFixture.AwaitRecord(Mesh, r => r.ModuleVersion == FloorHoldFixture.V2Hash, ct); - after.HeldUpdate.Should().BeNull("the candidate is what is installed — there is no update left to hold"); - after.HeldSince.Should().BeNull(); - - // The control: on a record still at the installed version the same call does stamp the hold. - PackageDependencyFloorGate.AlreadyAt(staleRecord, candidate).Should().BeFalse(); - PackageDependencyFloorGate.AlreadyAt(after, candidate).Should().BeTrue(); - } - - /// - /// A FRESH install is not held — the CURRENT behaviour, stated, not a claim that it is safe: a - /// dependency installed earlier in the same closure is landed, not loaded. Refusing it on the - /// loaded reading would fail a new instance's first boot for every default package whose floor is - /// above the image's own module copy; the module-set proposal still names such a floor. - /// - /// - /// The other stale-decision race: the record moved to a version that is NEITHER the one the hold - /// was decided on NOR its candidate (a newer candidate landed through another replica). The hold - /// describes an update from the decided version, so it is not stamped onto the newer record. - /// - [Fact(Timeout = 300_000)] - public async Task AHoldDecidedOnAStaleRecord_IsNotStampedOnceTheRecordMovedElsewhere() - { - var ct = TestContext.Current.CancellationToken; - await InstallV1(PackageUpdatePolicy.Auto); - var staleRecord = await FloorHoldFixture.AwaitRecord(Mesh, r => r.ModuleVersion == FloorHoldFixture.V1Hash, ct); - // The candidate this hold was decided for — an older one than what lands next. - var olderCandidate = V2Requiring(Requirement) with { ModuleVersion = "older-candidate-hash", Version = "1.5.0" }; - - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()), "HEAD", - V2Requiring(Requirement), Logger) - .Timeout(180.Seconds()).Await(ct); - await FloorHoldFixture.AwaitRecord(Mesh, r => r.ModuleVersion == FloorHoldFixture.V2Hash, ct); - - var unmet = new UnmetPackageDependency(Requirement, Dependency, DependencyModule, "1.11.26"); - await PackageDependencyFloorGate.RecordHold(Mesh, olderCandidate, staleRecord, unmet, Logger) - .Timeout(60.Seconds()).Await(ct); - - var after = await FloorHoldFixture.AwaitRecord(Mesh, r => r.ModuleVersion == FloorHoldFixture.V2Hash, ct); - after.HeldUpdate.Should().BeNull("the record moved off the version the hold was decided on"); - PackageDependencyFloorGate.Moved(after, staleRecord).Should().BeTrue(); - PackageDependencyFloorGate.AlreadyAt(after, olderCandidate).Should().BeFalse( - "the premise: the candidate check alone would have stamped it"); - } - - /// - /// Clearing a met hold clears THAT hold only: when the live record carries a different dependency - /// hold (decided after the clear's read), it stands. - /// - [Fact(Timeout = 300_000)] - public async Task ClearingAMetHold_LeavesADifferentHoldStanding() - { - var ct = TestContext.Current.CancellationToken; - await InstallV1(PackageUpdatePolicy.Auto); - var v1 = await FloorHoldFixture.AwaitRecord(Mesh, r => r.ModuleVersion == FloorHoldFixture.V1Hash, ct); - var candidate = V2Requiring(Requirement); - - await PackageDependencyFloorGate.RecordHold(Mesh, candidate, v1, - new UnmetPackageDependency(Requirement, Dependency, DependencyModule, "1.11.26"), Logger) - .Timeout(60.Seconds()).Await(ct); - var firstHold = await FloorHoldFixture.AwaitRecord(Mesh, r => r.HeldUpdate is not null, ct); - - // A later decision holds for another reason (another loaded version) — a different sentence. - await PackageDependencyFloorGate.RecordHold(Mesh, candidate, firstHold, - new UnmetPackageDependency(Requirement, Dependency, DependencyModule, "1.10.0"), Logger) - .Timeout(60.Seconds()).Await(ct); - var secondHold = await FloorHoldFixture.AwaitRecord(Mesh, r => r.HeldUpdate is { } h && h.Contains("1.10.0"), ct); - secondHold.HeldUpdate.Should().NotBe(firstHold.HeldUpdate, "the premise: two different holds"); - - // The clear was judged on the FIRST hold. - await PackageDependencyFloorGate.ClearMetHold(Mesh, firstHold, Logger).Timeout(60.Seconds()).Await(ct); - - var after = await FloorHoldFixture.AwaitRecord(Mesh, _ => true, ct); - after.HeldUpdate.Should().Be(secondHold.HeldUpdate, "the hold standing now was never judged met"); - - // The control: a clear judged on the hold that stands does clear it. - await PackageDependencyFloorGate.ClearMetHold(Mesh, secondHold, Logger).Timeout(60.Seconds()).Await(ct); - (await FloorHoldFixture.AwaitRecord(Mesh, r => r.HeldUpdate is null, ct)).HeldUpdate.Should().BeNull(); - } - - /// - /// A read of the install record that FAULTS stops the attempt: it is not a fresh install, so the - /// gate neither proceeds (which would fetch the candidate and advance the record onto the unmet - /// floor) nor answers "nothing written". - /// - [Fact(Timeout = 300_000)] - public async Task AFaultedRecordRead_StopsTheAttempt_InsteadOfReadingAsAFreshInstall() - { - var ct = TestContext.Current.CancellationToken; - await InstallV1(PackageUpdatePolicy.Auto); - loaded.Set(Dependency, DependencyModule, "1.11.26"); - var store = new FaultingRecordReads(Mesh.ServiceProvider.GetRequiredService(), - $"{PackageInstaller.InstalledPartition}/{FloorHoldFixture.Package}"); - var proceeded = 0; - - var outcome = await CatalogLayoutAreas - .HoldIfADependencyIsBelowItsFloor(Mesh, V2Requiring(Requirement), Logger, - () => - { - Interlocked.Increment(ref proceeded); - return Observable.Return(new InstallResult(1, 1)); - }, - store) - .Select(_ => (Exception?)null) - .Catch((Exception ex) => Observable.Return(ex)) - .Timeout(60.Seconds()).Await(ct); - - store.Faults.Should().BeGreaterThan(0, "the premise: the record read was taken and faulted"); - outcome.Should().BeOfType("the read's fault is the attempt's outcome"); - Volatile.Read(ref proceeded).Should().Be(0, "an unknown record is not a fresh install"); - (await FloorHoldFixture.AwaitRecord(Mesh, _ => true, ct)).ModuleVersion.Should().Be(FloorHoldFixture.V1Hash); - } - - /// The real store with the read of one path made to throw. - private sealed class FaultingRecordReads(IStorageAdapter inner, string faultOnPath) : IStorageAdapter - { - private int faults; - - public int Faults => Volatile.Read(ref faults); - - public IObservable Read(string path, JsonSerializerOptions options) - => string.Equals(path, faultOnPath, StringComparison.OrdinalIgnoreCase) - ? Observable.Defer(() => - { - Interlocked.Increment(ref faults); - return Observable.Throw(new TimeoutException("The operation has timed out.")); - }) - : inner.Read(path, options); - - public IObservable<(IEnumerable NodePaths, IEnumerable DirectoryPaths)> - ListChildPaths(string? parentPath) => inner.ListChildPaths(parentPath); - - public IObservable Changes => inner.Changes; - - public IObservable Write(MeshNode node, JsonSerializerOptions options) => inner.Write(node, options); - - public IObservable Delete(string path) => inner.Delete(path); - - public IObservable Exists(string path) => inner.Exists(path); - - public IObservable GetPartitionObjects(string nodePath, string? subPath, JsonSerializerOptions options) - => inner.GetPartitionObjects(nodePath, subPath, options); - - public IObservable SavePartitionObjects( - string nodePath, string? subPath, IReadOnlyCollection objects, JsonSerializerOptions options) - => inner.SavePartitionObjects(nodePath, subPath, objects, options); - - public IObservable DeletePartitionObjects(string nodePath, string? subPath = null) - => inner.DeletePartitionObjects(nodePath, subPath); - - public IObservable GetPartitionMaxTimestamp(string nodePath, string? subPath = null) - => inner.GetPartitionMaxTimestamp(nodePath, subPath); - } - - [Fact(Timeout = 300_000)] - public async Task AFreshInstall_IsNotHeld() - { - var ct = TestContext.Current.CancellationToken; - await Mesh.ServiceProvider.GetRequiredService().Completed - .FirstAsync().Timeout(180.Seconds()).Await(ct); - loaded.Set(Dependency, DependencyModule, "1.11.26"); - - var source = new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()); - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, source, "HEAD", V2Requiring(Requirement), Logger) - .Timeout(180.Seconds()).Await(ct); - - var installed = await FloorHoldFixture.AwaitRecord(Mesh, r => r.ModuleVersion == FloorHoldFixture.V2Hash, ct); - installed.HeldUpdate.Should().BeNull(); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V2Text); - } - - /// The loaded-module reading, set by the test. - private sealed class SettableLoadedModules : ILoadedPackageModules - { - private readonly BehaviorSubject> current = - new(ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase)); - - public void Set(string package, string module, string version) - => current.OnNext(current.Value.SetItem(package, new LoadedPackageModule(module, version))); - - public IObservable> Read() => current.Take(1); - } -} - -/// The pure decision, and what it means for the module set (the symptom of MeshWeaver#6192). -public class PackageDependencyFloorDecisionTest -{ - private static ImmutableDictionary Loaded(string package, string version) => - ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase) - .Add(package, new LoadedPackageModule("MeshWeaver.Markdown.Collaboration", version)); - - private static PackageManifest Governance(params string[] requires) => new() - { - Id = "Governance", - Version = "0.16.1", - ReleasedVersion = "0.16.1", - Requires = [.. requires], - }; - - [Theory] - [InlineData("Essentials@^1.12.0", "1.11.26", true)] - [InlineData("Essentials@^1.12.0", "1.12.0", false)] - [InlineData("Essentials@^1.12.0", "1.13.4", false)] - [InlineData("Essentials@^1.12.0", "2.0.0", true)] - [InlineData("essentials@^1.12.0", "1.11.26", true)] - [InlineData("Essentials", "1.11.26", false)] - [InlineData("Essentials@not-a-range", "1.11.26", false)] - [InlineData("AI@^1.27.0", "1.11.26", false)] - public void ARequirementIsUnmet_OnlyWhenTheLoadedDependencyIsOutsideItsRange( - string requirement, string loadedEssentials, bool held) - { - var unmet = PackageDependencyFloorGate.Unmet(Governance(requirement), Loaded("Essentials", loadedEssentials)); - - (unmet is not null).Should().Be(held); - if (unmet is null) - return; - unmet.Requirement.Should().Be(requirement); - unmet.Module.Should().Be("MeshWeaver.Markdown.Collaboration"); - unmet.LoadedVersion.Should().Be(loadedEssentials); - var sentence = PackageDependencyFloorGate.HeldSentence(Governance(requirement), unmet); - sentence.Should().Contain("0.16.1").And.Contain(requirement).And.Contain(loadedEssentials); - PackageDependencyFloorGate.IsDependencyHold(sentence).Should().BeTrue(); - PackagePlatformFloorGate.IsFloorHold(sentence).Should().BeFalse(); - } - - [Fact] - public void TheOtherHolds_AreNotDependencyHolds() - { - var floor = PackagePlatformFloorGate.HeldSentence( - new PackageManifest { Id = "Store", Version = "1.16.0", MinMeshVersion = "3.0.0-ci.9494" }, - PlatformFloor.Evaluate("3.0.0-ci.9494", "3.0.0-ci.9412")); - PackageDependencyFloorGate.IsDependencyHold(floor).Should().BeFalse(); - PackageDependencyFloorGate.IsDependencyHold(null).Should().BeFalse(); - PackageDependencyFloorGate.IsDependencyHold( - "held: 1.12.5 — 'Essentials' is kept by its sync source, which has landed a, not b; converges when " - + "the sync lands it (the seal for this platform)").Should().BeFalse(); - } - - /// - /// What the hold buys the MODULE SET: with the record kept at the installed version the proposal - /// has no unmet floor; with the record advanced (the behaviour before the gate) the whole wave is - /// refused — for a package that lands no module, which no module-lane rule can hold back. - /// - [Fact] - public void AHeldRecord_LeavesTheModuleSetProposable_WhereAnAdvancedOneRefusesTheWave() - { - var essentials = new PackageManifest - { - Id = "Essentials", - Module = "MeshWeaver.Markdown.Collaboration", - ReleasedVersion = "1.11.26", - }; - var landed = new ModuleActivationList - { - Entries = - [ - new ModuleActivationEntry - { - Name = "MeshWeaver.Markdown.Collaboration", - PackagePath = "Plugins/Essentials", - Version = "1.11.26", - Directory = "g-essentials-1-11-26", - Enabled = true, - }, - ], - }; - var installedBefore = Governance("Essentials@^1.11.0") with { Version = "0.15.2", ReleasedVersion = "0.15.2" }; - var advanced = Governance("Essentials@^1.12.0"); - - ModuleDependencyFloor.Unmet([essentials, installedBefore], landed).Should().BeEmpty( - "the held record keeps the requirement the running dependency meets"); - var refused = ModuleDependencyFloor.Unmet([essentials, advanced], landed).Should().ContainSingle().Subject; - refused.Dependent.Should().Be("Governance"); - refused.LoadedVersion.Should().Be("1.11.26"); - } -} diff --git a/test/Memex.Portal.Shared.Test/PackageFloorHoldTest.cs b/test/Memex.Portal.Shared.Test/PackageFloorHoldTest.cs deleted file mode 100644 index 69cfc6aab4..0000000000 --- a/test/Memex.Portal.Shared.Test/PackageFloorHoldTest.cs +++ /dev/null @@ -1,542 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 2026-09-27, reproduced end to end on the SOURCE-CONTENT lane (policy -/// package-min-mesh-version). -/// -/// Store 1.16 used IPaymentProvider billing-portal members and Hosting used -/// DeploymentContent.AnnouncementKeySecret; both synced onto instances running -/// 3.0.0-ci.9412/9414 — images predating those members — and 14 NodeTypes were left -/// with no usable assembly. The content lane decided an update by the manifest hash alone; the -/// declared floor was advisory everywhere, and the module lane's link probe cannot see NodeType -/// SOURCE, which compiles in the mesh after it has landed. -/// -/// The fixture: a package installed at v1 whose v2 sources reference a platform member the -/// running platform lacks, declaring a floor above the running platform. Driven through the real -/// paths — (the unattended registry reconcile), -/// (the click and the boot install) and -/// (a fresh install) — with the source's own fetch log as -/// the witness that the held sources never travelled, so nothing could compile them. -/// -/// Why the suite that existed did not catch 09-27. Every floor test asserted the -/// OPPOSITE (#3648's "advisory everywhere" fixtures — ModuleFloorAdvisoryTest), and none -/// drove the content lane with a floor at all: the reconcile/click tests carry no -/// MinMeshVersion, and no test compared a package's declared floor with the running version -/// on that lane, so "a newer package lands on an older platform" was the tested, intended -/// behaviour. -/// -public class PackageFloorHoldTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private readonly FloorHoldFixture.RecordingDispatch dispatch = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false }) - .AddSingleton(FloorFixture.Pin) - .AddSingleton(dispatch)); - - private ILogger Logger => Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - - private InstanceAutoRegistrationService Installer => - Mesh.ServiceProvider.GetRequiredService(); - - /// - /// THE reproduction, on the unattended lane: held → the installed version's sources stay and - /// nothing is fetched → ONE blocking ticket, not one per tick → floor satisfied → the update - /// lands and the hold clears. - /// - [Fact(Timeout = 300_000)] - public async Task TheReconcile_HoldsAnUpdateAboveTheFloor_KeepsTheInstalledSources_TicketsOnce_ThenLands() - { - var ct = TestContext.Current.CancellationToken; - FloorFixture.AssertTheFloorHolds(Mesh); - await Installer.Completed.FirstAsync().Timeout(180.Seconds()).Await(ct); - - await FloorHoldFixture.InstallV1(Mesh, Logger, ct); - await PackageInstaller.SetUpdatePolicy(Mesh, FloorHoldFixture.Package, PackageUpdatePolicy.Auto, Logger) - .Timeout(60.Seconds()).Await(ct); - - // ── HELD ──────────────────────────────────────────────────────────────────────────── - var source = new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()); - await FloorHoldFixture.Reconcile(Mesh, source, FloorHoldFixture.V2(FloorFixture.Above), Logger, ct); - - var held = await FloorHoldFixture.AwaitRecord(Mesh, r => r.HeldUpdateDispatchedAt is not null, ct); - source.Fetches.Should().Be(0, - "THE assertion of 09-27: the held version's sources are never fetched, so nothing can compile them"); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V1Text, - "the installed version's source keeps serving (R1)"); - held.ModuleVersion.Should().Be(FloorHoldFixture.V1Hash, "the record still describes the installed version"); - held.HeldUpdate.Should().Contain(FloorFixture.Above).And.Contain(FloorFixture.Running) - .And.Contain("held").And.Contain(FloorHoldFixture.V2Version); - held.HeldUpdateDispatch.Should().StartWith("blocking ticket dispatched"); - held.HeldSince.Should().NotBeNull("the fleet view's 'held since' and the too-long rule read it"); - - var ticket = dispatch.Tickets.Should().ContainSingle("one held state, one blocking ticket").Subject; - ticket.Package.Should().Be(FloorHoldFixture.Package); - ticket.HeldVersion.Should().Be(FloorHoldFixture.V2Version); - ticket.Floor.Should().Be(FloorFixture.Above); - ticket.Running.Should().Be(FloorFixture.Running); - ticket.InstalledVersion.Should().Be(FloorHoldFixture.V1Version); - ticket.Unblocks.Should().Contain("rolls to a platform ≥ " + FloorFixture.Above); - - // ── the same held state, ticked again: nothing new is sent, nothing is fetched ───────── - await FloorHoldFixture.Reconcile(Mesh, source, FloorHoldFixture.V2(FloorFixture.Above), Logger, ct); - await FloorHoldFixture.Reconcile(Mesh, source, FloorHoldFixture.V2(FloorFixture.Above), Logger, ct); - dispatch.Tickets.Should().ContainSingle("repeated ticks over one held state send ONE ticket, not one per tick"); - source.Fetches.Should().Be(0); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V1Text); - - // ── the floor satisfied: the update lands and the hold clears ───────────────────────── - await FloorHoldFixture.Reconcile(Mesh, source, FloorHoldFixture.V2(FloorFixture.Below), Logger, ct); - var landed = await FloorHoldFixture.AwaitRecord(Mesh, r => r.ModuleVersion == FloorHoldFixture.V2Hash, ct); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V2Text, - "the positive control: with the floor met, the same update lands"); - landed.HeldUpdate.Should().BeNull("an install of the candidate ends the hold"); - landed.HeldUpdateDispatch.Should().BeNull(); - landed.HeldUpdateDispatchedAt.Should().BeNull(); - landed.HeldSince.Should().BeNull("a hold that ended carries no since-when"); - dispatch.Tickets.Should().ContainSingle("a satisfied floor sends no ticket"); - } - - /// The click / boot-install lane: the ONE orchestrator holds too, before a file travels. - [Fact(Timeout = 300_000)] - public async Task InstallOrUpdate_HoldsTheUpdate_AndReturnsNothingWritten() - { - var ct = TestContext.Current.CancellationToken; - FloorFixture.AssertTheFloorHolds(Mesh); - await Installer.Completed.FirstAsync().Timeout(180.Seconds()).Await(ct); - await FloorHoldFixture.InstallV1(Mesh, Logger, ct); - - var source = new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()); - var result = await CatalogLayoutAreas - .InstallOrUpdate(Mesh, source, "HEAD", FloorHoldFixture.V2(FloorFixture.Above), Logger) - .Timeout(120.Seconds()).Await(ct); - - result.Should().Be(new InstallResult(0, 0)); - source.Fetches.Should().Be(0); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V1Text); - var record = await FloorHoldFixture.AwaitRecord(Mesh, r => r.HeldUpdate is not null, ct); - record.ModuleVersion.Should().Be(FloorHoldFixture.V1Hash); - dispatch.Tickets.Should().ContainSingle(); - } - - /// A FRESH install of a version whose floor is unmet is refused, clearly, and writes - /// nothing — not a node, not a record, not a fetched file. - [Fact(Timeout = 300_000)] - public async Task AFreshInstallAboveTheFloor_IsRefused_NamingBothVersions_AndWritesNothing() - { - var ct = TestContext.Current.CancellationToken; - FloorFixture.AssertTheFloorHolds(Mesh); - await Installer.Completed.FirstAsync().Timeout(180.Seconds()).Await(ct); - - var source = new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()); - var refusal = await CatalogLayoutAreas - .InstallOrUpdate(Mesh, source, "HEAD", FloorHoldFixture.V2(FloorFixture.Above), Logger) - .Materialize().Where(n => n.Kind != System.Reactive.NotificationKind.OnNext).FirstAsync() - .Timeout(120.Seconds()).Await(ct); - - refusal.Exception.Should().BeOfType(); - refusal.Exception!.Message.Should().Contain(FloorHoldFixture.Package).And.Contain(FloorFixture.Above) - .And.Contain(FloorFixture.Running).And.Contain("not installed"); - source.Fetches.Should().Be(0, "refused before a single file is fetched"); - (await ReassertFixture.Read(Mesh, FloorHoldFixture.RecordPath)).Should().BeNull(); - (await ReassertFixture.Read(Mesh, FloorHoldFixture.SourcePath)).Should().BeNull(); - - // The installer itself refuses a direct caller the same way (the enforcement half). - var direct = await PackageInstaller - .Install(Mesh, FloorHoldFixture.V2(FloorFixture.Above), FloorHoldFixture.V2Files(), "HEAD", Logger) - .Materialize().Where(n => n.Kind != System.Reactive.NotificationKind.OnNext).FirstAsync() - .Timeout(120.Seconds()).Await(ct); - direct.Exception.Should().BeOfType(); - (await ReassertFixture.Read(Mesh, FloorHoldFixture.SourcePath)).Should().BeNull(); - - // Negative control: the same package with a satisfied floor installs. - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, source, "HEAD", FloorHoldFixture.V2(FloorFixture.Below), Logger) - .Timeout(180.Seconds()).Await(ct); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V2Text); - dispatch.Tickets.Should().BeEmpty("a refused fresh install is not a held update — nothing is kept running"); - } -} - -/// -/// A host with NO dispatch registered: the hold still happens and is still visible — the record -/// says the ticket was NOT dispatched and why; nothing crashes. -/// -public class PackageFloorHoldWithoutDispatchTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false }) - .AddSingleton(FloorFixture.Pin)); - - [Fact(Timeout = 300_000)] - public async Task TheHold_IsRecorded_AndSaysItWasNotDispatched() - { - var ct = TestContext.Current.CancellationToken; - FloorFixture.AssertTheFloorHolds(Mesh); - var logger = Mesh.ServiceProvider.GetRequiredService().CreateLogger(); - await Mesh.ServiceProvider.GetRequiredService().Completed - .FirstAsync().Timeout(180.Seconds()).Await(ct); - await FloorHoldFixture.InstallV1(Mesh, logger, ct); - - var source = new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()); - await FloorHoldFixture.Reconcile(Mesh, source, FloorHoldFixture.V2(FloorFixture.Above), logger, ct); - - var record = await FloorHoldFixture.AwaitRecord(Mesh, r => r.HeldUpdateDispatch is not null, ct); - record.HeldUpdate.Should().Contain(FloorFixture.Above); - record.HeldUpdateDispatch.Should().StartWith("NOT dispatched").And.Contain(nameof(IPackageHoldDispatch)); - record.HeldUpdateDispatchedAt.Should().BeNull("a ticket nobody accepted is retried, never marked sent"); - source.Fetches.Should().Be(0); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V1Text); - } -} - -/// -/// 🚨 The ticket through the REAL channel: over -/// — the signed POST a self-update announcement takes — into a -/// fake control inbox. One held state, one signed event whose body says what is held and what -/// unblocks it; repeated ticks send nothing more. -/// -public class PackageFloorHoldTicketTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Deployment = "unit-floor-instance"; - private const string InboxUrl = "https://control.example/api/hooks/Hosting/PlatformBuilds"; - private const string Secret = "unit-floor-inbox-secret"; - - private readonly FloorHoldFixture.FakeControlInbox inbox = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new ConfigurationBuilder().AddInMemoryCollection( - new Dictionary { [SelfUpdateHandover.SecretKey] = Secret }).Build()) - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false }) - .AddSingleton(FloorFixture.Pin) - .AddSingleton(new PackageHoldHandover - { - Handover = (hub, logger) => new SeamedHandover(hub, - new SelfUpdateHandover.Settings(Deployment, InboxUrl, SecretPresent: true, - LocalTargetListed: false, LocalSecretPresent: false, "https://unit.example"), - inbox), - })); - - [Fact(Timeout = 300_000)] - public async Task OneHeldState_SendsOneSignedBlockingEvent_ThroughTheSelfUpdateChannel() - { - var ct = TestContext.Current.CancellationToken; - FloorFixture.AssertTheFloorHolds(Mesh); - var logger = Mesh.ServiceProvider.GetRequiredService().CreateLogger(); - await Mesh.ServiceProvider.GetRequiredService().Completed - .FirstAsync().Timeout(180.Seconds()).Await(ct); - await FloorHoldFixture.InstallV1(Mesh, logger, ct); - - var source = new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()); - await FloorHoldFixture.Reconcile(Mesh, source, FloorHoldFixture.V2(FloorFixture.Above), logger, ct); - var record = await FloorHoldFixture.AwaitRecord(Mesh, r => r.HeldUpdateDispatchedAt is not null, ct); - record.HeldUpdateDispatch.Should().StartWith("blocking ticket dispatched").And.Contain(InboxUrl); - - var delivery = inbox.Deliveries.Should().ContainSingle().Subject; - WebhookInbox.VerifyHmacSha256(delivery.Signature, delivery.Body, Secret).Should().BeTrue( - "the control inbox verifies the same X-Hub-Signature-256 a self-update announcement carries"); - using var doc = JsonDocument.Parse(delivery.Body); - var root = doc.RootElement; - root.GetProperty("event").GetString().Should().Be(SelfUpdateHandover.PackageHeldEvent); - root.GetProperty("severity").GetString().Should().Be(PackageHoldHandover.Blocking); - root.GetProperty("deployment").GetString().Should().Be(Deployment); - root.GetProperty("package").GetString().Should().Be(FloorHoldFixture.Package); - root.GetProperty("heldVersion").GetString().Should().Be(FloorHoldFixture.V2Version); - root.GetProperty("floor").GetString().Should().Be(FloorFixture.Above); - root.GetProperty("currentVersion").GetString().Should().Be(FloorFixture.Running); - root.GetProperty("installedVersion").GetString().Should().Be(FloorHoldFixture.V1Version); - root.GetProperty("unblocks").GetString().Should().Contain("rolls to a platform ≥ " + FloorFixture.Above); - root.GetProperty("reporter").GetString().Should().Be(PackageHoldHandover.Reporter); - - await FloorHoldFixture.Reconcile(Mesh, source, FloorHoldFixture.V2(FloorFixture.Above), logger, ct); - inbox.Deliveries.Should().ContainSingle("the same held state is not re-sent on the next tick"); - } - - private sealed class SeamedHandover(IMessageHub hub, SelfUpdateHandover.Settings settings, HttpMessageHandler inbox) - : SelfUpdateHandover(hub, null, new HttpClient(inbox)) - { - public override Settings ReadSettings() => settings; - } -} - -/// -/// The real on an instance that declares NO control inbox: the -/// absence is the finding — recorded as "NOT dispatched" naming what is missing, never a crash. -/// -public class PackageFloorHoldNoInboxTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false }) - .AddSingleton(FloorFixture.Pin) - .AddSingleton(new PackageHoldHandover())); - - [Fact(Timeout = 300_000)] - public async Task NoControlInbox_IsRecordedAsNotDispatched_NamingWhatIsMissing() - { - var ct = TestContext.Current.CancellationToken; - FloorFixture.AssertTheFloorHolds(Mesh); - var logger = Mesh.ServiceProvider.GetRequiredService().CreateLogger(); - await Mesh.ServiceProvider.GetRequiredService().Completed - .FirstAsync().Timeout(180.Seconds()).Await(ct); - await FloorHoldFixture.InstallV1(Mesh, logger, ct); - - await FloorHoldFixture.Reconcile(Mesh, new FloorHoldFixture.CountingSource(FloorHoldFixture.V2Files()), - FloorHoldFixture.V2(FloorFixture.Above), logger, ct); - - var record = await FloorHoldFixture.AwaitRecord(Mesh, r => r.HeldUpdateDispatch is not null, ct); - record.HeldUpdateDispatch.Should().StartWith("NOT dispatched").And.Contain("Hosting:"); - record.HeldUpdateDispatchedAt.Should().BeNull(); - (await FloorHoldFixture.Text(Mesh)).Should().Be(FloorHoldFixture.V1Text); - } -} - -/// The pure de-duplication rule and the ticket's shape. -public class PackageHoldTicketRulesTest -{ - private static readonly DateTimeOffset Now = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero); - - private static PlatformFloorVerdict Held() => PlatformFloor.Evaluate("3.0.0-ci.9494", "3.0.0-ci.9412"); - - private static PackageManifest Candidate() => new() { Id = "Store", Version = "1.16.0", MinMeshVersion = "3.0.0-ci.9494" }; - - [Fact] - public void ATicketIsSentOncePerHeldState_AndAgainOnlyWhenItChanges_OrADayLater() - { - var sentence = PackagePlatformFloorGate.HeldSentence(Candidate(), Held()); - var fresh = new PackageManifest { Id = "Store", Version = "1.15.0" }; - PackagePlatformFloorGate.ShouldDispatch(fresh, sentence, Now).Should().BeTrue("a new held state"); - - var sent = fresh with { HeldUpdate = sentence, HeldUpdateDispatchedAt = Now.AddHours(-1) }; - PackagePlatformFloorGate.ShouldDispatch(sent, sentence, Now).Should().BeFalse("the same state, sent an hour ago"); - PackagePlatformFloorGate.ShouldDispatch(sent, sentence, Now.AddHours(24)).Should().BeTrue("a day later it is re-raised"); - - var rolled = PackagePlatformFloorGate.HeldSentence(Candidate(), PlatformFloor.Evaluate("3.0.0-ci.9494", "3.0.0-ci.9450")); - PackagePlatformFloorGate.ShouldDispatch(sent, rolled, Now).Should().BeTrue( - "the running platform moved but not far enough — a changed held state is a new ticket"); - - var failed = fresh with { HeldUpdate = sentence, HeldUpdateDispatchedAt = null, HeldUpdateDispatch = "NOT dispatched: x" }; - PackagePlatformFloorGate.ShouldDispatch(failed, sentence, Now).Should().BeTrue("an attempt nobody accepted is retried"); - } - - [Fact] - public void TheTicket_SaysWhatIsHeld_WhatKeepsRunning_AndWhatUnblocksIt() - { - var ticket = PackagePlatformFloorGate.Ticket( - Candidate(), new PackageManifest { Id = "Store", Version = "1.15.0" }, Held()); - ticket.Package.Should().Be("Store"); - ticket.HeldVersion.Should().Be("1.16.0"); - ticket.Floor.Should().Be("3.0.0-ci.9494"); - ticket.Running.Should().Be("3.0.0-ci.9412"); - ticket.InstalledVersion.Should().Be("1.15.0"); - ticket.Unblocks.Should().Contain("rolls to a platform ≥ 3.0.0-ci.9494").And.Contain("1.15.0 keeps running"); - - var announcement = PackageHoldHandover.AnnouncementOf(ticket, Now); - var body = SelfUpdateHandover.Body(announcement); - using var doc = JsonDocument.Parse(body); - doc.RootElement.GetProperty("event").GetString().Should().Be("package-update-held"); - doc.RootElement.GetProperty("severity").GetString().Should().Be("blocking"); - doc.RootElement.TryGetProperty("newVersion", out _).Should().BeFalse( - "a held-package ticket carries no release fields a self-update router could misread"); - } -} - -/// One package: v1 installed, v2 referencing a platform member the running platform lacks. -internal static class FloorHoldFixture -{ - public const string Package = "FloorHoldPkg"; - public const string SourcePath = $"{Package}/BillingSource"; - public static readonly string RecordPath = $"{PackageInstaller.InstalledPartition}/{Package}"; - - public const string V1Version = "1.15.0"; - public const string V2Version = "1.16.0"; - public const string V1Hash = "mv-floorhold-v1"; - public const string V2Hash = "mv-floorhold-v2"; - - public const string V1Text = "IPaymentProvider.CreateCheckout(order) — v1, compiles on this platform"; - - /// The 09-27 shape: v2's source calls a member the running platform does not have. - public const string V2Text = "IPaymentProvider.CreateBillingPortalSession(customer) — v2, needs a newer platform"; - - public static PackageManifest V1() => Candidate(V1Version, V1Hash, floor: null); - - public static PackageManifest V2(string floor) => Candidate(V2Version, V2Hash, floor); - - private static PackageManifest Candidate(string version, string hash, string? floor) => new() - { - Id = Package, - Name = Package, - Kind = PackageKind.NodeRepo, - TargetPartition = Package, - SourceFolder = Package, - Version = version, - ModuleVersion = hash, - MinMeshVersion = floor, - }; - - public static IReadOnlyList V1Files() => Files(V1Version, V1Hash, V1Text); - - public static IReadOnlyList V2Files() => Files(V2Version, V2Hash, V2Text); - - private static IReadOnlyList Files(string version, string hash, string text) => - [ - new PackageFile($"{Package}/{ModuleManifest.FileName}", $$""" - { - "module": "{{Package}}", - "moduleVersion": "{{hash}}", - "version": "{{version}}", - "files": { - "{{Package}}/index.json": "idx", - "{{Package}}/BillingSource.md": "{{hash}}-src" - } - } - """), - new PackageFile($"{Package}/index.json", $$""" - { - "id": "{{Package}}", - "path": "{{Package}}", - "nodeType": "Space", - "name": "Floor hold package", - "state": "Active" - } - """), - new PackageFile($"{Package}/BillingSource.md", text), - ]; - - /// A source that counts every fetch — the witness that held sources never travel. - public sealed class CountingSource(IReadOnlyList files) : IPackageSource - { - private int fetches; - - public int Fetches => Volatile.Read(ref fetches); - - public IObservable> ListPackages(string gitRef) => - Observable.Return>([]); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) => - Observable.Defer(() => - { - Interlocked.Increment(ref fetches); - return Observable.Return(files); - }); - } - - /// Records every ticket the catalog hands over. - public sealed class RecordingDispatch : IPackageHoldDispatch - { - private ImmutableList tickets = ImmutableList.Empty; - - public ImmutableList Tickets => tickets; - - public IObservable Dispatch(IMessageHub hub, PackageHoldTicket ticket) => - Observable.Defer(() => - { - ImmutableInterlocked.Update(ref tickets, t => t.Add(ticket)); - return Observable.Return("accepted by the test dispatch"); - }); - } - - /// The control inbox, substituted: records every delivery and answers accepted/verified. - public sealed class FakeControlInbox : HttpMessageHandler - { - private ImmutableList deliveries = ImmutableList.Empty; - - public ImmutableList Deliveries => deliveries; - - public sealed record Delivery(string Url, string Body, string? Signature); - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - var body = await request.Content!.ReadAsStringAsync(ct); - request.Headers.TryGetValues(WebhookInbox.SignatureHeader, out var signatures); - ImmutableInterlocked.Update(ref deliveries, d => d.Add( - new Delivery(request.RequestUri!.ToString(), body, signatures?.FirstOrDefault()))); - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"status\":\"accepted\",\"signature\":\"verified\"}"), - }; - } - } - - /// Installs v1 through the ONE orchestrator and waits until its source is in the mesh. - public static async Task InstallV1(IMessageHub mesh, ILogger logger, CancellationToken ct) - { - await CatalogLayoutAreas.InstallOrUpdate(mesh, new CountingSource(V1Files()), "HEAD", V1(), logger) - .Timeout(180.Seconds()).Await(ct); - (await ReassertFixture.WaitForNode(mesh, SourcePath, present: true)).Should().BeTrue( - "v1 must be installed before an update can be held"); - (await Text(mesh)).Should().Be(V1Text); - (await AwaitRecord(mesh, r => r.ModuleVersion == V1Hash, ct)).Version.Should().Be(V1Version); - } - - /// One unattended reconcile pass over one candidate — the call every entry point makes. - public static Task Reconcile(IMessageHub mesh, IPackageSource source, PackageManifest candidate, ILogger logger, CancellationToken ct) => - PackageUpdateReconciler.ReconcileInstalled(mesh, source, "HEAD", [candidate], "Served by the test", logger) - .Timeout(180.Seconds()).Await(ct); - - /// The source node's markdown body, read authoritatively off storage. - public static async Task Text(IMessageHub mesh) - { - var node = await ReassertFixture.Read(mesh, SourcePath); - if (node?.Content is null) - return null; - using var doc = JsonDocument.Parse(JsonSerializer.Serialize(node.Content, mesh.JsonSerializerOptions)); - return doc.RootElement.ValueKind == JsonValueKind.Object - && doc.RootElement.TryGetProperty("content", out var body) - && body.ValueKind == JsonValueKind.String - ? body.GetString() - : doc.RootElement.ToString(); - } - - /// Polls the install record off storage until holds. - public static Task AwaitRecord(IMessageHub mesh, Func predicate, CancellationToken ct) => - Observable.Interval(TimeSpan.FromMilliseconds(100)).StartWith(0L) - .SelectMany(_ => mesh.ServiceProvider.GetRequiredService() - .Read(RecordPath, mesh.JsonSerializerOptions).Take(1).DefaultIfEmpty(null)) - .Select(n => n?.ContentAs(mesh.JsonSerializerOptions)) - .Where(r => r is not null && predicate(r)) - .Select(r => r!) - .FirstAsync() - .Timeout(120.Seconds()) - .Await(ct); -} diff --git a/test/Memex.Portal.Shared.Test/PackageInstallRebuildsDependentsTest.cs b/test/Memex.Portal.Shared.Test/PackageInstallRebuildsDependentsTest.cs deleted file mode 100644 index 9715a506b6..0000000000 --- a/test/Memex.Portal.Shared.Test/PackageInstallRebuildsDependentsTest.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.Persistence.Parsers; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 IF STORE UPDATES, ALL DEPENDENT PACKAGES UPDATE AS WELL — the mandate, and the defect -/// that made it false. This is the mechanism behind the 2026-08-25 Store outage, in which every -/// Store NodeType recompiled green and the page still went down. See -/// Doc/Architecture/CompileProgramStateOfRecord → "A Store update does not rebuild its -/// dependents". -/// -/// What was wrong. A type in package B that names -/// shared=@{A}/…/Source compiles A's source TEXT into B's OWN assembly, so B is stale the -/// instant A's sources move. The correct closure existed — ReleaseAffectedNodeTypes -/// enumerates NodeTypes MESH-WIDE and matches a changed path against every type's expanded -/// source/test queries — and the GitSync transaction used it. The package installer did not: it -/// derived its release targets from the nodes it had just written that happened to be NodeType -/// definitions, which can only ever name types INSIDE the installed package. So an update to A -/// rebuilt A and left B running the assembly it already had — two hubs on different compiles of -/// the same sources, disagreeing about the type registry, which reads as -/// $type … is not registered and renders as an empty view. -/// -/// Why the cross-package assertion is the whole test. Asserting that A's OWN types are -/// released passes on the broken code and proves nothing. B — a type this install never writes, -/// never parses, and cannot name — is the only observation that separates the package-local -/// derivation from the mesh-wide closure. -/// -/// The second defect, on the same line. The package-local selector was -/// n.Content is NodeTypeDefinition — a pattern-match on an object payload. Content -/// that arrived as JSON does not match, so the filter selected NOTHING and no release was issued at -/// all: the same outage reached more quietly, with no exception and nothing to grep. The provider -/// package installed here ships its NodeType with an untyped content payload on purpose, so this -/// test drives both fixes in one install; -/// pins the selector -/// itself, deterministically and without the closure being able to answer for it. -/// -public class PackageInstallRebuildsDependentsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// The install record is a Package node, so the catalog's own types have to be - /// registered for an install to record itself — the same registration every portal makes. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - /// The package being installed — the "Store" of the incident. - private const string Package = "SharedSourcePkg"; - - /// Its own NodeType, whose Source/ subtree other packages compile. - private const string ProviderType = $"{Package}/Catalog"; - - /// The shared compile input the install writes. - private const string SharedSource = $"{ProviderType}/Source/Shared"; - - /// The DEPENDENT package — already installed, outside , and named - /// nowhere in the install's inputs. - private const string ConsumerNamespace = "DependentPkg"; - private const string ConsumerType = $"{ConsumerNamespace}/Model"; - - /// - /// The provider's NodeType node exactly as a repo may author it: nodeType: NodeType and a - /// content object carrying NO $type discriminator — so the polymorphic converter - /// hands the installer a raw , which is also what ANY hub whose - /// TypeRegistry cannot resolve the discriminator hands back. - /// - private const string UntypedNodeTypeJson = """ - { - "id": "Catalog", - "namespace": "SharedSourcePkg", - "path": "SharedSourcePkg/Catalog", - "nodeType": "NodeType", - "name": "Provider catalog", - "state": "Active", - "content": { "configuration": "config => config" } - } - """; - - /// - /// Installing the provider package must release the DEPENDENT package's type — the type that - /// compiles the provider's sources into its own assembly and that the install can neither see - /// nor name. - /// - [Fact(Timeout = 300_000)] - public async Task InstallingAPackage_ReleasesTheDependentPackageThatSharesItsSources() - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - - // ── The dependent package, already installed. Its ONLY source is the provider's, which is - // the shape a cross-package `shared=` consumer has: its assembly is built from text it - // does not own, so it is stale the moment that text moves. - await meshService.CreateNode(new MeshNode("Model", ConsumerNamespace) - { - NodeType = MeshNode.NodeTypePath, - Name = "Dependent model", - State = MeshNodeState.Active, - Content = new NodeTypeDefinition - { - Configuration = "config => config", - Sources = [$"shared=@{ProviderType}/Source"], - }, - }) - .Should().Within(60.Seconds()) - .Emit("the dependent type must exist BEFORE the provider updates — that is the whole " - + "premise: it is already running an assembly built from the provider's sources", cancellationToken: TestContext.Current.CancellationToken); - - // The closure enumerates types from the query INDEX, which trails the store. Waiting for - // the dependent to be listed is this test's precondition, not a settle: without it the - // assertion could measure index lag instead of the installer's behaviour. - await Mesh.GetQuery("dependents-probe", $"nodeType:{MeshNode.NodeTypePath}") - .Where(nodes => nodes.Any(n => - string.Equals(n.Path, ConsumerType, StringComparison.OrdinalIgnoreCase))) - .FirstAsync() - .Timeout(60.Seconds()) - .Await(TestContext.Current.CancellationToken); - - // ── The provider package's install. A node repo: the files ARE nodes at their canonical - // paths. Its NodeType arrives with UNTYPED content on purpose (see UntypedNodeTypeJson). - var result = await PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = Package, - Name = Package, - Kind = PackageKind.NodeRepo, - TargetPartition = Package, - SourceFolder = Package, - Version = "1.0.0", - }, - [ - new PackageFile($"{Package}.md", $"# {Package}"), - new PackageFile($"{ProviderType}.json", UntypedNodeTypeJson), - new PackageFile($"{SharedSource}.cs", "public class SharedThing { }"), - ], - "HEAD") - .Should().Within(180.Seconds()) - .Emit("the install itself must complete before anything about releases can be read", cancellationToken: TestContext.Current.CancellationToken); - - result.WrittenPaths.Should().Contain(SharedSource, - "the shared compile input is what makes every dependent stale — if it was not written " - + "there is no change for the closure to run over and this test proves nothing"); - - // ── THE assertion. A release stamps RequestedReleaseAt, and nothing else writes it. - await Mesh.GetWorkspace().GetMeshNodeStream(ConsumerType) - .Should().Within(120.Seconds()) - .Match( - n => n.ContentAs(Mesh.JsonSerializerOptions) - ?.RequestedReleaseAt is not null, - "a package OUTSIDE the installed one, whose assembly is built from the installed " - + "package's sources, must be rebuilt by that install. Releasing only the types " - + "the installer just wrote leaves it on the assembly it already had — the " - + "2026-08-25 Store outage, where every Store type recompiled green and the page " - + "still rendered empty", cancellationToken: TestContext.Current.CancellationToken); - } - - /// - /// The quieter half: a NodeType whose content arrived as JSON must still be SELECTED as a type. - /// - /// Deterministic on purpose. Driving this through an install would let the mesh-wide - /// closure answer for the selector whenever the query index happens to have caught up — an - /// assertion that can pass for the wrong reason. Here the real parser produces the real node - /// and both predicates are read directly, so the trap-door and its replacement are compared on - /// the same value. - /// - [Fact] - public void ANodeTypeWhoseContentArrivedAsJson_IsStillSelectedAsAType() - { - var parsers = new FileFormatParserRegistry( - Mesh.JsonSerializerOptions, - Mesh.ServiceProvider.GetServices()); - - var parsed = parsers.TryParse( - ".json", $"{ProviderType}.json", UntypedNodeTypeJson, $"{ProviderType}.json"); - - parsed.Should().NotBeNull("the file is a well-formed node file"); - parsed!.Content.Should().BeOfType( - "content with no $type discriminator degrades to raw JSON — and so does ANY content " - + "read on a hub whose TypeRegistry cannot resolve the discriminator, which is the " - + "case that actually happens in a running mesh"); - - (parsed.Content is NodeTypeDefinition).Should().BeFalse( - "THIS is the trap-door: the installer picked its release targets with exactly this " - + "pattern-match on an object payload, so an untyped NodeType selected NOTHING and the " - + "install issued no release at all — the outage reached with no exception, no log, " - + "and nothing to grep"); - - ImportWriteOrder.IsNodeTypeDefinition(parsed).Should().BeTrue( - "the framework's shape-tolerant predicate — the CLR test OR the cast-free NodeType " - + "meta-marker — is what the installer selects with now. A bare " - + "ContentAs would be wrong in the OTHER direction: every member " - + "of that record is optional, so any JSON object deserializes into one and every " - + "plain content node would read as a type"); - } -} diff --git a/test/Memex.Portal.Shared.Test/PackageListingCacheIsArmedTest.cs b/test/Memex.Portal.Shared.Test/PackageListingCacheIsArmedTest.cs deleted file mode 100644 index be4dd95166..0000000000 --- a/test/Memex.Portal.Shared.Test/PackageListingCacheIsArmedTest.cs +++ /dev/null @@ -1,307 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A cache nobody arms is the per-request repository read, still there and now harder to -/// see. drives the cache directly; this drives the path -/// GET /api/plugins actually takes — a REAL mesh built with AddPluginCatalog, and -/// PackageSources.FromRepo called once per "request" exactly as -/// PluginRegistryEndpoints.Sources calls it. -/// -/// Without this, every assertion in the sibling suite could hold while -/// AddPluginCatalog registered nothing, FromRepo wrapped nothing, and production kept -/// cloning the plugins repository on every request (MeshWeaver#4222). -/// -public class PackageListingCacheIsArmedTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Repo = "https://github.com/Systemorph/MeshWeaver.Plugins"; - private const string Ref = "main"; - - private readonly CountingRepoClient repoClient = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services.AddSingleton(repoClient)); - - /// - /// 🚨 The arming half: AddPluginCatalog puts a cache on the mesh. Resolved from the mesh's - /// own provider, not restated — a restatement of the registration would agree with itself while - /// the deployment registered nothing. - /// - [Fact] - public void AddPluginCatalog_ArmsTheListingCache() - { - var cache = Mesh.ServiceProvider.GetService(); - - Assert.NotNull(cache); - Assert.True(cache.Enabled, - "the listing cache is registered but switched off by default. The default has to CACHE — " - + "PluginCatalog:ListingCacheSeconds is the opt-out, not the opt-in (#4222)."); - Assert.Equal(PackageListingCache.DefaultWindow, cache.Window); - } - - /// - /// The end-to-end property, over the real factory: five requests, five freshly-built sources — - /// one repository fetch. The loop rebuilds the source every time on purpose; that is what - /// PluginRegistryEndpoints.Sources does, and it is why the cache cannot live on the - /// source instance. - /// - [Fact] - public async Task FiveRequestsThroughTheRealFactory_FetchTheRepositoryOnce() - { - for (var request = 0; request < 5; request++) - await Request(); - - Assert.Equal(1, repoClient.Fetches); - } - - /// - /// 🚨 The LISTING asks for the narrow transfer, and so does the INSTALL — each with its own - /// predicate. Asserted against the real factory. - /// - /// A listing that quietly reverted to the unfiltered Fetch would still answer - /// correctly and still be cached, so nothing else in this suite could see it: the only symptom - /// is that the registry goes back to moving the whole repository (47.8 MB / 13 s against - /// MeshWeaver.Plugins) for the manifests it parses. - /// - /// 🚨 The INSTALL used to be pinned UNFILTERED here, on the reasoning that narrowing it - /// would install an empty package. That is true of the listing's predicate, and false of one that - /// keeps the package's folder — and leaving it whole made every /api/plugins/files answer - /// cost the registry the whole repository before its first byte, which is how a booting instance's - /// 30 s attempts for Anthropic and AppleIntelligence all expired waiting for headers - /// (MeshWeaver#5826). The empty-package failure is guarded by asserting the files the install - /// actually returns: the whole folder, binary blob included, and nothing outside it. - /// - [Fact] - public async Task TheListingAndTheInstall_BothAskForTheNarrowFetch_AndTheInstallGetsTheWholeFolder() - { - await Request(); - - Assert.Equal(1, repoClient.Fetches); - Assert.Equal(1, repoClient.FilteredFetches); - - var source = PackageSources.FromRepo(Mesh, Repo, sourceSubdir: null, logger: null, nodeRepo: true); - Assert.NotNull(source); - var files = await source.FetchPackageFiles(new PackageManifest { Id = "Widget", SourceFolder = "Widget" }, Ref) - .Should().Within(TestTimeouts.Quick) - .Emit("an install must read the package's files", - cancellationToken: TestContext.Current.CancellationToken); - - // The install read the repository again (file fetches are deliberately NOT cached) and it - // did so NARROWED — the filtered count moved with it. - Assert.Equal(2, repoClient.Fetches); - Assert.Equal(2, repoClient.FilteredFetches); - - // …and narrowed to the WHOLE folder: every file under `Widget/`, the binary one included, - // and none of the neighbours a prefix match could confuse with it (`WidgetX/`). - Assert.Equal( - ["Widget/Source/Widget.cs", "Widget/content/poster.png", "Widget/index.json"], - files.Select(f => f.RelativePath).OrderBy(p => p, StringComparer.Ordinal).ToArray()); - Assert.NotNull(files.Single(f => f.RelativePath == "Widget/content/poster.png").Binary); - } - - /// - /// The manifest-diff fast path (an install that names the paths it wants) is narrowed to exactly - /// those paths — and never reaches outside the package's folder even when asked to. - /// - [Fact] - public async Task ThePathsSubset_MovesOnlyThosePaths_InsideThePackage() - { - var source = PackageSources.FromRepo(Mesh, Repo, sourceSubdir: null, logger: null, nodeRepo: true); - Assert.NotNull(source); - var files = await source.FetchPackageFiles( - new PackageManifest { Id = "Widget", SourceFolder = "Widget" }, Ref, - ["Widget/Source/Widget.cs", "Other/index.json"]) - .Should().Within(TestTimeouts.Quick) - .Emit("an incremental install must read the paths it names", - cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal(1, repoClient.FilteredFetches); - Assert.Equal(["Widget/Source/Widget.cs"], files.Select(f => f.RelativePath).ToArray()); - } - - /// - /// 🚨 And the green build still gets through: after the webhook's eviction the next request - /// fetches again, so a merge is visible without waiting out the freshness window. - /// - [Fact] - public async Task AfterAGreenBuildEviction_TheNextRequestFetchesAgain() - { - await Request(); - await Request(); - // 🚨 The control that keeps the assertion below from passing on an UNCACHED path, where the - // count would climb anyway: two requests, one fetch. - Assert.Equal(1, repoClient.Fetches); - - Mesh.ServiceProvider.GetRequiredService().EvictRepo(Repo); - - await Request(); - Assert.Equal(2, repoClient.Fetches); - } - - /// - /// One request, the way PluginRegistryEndpoints.Sources makes one: a source built from - /// scratch, then listed. 🚨 Awaited through the reactive assertion helper, never .Wait() — - /// a blocking bridge would wedge the run instead of naming what did not arrive. - /// - private Task> Request() - { - var source = PackageSources.FromRepo(Mesh, Repo, sourceSubdir: null, logger: null, nodeRepo: true); - Assert.NotNull(source); - return source.ListPackages(Ref).Should().Within(TestTimeouts.Quick) - .Emit("the real factory's source must produce a listing", - cancellationToken: TestContext.Current.CancellationToken); - } - - /// - /// 🚨 THE SCOPE CONTROL. A LOCAL directory source is NOT cached, and that is a correctness - /// boundary rather than a tuning choice: a mounted source's listed version is contractually a - /// function of the files on disk right now — LocalSourceContentVersionTest pins that an - /// edit shows on the very next listing — and there is no webhook to invalidate it. Caching one - /// would make a local-dev or air-gapped registry report yesterday's content for five minutes. - /// - [Fact] - public async Task ALocalDirectorySource_IsNotCached() - { - var root = Path.Combine(Path.GetTempPath(), "mw-listing-cache-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(Path.Combine(root, "Widget")); - try - { - File.WriteAllText(Path.Combine(root, "Widget", "index.json"), - """{"$type":"MeshNode","id":"Widget","namespace":"","path":"Widget","mainNode":"Widget","name":"Widget","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"A widget."}}"""); - var file = Path.Combine(root, "Widget", "Page.md"); - File.WriteAllBytes(file, [65, 66, 67]); - - var source = PackageSources.FromRepo(Mesh, root, "", nodeRepo: true); - Assert.NotNull(source); - - var before = await Listed(source); - File.WriteAllBytes(file, [68, 69, 70]); - var after = await Listed(source); - - Assert.NotEqual(before.Version, after.Version); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - private static async Task Listed(IPackageSource source) => - (await source.ListPackages("HEAD").Should().Within(TestTimeouts.Quick) - .Emit("a local directory source must list its packages", - cancellationToken: TestContext.Current.CancellationToken)).Single(); - - /// Counts the repository reads the listing path actually performs — the number #4222 is about. - private sealed class CountingRepoClient : IGitHubRepoClient - { - private int fetches; - private int filteredFetches; - - public int Fetches => fetches; - - /// - /// 🚨 How many reads asked for the NARROW transfer. This override is what makes that - /// question answerable at all: with only the four-argument Fetch implemented, the - /// interface's DEFAULT five-argument member forwards to it, so every assertion below would - /// have held whether or not PackageSources wired NarrowFetch — and the - /// listing could regress to whole-repository reads with this suite still green (#4222). - /// - public int FilteredFetches => filteredFetches; - - /// The repository both reads serve: a package, a neighbour whose name shares its - /// prefix, an unrelated package and a file at the root. - private static readonly RepoFile[] Files = - [ - new("Widget/index.json", - """{"$type":"MeshNode","id":"Widget","namespace":"","path":"Widget","mainNode":"Widget","name":"Widget","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"A widget."}}"""), - new("Widget/Source/Widget.cs", "public sealed class Widget;"), - new("Widget/content/poster.png", "", [137, 80, 78, 71]), - new("WidgetX/index.json", - """{"$type":"MeshNode","id":"WidgetX","namespace":"","path":"WidgetX","mainNode":"WidgetX","name":"WidgetX","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"Not the widget."}}"""), - new("Other/index.json", - """{"$type":"MeshNode","id":"Other","namespace":"","path":"Other","mainNode":"Other","name":"Other","nodeType":"Space","state":"Active","content":{"$type":"PluginManifest","description":"Another."}}"""), - new("README.md", "# plugins"), - ]; - - /// The plain read: the whole repository, as the real client transfers it. - public IObservable Fetch( - string repositoryUrl, string commitish, string? subdirectory, string accessToken) - { - System.Threading.Interlocked.Increment(ref fetches); - return Observable.Return(new RepoSnapshot("sha-1", Files)); - } - - /// The narrow read: only what the predicate keeps — what the real client moves. - public IObservable Fetch( - string repositoryUrl, string commitish, string? subdirectory, string accessToken, - Func pathFilter) - { - System.Threading.Interlocked.Increment(ref fetches); - System.Threading.Interlocked.Increment(ref filteredFetches); - return Observable.Return(new RepoSnapshot("sha-1", Files.Where(f => pathFilter(f.Path)).ToArray())); - } - - // Everything below the listing path. NotSupported rather than a canned answer: the catalog - // listing must never reach any of them, and a test that silently succeeded through one - // would be measuring something other than #4222. - private static IObservable NotOnThisPath() => - Observable.Throw(new NotSupportedException("the catalog listing path never calls this")); - - public IObservable Push(GitHubPushRequest request) => NotOnThisPath(); - - public IObservable CreateBranch(GitHubCreateBranchRequest request) => - NotOnThisPath(); - - public IObservable OpenPullRequest(GitHubOpenPullRequestRequest request) => - NotOnThisPath(); - - public IObservable GetPullRequestStatus( - string repositoryUrl, int number, string accessToken) => NotOnThisPath(); - - public IObservable> ListIssues( - string repositoryUrl, GitHubIssueState? state, string accessToken) => - NotOnThisPath>(); - - public IObservable GetIssue(string repositoryUrl, int number, string accessToken) => - NotOnThisPath(); - - public IObservable CreateIssue(GitHubCreateIssueRequest request) => NotOnThisPath(); - - public IObservable CommentIssue( - string repositoryUrl, int number, string body, string accessToken) => - NotOnThisPath(); - - public IObservable SetIssueState( - string repositoryUrl, int number, GitHubIssueState state, string accessToken) => - NotOnThisPath(); - - public IObservable> ListPullRequests( - string repositoryUrl, PullRequestStatus? state, string accessToken) => - NotOnThisPath>(); - - public IObservable GetPullRequestDetail( - string repositoryUrl, int number, string accessToken) => NotOnThisPath(); - - public IObservable CommentPullRequest( - string repositoryUrl, int number, string body, string accessToken) => - NotOnThisPath(); - - public IObservable MergePullRequest(GitHubMergePullRequestRequest request) => - NotOnThisPath(); - } -} diff --git a/test/Memex.Portal.Shared.Test/PackageListingCacheTest.cs b/test/Memex.Portal.Shared.Test/PackageListingCacheTest.cs deleted file mode 100644 index cfbde6c003..0000000000 --- a/test/Memex.Portal.Shared.Test/PackageListingCacheTest.cs +++ /dev/null @@ -1,502 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Mesh.Security; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The registry's catalog listing reads its source repository ONCE per freshness window, not once -/// per request (MeshWeaver#4222) — and the per-caller decision still runs on every request, which is -/// what keeps a cache from becoming a disclosure (#3768). -/// -/// The defect this pins. GET /api/plugins resolves its sources FRESH on every -/// request (PluginRegistryEndpoints.Sources → PackageSources.FromConfiguration → -/// PackageSources.FromRepo, which constructs a brand-new IPackageSource each time), and -/// each listing fetched the whole plugins repository from GitHub and parsed ~70 manifests. Measured -/// from two production portals on 2026-08-26: 12–19 s to first byte for an 8.7 KB answer; measured -/// from the consumer a month later: 2,028 attempt timeouts in 33.6 days, ~60 a day. So the tests -/// below build a NEW source per "request" on purpose — a cache the source instance owned would be -/// thrown away with it, which is exactly why this one is mesh-scoped. -/// -public class PackageListingCacheTest -{ - private const string Repo = "https://github.com/Systemorph/MeshWeaver.Plugins"; - private const string OtherRepo = "https://github.com/Systemorph/MeshWeaver.Education"; - private const string Ref = "main"; - private const string Platform = "Plugins"; - - private static readonly TimeSpan Window = TimeSpan.FromMinutes(5); - - /// - /// 🚨 THE POSITIVE CONTROL, and the measurement of the defect: with no cache, five requests are - /// five repository reads. Everything below is only meaningful against this number. - /// - [Fact] - public async Task WithoutTheCache_EveryRequestReadsTheRepositoryAgain() - { - var reads = 0; - for (var request = 0; request < 5; request++) - await Listing(NewSourcePerRequest(() => reads++)); - - Assert.Equal(5, reads); - } - - /// The fix: the same five requests, each with its own freshly-built source, share ONE read. - [Fact] - public async Task WithTheCache_FiveRequestsReadTheRepositoryOnce() - { - var cache = new PackageListingCache(Window); - var reads = 0; - - for (var request = 0; request < 5; request++) - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - - Assert.Equal(1, reads); - } - - /// - /// 🚨 THE DISCLOSURE CONTROL (#3768's shape). Two instances with different grants are served off - /// the SAME cached source list, and each sees only what it was granted. What is cached is the - /// repository snapshot, pre-filter; the grant runs per request over it. A response cache would - /// fail this test, which is the whole reason it exists. - /// - [Fact] - public async Task TwoCallersSharingOneCachedListing_EachSeeOnlyTheirOwnGrant() - { - var cache = new PackageListingCache(Window); - var reads = 0; - var source = Wrap(cache, NewSourcePerRequest(() => reads++, Manifest("Hosting"), Manifest("Education"))); - var configured = new ConfiguredPackageSource(source, Ref, Platform); - - var hosting = await SeenBy(configured, Caller("a", "Hosting")); - var education = await SeenBy(configured, Caller("b", "Education")); - - Assert.Equal(["Hosting"], hosting); - Assert.Equal(["Education"], education); - Assert.Equal(1, reads); - } - - /// - /// 🚨 A FAULT IS NEVER CACHED (#1369). A transient GitHub failure replayed for the life of the - /// pod would trade a latency defect for an availability one — the next caller reads again. - /// - [Fact] - public async Task AFailedRead_IsNotCached_AndTheNextRequestTriesAgain() - { - var cache = new PackageListingCache(Window); - var attempts = 0; - - var faulted = new AsyncSubject(); - using var sub = Wrap(cache, new FakeSource(_ => - { - attempts++; - return Observable.Throw>( - new InvalidOperationException("GitHub said no")); - })) - .ListPackages(Ref) - .Subscribe(_ => { }, ex => { faulted.OnNext(ex); faulted.OnCompleted(); }); - - await faulted.Should().Within(TestTimeouts.Quick) - .Emit("the failing read must surface its error rather than park the caller"); - - var second = await Listing(Wrap(cache, NewSourcePerRequest(() => attempts++, Manifest("Hosting")))); - - Assert.Equal(2, attempts); - Assert.Equal(["Hosting"], second.Select(p => p.Id)); - } - - /// - /// Concurrent first callers share the ONE read — the other half of the win. A burst of catalog - /// opens used to be a burst of clones, which is what made the attempt budget reachable at all. - /// - [Fact] - public void ConcurrentFirstCallers_ShareOneRead() - { - var cache = new PackageListingCache(Window); - var reads = 0; - var gate = new Subject>(); - - var a = new List>(); - var b = new List>(); - using var subA = Wrap(cache, new FakeSource(_ => { reads++; return gate; })).ListPackages(Ref).Subscribe(a.Add); - using var subB = Wrap(cache, new FakeSource(_ => { reads++; return gate; })).ListPackages(Ref).Subscribe(b.Add); - - gate.OnNext([Manifest("Hosting")]); - gate.OnCompleted(); - - Assert.Equal(1, reads); - Assert.Equal(["Hosting"], a.Single().Select(p => p.Id)); - Assert.Equal(["Hosting"], b.Single().Select(p => p.Id)); - } - - /// - /// The freshness window is the SAFETY NET for a webhook that never arrived, so it must actually - /// expire. Driven over an injected monotonic clock rather than by waiting. - /// - [Fact] - public async Task PastTheWindow_TheRepositoryIsReadAgain() - { - var ticks = 0L; - var cache = new PackageListingCache(Window, () => ticks); - var reads = 0; - - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - ticks += StopwatchTicks(Window) / 2; - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - Assert.Equal(1, reads); - - ticks += StopwatchTicks(Window); - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - Assert.Equal(2, reads); - } - - /// - /// 🚨 MeshWeaver#4963 — past the window the HELD listing answers at once while the re-read runs - /// behind it. Production, 2026-10-09: an expired entry made the request that found it wait for - /// the re-read, and on the fleet registry that wait outlasted the 25 s answer budget — 123 - /// catalog refusals in 110 minutes with every source listing "running 24.9 s". The re-read here - /// is a source that has NOT answered yet: the request must not wait for it, and its answer must - /// replace the held one once it lands. - /// - [Fact] - public async Task PastTheWindow_TheHeldListingAnswersAtOnce_AndTheReReadReplacesItWhenItLands() - { - var ticks = 0L; - var cache = new PackageListingCache(Window, () => ticks); - var reads = 0; - - var first = await Listing(Wrap(cache, NewSourcePerRequest(() => reads++, Manifest("Hosting")))); - Assert.Equal(["Hosting"], first.Select(p => p.Id)); - - ticks += StopwatchTicks(Window) * 2; - var reRead = new Subject>(); - var served = await Listing(Wrap(cache, new FakeSource(_ => { reads++; return reRead; }))); - - Assert.Equal(["Hosting"], served.Select(p => p.Id)); - Assert.Equal(2, reads); - - // A second request while the re-read is still in flight joins it rather than starting another. - var again = await Listing(Wrap(cache, new FakeSource(_ => { reads++; return reRead; }))); - Assert.Equal(["Hosting"], again.Select(p => p.Id)); - Assert.Equal(2, reads); - - reRead.OnNext([Manifest("Hosting"), Manifest("Education")]); - reRead.OnCompleted(); - - var landed = await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - Assert.Equal(["Hosting", "Education"], landed.Select(p => p.Id)); - Assert.Equal(2, reads); - } - - /// Negative control for the test above: a key that has NEVER been read has nothing to - /// answer from, so its first caller does wait for the source — the stall is real, and only the - /// held listing is what the fix serves past it. - [Fact] - public async Task AKeyNeverRead_WaitsForItsFirstRead() - { - var cache = new PackageListingCache(Window); - await Wrap(cache, new FakeSource(_ => Observable.Never>())) - .ListPackages(Ref) - .Should().NotEmit(within: TimeSpan.FromMilliseconds(500), - because: "with nothing held, the first caller can only be answered by the source", - cancellationToken: TestContext.Current.CancellationToken); - } - - /// A FAILED re-read keeps the held listing in service — a transient GitHub fault must not - /// turn a catalog that worked into an empty one — and leaves the entry expired, so the very next - /// request asks the source again rather than treating the old listing as fresh. - [Fact] - public async Task AFailedReRead_KeepsServingTheHeldListing_AndTheNextRequestAsksAgain() - { - var ticks = 0L; - var cache = new PackageListingCache(Window, () => ticks); - var reads = 0; - - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++, Manifest("Hosting")))); - ticks += StopwatchTicks(Window) * 2; - - var failing = new FakeSource(_ => - { - reads++; - return Observable.Throw>(new InvalidOperationException("GitHub said no")); - }); - Assert.Equal(["Hosting"], (await Listing(Wrap(cache, failing))).Select(p => p.Id)); - Assert.Equal(["Hosting"], (await Listing(Wrap(cache, failing))).Select(p => p.Id)); - - Assert.Equal(3, reads); - } - - /// - /// ONE subscription per revalidation (review of #6389): every request that finds the entry - /// expired while the re-read is in flight answers from the held listing WITHOUT subscribing to - /// the re-read. Measured by its terminal: when the stalled re-read finally faults, the failure is - /// logged once — not once per request that saw the expiry, which is what one retained observer - /// per request looked like. - /// - [Fact] - public async Task ManyRequestsDuringOneStalledReRead_HoldOneSubscription() - { - var ticks = 0L; - var log = new CapturingLogger(); - var cache = new PackageListingCache(Window, () => ticks, log); - var reads = 0; - - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - ticks += StopwatchTicks(Window) * 2; - var reRead = new Subject>(); - for (var request = 0; request < 5; request++) - await Listing(Wrap(cache, new FakeSource(_ => { reads++; return reRead; }))); - Assert.Equal(2, reads); - - reRead.OnError(new InvalidOperationException("GitHub said no")); - - Assert.Single(log.Lines(Microsoft.Extensions.Logging.LogLevel.Warning), - l => l.Contains("revalidating", StringComparison.Ordinal)); - } - - /// A green build that lands WHILE a re-read is in flight: that read started before the - /// merge, so its answer is served but stays stale — the next request reads again. - [Fact] - public async Task AReReadThatStartedBeforeAGreenBuild_LandsStale() - { - var ticks = 0L; - var cache = new PackageListingCache(Window, () => ticks); - var reads = 0; - - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - ticks += StopwatchTicks(Window) * 2; - var reRead = new Subject>(); - await Listing(Wrap(cache, new FakeSource(_ => { reads++; return reRead; }))); - Assert.Equal(2, reads); - - Assert.Equal(1, cache.EvictRepo(Repo)); - reRead.OnNext([Manifest("Hosting")]); - reRead.OnCompleted(); - - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - Assert.Equal(3, reads); - } - - /// - /// The PRIMARY invalidation: a green build of that repository forgets its listings immediately, - /// so a merge does not have to wait out the window. And it forgets only that repository — - /// spelled with or without .git, because the webhook and the configured source disagree. - /// - [Theory] - [InlineData(Repo)] - [InlineData(Repo + ".git")] - [InlineData(Repo + "/")] - public async Task AGreenBuild_ForgetsThatRepositoryAndLeavesTheOthers(string built) - { - var cache = new PackageListingCache(Window); - var mine = 0; - var other = 0; - - await Listing(Wrap(cache, NewSourcePerRequest(() => mine++))); - await Listing(Wrap(cache, NewSourcePerRequest(() => other++), OtherRepo)); - - Assert.Equal(1, cache.EvictRepo(built)); - - await Listing(Wrap(cache, NewSourcePerRequest(() => mine++))); - await Listing(Wrap(cache, NewSourcePerRequest(() => other++), OtherRepo)); - - Assert.Equal(2, mine); - Assert.Equal(1, other); - } - - /// - /// 🚨 File fetches are NEVER cached. They are per-package reads on the INSTALL path, not repeated - /// per catalog render, and a stale one would install stale bytes. - /// - [Fact] - public async Task FileFetches_AreNeverCached() - { - var cache = new PackageListingCache(Window); - var fetches = 0; - var inner = new FakeSource(_ => Observable.Return((IReadOnlyList)[Manifest("Hosting")])) - { - OnFetch = () => fetches++, - }; - var wrapped = Wrap(cache, inner); - - await Files(wrapped); - await Files(wrapped); - - Assert.Equal(2, fetches); - } - - /// Two READINGS of one repository are different listings — a node repo and a - /// package.json repo parse the same tree differently, so they must not share an entry. - [Fact] - public async Task TwoFormatsOfOneRepository_DoNotShareAnEntry() - { - var cache = new PackageListingCache(Window); - var reads = 0; - - await Listing(cache.Wrap(NewSourcePerRequest(() => reads++), Repo, "", "node")); - await Listing(cache.Wrap(NewSourcePerRequest(() => reads++), Repo, "", "package.json")); - - Assert.Equal(2, reads); - } - - /// Two REFS of one repository are different listings too. - [Fact] - public async Task TwoRefsOfOneRepository_DoNotShareAnEntry() - { - var cache = new PackageListingCache(Window); - var reads = 0; - var source = Wrap(cache, NewSourcePerRequest(() => reads++)); - - await Listing(source, "main"); - await Listing(source, "v1.2.3"); - - Assert.Equal(2, reads); - } - - /// - /// 🚨 Switched off means OFF, and a typo must not switch it off. A malformed value falling back - /// to "no cache" would silently restore the per-request repository read this exists to remove, - /// and nothing would say so. - /// - [Fact] - public void TheWindow_DefaultsWhenUnsetOrMalformed_AndIsOffOnlyWhenExplicitlyZero() - { - Assert.Equal(PackageListingCache.DefaultWindow, PackageListingCache.WindowOf(null)); - Assert.Equal(PackageListingCache.DefaultWindow, PackageListingCache.WindowOf("")); - Assert.Equal(PackageListingCache.DefaultWindow, PackageListingCache.WindowOf("five minutes")); - Assert.Equal(PackageListingCache.DefaultWindow, PackageListingCache.WindowOf("300s")); - Assert.Equal(TimeSpan.Zero, PackageListingCache.WindowOf("0")); - Assert.Equal(TimeSpan.Zero, PackageListingCache.WindowOf("-1")); - Assert.Equal(TimeSpan.FromSeconds(45), PackageListingCache.WindowOf("45")); - } - - /// - /// 🚨 double.TryParse SUCCEEDS on these, and TimeSpan.FromSeconds throws on them — - /// inside the DI factory that builds the singleton, which would take the host down over a typo - /// in a config value. They are malformed values and must land on the default like any other. - /// - [Theory] - [InlineData("NaN")] - [InlineData("Infinity")] - [InlineData("-Infinity")] - [InlineData("1e400")] - [InlineData("1e30")] - public void ANonFiniteOrAbsurdWindow_FallsBackToTheDefault_AndNeverThrows(string configured) - { - Assert.Equal(PackageListingCache.DefaultWindow, PackageListingCache.WindowOf(configured)); - } - - /// - /// 🚨 Expiry must not strip a CONCURRENT caller's fresh entry of its stamp. An entry with no - /// stamp can never expire again and is invisible to , - /// so the immortal-entry shape is the one to pin: after an expiry-driven re-read, the entry is - /// still evictable and still expires. - /// - [Fact] - public async Task AnEntryRebuiltAfterExpiry_IsStillEvictableAndStillExpires() - { - var ticks = 0L; - var cache = new PackageListingCache(Window, () => ticks); - var reads = 0; - - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - ticks += StopwatchTicks(Window) * 2; - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - Assert.Equal(2, reads); - - // Still visible to the green-build signal — the property an unstamped entry would lose. - Assert.Equal(1, cache.EvictRepo(Repo)); - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - Assert.Equal(3, reads); - - // And still expiring on its own clock. - ticks += StopwatchTicks(Window) * 2; - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - Assert.Equal(4, reads); - } - - /// With the window at zero the wrapper is not even installed — every request reads - /// again, exactly as before the cache existed. - [Fact] - public async Task WithTheCacheOff_EveryRequestReadsTheRepositoryAgain() - { - var cache = new PackageListingCache(TimeSpan.Zero); - var reads = 0; - - for (var request = 0; request < 3; request++) - await Listing(Wrap(cache, NewSourcePerRequest(() => reads++))); - - Assert.False(cache.Enabled); - Assert.Equal(3, reads); - } - - // ─────────────────────────── helpers ─────────────────────────── - - /// One "request": a source instance built from scratch, as PluginRegistryEndpoints does. - private static IPackageSource NewSourcePerRequest(Action onRead, params PackageManifest[] packages) => - new FakeSource(_ => - { - onRead(); - return Observable.Return((IReadOnlyList) - (packages.Length == 0 ? [Manifest("Hosting")] : packages)); - }); - - private static IPackageSource Wrap(PackageListingCache cache, IPackageSource inner, string repoUrl = Repo) => - cache.Wrap(inner, repoUrl, "", "node"); - - /// - /// One listing, awaited through the reactive assertion helper. 🚨 Never .Wait(): a blocking - /// bridge in a test is a guard violation here, and it measures the wrong thing — an observable - /// that never emits would wedge the run instead of naming what did not arrive. - /// - private static Task> Listing(IPackageSource source, string gitRef = Ref) => - source.ListPackages(gitRef).Should().Within(TestTimeouts.Quick) - .Emit("the source must produce a listing", cancellationToken: TestContext.Current.CancellationToken); - - private static Task> Files(IPackageSource source) => - source.FetchPackageFiles(Manifest("Hosting"), Ref).Should().Within(TestTimeouts.Quick) - .Emit("the source must produce the package's files", - cancellationToken: TestContext.Current.CancellationToken); - - private static long StopwatchTicks(TimeSpan span) => - (long)(span.TotalSeconds * System.Diagnostics.Stopwatch.Frequency); - - private static PackageManifest Manifest(string id) => new() { Id = id, Name = id, Version = "1.0.0" }; - - private static AuthenticatedInstance Caller(string instanceId, string packageId) => - new(new MeshWeaverInstance { InstanceId = instanceId, KeyHash = "hash", Plan = "enterprise" }, - new PluginGrant - { - InstanceId = instanceId, - Entries = [new PluginGrantEntry { Source = Platform, PackageId = packageId }], - }); - - private static async Task SeenBy(ConfiguredPackageSource configured, AuthenticatedInstance caller) - { - var listing = await Memex.Portal.Shared.Api.PluginRegistryEndpoints - .ListAll([configured], caller, Observable.Return((IReadOnlyList)[]), null) - .Should().Within(TestTimeouts.Quick).Emit("the registry must answer this caller", - cancellationToken: TestContext.Current.CancellationToken); - return listing.Packages.Select(p => p.Id).ToArray(); - } - - private sealed class FakeSource(Func>> list) : IPackageSource - { - public Action? OnFetch { get; init; } - - public IObservable> ListPackages(string gitRef) => list(gitRef); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) - { - OnFetch?.Invoke(); - return Observable.Return((IReadOnlyList)[]); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/PackageOriginAnchorAnswersFromItsSnapshotTest.cs b/test/Memex.Portal.Shared.Test/PackageOriginAnchorAnswersFromItsSnapshotTest.cs deleted file mode 100644 index 6953907b7a..0000000000 --- a/test/Memex.Portal.Shared.Test/PackageOriginAnchorAnswersFromItsSnapshotTest.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Reactive.Assertions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#4963 — the entitlement anchor answers from the snapshot it HOLDS and never makes a -/// request wait for its sources to be listed again. Production, 2026-10-09: the fleet registry's -/// answer deadline refused 124 index and bundle requests in 110 minutes with "Still waiting on: -/// package origin anchor (running 25.0 s)" while authentication, the installed-packages query and -/// the activation list had all finished in well under a second — because every read past the -/// anchor's 60 s window re-listed every source with the request waiting on it. -/// -/// The repro drives the anchor through its seam constructor with a source whose re-listing has -/// NOT answered: a read past the window must answer at once from the held snapshot, and the -/// re-listing's answer must replace it once it lands. Negative control: with nothing held yet, the -/// same stalled source does hold its reader — the only read that may wait. -/// -public class PackageOriginAnchorAnswersFromItsSnapshotTest -{ - private static readonly TimeSpan Freshness = TimeSpan.FromSeconds(60); - - [Fact] - public async Task PastItsWindow_TheAnchorAnswersFromItsSnapshot_WhileTheReListingRuns() - { - var now = DateTimeOffset.Parse("2026-10-09T18:00:00Z"); - var source = new ScriptedSource(); - var anchor = new PackageOriginAnchor(() => [new ConfiguredPackageSource(source, "main", "Plugins")], Freshness, () => now); - - source.Next = Observable.Return(Listing("Hosting")); - var first = await Read(anchor); - Assert.Equal(AnchorState.Authoritative, first.State); - Assert.Equal(["Hosting"], first.Origins.Keys); - - now += Freshness * 2; - var reListing = new Subject>(); - source.Next = reListing; - - var served = await Read(anchor); - Assert.Same(first, served); - Assert.Equal(2, source.Listings); - - // A second read while the re-listing is in flight joins it rather than starting another. - Assert.Same(first, await Read(anchor)); - Assert.Equal(2, source.Listings); - - reListing.OnNext(Listing("Hosting", "Education")); - reListing.OnCompleted(); - - var landed = await Read(anchor); - Assert.Equal(AnchorState.Authoritative, landed.State); - Assert.Equal(["Education", "Hosting"], landed.Origins.Keys.OrderBy(k => k, StringComparer.Ordinal)); - Assert.Equal(2, source.Listings); - } - - /// Negative control: with NOTHING held, the anchor has no snapshot to answer from, so a - /// stalled listing does hold its reader — the stall the deadline measured is real, and the held - /// snapshot is what the fix serves past it. - [Fact] - public async Task WithNothingHeld_AStalledListingHoldsTheReader() - { - var source = new ScriptedSource { Next = Observable.Never>() }; - var anchor = new PackageOriginAnchor(() => [new ConfiguredPackageSource(source, "main", "Plugins")], Freshness, () => DateTimeOffset.UtcNow); - - await anchor.Read().Should().NotEmit(within: TimeSpan.FromMilliseconds(500), - because: "a first read can only be answered by the sources", - cancellationToken: TestContext.Current.CancellationToken); - } - - /// Concurrent first readers share ONE listing of the sources rather than one each. - [Fact] - public async Task ConcurrentFirstReaders_ShareOneListing() - { - var gate = new Subject>(); - var source = new ScriptedSource { Next = gate }; - var anchor = new PackageOriginAnchor(() => [new ConfiguredPackageSource(source, "main", "Plugins")], Freshness, () => DateTimeOffset.UtcNow); - - var a = anchor.Read().Replay(1); - var b = anchor.Read().Replay(1); - using var ca = a.Connect(); - using var cb = b.Connect(); - gate.OnNext(Listing("Hosting")); - gate.OnCompleted(); - - Assert.Same(await a.Should().Within(TestTimeouts.Quick).Emit("the first reader must be answered", - cancellationToken: TestContext.Current.CancellationToken), - await b.Should().Within(TestTimeouts.Quick).Emit("the second reader must be answered", - cancellationToken: TestContext.Current.CancellationToken)); - Assert.Equal(1, source.Listings); - } - - /// A window of zero still means no reuse: every read waits for its own listing, as configured. - [Fact] - public async Task AZeroWindow_StillListsOnEveryRead() - { - var source = new ScriptedSource { Next = Observable.Return(Listing("Hosting")) }; - var anchor = new PackageOriginAnchor(() => [new ConfiguredPackageSource(source, "main", "Plugins")], TimeSpan.Zero, () => DateTimeOffset.UtcNow); - - await Read(anchor); - await Read(anchor); - - Assert.Equal(2, source.Listings); - } - - private static Task Read(PackageOriginAnchor anchor) => - anchor.Read().Should().Within(TestTimeouts.Quick) - .Emit("the anchor must answer", cancellationToken: TestContext.Current.CancellationToken); - - private static IReadOnlyList Listing(params string[] ids) => - ids.Select(id => new PackageManifest { Id = id, Name = id, Version = "1.0.0", ReleasedVersion = "1.0.0" }).ToList(); - - private sealed class ScriptedSource : IPackageSource - { - public IObservable> Next { get; set; } = - Observable.Return((IReadOnlyList)[]); - - public int Listings { get; private set; } - - public IObservable> ListPackages(string gitRef) - { - Listings++; - return Next; - } - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) => - Observable.Return((IReadOnlyList)[]); - } -} diff --git a/test/Memex.Portal.Shared.Test/PackageReasonsNeverSayTheRollIsHeldTest.cs b/test/Memex.Portal.Shared.Test/PackageReasonsNeverSayTheRollIsHeldTest.cs deleted file mode 100644 index 2bf98c8586..0000000000 --- a/test/Memex.Portal.Shared.Test/PackageReasonsNeverSayTheRollIsHeldTest.cs +++ /dev/null @@ -1,103 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A package reason never says the roll is held — policy -/// platform-rolls-independently-of-packages. -/// -/// A package availability reason is carried VERBATIM into a roll advisory -/// (UpdatabilityVerdict.ForPlatformRoll()), and the Updates tab shows it while the roll -/// proceeds. A reason that ends "nothing rolls" or "not clearance to proceed" therefore tells the -/// operator the opposite of what is happening. Every reason sentence these files build is checked -/// here, so a new branch cannot reintroduce the wording: the empty-denominator refusal -/// (ReleaseAvailabilityService), every PackageAvailability reason -/// (ReleaseAvailability) and the unreadable-root refusal (PublishedBundleCatalogue). -/// -/// The sentences are split across concatenated literals, so the check reads the string -/// literals of each file (comments excluded) joined in source order, which is how the sentence is -/// assembled at run time. -/// -public class PackageReasonsNeverSayTheRollIsHeldTest -{ - private static readonly string[] ReasonSources = - [ - Path.Combine("src", "MeshWeaver.PluginCatalog", "ReleaseAvailability.cs"), - Path.Combine("src", "MeshWeaver.PluginCatalog", "PublishedBundleCatalogue.cs"), - Path.Combine("memex", "Memex.Portal.Shared", "SelfUpdate", "ReleaseAvailabilityService.cs"), - ]; - - /// Phrases that state a roll hold. A package reason states a PACKAGE result. - private static readonly string[] Forbidden = - [ - "roll is held", - "nothing rolls", - "not clearance", - "clearance to roll", - "clearance to proceed", - "holds the roll", - "the roll stops", - ]; - - private static readonly Regex Literal = new(@"\$?@?""((?:[^""\\]|\\.)*)""", RegexOptions.Compiled); - - [Fact] - public void NoPackageReasonSentence_StatesARollHold() - { - var root = RepoRoot(); - foreach (var relative in ReasonSources) - { - var text = LiteralText(File.ReadAllText(Path.Combine(root, relative))); - Assert.False(string.IsNullOrEmpty(text), $"{relative} builds reason sentences from string literals"); - foreach (var phrase in Forbidden) - text.Should().NotContain(phrase, - $"{relative} builds a package reason that is carried verbatim into a roll advisory, " - + "and the platform rolls independently of packages"); - } - } - - /// The negative control: the extraction finds a forbidden phrase split across two - /// concatenated literals, exactly the shape the reasons were written in. - [Fact] - public void TheCheck_FindsAPhraseSplitAcrossConcatenatedLiterals() - { - const string source = """ - // a comment saying the roll is held is not a reason - return "cannot determine availability, which is not " - + "clearance to proceed"; - """; - var text = LiteralText(source); - text.Should().Contain("not clearance to proceed"); - text.Should().NotContain("roll is held", "comments are not reasons"); - } - - private static string LiteralText(string source) - { - var sb = new StringBuilder(); - foreach (var line in source.Split('\n')) - { - var trimmed = line.TrimStart(); - if (trimmed.StartsWith("//", StringComparison.Ordinal) || trimmed.StartsWith('*')) - continue; - foreach (Match m in Literal.Matches(line)) - sb.Append(m.Groups[1].Value); - } - return sb.ToString(); - } - - private static string RepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - Assert.SkipWhen(dir is null, "repository tree not reachable — source guard runs in-repo only"); - return dir!.FullName; - } -} diff --git a/test/Memex.Portal.Shared.Test/PackageUninstallTest.cs b/test/Memex.Portal.Shared.Test/PackageUninstallTest.cs deleted file mode 100644 index 45d5e78a96..0000000000 --- a/test/Memex.Portal.Shared.Test/PackageUninstallTest.cs +++ /dev/null @@ -1,261 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// The pure rules of a package uninstall. -public class PackageUninstallRulesTest -{ - [Fact] - public void AnUninstall_NeedsAPackageAndAReason() - { - PackageUninstall.Validate(new PackageUninstallRequest { Package = "BuildServer", Reason = "x" }).Should().BeNull(); - PackageUninstall.Validate(new PackageUninstallRequest { Package = "", Reason = "x" }).Should().Contain("package id"); - PackageUninstall.Validate(new PackageUninstallRequest { Package = "Plugins/X", Reason = "x" }).Should().Contain("not a package id"); - PackageUninstall.Validate(new PackageUninstallRequest { Package = "X", Reason = " " }).Should().Contain("reason"); - } - - [Fact] - public void RowsAreCountedPerTable_AndOnlyPeopleCountAsUserData() - { - PackageUninstallExecutor.TableOf("P", "P").Should().Be("mesh_nodes"); - PackageUninstallExecutor.TableOf("P", "P/Doc/Intro").Should().Be("mesh_nodes"); - PackageUninstallExecutor.TableOf("P", "P/_Access/Admins").Should().Be("_Access"); - PackageUninstallExecutor.TableOf("P", "P/Doc/_Thread/t1").Should().Be("_Thread"); - - PackageUninstallExecutor.IsUserCreated(WellKnownUsers.System).Should().BeFalse("the installer writes as System"); - PackageUninstallExecutor.IsUserCreated(null).Should().BeFalse(); - PackageUninstallExecutor.IsUserCreated("alice").Should().BeTrue(); - } -} - -/// -/// 🚨 Uninstall, end to end — two phases. Phase 1 retires the module (one restart here: no -/// live loader), removes the install record and blocks re-install, and only PREVIEWS the data; -/// phase 2 drops it only on the requester's exact confirmation. Real request node, executor, -/// install records, landing service, self-updater restart path and the platform's partition teardown. -/// -public class PackageUninstallTest(ITestOutputHelper output) : ModuleReloadScenario(output) -{ - private const string Requester = "uninstall-requester"; - - private IStorageAdapter Storage => Mesh.ServiceProvider.GetRequiredService(); - - /// The package's content partition, written as System — what the installer would have landed. - private async Task Content(CancellationToken ct) - { - foreach (var node in new[] - { - MeshNode.FromPath(Package) with { NodeType = "Markdown", Name = Package, State = MeshNodeState.Active }, - MeshNode.FromPath($"{Package}/Guide") with { NodeType = "Markdown", Name = "Guide", State = MeshNodeState.Active }, - }) - await Access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode(node)).Timeout(TestTimeouts.Convergence).Await(ct); - (await Exists($"{Package}/Guide", ct)).Should().BeTrue("the premise: the package's content exists"); - } - - private Task Exists(string path, CancellationToken ct) => - Storage.Read(path, Mesh.JsonSerializerOptions).Take(1).DefaultIfEmpty(null) - .Select(n => n is not null).Timeout(TestTimeouts.Convergence).Await(ct); - - private async Task Uninstall(string package, CancellationToken ct) - { - var ticket = await PackageUninstall.Request(Mesh, new PackageUninstallRequest - { - Package = package, - Reason = "retired from Plugins main", - RequestedBy = Requester, - }) - .Timeout(TestTimeouts.Convergence).Await(ct); - ticket.Refusal.Should().BeNull(); - ticket.Path.Should().NotBeNull(); - return ticket.Path ?? ""; - } - - private Task Await(string path, Func until, CancellationToken ct) => - Access.RunAsSystem(() => Mesh.GetMeshNodeStream(path)) - .Select(n => n.ContentAs(Mesh.JsonSerializerOptions)) - .OfType() - .Where(until) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - [Fact(Timeout = 240_000)] - public async Task PhaseOneRetainsTheData_AWrongConfirmationIsRefused_TheRightOneDropsIt() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - await Content(ct); - - var path = await Uninstall(Package, ct); - var preview = await Await(path, r => r.Status == PackageUninstallStatus.AwaitingConfirmation || PackageUninstallStatus.IsTerminal(r.Status), ct); - - preview.Status.Should().Be(PackageUninstallStatus.AwaitingConfirmation, preview.Failure ?? ""); - preview.ConfirmationRequired.Should().Be(Package); - var p = preview.Partitions.Single(); - p.Partition.Should().Be(Package); - p.RowsByTable["mesh_nodes"].Should().Be(2, "the preview counts exactly what phase 2 would drop"); - p.Unmeasured.Should().NotBeEmpty("what cannot be counted is said"); - preview.ModuleOutcome.Should().Contain("one restart requested"); - Updater.Restarts.Should().Be(1, "the loaded module unloads by exactly one restart (no live loader here)"); - (await Exists($"{PackageInstaller.InstalledPartition}/{Package}", ct)).Should().BeFalse("the install record is removed"); - (await PackageUninstallExecutor.UninstalledHere(Mesh).Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().Contain(Package, "no unattended pass may install it again"); - - // ── Negative control: NO confirmation → the data is retained. ────────────────────────── - (await Exists($"{Package}/Guide", ct)).Should().BeTrue("without a confirmation nothing is dropped"); - - // ── A WRONG confirmation is refused by name, and the data is still retained. ─────────── - (await PackageUninstall.Confirm(Mesh, path, "Plugins", Requester).Timeout(TestTimeouts.Convergence).Await(ct)).Accepted.Should().BeTrue(); - var refused = await Await(path, r => r.ConfirmationRefusal is not null, ct); - refused.ConfirmationRefusal.Should().Contain("'Plugins' does not match the required 'ReloadPkg'"); - refused.Status.Should().Be(PackageUninstallStatus.AwaitingConfirmation); - (await Exists($"{Package}/Guide", ct)).Should().BeTrue(); - - // ── Someone other than the requester is refused too. ────────────────────────────────── - await PackageUninstall.Confirm(Mesh, path, Package, "someone-else").Timeout(TestTimeouts.Convergence).Await(ct); - (await Await(path, r => r.ConfirmationRefusal?.Contains("someone-else") == true, ct)).Status - .Should().Be(PackageUninstallStatus.AwaitingConfirmation); - (await Exists($"{Package}/Guide", ct)).Should().BeTrue(); - - // ── The requester's exact confirmation drops the partition. ──────────────────────────── - await PackageUninstall.Confirm(Mesh, path, Package, Requester).Timeout(TestTimeouts.Convergence).Await(ct); - var done = await Await(path, r => PackageUninstallStatus.IsTerminal(r.Status), ct); - done.Status.Should().Be(PackageUninstallStatus.Done, done.Failure ?? ""); - done.ConfirmedBy.Should().Be(Requester); - done.ConfirmedAt.Should().NotBeNull(); - done.Partitions.Single().TornDown.Should().BeTrue(); - done.Partitions.Single().TeardownOutcome.Should().Contain("Admin/Partition/ReloadPkg"); - (await Exists($"{Package}/Guide", ct)).Should().BeFalse("the partition's content is gone"); - (await Exists(Package, ct)).Should().BeFalse("and its root"); - Updater.Restarts.Should().Be(1, "phase 2 restarts nothing"); - } - - [Fact(Timeout = 240_000)] - public async Task ARePassDoesNotBringItBack_UnlessAPersonInstallsItAgain() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - await Content(ct); - var path = await Uninstall(Package, ct); - await Await(path, r => r.Status == PackageUninstallStatus.AwaitingConfirmation, ct); - - (await PackageUninstallExecutor.UninstalledHere(Mesh).Timeout(TestTimeouts.Convergence).Await(ct)).Should().Contain(Package); - - // Negative control: a person installs it again (an install record exists) → no longer blocked. - var reinstalled = MeshNode.FromPath($"{PackageInstaller.InstalledPartition}/{Package}") with - { - NodeType = PackageInstaller.PackageNodeType, - Name = Package, - State = MeshNodeState.Active, - Content = new PackageManifest { Id = Package, Name = Package, Version = "1.1.0", TargetPartition = Package, Module = Module }, - }; - await Access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode(reinstalled)).Timeout(TestTimeouts.Convergence).Await(ct); - (await PackageUninstallExecutor.UninstalledHere(Mesh).Timeout(TestTimeouts.Convergence).Await(ct)) - .Should().NotContain(Package, "installing it again lifts the block"); - } - - [Fact(Timeout = 240_000)] - public async Task AnUnknownPackage_IsRefusedByName() - { - var ct = TestContext.Current.CancellationToken; - var path = await Uninstall("NotInstalledHere", ct); - var red = await Await(path, r => PackageUninstallStatus.IsTerminal(r.Status), ct); - red.Status.Should().Be(PackageUninstallStatus.Failed); - red.Failure.Should().Contain("'NotInstalledHere' is not an installed package here"); - Updater.Restarts.Should().Be(0); - - // A confirmation for a request that does not AWAIT one is refused and records nothing — - // the preview is what a confirmation answers (#6124 review). - var early = await PackageUninstall.Confirm(Mesh, path, "NotInstalledHere", Requester).Timeout(TestTimeouts.Convergence).Await(ct); - early.Accepted.Should().BeFalse(); - early.Refusal.Should().Contain("does not await a confirmation"); - (await Await(path, _ => true, ct)).Confirmation.Should().BeNull("nothing was recorded"); - } - - [Fact(Timeout = 240_000)] - public async Task ARequestNamingNoRequester_CannotBeConfirmedByAnyone() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - await Content(ct); - var ticket = await PackageUninstall.Request(Mesh, new PackageUninstallRequest - { - Package = Package, - Reason = "retired from Plugins main", - RequestedBy = null, - }) - .Timeout(TestTimeouts.Convergence).Await(ct); - ticket.Path.Should().NotBeNull(ticket.Refusal ?? ""); - var path = ticket.Path ?? ""; - var preview = await Await(path, r => r.Status == PackageUninstallStatus.AwaitingConfirmation || PackageUninstallStatus.IsTerminal(r.Status), ct); - preview.Status.Should().Be(PackageUninstallStatus.AwaitingConfirmation, preview.Failure ?? ""); - preview.AwaitingConfirmationAt.Should().NotBeNull("the preview's instant is stamped"); - - (await PackageUninstall.Confirm(Mesh, path, Package, "any-admin").Timeout(TestTimeouts.Convergence).Await(ct)).Accepted.Should().BeTrue(); - var refused = await Await(path, r => r.ConfirmationRefusal is not null, ct); - refused.ConfirmationRefusal.Should().Contain("names no requester"); - refused.Status.Should().Be(PackageUninstallStatus.AwaitingConfirmation); - (await Exists($"{Package}/Guide", ct)).Should().BeTrue("nothing is dropped without a named requester"); - } - - [Fact(Timeout = 240_000)] - public async Task ASharedPartition_IsRefused_AndNothingIsTouched() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - await Content(ct); - var other = MeshNode.FromPath($"{PackageInstaller.InstalledPartition}/OtherPkg") with - { - NodeType = PackageInstaller.PackageNodeType, - Name = "OtherPkg", - State = MeshNodeState.Active, - Content = new PackageManifest { Id = "OtherPkg", Name = "OtherPkg", Version = "1.0.0", TargetPartition = Package }, - }; - await Access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode(other)).Timeout(TestTimeouts.Convergence).Await(ct); - - var path = await Uninstall(Package, ct); - var red = await Await(path, r => PackageUninstallStatus.IsTerminal(r.Status), ct); - red.Status.Should().Be(PackageUninstallStatus.Failed); - red.Failure.Should().Contain("shared with the installed package(s) OtherPkg"); - (await Exists($"{PackageInstaller.InstalledPartition}/{Package}", ct)).Should().BeTrue("nothing was touched"); - (await Exists($"{Package}/Guide", ct)).Should().BeTrue(); - Updater.Restarts.Should().Be(0); - } - - [Fact(Timeout = 240_000)] - public async Task APartitionHoldingUserData_IsRefused() - { - var ct = TestContext.Current.CancellationToken; - await RunningVersionOne(ct); - await Content(ct); - // A node a PERSON created in the package's partition. - var mine = MeshNode.FromPath($"{Package}/MyNotes") with - { - NodeType = "Markdown", Name = "My notes", State = MeshNodeState.Active, CreatedBy = "alice", - }; - await Access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode(mine)).Timeout(TestTimeouts.Convergence).Await(ct); - var stored = await Storage.Read(mine.Path, Mesh.JsonSerializerOptions).Take(1).Timeout(TestTimeouts.Convergence).Await(ct); - Assert.SkipWhen(stored?.CreatedBy != "alice", - "the store stamps CreatedBy itself here, so a person's node cannot be simulated through this write path"); - - var path = await Uninstall(Package, ct); - var red = await Await(path, r => PackageUninstallStatus.IsTerminal(r.Status), ct); - red.Status.Should().Be(PackageUninstallStatus.Failed); - red.Failure.Should().Contain("created by people").And.Contain("MyNotes by alice"); - (await Exists($"{PackageInstaller.InstalledPartition}/{Package}", ct)).Should().BeTrue("nothing was touched"); - } -} diff --git a/test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs b/test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs deleted file mode 100644 index 010c56b5b5..0000000000 --- a/test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs +++ /dev/null @@ -1,308 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 An "Update available" reminder is a STATE, not an event — a second reconcile pass over the -/// same pending update must leave ONE notification node, not two -/// (Systemorph/MeshWeaver#3213). -/// -/// Measured on memex.meshweaver.cloud on 2026-09-03: 124 of the newest 200 notification -/// rows were this one emitter, two packages producing four rows each per day and the same shape all -/// the way back through the window. re-decides on every poll, -/// and nothing about a reminder was idempotent. -/// -/// Two independent halves, and fixing either alone still duplicates — so this fixture -/// exercises both, each with its own arm that fails if that half is missing: -/// -/// A silence gate that can actually become true. The content-identity gate asks "is -/// the candidate installed?", which is self-silencing on the AutoUpdate path and -/// UNSATISFIABLE on the reminder path — nothing is installed there, by design, so it evaluates -/// false on every subsequent poll forever. Arm 2 below runs an IDENTICAL second pass; without -/// it writes a second row. -/// A deterministic identity to be idempotent on. Every notification used to mint a -/// fresh GUID, so a repeat had nothing to collide with. Arm 3 puts the marker back to a stale -/// value — the shape a failed marker write, or a second reconcile entry point racing the first, -/// actually leaves behind — and re-runs: the notify path executes end to end (proved by the -/// marker being rewritten) and still lands on the SAME node. -/// -/// -/// Arm 4 is the falsifier the suppression needs: a genuinely NEW candidate version must still -/// raise its own unread bell. A gate that silenced that would pass arms 2 and 3 while breaking the -/// feature, and it is also what bounds the negative assertions — once the listing shows the v3 -/// reminder, anything an earlier pass wrote is already committed and would be counted. -/// -public class PackageUpdateReminderIdempotenceTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string PackageId = "UpdateReminderIdempotencePkg"; - private const string PackageName = "Update Reminder Idempotence Package"; - private const string InstalledModuleVersion = "mv-installed-3213"; - private const string CandidateModuleVersion = "mv-candidate-3213"; - private const string NextCandidateModuleVersion = "mv-candidate-3213-next"; - - /// A marker value that is neither candidate — what a marker write that never landed, - /// or one left by some other candidate, looks like to the gate. - private const string StaleMarker = "mv-marker-from-some-other-pass"; - - private const string RegistryUrl = "http://registry.reminder-idempotence.test"; - private const string Token = "mwi_reminder_idempotence_test"; - - private static string RecordPath => $"{PackageInstaller.InstalledPartition}/{PackageId}"; - - /// The negative control: the reminder path must not fetch a single file. Every request - /// this handler ever sees is a reconcile that took the APPLY branch it had no business taking. - private readonly CountingRegistry registry = new(); - - private System.Text.Json.JsonSerializerOptions Json => Mesh.JsonSerializerOptions; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(s => s.AddSingleton(new CountingClientFactory(registry))); - - [Fact] - public async Task ASecondReconcileOverTheSamePendingUpdate_LeavesExactlyOneNotification() - { - await SeedInstallRecord(); - - var source = new RegistryPackageSource(Mesh, RegistryUrl, Token); - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(nameof(PackageUpdateReminderIdempotenceTest)); - - // ── 1. The first pass tells the user, once ────────────────────────────────────────────── - await Reconcile(source, CandidateModuleVersion, "Served by registry 'first pass'", logger); - - var first = await Reminders() - .Where(ns => ns.Count == 1) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - var reminderPath = first[0].Path; - var reminder = first[0].ContentAs(Json); - Assert.NotNull(reminder); - Assert.StartsWith("Update available", reminder!.Title, StringComparison.Ordinal); - Assert.Contains(PackageName, reminder.Title); - Assert.False(reminder.IsRead); - - // The gate the reminder path now owns: "have I already told them about THIS candidate?". - // Writing it is what makes the gate become TRUE — which is precisely what comparing the - // record's installed version against the candidate could never do here. - await AwaitMarker(CandidateModuleVersion); - - // ── 2. HALF ONE — an identical second pass says nothing at all ────────────────────────── - // 🚨 Counting rows CANNOT test this half, and asserting only the count is how a fixture - // passes having checked nothing: with the deterministic identity in place, a second pass - // that is NOT suppressed still lands on the SAME node, so the count stays 1 either way. - // What separates "suppressed" from "absorbed" is what the user sees — so dismiss the bell - // first. A pass that re-raises an unchanged reminder un-reads it, which is the defect a - // reader of the bell actually experiences four times a day. - await MarkRead(reminderPath); - await AwaitRead(reminderPath, true); - - // This is the pass that produced a new row per poll, forever. Its own completion is the - // bound: the notification write, if there were one, sits inside the awaited chain. - await Reconcile(source, CandidateModuleVersion, "Served by registry 'second pass'", logger); - - var afterSecond = await Reminders().FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.Single(afterSecond); - Assert.Equal(reminderPath, afterSecond[0].Path); - var stillDismissed = afterSecond[0].ContentAs(Json); - Assert.NotNull(stillDismissed); - Assert.True(stillDismissed!.IsRead, - "the second pass re-raised a reminder the user had already dismissed — the reminder " - + "path's silence gate did not suppress it"); - Assert.Equal(reminder.CreatedAt, stillDismissed.CreatedAt); - - // ── 3. HALF TWO — a pass that DOES reach the notify path still lands on the same node ─── - // The marker is put back to a value the gate cannot match, so guard one steps aside and the - // notify path runs for real. Only the deterministic identity keeps this from adding a row. - await SetMarker(StaleMarker); - await AwaitMarker(StaleMarker); - - await Reconcile(source, CandidateModuleVersion, "Served by registry 'third pass'", logger); - - // Positive signal that this pass executed the notify path end to end — not that it was - // skipped, which would make the assertion below vacuous. - await AwaitMarker(CandidateModuleVersion); - - var afterThird = await Reminders().FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.Single(afterThird); - Assert.Equal(reminderPath, afterThird[0].Path); - - // The upsert REFRESHES the row, so the reminder reads as new again. That is the honest - // answer to "this condition is still true and I have no record of having told you", and it - // is precisely why guard one has to exist: on its own, guard two would do this every poll. - var refreshed = afterThird[0].ContentAs(Json); - Assert.NotNull(refreshed); - Assert.False(refreshed!.IsRead); - - // ── 4. A genuinely NEW candidate is still worth telling them about ────────────────────── - // Suppression keyed too coarsely would swallow this, and every arm above would still pass. - await Reconcile(source, NextCandidateModuleVersion, "Served by registry 'new build'", logger); - - var afterNew = await Reminders() - .Where(ns => ns.Any(n => n.Path != reminderPath)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.Equal(2, afterNew.Count); - var newReminder = afterNew.Single(n => n.Path != reminderPath).ContentAs(Json); - Assert.NotNull(newReminder); - Assert.StartsWith("Update available", newReminder!.Title, StringComparison.Ordinal); - Assert.False(newReminder.IsRead); - await AwaitMarker(NextCandidateModuleVersion); - - // The listing now contains the newest write, so every earlier one is committed too: two - // candidates, two reminders — not one row per pass. - Assert.Equal(2, (await Reminders().FirstAsync().Timeout(TestTimeouts.Convergence).Await()).Count); - - // …and the reminder path fetched nothing. Four reconciles, zero registry round-trips. - Assert.Equal(0, registry.Attempts); - } - - /// One reconcile pass over one candidate — the exact call every entry point makes - /// (the boot reconcile, a feed read draining a deferral, a build webhook), awaited to - /// completion so the pass's own writes are done before the assertion reads. - private async Task Reconcile( - IPackageSource source, string candidate, string provenance, ILogger logger) - => await PackageUpdateReconciler - .ReconcileInstalled(Mesh, source, "HEAD", [Candidate(candidate)], provenance, logger) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - private static PackageManifest Candidate(string moduleVersion) => new() - { - Id = PackageId, - Name = PackageName, - Version = "1.0.1", - ModuleVersion = moduleVersion, - TargetPartition = PackageId, - }; - - private async Task SeedInstallRecord() - { - var manifest = new PackageManifest - { - Id = PackageId, - Name = PackageName, - Version = "1.0.0", - ModuleVersion = InstalledModuleVersion, - TargetPartition = PackageId, - // The reminder path — the one whose gate could never become true. Since policy - // packages-auto-update it is reached only by an administrator's DELIBERATE choice - // (stamped), which the boot migration keeps; a seeded Notify would be moved to Auto. - AutoUpdate = false, - UpdatePolicy = PackageUpdatePolicy.Notify, - UpdatePolicySetAt = DateTimeOffset.UtcNow, - }; - var record = MeshNode.FromPath(RecordPath) with - { - NodeType = PackageInstaller.PackageNodeType, - Name = manifest.Name, - State = MeshNodeState.Active, - Content = manifest, - }; - var access = Mesh.ServiceProvider.GetRequiredService(); - await access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode(record)) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - } - - private async Task SetMarker(string value) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - await access - .RunAsSystem(() => Mesh.GetMeshNodeStream(RecordPath) - .Update(current => current with { NotifiedModuleVersion = value })) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - } - - /// The user dismissing the bell — the same field the bell list flips on click. - private async Task MarkRead(string notificationPath) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - await access - .RunAsSystem(() => Mesh.GetMeshNodeStream(notificationPath) - .Update(current => current with { IsRead = true })) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - } - - private async Task AwaitRead(string notificationPath, bool expected) => - await Mesh.GetMeshNodeStream(notificationPath) - .Select(n => n.ContentAs(Json)?.IsRead) - .Where(v => v == expected) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - private async Task AwaitMarker(string expected) => - await Mesh.GetMeshNodeStream(RecordPath) - .Select(n => n.ContentAs(Json)?.NotifiedModuleVersion) - .Where(v => string.Equals(v, expected, StringComparison.Ordinal)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - /// This package's reminders on the PLATFORM bell, read LIVE through a children query - /// (a query never storms on a parent that does not exist yet, and it re-emits on every write), - /// then narrowed to the rows THIS install record raised. - /// - /// 🚨 The reminder is ADDRESSED to the platform operators — only a platform admin can - /// apply a plugin update — so it is delivered into - /// for - /// , not filed beside the plugin record it is - /// about (Systemorph/MeshWeaver#3156). The read is path:-anchored, which is also what lets - /// it reach Admin at all: that schema is excluded from searchable_schemas, so only a - /// path-anchored read is pinned to it (#3216). - /// - /// 🚨 The narrowing is load-bearing, not cosmetic. A shared operations bell holds - /// whatever else the mesh reports — a startup error, a discovery status change — so counting that - /// namespace raw would make every assertion in this fixture depend on what the boot happened to - /// say. TargetNodePath is the install record, which is exactly the click target the notify - /// path stamps. - private IObservable> Reminders() => - Mesh.GetWorkspace() - .GetQuery("notif|pkg|reminder-idempotence", - $"path:{NotificationService.DeliveryNamespace(NotificationService.PlatformAddressee)} " - + "scope:children nodeType:Notification") - .Select(ns => (IReadOnlyList)(ns ?? []) - .Where(n => string.Equals( - n.ContentAs(Json)?.TargetNodePath, - RecordPath, - StringComparison.Ordinal)) - .ToList()); - - /// Answers nothing and counts every attempt — the reminder path must never reach it. - private sealed class CountingRegistry : HttpMessageHandler - { - private int attempts; - - public int Attempts => Volatile.Read(ref attempts); - - // HttpMessageHandler's contract is Task-shaped; nothing here bridges an observable. - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - Interlocked.Increment(ref attempts); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) - { - Content = new StringContent( - "{\"error\":\"the reminder path must not fetch\"}", Encoding.UTF8, "application/json"), - }); - } - } - - private sealed class CountingClientFactory(HttpMessageHandler handler) : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); - } -} diff --git a/test/Memex.Portal.Shared.Test/PackagesAutoUpdateTest.cs b/test/Memex.Portal.Shared.Test/PackagesAutoUpdateTest.cs deleted file mode 100644 index 5754928f23..0000000000 --- a/test/Memex.Portal.Shared.Test/PackagesAutoUpdateTest.cs +++ /dev/null @@ -1,222 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Policy packages-auto-update — the pure rules: a fresh install is Auto whatever the -/// deployment's legacy defaults say; a SEEDED reminder-only record migrates to Auto; an -/// administrator's explicit choice and a pin are kept and named. -/// -public class PackagesAutoUpdateRulesTest -{ - [Fact] - public void AFreshInstall_IsAuto_WhateverTheDeploymentDefaultSaid() - { - PackageInstaller.SeedUpdatePolicy(null, null).Should().Be(PackageUpdatePolicy.Auto); - PackageInstaller.SeedUpdatePolicy(null, new PluginCatalogOptions()).Should().Be(PackageUpdatePolicy.Auto, - "AutoUpdateByDefault=false is the CLR default — 'nobody said anything' — and no longer means reminder-only"); - PackageInstaller.SeedUpdatePolicy(null, new PluginCatalogOptions { DefaultUpdatePolicy = PackageUpdatePolicy.Notify }) - .Should().Be(PackageUpdatePolicy.Auto, "a deployment-wide notify default is exactly what the policy retires"); - } - - [Fact] - public void ASeededReminderOnlyRecord_Migrates_AndADeliberateChoiceIsKept() - { - var legacy = new PackageManifest { Id = "A" }; // autoUpdate:false, no policy - var seeded = new PackageManifest { Id = "B", UpdatePolicy = PackageUpdatePolicy.Notify }; // the old seed - var chosen = new PackageManifest { Id = "C", UpdatePolicy = PackageUpdatePolicy.Notify, UpdatePolicySetAt = DateTimeOffset.UtcNow }; - var pinned = new PackageManifest { Id = "D", UpdatePolicy = PackageUpdatePolicy.None }; - var auto = new PackageManifest { Id = "E", UpdatePolicy = PackageUpdatePolicy.Auto, AutoUpdate = true }; - - PackageAutoUpdateMigration.Migrates(legacy).Should().BeTrue(); - PackageAutoUpdateMigration.Migrates(seeded).Should().BeTrue(); - PackageAutoUpdateMigration.Migrated(seeded).EffectiveUpdatePolicy.Should().Be(PackageUpdatePolicy.Auto); - PackageAutoUpdateMigration.Migrated(legacy).AutoUpdate.Should().BeTrue("the legacy flag is kept in step"); - - // Negative controls: the opt-outs the policy keeps — named, never migrated. - PackageAutoUpdateMigration.Migrates(chosen).Should().BeFalse(); - PackageAutoUpdateMigration.KeptOptOut(chosen).Should().Contain("chosen by an administrator"); - PackageAutoUpdateMigration.Migrates(pinned).Should().BeFalse(); - PackageAutoUpdateMigration.KeptOptOut(pinned).Should().Contain("pinned"); - PackageAutoUpdateMigration.Migrates(auto).Should().BeFalse("already Auto — the migration is idempotent"); - - // A re-stamp of an existing record follows the same rule. - PackageInstaller.SeedUpdatePolicy(seeded, null).Should().Be(PackageUpdatePolicy.Auto); - PackageInstaller.SeedUpdatePolicy(chosen, null).Should().Be(PackageUpdatePolicy.Notify); - PackageInstaller.SeedUpdatePolicy(pinned, null).Should().Be(PackageUpdatePolicy.None); - } -} - -/// -/// 🚨 Policy packages-auto-update, end to end, with the platform FIXED: no platform -/// build, no image roll, no publication seal, no seal for the running framework identity — and a -/// partition whose SYNC SOURCE still carries the old content. The only inputs are: the registry -/// published a newer version, and its declared floor is met. The registry's own reconcile pass -/// () lands it and files the activation through -/// the module reload path — exactly one automatic restart here, since this process has no live -/// loader — with no human step. -/// -public class PackagesAutoUpdateTest(ITestOutputHelper output) : ModuleReloadScenario(output) -{ - private RegistryUpdateReconciler Reconciler => Mesh.ServiceProvider.GetRequiredService(); - - /// The registry FEED lists the package at the content identity installed here (so the - /// content lane has nothing to do) — the module lane is what moves. - private void PublishInFeed(string? floor = null, string moduleVersion = "content-v1") => - Registry.Feed = - [ - new PackageManifest - { - Id = Package, Name = Package, Version = "1.0.0", ModuleVersion = moduleVersion, - TargetPartition = Package, Module = Module, MinMeshVersion = floor, - }, - ]; - - private Task Ready(CancellationToken ct) => - Reconciler.BootReconciled.DefaultIfEmpty().Timeout(TestTimeouts.Convergence).Await(ct); - - /// The partition's SYNC SOURCE carries the OLD content — under the retired gate this - /// held the module until the platform sealed the content repo. - private async Task SyncOwnedAtOldContent(CancellationToken ct) - { - var config = new GitHubSyncConfig - { - RepositoryUrl = "https://github.com/Systemorph/Example", - Branch = "main", - ModuleVersions = ImmutableDictionary.Empty.Add(Package, "content-v0"), - }; - await Access.RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService().CreateNode( - new MeshNode(GitHubSyncService.ConfigId, Package) - { - Name = "GitHub Sync", - NodeType = GitHubSyncService.ConfigNodeType, - State = MeshNodeState.Active, - Content = config, - }).Take(1)) - .Timeout(TestTimeouts.Convergence).Await(ct); - var owner = await PartitionContentOwnership.Observe(Mesh, Package).Take(1).Timeout(TestTimeouts.Convergence).Await(ct); - owner.Owner.Should().Be(PartitionContentOwner.SyncSource, "the premise: the partition is sync-owned"); - } - - private Task Record(Func until, CancellationToken ct) => - Access.RunAsSystem(() => Mesh.GetMeshNodeStream($"{PackageInstaller.InstalledPartition}/{Package}")) - .Select(n => n.ContentAs(Mesh.JsonSerializerOptions)) - .Where(m => m is not null && until(m)) - .Select(m => m!) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - private Task> Requests(CancellationToken ct) => - Access.RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService() - .Query(MeshQueryRequest.FromQuery( - $"namespace:{ModuleReloadRequest.Namespace} scope:children nodeType:{ModuleReloadRequest.NodeType}")) - .Take(1)) - .Select(change => change.Items - .Select(n => (n.Path, Request: n.ContentAs(Mesh.JsonSerializerOptions)!)) - .ToImmutableList()) - .Timeout(TestTimeouts.Convergence).Await(ct); - - [Fact(Timeout = 240_000)] - public async Task ANewerCompatibleVersion_IsInstalledAndActivated_WithNoHumanStep_IndependentOfSyncAndSeal() - { - var ct = TestContext.Current.CancellationToken; - await Ready(ct); - // Installed on the RETIRED reminder-only default (autoUpdate:false, no policy). - await RunningVersionOne(ct, m => m with { ModuleVersion = "content-v1" }); - await SyncOwnedAtOldContent(ct); - - // The migration moves the seeded reminder-only record to Auto. - var pass = await PackageAutoUpdateMigration.Run(Mesh).Timeout(TestTimeouts.Convergence).Await(ct); - (await Record(r => r.EffectiveUpdatePolicy == PackageUpdatePolicy.Auto, ct)).UpdatePolicy - .Should().Be(PackageUpdatePolicy.Auto, "a record previously on notify-only is migrated to Auto"); - pass.KeptOptOuts.Should().BeEmpty(); - - // 1.2.0 is PUBLISHED, compatible. Nothing else happens: no platform build, no seal, the - // sync still holds the old content. - Registry.Serve("1.2.0", floor: FloorFixture.Below); - PublishInFeed(); - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - - Head().Version.Should().Be("1.2.0", "the published compatible module landed with no human step"); - var filed = await Requests(ct); - filed.Should().ContainSingle("ONE activation request for the wave"); - var activated = await AwaitRequest(filed[0].Path, - r => r.Status == ModuleReloadStatus.AwaitingRestart && r.ActivationDetail?.StartsWith(ModuleRestartKinds.Restarted) == true, ct); - activated.Reason.Should().Contain("packages-auto-update"); - Updater.Restarts.Should().Be(1, "activated by exactly one automatic restart"); - - // A SECOND publication minutes later rides the restart already on its way — still one. - Registry.Serve("1.3.0", floor: FloorFixture.Below); - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - Head().Version.Should().Be("1.3.0"); - var second = (await Requests(ct)).Single(r => r.Path != filed[0].Path); - var rode = await AwaitRequest(second.Path, - r => r.Log.Any(l => l.Contains("riding it") || l.Contains("restart Restarted")) || ModuleReloadStatus.IsTerminal(r.Status), ct); - rode.ActivationDetail.Should().Contain("rides the ONE restart"); - Updater.Restarts.Should().Be(1, "two waves before the restart happened → still exactly one restart"); - } - - /// Negative control: an administrator's DELIBERATE notify choice is kept — the same - /// publication lands nothing and files nothing. - [Fact(Timeout = 240_000)] - public async Task ADeliberateOptOut_IsKept_AndNothingLands() - { - var ct = TestContext.Current.CancellationToken; - await Ready(ct); - await RunningVersionOne(ct, m => m with - { - ModuleVersion = "content-v1", - UpdatePolicy = PackageUpdatePolicy.Notify, - UpdatePolicySetAt = DateTimeOffset.UtcNow, - }); - - var pass = await PackageAutoUpdateMigration.Run(Mesh).Timeout(TestTimeouts.Convergence).Await(ct); - pass.Migrated.Should().BeEmpty(); - pass.KeptOptOuts.Should().ContainSingle().Which.Should().Contain("chosen by an administrator"); - - Registry.Serve("1.2.0", floor: FloorFixture.Below); - PublishInFeed(); - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - - Head().Version.Should().Be("1.1.0"); - Registry.Downloads.Should().Equal(["1.1.0"]); - (await Requests(ct)).Should().BeEmpty(); - Updater.Restarts.Should().Be(0); - } - - /// An incompatible floor: declined BY NAME on the record, nothing downloaded or - /// activated, and the running version keeps serving. - [Fact(Timeout = 240_000)] - public async Task AnIncompatibleFloor_IsDeclinedByName_AndTheRunningVersionKeepsServing() - { - var ct = TestContext.Current.CancellationToken; - await Ready(ct); - await RunningVersionOne(ct, m => m with { ModuleVersion = "content-v1", UpdatePolicy = PackageUpdatePolicy.Auto, AutoUpdate = true }); - - Registry.Serve("1.2.0", floor: FloorFixture.Above); - PublishInFeed(floor: FloorFixture.Above, moduleVersion: "content-v2"); - await Reconciler.ReconcileNow().Timeout(TestTimeouts.Convergence).Await(ct); - - var held = await Record(r => r.HeldUpdate is not null, ct); - held.HeldUpdate.Should().Contain(FloorFixture.Above).And.Contain(FloorFixture.Running); - Head().Version.Should().Be("1.1.0", "the running version keeps serving"); - Registry.Downloads.Should().Equal(["1.1.0"], "the held bundle is not downloaded"); - (await Requests(ct)).Should().BeEmpty("nothing landed, so nothing is activated"); - Updater.Restarts.Should().Be(0); - } -} diff --git a/test/Memex.Portal.Shared.Test/PatchUnknownContentMembersTest.cs b/test/Memex.Portal.Shared.Test/PatchUnknownContentMembersTest.cs deleted file mode 100644 index 341e7d1cbe..0000000000 --- a/test/Memex.Portal.Shared.Test/PatchUnknownContentMembersTest.cs +++ /dev/null @@ -1,163 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Reactive.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Data; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// An MCP patch answered "Patched: {path}" while writing nothing: the content keys -/// it carried (aliases, domains, matchCaseSensitive on a Crm/Counterparty node -/// whose NodeType had just gained them) were not declared by the TYPE the portal had bound, so the -/// typed deserialisation dropped them without error, the landed-write check projected them as -/// "expect absent", and the live node satisfied that at once — version unchanged, keys absent on -/// read-back. The rule pinned here: a write carrying an undeclared content member is REFUSED naming -/// the members and the type, and a patch that changes nothing says No change, never -/// Patched. Runs against a REAL mesh with a real typed content (). -/// -public class PatchUnknownContentMembersTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private static readonly TimeSpan Budget = TestTimeouts.Convergence; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddUpdatePolicyType(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - [Fact] - public async Task Patch_WithUndeclaredContentMembers_IsRefusedNamingThem_AndWritesNothing() - { - var ct = TestContext.Current.CancellationToken; - var before = await Seed(UpdatePolicyKind.Stable, ct); - - var result = await Run(new MeshOperations(Mesh).Patch( - UpdatePolicyNodeType.NodePath, - """{"content":{"aliases":["ACME AG"],"domains":["acme.ch"],"policy":"None"}}"""), ct); - - Output.WriteLine($"Patch tool returned: {result}"); - result.Should().StartWith("Error:", "an undeclared member would be dropped silently"); - result.Should().NotContain("Patched"); - result.Should().Contain("'aliases'").And.Contain("'domains'") - .And.Contain(nameof(UpdatePolicyContent)); - result.Should().NotContain("'policy'", "policy IS declared and must not be named"); - - var after = await ReadNode(ct); - after.Version.Should().Be(before.Version, "a refused patch writes nothing — not even the declared key"); - UpdatePolicyNodeType.Parse(after, Mesh.JsonSerializerOptions).Policy.Should().Be(UpdatePolicyKind.Stable); - } - - [Fact] - public async Task Patch_ThatChangesNothing_ReportsNoChange_NotPatched() - { - var ct = TestContext.Current.CancellationToken; - var before = await Seed(UpdatePolicyKind.Stable, ct); - - var result = await Run(new MeshOperations(Mesh).Patch( - UpdatePolicyNodeType.NodePath, """{"content":{"policy":"Stable"}}"""), ct); - - Output.WriteLine($"Patch tool returned: {result}"); - result.Should().StartWith("No change:"); - result.Should().Contain(UpdatePolicyNodeType.NodePath); - (await ReadNode(ct)).Version.Should().Be(before.Version); - } - - [Fact] - public async Task Patch_ThatChangesADeclaredMember_StillReportsPatched_AndBumpsTheVersion() - { - var ct = TestContext.Current.CancellationToken; - var before = await Seed(UpdatePolicyKind.Stable, ct); - - var result = await Run(new MeshOperations(Mesh).Patch( - UpdatePolicyNodeType.NodePath, """{"content":{"policy":"None"}}"""), ct); - - Output.WriteLine($"Patch tool returned: {result}"); - result.Should().StartWith("Patched:"); - var after = await ReadNode(ct, n => UpdatePolicyNodeType.Parse(n, Mesh.JsonSerializerOptions).Policy - == UpdatePolicyKind.None); - after.Version.Should().BeGreaterThan(before.Version); - result.Should().Contain($"v{before.Version} →", "the success line carries the version delta"); - } - - [Fact] - public async Task Update_WithUndeclaredContentMembers_IsRefusedNamingThem() - { - var ct = TestContext.Current.CancellationToken; - var before = await Seed(UpdatePolicyKind.Stable, ct); - var nodeJson = JsonSerializer.SerializeToNode(before, Mesh.JsonSerializerOptions)!.AsObject(); - nodeJson["content"]!.AsObject()["matchCaseSensitive"] = true; - - var result = await Run(new MeshOperations(Mesh).Update(new JsonArray(nodeJson).ToJsonString()), ct); - - Output.WriteLine($"Update tool returned: {result}"); - result.Should().StartWith("Error:"); - result.Should().Contain("'matchCaseSensitive'").And.Contain(nameof(UpdatePolicyContent)); - result.Should().NotContain("Updated:"); - (await ReadNode(ct)).Version.Should().Be(before.Version); - } - - [Fact] - public void UnknownContentMembers_JudgesOnlyTypedContent_AndIgnoresWireMetadata() - { - var options = Mesh.JsonSerializerOptions; - var delta = JsonNode.Parse("""{"$type":"UpdatePolicyContent","policy":"None","aliases":[]}""")!.AsObject(); - - MeshOperations.UnknownContentMembers(delta, new UpdatePolicyContent(), options) - .Should().Equal("aliases"); - MeshOperations.UnknownContentMembers(delta, JsonDocument.Parse("{}").RootElement, options) - .Should().BeEmpty("untyped content keeps every key, so nothing is dropped"); - MeshOperations.UnknownContentMembers(delta, null, options).Should().BeEmpty(); - } - - // ── helpers ── - - private static Task Run(IObservable op, CancellationToken ct) => - op.FirstAsync().Timeout(Budget).Await(ct); - - private async Task Seed(UpdatePolicyKind policy, CancellationToken ct) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = policy }, - }; - await Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(ct); - return await ReadNode(ct); - } - - private Task ReadNode(CancellationToken ct, Func? until = null) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null && (until is null || until(node))) - .Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(ct); -} diff --git a/test/Memex.Portal.Shared.Test/PendingModuleActivationsCostTest.cs b/test/Memex.Portal.Shared.Test/PendingModuleActivationsCostTest.cs deleted file mode 100644 index 0b16f68246..0000000000 --- a/test/Memex.Portal.Shared.Test/PendingModuleActivationsCostTest.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 #3664 — is the startup/readiness probe's cost. -/// It used to walk the module volume on every call (the sidecar, every activation.d/*.json, -/// the set index, one File.Exists per landed DLL in three passes): 8–10 s per probe on -/// memex-cloud's Azure Files volume against a 5 s probe timeout, so a fresh pod never became -/// ready and every rollout stalled. These pin the rule that replaces it: the volume is read once -/// per CHANGE of the on-disk activation state, never per call — and a change is seen. -/// -public class PendingModuleActivationsCostTest : IDisposable -{ - private const string ModuleA = "MeshWeaver.Payments.Stripe"; - private const string ModuleB = "MeshWeaver.Social"; - private const string ModuleC = "MeshWeaver.Speech"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-pending-cost-" + Guid.NewGuid().ToString("N")); - private readonly ModuleLandingService landing; - - public PendingModuleActivationsCostTest() - { - Directory.CreateDirectory(root); - landing = new ModuleLandingService(baseDirectory: root); - } - - public void Dispose() - { - landing.Dispose(); - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - GC.SuppressFinalize(this); - } - - /// Two probes against an unchanged volume cost ONE disk read; the second is served from the snapshot and says the same thing. - [Fact] - public async Task AnUnchangedVolume_IsReadOnce_HoweverOftenTheProbeAsks() - { - await LandWave(ModuleA, ModuleB); - var pending = new PendingModuleActivations(root); - var nothingLoaded = new HashSet(StringComparer.OrdinalIgnoreCase); - - var first = pending.Read(nothingLoaded); - var second = pending.Read(nothingLoaded); - var third = pending.Read(nothingLoaded); - - Assert.Equal(1, pending.DiskReads); - Assert.Equal(2, first.Pending.Count); - Assert.Equal(first.Describe(), second.Describe()); - Assert.Equal(first.Describe(), third.Describe()); - } - - /// A landing (temp-file + rename into activation.d) moves the fingerprint: the next probe re-reads and reports the new module. - [Fact] - public async Task ALanding_IsSeenByTheNextProbe() - { - await LandWave(ModuleA); - var pending = new PendingModuleActivations(root); - var nothingLoaded = new HashSet(StringComparer.OrdinalIgnoreCase); - Assert.Single(pending.Read(nothingLoaded).Pending); - Assert.Equal(1, pending.DiskReads); - - await LandWave(ModuleB, ModuleC); - - var after = pending.Read(nothingLoaded); - Assert.Equal(2, pending.DiskReads); - Assert.Equal(3, after.Pending.Count); - Assert.Contains(after.Pending, p => string.Equals(p.Name, ModuleC, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// The in-process half is NOT memoised: the same snapshot, asked with this process's loaded set, - /// answers for that set — a module that loaded since the last probe stops being pending without - /// a disk read. - /// - [Fact] - public async Task WhatThisProcessLoaded_IsReadFresh_WithoutTouchingTheVolume() - { - await LandWave(ModuleA, ModuleB); - var pending = new PendingModuleActivations(root); - var nothingLoaded = new HashSet(StringComparer.OrdinalIgnoreCase); - Assert.Equal(2, pending.Read(nothingLoaded).Pending.Count); - - var loadedA = new HashSet(StringComparer.OrdinalIgnoreCase) { ModuleA }; - var report = pending.Read(loadedA); - - Assert.Equal(1, pending.DiskReads); - Assert.Single(report.Pending); - Assert.Equal(ModuleB, report.Pending.Single().Name, ignoreCase: true); - } - - /// - /// 🚨 MeshWeaver#6829 — the module volume is an SMB share, on which a directory's last-write - /// time need not move when a file is renamed into it. Reproduced here by putting the three - /// directories' timestamps back after the second wave: the volume's CONTENT changed (two more - /// modules landed, set 2 proposed) while every directory timestamp reads as before. A reader - /// keyed on those timestamps keeps its first snapshot — 1 pending, the shape that made the - /// self-updater answer NothingToActivate — while 3 are pending on disk. - /// - [Fact] - public async Task ALandingIsSeen_WhenTheVolumeDoesNotMoveDirectoryTimestamps() - { - await LandWave(ModuleA); - var pending = new PendingModuleActivations(root); - var nothingLoaded = new HashSet(StringComparer.OrdinalIgnoreCase); - Assert.Single(pending.Read(nothingLoaded).Pending); - Assert.Equal(1, pending.DiskReads); - - var directories = new[] - { - Path.Combine(root, "modules"), - ModuleActivationSidecar.EntriesDirectory(root), - ModuleSetStore.SetsDirectory(root), - }; - var before = directories.Select(Directory.GetLastWriteTimeUtc).ToArray(); - - await LandWave(ModuleB, ModuleC); - for (var i = 0; i < directories.Length; i++) - Directory.SetLastWriteTimeUtc(directories[i], before[i]); - Assert.Equal(before, directories.Select(Directory.GetLastWriteTimeUtc).ToArray()); - - var after = pending.Read(nothingLoaded); - - Assert.Equal(2, pending.DiskReads); - Assert.Equal(3, after.Pending.Count); - Assert.Empty(after.Deferred); - - // And the memo still holds: an unchanged volume is not re-read. - pending.Read(nothingLoaded); - Assert.Equal(2, pending.DiskReads); - } - - /// - /// 🚨 MeshWeaver#6829, review of #6833 — a module's state is derived from the RECORDS in its own - /// directory (activation.d/<Name>/), and a record lands by a rename into that - /// directory, which leaves activation.d/'s own listing as it was. An uninstall tombstone - /// written on its own (its projection write failed) is that change: with every directory - /// timestamp put back, a fingerprint that lists only the three top directories keeps serving - /// "2 pending" while the volume says 1. - /// - [Fact] - public async Task ARecordOnlyChange_InsideAModulesRecordDirectory_IsSeen() - { - await LandWave(ModuleA, ModuleB); - var pending = new PendingModuleActivations(root); - var nothingLoaded = new HashSet(StringComparer.OrdinalIgnoreCase); - Assert.Equal(2, pending.Read(nothingLoaded).Pending.Count); - Assert.Equal(1, pending.DiskReads); - - var entries = ModuleActivationSidecar.EntriesDirectory(root); - var directories = new[] - { - Path.Combine(root, "modules"), - entries, - ModuleSetStore.SetsDirectory(root), - } - .Concat(Directory.EnumerateDirectories(entries)) - .ToArray(); - var before = directories.Select(Directory.GetLastWriteTimeUtc).ToArray(); - - // The tombstone alone — no projection rewrite of activation.d/.json. - ModuleActivationSidecar.WriteUninstall(root, ModuleB, after: null); - for (var i = 0; i < directories.Length; i++) - Directory.SetLastWriteTimeUtc(directories[i], before[i]); - - var after = pending.Read(nothingLoaded); - - Assert.Equal(2, pending.DiskReads); - Assert.Equal(ModuleA, Assert.Single(after.Pending).Name, ignoreCase: true); - } - - /// - /// The fingerprint is volume work (a listing per directory and per module record directory), so a - /// cancelled refresh must not start it: the token is observed before the first listing. - /// - [Fact] - public async Task ACancelledFingerprint_StartsNoListing() - { - await LandWave(ModuleA); - using var cancelled = new System.Threading.CancellationTokenSource(); - await cancelled.CancelAsync(); - - Assert.ThrowsAny( - () => PendingModuleActivations.ListingFingerprint(root, cancelled.Token)); - Assert.NotNull(PendingModuleActivations.ListingFingerprint(root)); - } - - /// A missing modules tree is a fingerprint too — the first landing into it is a change, not an identical "nothing". - [Fact] - public async Task TheFirstLandingIntoAnEmptyRoot_IsAChange() - { - var pending = new PendingModuleActivations(root); - var nothingLoaded = new HashSet(StringComparer.OrdinalIgnoreCase); - Assert.Empty(pending.Read(nothingLoaded).Pending); - Assert.Equal(1, pending.DiskReads); - - await LandWave(ModuleA); - - Assert.Single(pending.Read(nothingLoaded).Pending); - Assert.Equal(2, pending.DiskReads); - } - - private async Task Land(string name) => - await landing.LandModule(name, [(name + ".dll", RealAssemblyBytes)]) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - private static byte[] RealAssemblyBytes => - File.ReadAllBytes(typeof(MeshWeaver.Plugin.Packaging.BundleReader).Assembly.Location); - - private async Task LandWave(params string[] names) - { - foreach (var name in names) - await Land(name); - await landing.ProposeModuleSet().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - } -} diff --git a/test/Memex.Portal.Shared.Test/PlanTierRefusalTest.cs b/test/Memex.Portal.Shared.Test/PlanTierRefusalTest.cs deleted file mode 100644 index 9b6d5363f5..0000000000 --- a/test/Memex.Portal.Shared.Test/PlanTierRefusalTest.cs +++ /dev/null @@ -1,446 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Mesh.Security; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A module refused by plan tier says so on the instance (#4097). -/// -/// The hour these pin — 2026-09-12, build.meshweaver.cloud. A fresh instance -/// on the free plan showed canPatch=False ("list MeshWeaver.SelfUpdate.Aks under -/// Modules:Assemblies"), required_modules: Degraded — install the package from the registry, -/// and a package card that simply did not exist. The cause was one line on the REGISTRY: the -/// Hosting package (preInstalled: true, tier: enterprise) was refused by -/// PluginGrant.Allows → PlanTierRanks.CoversInstance, and by design that refusal was -/// indistinguishable from "no such bundle" on the wire. One cause, three consequences, nothing on -/// the instance naming it. -/// -/// The two halves. The REGISTRY side returns a TYPED refusal for a package it itself -/// declares in the instance's default set from a source the grant reaches — and stays silent, -/// exactly as before, for a package outside the granted sources (the enumeration defence). The -/// CONSUMER side records the verdict on the activation record and says the one sentence in the -/// #4091 vocabulary on every surface that used to name the consequence. -/// -public class PlanTierRefusalTest : IDisposable -{ - private static readonly DateTimeOffset Now = new(2026, 9, 12, 12, 0, 0, TimeSpan.Zero); - - /// The Store's ladder as the registry reads it: free 0 · personal 10 · pro 20 · - /// dedicated 25 (all-access) · enterprise 30. - private static readonly PlanTierRanks Ladder = new( - ImmutableDictionary.Empty - .Add("free", 0).Add("personal", 10).Add("pro", 20).Add("dedicated", 25).Add("enterprise", 30), - ImmutableHashSet.Create("dedicated")); - - private const string Platform = "Plugins"; - private const string Paid = "Reinsurance"; - private const string Patcher = "MeshWeaver.SelfUpdate.Aks"; - - private static PluginGrantEntry Entry(string source, string package = PluginGrantEntry.AllPackages, string? cap = null) => - new() { Source = source, PackageId = package, Tier = cap }; - - private static AuthenticatedInstance Instance(string? plan, params PluginGrantEntry[] entries) => - new(new MeshWeaverInstance { InstanceId = "build", KeyHash = "hash", Plan = plan }, - new PluginGrant { InstanceId = "build", Entries = entries }) - { Ranks = Ladder }; - - /// The Hosting package as MeshWeaver.Plugins' Hosting/index.json declares it. - private static readonly PackageManifest Hosting = new() - { - Id = "Hosting", Name = "Hosting", Module = Patcher, PreInstalled = true, Tier = "enterprise", - }; - - private static readonly PackageManifest Store = new() { Id = "Store", Name = "Store", PreInstalled = true }; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-tierrefusal-" + Guid.NewGuid().ToString("N")); - - public PlanTierRefusalTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - GC.SuppressFinalize(this); - } - - /// - /// The DEFAULT APPS are free-tier packages (AI = Threads, Developer = Examples): a - /// free-plan instance is served them by a whole-source grant, and is never refused them by plan. - /// Pinned because the default-apps lane would otherwise select a package the registry then - /// withholds, which reads as "installed nothing" on exactly the instances it exists for. - /// - [Theory] - [InlineData("AI")] - [InlineData("Developer")] - public void AFreePlanInstance_IsServedTheDefaultApps_AndNeverRefusedThemByPlan(string package) - { - var caller = Instance("free", Entry(Platform)); - - Assert.True(caller.Allows(Platform, package, "free", Now)); - Assert.Null(caller.TierRefusal(Platform, package, "free", module: null, Now)); - // an unset plan is the baseline, i.e. free, and untiered packages are covered as well - Assert.True(Instance(null, Entry(Platform)).Allows(Platform, package, "free", Now)); - } - - // ───────────────────────────────────────────── the registry side: the grant's verdict - - /// - /// The incident's exact shape: a free instance holding Plugins/*, and the enterprise - /// Hosting package the registry declares in its default set. Allowed? No. Refused BY - /// PLAN, and said so — with the plan the decision was taken at. - /// - [Fact] - public void AFreeInstance_IsRefusedTheEnterprisePreInstall_ByPlan_AndToldSo() - { - var caller = Instance("free", Entry(Platform)); - - Assert.False(caller.Allows(Platform, Hosting.Id, Hosting.Tier, Now)); - - var refusal = caller.TierRefusal(Platform, Hosting.Id, Hosting.Tier, Hosting.Module, Now); - Assert.NotNull(refusal); - Assert.Equal("Hosting", refusal!.PackageId); - Assert.Equal(Patcher, refusal.Module); - Assert.Equal("enterprise", refusal.RequiredTier); - Assert.Equal("free", refusal.InstancePlan); - Assert.Equal( - "⛔ Not installed on this instance: Hosting needs plan tier enterprise, this instance is on free.", - refusal.Describe()); - } - - /// A record that predates the plan field is a FREE instance, and the sentence says - /// free, never "(none)". - [Fact] - public void ABlankPlan_IsRefusedAtTheBaseline_AndTheSentenceNamesFree() - { - var refusal = Instance(null, Entry(Platform)).TierRefusal(Platform, Hosting.Id, Hosting.Tier, Hosting.Module, Now); - Assert.Equal("free", refusal!.InstancePlan); - } - - /// - /// 🚨 THE ENUMERATION DEFENCE, unchanged. The same enterprise package from a source the - /// instance holds NO entry for is absence — null, exactly like a package that does not exist. - /// A registered instance must not learn what a registry carries by reading its refusals. - /// - [Fact] - public void AnUngrantedSource_YieldsAbsence_NotARefusal() - { - var caller = Instance("free", Entry(Platform)); - - Assert.Null(caller.TierRefusal(Paid, "UWDeepfield", "enterprise", null, Now)); - // And the grant itself agrees, before the token scope even enters. - Assert.Null(caller.Grant.TierRefusal(Paid, "UWDeepfield", "enterprise", Ladder, "free", Now)); - } - - /// An expired entry reaches nothing: absence, not a refusal it could not decide. - [Fact] - public void AnExpiredEntry_YieldsAbsence() - { - var expired = new PluginGrantEntry { Source = Platform, PackageId = PluginGrantEntry.AllPackages, ExpiresAt = Now.AddDays(-1) }; - Assert.Null(Instance("free", expired).TierRefusal(Platform, Hosting.Id, Hosting.Tier, Hosting.Module, Now)); - } - - /// A revoked grant licenses nothing and explains nothing — the kill switch is silence. - [Fact] - public void ARevokedGrant_YieldsAbsence() - { - var caller = new AuthenticatedInstance( - new MeshWeaverInstance { InstanceId = "build", KeyHash = "hash", Plan = "free" }, - new PluginGrant { InstanceId = "build", Entries = [Entry(Platform)], IsRevoked = true }) - { Ranks = Ladder }; - Assert.Null(caller.TierRefusal(Platform, Hosting.Id, Hosting.Tier, Hosting.Module, Now)); - } - - /// A plan that covers the package is ALLOWED — no refusal to report. - [Theory] - [InlineData("enterprise")] - [InlineData("dedicated")] - public void ACoveringPlan_IsAllowed_AndNothingIsRefused(string plan) - { - var caller = Instance(plan, Entry(Platform)); - Assert.True(caller.Allows(Platform, Hosting.Id, Hosting.Tier, Now)); - Assert.Null(caller.TierRefusal(Platform, Hosting.Id, Hosting.Tier, Hosting.Module, Now)); - } - - /// A baseline package (no tier) is covered by every plan, so a free instance is never - /// told it is refused the Store. - [Fact] - public void ABaselinePackage_IsNeverRefused() - { - Assert.Null(Instance("free", Entry(Platform)).TierRefusal(Platform, Store.Id, Store.Tier, null, Now)); - } - - /// - /// 🚨 The sentence names the plan that ACTUALLY refused. A pro instance whose only entry for - /// the source is capped at free (Plugins/*@free) is decided at free — saying "this - /// instance is on pro" would send the operator to a plan that is not the problem. - /// - [Fact] - public void ACappedEntry_ReportsTheNarrowedPlan() - { - var refusal = Instance("pro", Entry(Platform, cap: "free")) - .TierRefusal(Platform, Hosting.Id, Hosting.Tier, Hosting.Module, Now); - Assert.Equal("free", refusal!.InstancePlan); - } - - /// - /// 🚨 The sentence speaks the LADDER's language. A cap the ladder does not know reads as the - /// baseline in the decision (Narrower), so the sentence says free too — never - /// "this instance is on gold", a plan that is neither the instance's nor an upgrade target. - /// - [Fact] - public void AnUnknownCap_ReportsTheBaseline_NotTheUnknownWord() - { - var refusal = Instance("pro", Entry(Platform, cap: "gold")) - .TierRefusal(Platform, Hosting.Id, Hosting.Tier, Hosting.Module, Now); - Assert.Equal("free", refusal!.InstancePlan); - } - - /// A token can only NARROW: a package outside the presented token's scope is absent - /// to that token, whatever the durable grant says. - [Fact] - public void ATokenScopeExcludingThePackage_YieldsAbsence() - { - var caller = Instance("free", Entry(Platform)) with - { - TokenScope = new SyncAccessTokenClaims("build", "hash", ["Plugins/Store"], Now.AddHours(1)), - }; - Assert.Null(caller.TierRefusal(Platform, Hosting.Id, Hosting.Tier, Hosting.Module, Now)); - } - - // ───────────────────────────────────────────── the registry side: the listing - - /// - /// The listing's refusal set, computed exactly as /api/plugins computes it: the - /// pre-installed package the plan refuses from the GRANTED source is named; the same package - /// from an UNGRANTED source is not; a granted package is not; a refused package that is NOT - /// pre-installed is not either — the default set is what the registry chose to tell the - /// instance about, and nothing else is. - /// - [Fact] - public void TheListing_NamesOnlyTheDefaultSetRefusals_FromGrantedSources() - { - var caller = Instance("free", Entry(Platform)); - var platform = new ConfiguredPackageSource(new NullSource(), "HEAD", Platform); - var paid = new ConfiguredPackageSource(new NullSource(), "HEAD", Paid); - var optionalEnterprise = Hosting with { Id = "Optional", Module = null, PreInstalled = false }; - - var refused = PluginRegistryEndpoints.TierRefusals(caller, - [ - (platform, (IReadOnlyList)[Store, Hosting, optionalEnterprise]), - (paid, (IReadOnlyList)[Hosting with { Id = "PaidHosting" }]), - ]); - - var only = Assert.Single(refused); - Assert.Equal("Hosting", only.PackageId); - Assert.Equal(Patcher, only.Module); - } - - /// The legacy anonymous caller sees everything and is refused nothing. - [Fact] - public void TheAnonymousCaller_HasNoRefusals() - { - var platform = new ConfiguredPackageSource(new NullSource(), "HEAD", Platform); - Assert.Empty(PluginRegistryEndpoints.TierRefusals(null, [(platform, (IReadOnlyList)[Hosting])])); - } - - /// - /// The wire is ADDITIVE both ways: the refusal travels beside packages and round-trips - /// typed; a listing from a registry that predates #4097 (no refused member) parses to - /// an empty refusal set, never to an error. - /// - [Fact] - public void ThePayload_CarriesTheRefusalBesideThePackages_AndAnOldRegistryParsesToNone() - { - var refusal = new PlanTierRefusal("Hosting", Patcher, "enterprise", "free"); - var json = PluginRegistryPayloads.List([Store], [refusal]); - Assert.Contains("\"refused\"", json); - - var listing = PluginRegistryPayloads.ParseList(json); - Assert.Equal("Store", Assert.Single(listing.Packages).Id); - Assert.Equal(refusal, Assert.Single(listing.Refused)); - - var legacy = PluginRegistryPayloads.ParseList("{\"packages\":[{\"id\":\"Store\"}]}"); - Assert.Single(legacy.Packages); - Assert.Empty(legacy.Refused); - - // No refusals ⇒ the member is not written at all — byte-identical to before #4097. - Assert.DoesNotContain("refused", PluginRegistryPayloads.List([Store])); - } - - // ───────────────────────────────────────────── the consumer side - - /// - /// Surface 1 — /health required_modules. With the refusal on the activation - /// record (which is what the host's health check reads), the required patcher module is still - /// ExpectedLater — no rollout can change a plan — but the reason names the package, the tier - /// and the plan instead of "install the package from the registry". - /// - [Fact] - public void TheRequiredModuleProbe_NamesTheTier_InsteadOfTellingTheInstanceToInstall() - { - var activation = new ModuleActivationList - { - TierRefusals = [new PlanTierRefusal("Hosting", Patcher, "enterprise", "free")], - }; - - var verdicts = RequiredModuleStatus.Classify( - requiredEntries: [Patcher + ".dll"], - baselineEntries: [], - loadedAssemblyNames: new HashSet(StringComparer.OrdinalIgnoreCase), - resolvesFromDeployment: _ => false, - activation: activation, - landedDllExists: _ => false, - platformGate: _ => null, - incompatibleModules: []); - - var expected = Assert.Single(RequiredModuleStatus.ExpectedLater(verdicts)); - Assert.Equal(Patcher, expected.Name); - Assert.Contains( - "⛔ Not installed on this instance: Hosting needs plan tier enterprise, this instance is on free.", - expected.Reason); - Assert.DoesNotContain("install the package from the registry", expected.Reason); - Assert.Empty(RequiredModuleStatus.Absent(verdicts)); - } - - /// The control arm: without a recorded refusal the sentence is the one it always was. - [Fact] - public void TheRequiredModuleProbe_WithoutARefusal_StillSaysInstallFromTheRegistry() - { - var verdicts = RequiredModuleStatus.Classify( - requiredEntries: [Patcher + ".dll"], - baselineEntries: [], - loadedAssemblyNames: new HashSet(StringComparer.OrdinalIgnoreCase), - resolvesFromDeployment: _ => false, - activation: new ModuleActivationList(), - landedDllExists: _ => false, - platformGate: _ => null, - incompatibleModules: []); - - Assert.Contains("install the package from the registry", Assert.Single(verdicts).Reason); - } - - /// - /// The record between the default install and the probes: the markers are made to MATCH the - /// registry's answer of the pass — written, read back onto the activation list (the SAME read - /// the host's health check performs), and cleared by a later pass in which the registry no - /// longer refuses (a plan upgrade). A content-only refusal leaves no marker. - /// - [Fact] - public void TheMarkers_FollowTheRegistrysAnswer_AndReadBackOntoTheActivationList() - { - var hosting = new PlanTierRefusal("Hosting", Patcher, "enterprise", "free"); - var contentOnly = new PlanTierRefusal("Course", null, "pro", "free"); - - Assert.Equal([Patcher], ModuleActivationSidecar.SyncTierRefusals(root, [hosting, contentOnly])); - Assert.True(File.Exists(ModuleActivationSidecar.TierRefusedMarkerPath(root, Patcher))); - - var read = ModuleActivationSidecar.Read(root); - Assert.Equal(hosting, Assert.Single(read.TierRefusals)); - Assert.Equal(hosting, read.TierRefusalFor(Patcher)); - Assert.Equal(hosting, read.TierRefusalFor(Patcher.ToUpperInvariant())); - Assert.Null(read.TierRefusalFor("MeshWeaver.AI")); - - // The activation report — what the package card and the activation health line read. - var report = new PendingModuleActivations(root).Read(new HashSet(StringComparer.OrdinalIgnoreCase)); - Assert.True(report.HasTierRefused); - Assert.Contains("Hosting needs plan tier enterprise, this instance is on free", report.Describe()); - - // The plan was raised: the next pass answers no refusals, and the marker goes. - Assert.Empty(ModuleActivationSidecar.SyncTierRefusals(root, [])); - Assert.False(File.Exists(ModuleActivationSidecar.TierRefusedMarkerPath(root, Patcher))); - Assert.Empty(ModuleActivationSidecar.Read(root).TierRefusals); - } - - /// - /// A module name arrives from the REGISTRY and becomes a file name. One that cannot be a file - /// name gets no marker and no exception — the pass that records every other refusal must not - /// abort on it (the refusal is still on the ledger and the card). - /// - [Fact] - public void AMalformedModuleName_LeavesNoMarker_AndDoesNotThrow() - { - var written = ModuleActivationSidecar.SyncTierRefusals(root, - [ - new PlanTierRefusal("Evil", "../escape", "enterprise", "free"), - new PlanTierRefusal("Hosting", Patcher, "enterprise", "free"), - ]); - Assert.Equal([Patcher], written); - Assert.Single(ModuleActivationSidecar.Read(root).TierRefusals); - } - - /// The marker is a sibling of the #4083 refusal marker and must not be mistaken for - /// an activation ENTRY by the bulk write or the entry enumeration. - [Fact] - public void TheMarker_IsNotAnEntry_AndSurvivesABulkWrite() - { - ModuleActivationSidecar.SyncTierRefusals(root, [new PlanTierRefusal("Hosting", Patcher, "enterprise", "free")]); - ModuleActivationSidecar.Write(root, new ModuleActivationList - { - Entries = [new ModuleActivationEntry { Name = "MeshWeaver.AI", Directory = "MeshWeaver.AI/1" }], - }); - - var read = ModuleActivationSidecar.Read(root); - Assert.Equal("MeshWeaver.AI", Assert.Single(read.Entries).Name); - Assert.Single(read.TierRefusals); - } - - /// - /// Surface 3 — the self-updater's startup line. With the patcher's package refused by plan - /// the qualifier after canPatch=False names the tier; without one it keeps pointing at - /// the two ways a Kubernetes install gets its patcher. - /// - [Fact] - public void TheSelfUpdaterStartupLine_NamesTheTier_WhenThePatchersPackageWasRefused() - { - ModuleActivationSidecar.SyncTierRefusals(root, [new PlanTierRefusal("Hosting", Patcher, "enterprise", "free")]); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { [ModuleRoot.ConfigKey] = root }) - .Build(); - - var refusal = UnavailableUpdateMechanics.PatcherTierRefusal(configuration); - Assert.NotNull(refusal); - var line = UnavailableUpdateMechanics.DescribeCannotPatch(refusal); - Assert.Contains("Hosting needs plan tier enterprise, this instance is on free", line); - Assert.DoesNotContain("Modules:Assemblies", line); - - ModuleActivationSidecar.SyncTierRefusals(root, []); - Assert.Null(UnavailableUpdateMechanics.PatcherTierRefusal(configuration)); - Assert.Contains("Modules:Assemblies", UnavailableUpdateMechanics.DescribeCannotPatch(null)); - } - - /// Surface 2 — the card's row: the refusal folds into a manifest that is pre-installed, - /// carries the module and the tier, and is never mistaken for an installable entry. - [Fact] - public void ARefusalFoldsIntoAManifestRow_ThatIsRefused_NotInstallable() - { - var row = PackageManifest.FromRefusal(new PlanTierRefusal("Hosting", Patcher, "enterprise", "free"), Platform); - Assert.True(row.IsRefused); - Assert.True(row.PreInstalled); - Assert.Equal("Hosting", row.Id); - Assert.Equal(Patcher, row.Module); - Assert.Equal("enterprise", row.Tier); - Assert.Equal(Platform, row.Source); - Assert.False(Hosting.IsRefused); - } - - /// A source that lists nothing — the endpoints' refusal computation never lists. - private sealed class NullSource : IPackageSource - { - public IObservable> ListPackages(string gitRef) => - System.Reactive.Linq.Observable.Return((IReadOnlyList)[]); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) => - System.Reactive.Linq.Observable.Return((IReadOnlyList)[]); - } -} diff --git a/test/Memex.Portal.Shared.Test/PlatformBuildIdentityTest.cs b/test/Memex.Portal.Shared.Test/PlatformBuildIdentityTest.cs deleted file mode 100644 index ebb79e2d58..0000000000 --- a/test/Memex.Portal.Shared.Test/PlatformBuildIdentityTest.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Reflection; -using MeshWeaver.Mesh; -using Xunit; - -// The class and its namespace share the name "ServiceDefaults", so the bare name binds to the -// namespace here — same aliasing as VersionEndpointTest. -using Defaults = Memex.Portal.ServiceDefaults.ServiceDefaults; - -namespace Memex.Portal.Shared.Test; - -/// -/// The portal must report the build it is actually running, and every surface that reports it must -/// report the SAME one. -/// -/// The incident these tests pin. Until 2026-09-01 the About page and the self-updater -/// read Assembly.GetEntryAssembly() with no fallback, while /api/version read the -/// same two stamps through a selection that falls back to a stamped assembly when the entry -/// assembly is not part of this build. That difference was invisible while the portal executable -/// lived in this repo and was stamped. When the portal hosts moved to MeshWeaver.Plugins -/// (2026-08-25) the executable stopped being stamped, and the two readers diverged on the deployed -/// portal: /api/version answered 3.0.0-rc9+0a1eabdc… while the About page said -/// Version: 1.0.0 and Build commit: not recorded for this build. -/// -/// A version pinned at 1.0.0 is not a cosmetic defect: VersionSelect.IsNewer -/// then reports EVERY registry tag as newer, so the install can never reach "up to date", the -/// header build chip is permanently in its update-available state (where a click is a page reload -/// rather than a link to About), and the deployment re-rolls on every check floor. -/// -/// 🚨 This test run IS the failure case, which is what gives the assertions teeth: -/// test/Directory.Build.props deliberately does not import the root props, so -/// AddCommitHashMetadata never runs for a test project and the entry assembly here — the -/// runner — carries no CommitHash, exactly like the relocated portal executable. Each test -/// states its own non-vacuity guard rather than assuming that. -/// -public class PlatformBuildIdentityTest -{ - /// An assembly THIS build stamped — the platform half of the selection. - private static Assembly StampedAssembly => typeof(ShippedReleaseSeed).Assembly; - - /// An assembly built outside this repo, so it carries no CommitHash. - private static Assembly UnstampedAssembly => typeof(object).Assembly; - - private static string? CommitOf(Assembly assembly) => - assembly.GetCustomAttributes() - .FirstOrDefault(a => string.Equals(a.Key, "CommitHash", StringComparison.OrdinalIgnoreCase)) - ?.Value is { Length: > 0 } sha - ? sha - : null; - - /// - /// A stamped entry assembly IS the build identity — the production branch. A portal executable - /// built in this repo must keep answering for itself; the fallback exists for the other case - /// and must never override a real answer. - /// - [Fact] - public void Select_keeps_the_entry_assembly_when_this_build_stamped_it() - { - // Non-vacuity: the assembly handed in really is stamped, so "it was kept" means something. - CommitOf(StampedAssembly).Should().NotBeNull( - "AddCommitHashMetadata stamps every assembly built from this repo"); - - PlatformBuildInfo.SelectBuildAssembly(StampedAssembly).Should().BeSameAs(StampedAssembly); - } - - /// - /// An entry assembly that is not part of this build is REFUSED as the build identity — the case - /// a relocated portal executable and a test runner have in common. Reading it would report the - /// SDK default 1.0.0 and no commit at all. - /// - [Fact] - public void Select_falls_back_when_the_entry_assembly_is_not_part_of_this_build() - { - // Non-vacuity: the assembly handed in really is unstamped, so the fallback really is taken. - CommitOf(UnstampedAssembly).Should().BeNull( - "the framework's own assembly is built outside this repo"); - - var selected = PlatformBuildInfo.SelectBuildAssembly(UnstampedAssembly); - - selected.Should().NotBeSameAs(UnstampedAssembly); - CommitOf(selected).Should().NotBeNull("the fallback must land on an assembly this build stamped"); - } - - /// - /// An absent entry assembly (a host that reports none) is the same case as an unstamped one. - /// - [Fact] - public void Select_falls_back_when_there_is_no_entry_assembly() - => CommitOf(PlatformBuildInfo.SelectBuildAssembly(null)).Should().NotBeNull(); - - /// - /// The About page's build identity is the REAL build, not the host's default — asserted on the - /// commit because that half is independent of the injected run number, so the assertion holds - /// whether or not a container variable is present in this process. - /// - [Fact] - public void The_about_page_reports_a_real_build_commit_under_an_unstamped_host() - { - // Non-vacuity: this process's entry assembly is the unstamped shape the fix is about. - CommitOf(Assembly.GetEntryAssembly()!).Should().BeNull( - "test projects do not import the root props, so the runner carries no CommitHash"); - - ShippedReleaseSeed.CommitHash.Should().NotBeNull( - "reading only the entry assembly reports 'not recorded for this build' on every " + - "deployment whose executable is built outside this repo"); - } - - /// - /// 🚨 The whole point: /api/version and the About page CANNOT disagree. They are separate - /// readers only because Memex.Portal.Shared sits far above the infrastructure project — they - /// must resolve the same assembly and read the same two stamps, and this is the assertion that - /// makes that structural rather than a coincidence of how each was written. - /// - [Fact] - public void The_about_page_and_the_version_endpoint_report_the_same_build() - { - // Non-vacuity, and a real assertion rather than a skip: with a run number injected the two - // surfaces legitimately differ (the endpoint reports assembly metadata only), so a host - // that sets the variable must fail here loudly instead of quietly proving nothing. - PlatformBuildInfo.RuntimePlatformVersion.Should().BeNull( - "no container variable is injected in a test process — see ShippedReleaseSeed"); - - ShippedReleaseSeed.InstalledPlatformVersion.Should().Be(Defaults.Build.Version, - "the deployed portal answered 3.0.0-rc9 on /api/version and 1.0.0 on the About page"); - - (ShippedReleaseSeed.CommitHash ?? "").Should().Be(Defaults.Build.Commit, - "one build produced this process — the two surfaces name the same commit or one is lying"); - } -} diff --git a/test/Memex.Portal.Shared.Test/PlatformReferences.cs b/test/Memex.Portal.Shared.Test/PlatformReferences.cs deleted file mode 100644 index 5d2f460f87..0000000000 --- a/test/Memex.Portal.Shared.Test/PlatformReferences.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Immutable; -using MeshWeaver.Compiler; -using Microsoft.CodeAnalysis; - -namespace Memex.Portal.Shared.Test; - -/// -/// The reference set a test's Roslyn Emit compiles against — the process-wide -/// , filtered, never rebuilt. -/// -/// 🚨 Three test classes used to build this list themselves, per call: -/// MetadataReference.CreateFromFile over every entry of TRUSTED_PLATFORM_ASSEMBLIES -/// — about 520 assemblies, ~180 MB of PE images — for EACH of their 34 compilations. Roslyn reads -/// every reference's metadata when it binds the compilation, so each Emit re-read the whole -/// platform into memory that only a finalizer releases. On CI (#4127) that was the shape of the -/// two RSS steps the memory watchdog's trace shows — +1.8 GB in the eleven seconds -/// ModuleLinkVersionTest ran (11 emits) and +3.9 GB in the four seconds -/// ModulePlatformLinkTest ran (19 emits), with no MonolithMeshTestBase class active -/// and every mesh class before and after them flat — and the watchdog then FailFasted the -/// host blaming a cumulative Autofac leak its own trace refutes. -/// is what a NodeType compile uses in production: built once, and Roslyn caches each reference's -/// metadata and symbol table against the instance, so the platform is read once per process. -/// -internal static class PlatformReferences -{ - /// - /// The platform's references, optionally without the assembly named - /// (file name without extension) — the way a test stands in a different build of one contract. - /// - /// An assembly to leave out, or null for the whole set. - /// The references, in the order the platform lists them — immutable; a caller that - /// needs one more reassigns platform = platform.Add(extra). - public static ImmutableList Platform(string? excludeReference = null) => - CompileReferences.Default - .Where(r => r.Display is { } path - && path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) - && (excludeReference is null - || !string.Equals(Path.GetFileNameWithoutExtension(path), excludeReference, - StringComparison.OrdinalIgnoreCase))) - .ToImmutableList(); -} diff --git a/test/Memex.Portal.Shared.Test/PlatformRollsIndependentlyOfPackagesTest.cs b/test/Memex.Portal.Shared.Test/PlatformRollsIndependentlyOfPackagesTest.cs deleted file mode 100644 index a45f121cfb..0000000000 --- a/test/Memex.Portal.Shared.Test/PlatformRollsIndependentlyOfPackagesTest.cs +++ /dev/null @@ -1,588 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The platform rolls independently of its packages — policy -/// platform-rolls-independently-of-packages. -/// -/// Measured on an enterprise deployment: Admin/UpdatePolicy read -/// HOLDING 3.1.10502 — the framework identity of release 3.1.10502 is not resolvable — no content -/// bake has been published for it, so nothing can be shown adoptable; cannot determine availability, -/// which is not clearance to proceed (heldIndeterminate=true), while every package update -/// on the same instance was held because it needed platform >= 3.1.10493 — a deadlock between -/// platform and packages. Another deployment ran 3.1.10502 fine. -/// -/// These tests drive the REAL poller against a real monolith mesh with the availability gate -/// answering exactly that verdict (and the strict-mode missing-bake one), and assert the rule from -/// both sides: the roll LANDS on the newest admitted tag, and the package answer is not lost — it is -/// on Admin/UpdatePolicy as an advisory (no hold), and on the check verdict. -/// -public class PlatformRollsIndependentlyOfPackagesTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string CandidateTag = "9999.0.0-ci.1"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddUpdatePolicyType().AddGitHubSyncTypes(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - // ══════════════════════════════════════════════════════════════════════════ - // The pure rule - // ══════════════════════════════════════════════════════════════════════════ - - /// That deployment's verdict, through the shared predicate: an unresolvable framework - /// identity makes every package Indeterminate and the package verdict NOT updatable. - private static UpdatabilityVerdict UnresolvableIdentity() => - ReleaseAvailability.IsUpdatable( - new ReleaseTarget(CandidateTag, null), - [new RequiredPackage("Feedback", "Feedback"), new RequiredPackage("Crm", "Crm")], - ReleaseArtifacts.Of([])); - - /// A missing content bake on an instance that opts into the strict mode — the one - /// place a missing bake is still a package refusal. - private static UpdatabilityVerdict MissingBakeUnderRequirePrebuilt() => - ReleaseAvailability.IsUpdatable( - new ReleaseTarget(CandidateTag, "s20261011missingbake000000000000"), - [new RequiredPackage("Feedback", "Feedback"), new RequiredPackage("Crm", "Crm")], - ReleaseArtifacts.Of(["Crm"]), - new ReleaseGatePolicy(RequirePrebuilt: true)); - - [Fact] - public void ThePackageVerdictStillRefuses_TheRollVerdictProceeds_WithTheReasonAsAnAdvisory() - { - var packages = UnresolvableIdentity(); - packages.IsUpdatable.Should().BeFalse("the predicate answers the PACKAGE question truthfully"); - packages.IsIndeterminate.Should().BeTrue(); - - var roll = packages.ForPlatformRoll(); - - roll.IsUpdatable.Should().BeTrue("a package answer never holds the platform roll"); - roll.HoldReason.Should().BeNull(); - roll.Blockers.Should().BeEmpty(); - roll.WaivedHold.Should().Be(packages.HoldReason); - roll.Lagging.Select(p => p.Package).Order(StringComparer.Ordinal).Should().Equal(["Crm", "Feedback"]); - roll.Advisories[0].Should().Contain("independently of packages") - .And.Contain("could NOT be determined") - .And.Contain("framework identity of release 9999.0.0-ci.1 is not resolvable"); - roll.IsIndeterminate.Should().BeTrue("the packages' own answer is kept, not rewritten"); - - roll.ForPlatformRoll().Should().BeSameAs(roll, "the waiver is idempotent"); - var clear = ReleaseAvailability.IsUpdatable( - new ReleaseTarget(CandidateTag, "sid"), [new RequiredPackage("Crm", "Crm")], ReleaseArtifacts.Of(["Crm"])); - clear.ForPlatformRoll().Should().BeSameAs(clear, "an updatable verdict comes back unchanged"); - } - - [Fact] - public void AMissingBakeUnderRequirePrebuilt_NamesThePackageThatDoesNotFollow() - { - var packages = MissingBakeUnderRequirePrebuilt(); - packages.IsUpdatable.Should().BeFalse(); - - var roll = packages.ForPlatformRoll(); - - roll.IsUpdatable.Should().BeTrue(); - roll.Lagging.Should().ContainSingle().Which.Package.Should().Be("Feedback"); - roll.Advisories[0].Should().Contain("1 package(s) are not available").And.Contain("Feedback"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // The poller - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 THE ACCEPTANCE CRITERION — that deployment's exact verdict. Before policy - /// platform-rolls-independently-of-packages the poller wrote - /// HeldTag = 3.1.10502, heldIndeterminate = true and patched nothing; now the roll lands, - /// no hold is recorded, and the reason is on Admin/UpdatePolicy as the advisory the - /// Updates and About tabs render. - /// - [Fact(Timeout = 240_000)] - public async Task AnIndeterminateVerdict_RollsTheNewestTag_WithTheReasonAsAnAdvisory() - { - await Seed(TestContext.Current.CancellationToken, staleHold: true); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, UnresolvableIdentity()); - - updater.Tags.Should().Contain(CandidateTag, - "the platform rolls independently of packages — an indeterminate package verdict is not a hold"); - content.IsHeld(CandidateTag).Should().BeFalse("a package answer leaves no hold on the platform"); - content.HeldReason.Should().BeNull("the stale hold the old poller wrote is cleared on the roll"); - content.HeldIndeterminate.Should().BeFalse(); - content.HasAdvisoriesFor(CandidateTag).Should().BeTrue( - "the package answer is not lost — the Updates/About tabs still list what does not follow"); - content.Advisories.Should().Contain("independently of packages") - .And.Contain("framework identity of release 9999.0.0-ci.1 is not resolvable"); - content.LastCheckVerdict.Should().Contain("PACKAGE ADVISORY", - "the roll over a package answer is said on the check verdict, the durable half"); - content.LastCheckVerdict.Should().NotContain("HOLDING"); - - PlatformUpdateStatus.Derive(content with { LatestAvailableTag = CandidateTag }, "1.0.0") - .Availability.Should().NotBe(PlatformUpdateAvailability.UpdateHeld, - "the About tab must no longer say 'Update held — a package … is not available'"); - } - - /// A missing content bake under Modules:RequirePrebuilt — the last place a - /// missing bake still refuses — rolls the same way, naming the package. - [Fact(Timeout = 240_000)] - public async Task AMissingBakeUnderRequirePrebuilt_RollsTheNewestTag_NamingThePackage() - { - await Seed(TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, MissingBakeUnderRequirePrebuilt()); - - updater.Tags.Should().Contain(CandidateTag); - content.IsHeld(CandidateTag).Should().BeFalse(); - content.Advisories.Should().Contain("Feedback"); - content.LastCheckVerdict.Should().Contain("PACKAGE ADVISORY").And.Contain("Feedback"); - } - - /// - /// 🌍 MeshWeaver#6540 — the advisory the poller RECORDS renders in the viewer's language. The - /// record goes through the real mesh (so the reason code's arguments come back as JSON), and the - /// Updates tab's status renders it for a German and an English viewer off the SAME record: the - /// German line is the catalog's German, the English line is exactly the stored English. - /// - [Fact(Timeout = 240_000)] - public async Task TheRecordedAdvisory_RendersInTheViewersLanguage() - { - await Seed(TestContext.Current.CancellationToken); - - var content = (await RunOneCheck(new RecordingUpdater(), MissingBakeUnderRequirePrebuilt())) - with { LatestAvailableTag = CandidateTag }; - - var item = content.AdvisoryItems.Should().ContainSingle( - "the record carries the advisory with its reason code beside the joined English").Subject; - item.Key.Should().Be(ReleaseAdvisoryCode.PackagesDoNotFollow); - item.Text.Should().Be(content.Advisories, "the item's English IS the sentence the record joins"); - - var german = Memex.Portal.Shared.Settings.UpdatePolicySettingsTab.StatusMarkdown( - content, (key, args) => LocalizationCatalog.Get(key, "de", args), locale: "de"); - var english = Memex.Portal.Shared.Settings.UpdatePolicySettingsTab.StatusMarkdown( - content, (key, args) => LocalizationCatalog.Get(key, "en", args), locale: "en"); - - german.Should().Contain("die Plattform wird unabhängig von den Paketen aktualisiert — 1 Paket(e) sind für dieses Release nicht verfügbar", - "the count argument came back from the stored record and bound into the German template") - .And.NotContain("1 package(s) are not available", "a German viewer no longer reads the English sentence"); - english.Should().Contain("1 package(s) are not available for this release") - .And.NotContain("Paket(e)"); - } - - /// - /// The reboot's image step names EVERY advisory the verdict carries — not only a waived hold. - /// An already-updatable verdict has WaivedHold null and still carries its boot-compile / - /// link / declared-floor advisories; a detail built from the waived hold alone drops them - /// silently, so the operator reading the reboot's record never learns what the roll accepted. - /// - [Fact(Timeout = 240_000)] - public async Task TheRebootImageStep_NamesEveryAdvisory_AlsoOnAnUpdatableVerdict() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - const string BootCompile = "Feedback: no prebuilt bake for this release — compiled at boot"; - var updatable = ReleaseAvailability.IsUpdatable( - new ReleaseTarget(CandidateTag, "sid"), [new RequiredPackage("Crm", "Crm")], ReleaseArtifacts.Of(["Crm"])) - with { Advisories = [BootCompile] }; - updatable.IsUpdatable.Should().BeTrue(); - updatable.WaivedHold.Should().BeNull("nothing was waived — the advisory rides on an updatable verdict"); - - var choice = await Service(new RecordingUpdater(), updatable) - .SelectImage(wedged: false).FirstAsync().Timeout(Budget).Await(ct); - - choice.Target.Should().Be(CandidateTag); - choice.Detail.Should().Contain(BootCompile).And.Contain("do not hold the platform"); - - var waived = await Service(new RecordingUpdater(), UnresolvableIdentity()) - .SelectImage(wedged: false).FirstAsync().Timeout(Budget).Await(ct); - - waived.Target.Should().Be(CandidateTag, "a waived package hold does not keep the reboot on the running image"); - waived.Detail.Should().Contain("framework identity of release 9999.0.0-ci.1 is not resolvable"); - } - - /// - /// 🚨 THE MANUAL "APPLY UPDATE NOW" BUTTON decides as the poller does. This route has drifted - /// from the poller before (SelfUpdateMigratesBeforeItRollsTest exists to pin it), so - /// both halves are pinned: the decision behaviourally — a refusing availability verdict and a - /// recorded combo Red each become advisories on the note, and nothing refuses — and the - /// wiring by source order: the tab reads that decision and then reaches BOTH the hand-over - /// and the patch, with no early return on a package answer in between. - /// - [Fact] - public void TheManualRoll_RollsOverARefusalAndARed_AndNamesBothOnTheResultLine() - { - var red = ComboClearance.For(CandidateTag, new ComboVerification - { - CandidateTag = CandidateTag, - Verdict = ComboVerdictKind.Red, - VerifiedAt = DateTimeOffset.UtcNow, - }); - red.Refuses.Should().BeTrue(); - var none = ComboVerificationGate.NotRegistered(CandidateTag); - - var both = Memex.Portal.Shared.Settings.UpdatePolicySettingsTab.ManualRollDecision( - MissingBakeUnderRequirePrebuilt(), red, key => $"<{key}>"); - both.ProceedsOverPackageAnswer.Should().BeTrue(); - both.Advisories.Should().HaveCount(2); - both.Advisories[0].Should().Contain("independently of packages").And.Contain("Feedback"); - both.Advisories[1].Should().Be(red.Reason); - both.Note.Should().Contain("").And.Contain("Feedback"); - - var availabilityOnly = Memex.Portal.Shared.Settings.UpdatePolicySettingsTab.ManualRollDecision( - UnresolvableIdentity(), none, key => $"<{key}>"); - availabilityOnly.ProceedsOverPackageAnswer.Should().BeTrue(); - availabilityOnly.Advisories.Should().ContainSingle() - .Which.Should().Contain("could NOT be determined"); - - var clean = ReleaseAvailability.IsUpdatable( - new ReleaseTarget(CandidateTag, "sid"), [new RequiredPackage("Crm", "Crm")], ReleaseArtifacts.Of(["Crm"])); - var nothing = Memex.Portal.Shared.Settings.UpdatePolicySettingsTab.ManualRollDecision(clean, none, key => key); - nothing.ProceedsOverPackageAnswer.Should().BeFalse(); - nothing.Note.Should().BeEmpty("a roll with nothing to say appends nothing"); - - var source = File.ReadAllText(Path.Combine( - RepoRoot(), "memex", "Memex.Portal.Shared", "Settings", "UpdatePolicySettingsTab.cs")); - var decided = source.IndexOf("ManualRollDecision(raw, clearance", StringComparison.Ordinal); - var handedOver = source.IndexOf("handover.Announce(", decided, StringComparison.Ordinal); - var patched = source.IndexOf("PatchToVersionAsync(", decided, StringComparison.Ordinal); - decided.Should().BeGreaterThan(-1, "the button reads the shared manual-roll decision"); - handedOver.Should().BeGreaterThan(decided, "the control-lane hand-over follows the decision"); - patched.Should().BeGreaterThan(decided, "the patch follows the decision"); - source[decided..handedOver].Should().NotContain("return;", - "no package answer returns before the hand-over — the platform rolls independently of packages"); - source[handedOver..patched].Should().Contain("outcome.Destination) + note)", - "the hand-over result line carries the advisory note"); - source[patched..].Should().Contain("tag) + note)", - "the patch result line carries the advisory note"); - } - - /// The repository root — the same probe and skip rule as the other source guards - /// (SelfUpdateMigratesBeforeItRollsTest): they run in-repo only. - private static string RepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - Assert.SkipWhen(dir is null, "repository tree not reachable — source guard runs in-repo only"); - return dir!.FullName; - } - - /// - /// 🚨 THE EXTERNALLY CONSUMED CONTRACT of /api/plugins/is-updatable. The route answers - /// the ROLL question: with a package verdict that refuses, the body still says - /// isUpdatable: true, and the refusal is not lost — it is on waivedHold, leads - /// advisories, and every package keeps its own status. A caller that scripted on - /// isUpdatable reads the roll decision; one that needs the package answer reads - /// waivedHold and packages. - /// - [Fact(Timeout = 240_000)] - public async Task TheIsUpdatableEndpoint_AnswersTheRollQuestion_AndKeepsThePackageAnswer() - { - var ct = TestContext.Current.CancellationToken; - var packages = MissingBakeUnderRequirePrebuilt(); - var http = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .AddSingleton( - new FixedVerdict(Mesh, new ConfigurationBuilder().Build(), packages)) - .BuildServiceProvider(), - }; - using var body = new MemoryStream(); - http.Response.Body = body; - - var result = await ReleaseGateEndpoints.Answer(http, CandidateTag).FirstAsync().Timeout(Budget).Await(ct); - await result.ExecuteAsync(http); - - http.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - using var json = JsonDocument.Parse(body.ToArray()); - var root = json.RootElement; - root.GetProperty("version").GetString().Should().Be(CandidateTag); - root.GetProperty("isUpdatable").GetBoolean().Should().BeTrue("a package answer never holds the platform roll"); - root.GetProperty("waivedHold").GetString().Should().Be(packages.HoldReason); - root.GetProperty("holdReason").ValueKind.Should().Be(JsonValueKind.Null); - root.GetProperty("indeterminate").GetBoolean().Should().BeFalse("a missing bake is a determined answer"); - root.GetProperty("enforced").GetBoolean().Should().BeTrue(); - root.GetProperty("advisories").EnumerateArray().First().GetString() - .Should().Contain("independently of packages").And.Contain("Feedback"); - var statuses = root.GetProperty("packages").EnumerateArray() - .ToImmutableDictionary(p => p.GetProperty("package").GetString()!, p => p.GetProperty("status").GetString()); - statuses.Keys.Order(StringComparer.Ordinal).Should().Equal(["Crm", "Feedback"]); - statuses["Crm"].Should().Be(nameof(PackageAvailabilityKind.Available)); - statuses["Feedback"].Should().NotBe(nameof(PackageAvailabilityKind.Available), - "the package that does not follow the roll keeps its own status"); - - // …and an availability that could not be determined is still told apart from a refusal. - var unknown = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .AddSingleton( - new FixedVerdict(Mesh, new ConfigurationBuilder().Build(), UnresolvableIdentity())) - .BuildServiceProvider(), - }; - using var unknownBody = new MemoryStream(); - unknown.Response.Body = unknownBody; - await (await ReleaseGateEndpoints.Answer(unknown, CandidateTag).FirstAsync().Timeout(Budget).Await(ct)) - .ExecuteAsync(unknown); - using var unknownJson = JsonDocument.Parse(unknownBody.ToArray()); - unknownJson.RootElement.GetProperty("isUpdatable").GetBoolean().Should().BeTrue(); - unknownJson.RootElement.GetProperty("indeterminate").GetBoolean().Should().BeTrue(); - unknownJson.RootElement.GetProperty("waivedHold").GetString() - .Should().Contain("framework identity of release 9999.0.0-ci.1 is not resolvable"); - } - - /// - /// 🚨 THE RECOVERY PATH. An instance an older build froze only receives this code through an - /// external roll — typically onto the very tag it held. From then on nothing is newer, so the - /// check takes the no-candidate exit and never reaches the candidate path's unconditional - /// clear: without its own clear there, the Updates tab would call the RUNNING build held for - /// ever. The advisories recorded for the running build are not part of the hold and stay. - /// - [Fact(Timeout = 240_000)] - public async Task ALegacyHold_IsClearedWhenNothingIsNewer_AndTheAdvisoriesStay() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct, staleHold: true, advisories: "Feedback: no sealed content bake"); - var updater = new RecordingUpdater(); - var service = Service(updater, UnresolvableIdentity(), new FakeAcrTagLister([])); - - await service.StartAsync(CancellationToken.None); - UpdatePolicyContent content; - try - { - await service.Evaluations.FirstAsync().Timeout(Budget).Await(ct); - content = await WaitForContent(c => c.LastCheckVerdict is not null && c.HeldTag is null); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - - updater.Tags.Should().BeEmpty("nothing is newer than the running build"); - content.HeldTag.Should().BeNull("a hold an older build wrote is not a state of this build"); - content.HeldReason.Should().BeNull(); - content.HeldIndeterminate.Should().BeFalse(); - content.HeldAt.Should().BeNull(); - content.Advisories.Should().Be("Feedback: no sealed content bake"); - } - - /// - /// A host with NO availability gate registered still names a recorded combo Red on the reboot's - /// image step — the poller's unwired path evaluates combo too, and a branch that returned - /// before consulting it would select the image without saying which modules do not survive it. - /// - [Fact(Timeout = 240_000)] - public async Task TheRebootImageStep_WithNoAvailabilityGate_StillNamesARecordedComboRed() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct, comboRed: true); - - var choice = await Service(new RecordingUpdater(), verdict: null) - .SelectImage(wedged: false).FirstAsync().Timeout(Budget).Await(ct); - - choice.Target.Should().Be(CandidateTag, "neither an unwired gate nor a combo Red holds the platform"); - choice.Detail.Should().Contain("No release-availability gate is registered") - .And.Contain("combo gate recorded RED"); - - var wedged = await Service(new RecordingUpdater(), verdict: null) - .SelectImage(wedged: true).FirstAsync().Timeout(Budget).Await(ct); - - wedged.Detail.Should().NotContain("combo gate recorded RED", - "a wedged instance does not wait for the combo verdict, so it reports none"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Harness (the ComboGateRollTest shape) - // ══════════════════════════════════════════════════════════════════════════ - - private async Task RunOneCheck(RecordingUpdater updater, UpdatabilityVerdict verdict) - { - var ct = TestContext.Current.CancellationToken; - var service = Service(updater, verdict); - - await service.StartAsync(CancellationToken.None); - try - { - await service.Evaluations.FirstAsync().Timeout(Budget).Await(ct); - return await WaitForContent(c => c.LastCheckVerdict is not null); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - } - - /// The service under test; a null is a host with no - /// availability gate registered. - private GatedSelfUpdateService Service( - RecordingUpdater updater, UpdatabilityVerdict? verdict, FakeAcrTagLister? registry = null) => - new(Mesh, registry ?? new FakeAcrTagLister(), updater, FastPoll(), - Mesh.ServiceProvider.GetService>(), - verdict is null ? null : new FixedVerdict(Mesh, new ConfigurationBuilder().Build(), verdict), - new ComboVerificationGate(Mesh)); - - private static SelfUpdateOptions FastPoll() => new() - { - RetryInterval = TimeSpan.FromMilliseconds(500), - EventCoalesceWindow = TimeSpan.FromMilliseconds(50), - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - }; - - /// The registry's listing — by default the one candidate; an empty listing is "nothing - /// is newer than the running build". - private sealed class FakeAcrTagLister(ImmutableArray? tags = null) : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult>(tags ?? [CandidateTag]); - } - - private sealed class RecordingUpdater : IDeploymentUpdater - { - private ImmutableList tags = ImmutableList.Empty; - - public ImmutableList Tags => tags; - public bool CanPatch => true; - - public Task LastRolledAtAsync(CancellationToken ct) => - Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) - { - ImmutableInterlocked.Update(ref tags, current => current.Add(versionTag)); - return Task.CompletedTask; - } - } - - /// The availability gate pinned to one verdict, for every question it is asked — the - /// candidate selection and the gate both. - private sealed class FixedVerdict(IMessageHub hub, IConfiguration configuration, UpdatabilityVerdict verdict) - : ReleaseAvailabilityService(hub, configuration) - { - public override IObservable IsUpdatable(string? targetVersion) => - Observable.Return(verdict); - - public override IObservable SelectRollTarget( - string? currentVersion, - IReadOnlyList? candidatesNewestFirst = null, - Func? condemned = null, - UpdatePolicyKind policy = UpdatePolicyKind.Continuous, - bool requireCiGreen = true) => - RollSelection.Select( - new RollSelectionInputs( - "test", currentVersion, [.. candidatesNewestFirst ?? []], - PluginInventory.Of(verdict.Packages.Select(p => new RequiredPackage(p.Package, p.Package)), "test")), - _ => Observable.Return(verdict)); - } - - private sealed class GatedSelfUpdateService( - IMessageHub hub, - IAcrTagLister acr, - IDeploymentUpdater updater, - SelfUpdateOptions options, - ILogger? logger, - ReleaseAvailabilityService? gate, - ComboVerificationGate? combo) - : SelfUpdateHostedService(hub, acr, updater, options, logger) - { - public IObservable Evaluations => ChecksReported; - - protected override ReleaseAvailabilityService? ResolveAvailabilityGate() => gate; - - protected override ComboVerificationGate? ResolveComboGate() => combo; - } - - /// Seeds the policy node — optionally with the hold the poller wrote, before policy - /// platform-rolls-independently-of-packages, on - /// that deployment, so the test also proves the roll CLEARS it. - private Task Seed(CancellationToken cancellationToken, bool staleHold = false, bool comboRed = false, string? advisories = null) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var content = new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, Pattern = "*-ci*" }; - if (staleHold) - content = content with - { - HeldTag = CandidateTag, - HeldReason = UnresolvableIdentity().HoldReason, - HeldIndeterminate = true, - HeldAt = DateTimeOffset.UtcNow.AddHours(-1), - }; - if (advisories is not null) - content = content with { AdvisoriesTag = CandidateTag, Advisories = advisories }; - if (comboRed) - content = content with - { - ComboVerifications = - [ - new ComboVerification - { - CandidateTag = CandidateTag, - Verdict = ComboVerdictKind.Red, - VerifiedAt = DateTimeOffset.UtcNow, - }, - ], - }; - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = content, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } - - private Task WaitForContent(Func predicate) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace() - .GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .Subscribe(observer); - }) - .Where(predicate) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/PlatformSettingsTabSeedTest.cs b/test/Memex.Portal.Shared.Test/PlatformSettingsTabSeedTest.cs deleted file mode 100644 index 7fdb6beeff..0000000000 --- a/test/Memex.Portal.Shared.Test/PlatformSettingsTabSeedTest.cs +++ /dev/null @@ -1,205 +0,0 @@ -using Memex.Portal.Shared.Settings; -using MeshWeaver.Graph.Configuration; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Seed-integrity contract for the settings tabs riding the UiContribution lane -/// (, WS7 slices 2 + 4). Every ungated seed must project into -/// the global settings menu (Context = Settings) and every Administration seed into the ADMIN APP -/// (Context = NodeSettings, gated to the AdminApp node type), point at an AREA the same class actually registers -/// (a dangling area renders the standard not-found placeholder — silently, to every user), keep -/// its node id equal to the tab's former compiled tab id (the /GlobalSettings/{Id} deep-link -/// contract), and the admin tabs must carry Gates.AdminOnly — dropping the gate would list -/// an Administration tab in every signed-in user's settings menu. -/// -public class PlatformSettingsTabSeedTest -{ - /// The former compiled tab ids that must survive as node ids, exactly. - private static readonly string[] ExpectedIds = - [ - WhatsNewSettingsTab.TabId, - AboutSettingsTab.TabId, - PrivacySettingsTab.TabId, - InvitationsSettingsTab.TabId, - InboxSettingsTab.TabId, - UpdatePolicySettingsTab.TabId, - PublishedSettingsTab.TabId, - ControlLaneSettingsTab.TabId, - // New as a seed (never a compiled tab) — its id is its deep link from the start. - ServiceIdentitiesSettingsTab.TabId, - ]; - - /// The tabs whose compiled providers gated on the platform-admin check. - private static readonly string[] AdminTabIds = - [ - PrivacySettingsTab.TabId, - InvitationsSettingsTab.TabId, - InboxSettingsTab.TabId, - UpdatePolicySettingsTab.TabId, - PublishedSettingsTab.TabId, - ControlLaneSettingsTab.TabId, - ServiceIdentitiesSettingsTab.TabId, - ]; - - [Fact] - public void Every_Seed_Targets_Its_Surface_And_A_Registered_Area() - { - Assert.NotEmpty(PlatformSettingsTabAreas.Seeds); - Assert.All(PlatformSettingsTabAreas.Seeds, seed => - { - var contribution = Assert.IsType(seed.Content); - // Administration tabs are tabs of the Admin app; everything else stays on the global - // settings page every signed-in viewer can open. - Assert.Equal( - AdminTabIds.Contains(seed.Id) ? UiContribution.NodeSettingsContext : UiContribution.SettingsContext, - contribution.Context); - Assert.NotNull(contribution.Area); - Assert.NotEqual("", contribution.Area); - Assert.Contains(contribution.Area, PlatformSettingsTabAreas.Areas); - Assert.False(string.IsNullOrEmpty(contribution.LabelKey), - $"'{seed.Id}' must localize its label"); - }); - - // 1:1 — every registered area is claimed by exactly one seed, so neither list can grow - // without the other (an area without a seed is unreachable; a seed without an area dangles). - var claimed = PlatformSettingsTabAreas.Seeds - .Select(s => Assert.IsType(s.Content).Area ?? "") - .ToList(); - Assert.Equal(claimed.Count, claimed.Distinct().Count()); - Assert.Equal( - PlatformSettingsTabAreas.Areas.OrderBy(a => a, StringComparer.Ordinal), - claimed.OrderBy(a => a, StringComparer.Ordinal)); - } - - [Fact] - public void Node_Ids_Are_The_Former_Compiled_Tab_Ids() - { - // The node id becomes the /GlobalSettings/{Id} route segment (ProjectSettingsTabs), so a - // renamed seed silently breaks every bookmarked deep link of the tab it migrated. - Assert.Equal( - ExpectedIds.OrderBy(i => i, StringComparer.Ordinal), - PlatformSettingsTabAreas.Seeds.Select(s => s.Id).OrderBy(i => i, StringComparer.Ordinal)); - } - - [Fact] - public void Admin_Tabs_Carry_The_AdminOnly_Gate_And_Ungated_Tabs_Do_Not() - { - Assert.All(PlatformSettingsTabAreas.Seeds, seed => - { - var contribution = Assert.IsType(seed.Content); - if (AdminTabIds.Contains(seed.Id)) - { - Assert.True(contribution.Gates?.AdminOnly, - $"'{seed.Id}' was admin-gated as a compiled provider and must stay admin-only"); - // Only the Admin app's node may carry it — not every node's settings page. - Assert.Equal(new[] { AdminAppNodeType.NodeType }, contribution.Gates?.NodeTypes?.ToArray() ?? []); - } - else - Assert.NotEqual(true, contribution.Gates?.AdminOnly); - }); - } - - /// - /// Every Administration seed is recorded as RELOCATED, so an old - /// /_Setting/GlobalSettings/{id} link redirects into the Admin app instead of landing on - /// the global page's first tab. - /// - [Fact] - public void Admin_Tabs_Are_Recorded_As_Relocated_To_The_Admin_App() - => Assert.Equal( - AdminTabIds.OrderBy(i => i, StringComparer.Ordinal), - PlatformSettingsTabAreas.AdminAppTabIds.OrderBy(i => i, StringComparer.Ordinal)); - - [Fact] - public void Seeds_Live_In_The_Admin_UiContribution_Namespace_As_UiContribution_Nodes() - { - Assert.All(PlatformSettingsTabAreas.Seeds, seed => - { - Assert.Equal(UiContributionNodeType.NodeType, seed.NodeType); - Assert.Equal("Admin/UiContribution", seed.Namespace); - }); - } - - [Fact] - public void Every_Seed_Passes_The_Contribution_Integrity_Check() - { - // 🚨 The check core did not have. A contributed entry naming a context nobody declares - // renders NOWHERE — no error, no warning, not even an area-not-found placeholder; six - // shipped entries were dark for nine days that way (Systemorph/MeshWeaver.Plugins#1162, - // which is why that repo grew scripts/check-menu-contexts.py). Core seeds its - // contributions from COMPILED code, so the cheap equivalent is a pinning test over the - // seed list — this one. - var problems = UiContributionSeedValidation.Validate( - PlatformSettingsTabAreas.Seeds, - registeredAreas: PlatformSettingsTabAreas.Areas); - Assert.Empty(problems); - - // CONTROL ARM 1 — the subject is still there. A Seeds list that emptied (a moved module, a - // renamed property, a refactor that stopped populating it) would make the assertion above - // pass having checked nothing at all. - Assert.Equal(ExpectedIds.Length, PlatformSettingsTabAreas.Seeds.Count); - - // CONTROL ARM 2 — the checker can still fail. Break one real seed in each of the ways that - // ship dark and require the validator to say so; a validator degenerated into "no - // problems" (or one whose vocabulary drifted away from the projection's) reds HERE. - var sample = PlatformSettingsTabAreas.Seeds[0]; - var content = Assert.IsType(sample.Content); - - Assert.NotEmpty(UiContributionSeedValidation.Validate( - [sample with { Content = content with { Context = "SettingsTypo" } }], - registeredAreas: PlatformSettingsTabAreas.Areas)); - Assert.NotEmpty(UiContributionSeedValidation.Validate( - [sample with { Content = content with { Area = null } }], - registeredAreas: PlatformSettingsTabAreas.Areas)); - Assert.NotEmpty(UiContributionSeedValidation.Validate( - [sample with { Content = content with { Area = "NotRegisteredAnywhere" } }], - registeredAreas: PlatformSettingsTabAreas.Areas)); - Assert.NotEmpty(UiContributionSeedValidation.Validate( - [sample with { Content = content with { Href = "https://evil.example/phish" } }], - registeredAreas: PlatformSettingsTabAreas.Areas)); - Assert.NotEmpty(UiContributionSeedValidation.Validate( - [sample with { Content = content with { LabelKey = null } }], - registeredAreas: PlatformSettingsTabAreas.Areas)); - Assert.NotEmpty(UiContributionSeedValidation.Validate( - [sample with { NodeType = "Markdown" }], - registeredAreas: PlatformSettingsTabAreas.Areas)); - } - - /// - /// Every Administration seed names the ADMIN APP section it belongs to — group, group key and - /// an order inside that section's band — so the nav groups it beside the compiled tabs of the - /// same section rather than in a stray group of its own. (That the page then actually RENDERS a - /// seeded tab in its section, for a viewer resolved on the render turn, is pinned end to end in - /// AdminAppTest.ASeededAdminTab_ShowsInItsSection and - /// AdminAppTest.SeededAdminTabs_SurviveASubscriptionOffTheViewersDelivery.) - /// - [Fact] - public void Every_Admin_Seed_Names_Its_Admin_App_Section() - { - var expected = new Dictionary - { - [InvitationsSettingsTab.TabId] = (AdminAppNodeType.PeopleGroup, AdminAppNodeType.PeopleGroupKey, AdminAppNodeType.PeopleOrder), - [PrivacySettingsTab.TabId] = (AdminAppNodeType.PeopleGroup, AdminAppNodeType.PeopleGroupKey, AdminAppNodeType.PeopleOrder), - [PublishedSettingsTab.TabId] = (AdminAppNodeType.PeopleGroup, AdminAppNodeType.PeopleGroupKey, AdminAppNodeType.PeopleOrder), - [UpdatePolicySettingsTab.TabId] = (AdminAppNodeType.OperationsGroup, AdminAppNodeType.OperationsGroupKey, AdminAppNodeType.OperationsOrder), - [ControlLaneSettingsTab.TabId] = (AdminAppNodeType.OperationsGroup, AdminAppNodeType.OperationsGroupKey, AdminAppNodeType.OperationsOrder), - [InboxSettingsTab.TabId] = (AdminAppNodeType.OperationsGroup, AdminAppNodeType.OperationsGroupKey, AdminAppNodeType.OperationsOrder), - [ServiceIdentitiesSettingsTab.TabId] = (AdminAppNodeType.PeopleGroup, AdminAppNodeType.PeopleGroupKey, AdminAppNodeType.PeopleOrder), - }; - Assert.Equal(AdminTabIds.OrderBy(i => i, StringComparer.Ordinal), - expected.Keys.OrderBy(i => i, StringComparer.Ordinal)); - - var adminSeeds = PlatformSettingsTabAreas.Seeds.Where(s => AdminTabIds.Contains(s.Id)).ToList(); - Assert.Equal(AdminTabIds.Length, adminSeeds.Count); - Assert.All(adminSeeds, seed => - { - var contribution = Assert.IsType(seed.Content); - var (group, groupKey, band) = expected[seed.Id]; - Assert.Equal(group, contribution.Group); - Assert.Equal(groupKey, contribution.GroupKey); - Assert.InRange(contribution.Order, band, band + 99); - }); - } -} diff --git a/test/Memex.Portal.Shared.Test/PlatformUpdateChipTest.cs b/test/Memex.Portal.Shared.Test/PlatformUpdateChipTest.cs deleted file mode 100644 index ceaec3a837..0000000000 --- a/test/Memex.Portal.Shared.Test/PlatformUpdateChipTest.cs +++ /dev/null @@ -1,246 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using Memex.Portal.Shared.SelfUpdate; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the header build chip: what it names, what it says, and what a click does. -/// -/// The behaviour worth protecting is that the chip renders in EVERY state. It is the answer to -/// "which instance am I on?", which is the question ahead of starting a thread round — and it can -/// only confirm a refresh landed if the running build was already on screen before it. A chip that -/// showed up only when an update was pending would announce availability and never arrival. -/// -public class PlatformUpdateChipTest -{ - // Localizer echoes the key, so assertions pin WHICH key each state reads. - private static string Echo(string key) => key; - - /// A fixed deployment moment: 13:35 UTC → 15:35 in Zurich (CEST, UTC+2). - private static readonly DateTimeOffset Deployed = new(2026, 8, 18, 13, 35, 0, TimeSpan.Zero); - - private static PlatformUpdateChip Describe(PlatformUpdateStatus status) => - PlatformUpdateChip.Describe( - status, "3.0.0-rc4.ci.4180", "memex-portal-7d9c-abcde", Deployed, "Europe/Zurich", Echo); - - /// - /// The header shows a SHORT build id. A local build's full version carries the whole commit - /// sha — 3.0.0-rc4.ci.0+8278244204d7e3d0cc95b1461c825383cf0875a9, 48 characters of - /// mostly hash — which is unreadable in a top bar sitting between two icon buttons. The - /// component's own CSS already conceded the point by hiding the text entirely below 720px. - /// - /// The sha is SHORTENED rather than dropped: on a local build every version is - /// ci.0, so the sha is the only part that distinguishes one build from the next. The - /// full string stays on the tooltip and on the About page. - /// - [Theory] - [InlineData("3.0.0-rc4.ci.0+8278244204d7e3d0cc95b1461c825383cf0875a9", "3.0.0-rc4.ci.0+8278244")] - [InlineData("3.0.0-rc4.ci.4180+76380ec7503eca69d2d1d20ab8870360fc88acd6", "3.0.0-rc4.ci.4180+76380ec")] - public void ShortVersion_keeps_the_build_and_abbreviates_the_sha(string full, string expected) - => PlatformUpdateChip.ShortVersion(full).Should().Be(expected); - - /// A version with no build-metadata sha is already short — it must pass through whole. - [Theory] - [InlineData("3.0.0-rc4.ci.4180")] - [InlineData("3.0.0")] - [InlineData("")] - public void ShortVersion_leaves_a_version_without_a_sha_alone(string full) - => PlatformUpdateChip.ShortVersion(full).Should().Be(full); - - /// - ///🚨 The header carries WHEN, never WHICH BUILD. A version string — even shortened — is an - /// identifier an ordinary reader cannot act on, and it sat in the busiest strip of the portal. - /// "Last deployed 08-18 15:35" answers the question people actually bring to it ("is this - /// current?"), and anyone who needs the exact build has it on the About page, one click away, - /// and on the tooltip without even that. - /// - [Fact] - public void Tooltip_says_when_it_was_deployed_and_the_bar_names_no_version() - { - const string full = "3.0.0-rc4.ci.0+8278244204d7e3d0cc95b1461c825383cf0875a9"; - var chip = PlatformUpdateChip.Describe( - new PlatformUpdateStatus(PlatformUpdateAvailability.UpToDate, null), - full, "memex-portal-7d9c-abcde", Deployed, "Europe/Zurich", Echo); - - chip.Tooltip.Should().Contain("about.lastDeployed 08-18 15:35", - "the chip is glyph-only, so the deployment time has nowhere to live but the tooltip"); - chip.Tooltip.Should().Contain(full, "the full build id must remain one hover away"); - } - - /// - /// Even with an update pending the header stays on the deployment time. The pending BUILD is - /// carried by the glyph (an up-arrow, coloured) and named in full on the tooltip — a version - /// string in the bar would be the same unreadable identifier, just a newer one. - /// - [Fact] - public void Tooltip_still_carries_the_deployment_time_when_an_update_is_pending() - { - var chip = PlatformUpdateChip.Describe( - new PlatformUpdateStatus(PlatformUpdateAvailability.UpdateAvailable, "3.0.0-rc4.ci.4191"), - "3.0.0-rc4.ci.4180", "memex-portal-7d9c-abcde", Deployed, "Europe/Zurich", Echo); - - chip.Tooltip.Should().Contain("about.lastDeployed 08-18 15:35", - "an update pending does not displace WHEN — both ride the one tooltip"); - chip.IsUpdate.Should().BeTrue("the glyph is what says an update is waiting"); - chip.Tooltip.Should().Contain("3.0.0-rc4.ci.4191"); - } - - /// - /// An unknown deployment time is OMITTED, not invented — no epoch date, no "unknown" filler. - /// The tooltip still names the build, and the glyph still renders, so the button is never - /// blank-but-clickable. - /// - [Fact] - public void An_unknown_deployment_time_is_omitted_rather_than_invented() - { - var chip = PlatformUpdateChip.Describe( - new PlatformUpdateStatus(PlatformUpdateAvailability.UpToDate, null), - "3.0.0-rc4.ci.4180", "memex-portal-7d9c-abcde", default, "Europe/Zurich", Echo); - - chip.Tooltip.Should().NotContain("about.lastDeployed"); - chip.Tooltip.Should().Contain("3.0.0-rc4.ci.4180", - "the build is a fact even when the deployment time is not"); - } - - /// - /// The header also carries WHEN this build started serving — the half a version cannot express. - /// Compact by necessity (it shares a top bar with two icon buttons), and in the VIEWER's zone: a - /// bare UTC clock is wrong by an hour for a Zurich reader in summer, silently. - /// - /// 🚨 NUMERIC, never a month NAME. dd MMM under InvariantCulture renders - /// "18 Aug" — an English abbreviation hard-coded into a string a German viewer reads, which is - /// the i18n rule broken in the one place it is hardest to notice. Numeric needs no catalog and - /// no culture, and it matches the yyyy-MM-dd HH:mm the About page already prints, minus - /// the year the header has no room for. - /// - [Fact] - public void ShortStartedAt_is_compact_and_in_the_viewers_zone() - { - var utc = new DateTimeOffset(2026, 8, 18, 13, 35, 0, TimeSpan.Zero); - - var text = PlatformUpdateChip.ShortStartedAt(utc, "Europe/Zurich"); - - text.Should().Be("08-18 15:35", "Zurich is UTC+2 in August, and the bar has no room for a full stamp"); - } - - /// - /// No month NAME in any language: the header must read identically for an English and a German - /// viewer, because it is formatted, not translated. - /// - [Fact] - public void ShortStartedAt_names_no_month_so_it_reads_the_same_in_every_language() - { - var text = PlatformUpdateChip.ShortStartedAt( - new DateTimeOffset(2026, 8, 18, 13, 35, 0, TimeSpan.Zero), "Europe/Zurich")!; - - // Digits, separators and a colon only — a letter here could only be a month name, which is - // English text smuggled into a localized UI. - text.Should().MatchRegex(@"^\d{2}-\d{2} \d{2}:\d{2}$"); - text.Any(char.IsLetter).Should().BeFalse( - "a month abbreviation would read 'Aug' to a German viewer as much as an English one"); - } - - /// Unknown start time renders nothing rather than an invented or epoch value. - [Fact] - public void ShortStartedAt_is_null_when_unknown() - => PlatformUpdateChip.ShortStartedAt(default, "Europe/Zurich").Should().BeNull(); - - [Fact] - public void UpToDate_still_names_the_running_build_and_instance() - { - var chip = Describe(new PlatformUpdateStatus(PlatformUpdateAvailability.UpToDate, null)); - - chip.Tooltip.Should().Contain("about.lastDeployed 08-18 15:35"); - chip.IsUpdate.Should().BeFalse(); - chip.Action.Should().Be(PlatformUpdateChipAction.OpenAbout, - "there is no newer build to reload onto, so the chip is a link to the full build identity"); - chip.Tooltip.Should().Contain("3.0.0-rc4.ci.4180").And.Contain("memex-portal-7d9c-abcde", - "two replicas can run the same build, so the version alone cannot answer 'did my session move?'"); - } - - [Fact] - public void Unknown_is_not_silent_it_still_shows_the_running_build() - { - // Unknown means nothing is polling — so no VERDICT may be claimed. The running build is a - // fact regardless, and it is the whole point of the chip. - var chip = Describe(PlatformUpdateStatus.Unknown); - - chip.Tooltip.Should().Contain("about.lastDeployed 08-18 15:35"); - chip.IsUpdate.Should().BeFalse(); - chip.Tooltip.Should().Contain("3.0.0-rc4.ci.4180", - "the running build is still a fact — it moved to the tooltip, it did not disappear"); - chip.Tooltip.Should().NotContain("about.upToDate", - "an install with nothing polling must not be told it is up to date"); - } - - [Fact] - public void UpdateAvailable_names_the_pending_build_and_offers_the_refresh() - { - var chip = Describe( - new PlatformUpdateStatus(PlatformUpdateAvailability.UpdateAvailable, "3.0.0-rc4.ci.4191")); - - chip.IsUpdate.Should().BeTrue(); - chip.Action.Should().Be(PlatformUpdateChipAction.Refresh); - chip.Tooltip.Should().Contain("about.updateAvailable").And.Contain("ui.updateRefreshHint"); - chip.Tooltip.Should().Contain("3.0.0-rc4.ci.4180", - "the running build stays on the tooltip — it is what the new one will be compared against"); - } - - /// - /// 🚨 The state that must not collapse into the one beside it. A hold is loud (it reads - /// differently from an available update, per PlatformUpdateAvailability.UpdateHeld) but NOT - /// actionable from the header: refreshing cannot clear it, and offering the button would teach - /// the user to click at a problem they cannot fix from here. - /// - [Fact] - public void UpdateHeld_says_so_but_offers_no_refresh() - { - var chip = Describe( - new PlatformUpdateStatus(PlatformUpdateAvailability.UpdateHeld, "3.0.0-rc4.ci.4191")); - - chip.IsUpdate.Should().BeTrue("a held update is still a pending one — it must not look quiet"); - chip.Tooltip.Should().Contain("about.updateHeld").And.Contain("3.0.0-rc4.ci.4191"); - chip.Tooltip.Should().NotContain("about.updateAvailable", - "a deployment that has REFUSED a build must not read like one that is about to take it"); - chip.Action.Should().Be(PlatformUpdateChipAction.OpenAbout, - "a refresh cannot clear a hold"); - } - - [Fact] - public void Every_localization_key_the_chip_reads_exists_in_the_catalog() - { - // A key missing from the catalog fails nowhere at compile time — it renders raw to the user. - string[] used = - [ - "about.version", "about.instance", "about.updateAvailable", "about.updateHeld", - "ui.updateRefreshHint" - ]; - - var missing = used.Where(k => !Catalog("en").ContainsKey(k)).ToList(); - missing.Should().BeEmpty($"the chip renders these keys raw when absent: {string.Join(", ", missing)}"); - - // German too: the chip is header chrome on every page, so a gap ships to every German user. - var untranslated = used.Where(k => !Catalog("de").ContainsKey(k)).ToList(); - untranslated.Should().BeEmpty($"missing German: {string.Join(", ", untranslated)}"); - } - - private static Dictionary Catalog(string locale) - { - var path = Path.Combine(FindRepoRoot(), - "src", "MeshWeaver.Messaging.Hub", "Localization", $"strings.{locale}.json"); - return JsonSerializer.Deserialize>(File.ReadAllText(path))!; - } - - private static string FindRepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); - } -} diff --git a/test/Memex.Portal.Shared.Test/PlatformUpdateStatusTest.cs b/test/Memex.Portal.Shared.Test/PlatformUpdateStatusTest.cs deleted file mode 100644 index da99796022..0000000000 --- a/test/Memex.Portal.Shared.Test/PlatformUpdateStatusTest.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.PluginCatalog; -using Xunit; -using MeshWeaver.Hosting.SelfUpdate; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the verdict an ORDINARY user is shown on the About tab: "up to date" vs "update available". -/// Two properties matter here and neither is obvious: -/// -/// The projection carries the verdict and — only when there IS an update — the newer tag. -/// Everything else on Admin/UpdatePolicy (the strategy, the CI-green flag, the poller's -/// checked-at) is platform-admin data and must not leak through this record. -/// With automatic checks OFF, the answer is Unknown, never "up to date". Nothing is -/// polling, so currency is a claim we have no evidence for — and the About tab renders nothing -/// rather than reassure a user falsely. -/// -/// -public class PlatformUpdateStatusTest -{ - private const string Running = "3.0.0-ci.2340"; - - [Fact] - public void NewerRecordedTag_IsAnAvailableUpdate() - { - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, LatestAvailableTag = "3.0.0-ci.2400" }, Running); - - Assert.Equal(PlatformUpdateAvailability.UpdateAvailable, status.Availability); - Assert.Equal("3.0.0-ci.2400", status.LatestVersion); - } - - [Fact] - public void NoRecordedTag_IsUpToDate() - { - // The poller records ONLY when it finds something newer, so an empty tag IS the up-to-date signal. - var status = PlatformUpdateStatus.Derive(new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous }, Running); - - Assert.Equal(PlatformUpdateAvailability.UpToDate, status.Availability); - Assert.Null(status.LatestVersion); - } - - [Fact] - public void AlreadyRolledToTheRecordedTag_IsUpToDate() - { - // After the install takes the update, LatestAvailableTag stays behind on the node — equal to - // (not newer than) the running build. Comparing, rather than testing for presence, is what - // keeps the tab from claiming an update forever after it was applied. - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, LatestAvailableTag = Running }, Running); - - Assert.Equal(PlatformUpdateAvailability.UpToDate, status.Availability); - Assert.Null(status.LatestVersion); - } - - [Fact] - public void OlderRecordedTag_IsUpToDate() - { - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, LatestAvailableTag = "3.0.0-ci.2000" }, Running); - - Assert.Equal(PlatformUpdateAvailability.UpToDate, status.Availability); - } - - [Fact] - public void ChecksSwitchedOff_WithNothingRecorded_IsUnknown_NotUpToDate() - { - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent { Policy = UpdatePolicyKind.None }, Running); - - Assert.Equal(PlatformUpdateAvailability.Unknown, status.Availability); - Assert.Null(status.LatestVersion); - } - - [Fact] - public void ChecksSwitchedOff_ButANewerBuildWasRecorded_StillReportsIt() - { - // An available update is a fact about the registry, not about whether this install intends to - // take it — a "None" policy hides the update from nobody. - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent { Policy = UpdatePolicyKind.None, LatestAvailableTag = "3.1.0" }, - Running); - - Assert.Equal(PlatformUpdateAvailability.UpdateAvailable, status.Availability); - Assert.Equal("3.1.0", status.LatestVersion); - } - - [Fact] - public void UnparseableRunningVersion_IsUpToDate_NotAFalseUpdatePrompt() - { - // A git-less source drop stamps "unknown"; VersionSelect.IsNewer refuses to compare against it - // (the same gate that stops the poller auto-rolling). The user must not be nagged on that basis. - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, LatestAvailableTag = "3.1.0" }, - "unknown"); - - Assert.Equal(PlatformUpdateAvailability.UpToDate, status.Availability); - } - - [Fact] - public void ANewerTagTheAvailabilityGateHolds_ReadsAsHeld_NotAsAvailable() - { - // 🚨 An install that has REFUSED a build must not look like one that is about to take it. - // Rendering a hold as "update available" is how a deployment stays frozen for weeks while - // every surface says it is fine — the outage the gate must never become. - // - // 🚨 Policy is now STATED rather than defaulted (#3812). It always mattered and was never - // written down: a hold reads as a hold only while a poller is running to clear it, and - // Policy's default is None. Leaving it implicit made this case indistinguishable from the - // frozen-record case that FrozenHoldIsHistoryTest pins. - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent - { - Policy = UpdatePolicyKind.Continuous, - LatestAvailableTag = "3.0.0-ci.2400", - HeldTag = "3.0.0-ci.2400", - HeldReason = "Store: no sealed content bake for framework identity sabc", - }, - Running); - - Assert.Equal(PlatformUpdateAvailability.UpdateHeld, status.Availability); - Assert.Equal("3.0.0-ci.2400", status.LatestVersion); - } - - [Fact] - public void AHoldRecordedForADIFFERENTTag_DoesNotHoldTheCurrentCandidate() - { - // A stale hold from an earlier candidate must not suppress a later one. The poller clears - // the hold on every successful tick, but the projection must not depend on it having done so. - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent - { - LatestAvailableTag = "3.0.0-ci.2400", - HeldTag = "3.0.0-ci.2350", - HeldReason = "an older candidate blocked once", - }, - Running); - - Assert.Equal(PlatformUpdateAvailability.UpdateAvailable, status.Availability); - } - - /// - /// 🚨 A combo RED (#2274) is read off the recorded verdict itself — not only off the poller's - /// notes — and under policy platform-rolls-independently-of-packages it renders as what - /// it is: an ADVISORY about packages, never a - /// hold. The - /// build stays "available" (the poller rolls to it) and the failing modules are named on the - /// status, so it never reads as an unqualified "update available" either. - /// - [Fact] - public void ACandidateTheComboGateRecordedRed_RendersAsAvailable_WithTheModulesAsAdvisories() - { - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent - { - LatestAvailableTag = "3.0.0-ci.2400", - ComboVerifications = [Verdict("3.0.0-ci.2400", ComboVerdictKind.Red)], - }, - Running); - - Assert.Equal(PlatformUpdateAvailability.UpdateAvailable, status.Availability); - Assert.Equal("3.0.0-ci.2400", status.LatestVersion); - Assert.True(status.HasAdvisories, "a Red verification is announced as a package advisory"); - } - - /// - /// 🚨 THE ABOUT TAB UNDER POLICY platform-rolls-independently-of-packages. This - /// reproduces an enterprise deployment's exact state: the poller found 3.1.10502, the availability check could not resolve its - /// framework identity, and — before the change — the record said HELD and the About tab read - /// "Update held — a package this deployment runs is not available for that build". Now the - /// poller records no hold, only the advisory, and the About line says the platform rolls and - /// points to the Updates tab, where the advisories are listed. - /// - [Fact] - public void AWaivedPackageHold_RendersAsAvailable_AndTheAboutLineAnnouncesThePackageAdvisories() - { - const string Tag = "3.1.10502"; - const string Advisory = - "the platform rolls independently of packages — package availability for this release could NOT " - + "be determined, and that decides nothing about the platform roll: the framework identity of " - + "release 3.1.10502 is not resolvable"; - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent - { - Policy = UpdatePolicyKind.Continuous, - LatestAvailableTag = Tag, - AdvisoriesTag = Tag, - Advisories = Advisory, - }, - "3.1.10490"); - - Assert.Equal(PlatformUpdateAvailability.UpdateAvailable, status.Availability); - Assert.True(status.HasAdvisories); - Assert.DoesNotContain("framework identity", status.ToString()); - - var markdown = Memex.Portal.Shared.Settings.AboutSettingsTab.UpdateStatusMarkdown(status, key => $"<{key}>"); - Assert.NotNull(markdown); - Assert.DoesNotContain("", markdown); - Assert.Contains("", markdown); - Assert.Contains("", markdown); - // The diagnostics themselves are untranslated server prose: admin-scoped, on the Updates - // tab — the About line carries the translated sentence only. - Assert.DoesNotContain("framework identity of release 3.1.10502 is not resolvable", markdown); - } - - /// - /// After the roll, the running build is the one the advisories describe: the About tab reads - /// "up to date" and still says that package advisories are recorded for it. - /// - [Fact] - public void AfterTheRoll_UpToDate_StillAnnouncesThePackageAdvisories() - { - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent - { - Policy = UpdatePolicyKind.Continuous, - AdvisoriesTag = "3.1.10502", - Advisories = "Feedback: no sealed content bake", - }, - "3.1.10502+abc123"); - - Assert.Equal(PlatformUpdateAvailability.UpToDate, status.Availability); - Assert.True(status.HasAdvisories); - Assert.DoesNotContain("Feedback", status.ToString()); - Assert.Contains("", - Memex.Portal.Shared.Settings.AboutSettingsTab.UpdateStatusMarkdown(status, key => $"<{key}>")); - } - - /// - /// 🚨 Only a RED reads as held. "We could not find out" is not a refusal — nothing blocked that - /// build — and rendering it as one would send an operator to fix an incompatibility that was - /// never diagnosed. The Updates settings tab renders that state on its own terms. - /// - [Theory] - [InlineData(ComboVerdictKind.Green)] - [InlineData(ComboVerdictKind.NotVerifiable)] - public void ANonRedVerdict_IsNotAHold(ComboVerdictKind kind) - { - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent - { - LatestAvailableTag = "3.0.0-ci.2400", - ComboVerifications = [Verdict("3.0.0-ci.2400", kind)], - }, - Running); - - Assert.Equal(PlatformUpdateAvailability.UpdateAvailable, status.Availability); - } - - /// A verdict about an EARLIER candidate must not suppress the current one. - [Fact] - public void ARedVerdictForADifferentTag_DoesNotHoldTheCurrentCandidate() - { - var status = PlatformUpdateStatus.Derive( - new UpdatePolicyContent - { - LatestAvailableTag = "3.0.0-ci.2400", - ComboVerifications = [Verdict("3.0.0-ci.2350", ComboVerdictKind.Red)], - }, - Running); - - Assert.Equal(PlatformUpdateAvailability.UpdateAvailable, status.Availability); - } - - private static ComboVerification Verdict(string tag, ComboVerdictKind kind) => new() - { - CandidateTag = tag, - Verdict = kind, - VerifiedAt = DateTimeOffset.UtcNow, - }; -} diff --git a/test/Memex.Portal.Shared.Test/PluginBundleAnchorTest.cs b/test/Memex.Portal.Shared.Test/PluginBundleAnchorTest.cs deleted file mode 100644 index 7891d0352b..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginBundleAnchorTest.cs +++ /dev/null @@ -1,363 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 THE ENTITLEMENT ANCHOR, END TO END (#1782 gap 2) — the registry answers, the local -/// install record is a cache, and its absence never denies. -/// -/// pins the gate itself (#1772/#1777): only granted -/// packages are served, and every refusal is byte-identical to "no such bundle". That fixture is -/// unchanged and still passes — this one pins the half it could not express, because before the -/// anchor there was nothing to express it WITH: the (source, package) binding came from the -/// install record and from nowhere else, so a package the serving instance had not itself installed -/// had nothing to match a grant against, and "I cannot tell which source this is from" came out as -/// "you are not entitled to it". -/// -/// Everything below the anchor is REAL — the same production path -/// exercises: instances registered through -/// (real minted key, real DefaultGrants -/// seed into the admin-owned grant node), install records written and removed by -/// , the key resolved by the production -/// over a real HTTP request. Only the registry SOURCE is -/// a stub, and it has to be: the two states that matter most are a registry that carries a package -/// this instance never installed, and one that will not answer at all. -/// -public class PluginBundleAnchorTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// The package whose install record is REMOVED — the cache, deleted — while the - /// registry keeps carrying it. - private const string AnchoredPackage = "BundleAnchored"; - - /// The paid package beside it, carried by the registry in a source this caller does - /// not hold. - private const string PaidPackage = "BundleAnchoredPaid"; - - /// Never installed, never advertised — the package nothing has ever observed. - private const string UnknownPackage = "BundleNeverHeardOf"; - - private const string PlatformSource = "Plugins"; - private const string PaidSource = "Education"; - private const string Version = "1.4.0"; - - private const string GrantedInstance = "anchor-consumer-granted"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - // ── the real registration + install path (identical to PluginBundleEntitlementTest) ──────── - - private Task RegisterInstance(string instanceId, params string[] defaultGrants) => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection(defaultGrants.Select((entry, i) => - new KeyValuePair( - $"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:{i}", entry))) - .Build()) - .Register("anchor-owner", "Anchor Owner", "owner@test.com", instanceId, instanceId) - .Select(r => r.RawKey) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(60)) - .Await(TestContext.Current.CancellationToken); - - private Task InstallPackage(string id, string? source, CancellationToken cancellationToken) => - PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = id, - Name = id, - Kind = PackageKind.Content, - TargetPartition = id, - SourceFolder = id, - Version = "1.0.0", - ReleasedVersion = Version, - Source = source, - }, - [new PackageFile($"{id}/Doc.md", $"# {id}")], - "HEAD") - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(120)) - .Await(cancellationToken); - - /// Removes the install record through the installer's own sanctioned route — the - /// CACHE, deleted, leaving the installed content and its partition exactly where they were. - private Task RemoveInstallRecord(string id) => - PackageInstaller.RemoveInstalledRecord(Mesh, id) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(60)) - .Await(TestContext.Current.CancellationToken); - - // ── the host, with a stub ANCHOR ────────────────────────────────────────────────────────── - - private PackageEntitlementLedger ledger = new(); - - /// - /// The bundle routes on a TestServer wired to the REAL mesh, plus a stub registry anchor. - /// - /// The anchor is registered on the REQUEST's services, which is exactly how a host - /// overrides it in production shapes too (PluginBundleEndpoints.Anchor reads - /// RequestServices first, then the mesh's — the same two-step - /// ModuleLandingService uses). - /// - private async Task StartBundleHost(params ConfiguredPackageSource[] sources) - { - ledger = new PackageEntitlementLedger(); - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(new InstanceRegistryAuthenticator( - Mesh, Mesh.ServiceProvider.GetRequiredService>())); - builder.Services.AddSingleton(ledger); - builder.Services.AddSingleton(new PackageOriginAnchor( - () => sources, - // No reuse window: every request re-reads, so a source that starts failing mid-test is - // actually observed failing rather than answered from a snapshot. - TimeSpan.Zero, - () => DateTimeOffset.UtcNow, - Mesh.ServiceProvider.GetRequiredService>())); - - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(); - return app; - } - - private static async Task Get(WebApplication app, string route, string key) - { - using var request = new HttpRequestMessage(HttpMethod.Get, route); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - return await app.GetTestClient().SendAsync(request); - } - - private static string BundleRoute(string plugin) => - $"{PluginBundleEndpoints.RoutePrefix}/{plugin}/{Version}"; - - private static string IndexRoute => PluginBundleEndpoints.RoutePrefix + "/index.json"; - - private static async Task> IndexedPlugins(HttpResponseMessage response) - { - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - return json.RootElement.GetProperty("bundles").EnumerateArray() - .Select(b => b.GetProperty("plugin").GetString()!) - .ToArray(); - } - - // ── the assertions ──────────────────────────────────────────────────────────────────────── - - /// - /// 🚨 THE ISSUE, end to end: an entitled caller with NO install record is not denied. - /// - /// The package is installed the production way and then its install RECORD is removed — - /// the cache, deleted, while the content and its partition stay exactly where they are. That is - /// the state a registry which provisions its packages as Spaces is permanently in (memex-cloud - /// never runs the catalog install, so it has no install records at all), and it is the state a - /// fresh instance is in for a package a customer has already paid for. - /// - /// Pre-fix the answer was 404 with the package absent from the index, because the grant - /// had nothing to match against. It must now resolve upstream and serve. - /// - [Fact(Timeout = 300_000)] - public async Task AnEntitledCallerWithNoInstallRecordIsStillServed() - { - await InstallPackage(AnchoredPackage, PlatformSource, TestContext.Current.CancellationToken); - (await RemoveInstallRecord(AnchoredPackage)).Should().BeTrue( - "the test's premise is that the CACHE is gone"); - - var key = await RegisterInstance(GrantedInstance, $"{PlatformSource}/*"); - - var app = await StartBundleHost(Carrying(PlatformSource, AnchoredPackage)); - await using var _ = app; - - using var index = await Get(app, IndexRoute, key); - index.StatusCode.Should().Be(HttpStatusCode.OK); - (await IndexedPlugins(index)).Should().Contain(AnchoredPackage, - "the registry carries this package and the caller's grant covers that source — the " - + "absence of a local install record is not evidence of anything"); - - using var served = await Get(app, BundleRoute(AnchoredPackage), key); - served.StatusCode.Should().Be(HttpStatusCode.OK, - "🚨 a purchase must not read as no purchase merely because nothing has installed here"); - - ledger.Decisions.Should().Contain( - d => d.PackageId == AnchoredPackage - && d.Outcome == EntitlementOutcome.Granted - && d.Anchor == EntitlementAnchorKind.Registry, - "and the answer must say it came from the anchor, not from a cache"); - } - - /// - /// 🚨 An UNREACHABLE registry produces the third state, never a denial. - /// - /// Two halves, and they need different answers. A package whose entitlement was - /// PREVIOUSLY OBSERVED here — the install record still carries its stamped source — keeps being - /// served, from a decision that says out loud that it came from a cache. A package nothing has - /// ever observed cannot be served (there is no answer to serve from), but it resolves - /// : UNKNOWN, recorded, and never asserted as - /// "not entitled". - /// - [Fact(Timeout = 300_000)] - public async Task AnUnreachableRegistryDoesNotDeny() - { - await InstallPackage(AnchoredPackage, PlatformSource, TestContext.Current.CancellationToken); - var key = await RegisterInstance(GrantedInstance, $"{PlatformSource}/*"); - - var app = await StartBundleHost(Failing(PlatformSource, "the registry is down")); - await using var _ = app; - - using var served = await Get(app, BundleRoute(AnchoredPackage), key); - served.StatusCode.Should().Be(HttpStatusCode.OK, - "fail toward not blocking a caller whose entitlement was previously observed — an " - + "unreachable registry is not evidence of a missing purchase"); - ledger.Decisions.Should().Contain( - d => d.PackageId == AnchoredPackage - && d.Outcome == EntitlementOutcome.Granted - && d.Anchor == EntitlementAnchorKind.Cache - && d.IsDegraded, - "🚨 …and the cache must say it is a cache, or it silently becomes the anchor again"); - - using var unknown = await Get(app, BundleRoute(UnknownPackage), key); - unknown.StatusCode.Should().Be(HttpStatusCode.NotFound, - "the bytes are withheld — the third state differs in what it CLAIMS, not in what it " - + "hands over"); - ledger.Decisions.Should().Contain( - d => d.PackageId == UnknownPackage && d.Outcome == EntitlementOutcome.Indeterminate, - "🚨 and the recorded answer is UNKNOWN, not Denied: 'I could not find out' and 'you did " - + "not buy it' must never be the same fact"); - ledger.Decisions.Should().NotContain( - d => d.PackageId == UnknownPackage && d.Outcome == EntitlementOutcome.Denied); - ledger.Describe().Should().Contain("UNKNOWN, not denials", - "the degradation has to be legible rather than inferred from a quiet day"); - } - - /// - /// 🚨 The deny direction, which must not be lost: a caller who is genuinely not entitled still - /// sees nothing — including when the ANCHOR is the thing that carries the package. - /// - /// This is the paid-content boundary the anchor could otherwise have widened: the - /// registry advertises a package to everyone who can read its catalog, and holding - /// Plugins/* must still not sweep in a 900 CHF course from Education. - /// - [Fact(Timeout = 300_000)] - public async Task ACallerWhoIsNotEntitledStillSeesNothing() - { - await InstallPackage(PaidPackage, PaidSource, TestContext.Current.CancellationToken); - var key = await RegisterInstance(GrantedInstance, $"{PlatformSource}/*"); - - var app = await StartBundleHost( - Carrying(PaidSource, PaidPackage), Carrying(PlatformSource, AnchoredPackage)); - await using var _ = app; - - using var index = await Get(app, IndexRoute, key); - (await IndexedPlugins(index)).Should().NotContain(PaidPackage, - "a caller must not even learn that an ungranted package is carried here"); - - using var refused = await Get(app, BundleRoute(PaidPackage), key); - refused.StatusCode.Should().Be(HttpStatusCode.NotFound); - ledger.Decisions.Should().Contain( - d => d.PackageId == PaidPackage - && d.Outcome == EntitlementOutcome.Denied - && d.Anchor == EntitlementAnchorKind.Registry, - "the anchor was consulted in full and the grant does not cover the source it names — " - + "that is a real denial, and the rule must still be able to produce one"); - } - - /// - /// #1777 is not weakened: with the anchor in play, a refusal is still byte-identical to the - /// answer for a package that does not exist — status, body, content headers and header names. - /// - /// The anchor adds STATES, never wire outcomes: the route still answers with the bytes or - /// with the one NoSuchBundle(). Here the two refusals are reached by different internal - /// paths on purpose — one Denied against the registry, one Indeterminate because nothing binds - /// it — which is exactly the pair a distinguishable refusal would leak. - /// - [Fact(Timeout = 300_000)] - public async Task TheThirdStateIsIndistinguishableOnTheWire() - { - await InstallPackage(PaidPackage, PaidSource, TestContext.Current.CancellationToken); - var key = await RegisterInstance(GrantedInstance, $"{PlatformSource}/*"); - - var app = await StartBundleHost(Failing(PlatformSource, "the registry is down")); - await using var _ = app; - - // Denied — a cached binding this caller's grant does not cover. - using var denied = await Get(app, BundleRoute(PaidPackage), key); - // Indeterminate — nothing binds it and the anchor cannot be asked. - using var indeterminate = await Get(app, BundleRoute(UnknownPackage), key); - - denied.StatusCode.Should().Be(indeterminate.StatusCode); - (await denied.Content.ReadAsByteArrayAsync()) - .Should().Equal(await indeterminate.Content.ReadAsByteArrayAsync(), - "a body that revealed WHICH refusal this was would be the inventory oracle #1777 " - + "closed, rebuilt out of the new states"); - denied.Content.Headers.ContentType?.ToString() - .Should().Be(indeterminate.Content.Headers.ContentType?.ToString()); - denied.Content.Headers.ContentLength - .Should().Be(indeterminate.Content.Headers.ContentLength); - denied.Headers.Select(h => h.Key).OrderBy(k => k, StringComparer.Ordinal) - .Should().Equal( - indeterminate.Headers.Select(h => h.Key).OrderBy(k => k, StringComparer.Ordinal)); - - // …and the distinction lives where the caller cannot read it. - ledger.Decisions.Should().Contain(d => d.PackageId == PaidPackage - && d.Outcome == EntitlementOutcome.Denied); - ledger.Decisions.Should().Contain(d => d.PackageId == UnknownPackage - && d.Outcome == EntitlementOutcome.Indeterminate); - } - - // ── stub sources ────────────────────────────────────────────────────────────────────────── - - private static ConfiguredPackageSource Carrying(string source, params string[] packageIds) => - new( - new StubSource( - packageIds.Select(id => new PackageManifest - { - Id = id, - Name = id, - ReleasedVersion = Version, - TargetPartition = id, - }).ToArray(), - null), - "HEAD", source); - - private static ConfiguredPackageSource Failing(string source, string message) => - new(new StubSource(null, new InvalidOperationException(message)), "HEAD", source); - - /// A package source that lists exactly what it is told to, or refuses to list at - /// all — standing in for the registry's own catalog read. - private sealed class StubSource(IReadOnlyList? packages, Exception? failure) - : IPackageSource - { - public IObservable> ListPackages(string gitRef) => - failure is null - ? Observable.Return(packages ?? []) - : Observable.Throw>(failure); - - public IObservable> FetchPackageFiles( - PackageManifest package, string gitRef) => - Observable.Return>([]); - } -} diff --git a/test/Memex.Portal.Shared.Test/PluginBundleArtifactFetchTest.cs b/test/Memex.Portal.Shared.Test/PluginBundleArtifactFetchTest.cs deleted file mode 100644 index 27d088c125..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginBundleArtifactFetchTest.cs +++ /dev/null @@ -1,343 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using PackagingManifest = MeshWeaver.Plugin.Packaging.PluginManifest; - -namespace Memex.Portal.Shared.Test; - -/// -/// learning the artifact path -/// (Doc/Architecture/PluginBundlesInTheRegistry): when the bundle index names an -/// artifact, the bytes come from the OCI registry — manifest by digest, layer by digest, -/// both verified — and enter the SAME landing path as before; when it names none, the HTTP bundle -/// route is taken exactly as today; and when the OCI registry serves bytes that do not hash to -/// the sealed digest, NOTHING lands — no file, no sidecar entry, no HTTP fallback — and the ledger -/// says why. -/// -/// The witnesses are the two registries' own request logs: which route was asked for is the -/// one thing the consumer cannot fake. The hub, the landing service and the update decision are -/// real; only the network is a fake. -/// -public class PluginBundleArtifactFetchTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string RegistryHost = "registry.artifact.test"; - private const string RegistryUrl = "https://" + RegistryHost; - private const string OciHost = "cr.artifact.test"; - private const string Token = "mwi_artifact_consumer"; - private const string Plugin = "ArtifactPkg"; - private const string Module = "MeshWeaver.ArtifactModule"; - private const string Version = "1.2.0"; - private const string ServedFramework = "s1234567890abcdef1234567890abcdef"; - - private readonly string landingRoot = - Path.Combine(Path.GetTempPath(), "mw-artifact-fetch-" + Guid.NewGuid().ToString("N")); - - // One packed bundle, shared by both fakes: the OCI registry serves it as the artifact's layer, - // the plugin registry serves it on the HTTP route. Immutable bytes, never written after - // construction — the same shape as a media-type table. - private static readonly byte[] BundleBytes = ModuleBundle(); - - private readonly FakeOciRegistry oci = new(OciHost, Token, $"plugins/Plugins/{Plugin}", BundleBytes); - private readonly FakePluginRegistry registry = new(BundleBytes); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - Directory.CreateDirectory(landingRoot); - return base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new HostRoutingClientFactory(oci, registry)) - // Land into a per-test temp tree, never the test host's own bin folder. - .AddSingleton(new ModuleLandingService(baseDirectory: landingRoot))); - } - - /// - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - try - { - if (Directory.Exists(landingRoot)) - Directory.Delete(landingRoot, recursive: true); - } - catch - { - // Best-effort: a cleanup hiccup must never turn a green test red. - } - } - - private PluginBundleClient Client() => new(Mesh, RegistryUrl, Token); - - private BundleAdoptionLedger Ledger => Mesh.ServiceProvider.GetRequiredService(); - - private string LandedDll() - { - var list = ModuleActivationSidecar.Read(landingRoot); - var entry = list.Entries.Single(e => e.Name == Module); - return Path.Combine(ModuleLandingService.ModuleDirectoryFor(landingRoot, Module, entry), Module + ".dll"); - } - - [Fact(Timeout = 120_000)] - public async Task AnAdvertisedArtifact_IsFetchedFromTheOciRegistry_NeverTheHttpRoute() - { - var ct = TestContext.Current.CancellationToken; - registry.Artifact = oci.Reference; - - var landed = await Client().AdoptModule(Plugin, Module, "Plugins/" + Plugin) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - landed.Should().Be(1, "the module file in the artifact's bundle layer lands"); - File.Exists(LandedDll()).Should().BeTrue(); - registry.BundleDownloads.Should().BeEmpty( - "with an artifact advertised the HTTP bundle route is not asked at all"); - oci.ManifestRequests.Should().Be(1); - oci.BlobRequests.Should().Be(1); - oci.PresentedSecret.Should().Be(Token, - "the credential at the OCI realm is the plugin-registry token the client already holds"); - oci.PresentedUser.Should().Be(OciRegistryClient.DefaultUsername); - Ledger.Outcomes.Should().NotContain(o => o.PluginId == Plugin && o.IsMiss); - } - - [Fact(Timeout = 120_000)] - public async Task ATamperedArtifact_LandsNothing_AndDoesNotFallBackToHttp() - { - var ct = TestContext.Current.CancellationToken; - registry.Artifact = oci.Reference; - oci.TamperBlob = true; - - var landed = await Client().AdoptModule(Plugin, Module, "Plugins/" + Plugin) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - landed.Should().Be(0); - ModuleActivationSidecar.Read(landingRoot).Entries.Should().BeEmpty("nothing landed"); - Directory.EnumerateFiles(landingRoot, "*.dll", SearchOption.AllDirectories).Should().BeEmpty( - "not one byte of a bundle that failed its digest reaches the module tree"); - registry.BundleDownloads.Should().BeEmpty( - "a refusal is not a hiccup — the HTTP route is never a fallback for bytes that failed their digest"); - var miss = Ledger.Outcomes.Should().ContainSingle(o => o.PluginId == Plugin).Subject; - miss.Kind.Should().Be(BundleAdoptionKind.ArtifactRefused); - miss.Reason.Should().Contain(oci.BundleDigest); - } - - [Fact(Timeout = 120_000)] - public async Task WithoutAnArtifact_TheHttpRouteIsTaken_AsToday() - { - var ct = TestContext.Current.CancellationToken; - registry.Artifact = null; - - var landed = await Client().AdoptModule(Plugin, Module, "Plugins/" + Plugin) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - landed.Should().Be(1); - File.Exists(LandedDll()).Should().BeTrue(); - registry.BundleDownloads.Should().ContainSingle().Which.Should().Be(Plugin); - oci.Requests.Should().BeEmpty("an index without an artifact never reaches the OCI registry"); - } - - /// - /// 🚨 MeshWeaver#4123 — the instance key goes to an artifact's OCI host ONLY when the registry it - /// belongs to declares that host (index-level artifactRegistry) or it is the registry's own - /// host. A registry that declares none, or declares a DIFFERENT host, never sees the OCI host - /// asked for anything: the bytes come over the registry's own HTTP route, so the install still - /// adopts. The declared case is - /// — the positive control for both arms below. - /// - [Theory(Timeout = 120_000)] - [InlineData(null)] - [InlineData("cr.someone-else.test")] - [InlineData("cr.artifact.test.evil")] - public async Task AnUndeclaredArtifactHost_NeverReceivesTheKey_AndTheHttpRouteServes(string? declared) - { - var ct = TestContext.Current.CancellationToken; - registry.Artifact = oci.Reference; - registry.ArtifactRegistry = declared; - - var landed = await Client().AdoptModule(Plugin, Module, "Plugins/" + Plugin) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - landed.Should().Be(1, "the bundle still lands — over the registry's own HTTP route"); - File.Exists(LandedDll()).Should().BeTrue(); - oci.Requests.Should().BeEmpty( - $"a registry declaring '{declared ?? "(nothing)"}' never sends this instance's key to {OciHost}"); - oci.PresentedSecret.Should().BeNull("the key was never presented at the undeclared host"); - registry.BundleDownloads.Should().ContainSingle().Which.Should().Be(Plugin); - } - - /// The rule itself, pure (MeshWeaver#4123): own host or the index's declared artifact - /// registry; whole-host and case-insensitive; a suffix-extended host or another port is not it. - [Fact] - public void TheArtifactKeyTarget_IsTheRegistrysOwnHost_OrItsDeclaredArtifactRegistry() - { - var artifact = "cr.meshweaver.cloud/plugins/Plugins/X@sha256:" + new string('a', 64); - PluginBundleClient.ArtifactKeyTarget("https://memex.meshweaver.cloud", "cr.meshweaver.cloud", artifact) - .Should().BeNull("the registry declares that host"); - PluginBundleClient.ArtifactKeyTarget("https://memex.meshweaver.cloud", "CR.MeshWeaver.Cloud", artifact) - .Should().BeNull("hosts compare case-insensitively"); - PluginBundleClient.ArtifactKeyTarget("https://memex.meshweaver.cloud", "https://cr.meshweaver.cloud/", artifact) - .Should().BeNull("a declaration written as a URL names the same host"); - PluginBundleClient.ArtifactKeyTarget("https://cr.meshweaver.cloud", null, artifact) - .Should().BeNull("the registry's OWN host needs no declaration"); - - PluginBundleClient.ArtifactKeyTarget("https://memex.meshweaver.cloud", null, artifact) - .Should().Contain("declares no artifact registry"); - PluginBundleClient.ArtifactKeyTarget("https://memex.meshweaver.cloud", "cr.other.example", artifact) - .Should().Contain("declares 'cr.other.example'"); - PluginBundleClient.ArtifactKeyTarget("https://memex.meshweaver.cloud", "meshweaver.cloud", artifact) - .Should().NotBeNull("a parent domain is a different host"); - PluginBundleClient.ArtifactKeyTarget("https://memex.meshweaver.cloud", "cr.meshweaver.cloud", - "cr.meshweaver.cloud:5000/plugins/Plugins/X@sha256:" + new string('a', 64)) - .Should().NotBeNull("another port is another endpoint"); - } - - /// - /// 🚨 MeshWeaver#6172 — what the adopt OUTCOME says about a failed artifact fetch decides whether a - /// module reload is Faulted (retried) or Failed (final). A registry that is briefly - /// down or rate-limiting answers something that clears on its own; a refusal or a tampered - /// layer is an answer the registry will give again. - /// - [Theory(Timeout = 120_000)] - [InlineData(HttpStatusCode.ServiceUnavailable, true)] - [InlineData(HttpStatusCode.TooManyRequests, true)] - [InlineData(HttpStatusCode.BadGateway, true)] - [InlineData(HttpStatusCode.GatewayTimeout, true)] - [InlineData(HttpStatusCode.NotFound, false)] - [InlineData(HttpStatusCode.Forbidden, false)] - public async Task AFailedArtifactFetch_IsTransient_ExactlyWhenItsStatusMayClear(HttpStatusCode status, bool transient) - { - var ct = TestContext.Current.CancellationToken; - registry.Artifact = oci.Reference; - oci.BlobStatus = status; - - var outcome = await Client().AdoptModuleOutcome(Plugin, Module, "Plugins/" + Plugin) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - outcome.Failure.Should().NotBeNull($"the premise: a blob answering {(int)status} lands nothing"); - outcome.Transient.Should().Be(transient, - $"{(int)status} {(transient ? "may clear on its own — retried" : "is a decided answer — final")}: {outcome.Failure}"); - } - - /// A layer that fails its digest is a REFUSAL — the registry served bytes that are not the - /// artifact, and it will serve the same bytes next time: final, never retried. - [Fact(Timeout = 120_000)] - public async Task ATamperedArtifact_IsADecidedFailure_NotTransient() - { - var ct = TestContext.Current.CancellationToken; - registry.Artifact = oci.Reference; - oci.TamperBlob = true; - - var outcome = await Client().AdoptModuleOutcome(Plugin, Module, "Plugins/" + Plugin) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - - outcome.Failure.Should().Contain("ArtifactRefused"); - outcome.Transient.Should().BeFalse("a hash mismatch is the registry's decided answer"); - } - - /// A packed module bundle carrying REAL assembly bytes — the landing measures them - /// (#3538), so a byte stand-in would be refused as unreadable and the test would be about that - /// gate instead of the fetch. - private static byte[] ModuleBundle() - { - var manifestJson = JsonSerializer.Serialize(new - { - plugin = Plugin, - version = Version, - frameworkMvid = ServedFramework, - module = new - { - assemblyName = Module, - assemblies = new[] { Module + ".dll" }, - }, - }); - - var buffer = new MemoryStream(); - NuGetPackageWriter.Write( - buffer, - new PackagingManifest(Plugin, "MeshWeaver.Plugin." + Plugin, Version, Plugin, null, []), - "3.0.0", - [ - new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleEntryPathFor(Module + ".dll"), - () => new MemoryStream(File.ReadAllBytes(typeof(BundleReader).Assembly.Location))), - ], - manifestJson); - return buffer.ToArray(); - } - - /// - /// The plugin registry as the bundle client reaches it: the bundle index advertising ONE - /// module bundle — with or without an artifact — and the HTTP bundle route serving the - /// same bytes the OCI fake holds, RECORDING every download, which is the witness. - /// - private sealed class FakePluginRegistry(byte[] bundle) : HttpMessageHandler, IHostHandler - { - private ImmutableList downloads = ImmutableList.Empty; - - public string? Artifact; - - /// The index-level artifactRegistry this registry declares (MeshWeaver#4123). - /// Defaults to the OCI fake's host, the fleet's shape; a test clears or changes it. - public string? ArtifactRegistry = OciHost; - - public ImmutableList BundleDownloads => downloads; - - public bool Serves(string host) => string.Equals(host, RegistryHost, StringComparison.OrdinalIgnoreCase); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var path = request.RequestUri!.AbsolutePath; - if (request.Method == HttpMethod.Get && path == $"{PluginBundleClient.RoutePrefix}/index.json") - { - var body = JsonSerializer.Serialize(new - { - frameworkMvid = ServedFramework, - artifactRegistry = ArtifactRegistry, - bundles = new[] - { - new - { - plugin = Plugin, - version = Version, - url = $"{RegistryUrl}{PluginBundleClient.RoutePrefix}/{Plugin}/{Version}", - module = Module, - frameworkMvid = ServedFramework, - artifact = Artifact, - }, - }, - }, PluginRegistryPayloads.Json); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }); - } - - if (request.Method == HttpMethod.Get && path.StartsWith($"{PluginBundleClient.RoutePrefix}/", StringComparison.Ordinal)) - { - var package = path[(PluginBundleClient.RoutePrefix.Length + 1)..].Split('/')[0]; - ImmutableInterlocked.Update(ref downloads, d => d.Add(Uri.UnescapeDataString(package))); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(bundle), - }); - } - - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/PluginBundleAuthTest.cs b/test/Memex.Portal.Shared.Test/PluginBundleAuthTest.cs deleted file mode 100644 index 429f063641..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginBundleAuthTest.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The bundle routes hand out COMPILED ASSEMBLIES for paid modules, so they must fail closed. -/// -/// 🚨 Every assertion here demands exactly 401, never merely "not 200". A typo'd route -/// answers 404, which satisfies "not success" while proving nothing — the guard would pass having -/// tested an endpoint that does not exist. 401 is the only status that can only come from the -/// filter, so it is simultaneously the security assertion and the proof the route is mapped. -/// -/// The registry's /api/plugins keeps an anonymous escape hatch for local dev. These -/// routes deliberately do not: "open when unconfigured" is how private sources were once served to -/// anyone who knew the URL, and the blast radius is larger here. -/// -/// No is registered at all, and that is deliberate: the rejection -/// path must need nothing but the header. InstanceRegistryAuthenticator.Authenticate returns -/// null the moment InstanceKeys.ExtractKey finds no key, without touching the mesh. -/// -public class PluginBundleAuthTest -{ - /// Both routes, with values that would resolve if the caller were authenticated. - public static TheoryData BundleRoutes => - [ - PluginBundleEndpoints.RoutePrefix + "/index.json", - PluginBundleEndpoints.RoutePrefix + "/ThreeBody/1.3.2", - ]; - - private static async Task Get(string route, string? authorization) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - - // 🚨 NO IMessageHub is registered, deliberately — that is half the assertion. The routes - // resolve the hub inside the handler rather than binding it as a parameter, because - // minimal-API binds arguments BEFORE filters run: a bound hub would make an unauthenticated - // request depend on the mesh being resolvable and answer 500 instead of 401. With nothing - // registered here, any regression to parameter binding fails these tests immediately. - builder.Services.AddSingleton(sp => - new InstanceRegistryAuthenticator( - null!, - sp.GetRequiredService< - Microsoft.Extensions.Logging.ILogger>())); - - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(); - - var client = app.GetTestClient(); - using var request = new HttpRequestMessage(HttpMethod.Get, route); - if (authorization is not null) - request.Headers.TryAddWithoutValidation("Authorization", authorization); - - return await client.SendAsync(request); - } - - [Theory] - [MemberData(nameof(BundleRoutes))] - public async Task NoCredentialIsRejected(string route) - { - using var response = await Get(route, authorization: null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Theory] - [MemberData(nameof(BundleRoutes))] - public async Task AnUnparseableBearerIsRejected(string route) - { - using var response = await Get(route, "Bearer not-an-instance-key"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Theory] - [MemberData(nameof(BundleRoutes))] - public async Task AnUnparseableBasicIsRejected(string route) - { - // Basic is accepted as a SCHEME (a NuGet-style client cannot send Bearer), which must not - // become a second, weaker way in: the password half still has to be a real instance key. - var header = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:not-a-key")); - - using var response = await Get(route, header); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task TheRejectionNamesWhatIsMissing() - { - // An operator wiring up a consumer sees this body. "401" alone sends them to the wrong - // place — a personal mw_ token, an OAuth flow — when what is needed is the instance key. - using var response = await Get(PluginBundleEndpoints.RoutePrefix + "/index.json", null); - var body = await response.Content.ReadAsStringAsync(); - - Assert.Contains("instance key", body, StringComparison.OrdinalIgnoreCase); - } -} diff --git a/test/Memex.Portal.Shared.Test/PluginBundleEntitlementTest.cs b/test/Memex.Portal.Shared.Test/PluginBundleEntitlementTest.cs deleted file mode 100644 index 49f691df92..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginBundleEntitlementTest.cs +++ /dev/null @@ -1,282 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 THE PER-PACKAGE ENTITLEMENT GATE ON THE BUNDLE ROUTES (#1772). -/// -/// pins the AUTHENTICATION half — a caller without a valid -/// mwi_ key gets 401. That half always worked. This fixture pins the half that did not: the -/// authenticated caller was written into HttpContext.Items and never read back, so any -/// registered instance could download every installed package's bundle — 900 CHF courses included — -/// while the route's own XML doc claimed per-package entitlement was enforced. An instance key is -/// provisioned to every registered installation; the population that could exercise this was -/// "everyone who ever registered", not "an attacker who breached something". -/// -/// The grant path is REAL, end to end. Instances are registered through -/// (which mints the key and seeds -/// PluginCatalog:DefaultGrants into the admin-owned PluginGrant node in the Admin -/// partition), the install records are written by , and the -/// key is resolved by the production over a real HTTP -/// request. Nothing is mocked, so a regression anywhere on that path fails here. -/// -/// Both directions, and the oracle. A granted instance gets the bytes; an ungranted one -/// gets a refusal that is byte-identical to the answer for a package that does not exist — -/// compares status, body AND content headers, -/// because the URL scheme is fully predictable and a distinguishable refusal is an inventory oracle -/// over the whole catalogue (the property /api/content established in #587). -/// -public class PluginBundleEntitlementTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// The package the consumer IS entitled to — installed from the platform source. - private const string GrantedPackage = "BundleGranted"; - - /// The paid package it is NOT entitled to — installed from a different source, exactly - /// as a 900 CHF course sits beside the free platform repo on the real registry. - private const string PaidPackage = "BundlePaid"; - - /// A package id that was never installed at all — the not-found baseline the refusal - /// must be indistinguishable from. - private const string AbsentPackage = "BundleNeverInstalled"; - - private const string PlatformSource = "Plugins"; - private const string PaidSource = "Education"; - private const string Version = "1.4.0"; - - /// The consumer that holds Plugins/* — the shape a real install is registered - /// with (the platform repo defaulted in, paid sources left admin-granted). - private const string GrantedInstance = "bundle-consumer-granted"; - - /// A consumer registered with NO defaults: it authenticates and is entitled to nothing. - /// "Registering is identity, not entitlement." - private const string UngrantedInstance = "bundle-consumer-ungranted"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - // ── the real registration + grant path ──────────────────────────────────────────────────── - - private MeshWeaverInstanceService InstanceService(params string[] defaultGrants) => - new(Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection(defaultGrants.Select((entry, i) => - new KeyValuePair( - $"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:{i}", entry))) - .Build()); - - /// Registers an install and returns its raw instance key — the only time it is - /// readable, exactly as a real registration hands it over once. - private Task RegisterInstance(string instanceId, params string[] defaultGrants) => - InstanceService(defaultGrants) - .Register("bundle-owner", "Bundle Owner", "owner@test.com", instanceId, instanceId) - .Select(r => r.RawKey) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(60)) - .Await(TestContext.Current.CancellationToken); - - /// Installs a package the production way, so the install record carries the same - /// Source stamp the registry writes — the half of the grant pair that is not the id. - private Task InstallPackage(string id, string source, CancellationToken cancellationToken) => - PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = id, - Name = id, - Kind = PackageKind.Content, - TargetPartition = id, - SourceFolder = id, - Version = "1.0.0", - // What the bundle index keys its URL on — a record without it is not servable. - ReleasedVersion = Version, - // Stamped by the registry as it merges its sources (PluginRegistryEndpoints) or - // by the lister on a registry instance (InstanceAutoRegistrationService). It is - // what a PluginGrantEntry is matched against. - Source = source, - }, - [new PackageFile($"{id}/Doc.md", $"# {id}")], - "HEAD") - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(120)) - .Await(cancellationToken); - - // ── the route, over a real HTTP pipeline ────────────────────────────────────────────────── - - /// - /// The bundle routes on a TestServer wired to the REAL mesh: the production - /// resolves the presented key against the instance - /// and grant nodes this fixture actually wrote, and the handlers read this mesh's install records. - /// - private async Task StartBundleHost() - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(new InstanceRegistryAuthenticator( - Mesh, Mesh.ServiceProvider.GetRequiredService>())); - - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(); - return app; - } - - private static async Task Get(WebApplication app, string route, string key) - { - using var request = new HttpRequestMessage(HttpMethod.Get, route); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - return await app.GetTestClient().SendAsync(request); - } - - private static string BundleRoute(string plugin) => - $"{PluginBundleEndpoints.RoutePrefix}/{plugin}/{Version}"; - - private static string IndexRoute => PluginBundleEndpoints.RoutePrefix + "/index.json"; - - /// The plugin ids an index response advertises. - private static async Task> IndexedPlugins(HttpResponseMessage response) - { - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - return json.RootElement.GetProperty("bundles").EnumerateArray() - .Select(b => b.GetProperty("plugin").GetString()!) - .ToArray(); - } - - // ── the assertions ──────────────────────────────────────────────────────────────────────── - - /// - /// 🚨 The whole defect, in one test: an instance that authenticates but was granted nothing must - /// not receive an installed package's bundle — and an instance granted the PLATFORM source must - /// not receive the PAID one that sits beside it. - /// - /// Pre-fix both refusals were 200 with the archive attached, because - /// AuthenticatedInstance.Allows was never called on these routes. - /// - [Fact(Timeout = 300_000)] - public async Task OnlyGrantedPackagesAreServed() - { - await InstallPackage(GrantedPackage, PlatformSource, TestContext.Current.CancellationToken); - await InstallPackage(PaidPackage, PaidSource, TestContext.Current.CancellationToken); - var grantedKey = await RegisterInstance(GrantedInstance, $"{PlatformSource}/*"); - var ungrantedKey = await RegisterInstance(UngrantedInstance); - - var app = await StartBundleHost(); - await using var _ = app; - - // The entitled fetch still works — a gate that refuses everyone is not a fix. - using var served = await Get(app, BundleRoute(GrantedPackage), grantedKey); - served.StatusCode.Should().Be(HttpStatusCode.OK, - "an instance granted Plugins/* must still get the platform package's bundle"); - (await served.Content.ReadAsByteArrayAsync()).Length.Should().BeGreaterThan(0, - "the served bundle is a real archive, not an empty 200"); - - // Same URL, a key that was granted nothing. - using var refusedWholesale = await Get(app, BundleRoute(GrantedPackage), ungrantedKey); - refusedWholesale.StatusCode.Should().Be(HttpStatusCode.NotFound, - "a registered instance with no grant is entitled to NOTHING — registering is identity, " - + "not entitlement"); - - // The sharper direction: a real grant, for a different source. This is the paid-content - // boundary — Plugins/* must not sweep in a 900 CHF course from Education. - using var refusedPaid = await Get(app, BundleRoute(PaidPackage), grantedKey); - refusedPaid.StatusCode.Should().Be(HttpStatusCode.NotFound, - "a grant is per (source, package) — holding the platform source must not confer the " - + "paid source installed beside it"); - } - - /// - /// 🚨 A refusal must be indistinguishable from "there is no such bundle" — status, body AND - /// content headers. - /// - /// The URL is /{plugin}/{version} and plugin ids are public knowledge, so any - /// difference between "you may not have this" and "this does not exist" turns the route into an - /// inventory oracle over the entire catalogue: an instance could enumerate every paid package - /// this registry carries, and at which versions, without being entitled to one of them. This is - /// the property /api/content established in #587 by making its refusal body byte-identical - /// to its not-found body. - /// - [Fact(Timeout = 300_000)] - public async Task RefusalIsIndistinguishableFromNotFound() - { - await InstallPackage(PaidPackage, PaidSource, TestContext.Current.CancellationToken); - var grantedKey = await RegisterInstance(GrantedInstance, $"{PlatformSource}/*"); - - var app = await StartBundleHost(); - await using var _ = app; - - // Installed here, but not for this caller … - using var refused = await Get(app, BundleRoute(PaidPackage), grantedKey); - // … versus a package that genuinely does not exist on this instance. - using var absent = await Get(app, BundleRoute(AbsentPackage), grantedKey); - - refused.StatusCode.Should().Be(absent.StatusCode, - "the status must not reveal that the package exists"); - (await refused.Content.ReadAsByteArrayAsync()) - .Should().Equal(await absent.Content.ReadAsByteArrayAsync(), - "the body must be byte-identical — a differing reason string IS the oracle"); - refused.Content.Headers.ContentType?.ToString() - .Should().Be(absent.Content.Headers.ContentType?.ToString(), - "a Content-Type present on one answer and not the other distinguishes them just as " - + "well as a body would"); - refused.Content.Headers.ContentLength - .Should().Be(absent.Content.Headers.ContentLength); - refused.Headers.Select(h => h.Key).OrderBy(k => k, StringComparer.Ordinal) - .Should().Equal(absent.Headers.Select(h => h.Key).OrderBy(k => k, StringComparer.Ordinal), - "and neither may carry a header the other does not"); - } - - /// - /// The index is scoped too — an ungranted package is not merely un-fetchable, it is not listed. - /// - /// This is what makes the download refusal above non-informative: with the index filtered, - /// "absent from your index" and "404 on fetch" agree, and neither confirms existence. An index - /// that advertised the whole inventory would hand over exactly the catalogue the 404 withholds. - /// - [Fact(Timeout = 300_000)] - public async Task TheIndexListsOnlyGrantedPackages() - { - await InstallPackage(GrantedPackage, PlatformSource, TestContext.Current.CancellationToken); - await InstallPackage(PaidPackage, PaidSource, TestContext.Current.CancellationToken); - var grantedKey = await RegisterInstance(GrantedInstance, $"{PlatformSource}/*"); - var ungrantedKey = await RegisterInstance(UngrantedInstance); - - var app = await StartBundleHost(); - await using var _ = app; - - using var granted = await Get(app, IndexRoute, grantedKey); - granted.StatusCode.Should().Be(HttpStatusCode.OK); - var advertised = await IndexedPlugins(granted); - advertised.Should().Contain(GrantedPackage, "the granted package is servable to this caller"); - advertised.Should().NotContain(PaidPackage, - "a caller must not be able to learn that an ungranted package is installed here"); - - using var ungranted = await Get(app, IndexRoute, ungrantedKey); - ungranted.StatusCode.Should().Be(HttpStatusCode.OK, - "an entitled-to-nothing instance still gets a well-formed index — an error would itself " - + "be a signal"); - (await IndexedPlugins(ungranted)).Should().BeEmpty( - "and that index is indistinguishable from a registry with nothing installed"); - } -} diff --git a/test/Memex.Portal.Shared.Test/PluginBundleIndexArtifactTest.cs b/test/Memex.Portal.Shared.Test/PluginBundleIndexArtifactTest.cs deleted file mode 100644 index b4febc8d66..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginBundleIndexArtifactTest.cs +++ /dev/null @@ -1,219 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The bundle index (/api/plugins/bundles/index.json) naming each bundle's OCI -/// artifact (Doc/Architecture/PluginBundlesInTheRegistry) — from the registry's -/// record of pushed publications, the seam. Additive: the -/// field is present on every bundle, null where nothing was pushed, and with the platform's -/// default (which records nothing) it is null everywhere, so a pre-artifact consumer and a -/// pre-artifact registry keep exactly today's shape. -/// -/// The same host shape as : the REAL mesh, the real -/// registration and install path, the routes on a TestServer; the artifacts record is a fake -/// registered on the request's services — the two-step resolution a host uses. -/// -public class PluginBundleIndexArtifactTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string PushedPackage = "IndexArtifactPushed"; - private const string PlainPackage = "IndexArtifactPlain"; - private const string Source = "Plugins"; - private const string Version = "2.1.0"; - private const string Instance = "index-artifact-consumer"; - - private static readonly string Reference = - $"cr.example.test/plugins/{Source}/{PushedPackage}@sha256:" + new string('a', 64); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - private Task RegisterInstance(params string[] defaultGrants) => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection(defaultGrants.Select((entry, i) => - new KeyValuePair( - $"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:{i}", entry))) - .Build()) - .Register("index-owner", "Index Owner", "owner@test.com", Instance, Instance) - .Select(r => r.RawKey) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(60)) - .Await(TestContext.Current.CancellationToken); - - private Task InstallPackage(string id, CancellationToken cancellationToken) => - PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = id, - Name = id, - Kind = PackageKind.Content, - TargetPartition = id, - SourceFolder = id, - Version = "1.0.0", - ReleasedVersion = Version, - Source = Source, - }, - [new PackageFile($"{id}/Doc.md", $"# {id}")], - "HEAD") - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(120)) - .Await(cancellationToken); - - private async Task StartBundleHost( - IPublicationArtifacts? artifacts, CancellationToken cancellationToken, string? artifactRegistry = null) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - if (artifactRegistry is not null) - builder.Configuration[PluginBundleClient.ArtifactRegistryConfigKey] = artifactRegistry; - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(new InstanceRegistryAuthenticator( - Mesh, Mesh.ServiceProvider.GetRequiredService>())); - if (artifacts is not null) - builder.Services.AddSingleton(artifacts); - - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(cancellationToken); - return app; - } - - private static async Task> ArtifactsInIndex(WebApplication app, string key) - { - using var request = new HttpRequestMessage(HttpMethod.Get, PluginBundleEndpoints.RoutePrefix + "/index.json"); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - using var response = await app.GetTestClient().SendAsync(request); - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - return json.RootElement.GetProperty("bundles").EnumerateArray().ToDictionary( - b => b.GetProperty("plugin").GetString()!, - b => b.TryGetProperty("artifact", out var a) && a.ValueKind == JsonValueKind.String - ? a.GetString() - : b.TryGetProperty("artifact", out _) ? null : "(absent)"); - } - - [Fact(Timeout = 300_000)] - public async Task TheIndexNamesTheArtifact_ForABundleThatWasPushed_AndNullForOneThatWasNot() - { - await InstallPackage(PushedPackage, TestContext.Current.CancellationToken); - await InstallPackage(PlainPackage, TestContext.Current.CancellationToken); - var key = await RegisterInstance($"{Source}/*"); - - var app = await StartBundleHost(new RecordedArtifacts( - new PublicationArtifact(Source, PushedPackage, Version, Reference)), TestContext.Current.CancellationToken); - await using var _ = app; - - var artifacts = await ArtifactsInIndex(app, key); - - artifacts.Keys.Should().Contain([PushedPackage, PlainPackage]); - artifacts[PushedPackage].Should().Be(Reference, - "the registry pushed this bundle and its index says where, by digest"); - artifacts[PlainPackage].Should().BeNull( - "the field is present and null on a bundle nothing pushed — the consumer takes the HTTP route"); - } - - [Fact(Timeout = 300_000)] - public async Task WithThePlatformDefault_EveryArtifactIsNull() - { - await InstallPackage(PlainPackage, TestContext.Current.CancellationToken); - var key = await RegisterInstance($"{Source}/*"); - - var app = await StartBundleHost(artifacts: null, cancellationToken: TestContext.Current.CancellationToken); - await using var _ = app; - - var artifacts = await ArtifactsInIndex(app, key); - - artifacts.Keys.Should().Contain(PlainPackage); - artifacts[PlainPackage].Should().BeNull( - "AddPluginCatalog registers NoPublicationArtifacts, which records nothing"); - } - - private static async Task ArtifactRegistryInIndex(WebApplication app, string key) - { - using var request = new HttpRequestMessage(HttpMethod.Get, PluginBundleEndpoints.RoutePrefix + "/index.json"); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - using var response = await app.GetTestClient().SendAsync(request); - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - return json.RootElement.TryGetProperty("artifactRegistry", out var value) ? value.Clone() : null; - } - - /// - /// MeshWeaver#4123 — the index states the registry's OWN artifact-registry declaration, from its - /// config (the chart renders it from registry.host): the one host besides the registry's - /// own to which a consumer presents its key. Unconfigured, it is null — and the consumer then - /// takes the HTTP route for every off-host artifact (the negative control). - /// - [Fact(Timeout = 300_000)] - public async Task TheIndexStatesTheRegistrysOwnArtifactRegistry_AndNullWhenUndeclared() - { - await InstallPackage(PlainPackage, TestContext.Current.CancellationToken); - var key = await RegisterInstance($"{Source}/*"); - - var declared = await StartBundleHost(null, TestContext.Current.CancellationToken, " cr.example.test "); - await using (declared) - { - var value = await ArtifactRegistryInIndex(declared, key); - value.Should().NotBeNull("the field is on the index"); - value!.Value.GetString().Should().Be("cr.example.test", "the configured declaration, trimmed"); - } - - var undeclared = await StartBundleHost(null, TestContext.Current.CancellationToken); - await using (undeclared) - { - var value = await ArtifactRegistryInIndex(undeclared, key); - (value is null || value.Value.ValueKind == JsonValueKind.Null).Should().BeTrue( - "a registry with no declaration states none — never a guessed host"); - } - } - - [Fact] - public void TheLookup_MatchesPackageAndVersion_AndSourceOnlyWhenBothStateOne() - { - IReadOnlyList pushed = - [ - new(Source, "Pkg", "1.0.0", "cr/plugins/Plugins/Pkg@sha256:" + new string('1', 64)), - new(null, "Loose", "2.0.0", "cr/plugins/x/Loose@sha256:" + new string('2', 64)), - ]; - - pushed.ReferenceFor(Source, "pkg", "1.0.0").Should().Be(pushed[0].Reference, "package ids are case-insensitive"); - pushed.ReferenceFor("Education", "Pkg", "1.0.0").Should().BeNull("a different source is a different artifact"); - pushed.ReferenceFor(null, "Pkg", "1.0.0").Should().Be(pushed[0].Reference, "an entry without a source matches on package and version"); - pushed.ReferenceFor(Source, "Pkg", "1.0.1").Should().BeNull("the version is compared exactly"); - pushed.ReferenceFor(Source, "Pkg", null, "1.0.0").Should().Be(pushed[0].Reference, "versions are tried in order, nulls skipped"); - pushed.ReferenceFor("Anything", "Loose", "2.0.0").Should().Be(pushed[1].Reference, "a record without a source matches wherever the package is served from"); - } - - private sealed class RecordedArtifacts(params PublicationArtifact[] artifacts) : IPublicationArtifacts - { - public IObservable> Read() => - Observable.Return((IReadOnlyList)artifacts); - } -} diff --git a/test/Memex.Portal.Shared.Test/PluginBundleLaneResolutionTest.cs b/test/Memex.Portal.Shared.Test/PluginBundleLaneResolutionTest.cs deleted file mode 100644 index da88837e33..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginBundleLaneResolutionTest.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System; -using System.Collections.Generic; -using Memex.Portal.Shared.Api; -using MeshWeaver.Graph.Configuration; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins which assembly the bundle route serves for a caller's lane (#1751) — the whole distribution -/// decision, and both of its branches fail SILENTLY when they are wrong. -/// -/// -/// Serve a lagging build on the instance's own lane and everything looks -/// healthy: the consumer adopts, the counts say "adopted", and it is quietly running an older -/// assembly than the portal that served it. A Release record genuinely can lag — -/// PrebuiltAssemblySeeder stamps LastCompiledVersion on adopt without minting a release -/// at all — so this is the ordinary case, not a corner one. -/// Serve a foreign lane's build and nothing complains until activation, -/// where it surfaces as a TypeLoadException inside a collectible ALC: no compile error, no -/// overlay, nothing to grep. -/// -/// -/// Neither is observable from a passing request, which is why the choice is pinned directly -/// rather than through an HTTP round trip against a live portal. -/// -public class PluginBundleLaneResolutionTest -{ - private const string OwnIdentity = "s1a2b3c4d5e6f708"; - private const string ForeignIdentity = "s9f8e7d6c5b4a302"; - private const string NodePath = "Pkg/Type"; - - private static NodeTypeDefinition Compiled(long version) => - new() { Description = "test", LastCompiledVersion = version }; - - private static IReadOnlyDictionary> Releases( - params ReleaseArtifact[] artifacts) => - new Dictionary>(StringComparer.OrdinalIgnoreCase) - { - [NodePath] = - [ - new NodeTypeRelease - { - Path = $"{NodePath}/Release/v1", - NodeTypePath = NodePath, - Release = "hash", - FrameworkVersion = "3.0.0.0", - CreatedAt = DateTimeOffset.UnixEpoch, - Artifacts = artifacts, - }, - ], - }; - - [Fact] - public void OwnLaneServesTheCurrentBuild_NotTheLaggingRelease() - { - // The release records store version 7; the portal actually RUNS 9. On its own lane the - // identity claim is true by construction, so the current build is both correct and newer — - // resolving through the release here would ship 7 and look perfectly healthy doing it. - var misses = new List(); - - var version = PluginBundleEndpoints.ResolveStoreVersion( - NodePath, Compiled(9), - Releases(new ReleaseArtifact(OwnIdentity, "linux-x64", AssemblyStoreVersion: 7)), - OwnIdentity, "linux-x64", servesOwnLane: true, misses); - - Assert.Equal(9, version); - Assert.Empty(misses); - } - - [Fact] - public void OwnLaneServesEvenWhenNoReleaseRecordsALink() - { - // The pre-#1751 shape, which must stay byte-for-byte what it was: every type adopted from a - // CI bake has a LastCompiledVersion and no release at all. Counting these as misses would - // empty every bundle the registry serves today. - var misses = new List(); - - var version = PluginBundleEndpoints.ResolveStoreVersion( - NodePath, Compiled(9), - new Dictionary>(), - OwnIdentity, "linux-x64", servesOwnLane: true, misses); - - Assert.Equal(9, version); - Assert.Empty(misses); - } - - [Fact] - public void ForeignLaneServesTheArtifactRecordedForIt() - { - // The capability the link adds: an arm64 caller can now be served, from the record the arm64 - // bake wrote — rather than told "not adoptable" because the registry's own lane is x64. - var misses = new List(); - - var version = PluginBundleEndpoints.ResolveStoreVersion( - NodePath, Compiled(9), - Releases( - new ReleaseArtifact(OwnIdentity, "linux-x64", AssemblyStoreVersion: 9), - new ReleaseArtifact(ForeignIdentity, "linux-arm64", AssemblyStoreVersion: 4)), - ForeignIdentity, "linux-arm64", servesOwnLane: false, misses); - - Assert.Equal(4, version); - Assert.Empty(misses); - } - - [Fact] - public void ForeignLaneNeverFallsBackToTheCurrentBuild() - { - // 🚨 The one that must not be "helpful". LastCompiledVersion is THIS lane's build; handing it - // to a caller under the identity it asked for would claim a compatibility nobody established, - // and the consumer's own gate would then wave it through because the manifest says what it - // asked for. Nothing served, and the miss is recorded. - var misses = new List(); - - var version = PluginBundleEndpoints.ResolveStoreVersion( - NodePath, Compiled(9), - Releases(new ReleaseArtifact(OwnIdentity, "linux-x64", AssemblyStoreVersion: 9)), - ForeignIdentity, "linux-arm64", servesOwnLane: false, misses); - - Assert.Null(version); - var miss = Assert.Single(misses); - Assert.Contains(NodePath, miss); - Assert.Contains(ForeignIdentity, miss); - Assert.Contains("linux-arm64", miss); - } - - [Fact] - public void ForeignLaneWithAnUnlocatableArtifactIsAMiss_NotASilentSkip() - { - // An artifact that proves the lane but records no store key cannot be fetched. Serving - // nothing is right; saying nothing is not — the reason has to distinguish "no bake for your - // lane" from "a bake exists but its bytes cannot be located", because they are different - // things to go and fix. - var misses = new List(); - - var version = PluginBundleEndpoints.ResolveStoreVersion( - NodePath, Compiled(9), - Releases(new ReleaseArtifact(ForeignIdentity, "linux-arm64")), - ForeignIdentity, "linux-arm64", servesOwnLane: false, misses); - - Assert.Null(version); - Assert.Contains("cannot be located", Assert.Single(misses)); - } -} diff --git a/test/Memex.Portal.Shared.Test/PluginBundlePlanTest.cs b/test/Memex.Portal.Shared.Test/PluginBundlePlanTest.cs deleted file mode 100644 index 910d9898d0..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginBundlePlanTest.cs +++ /dev/null @@ -1,376 +0,0 @@ -using System.Collections.Immutable; -using System.Net; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text.Json; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 AN INSTANCE ON THE WRONG PLAN CANNOT PULL A PACKAGE ABOVE IT. -/// -/// The Store sells plans and every package declares the plan it belongs to; the instance's -/// record carries its plan (#2804) and the registry serves only the packages that plan covers, -/// from the sources the instance is granted. This fixture pins that boundary where it is enforced — on the registry's bundle routes, over a real -/// HTTP pipeline, with the production resolving the -/// caller's grant AND the plan ladder off this mesh's Admin/Tiers nodes. Nothing is mocked: -/// the instances are registered through on a plan -/// with a plan-less whole-source default grant, the packages are installed by with -/// their declared tier, and the tier nodes are written the way the Store seeds them. -/// -/// Both directions, as always: the free-plan instance still gets the free package (a gate -/// that refuses everyone is not a licence), the pro-plan instance gets both, and on a registry with -/// NO tier nodes a paid plan reads as the baseline — free flows, nothing above it does; a ladder -/// that cannot be read must never widen a licence. -/// -public class PluginBundlePlanTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string FreeApp = "PlanFreeApp"; - private const string ProApp = "PlanProApp"; - private const string AbsentApp = "PlanNeverInstalled"; - private const string Source = "Plugins"; - private const string Version = "2.0.0"; - private const string FreeInstance = "plan-consumer-free"; - private const string ProInstance = "plan-consumer-pro"; - private const string TierNodeType = "Store/Tier"; - - /// The tier node's content as the registry reads it — the rank and the all-access - /// flag, the two fields of the Store's TierContent that - /// consumes. A test-local type because the Store is a module this fixture does not run; the - /// ladder reads the SERIALIZED content shape-tolerantly, never the CLR type, which is exactly - /// what lets the platform read a node whose type is Store source. - public record TierNode - { - public int Rank { get; init; } - public bool AllAccess { get; init; } - } - - // ── the lagging-mirror seam (#6818) ─────────────────────────────────────────────────────── - // - // While armed, every DataChangedEvent the instance record's OWNER posts — the commit echo that - // advances this process's live mirror — is held at the owner's outbound pipeline instead of - // sent. The owner's ACK (PatchDataResponse) is untouched, so a write completes exactly as it - // does when the echo is merely slow. Released by the test, which re-delivers the held frames in - // order. Instance fields: per-test lifetime. - private volatile bool holdInstanceEcho; - private ImmutableList<(IMessageHub Owner, IMessageDelivery Delivery)> heldEcho = []; - private readonly AsyncSubject echoHeld = new(); - - private void ReleaseInstanceEcho() - { - holdInstanceEcho = false; - foreach (var (owner, delivery) in Interlocked.Exchange(ref heldEcho, [])) - owner.DeliverMessage(delivery); - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .ConfigureDefaultNodeHub(config => config.AddPostPipeline(p => p.AddPipeline((d, next) => - { - if (!holdInstanceEcho - || d.Message is not DataChangedEvent - || !string.Equals(p.Hub.Address.Path, InstancePath(FreeInstance), StringComparison.Ordinal)) - return next.Invoke(d); - ImmutableInterlocked.Update(ref heldEcho, list => list.Add((p.Hub, d))); - echoHeld.OnNext(Unit.Default); - echoHeld.OnCompleted(); - return d.Ignored(); - }))) - .AddPluginCatalog() - // The Store/Tier node type, as far as this fixture needs it: enough for Admin/Tiers/* - // nodes to be creatable on a mesh that does not run the Store. - .AddMeshNodes(new MeshNode(TierNodeType) - { - Name = "Tier", - IsSatelliteType = false, - HubConfiguration = config => config - .AddMeshDataSource(source => source.WithContentType()), - }) - .ConfigureHub(config => config.WithType(nameof(TierNode))); - - // ── the real registration + grant path, one plan-scoped default entry per instance ──────── - - private MeshWeaverInstanceService InstanceService(params string[] defaultGrants) => - new(Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection(defaultGrants.Select((entry, i) => - new KeyValuePair( - $"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:{i}", entry))) - .Build()); - - /// Registers an instance ON (null = the baseline, exactly what - /// open registration yields) with plan-less default grants — the plan lives on the record - /// (#2804), the grant says only which sources. - private Task RegisterInstance(string instanceId, string? tier, params string[] defaultGrants) => - InstanceService(defaultGrants) - .Register("plan-owner", "Plan Owner", "owner@test.com", instanceId, instanceId, tier: tier) - .Select(r => r.RawKey) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(60)) - .Await(TestContext.Current.CancellationToken); - - /// Where puts the record: the owner's - /// partition. - private static string InstancePath(string instanceId) => - $"plan-owner/{MeshWeaverInstanceService.InstanceNamespace}/{instanceId}"; - - /// Installs a package the production way, carrying the plan it declares — the - /// content.tier a node-repo source reads off the package root. - private Task InstallPackage(string id, string tier, CancellationToken cancellationToken) => - PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = id, - Name = id, - Kind = PackageKind.Content, - TargetPartition = id, - SourceFolder = id, - Version = "1.0.0", - ReleasedVersion = Version, - Source = Source, - Tier = tier, - }, - [new PackageFile($"{id}/Doc.md", $"# {id}")], - "HEAD") - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(120)) - .Await(cancellationToken); - - /// The plan ladder as the Store seeds it — one Admin/Tiers/{id} node per plan, - /// its rank on the content. Written as System: the Admin partition is exactly what a registering - /// instance's owner cannot reach. - private Task SeedLadder(CancellationToken cancellationToken) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - var mesh = Mesh.ServiceProvider.GetRequiredService(); - return new[] { ("free", 0), ("pro", 20) } - .Select(plan => Observable.Defer(() => - { - var system = access.ImpersonateAsSystem(); - return mesh.CreateOrUpdateNode(new MeshNode(plan.Item1, PlanTierLadder.Namespace) - { - Name = plan.Item1, - NodeType = TierNodeType, - State = MeshNodeState.Active, - Content = new TierNode { Rank = plan.Item2 }, - }) - .Finally(() => system.Dispose()); - })) - .Concat() - .LastAsync() - .Timeout(TimeSpan.FromSeconds(60)) - .Await(cancellationToken); - } - - // ── the routes, over a real HTTP pipeline ───────────────────────────────────────────────── - - private async Task StartBundleHost() - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(Mesh); - // The mesh's OWN authenticator, not a private one: a promotion invalidates the verdict - // cached on the mesh-scoped singleton, which is the one production endpoints resolve. - builder.Services.AddSingleton(Mesh.ServiceProvider.GetRequiredService()); - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(); - return app; - } - - private static async Task Get(WebApplication app, string route, string key) - { - using var request = new HttpRequestMessage(HttpMethod.Get, route); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - return await app.GetTestClient().SendAsync(request); - } - - private static string BundleRoute(string plugin) => $"{PluginBundleEndpoints.RoutePrefix}/{plugin}/{Version}"; - private static string IndexRoute => PluginBundleEndpoints.RoutePrefix + "/index.json"; - - private static async Task> IndexedPlugins(HttpResponseMessage response) - { - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - return json.RootElement.GetProperty("bundles").EnumerateArray() - .Select(b => b.GetProperty("plugin").GetString()!) - .ToArray(); - } - - // ── the assertions ──────────────────────────────────────────────────────────────────────── - - /// - /// 🚨 The whole boundary in one test: the free-plan instance gets the free package, is not - /// even shown the pro one, and its fetch of it is byte-identical to fetching a package that - /// does not exist — while the pro-plan instance, holding the SAME source, gets both. - /// - [Fact(Timeout = 300_000)] - public async Task AnInstanceOnTheFreePlan_CannotPullAProPackage() - { - await SeedLadder(TestContext.Current.CancellationToken); - await InstallPackage(FreeApp, "free", TestContext.Current.CancellationToken); - await InstallPackage(ProApp, "pro", TestContext.Current.CancellationToken); - var freeKey = await RegisterInstance(FreeInstance, "free", $"{Source}/*"); - var proKey = await RegisterInstance(ProInstance, "pro", $"{Source}/*"); - - var app = await StartBundleHost(); - await using var _ = app; - - // The free plan: its own tier is served, the tier above is not — and is not listed. - using var freeIndex = await Get(app, IndexRoute, freeKey); - freeIndex.StatusCode.Should().Be(HttpStatusCode.OK); - var freeSees = await IndexedPlugins(freeIndex); - freeSees.Should().Contain(FreeApp, "the free plan covers a package declaring `free`"); - freeSees.Should().NotContain(ProApp, - "a free-plan instance must not even learn that a pro package is installed here"); - - using var servedFree = await Get(app, BundleRoute(FreeApp), freeKey); - servedFree.StatusCode.Should().Be(HttpStatusCode.OK, "a gate that refuses everyone is not a licence"); - - using var refusedPro = await Get(app, BundleRoute(ProApp), freeKey); - using var absent = await Get(app, BundleRoute(AbsentApp), freeKey); - refusedPro.StatusCode.Should().Be(HttpStatusCode.NotFound, - "a package above the instance's plan is not deployable to it"); - (await refusedPro.Content.ReadAsByteArrayAsync()) - .Should().Equal(await absent.Content.ReadAsByteArrayAsync(), - "the refusal must be indistinguishable from not-found — a reason string is an oracle"); - - // The pro plan, same source, same registry: both. - using var proIndex = await Get(app, IndexRoute, proKey); - (await IndexedPlugins(proIndex)).Should().Contain([FreeApp, ProApp], - "the pro plan covers everything ranked at or below it"); - using var servedPro = await Get(app, BundleRoute(ProApp), proKey); - servedPro.StatusCode.Should().Be(HttpStatusCode.OK); - } - - /// - /// 🚨 No ladder, no widening. A registry without tier nodes cannot rank a paid plan, so an - /// instance on `pro` reads as the BASELINE there: the free package flows (a local self-registry - /// serves its free packages ladder or not), the pro one does not — pinned so a missing Store - /// seed can never read as "every plan is all-access". - /// - [Fact(Timeout = 300_000)] - public async Task WithoutTierNodes_APaidPlanReadsAsTheBaseline() - { - await InstallPackage(FreeApp, "free", TestContext.Current.CancellationToken); - await InstallPackage(ProApp, "pro", TestContext.Current.CancellationToken); - var proKey = await RegisterInstance(ProInstance, "pro", $"{Source}/*"); - - var app = await StartBundleHost(); - await using var _ = app; - - // No ladder: "pro" cannot be ranked, so the pro package is not covered even for an instance - // that names that plan — while the baseline still flows, because "free" ranks at the - // baseline by definition (a local self-registry serves its free packages ladder or not). - using var index = await Get(app, IndexRoute, proKey); - index.StatusCode.Should().Be(HttpStatusCode.OK, "the caller authenticates"); - var sees = await IndexedPlugins(index); - sees.Should().Contain(FreeApp, "the baseline needs no ladder"); - sees.Should().NotContain(ProApp, "a tier the registry cannot rank is covered by nothing"); - using var refused = await Get(app, BundleRoute(ProApp), proKey); - refused.StatusCode.Should().Be(HttpStatusCode.NotFound); - } - - /// - /// 🚨 THE HOLE #2804 CLOSES. Every instance registered before the plan lane carries plan-less - /// grant entries (memex-cloud: Plugins/*), and "a plan-less entry covers every tier" - /// let them pull pro and enterprise bundles. Now the licence is the INSTANCE's plan, and an - /// instance whose record names none is on the baseline — free, not unlimited. - /// - [Fact(Timeout = 300_000)] - public async Task ALegacyPlanLessGrant_IsCappedByTheInstancePlan() - { - await SeedLadder(TestContext.Current.CancellationToken); - await InstallPackage(FreeApp, "free", TestContext.Current.CancellationToken); - await InstallPackage(ProApp, "pro", TestContext.Current.CancellationToken); - var legacyKey = await RegisterInstance(FreeInstance, null, $"{Source}/*"); - - var app = await StartBundleHost(); - await using var _ = app; - - using var index = await Get(app, IndexRoute, legacyKey); - var sees = await IndexedPlugins(index); - sees.Should().Contain(FreeApp, "the baseline plan covers the free package"); - sees.Should().NotContain(ProApp, "a plan-less grant on an instance with no plan is a FREE licence, not an unlimited one"); - using var refused = await Get(app, BundleRoute(ProApp), legacyKey); - using var absent = await Get(app, BundleRoute(AbsentApp), legacyKey); - refused.StatusCode.Should().Be(HttpStatusCode.NotFound); - (await refused.Content.ReadAsByteArrayAsync()) - .Should().Equal(await absent.Content.ReadAsByteArrayAsync(), "indistinguishable from not-found"); - } - - /// - /// 🚨 PROMOTION IS ONE FIELD, AND IT TAKES EFFECT AT ONCE. A global admin sets the plan on the - /// instance record, and the very next request on this replica is decided with the new plan — - /// no grant string edited, no cache window waited out. - /// - /// 🚨 Pinned against a LAGGING MIRROR (#6818). The authenticator reads the record off the - /// process-wide live mirror, which advances on the owner's commit echo — a different message - /// from the ack that completes the write, with nothing ordering the two. This test holds the - /// echo back (the seam above), so the mirror provably lags the ack: the promotion must NOT - /// report done while the record the registry decides with still says free. Before the - /// fix it did, and the next request was refused the package it had just been promoted to — the - /// flake CI showed when the echo merely ran late. - /// - [Fact(Timeout = 300_000)] - public async Task PromotingTheInstance_WidensTheNextRequest() - { - await SeedLadder(TestContext.Current.CancellationToken); - await InstallPackage(FreeApp, "free", TestContext.Current.CancellationToken); - await InstallPackage(ProApp, "pro", TestContext.Current.CancellationToken); - var key = await RegisterInstance(FreeInstance, "free", $"{Source}/*"); - - var app = await StartBundleHost(); - await using var _ = app; - - using var before = await Get(app, BundleRoute(ProApp), key); - before.StatusCode.Should().Be(HttpStatusCode.NotFound, "on the free plan the pro package is not deployable"); - - // The promotion: the plan service the admin tab calls, against the record's own path — with - // the owner's commit echo held, so the mirror the bundle host reads lags the acked write. - holdInstanceEcho = true; - var plans = Mesh.ServiceProvider.GetRequiredService(); - var promoted = new ReplaySubject(); - using var promotion = plans.SetPlan(InstancePath(FreeInstance), "pro").Subscribe(promoted); - try - { - await echoHeld.Should().Within(TimeSpan.FromSeconds(60)).Emit( - "the owner committed the promotion and posted its echo — which the seam is holding"); - await promoted.Should().NotEmit(TimeSpan.FromSeconds(5), - "the promotion must not report done while the mirror the registry decides with still " - + "carries the pre-promotion record — a request issued now would be refused the package"); - } - finally - { - ReleaseInstanceEcho(); - } - - await promoted.Should().Within(TimeSpan.FromSeconds(60)).Emit( - "once the echo lands the mirror carries the promotion, and the promotion completes"); - - using var after = await Get(app, BundleRoute(ProApp), key); - after.StatusCode.Should().Be(HttpStatusCode.OK, "the promoted instance pulls the pro package on its next request"); - using var index = await Get(app, IndexRoute, key); - (await IndexedPlugins(index)).Should().Contain([FreeApp, ProApp]); - } -} diff --git a/test/Memex.Portal.Shared.Test/PluginBundlePublishAssetsTest.cs b/test/Memex.Portal.Shared.Test/PluginBundlePublishAssetsTest.cs deleted file mode 100644 index 0e9be17b8d..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginBundlePublishAssetsTest.cs +++ /dev/null @@ -1,221 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A module PUBLISHED to the registry must reach the shelf with its STATIC WEB ASSETS, not just -/// its assemblies. -/// -/// 🚨 Why this needs a test of its own: nothing else fails when the assets are dropped. The -/// publish returns 200, the shelf entry is recorded, the module lands, it loads, and every page -/// still renders — only unstyled, and only for the packs whose image copy has been retired. That -/// is exactly how it shipped: Validate already took the assets and Accepted already -/// carried them to ShelveModule, but the endpoint passed neither argument, so every module -/// ever published landed with assemblies alone. Measured in production 2026-08-25 (#2221): -/// DefaultViews' bundle declares 254 static assets while its landed generation held 11 files and -/// no wwwroot at all; MeshWeaver.Blazor.EntityViews.styles.css had been 404 since -/// #2188 retired that pack's image copy, and the portal was styled only because Views' and Graph's -/// image copies were still there. -/// -/// So the assertion is deliberately about BYTES ON DISK under the landed module directory, -/// not about a status code or a log line: the consumer's stylesheet request is a file lookup, and -/// only a file satisfies it. -/// -public class PluginBundlePublishAssetsTest : IDisposable -{ - private const string Token = "publish-token-for-this-test"; - private const string Module = "MeshWeaver.Test.ViewPack"; - private const string Plugin = "TestViewPack"; - private const string AssetPath = "wwwroot/MeshWeaver.Test.ViewPack.styles.css"; - private static readonly byte[] AssetBytes = Encoding.UTF8.GetBytes(".mw-test{color:#bada55}"); - - // 🚨 A SECOND asset, and a NESTED one deliberately: one file landing is not evidence that the - // set landed, so a partial drop has to fail here too — and a pack's collocated JS is requested - // at its relative path (_content//Components/…), so flattening it would 404 exactly like - // dropping it. Both are asserted byte-exact below. - private const string NestedAssetPath = "wwwroot/Components/TestView.razor.js"; - private static readonly byte[] NestedAssetBytes = - Encoding.UTF8.GetBytes("export function init(){ return 'mw-test'; }"); - - private readonly string root = Path.Combine( - Path.GetTempPath(), "mw-publish-assets-" + Guid.NewGuid().ToString("N")); - - /// - /// The publish endpoint lands the bundle's assets beside its assemblies — the file a browser - /// would request exists, with the bytes the producer packed. - /// - [Fact] - public async Task PublishedModule_LandsItsStaticAssets() - { - var response = await Publish(BuildBundle(withAssets: true)); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - foreach (var (relative, expected) in new[] - { - (AssetPath, AssetBytes), - (NestedAssetPath, NestedAssetBytes), - }) - { - var landed = LandedFile(relative); - Assert.True(landed is not null, - $"the published bundle declared '{relative}' but no landed module directory under " - + $"'{root}' carries it — the shelf dropped part or all of the pack's static web " - + "assets, so consumers of this module render unstyled or 404 its collocated JS " - + "(#2221)"); - Assert.Equal(expected, File.ReadAllBytes(landed!)); - } - } - - /// - /// The entry assembly still lands — the assertion above must be evidence about ASSETS, not - /// about a publish path that happens to write everything or nothing. - /// - [Fact] - public async Task PublishedModule_LandsItsEntryAssembly() - { - var response = await Publish(BuildBundle(withAssets: true)); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.True(LandedFile(Module + ".dll") is not null, - "the entry assembly did not land — this test's own premise is broken, so its asset " - + "assertion would prove nothing"); - } - - /// - /// A bundle that declares NO assets lands its assembly and writes no wwwroot — the - /// asset path is driven by what the producer packed, never fabricated by the shelf. - /// - [Fact] - public async Task PublishedModuleWithoutAssets_LandsNoWwwroot() - { - var response = await Publish(BuildBundle(withAssets: false)); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.True(LandedFile(Module + ".dll") is not null, "the entry assembly did not land"); - Assert.Null(LandedFile(AssetPath)); - Assert.Null(LandedFile(NestedAssetPath)); - } - - /// The landed path of one module-relative file, or null when no generation carries it. - private string? LandedFile(string relativePath) - { - var modules = Path.Combine(root, "modules"); - if (!Directory.Exists(modules)) - return null; - foreach (var directory in Directory.EnumerateDirectories(modules)) - { - var candidate = Path.Combine(directory, relativePath.Replace('/', Path.DirectorySeparatorChar)); - if (File.Exists(candidate)) - return candidate; - } - return null; - } - - private async Task Publish(byte[] bundle) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Configuration[ModulePublish.TokenConfigKey] = Token; - // The landing seam: a real service writing under this test's own root, so the assertion - // reads the bytes the production path would have written. - builder.Services.AddSingleton(_ => new ModuleLandingService(null, root)); - - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(); - - var client = app.GetTestClient(); - var content = new ByteArrayContent(bundle); - var request = new HttpRequestMessage( - HttpMethod.Post, $"{PluginBundleEndpoints.RoutePrefix}/{Plugin}") - { - Content = content, - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); - - var response = await client.SendAsync(request); - await app.StopAsync(); - return response; - } - - /// - /// A minimal but REAL module bundle: the same writer the packer uses, so the manifest shape and - /// the entry paths are the production ones rather than this test's idea of them. - /// - private static byte[] BuildBundle(bool withAssets) - { - // 🚨 A REAL managed assembly since #3538: the landing measures the module's link - // requirements against this platform's surface, and bytes that are not an assembly at all - // are refused as INDETERMINATE — the fail-closed third state. This test is about static - // assets riding along, so it hands the landing something it can actually read. - var assembly = File.ReadAllBytes(typeof(BundleReader).Assembly.Location); - var entries = new List - { - new(NuGetPackageWriter.ModuleEntryPathFor(Module + ".dll"), - () => new MemoryStream(assembly)), - }; - if (withAssets) - { - entries.Add(new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleAssetEntryPathFor(AssetPath), - () => new MemoryStream(AssetBytes))); - entries.Add(new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleAssetEntryPathFor(NestedAssetPath), - () => new MemoryStream(NestedAssetBytes))); - } - - var manifest = new BundleReader.Manifest( - Plugin, - "1.0.0", - FrameworkMvid: "00000000000000000000000000000000", - Assemblies: null, - Module: new BundleReader.ModuleRef( - Module, - [Module + ".dll"], - MinMeshVersion: null, - StaticAssets: withAssets ? [AssetPath, NestedAssetPath] : null)); - - using var stream = new MemoryStream(); - NuGetPackageWriter.Write( - stream, - new MeshWeaver.Plugin.Packaging.PluginManifest( - Plugin, - MeshWeaver.Plugin.Packaging.PluginManifest.IdPrefix + Plugin, - "1.0.0", - "a view pack for the publish-assets guard", - MinMeshVersion: null, - Requires: ImmutableArray.Empty), - frameworkVersion: "3.0.0", - entries, - JsonSerializer.Serialize(manifest, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - })); - return stream.ToArray(); - } - - public void Dispose() - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - GC.SuppressFinalize(this); - } -} diff --git a/test/Memex.Portal.Shared.Test/PluginBundlePublishNativesTest.cs b/test/Memex.Portal.Shared.Test/PluginBundlePublishNativesTest.cs deleted file mode 100644 index c306eecf7b..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginBundlePublishNativesTest.cs +++ /dev/null @@ -1,353 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A module PUBLISHED to the registry reaches the shelf with its RID-specific NATIVE payloads laid -/// out where the module loader probes them, and the shelf RE-SERVES them (#4126, stages 2 and 3). -/// -/// 🚨 Why this needs a test of its own, exactly like -/// before it: nothing else fails when the natives are -/// dropped. Stage 1 (#4239) made the bundle CARRY them under -/// meshweaver/modulenatives/, declared in the manifest — and the publish then returned 200, -/// the entry was recorded, the module landed and loaded, and the first P/Invoke threw -/// DllNotFoundException, because the landing wrote the assemblies and the wwwroot -/// tree and nothing else. Carried, declared, ignored. -/// -/// The assertion is about BYTES ON DISK at the EXACT path ModuleNativeAssets probes — -/// modules/<generation>/runtimes/<rid>/native/<file> — because the -/// resolver composes that path from four segments and has no recursive walk: a native landed -/// anywhere else reads as shipped and behaves as absent. -/// -public class PluginBundlePublishNativesTest : IDisposable -{ - private const string Token = "publish-token-for-this-test"; - private const string Module = "MeshWeaver.Test.Sqlite"; - private const string Plugin = "TestSqlite"; - - // 🚨 TWO RIDs deliberately: one file landing is not evidence that the SET landed, and a - // partial drop would otherwise pass. They are also the two shapes a real native package emits - // (a .so and a .dylib), so a filter that happened to key on an extension fails here. - private const string NativePath = "runtimes/linux-x64/native/libe_sqlite3.so"; - private const string SecondNativePath = "runtimes/osx-arm64/native/libe_sqlite3.dylib"; - private static readonly byte[] NativeBytes = Encoding.UTF8.GetBytes("ELF-linux-x64-engine"); - private static readonly byte[] SecondNativeBytes = Encoding.UTF8.GetBytes("MACHO-osx-arm64-engine"); - - private readonly string root = Path.Combine( - Path.GetTempPath(), "mw-publish-natives-" + Guid.NewGuid().ToString("N")); - - /// - public void Dispose() - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - GC.SuppressFinalize(this); - } - - /// - /// THE defect: the publish endpoint lands the bundle's natives under the module folder, at the - /// layout the loader probes, byte-exact. - /// - [Fact] - public async Task PublishedModule_LandsItsNativePayloads_WhereTheLoaderProbes() - { - var response = await Publish(BuildBundle(withNatives: true)); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - foreach (var (relative, expected) in new[] - { - (NativePath, NativeBytes), - (SecondNativePath, SecondNativeBytes), - }) - { - var landed = LandedFile(relative); - Assert.True(landed is not null, - $"the published bundle declared native '{relative}' and no landed module directory " - + $"under '{root}' carries it — the shelf dropped the module's engine, so every " - + "consumer downstream gets a module that lands, loads, and throws " - + "DllNotFoundException at its first P/Invoke (#4126)"); - Assert.Equal(expected, File.ReadAllBytes(landed!)); - } - } - - /// - /// The entry assembly still lands — the assertion above must be evidence about NATIVES, not - /// about a publish path that happens to write everything or nothing. - /// - [Fact] - public async Task PublishedModule_LandsItsEntryAssembly() - { - var response = await Publish(BuildBundle(withNatives: true)); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.True(LandedFile(Module + ".dll") is not null, - "the entry assembly did not land — this test's own premise is broken, so its native " - + "assertion would prove nothing"); - } - - /// - /// Stage 3: what the shelf HOLDS is what the registry RE-SERVES. A consumer never reads the - /// upload's archive — it fetches what the serve side collects — so a native on the shelf that - /// the collect drops is unreachable for every instance downstream. - /// - [Fact] - public async Task ShelvedNatives_AreVisibleToTheServeSide() - { - var response = await Publish(BuildBundle(withNatives: true)); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var activation = ModuleActivationSidecar.Read(root); - var (files, _, decline) = ModuleBundleSource.Collect(root, Module, activation); - Assert.Null(decline); - Assert.NotEmpty(files); - - var natives = ModuleBundleSource.NativeAssetsOf(root, Module, activation, version: null); - Assert.Equal( - [NativePath, SecondNativePath], - natives.Select(n => n.RelativePath).OrderBy(p => p, StringComparer.Ordinal).ToArray()); - Assert.Equal( - NativeBytes, - File.ReadAllBytes(natives.Single(n => n.RelativePath == NativePath).FullPath)); - - // 🚨 The control that keeps the section SEPARATE: the flat closure is not where the - // natives went. Every consumer of meshweaver/modules/ filters to entries with no '/' in - // the remainder, so a native listed there would be silently skipped rather than laid out — - // which is precisely why the bundle gained a third folder instead of reusing that one. - Assert.DoesNotContain(files, f => - f.EndsWith(".so", StringComparison.Ordinal) || f.EndsWith(".dylib", StringComparison.Ordinal)); - } - - /// - /// The generation is CONTENT-ADDRESSED (#3656), and the natives are part of that content: two - /// bundles differing ONLY in a native payload must land TWO generations. Hashing the - /// assemblies alone would resolve both to one directory, and the second landing would adopt - /// the first's engine while its activation entry claimed its own. - /// - [Fact] - public void TwoLandingsDifferingOnlyInANative_AddressDifferentGenerations() - { - var assembly = File.ReadAllBytes(typeof(BundleReader).Assembly.Location); - - var first = ModuleLandingService.GenerationIdOf( - [(Module + ".dll", assembly)], null, [(NativePath, NativeBytes)]); - var second = ModuleLandingService.GenerationIdOf( - [(Module + ".dll", assembly)], null, - [(NativePath, Encoding.UTF8.GetBytes("ELF-linux-x64-engine-PATCHED"))]); - var none = ModuleLandingService.GenerationIdOf([(Module + ".dll", assembly)], null); - - Assert.NotEqual(first, second); - Assert.NotEqual(first, none); - } - - /// - /// A bundle that declares NO natives lands its assembly and writes no runtimes tree — - /// the layout is driven by what the producer packed, never fabricated by the shelf. - /// - [Fact] - public async Task PublishedModuleWithoutNatives_LandsNoRuntimesTree() - { - var response = await Publish(BuildBundle(withNatives: false)); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.True(LandedFile(Module + ".dll") is not null, "the entry assembly did not land"); - Assert.Null(LandedFile(NativePath)); - Assert.Empty(ModuleBundleSource.NativeAssetsOf( - root, Module, ModuleActivationSidecar.Read(root), version: null)); - } - - /// - /// A native declared at a layout the loader does NOT probe is REFUSED at the registry door, - /// never landed — and named as a 400, not a 500. Bytes at a path nothing looks at read as - /// shipped and behave as absent, which is the one outcome the section exists to end. - /// - [Fact] - public async Task ANativeDeclaredOutsideTheProbedLayout_IsRefusedNotLanded() - { - var response = await Publish(BuildBundle( - withNatives: true, nativePath: "runtimes/linux-x64/other/native/libe_sqlite3.so")); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Null(LandedFile(Module + ".dll")); - } - - /// The landed path of one module-relative file, or null when no generation carries it. - private string? LandedFile(string relativePath) - { - var modules = Path.Combine(root, "modules"); - if (!Directory.Exists(modules)) - return null; - foreach (var directory in Directory.EnumerateDirectories(modules)) - { - var candidate = Path.Combine(directory, relativePath.Replace('/', Path.DirectorySeparatorChar)); - if (File.Exists(candidate)) - return candidate; - } - return null; - } - - /// - /// #5501 — the endpoint spools the upload instead of buffering it, so a CHUNKED upload (no - /// Content-Length, the shape a streaming publisher sends) must land exactly as a sized one - /// does: natives and all, byte-exact. - /// - [Fact] - public async Task AChunkedPublish_WithNoContentLength_LandsTheSameBundle() - { - var response = await Publish(BuildBundle(withNatives: true), chunked: true); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.True(LandedFile(Module + ".dll") is not null, "the entry assembly did not land"); - Assert.Equal(NativeBytes, File.ReadAllBytes(LandedFile(NativePath)!)); - } - - /// - /// #5501 — the spool is a FILE that holds the body byte-exact, is readable from its start, and is - /// gone once the request disposes it. A MemoryStream here would be the defect back: the whole - /// bundle contiguous on the large-object heap, grown by doubling and then copied out. - /// - [Fact] - public async Task TheUploadSpool_IsAFileThatHoldsTheBodyAndDeletesItself() - { - var body = BuildBundle(withNatives: true); - string spoolPath; - await using (var spool = await PluginBundleEndpoints.SpoolUploadAsync( - new NonSeekableStream(body), CancellationToken.None)) - { - spoolPath = spool.Name; - Assert.True(File.Exists(spoolPath), "the upload was not spooled to a file"); - Assert.Equal(0, spool.Position); - Assert.Equal(body.Length, spool.Length); - using var copy = new MemoryStream(); - await spool.CopyToAsync(copy); - Assert.Equal(body, copy.ToArray()); - } - Assert.False(File.Exists(spoolPath), "the spool outlived the request that owned it"); - } - - /// A body that cannot seek or report a length — what Kestrel hands a chunked upload. - private sealed class NonSeekableStream(byte[] bytes) : Stream - { - private readonly MemoryStream inner = new(bytes, writable: false); - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => throw new NotSupportedException(); - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); - public override void Flush() { } - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - } - - private async Task Publish(byte[] bundle, bool chunked = false) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Configuration[ModulePublish.TokenConfigKey] = Token; - // The landing seam: a real service writing under this test's own root, so the assertion - // reads the bytes the production path would have written. - builder.Services.AddSingleton(_ => new ModuleLandingService(null, root)); - - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(); - - var client = app.GetTestClient(); - var request = new HttpRequestMessage( - HttpMethod.Post, $"{PluginBundleEndpoints.RoutePrefix}/{Plugin}") - { - Content = chunked - ? new StreamContent(new NonSeekableStream(bundle)) - : new ByteArrayContent(bundle), - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); - - var response = await client.SendAsync(request); - await app.StopAsync(); - return response; - } - - /// - /// A minimal but REAL module bundle: the same writer the packer uses and the same manifest - /// record the reader deserializes, so the entry paths and the declaration shape are the - /// production ones rather than this test's idea of them. - /// - private static byte[] BuildBundle(bool withNatives, string nativePath = NativePath) - { - // A REAL managed assembly (#3538): the landing MEASURES the module's link requirements - // against this platform's surface, and bytes that are not an assembly at all are refused - // as INDETERMINATE — the fail-closed third state. - var assembly = File.ReadAllBytes(typeof(BundleReader).Assembly.Location); - var entries = new List - { - new(NuGetPackageWriter.ModuleEntryPathFor(Module + ".dll"), - () => new MemoryStream(assembly)), - }; - if (withNatives) - { - entries.Add(new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleNativeEntryPathFor(nativePath), - () => new MemoryStream(NativeBytes))); - entries.Add(new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleNativeEntryPathFor(SecondNativePath), - () => new MemoryStream(SecondNativeBytes))); - } - - var manifest = new BundleReader.Manifest( - Plugin, - "1.0.0", - FrameworkMvid: "00000000000000000000000000000000", - Assemblies: null, - Module: new BundleReader.ModuleRef( - Module, - [Module + ".dll"], - MinMeshVersion: null, - StaticAssets: null) - { - NativeAssets = withNatives ? [nativePath, SecondNativePath] : null, - }); - - using var stream = new MemoryStream(); - NuGetPackageWriter.Write( - stream, - new MeshWeaver.Plugin.Packaging.PluginManifest( - Plugin, - MeshWeaver.Plugin.Packaging.PluginManifest.IdPrefix + Plugin, - "1.0.0", - "a module that brings its own engine, for the publish-natives guard", - MinMeshVersion: null, - Requires: ImmutableArray.Empty), - frameworkVersion: "3.0.0", - entries, - JsonSerializer.Serialize(manifest, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - })); - return stream.ToArray(); - } -} diff --git a/test/Memex.Portal.Shared.Test/PluginBundleSealedLaneTest.cs b/test/Memex.Portal.Shared.Test/PluginBundleSealedLaneTest.cs deleted file mode 100644 index d3706c869b..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginBundleSealedLaneTest.cs +++ /dev/null @@ -1,439 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A registry serves the lane the CALLER asked for, when it holds a publication sealed for -/// it — and its OWN lane when it does not (MeshWeaver#3768). -/// -/// The defect: the index stamped frameworkMvid with the registry's own bake -/// unconditionally, and PluginBundleClient.Adopt compares that ONCE and declines the whole -/// index before requesting any package. So the download route's lane-awareness (#1751) and the -/// module half's sealed read (#3244) were both unreachable for exactly the consumer they exist for. -/// Measured on the fleet's own portals twice, with a different identity pair each time — 25 -/// adoption attempts, 0 adopted, 25 FrameworkDeclined — while the publication sealed for the -/// consumer's identity sat on the share the registry mounts. -/// -/// Both directions are asserted here, because a change that serves everybody passes a -/// one-sided test. A caller whose lane IS sealed here must be told so and handed THOSE bytes; a -/// caller whose lane is NOT must still be told this registry's own identity — which is what keeps -/// it declining and compiling — and must never be handed another identity's bundle in its place. -/// The adoption gate itself (, ordinal equality) -/// is untouched by the change and asserted here as the thing that still holds. -/// -public class PluginBundleSealedLaneTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// The lane the CONSUMER runs — not this test host's, which is what makes it "off - /// lane". A fixed literal: the value must not be whatever the process happens to resolve. - private const string ConsumerIdentity = "s11111111111111111111111111111111"; - - /// A third lane nothing is sealed for — the negative control's ask. - private const string UnsealedIdentity = "s22222222222222222222222222222222"; - - private const string Package = "SealedLanePackage"; - private const string NodeTypePath = Package + "/Widget"; - private const string Source = "plugins"; - private const string Version = "3.2.1"; - private const string Instance = "sealed-lane-consumer"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - /// - /// POSITIVE CONTROL — the caller's lane IS sealed here, so the index claims it and the bundle - /// carries the sealed publication's own bytes, stamped with the caller's identity. - /// - [Fact(Timeout = 120_000)] - public async Task ACallerWhoseLaneIsSealedHere_IsToldThatLane_AndServedThePublicationsBytes() - { - var root = NewRoot(); - try - { - SealPublication(root, ConsumerIdentity); - await InstallPackage(TestContext.Current.CancellationToken); - var key = await RegisterInstance($"{Source}/*"); - await using var app = await StartHost(root, TestContext.Current.CancellationToken); - - var index = await ReadIndex(app, key, ConsumerIdentity); - - Assert.Equal(ConsumerIdentity, index.GetProperty("frameworkMvid").GetString()); - Assert.Equal( - ReleaseArchitecture.Live, index.GetProperty("architecture").GetString()); - - var url = BundleUrl(index); - Assert.NotNull(url); - - using var response = await Get(app, url! + LaneQuery(ConsumerIdentity), key); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var (manifest, assemblies) = BundleReader.Read( - await response.Content.ReadAsByteArrayAsync()); - - Assert.Equal(ConsumerIdentity, manifest?.FrameworkMvid); - Assert.Equal(NodeTypePath, Assert.Single(assemblies).NodePath); - // The producer's source fingerprint rides through, so the consuming hub can still - // refuse an adoption whose source disagrees with the source that mesh holds (#2813). - Assert.Equal("fp-sealed", Assert.Single(assemblies).SourceFingerprint); - } - finally - { - Delete(root); - } - } - - /// - /// NEGATIVE CONTROL — nothing is sealed for the caller's lane, so the index keeps stating THIS - /// registry's own identity. That is what makes the consumer decline and compile, and it is the - /// assertion a fix that simply echoed the request back would fail. - /// - /// The second half matters as much: a publication sealed for a DIFFERENT identity is - /// present on the same root, and it must not be handed over in place of the lane that was - /// asked for. Serving bytes baked for another framework is the outage this gate exists to - /// prevent. - /// - [Fact(Timeout = 120_000)] - public async Task ACallerWhoseLaneIsNotSealedHere_IsToldThisRegistrysOwnLane_AndGetsNoOtherLanesBytes() - { - var root = NewRoot(); - try - { - SealPublication(root, ConsumerIdentity); // a lane that is NOT the one asked for - await InstallPackage(TestContext.Current.CancellationToken); - var key = await RegisterInstance($"{Source}/*"); - await using var app = await StartHost(root, TestContext.Current.CancellationToken); - - var index = await ReadIndex(app, key, UnsealedIdentity); - - Assert.Equal( - PrebuiltAssemblySeeder.LiveFrameworkMvid, - index.GetProperty("frameworkMvid").GetString()); - Assert.NotEqual(UnsealedIdentity, index.GetProperty("frameworkMvid").GetString()); - - // …and the consumer's unchanged gate turns exactly that into a decline naming both. - var decline = PrebuiltAssemblySeeder.DeclineReason( - index.GetProperty("frameworkMvid").GetString(), UnsealedIdentity); - Assert.NotNull(decline); - Assert.Contains(UnsealedIdentity, decline); - - var url = BundleUrl(index); - Assert.NotNull(url); - - using var response = await Get(app, url! + LaneQuery(UnsealedIdentity), key); - if (response.StatusCode == HttpStatusCode.OK) - { - var (manifest, assemblies) = BundleReader.Read( - await response.Content.ReadAsByteArrayAsync()); - Assert.NotEqual(ConsumerIdentity, manifest?.FrameworkMvid); - Assert.Empty(assemblies); - } - else - { - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - } - finally - { - Delete(root); - } - } - - /// - /// A caller that states NO lane is answered exactly as before — the shape every already - /// deployed consumer asks in, which must not move when a newer one starts stating its lane. - /// - [Fact(Timeout = 120_000)] - public async Task ACallerThatStatesNoLane_IsAnsweredWithThisRegistrysOwnIdentity() - { - var root = NewRoot(); - try - { - SealPublication(root, ConsumerIdentity); - await InstallPackage(TestContext.Current.CancellationToken); - var key = await RegisterInstance($"{Source}/*"); - await using var app = await StartHost(root, TestContext.Current.CancellationToken); - - var index = await ReadIndex(app, key, identity: null); - - Assert.Equal( - PrebuiltAssemblySeeder.LiveFrameworkMvid, - index.GetProperty("frameworkMvid").GetString()); - Assert.Equal( - ReleaseArchitecture.Live, index.GetProperty("architecture").GetString()); - } - finally - { - Delete(root); - } - } - - /// - /// 🚨 The stated identity composes a PATH under the mounted publication root, so a request that - /// is not a bare name is refused outright rather than resolved. Without this, an identity like - /// ../.. reaches outside the lane it names — a traversal the route did not have while - /// the identity was only ever string-compared. - /// - [Theory(Timeout = 120_000)] - [InlineData("../../etc")] - [InlineData("..")] - [InlineData("a/b")] - [InlineData("c:evil")] - public async Task AnIdentityThatIsNotABareNameIsRefused_OnBothRoutes(string identity) - { - var root = NewRoot(); - try - { - SealPublication(root, ConsumerIdentity); - await InstallPackage(TestContext.Current.CancellationToken); - var key = await RegisterInstance($"{Source}/*"); - await using var app = await StartHost(root, TestContext.Current.CancellationToken); - - var lane = $"?identity={Uri.EscapeDataString(identity)}" - + $"&arch={Uri.EscapeDataString(ReleaseArchitecture.Live)}"; - - using var index = await Get( - app, PluginBundleEndpoints.RoutePrefix + "/index.json" + lane, key); - Assert.Equal(HttpStatusCode.BadRequest, index.StatusCode); - - using var bundle = await Get( - app, $"{PluginBundleEndpoints.RoutePrefix}/{Package}/{Version}{lane}", key); - Assert.Equal(HttpStatusCode.BadRequest, bundle.StatusCode); - } - finally - { - Delete(root); - } - } - - /// - /// 🚨 The lookup may not cross the (source, package) pair the entitlement decision resolved. - /// Two sources seal a bundle of the SAME package id under the same identity; the one this - /// caller is bound to must be the one served, and the other must not become reachable by being - /// alphabetically first. - /// - [Fact(Timeout = 120_000)] - public async Task TheBundleComesFromTheSourceTheEntitlementResolved_NotWhicheverSortsFirst() - { - var root = NewRoot(); - try - { - // "education" sorts BEFORE "plugins", so a lookup that scanned every source and took - // the first match would serve this one. - SealPublication( - root, ConsumerIdentity, source: "education", - nodeTypePath: Package + "/FromTheOtherSource", fingerprint: "fp-other"); - SealPublication(root, ConsumerIdentity); // the granted source, "plugins" - await InstallPackage(TestContext.Current.CancellationToken); // its install record says Source = plugins - var key = await RegisterInstance($"{Source}/*"); - await using var app = await StartHost(root, TestContext.Current.CancellationToken); - - var index = await ReadIndex(app, key, ConsumerIdentity); - var url = BundleUrl(index); - Assert.NotNull(url); - - using var response = await Get(app, url! + LaneQuery(ConsumerIdentity), key); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var (_, assemblies) = BundleReader.Read(await response.Content.ReadAsByteArrayAsync()); - var served = Assert.Single(assemblies); - Assert.Equal(NodeTypePath, served.NodePath); - Assert.Equal("fp-sealed", served.SourceFingerprint); - } - finally - { - Delete(root); - } - } - - /// - /// 🚨 THE safety property, asserted where this change could have broken it. The directory a - /// bundle is filed under is a convention; its manifest is the producer's claim. A bundle whose - /// own manifest names a DIFFERENT framework must never be served as the requested lane — if it - /// were, the outgoing manifest would be restamped with the requested identity and the - /// consumer's gate would see bytes that agree with its live framework and adopt them. - /// - [Fact(Timeout = 120_000)] - public async Task ABundleFiledUnderALaneButBuiltForAnotherIsRefused_NeverRestamped() - { - var root = NewRoot(); - try - { - SealPublication( - root, ConsumerIdentity, - manifestIdentity: "s99999999999999999999999999999999"); // mislabelled - await InstallPackage(TestContext.Current.CancellationToken); - var key = await RegisterInstance($"{Source}/*"); - await using var app = await StartHost(root, TestContext.Current.CancellationToken); - - var index = await ReadIndex(app, key, ConsumerIdentity); - var url = BundleUrl(index); - Assert.NotNull(url); - - using var response = await Get(app, url! + LaneQuery(ConsumerIdentity), key); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - finally - { - Delete(root); - } - } - - // ───────────────────────────── harness ───────────────────────────── - - private static string NewRoot() => - Path.Combine(Path.GetTempPath(), "mw3768-" + Guid.NewGuid().ToString("N")); - - private static void Delete(string root) - { - try - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - catch (IOException) - { - // A temp tree that will not delete must never fail the assertion that already passed. - } - } - - /// Writes a complete publication — one real bundle plus the seal that lists it, in the - /// layout <root>/<identity>/<source>/ the bake produces. - /// What the BUNDLE's own manifest claims it was built against, - /// when that must differ from the directory it is filed under — the mislabelled case. - private static void SealPublication( - string root, string identity, string source = Source, - string nodeTypePath = NodeTypePath, string fingerprint = "fp-sealed", - string? manifestIdentity = null) - { - var directory = Path.Combine(root, identity, source); - Directory.CreateDirectory(directory); - var bundle = Path.Combine(directory, $"{Package}.zip"); - using (var file = File.Create(bundle)) - BundleWriter.Write( - file, Package, Version, manifestIdentity ?? identity, - [ - new BundleWriter.AssemblyEntry( - nodeTypePath, - () => new MemoryStream(AssemblyBytes()), - Dependencies: new Dictionary(StringComparer.Ordinal)) - { - SourceFingerprint = fingerprint, - }, - ]); - File.WriteAllText( - Path.Combine(directory, ShippedPrebuiltBundles.CompletionSentinelFileName), - $"{Package}.zip\n"); - } - - /// Real PE bytes — a bundle carrying something that is not an assembly would be a - /// weaker subject than the one this route actually serves. - private static byte[] AssemblyBytes() => - File.ReadAllBytes(typeof(BundleWriter).Assembly.Location); - - private Task InstallPackage(CancellationToken cancellationToken) => - PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = Package, - Name = Package, - Kind = PackageKind.Content, - TargetPartition = Package, - SourceFolder = Package, - Version = "1.0.0", - ReleasedVersion = Version, - Source = Source, - }, - [new PackageFile($"{Package}/Doc.md", $"# {Package}")], - "HEAD") - .FirstAsync() - .Timeout(TestTimeouts.CrossSilo) - .Await(cancellationToken); - - private Task RegisterInstance(params string[] defaultGrants) => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection(defaultGrants.Select((entry, i) => - new KeyValuePair( - $"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:{i}", entry))) - .Build()) - .Register("sealed-lane-owner", "Owner", "owner@test.com", Instance, Instance) - .Select(r => r.RawKey) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - - private async Task StartHost(string publishedRoot, CancellationToken cancellationToken) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [PublishedBundleCatalogue.PublishedRootConfigKey] = publishedRoot, - }); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(new InstanceRegistryAuthenticator( - Mesh, Mesh.ServiceProvider.GetRequiredService>())); - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(cancellationToken); - return app; - } - - private static string LaneQuery(string identity) => - $"?identity={Uri.EscapeDataString(identity)}" - + $"&arch={Uri.EscapeDataString(ReleaseArchitecture.Live)}"; - - private static async Task ReadIndex( - WebApplication app, string key, string? identity) - { - var route = PluginBundleEndpoints.RoutePrefix + "/index.json" - + (identity is null ? "" : LaneQuery(identity)); - using var response = await Get(app, route, key); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone(); - } - - /// The relative bundle route the index advertises for the package under test. - private static string? BundleUrl(JsonElement index) => - index.GetProperty("bundles").EnumerateArray() - .Where(b => string.Equals( - b.GetProperty("plugin").GetString(), Package, StringComparison.OrdinalIgnoreCase)) - .Select(b => new Uri(b.GetProperty("url").GetString()!).PathAndQuery) - .FirstOrDefault(); - - private static async Task Get( - WebApplication app, string route, string key) - { - using var request = new HttpRequestMessage(HttpMethod.Get, route); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - return await app.GetTestClient().SendAsync(request); - } -} diff --git a/test/Memex.Portal.Shared.Test/PluginBundleStalledReadTest.cs b/test/Memex.Portal.Shared.Test/PluginBundleStalledReadTest.cs deleted file mode 100644 index 5e72892fe8..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginBundleStalledReadTest.cs +++ /dev/null @@ -1,258 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A bundle route whose catalogue read STALLS answers 503 + Retry-After — never an -/// unhandled 500 (MeshWeaver#5345). -/// -/// Both bundle routes read the installed-package catalogue -/// (namespace:Plugins nodeType:Package) through the query fan-in. When a provider never -/// delivers its Initial, the fan-in terminates the read with -/// (policy query-fanin-stall-terminal) — an -/// availability failure that states, in its own text, that it is retryable. The routes let it -/// escape, so ASP.NET's exception middleware answered a bare 500 and logged the stall as an -/// unhandled exception of the ENDPOINT: measured on memex-cloud 2026-09-22 14:43:42Z, pod -/// 5c444645f8-8pdzb, two occurrences, inside the same minute as 88 compile-watcher stalls on -/// that pod. -/// -/// The provider here is a test PROVIDER, not a mock of a core service — the -/// extension point every backend plugs into, the same seam -/// QueryFanInStallIsTerminalTest and ColdEmptyInitialIsNotCachedTest drive. It -/// claims ONLY the catalogue query, and only while -/// is set, so the instance-key authentication in front of the routes reads the real mesh. The stall -/// fires at the fan-in's own production rung (); -/// nothing here shortens it. -/// -public class PluginBundleStalledReadTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Source = "Plugins"; - private const string Instance = "stalled-read-consumer"; - - private readonly StallingCatalogueProvider provider = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services.AddSingleton(provider)); - - private Task RegisterInstance() => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection( - [ - new KeyValuePair( - $"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:0", $"{Source}/*"), - ]) - .Build()) - .Register("stalled-owner", "Stalled Owner", "owner@test.com", Instance, Instance) - .Select(r => r.RawKey) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(60)) - .Await(TestContext.Current.CancellationToken); - - private async Task StartBundleHost(CancellationToken cancellationToken) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(new InstanceRegistryAuthenticator( - Mesh, Mesh.ServiceProvider.GetRequiredService>())); - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(cancellationToken); - return app; - } - - private static async Task Get(WebApplication app, string key, string route) - { - using var request = new HttpRequestMessage(HttpMethod.Get, PluginBundleEndpoints.RoutePrefix + route); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - return await app.GetTestClient().SendAsync(request); - } - - /// - /// THE CHANGE, on both routes that read the catalogue: the stall arrives as 503 with - /// Retry-After. Unfixed, the exception escapes the handler and the TestServer rethrows it - /// (or answers 500) — either way no 503. - /// - [Theory(Timeout = 300_000)] - [InlineData("/index.json")] - [InlineData("/Plugins.Anything/1.0.0")] - public async Task AStalledCatalogueRead_Answers503WithRetryAfter(string route) - { - var ct = TestContext.Current.CancellationToken; - var key = await RegisterInstance(); - var app = await StartBundleHost(ct); - await using var _ = app; - - provider.Stalling = true; - using var response = await Get(app, key, route); - - response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable, - "the catalogue read produced no answer — an availability failure that says it is " - + "retryable — so the wire must say 'ask again later', not report an unhandled 500"); - response.Headers.RetryAfter.Should().NotBeNull("a 503 without Retry-After gives the consumer no schedule"); - response.Headers.RetryAfter!.Delta.Should().Be(TimeSpan.FromSeconds(InstanceRegistryAuthenticator.RetryAfterSeconds), - "the bundle routes use the same retry convention as the instance-key 503"); - provider.StalledReads.Should().BeGreaterThan(0, - "the stall must actually have been the catalogue read — otherwise this test measured " - + "something else"); - } - - /// - /// THE CONTROL: the same host, the same provider answering — the index is 200. Without this arm a - /// route that answered 503 unconditionally would pass the case above. - /// - [Fact(Timeout = 300_000)] - public async Task AnAnsweredCatalogueRead_StillAnswers200() - { - var ct = TestContext.Current.CancellationToken; - var key = await RegisterInstance(); - var app = await StartBundleHost(ct); - await using var _ = app; - - provider.Stalling = false; - using var response = await Get(app, key, "/index.json"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); - json.RootElement.TryGetProperty("bundles", out var bundles).Should().BeTrue(); - bundles.ValueKind.Should().Be(JsonValueKind.Array); - } - - /// - /// THE OTHER SIDE of the classification: an ordinary DEFECT on the same read is NOT dressed up as - /// "come back later". It still escapes the route, so a real endpoint defect stays visible, and - /// only an availability fault becomes 503. Without this arm a catch widened to every exception - /// would pass the stall case above while hiding real defects behind a retry. - /// - [Fact(Timeout = 300_000)] - public async Task AnOrdinaryFault_OnTheCatalogueRead_IsNotConvertedTo503() - { - var ct = TestContext.Current.CancellationToken; - var key = await RegisterInstance(); - var app = await StartBundleHost(ct); - await using var _ = app; - - provider.Faulting = true; - HttpStatusCode? status = null; - Exception? escaped = null; - try - { - using var response = await Get(app, key, "/index.json"); - status = response.StatusCode; - } - catch (Exception ex) - { - // TestServer rethrows an unhandled pipeline exception to the client by default — that - // IS the escape this case asserts. - escaped = ex; - } - - (escaped is not null || status == HttpStatusCode.InternalServerError).Should().BeTrue( - $"a defect must still escape as one (status {status?.ToString() ?? "none"}, " - + $"exception {escaped?.GetType().Name ?? "none"})"); - status.Should().NotBe(HttpStatusCode.ServiceUnavailable, - "only an availability fault may be answered 'retry later'"); - if (escaped is not null) - escaped.ToString().Should().Contain(DefectMessage, - "the exception that escaped must be the catalogue read's own defect, not something else"); - } - - private const string DefectMessage = "catalogue read defect (test)"; - - /// - /// Claims every query (so it is in every fan-in) and answers each with an empty Initial — except - /// the installed-package catalogue query while is set, which it subscribes - /// and never answers: no Initial, no completion, no error. That is the production shape the - /// fan-in's stall terminal exists for. - /// - private sealed class StallingCatalogueProvider : IMeshQueryProvider - { - private int stalling; - private int stalledReads; - - public string Name => nameof(StallingCatalogueProvider); - - public bool Stalling - { - get => Volatile.Read(ref stalling) == 1; - set => Interlocked.Exchange(ref stalling, value ? 1 : 0); - } - - private int faulting; - - /// When set, the catalogue read FAULTS with an ordinary defect - /// () instead of stalling. - public bool Faulting - { - get => Volatile.Read(ref faulting) == 1; - set => Interlocked.Exchange(ref faulting, value ? 1 : 0); - } - - /// How many catalogue reads were left unanswered. - public int StalledReads => Volatile.Read(ref stalledReads); - - public bool Matches(IReadOnlyList queryNamespaces) => true; - - public IObservable> Query( - MeshQueryRequest request, JsonSerializerOptions options) => - Observable.Defer(() => - { - var catalogue = request.EffectiveQueries.Any(q => - q.Contains($"nodeType:{PackageInstaller.PackageNodeType}", StringComparison.Ordinal)); - if (catalogue && Faulting) - return Observable.Throw>( - new InvalidOperationException(DefectMessage)); - if (!catalogue || !Stalling) - return Observable.Return(new QueryResultChange - { - ChangeType = QueryChangeType.Initial, - Items = Array.Empty(), - Timestamp = DateTimeOffset.UtcNow, - }); - Interlocked.Increment(ref stalledReads); - return Observable.Never>(); - }); - - public IObservable> Query( - MeshQueryRequest request, JsonSerializerOptions options) - => Observable.Return((IReadOnlyCollection)Array.Empty()); - - public IObservable> Autocomplete( - string basePath, string prefix, JsonSerializerOptions options, - AutocompleteMode mode = AutocompleteMode.RelevanceFirst, int limit = 10, - string? contextPath = null, string? context = null) - => Observable.Return((IReadOnlyCollection)Array.Empty()); - - public IObservable Select(string path, string property, JsonSerializerOptions options) - => Observable.Return(default); - } -} diff --git a/test/Memex.Portal.Shared.Test/PluginCatalogStandingWatchesReopenTest.cs b/test/Memex.Portal.Shared.Test/PluginCatalogStandingWatchesReopenTest.cs deleted file mode 100644 index 54e16f93fa..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginCatalogStandingWatchesReopenTest.cs +++ /dev/null @@ -1,325 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Reactive.Assertions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Reactive.Testing; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The plugin catalog's two standing waits must survive a fault of the query they read: -/// -/// the reconciler's module-published inbox watch, which a single fault ended until the next -/// boot (broadcasts then waited for a restart); -/// the open-registration CONSENT wait, whose fault fallback answered false — the -/// waiter filtered it out, the chain completed, and the registration never ran until a restart -/// however soon after the admin consented. The same chain also never re-read a record that was -/// written incomplete and completed later. -/// -/// Each test: the first open throws a , the wait survives, -/// the re-open runs on the test's clock, and a later emission is processed. -/// -public class PluginCatalogStandingWatchesReopenTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .AddWebhookInbox(); - - private sealed class ManualReopenClock - { - private ImmutableQueue due = ImmutableQueue.Empty; - private int scheduled; - - public int Scheduled => Volatile.Read(ref scheduled); - - public IDisposable Schedule(Action reopen) - { - ImmutableInterlocked.Enqueue(ref due, reopen); - Interlocked.Increment(ref scheduled); - return Disposable.Empty; - } - - public bool FireNext() - { - if (!ImmutableInterlocked.TryDequeue(ref due, out var reopen)) - return false; - reopen(); - return true; - } - } - - private sealed class FlakySource - { - private int opens; - - public Subject Live { get; } = new(); - - public int Opens => Volatile.Read(ref opens); - - public IObservable Open() => Interlocked.Increment(ref opens) == 1 - ? Observable.Throw(new QueryProviderStalledException("test-provider", TimeSpan.FromSeconds(1), "q", "system")) - : Live; - } - - private static MeshNode NodeAt(string path, object? content) - { - var slash = path.LastIndexOf('/'); - return new MeshNode(path[(slash + 1)..], path[..slash]) { Content = content }; - } - - private static Task Eventually(Func condition, string because, CancellationToken ct) => - Observable.Interval(TimeSpan.FromMilliseconds(20)).StartWith(0L) - .Where(_ => condition()) - .Should().Within(TestTimeouts.Convergence).Emit(because, ct); - - [Fact(Timeout = 60_000)] - public async Task TheInboxWatch_ReopensAfterAFault_AndDrainsTheNextDelivery() - { - var ct = TestContext.Current.CancellationToken; - var clock = new ManualReopenClock(); - var source = new FlakySource>(); - using var reconciler = new RegistryUpdateReconciler(Mesh, - Mesh.ServiceProvider.GetRequiredService>()) - { - OpenInboxOverride = source.Open, - ScheduleInboxReopen = clock.Schedule, - }; - - using var watch = reconciler.SubscribeInboxWatch(new PluginCatalogOptions()); - await Eventually(() => clock.Scheduled == 1, "the first open faults and a re-open is scheduled", ct); - - clock.FireNext().Should().BeTrue(); - await Eventually(() => source.Live.HasObservers, "the re-opened inbox query is subscribed", ct); - source.Opens.Should().Be(2); - - var delivery = $"{ModulePublished.InboxTarget}/{WebhookInbox.InboxContainer}/after-the-fault"; - source.Live.OnNext([NodeAt(delivery, null)]); - - await Eventually(() => reconciler.HasClaimedDelivery(delivery), "a delivery after the fault is picked up", ct); - } - - private sealed class ReopenableSource - { - private ImmutableList> opened = []; - - public IReadOnlyList> Opened => opened; - - public IObservable Open() - { - var live = new Subject(); - ImmutableInterlocked.Update(ref opened, list => list.Add(live)); - return live; - } - } - - /// - /// A fault in the MIDDLE of a drain must not strand the deliveries that listing already claimed: - /// A (in flight) and B (queued behind A) are released with the dead opening, and the re-opened - /// watch drains both. Before the fix both stayed claimed and B was never drained. - /// - [Fact(Timeout = 60_000)] - public async Task TheInboxWatch_AFaultMidDrain_ReleasesTheClaimsItsListingHadNotDrained() - { - var ct = TestContext.Current.CancellationToken; - var clock = new ManualReopenClock(); - var source = new ReopenableSource>(); - var drained = new System.Collections.Concurrent.ConcurrentQueue(); - var inbox = $"{ModulePublished.InboxTarget}/{WebhookInbox.InboxContainer}"; - string a = $"{inbox}/a", b = $"{inbox}/b"; - var firstOpening = 1; - using var reconciler = new RegistryUpdateReconciler(Mesh, - Mesh.ServiceProvider.GetRequiredService>()) - { - OpenInboxOverride = source.Open, - ScheduleInboxReopen = clock.Schedule, - DrainOverride = node => - { - drained.Enqueue(node.Path); - // In the first opening A's drain never finishes, so B waits behind it. - return Volatile.Read(ref firstOpening) == 1 && node.Path == a - ? Observable.Never() - : Observable.Return(System.Reactive.Unit.Default); - }, - }; - - using var watch = reconciler.SubscribeInboxWatch(new PluginCatalogOptions()); - await Eventually(() => source.Opened.Count == 1 && source.Opened[0].HasObservers, "the inbox watch opens", ct); - source.Opened[0].OnNext([NodeAt(a, null), NodeAt(b, null)]); - await Eventually(() => drained.Count == 1, "A's drain starts; B waits behind it", ct); - - Volatile.Write(ref firstOpening, 0); - source.Opened[0].OnError(new QueryProviderStalledException("test-provider", TimeSpan.FromSeconds(1), "q", "system")); - await Eventually(() => clock.Scheduled == 1, "the fault schedules a re-open", ct); - clock.FireNext().Should().BeTrue(); - await Eventually(() => source.Opened.Count == 2 && source.Opened[1].HasObservers, "the inbox watch re-opens", ct); - source.Opened[1].OnNext([NodeAt(a, null), NodeAt(b, null)]); - - await Eventually(() => drained.Count == 3, "both claims of the dead opening are drained after the re-open", ct); - drained.Should().Equal([a, a, b]); - } - - /// - /// The consent record's node stream can lag the query: the one query emission arrives while the - /// node still holds the INCOMPLETE record, and the completed record arrives on the node stream - /// afterwards, with no further query emission. Registration must still run. - /// - [Fact] - public void TheConsentWait_RegistersWhenTheRecordCompletesOnTheNodeStreamAlone() - { - var path = MeshWeaverInstanceNodeType.ConsentPath; - var rows = new Subject>(); - var content = new BehaviorSubject(NodeAt(path, new InstanceConsent { InstanceId = "acme" })); - using var service = new InstanceAutoRegistrationService(Mesh, - Mesh.ServiceProvider.GetRequiredService>()) - { - ConsentRowsOverride = () => rows, - ConsentContentOverride = () => content, - ConsentReopenScheduler = new TestScheduler(), - }; - var given = new List(); - Exception? fault = null; - - using var _ = service.ConsentGiven(wait: true).Where(g => g).Take(1) - .Subscribe(given.Add, ex => fault = ex); - - rows.OnNext([NodeAt(path, null)]); - given.Should().BeEmpty("the node stream still holds the incomplete record"); - - content.OnNext(NodeAt(path, new InstanceConsent - { - InstanceId = "acme", - PrivacyStatementHash = "p", - TermsHash = "t", - AcceptedByUserId = "admin", - AcceptedAt = DateTimeOffset.UnixEpoch, - })); - - given.Should().Equal(true); - fault.Should().BeNull(); - } - - /// - /// The reconciler's safety net is a standing LOOP (Timer → pass → Repeat), and - /// .Repeat() re-subscribes on completion only: one pass that faulted outside the - /// per-registry catch ended every later scheduled pass in the process until a restart. A faulted - /// pass must end the TICK — the next interval runs a fresh pass. - /// - [Fact] - public void TheSafetyNet_SurvivesAFaultedPass_AndTheNextIntervalRunsAgain() - { - var scheduler = new TestScheduler(); - var options = new PluginCatalogOptions { ReconcileSafetyNetInterval = TimeSpan.FromMinutes(30) }; - var passes = 0; - using var reconciler = new RegistryUpdateReconciler(Mesh, - Mesh.ServiceProvider.GetRequiredService>()) - { - SafetyNetPassOverride = () => ++passes == 1 - ? Observable.Throw( - new InvalidOperationException("the registry list could not be composed")) - : Observable.Return(System.Reactive.Unit.Default), - SafetyNetScheduler = scheduler, - }; - Exception? fault = null; - var ticks = 0; - - using var _ = reconciler.SafetyNet(options).Subscribe(_ => ticks++, ex => fault = ex); - - scheduler.AdvanceBy(options.ReconcileSafetyNetInterval.Ticks); - passes.Should().Be(1); - fault.Should().BeNull("a faulted pass ends its tick, never the loop"); - - scheduler.AdvanceBy(options.ReconcileSafetyNetInterval.Ticks); - passes.Should().Be(2, "the next interval runs a fresh pass"); - scheduler.AdvanceBy(options.ReconcileSafetyNetInterval.Ticks); - passes.Should().Be(3); - ticks.Should().Be(3); - fault.Should().BeNull(); - } - - /// Negative control for the safety net: the pre-fix loop shape (Timer → pass → - /// Repeat with no per-tick catch), driven by the same faulting pass, dies on the first tick. - [Fact] - public void TheUnguardedSafetyNetLoop_DiesOnItsFirstFaultedPass() - { - var scheduler = new TestScheduler(); - var interval = TimeSpan.FromMinutes(30); - var passes = 0; - Exception? fault = null; - - // The pre-fix loop re-subscribed on COMPLETION only. RepeatWhen over a zero-delay virtual - // timer has exactly those semantics (each tick's own Timer already carries the interval) — - // a completion re-subscribes, an error ends the loop. - using var _ = Observable.Defer(() => Observable.Timer(interval, scheduler)) - .SelectMany(_ => ++passes == 1 - ? Observable.Throw(new InvalidOperationException("x")) - : Observable.Return(System.Reactive.Unit.Default)) - .RepeatWhen(completions => completions.SelectMany(_ => Observable.Timer(TimeSpan.Zero, scheduler))) - .Subscribe(_ => { }, ex => fault = ex); - - scheduler.AdvanceBy(interval.Ticks * 3); - fault.Should().NotBeNull(); - passes.Should().Be(1, "the loop re-subscribes on completion only — no later pass ever runs"); - } - - [Fact] - public void TheConsentWait_ReopensAfterAFault_AndRegistersOnceConsentIsComplete() - { - var scheduler = new TestScheduler(); - var rows = new FlakySource>(); - var consent = new InstanceConsent { InstanceId = "acme" }; // written incomplete first - var path = MeshWeaverInstanceNodeType.ConsentPath; - using var service = new InstanceAutoRegistrationService(Mesh, - Mesh.ServiceProvider.GetRequiredService>()) - { - ConsentRowsOverride = rows.Open, - ConsentContentOverride = () => Observable.Return(NodeAt(path, consent)), - ConsentReopenScheduler = scheduler, - }; - var given = new List(); - var completed = false; - Exception? fault = null; - - using var _ = service.ConsentGiven(wait: true).Where(g => g).Take(1) - .Subscribe(given.Add, ex => fault = ex, () => completed = true); - - rows.Opens.Should().Be(1); - completed.Should().BeFalse("a fault is not an answer: the wait stays open"); - - scheduler.AdvanceBy(StandingWatchRecovery.DefaultBackoff(1).Ticks); - rows.Opens.Should().Be(2, "the wait re-opens its query on the backoff"); - - rows.Live.OnNext([NodeAt(path, consent)]); - given.Should().BeEmpty("an incomplete record gates exactly like an absent one"); - completed.Should().BeFalse("and the wait stays open for the record to be completed"); - - consent = consent with - { - PrivacyStatementHash = "p", - TermsHash = "t", - AcceptedByUserId = "admin", - AcceptedAt = DateTimeOffset.UnixEpoch, - }; - rows.Live.OnNext([NodeAt(path, consent)]); - - given.Should().Equal(true); - completed.Should().BeTrue(); - fault.Should().BeNull("no fault ever reaches the waiter"); - } -} diff --git a/test/Memex.Portal.Shared.Test/PluginSourceCredentialTest.cs b/test/Memex.Portal.Shared.Test/PluginSourceCredentialTest.cs deleted file mode 100644 index c3c3e069ff..0000000000 --- a/test/Memex.Portal.Shared.Test/PluginSourceCredentialTest.cs +++ /dev/null @@ -1,531 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A git plugin source is read with the credential IT declares — and with nothing else -/// when that credential cannot be produced. -/// -/// Before this, every URL source was read with the instance's ONE GitHub App installation -/// token. An instance whose App is installed on Systemorph could therefore not read a -/// private repository in Northwind: the token minted for one installation reads nothing -/// in another, and GitHub answers a private repository it will not show as "not found". -/// -/// Two declarations, both BY REFERENCE: PluginCatalog:Sources:N:TokenKey names the -/// configuration key the repository's token arrives under (from the vault), and -/// PluginCatalog:Sources:N:InstallationOwner names the account whose installation of the -/// App mints it. The three properties pinned here: the token is chosen PER SOURCE; a declared key -/// that is empty REFUSES by name, with no request to GitHub under any other identity; and two -/// sources on two installations each get their own token, minted once. -/// -public class PluginSourceCredentialTest -{ - private const string Plugins = "https://github.com/Systemorph/MeshWeaver.Plugins"; - private const string Extensions = "https://github.com/Northwind/extensions"; - private const string Other = "https://github.com/Fabrikam/modules"; - - // Every await is bounded: a wedged token stream must surface as a timeout, never a stuck run. - private static readonly TimeSpan Budget = TimeSpan.FromSeconds(10); - - [Fact] - public async Task TheTokenIsChosenPerSource_ByTheRepositoryItDeclaresItFor() - { - using var rsa = RSA.Create(2048); - var handler = new InstallationsHandler(); - var credentials = new GitSourceCredentials( - Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Plugins", - ["PluginCatalog:Sources:0:RepoPath"] = Plugins, - ["PluginCatalog:Sources:1:Name"] = "Extensions", - ["PluginCatalog:Sources:1:RepoPath"] = Extensions, - ["PluginCatalog:Sources:1:TokenKey"] = "PluginCatalog:SourceTokens:Extensions", - ["PluginCatalog:Sources:2:Name"] = "Other", - ["PluginCatalog:Sources:2:RepoPath"] = Other, - ["PluginCatalog:Sources:2:InstallationOwner"] = "Fabrikam", - ["PluginCatalog:SourceTokens:Extensions"] = "github_pat_extensions", - }), - App(rsa, handler)); - - // No declaration → null: the caller keeps its default identity, exactly as before. - Assert.Null(credentials.TokenFor(Plugins)); - Assert.Null(credentials.TokenFor("https://github.com/Systemorph/NotConfigured")); - - // The vault-delivered token, and only for ITS repository — however the URL is spelled. - Assert.Equal("github_pat_extensions", await credentials.TokenFor(Extensions)!.FirstAsync().Timeout(Budget).Await()); - Assert.Equal("github_pat_extensions", - await credentials.TokenFor("https://github.com/northwind/EXTENSIONS.git/")!.FirstAsync().Timeout(Budget).Await()); - Assert.Equal(0, handler.Requests); - - // The named installation's token. - Assert.Equal("ghs_Fabrikam_1", await credentials.TokenFor(Other)!.FirstAsync().Timeout(Budget).Await()); - } - - [Fact] - public async Task TheEnvironmentSpellingOfATokenKey_NamesTheSameKey() - { - var credentials = new GitSourceCredentials(Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Extensions", - ["PluginCatalog:Sources:0:RepoPath"] = Extensions, - ["PluginCatalog:Sources:0:TokenKey"] = "PluginCatalog__SourceTokens__Extensions", - ["PluginCatalog:SourceTokens:Extensions"] = "github_pat_extensions", - })); - - Assert.Equal("github_pat_extensions", await credentials.TokenFor(Extensions)!.FirstAsync().Timeout(Budget).Await()); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task ADeclaredKeyThatIsEmpty_RefusesByName_AndAsksGitHubNothing(string? delivered) - { - using var rsa = RSA.Create(2048); - var handler = new InstallationsHandler(); - var credentials = new GitSourceCredentials( - Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Extensions", - ["PluginCatalog:Sources:0:RepoPath"] = Extensions, - ["PluginCatalog:Sources:0:TokenKey"] = "PluginCatalog:SourceTokens:Extensions", - ["PluginCatalog:SourceTokens:Extensions"] = delivered, - }), - // The App IS configured and would mint: the refusal must not fall back to it. - App(rsa, handler)); - - var declared = credentials.TokenFor(Extensions); - Assert.NotNull(declared); - var refusal = await Assert.ThrowsAsync( - () => declared!.FirstAsync().Timeout(Budget).Await()); - - Assert.Contains("'Extensions'", refusal.Message, StringComparison.Ordinal); - Assert.Contains(Extensions, refusal.Message, StringComparison.Ordinal); - Assert.Contains("'PluginCatalog:SourceTokens:Extensions'", refusal.Message, StringComparison.Ordinal); - Assert.Contains("PluginCatalog__SourceTokens__Extensions", refusal.Message, StringComparison.Ordinal); - Assert.Equal(0, handler.Requests); - } - - /// - /// TokenKey PRESENT and blank is a declaration, not an absence. Reading it as "none" - /// would hand the repository to the instance's App or to an anonymous read — the identities - /// the declaration exists to rule out. - /// - [Theory] - [InlineData("")] - [InlineData(" ")] - public async Task ATokenKeyThatIsPresentButBlank_IsADeclaration_AndIsRefused(string stated) - { - using var rsa = RSA.Create(2048); - var handler = new InstallationsHandler(); - var credentials = new GitSourceCredentials( - Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Extensions", - ["PluginCatalog:Sources:0:RepoPath"] = Extensions, - ["PluginCatalog:Sources:0:TokenKey"] = stated, - }), - App(rsa, handler)); - - var declared = credentials.TokenFor(Extensions); - Assert.NotNull(declared); - var refusal = await Assert.ThrowsAsync( - () => declared!.FirstAsync().Timeout(Budget).Await()); - - Assert.Equal("gitsync.source.tokenKeyBlank", refusal.Refusal.Key); - Assert.Contains("'Extensions'", refusal.Message, StringComparison.Ordinal); - Assert.Equal(0, handler.Requests); - } - - [Fact] - public async Task TwoSourcesOnTwoInstallations_EachGetItsOwnToken_MintedOnce() - { - using var rsa = RSA.Create(2048); - var handler = new InstallationsHandler(); - var app = App(rsa, handler); - var credentials = new GitSourceCredentials( - Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Plugins", - ["PluginCatalog:Sources:0:RepoPath"] = Plugins, - ["PluginCatalog:Sources:0:InstallationOwner"] = "Systemorph", - ["PluginCatalog:Sources:1:Name"] = "Extensions", - ["PluginCatalog:Sources:1:RepoPath"] = Extensions, - ["PluginCatalog:Sources:1:InstallationOwner"] = "Northwind", - }), - app); - - Assert.Equal("ghs_Systemorph_1", await credentials.TokenFor(Plugins)!.FirstAsync().Timeout(Budget).Await()); - Assert.Equal("ghs_Northwind_1", await credentials.TokenFor(Extensions)!.FirstAsync().Timeout(Budget).Await()); - - // Cached PER INSTALLATION: a second read of either mints nothing, and neither answers - // with the other's token. - Assert.Equal("ghs_Systemorph_1", await credentials.TokenFor(Plugins)!.FirstAsync().Timeout(Budget).Await()); - Assert.Equal("ghs_Northwind_1", await credentials.TokenFor(Extensions)!.FirstAsync().Timeout(Budget).Await()); - Assert.Equal("ghs_Northwind_1", await app.GetInstallationToken("northwind").FirstAsync().Timeout(Budget).Await()); - Assert.Equal(1, handler.MintsFor("Systemorph")); - Assert.Equal(1, handler.MintsFor("Northwind")); - - // The instance's OWN installation (GitHub:App:InstallationOwner) is a third promise and - // is untouched by the named ones. - Assert.Equal("ghs_Systemorph_2", await app.GetInstallationToken().FirstAsync().Timeout(Budget).Await()); - Assert.Equal(1, handler.MintsFor("Northwind")); - } - - [Fact] - public async Task ANamedInstallationTheAppDoesNotHave_FailsByName_NeverWithAnotherInstallationsToken() - { - using var rsa = RSA.Create(2048); - var handler = new InstallationsHandler(); - var app = App(rsa, handler); - - var failure = await Assert.ThrowsAsync( - () => app.GetInstallationToken("Contoso").FirstAsync().Timeout(Budget).Await()); - - Assert.Equal(GitHubAppTokenMintStage.InstallationDiscovery, failure.Stage); - Assert.Contains("'Contoso'", failure.Message, StringComparison.Ordinal); - Assert.Equal(0, handler.Mints); - Assert.True(GitSyncRefusalException.TryGet(failure, out var refusal)); - Assert.Equal("gitsync.source.noInstallation", refusal.Key); - } - - /// - /// A source written straight into configuration can state BOTH credentials — the record's own - /// validation never sees it. Picking a winner would read the repository with an identity - /// nobody can tell from the declaration, so the reader refuses the pair by name. - /// - [Fact] - public async Task ASourceDeclaringBothCredentials_IsRefused_NotResolvedToAWinner() - { - using var rsa = RSA.Create(2048); - var handler = new InstallationsHandler(); - var credentials = new GitSourceCredentials( - Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Extensions", - ["PluginCatalog:Sources:0:RepoPath"] = Extensions, - ["PluginCatalog:Sources:0:TokenKey"] = "PluginCatalog:SourceTokens:Extensions", - ["PluginCatalog:Sources:0:InstallationOwner"] = "Northwind", - ["PluginCatalog:SourceTokens:Extensions"] = "github_pat_extensions", - }), - App(rsa, handler)); - - var refusal = await Assert.ThrowsAsync( - () => credentials.TokenFor(Extensions)!.FirstAsync().Timeout(Budget).Await()); - - Assert.Equal("gitsync.source.twoCredentials", refusal.Refusal.Key); - Assert.Contains("'Extensions'", refusal.Message, StringComparison.Ordinal); - Assert.Contains("'Northwind'", refusal.Message, StringComparison.Ordinal); - Assert.Equal(0, handler.Requests); - } - - /// - /// The refusals are raised server-side with no viewer in scope, so each carries a catalog key - /// and named arguments and is rendered where it is shown — in German for a German viewer. - /// - [Fact] - public async Task ARefusal_IsRenderedForItsViewer() - { - var credentials = new GitSourceCredentials(Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Extensions", - ["PluginCatalog:Sources:0:RepoPath"] = Extensions, - ["PluginCatalog:Sources:0:TokenKey"] = "PluginCatalog:SourceTokens:Extensions", - })); - - var thrown = await Assert.ThrowsAsync( - () => credentials.TokenFor(Extensions)!.FirstAsync().Timeout(Budget).Await()); - - Assert.True(GitSyncRefusalException.TryGet(thrown, out var refusal)); - Assert.Equal("gitsync.source.tokenKeyEmpty", refusal.Key); - var german = refusal.Render("de"); - Assert.Contains("Plugin-Quelle", german, StringComparison.Ordinal); - Assert.Contains("PluginCatalog__SourceTokens__Extensions", german, StringComparison.Ordinal); - Assert.DoesNotContain("{", german, StringComparison.Ordinal); - } - - /// - /// The App is installed on more accounts than one page of the listing holds: the named owner - /// on a later page is still found, rather than reported as an installation that does not exist. - /// - [Fact] - public async Task ANamedInstallationOnALaterPage_IsFound() - { - using var rsa = RSA.Create(2048); - var handler = new InstallationsHandler { Padding = 100 }; - var app = App(rsa, handler); - - Assert.Equal("ghs_Fabrikam_1", await app.GetInstallationToken("Fabrikam").FirstAsync().Timeout(Budget).Await()); - } - - /// The App is installed NOWHERE: a named owner is still refused by its name. - [Fact] - public async Task ANamedInstallation_WhenTheAppIsInstalledNowhere_StillFailsByName() - { - using var rsa = RSA.Create(2048); - var handler = new InstallationsHandler { Empty = true }; - var app = App(rsa, handler); - - var failure = await Assert.ThrowsAsync( - () => app.GetInstallationToken("Contoso").FirstAsync().Timeout(Budget).Await()); - - Assert.Contains("'Contoso'", failure.Message, StringComparison.Ordinal); - Assert.True(GitSyncRefusalException.TryGet(failure, out var refusal)); - Assert.Equal("gitsync.source.noInstallation", refusal.Key); - } - - [Fact] - public async Task ANamedInstallationWithoutAnApp_RefusesByName() - { - var credentials = new GitSourceCredentials(Config(new() - { - ["PluginCatalog:Sources:0:Name"] = "Extensions", - ["PluginCatalog:Sources:0:RepoPath"] = Extensions, - ["PluginCatalog:Sources:0:InstallationOwner"] = "Northwind", - })); - - var refusal = await Assert.ThrowsAsync( - () => credentials.TokenFor(Extensions)!.FirstAsync().Timeout(Budget).Await()); - - Assert.Contains("'Extensions'", refusal.Message, StringComparison.Ordinal); - Assert.Contains("'Northwind'", refusal.Message, StringComparison.Ordinal); - } - - private static IConfiguration Config(Dictionary entries) => - new ConfigurationBuilder().AddInMemoryCollection(entries).Build(); - - private static GitHubAppTokenService App(RSA rsa, HttpMessageHandler handler) => - new( - new IoPoolRegistry(), - Options.Create(new GitHubAppOptions - { - ClientId = "Iv23liTestApp", - PrivateKey = rsa.ExportRSAPrivateKeyPem(), - InstallationOwner = "Systemorph", - }), - httpClient: new HttpClient(handler)); - - /// - /// GitHub's App endpoints for ONE App installed on three accounts. A mint answers - /// ghs_{owner}_{n}, n counted per installation, so a token names both the installation - /// it was minted for and whether it was minted again. - /// - private sealed class InstallationsHandler : HttpMessageHandler - { - private static readonly ImmutableArray<(long Id, string Owner)> Installations = - [(77, "Systemorph"), (88, "Northwind"), (99, "Fabrikam")]; - - private readonly ConcurrentDictionary mintsByOwner = new(StringComparer.Ordinal); - private int requests; - private int mints; - - /// The App is installed on no account at all. - public bool Empty { get; init; } - - /// Unrelated installations listed ahead of the real ones. - public int Padding { get; init; } - - public int Requests => Volatile.Read(ref requests); - - public int Mints => Volatile.Read(ref mints); - - public int MintsFor(string owner) => mintsByOwner.GetValueOrDefault(owner); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - Interlocked.Increment(ref requests); - var path = request.RequestUri!.AbsolutePath; - if (request.Method == HttpMethod.Get && path == "/app/installations") - { - // Padding puts that many unrelated installations AHEAD of the real ones, so the - // real ones fall onto a later page of the 100-per-page listing. - var query = System.Web.HttpUtility.ParseQueryString(request.RequestUri.Query); - var perPage = int.TryParse(query["per_page"], out var size) ? size : 30; - var page = int.TryParse(query["page"], out var number) ? number : 1; - var all = Enumerable.Range(0, Padding).Select(i => (Id: 1000L + i, Owner: $"filler-{i}")) - .Concat(Empty ? [] : Installations); - return Json(HttpStatusCode.OK, "[" + string.Join(",", all.Skip((page - 1) * perPage).Take(perPage).Select(i => - $$"""{"id": {{i.Id}}, "account": {"login": "{{i.Owner}}"} }""")) + "]"); - } - - foreach (var (id, owner) in Installations) - { - if (request.Method != HttpMethod.Post || path != $"/app/installations/{id}/access_tokens") - continue; - Interlocked.Increment(ref mints); - var n = mintsByOwner.AddOrUpdate(owner, 1, (_, count) => count + 1); - var expires = DateTimeOffset.UtcNow.AddHours(1).ToString("o"); - return Json(HttpStatusCode.Created, - $$"""{"token": "ghs_{{owner}}_{{n}}", "expires_at": "{{expires}}"}"""); - } - - return Json(HttpStatusCode.NotFound, $$"""{"message": "unexpected {{request.Method}} {{path}}"}"""); - } - - private static Task Json(HttpStatusCode status, string body) => - Task.FromResult(new HttpResponseMessage(status) - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }); - } -} - -/// -/// The same rule where it is APPLIED: the package sources PackageSources.FromConfiguration -/// builds — the list the registry endpoint serves and the default install reads — hand each -/// repository's fetch the token its source declared. A configured source with no declaration keeps -/// the instance's default identity (anonymous here: this mesh has no App), and a declared key -/// that is empty never reaches the repository at all. -/// -public class PluginSourceCredentialFetchTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Plugins = "https://github.com/Systemorph/MeshWeaver.Plugins"; - private const string Extensions = "https://github.com/Northwind/extensions"; - private const string Unfunded = "https://github.com/Northwind/unfunded"; - - private static readonly TimeSpan Budget = TimeSpan.FromSeconds(20); - - private readonly RecordingRepoClient client = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .AddGitHubSyncTypes() - .ConfigureServices(services => services - .AddGitHubSyncServices() - // Last registration wins — for the configuration and for the client below. - .AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["PluginCatalog:Sources:0:Name"] = "Plugins", - ["PluginCatalog:Sources:0:RepoPath"] = Plugins, - ["PluginCatalog:Sources:1:Name"] = "Extensions", - ["PluginCatalog:Sources:1:RepoPath"] = Extensions, - ["PluginCatalog:Sources:1:TokenKey"] = "PluginCatalog:SourceTokens:Extensions", - ["PluginCatalog:Sources:2:Name"] = "Unfunded", - ["PluginCatalog:Sources:2:RepoPath"] = Unfunded, - ["PluginCatalog:Sources:2:TokenKey"] = "PluginCatalog:SourceTokens:Unfunded", - ["PluginCatalog:SourceTokens:Extensions"] = "github_pat_extensions", - }) - .Build()) - .AddSingleton(client)); - - [Fact] - public async Task EachConfiguredSource_FetchesItsRepositoryWithItsOwnToken() - { - var sources = PackageSources.FromConfiguration(Mesh, Mesh.ServiceProvider.GetRequiredService()); - Assert.Equal(["Plugins", "Extensions", "Unfunded"], sources.Select(s => s.Name)); - - await sources[0].Source.ListPackages("main").FirstAsync().Timeout(Budget).Await(); - await sources[1].Source.ListPackages("main").FirstAsync().Timeout(Budget).Await(); - - Assert.Equal([""], client.TokensFor(Plugins)); - Assert.Equal(["github_pat_extensions"], client.TokensFor(Extensions)); - } - - [Fact] - public async Task ASourceWhoseDeclaredKeyIsEmpty_IsNeverFetched() - { - var sources = PackageSources.FromConfiguration(Mesh, Mesh.ServiceProvider.GetRequiredService()); - - var refusal = await Assert.ThrowsAsync( - () => sources[2].Source.ListPackages("main").FirstAsync().Timeout(Budget).Await()); - - Assert.Contains("'Unfunded'", refusal.Message, StringComparison.Ordinal); - Assert.Contains("'PluginCatalog:SourceTokens:Unfunded'", refusal.Message, StringComparison.Ordinal); - Assert.Empty(client.TokensFor(Unfunded)); - } - - /// - /// 🚨 The SYNC route reads the same repository with the same credential. A package installed - /// out of a source is kept current by GitSync (import, branch reconcile), which resolved its - /// token on its own — the user's, else the instance's App. Had only the package source learned - /// the declaration, the catalog would list the repository and every sync of it would fail. - /// - [Fact] - public async Task GitSync_ReadsADeclaredRepositoryWithTheSameToken_AndRefusesTheSameWay() - { - var sync = Mesh.ServiceProvider.GetRequiredService(); - var user = MeshWeaver.Mesh.Security.WellKnownUsers.System; - - await sync.GetBranchHead(Extensions, "main", user).FirstAsync().Timeout(Budget).Await(); - Assert.Equal(["github_pat_extensions"], client.TokensFor(Extensions)); - - var refusal = await Assert.ThrowsAsync( - () => sync.GetBranchHead(Unfunded, "main", user).FirstAsync().Timeout(Budget).Await()); - Assert.Contains("'Unfunded'", refusal.Message, StringComparison.Ordinal); - Assert.Empty(client.TokensFor(Unfunded)); - } - - /// Records the token each repository was fetched with and answers an empty repository. - private sealed class RecordingRepoClient : IGitHubRepoClient - { - private readonly ConcurrentQueue<(string Repo, string Token)> fetches = new(); - - public IReadOnlyList TokensFor(string repo) => - fetches.Where(f => f.Repo == repo).Select(f => f.Token).Distinct().ToList(); - - private static IObservable Never() => Observable.Never(); - - public IObservable Fetch( - string repositoryUrl, string commitish, string? subdirectory, string accessToken) - { - fetches.Enqueue((repositoryUrl, accessToken)); - return Observable.Return(new RepoSnapshot("0000000000000000000000000000000000000000", [])); - } - - public IObservable Push(GitHubPushRequest request) => Never(); - - public IObservable CreateBranch(GitHubCreateBranchRequest request) => Never(); - - public IObservable OpenPullRequest(GitHubOpenPullRequestRequest request) => Never(); - - public IObservable GetPullRequestStatus( - string repositoryUrl, int number, string accessToken) => Never(); - - public IObservable> ListIssues( - string repositoryUrl, GitHubIssueState? state, string accessToken) => Never>(); - - public IObservable GetIssue(string repositoryUrl, int number, string accessToken) => Never(); - - public IObservable CreateIssue(GitHubCreateIssueRequest request) => Never(); - - public IObservable CommentIssue( - string repositoryUrl, int number, string body, string accessToken) => Never(); - - public IObservable SetIssueState( - string repositoryUrl, int number, GitHubIssueState state, string accessToken) => Never(); - - public IObservable> ListPullRequests( - string repositoryUrl, PullRequestStatus? state, string accessToken) => Never>(); - - public IObservable GetPullRequestDetail( - string repositoryUrl, int number, string accessToken) => Never(); - - public IObservable CommentPullRequest( - string repositoryUrl, int number, string body, string accessToken) => Never(); - - public IObservable MergePullRequest(GitHubMergePullRequestRequest request) => Never(); - } -} diff --git a/test/Memex.Portal.Shared.Test/PortalStepUpRungsTest.cs b/test/Memex.Portal.Shared.Test/PortalStepUpRungsTest.cs deleted file mode 100644 index 45532762f8..0000000000 --- a/test/Memex.Portal.Shared.Test/PortalStepUpRungsTest.cs +++ /dev/null @@ -1,349 +0,0 @@ -using System.Collections.Immutable; -using System.Formats.Cbor; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Fido2NetLib; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The portal-held rungs of the approval step-up (Refs #4305, Doc/Architecture/ApprovalStepUp): -/// the ladder's fallback decision (always prefer a passkey; TOTP only with no passkey at all), RFC -/// 6238 TOTP against the RFC's own vectors, and a real WebAuthn registration + assertion verified by -/// the FIDO2 library against a software authenticator. Each refusal is paired with the accepted -/// ceremony it differs from in one property — the negative control. -/// -public class PortalStepUpRungsTest -{ - private static readonly StepUpOptions On = new() { Enabled = true, EntraAuthenticationContext = "c1" }; - - private static PasskeyCredential AKey() => new() { CredentialId = "AAAA", PublicKey = "", CreatedAt = DateTimeOffset.UnixEpoch }; - - private static StepUpFactors PasskeyOnly() => new() { UserId = "u", Passkeys = ImmutableDictionary.Empty.Add("AAAA", AKey()) }; - - private static StepUpFactors TotpOnly() => new() { UserId = "u", TotpConfirmedAt = DateTimeOffset.UnixEpoch, TotpSecretProtected = "x" }; - - private static StepUpFactors Both() => TotpOnly() with { Passkeys = ImmutableDictionary.Empty.Add("AAAA", AKey()) }; - - // ───────────── the ladder ───────────── - - [Fact] - public void ThePasskeyIsAlwaysPreferred_AndTotpIsOnlyForAnAccountWithNoPasskey() - { - Assert.Equal(StepUpRung.Passkey, StepUpLadder.Decide("Google", On, true, PasskeyOnly())); - Assert.Equal(StepUpRung.Passkey, StepUpLadder.Decide("Google", On, true, Both())); // never TOTP while a passkey exists - Assert.Equal(StepUpRung.Totp, StepUpLadder.Decide("LinkedIn", On, true, TotpOnly())); - Assert.Equal(StepUpRung.Enroll, StepUpLadder.Decide("Google", On, true, null)); - Assert.Equal(StepUpRung.Enroll, StepUpLadder.Decide("Google", On with { AllowTotpFallback = false }, true, TotpOnly())); - // Entra accounts step up with Entra — one prompt, never the portal's own factors as well. - Assert.Equal(StepUpRung.Entra, StepUpLadder.Decide("Microsoft", On, true, Both())); - // Off until declared. - Assert.Equal(StepUpRung.NotRequired, StepUpLadder.Decide("Google", new StepUpOptions(), true, null)); - } - - [Fact] - public void ATotpCodeCannotDowngradeAnAccountThatHasAPasskey() - { - Assert.False(StepUpLadder.AcceptsTotp(On, Both())); - Assert.False(StepUpLadder.MayEnrollTotp(On, PasskeyOnly())); - // Negative control: with no passkey, both are allowed. - Assert.True(StepUpLadder.AcceptsTotp(On, TotpOnly())); - Assert.True(StepUpLadder.MayEnrollTotp(On, null)); - Assert.False(StepUpLadder.MayEnrollTotp(On with { AllowTotpFallback = false }, null)); - } - - // ───────────── completing a pending step-up: the server-decided rung, re-checked ───────────── - - private static StepUpPending StartedAs(string rung) => new() { Id = "p", UserId = "u", Rung = rung }; - - [Fact] - public void AMicrosoftAccountCannotFinishAnEntraStepUpWithAPortalFactor() - { - // A Microsoft account that somehow holds portal factors, with an Entra step-up pending. - var entraPending = StartedAs(StepUpRung.Entra); - Assert.False(StepUpLadder.MayComplete(entraPending.Rung, StepUpRung.Passkey, "Microsoft", On, true, Both())); - Assert.False(StepUpLadder.MayComplete(entraPending.Rung, StepUpRung.Totp, "Microsoft", On, true, TotpOnly())); - // Even a pending record claiming a portal rung does not help: the ladder decided NOW says Entra. - Assert.False(StepUpLadder.MayComplete(StepUpRung.Passkey, StepUpRung.Passkey, "Microsoft", On, true, Both())); - Assert.False(StepUpLadder.MayComplete(StepUpRung.Totp, StepUpRung.Totp, "Microsoft", On, true, TotpOnly())); - // Negative control: the Entra callback completes the Entra step-up it was started for. - Assert.True(StepUpLadder.MayComplete(entraPending.Rung, StepUpRung.Entra, "Microsoft", On, true, null)); - } - - [Fact] - public void APortalStepUpCompletesOnlyWithTheRungItWasStartedFor() - { - // Started as a passkey step-up: a TOTP code (or an Entra callback) does not finish it. - Assert.False(StepUpLadder.MayComplete(StepUpRung.Passkey, StepUpRung.Totp, "Google", On, true, Both())); - Assert.False(StepUpLadder.MayComplete(StepUpRung.Passkey, StepUpRung.Entra, "Google", On, true, Both())); - // A record from before the field existed completes nothing. - Assert.False(StepUpLadder.MayComplete("", StepUpRung.Passkey, "Google", On, true, PasskeyOnly())); - // A TOTP step-up whose account enrolled a passkey since: the ladder now says passkey. - Assert.False(StepUpLadder.MayComplete(StepUpRung.Totp, StepUpRung.Totp, "Google", On, true, Both())); - // Negative controls: each completes with its own rung. - Assert.True(StepUpLadder.MayComplete(StepUpRung.Passkey, StepUpRung.Passkey, "Google", On, true, PasskeyOnly())); - Assert.True(StepUpLadder.MayComplete(StepUpRung.Totp, StepUpRung.Totp, "LinkedIn", On, true, TotpOnly())); - } - - [Fact] - public void OnlyAKnownNonMicrosoftAccountMayEnrolPortalFactors() - { - Assert.False(StepUpLadder.MayEnrollPortalFactors("Microsoft")); - Assert.False(StepUpLadder.MayEnrollPortalFactors("microsoft")); - Assert.False(StepUpLadder.MayEnrollPortalFactors(null)); // the provider is never guessed - Assert.False(StepUpLadder.MayEnrollPortalFactors("")); - // Negative control. - Assert.True(StepUpLadder.MayEnrollPortalFactors("Google")); - Assert.True(StepUpLadder.MayEnrollPortalFactors("LinkedIn")); - } - - // ───────────── TOTP (RFC 6238 appendix B, SHA-1 secret) ───────────── - - private static readonly DateTimeOffset T59 = DateTimeOffset.FromUnixTimeSeconds(59); - - private static byte[] RfcSecret() => Encoding.ASCII.GetBytes("12345678901234567890"); - - [Theory] - [InlineData(59L, "94287082")] - [InlineData(1111111109L, "07081804")] - [InlineData(1234567890L, "89005924")] - [InlineData(2000000000L, "69279037")] - public void TotpMatchesTheRfcVectors(long unixSeconds, string eightDigits) => - Assert.Equal(eightDigits, Totp.Code(RfcSecret(), Totp.StepOf(DateTimeOffset.FromUnixTimeSeconds(unixSeconds)), digits: 8)); - - [Fact] - public void ACodeIsAcceptedOnce_WithinTheDriftWindow() - { - var secret = RfcSecret(); - var step = Totp.StepOf(T59); - var code = Totp.Code(secret, step); - Assert.Equal(step, Totp.Verify(secret, code, T59, lastAcceptedStep: 0)); - Assert.Null(Totp.Verify(secret, code, T59, lastAcceptedStep: step)); // replay - Assert.Equal(step, Totp.Verify(secret, code, T59.AddSeconds(30), 0)); // one step of drift - Assert.Null(Totp.Verify(secret, code, T59.AddSeconds(90), 0)); // too far - Assert.Null(Totp.Verify(secret, "000000" == code ? "111111" : "000000", T59, 0)); - Assert.Null(Totp.Verify(secret, "12a456", T59, 0)); - } - - [Fact] - public void ACodeSharedByAdjacentSteps_IsAcceptedOnlyOnce() - { - // This secret produces the same six digits for steps 1,999,999 and 2,000,000: the match must - // record the NEWER step, or the same code is accepted a second time for it. - var secret = Convert.FromHexString("0000000000000000000000000000000000127054"); - Assert.Equal(Totp.Code(secret, 1_999_999), Totp.Code(secret, 2_000_000)); - var now = DateTimeOffset.FromUnixTimeSeconds(2_000_000L * 30); - var code = Totp.Code(secret, 2_000_000); - Assert.Equal(2_000_000, Totp.Verify(secret, code, now, lastAcceptedStep: 0)); - Assert.Null(Totp.Verify(secret, code, now, lastAcceptedStep: 2_000_000)); // replay refused - // Negative control: the newer step is still accepted once the older one is spent. - Assert.Equal(2_000_000, Totp.Verify(secret, code, now, lastAcceptedStep: 1_999_999)); - } - - [Fact] - public void WithStepUpOff_AStaleSessionIsToldToSignInAgain_NotLoopedThroughAConfirmation() - { - // Step-up off: no receipt can ever be minted, so a stale session must not be sent to confirm. - Assert.Equal(StepUpController.EnrollAuthorization.NeedsFreshSignIn, StepUpController.FurtherFactorWithoutReceipt(enabled: false, signedInRecently: false)); - Assert.Equal(StepUpController.EnrollAuthorization.AllowedByReceipt, StepUpController.FurtherFactorWithoutReceipt(enabled: false, signedInRecently: true)); - Assert.Equal(StepUpController.EnrollAuthorization.NeedsFreshSignIn, StepUpController.FurtherFactorFromVerdict(StepUpOutcome.NotRequired, signedInRecently: false)); - Assert.Equal(StepUpController.EnrollAuthorization.AllowedByReceipt, StepUpController.FurtherFactorFromVerdict(StepUpOutcome.NotRequired, signedInRecently: true)); - // Step-up on: a fresh sign-in alone is never enough — the existing factor confirms first. - Assert.Equal(StepUpController.EnrollAuthorization.NeedsStepUp, StepUpController.FurtherFactorWithoutReceipt(enabled: true, signedInRecently: true)); - Assert.Equal(StepUpController.EnrollAuthorization.NeedsStepUp, StepUpController.FurtherFactorWithoutReceipt(enabled: true, signedInRecently: false)); - Assert.Equal(StepUpController.EnrollAuthorization.AllowedByReceipt, StepUpController.FurtherFactorFromVerdict(StepUpOutcome.Accepted, signedInRecently: false)); - } - - [Fact] - public void RecoveryCodesAreHashedAndNormalized() - { - var codes = Totp.NewRecoveryCodes(); - Assert.Equal(10, codes.Count); - Assert.Equal(10, codes.Distinct().Count()); - Assert.Equal(Totp.HashRecoveryCode(codes[0]), Totp.HashRecoveryCode(" " + codes[0].ToUpperInvariant().Replace("-", "") + " ")); - Assert.NotEqual(Totp.HashRecoveryCode(codes[0]), Totp.HashRecoveryCode(codes[1])); - } - - // ───────────── passkeys, against a software authenticator ───────────── - - private const string RpId = "portal.example.com"; - private const string Origin = "https://portal.example.com"; - - private static IReadOnlyList TargetsA => [new StepUpTarget { ActionPath = "Ops/InstanceAction/a", Binding = "h1" }]; - private static IReadOnlyList TargetsB => [new StepUpTarget { ActionPath = "Ops/InstanceAction/b", Binding = "h1" }]; - - private static string B64U(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - - /// A WebAuthn authenticator in software: one P-256 key, a counter, "none" attestation. - private sealed class SoftAuthenticator - { - private readonly ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); - public byte[] CredentialId { get; } = RandomNumberGenerator.GetBytes(16); - public uint Counter { get; set; } - - private static byte[] RpIdHash() => SHA256.HashData(Encoding.UTF8.GetBytes(RpId)); - - private byte[] CoseKey() - { - var p = key.ExportParameters(false); - var w = new CborWriter(CborConformanceMode.Ctap2Canonical); - w.WriteStartMap(5); - w.WriteInt32(1); w.WriteInt32(2); // kty: EC2 - w.WriteInt32(3); w.WriteInt32(-7); // alg: ES256 - w.WriteInt32(-1); w.WriteInt32(1); // crv: P-256 - w.WriteInt32(-2); w.WriteByteString(p.Q.X!); - w.WriteInt32(-3); w.WriteByteString(p.Q.Y!); - w.WriteEndMap(); - return w.Encode(); - } - - private static byte[] Counter32(uint c) => [(byte)(c >> 24), (byte)(c >> 16), (byte)(c >> 8), (byte)c]; - - private static string ClientData(string type, byte[] challenge) => - JsonSerializer.Serialize(new Dictionary { ["type"] = type, ["challenge"] = B64U(challenge), ["origin"] = Origin, ["crossOrigin"] = false }); - - public string Create(CredentialCreateOptions options) - { - var authData = RpIdHash() - .Concat(new byte[] { 0x45 }) // UP | UV | AT - .Concat(Counter32(Counter)) - .Concat(new byte[16]) // AAGUID - .Concat(new[] { (byte)(CredentialId.Length >> 8), (byte)CredentialId.Length }) - .Concat(CredentialId) - .Concat(CoseKey()).ToArray(); - var w = new CborWriter(CborConformanceMode.Ctap2Canonical); - w.WriteStartMap(3); - w.WriteTextString("fmt"); w.WriteTextString("none"); - w.WriteTextString("attStmt"); w.WriteStartMap(0); w.WriteEndMap(); - w.WriteTextString("authData"); w.WriteByteString(authData); - w.WriteEndMap(); - return JsonSerializer.Serialize(new - { - id = B64U(CredentialId), - rawId = B64U(CredentialId), - type = "public-key", - response = new - { - attestationObject = B64U(w.Encode()), - clientDataJSON = B64U(Encoding.UTF8.GetBytes(ClientData("webauthn.create", options.Challenge))), - }, - clientExtensionResults = new { }, - }); - } - - public string Get(byte[] challenge, byte[] userHandle, bool userVerified = true) - { - var authData = RpIdHash().Concat(new[] { (byte)(userVerified ? 0x05 : 0x01) }).Concat(Counter32(Counter)).ToArray(); - var clientData = Encoding.UTF8.GetBytes(ClientData("webauthn.get", challenge)); - var signature = key.SignData(authData.Concat(SHA256.HashData(clientData)).ToArray(), - HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); - return JsonSerializer.Serialize(new - { - id = B64U(CredentialId), - rawId = B64U(CredentialId), - type = "public-key", - response = new - { - authenticatorData = B64U(authData), - clientDataJSON = B64U(clientData), - signature = B64U(signature), - userHandle = B64U(userHandle), - }, - clientExtensionResults = new { }, - }); - } - } - - private static PasskeyStepUp Rp() => new(RpId, Origin, "Portal", IoPool.Unbounded); - - private static async Task<(SoftAuthenticator Device, PasskeyCredential Stored)> Enrolled(CancellationToken ct) - { - var device = new SoftAuthenticator(); - var options = Rp().RegistrationOptions("alice", "alice@example.com", []); - var registered = await Rp().Register(device.Create(options), options, [], DateTimeOffset.UtcNow).Await(ct); - Assert.True(registered.Ok, registered.Reason); - return (device, registered.Credential!); - } - - [Fact(Timeout = 60000)] - public async Task APasskeyEnrolsAndConfirmsTheStepUpItsChallengeIsBoundTo() - { - var ct = TestContext.Current.CancellationToken; - var (device, stored) = await Enrolled(ct); - Assert.Equal(B64U(device.CredentialId), stored.CredentialId); - - var challenge = PasskeyStepUp.ChallengeFor("alice", TargetsA, "nonce-1"); - device.Counter = 1; - var assertion = device.Get(challenge, PasskeyStepUp.UserHandle("alice")); - var accepted = await Rp().Assert(assertion, Rp().AssertionOptions(challenge, [stored]), [stored], DateTimeOffset.UtcNow).Await(ct); - Assert.True(accepted.Ok, accepted.Reason); - Assert.Equal(1u, accepted.Credential!.SignCount); - - // The SAME assertion presented for another approval: its challenge is bound to other targets. - var other = PasskeyStepUp.ChallengeFor("alice", TargetsB, "nonce-1"); - var refused = await Rp().Assert(assertion, Rp().AssertionOptions(other, [stored]), [stored], DateTimeOffset.UtcNow).Await(ct); - Assert.False(refused.Ok); - Assert.Equal("passkey", refused.Reason); - } - - [Fact(Timeout = 60000)] - public async Task ACounterThatDoesNotMoveForward_IsRefusedAsAClone() - { - var ct = TestContext.Current.CancellationToken; - var (device, stored) = await Enrolled(ct); - var challenge = PasskeyStepUp.ChallengeFor("alice", TargetsA, "nonce-2"); - var afterFive = stored with { SignCount = 5 }; - - device.Counter = 5; // the stored counter is 5 already - var replayed = await Rp().Assert(device.Get(challenge, PasskeyStepUp.UserHandle("alice")), - Rp().AssertionOptions(challenge, [afterFive]), [afterFive], DateTimeOffset.UtcNow).Await(ct); - Assert.False(replayed.Ok); - Assert.Equal("counter", replayed.Reason); - - device.Counter = 6; // negative control: a counter that moved forward passes - var moved = await Rp().Assert(device.Get(challenge, PasskeyStepUp.UserHandle("alice")), - Rp().AssertionOptions(challenge, [afterFive]), [afterFive], DateTimeOffset.UtcNow).Await(ct); - Assert.True(moved.Ok, moved.Reason); - } - - [Fact(Timeout = 60000)] - public async Task AnAssertionWithoutUserVerification_IsRefused_AndAnUnknownCredentialToo() - { - var ct = TestContext.Current.CancellationToken; - var (device, stored) = await Enrolled(ct); - var challenge = PasskeyStepUp.ChallengeFor("alice", TargetsA, "nonce-3"); - device.Counter = 1; - - var noUv = await Rp().Assert(device.Get(challenge, PasskeyStepUp.UserHandle("alice"), userVerified: false), - Rp().AssertionOptions(challenge, [stored]), [stored], DateTimeOffset.UtcNow).Await(ct); - Assert.False(noUv.Ok); - - var stranger = new SoftAuthenticator { Counter = 1 }; - var unknown = await Rp().Assert(stranger.Get(challenge, PasskeyStepUp.UserHandle("alice")), - Rp().AssertionOptions(challenge, [stored]), [stored], DateTimeOffset.UtcNow).Await(ct); - Assert.False(unknown.Ok); - Assert.Equal("unknownCredential", unknown.Reason); - } - - /// - /// A sealed enrolment payload (registration options, a TOTP secret) is bound to the account that - /// requested it: the cookie survives a sign-out, so account B must not complete what account A - /// started. Before the fix the payload carried no account at all. - /// - [Fact] - public void ASealedEnrolmentPayload_IsReadOnlyByTheAccountItWasBoundTo() - { - var bound = StepUpController.BindToUser("alice", "{\"challenge\":\"x\"}"); - Assert.Equal("{\"challenge\":\"x\"}", StepUpController.UnbindFromUser(bound, "alice")); - Assert.Null(StepUpController.UnbindFromUser(bound, "bob")); - Assert.Null(StepUpController.UnbindFromUser(bound, "alic")); - // Negative control: an unbound legacy payload (no account line) is refused, never read as anyone's. - Assert.Null(StepUpController.UnbindFromUser("{\"challenge\":\"x\"}", "alice")); - } -} diff --git a/test/Memex.Portal.Shared.Test/PrebuiltBundleRetentionIsRegisteredTest.cs b/test/Memex.Portal.Shared.Test/PrebuiltBundleRetentionIsRegisteredTest.cs deleted file mode 100644 index b1d4868567..0000000000 --- a/test/Memex.Portal.Shared.Test/PrebuiltBundleRetentionIsRegisteredTest.cs +++ /dev/null @@ -1,109 +0,0 @@ -using Memex.Portal.Shared; -using MeshWeaver.Hosting; -using MeshWeaver.Mesh; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Hosting.Internal; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #3963, pinned: the prebuilt-bundle retention sweep must be REGISTERED by the portal boot path. -/// -/// 🚨 A hosted service that exists, compiles and is tested proves nothing about whether it -/// RUNS. PrebuiltBundleRetentionHostedService ships with unit tests for every keep rule, -/// a ledger, a report-only mode and a documented discipline — and all of that is inert on a -/// deployment whose host never constructs it. Nothing anywhere reports the absence, because "the -/// sweep found nothing to collect" and "the sweep never ran" produce the same observable: no -/// deletion, no ledger line, no log. That is the same shape as a CI gate that skips. -/// -/// Why the assertion is on the DESCRIPTOR, and on IHostedService specifically. -/// Registering the class as a plain singleton compiles, resolves, and never runs — the generic host -/// only starts what is registered AS IHostedService. So the check is not "the type exists" -/// and not "the type resolves": it is "the boot path put this implementation on the hosted-service -/// roster". Removing the AddPrebuiltBundleRetention line from ConfigureMemexMesh, or -/// downgrading it to a bare AddSingleton, fails this test. -/// -/// The positive control. The sibling collector of the same data volume -/// (AddModuleGenerationsGc, registered two lines above it) is asserted in the SAME -/// inspection. A harness that stopped reaching these registrations at all — a throwing boot path, a -/// renamed extension, a configuration shape that returns early — would otherwise let the subject's -/// absence read as a pass. Here it cannot: the control fails first and names itself. -/// -/// 🚨 This covers the REGISTRATION only. Whether the sweep then COLLECTS anything depends on -/// PreWarm:PrebuiltBundleRoot being configured, and whether it DELETES depends on -/// PreWarm:PrebuiltBundleRetention:Delete — whose code default is true and whose -/// chart default is false. PrebuiltBundleRetentionArmingGuard in -/// MeshWeaver.Hosting.Test holds that half. See Doc/Architecture/PrebuiltBundleRetention. -/// -public class PrebuiltBundleRetentionIsRegisteredTest -{ - [Fact] - public void TheBootPath_PutsTheRetentionSweep_OnTheHostedServiceRoster() - { - var root = Directory.CreateTempSubdirectory("mw-3963-").FullName; - try - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Graph:Storage:Type"] = "FileSystem", - ["Graph:Storage:BasePath"] = root, - ["Modules:Root"] = root, - }) - .Build(); - - var services = new ServiceCollection(); - services.AddLogging(); - services.AddSingleton( - new ApplicationLifetime(NullLogger.Instance)); - - // The REAL boot path — the one both portal hosts call - // (Memex.Portal.Distributed/Program.cs, Memex.Portal.Monolith/Program.cs). - new MeshBuilder(configure => configure(services), new Address("mesh", "test")) - .ConfigureMemexMesh(configuration); - - // ── The positive control, asserted FIRST so a harness that reached no registration at - // all cannot let the subject's absence read as a pass. ──────────────────────────── - Assert.True( - services.Any(d => d.ServiceType == typeof(ModuleGenerationsGcHostedService)), - "POSITIVE CONTROL FAILED: ConfigureMemexMesh did not register the sibling " - + "collector (AddModuleGenerationsGc) either, so this test observed NO boot-path " - + "registrations and can say nothing about the prebuilt-bundle sweep. Fix the " - + "harness — do not read this as the sweep being absent."); - - // ── The subject. ────────────────────────────────────────────────────────────────── - Assert.True( - services.Any(d => d.ServiceType == typeof(IHostedService) - && d.ImplementationType == typeof(PrebuiltBundleRetentionHostedService)), - "ConfigureMemexMesh must register the prebuilt-bundle retention sweep as an " - + "IHostedService (services.AddPrebuiltBundleRetention(configuration), beside " - + "AddModuleGenerationsGc). It is registered NOWHERE else: the sibling collector of " - + "the same data volume is right there, so a deployment can mount " - + "PreWarm:PrebuiltBundleRoot and have nothing at all sweep it. A store nothing " - + "prunes filled memex.systemorph.com's 16 GiB /data to 3 MiB free on 2026-09-08, " - + "and a full Azure Files share TRUNCATES WRITES SILENTLY (#3963)."); - - // ── The rest of AddPrebuiltBundleRetention's contract: the policy the sweep reads and - // the status row an operator reads back. Either missing and the service cannot be - // constructed, which the host would surface only at StartAsync. ────────────────── - Assert.True( - services.Any(d => d.ServiceType == typeof(PrebuiltBundleRetention)), - "AddPrebuiltBundleRetention must register the PrebuiltBundleRetention policy read " - + "from configuration — without it the hosted service cannot be constructed."); - Assert.True( - services.Any(d => d.ServiceType == typeof(PrebuiltBundleRetentionStatus)), - "AddPrebuiltBundleRetention must register PrebuiltBundleRetentionStatus — it is " - + "where the last pass's result and fault are recorded for an operator to read."); - } - finally - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - } -} diff --git a/test/Memex.Portal.Shared.Test/PrebuiltPublicationTest.cs b/test/Memex.Portal.Shared.Test/PrebuiltPublicationTest.cs deleted file mode 100644 index d0770d0dbf..0000000000 --- a/test/Memex.Portal.Shared.Test/PrebuiltPublicationTest.cs +++ /dev/null @@ -1,576 +0,0 @@ -using System.IO.Compression; -using System.Net; -using System.Text.Json; -using System.Reactive.Linq; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// The registry SERVES sealed publications — step 2 of the plugin build contract, the half that -/// lets a downstream repo INSTALL its upstream without an Azure identity, a checkout of the -/// upstream's source, or any secret beyond the instance key it already holds. -/// -/// Pins four things, each of which was a real gap on 2026-08-27: -/// -/// a caller with a whole-source grant gets the seal's list and each listed bundle; -/// a caller WITHOUT the grant is refused (403) — the fetch is scoped per source, so a -/// satellite that may fetch plugins cannot fetch a source it does not depend on; -/// an unsealed source directory is 404 — the route serves nothing the boot seeder would -/// itself refuse, so a consumer can never be handed a torn set; -/// a bundle present on disk but NOT listed by the seal is 404 — an unsealed file is not -/// part of the publication, whatever the filesystem says. -/// -/// -public class PrebuiltPublicationTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Identity = "s0123456789abcdef0123456789abcdef"; - private const string Source = "plugins"; - private const string Granted = "granted-satellite"; - private const string Ungranted = "ungranted-satellite"; - - [Fact(Timeout = 120_000)] - public async Task ServesTheSealedSet_ToAWholeSourceGrant_AndRefusesEveryoneElse() - { - var root = Path.Combine(Path.GetTempPath(), "mw-prebuilt-" + Guid.NewGuid().ToString("N")); - var dir = Path.Combine(root, Identity, Source); - Directory.CreateDirectory(dir); - try - { - WriteBundle(Path.Combine(dir, "Store.zip"), "Store"); - WriteBundle(Path.Combine(dir, "Edu.zip"), "Edu"); - WriteBundle(Path.Combine(dir, "Orphan.zip"), "Orphan"); // on disk, NOT sealed - File.WriteAllText(Path.Combine(dir, ShippedPrebuiltBundles.CompletionSentinelFileName), "Store.zip\nEdu.zip\n"); - var torn = Path.Combine(root, Identity, "torn"); // a directory with no seal - Directory.CreateDirectory(torn); - WriteBundle(Path.Combine(torn, "X.zip"), "X"); - - var grantedKey = await RegisterInstance(Granted, $"{Source}/*"); - var ungrantedKey = await RegisterInstance(Ungranted); - await using var app = await StartHost(root, cancellationToken: TestContext.Current.CancellationToken); - - // 1. the seal's list, exactly — the orphan is not in it - var index = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}", grantedKey); - Assert.Equal(HttpStatusCode.OK, index.StatusCode); - var body = await index.Content.ReadAsStringAsync(); - Assert.Contains("Store.zip", body); Assert.Contains("Edu.zip", body); - Assert.DoesNotContain("Orphan.zip", body); - - // …and each listed bundle's bytes - var store = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}/Store.zip", grantedKey); - Assert.Equal(HttpStatusCode.OK, store.StatusCode); - Assert.True((await store.Content.ReadAsByteArrayAsync()).Length > 0); - - // 2. no grant on the source → 403, never the bytes - var refused = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}", ungrantedKey); - Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode); - - // 3. unsealed directory → 503 + Retry-After, NOT 404 (#3401). The publisher removes the - // sentinel before republishing and restores it last, so a caller here is looking at a - // publication that exists and is being replaced — transient, and it must say so. - var tornKey = await RegisterInstance("torn-reader", "torn/*"); - var tornResp = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/torn", tornKey); - Assert.Equal(HttpStatusCode.ServiceUnavailable, tornResp.StatusCode); - Assert.Equal("30", tornResp.Headers.RetryAfter?.Delta?.TotalSeconds.ToString("0")); - Assert.Contains("republished", await tornResp.Content.ReadAsStringAsync()); - - // …and a source nothing was ever published under stays a permanent 404, so the two are - // distinguishable — that distinction is the whole point. - var absentKey = await RegisterInstance("absent-reader", "never-published/*"); - var absent = await Get( - app, $"/api/plugins/bundles/prebuilt/{Identity}/never-published", absentKey); - Assert.Equal(HttpStatusCode.NotFound, absent.StatusCode); - - // 4. present on disk but not sealed → 404 - var orphan = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}/Orphan.zip", grantedKey); - Assert.Equal(HttpStatusCode.NotFound, orphan.StatusCode); - - // and a path segment that is not a bare name is refused before any disk read - var walk = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/..%2F{Source}", grantedKey); - Assert.NotEqual(HttpStatusCode.OK, walk.StatusCode); - } - finally - { - try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } - } - } - - /// - /// 🚨 MeshWeaver#2698: a gate pinned to an identity composes the module bytes the publication - /// was SEALED against, from the publication — never the registry's package endpoint, whose - /// bytes are the module's own lane's last build. The registry therefore serves a sealed - /// publication's module set beside its bundles: exactly the index's list (an unlisted file is - /// not part of the set), 404 with a reason for a publication that predates module sealing - /// (so a consumer fails RED naming the republish instead of composing something else), and an - /// EMPTY set for a bake that composed nothing — distinguishable from "predates". - /// - [Fact(Timeout = 120_000)] - public async Task ServesTheSealedModuleSet_AndRefusesAPublicationThatPredatesIt() - { - var root = Path.Combine(Path.GetTempPath(), "mw-prebuilt-modules-" + Guid.NewGuid().ToString("N")); - var dir = Path.Combine(root, Identity, Source); - var modules = Path.Combine(dir, PublishedBundleCatalogue.ModulesDirectoryName); - Directory.CreateDirectory(modules); - try - { - WriteBundle(Path.Combine(dir, "Store.zip"), "Store"); - File.WriteAllText(Path.Combine(dir, ShippedPrebuiltBundles.CompletionSentinelFileName), "Store.zip\n"); - WriteBundle(Path.Combine(modules, "ai.module.nupkg"), "AI"); - WriteBundle(Path.Combine(modules, "orphan.module.nupkg"), "Orphan"); // on disk, NOT in the set - File.WriteAllText(Path.Combine(modules, PublishedBundleCatalogue.ModulesIndexFileName), "ai.module.nupkg\n"); - - var legacy = Path.Combine(root, Identity, "legacy"); // sealed BEFORE module sealing - Directory.CreateDirectory(legacy); - WriteBundle(Path.Combine(legacy, "L.zip"), "L"); - File.WriteAllText(Path.Combine(legacy, ShippedPrebuiltBundles.CompletionSentinelFileName), "L.zip\n"); - - var bare = Path.Combine(root, Identity, "bare"); // sealed, composed nothing - Directory.CreateDirectory(Path.Combine(bare, PublishedBundleCatalogue.ModulesDirectoryName)); - WriteBundle(Path.Combine(bare, "B.zip"), "B"); - File.WriteAllText(Path.Combine(bare, ShippedPrebuiltBundles.CompletionSentinelFileName), "B.zip\n"); - File.WriteAllText(Path.Combine(bare, PublishedBundleCatalogue.ModulesDirectoryName, PublishedBundleCatalogue.ModulesIndexFileName), ""); - - var key = await RegisterInstance("module-reader", $"{Source}/*", "legacy/*", "bare/*"); - var ungrantedKey = await RegisterInstance("module-stranger"); - await using var app = await StartHost(root, cancellationToken: TestContext.Current.CancellationToken); - - // 1. the set is the index's list — the orphan on disk is not in it - var index = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}/modules", key); - Assert.Equal(HttpStatusCode.OK, index.StatusCode); - var body = await index.Content.ReadAsStringAsync(); - Assert.Contains("ai.module.nupkg", body); - Assert.DoesNotContain("orphan", body); - var ai = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}/modules/ai.module.nupkg", key); - Assert.Equal(HttpStatusCode.OK, ai.StatusCode); - Assert.True((await ai.Content.ReadAsByteArrayAsync()).Length > 0); - var orphan = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}/modules/orphan.module.nupkg", key); - Assert.Equal(HttpStatusCode.NotFound, orphan.StatusCode); - - // 2. the NodeType bundle index is untouched by the module set (and "modules" is not a bundle) - var bundles = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}", key); - Assert.Contains("Store.zip", await bundles.Content.ReadAsStringAsync()); - - // 3. a publication that predates module sealing → 404 that SAYS so, never an empty set - var predates = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/legacy/modules", key); - Assert.Equal(HttpStatusCode.NotFound, predates.StatusCode); - Assert.Contains("predates module sealing", await predates.Content.ReadAsStringAsync()); - - // 4. a bake that composed nothing → 200 with an EMPTY set - var empty = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/bare/modules", key); - Assert.Equal(HttpStatusCode.OK, empty.StatusCode); - Assert.Contains("\"modules\":[]", (await empty.Content.ReadAsStringAsync()).Replace(" ", "")); - - // 5. no grant → 403; a walking name → refused before any disk read - var refused = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}/modules", ungrantedKey); - Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode); - var walk = await Get(app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}/modules/..%2FStore.zip", key); - Assert.NotEqual(HttpStatusCode.OK, walk.StatusCode); - } - finally - { - try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } - } - } - - private static void WriteBundle(string path, string plugin) - { - using var zip = ZipFile.Open(path, ZipArchiveMode.Create); - using var w = new StreamWriter(zip.CreateEntry("meshweaver/manifest.json").Open()); - w.Write($"{{\"plugin\":\"{plugin}\"}}"); - } - - private MeshWeaverInstanceService InstanceService(params string[] defaultGrants) => - new(Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection(defaultGrants.Select((entry, i) => - new KeyValuePair($"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:{i}", entry))) - .Build()); - - private Task RegisterInstance(string instanceId, params string[] defaultGrants) => - InstanceService(defaultGrants) - .Register("owner", "Owner", "owner@test.com", instanceId, instanceId) - .Select(r => r.RawKey).FirstAsync().Timeout(TimeSpan.FromSeconds(60)).Await(TestContext.Current.CancellationToken); - - private async Task StartHost( - string publishedRoot, Action? beforePublicationRead = null, CancellationToken cancellationToken = default) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [PublishedBundleCatalogue.PublishedRootConfigKey] = publishedRoot, - }); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(new InstanceRegistryAuthenticator( - Mesh, Mesh.ServiceProvider.GetRequiredService>())); - if (beforePublicationRead is not null) - builder.Services.AddSingleton(new PublicationReadLoggerFactory(beforePublicationRead)); - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(cancellationToken); - return app; - } - - /// - /// 🚨 #3401. A consumer reads the index and then fetches each bundle it names — N+1 reads of a - /// directory the publisher may reseal underneath it. Without a generation the second half of - /// that fetch silently mixes bytes from two publications, and nothing in any status code says - /// so. With one, the server refuses the stale read and names both generations. - /// - /// This is the case that no amount of retrying fixes and that the 2026-09-06 - /// Manufacturing red could not have revealed: there, the window was observed as a 404 because - /// the seal was absent. Here the seal is present both times and only its CONTENT moved. - /// - [Fact(Timeout = 120_000)] - public async Task AResealBetweenTheIndexAndTheBundleIsRefused_NotSilentlyMixed() - { - var root = Path.Combine(Path.GetTempPath(), "mw-prebuilt-gen-" + Guid.NewGuid().ToString("N")); - var dir = Path.Combine(root, Identity, Source); - Directory.CreateDirectory(dir); - try - { - WriteBundle(Path.Combine(dir, "Store.zip"), "Store"); - var sentinel = Path.Combine(dir, ShippedPrebuiltBundles.CompletionSentinelFileName); - File.WriteAllText(sentinel, "Store.zip\n"); - - var key = await RegisterInstance(Granted, $"{Source}/*"); - await using var app = await StartHost(root, cancellationToken: TestContext.Current.CancellationToken); - var route = $"/api/plugins/bundles/prebuilt/{Identity}/{Source}"; - - // 1. the index carries a generation, and the same value as its ETag - var index = await Get(app, route, key); - Assert.Equal(HttpStatusCode.OK, index.StatusCode); - var generation = JsonDocument.Parse(await index.Content.ReadAsStringAsync()) - .RootElement.GetProperty("generation").GetString(); - Assert.False(string.IsNullOrWhiteSpace(generation)); - Assert.Equal(generation, index.Headers.ETag?.Tag.Trim('"')); - - // 2. pinning the generation we actually read is accepted - var fresh = await Get(app, $"{route}/Store.zip", key, generation); - Assert.Equal(HttpStatusCode.OK, fresh.StatusCode); - - // 3. the publisher reseals — same bundle NAME, new bytes. Nothing about the listing - // changes, which is exactly why a name-based check cannot see this. - File.Delete(Path.Combine(dir, "Store.zip")); // a republish REPLACES the bytes - WriteBundle(Path.Combine(dir, "Store.zip"), "Store-v2"); - File.SetLastWriteTimeUtc(sentinel, File.GetLastWriteTimeUtc(sentinel).AddSeconds(1)); - - var moved = await Get(app, $"{route}/Store.zip", key, generation); - Assert.Equal(HttpStatusCode.PreconditionFailed, moved.StatusCode); - var body = JsonDocument.Parse(await moved.Content.ReadAsStringAsync()).RootElement; - Assert.Equal(generation, body.GetProperty("held").GetString()); - Assert.NotEqual(generation, body.GetProperty("current").GetString()); - - // 4. …and re-reading the index hands the caller the generation that now applies - var again = await Get(app, route, key); - var current = JsonDocument.Parse(await again.Content.ReadAsStringAsync()) - .RootElement.GetProperty("generation").GetString(); - Assert.Equal(current, body.GetProperty("current").GetString()); - Assert.Equal( - HttpStatusCode.OK, (await Get(app, $"{route}/Store.zip", key, current)).StatusCode); - - // 5. a caller that pins NOTHING keeps working — the precondition is opt-in, so a - // satellite on an older gate pin is unaffected by this change. - Assert.Equal(HttpStatusCode.OK, (await Get(app, $"{route}/Store.zip", key)).StatusCode); - } - finally - { - try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } - } - } - - [HubFact] - public async Task RemovedSealAndRemovedIdentity_HaveDistinctAnswersOnEveryPublicationRoute() - { - var root = Path.Combine(Path.GetTempPath(), "mw-prebuilt-removed-" + Guid.NewGuid().ToString("N")); - var directory = Path.Combine(root, Identity, Source); - Directory.CreateDirectory(directory); - try - { - WriteBundle(Path.Combine(directory, "Store.zip"), "Store"); - var seal = Path.Combine(directory, ShippedPrebuiltBundles.CompletionSentinelFileName); - File.WriteAllText(seal, "Store.zip\n"); - var key = await RegisterInstance(Granted, $"{Source}/*"); - await using var app = await StartHost(root, cancellationToken: TestContext.Current.CancellationToken); - var route = $"/api/plugins/bundles/prebuilt/{Identity}/{Source}"; - Assert.Equal(HttpStatusCode.OK, (await Get(app, route, key)).StatusCode); - - File.Delete(seal); - string[] suffixes = ["", "/Store.zip", "/modules", "/modules/ai.module.nupkg"]; - foreach (var suffix in suffixes) - { - using var response = await Get(app, route + suffix, key); - Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); - Assert.Equal("30", response.Headers.RetryAfter?.ToString()); - } - - Directory.Delete(Path.Combine(root, Identity), recursive: true); - foreach (var suffix in suffixes) - { - using var response = await Get(app, route + suffix, key); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Null(response.Headers.RetryAfter); - } - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - [Theory] - [InlineData("/modules", false)] - [InlineData("/modules/ai.module.nupkg", false)] - [InlineData("/modules", true)] - [InlineData("/modules/ai.module.nupkg", true)] - public async Task PublicationRemovedBetweenTheTwoModuleReads_PreservesItsStatus( - string suffix, bool removeDirectory) - { - var root = Path.Combine(Path.GetTempPath(), "mw-prebuilt-between-" + Guid.NewGuid().ToString("N")); - var directory = Path.Combine(root, Identity, Source); - Directory.CreateDirectory(directory); - try - { - WriteBundle(Path.Combine(directory, "Store.zip"), "Store"); - var seal = Path.Combine(directory, ShippedPrebuiltBundles.CompletionSentinelFileName); - File.WriteAllText(seal, "Store.zip\n"); - var key = await RegisterInstance(Granted, $"{Source}/*"); - var reads = 0; - // Authentication obtains the first logger; each catalogue read obtains the next. - // This existing boundary lets the real HTTP handler finish its first sealed read - // before removal at the second catalogue read (third logger), with no production hook. - await using var app = await StartHost(root, () => - { - if (++reads != 3) - return; - Assert.True(File.Exists(seal)); - if (removeDirectory) - Directory.Delete(Path.Combine(root, Identity), recursive: true); - else - File.Delete(seal); - }); - using var response = await Get( - app, $"/api/plugins/bundles/prebuilt/{Identity}/{Source}" + suffix, key); - Assert.Equal(3, reads); - Assert.Equal(removeDirectory ? HttpStatusCode.NotFound : HttpStatusCode.ServiceUnavailable, - response.StatusCode); - if (removeDirectory) - Assert.Null(response.Headers.RetryAfter); - else - Assert.Equal("30", response.Headers.RetryAfter?.ToString()); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - /// - /// 🚨 #3876, the LAST unguarded open on these routes. The seal read lists a name and stats it - /// present; Results.File(path, …) then resolves that path AGAIN when the result executes, - /// after the handler has returned. The publisher replaces a publication several times an hour - /// during a release and retention removes whole identity directories, so a file that went away - /// in between threw (or - /// from a removed parent, which is what Linux raises) - /// out of result execution to ExceptionHandlerMiddleware — a 500 for a condition this - /// route has had a correct answer for since #3401. The open now happens in the handler, and the - /// SAME discrimination decides: the source directory is still there ⇒ transient, 503 + - /// Retry-After; the identity directory is gone ⇒ permanent, 404. - /// - /// Three directions, because a fix that answered 404 for everything would pass a - /// two-case test: the healthy publication must still serve its BYTES — the positive control, - /// asserted first, on the very route the removal then hits. - /// - /// The byte route under the publication. - /// Which PluginBundleEndpoints logger the open sits behind — - /// authentication takes the first, each catalogue read the next, and the serve the last. It is - /// asserted exactly, so an ordinal that drifts fails LOUDLY instead of removing nothing. - /// Whether the identity directory goes, or only the bytes. - [Theory] - [InlineData("/Store.zip", 3, false)] - [InlineData("/Store.zip", 3, true)] - [InlineData("/modules/ai.module.nupkg", 4, false)] - [InlineData("/modules/ai.module.nupkg", 4, true)] - public async Task BytesRemovedBetweenTheSealReadAndTheOpen_AreTransientOrNotFound_NeverA500( - string suffix, int openRead, bool removeIdentity) - { - var root = Path.Combine(Path.GetTempPath(), "mw-prebuilt-serve-" + Guid.NewGuid().ToString("N")); - var directory = Path.Combine(root, Identity, Source); - var modules = Path.Combine(directory, PublishedBundleCatalogue.ModulesDirectoryName); - Directory.CreateDirectory(modules); - try - { - WriteBundle(Path.Combine(directory, "Store.zip"), "Store"); - var seal = Path.Combine(directory, ShippedPrebuiltBundles.CompletionSentinelFileName); - File.WriteAllText(seal, "Store.zip\n"); - WriteBundle(Path.Combine(modules, "ai.module.nupkg"), "AI"); - File.WriteAllText( - Path.Combine(modules, PublishedBundleCatalogue.ModulesIndexFileName), "ai.module.nupkg\n"); - var bytes = Path.Combine( - directory, suffix.TrimStart('/').Replace('/', Path.DirectorySeparatorChar)); - - var key = await RegisterInstance(Granted, $"{Source}/*"); - var armed = false; - var reads = 0; - await using var app = await StartHost(root, () => - { - if (!armed || ++reads != openRead) - return; - Assert.True(File.Exists(seal), - "the seal must still be readable here — this test removes the BYTES after the " - + "seal read has listed them, which is the race; a seal already gone would be " - + "the older, already-fixed condition instead"); - if (removeIdentity) - Directory.Delete(Path.Combine(root, Identity), recursive: true); - else - File.Delete(bytes); - }); - var route = $"/api/plugins/bundles/prebuilt/{Identity}/{Source}"; - - // POSITIVE CONTROL, on the same route the removal then hits: a healthy sealed - // publication still serves its bytes, with no Retry-After. - using (var healthy = await Get(app, route + suffix, key)) - { - Assert.Equal(HttpStatusCode.OK, healthy.StatusCode); - Assert.True((await healthy.Content.ReadAsByteArrayAsync()).Length > 0, - "a healthy sealed publication must serve the bundle's BYTES"); - Assert.Null(healthy.Headers.RetryAfter); - } - - armed = true; - using var response = await Get(app, route + suffix, key); - Assert.Equal(openRead, reads); - Assert.NotEqual(HttpStatusCode.InternalServerError, response.StatusCode); - if (removeIdentity) - { - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Null(response.Headers.RetryAfter); - } - else - { - Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); - Assert.Equal("30", response.Headers.RetryAfter?.ToString()); - } - } - finally - { - try { Directory.Delete(root, recursive: true); } catch { /* the test may have removed it */ } - } - } - - /// - /// 🚨 #3876 on the module set's OWN seal. File.Exists(modules/_index) leading an - /// unguarded File.ReadAllLines(modules/_index) is the same racing pair the - /// _complete readers shed in #3877/#3885, and it threw the same unhandled exception onto - /// the same two module routes. The open now decides, and the discrimination is the point: an - /// index absent at the open needs the OPPOSITE answer depending on WHY. - /// - /// Three directions. (1) The index is gone at the open and the seal has gone with it — - /// the publisher unseals FIRST and retention unseals before it removes an identity, so this is - /// a publication being replaced right now: PublicationUnavailable, which the HTTP - /// readers already turn into 503 + Retry-After while the directory is there and 404 once - /// it is not ( - /// pins that mapping). (2) The index was never there and the seal reads fine — the publication - /// predates module sealing, which is PERMANENT and must stay a 404 that says to republish; - /// collapsing either into the other is the defect. (3) A healthy publication still yields its - /// module set. - /// - /// Whether the whole modules directory goes at the open - /// () or only the index - /// () — a removed parent raises neither the same exception - /// nor the same message, and both must classify. - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AModuleIndexRemovedAtTheOpen_IsTransient_NotAPublicationThatPredatesModuleSealing( - bool removeDirectory) - { - var root = Path.Combine(Path.GetTempPath(), "mw-prebuilt-index-" + Guid.NewGuid().ToString("N")); - var directory = Path.Combine(root, Identity, Source); - var modules = Path.Combine(directory, PublishedBundleCatalogue.ModulesDirectoryName); - Directory.CreateDirectory(modules); - try - { - WriteBundle(Path.Combine(directory, "Store.zip"), "Store"); - var seal = Path.Combine(directory, ShippedPrebuiltBundles.CompletionSentinelFileName); - File.WriteAllText(seal, "Store.zip\n"); - WriteBundle(Path.Combine(modules, "ai.module.nupkg"), "AI"); - var index = Path.Combine(modules, PublishedBundleCatalogue.ModulesIndexFileName); - File.WriteAllText(index, "ai.module.nupkg\n"); - - // POSITIVE CONTROL: healthy, so a reading that refused everything could not pass. - var healthy = PublishedBundleCatalogue.SealedModulesOf(directory); - Assert.Equal(["ai.module.nupkg"], healthy.Modules); - Assert.False(healthy.PublicationUnavailable); - - // 1. gone AT THE OPEN, seal gone with it — the publisher's own order — is TRANSIENT. - var removedAtTheOpen = PublishedBundleCatalogue.ReadModuleSet(directory, null, path => - { - if (removeDirectory) - Directory.Delete(modules, recursive: true); - else - File.Delete(index); - File.Delete(seal); - return File.ReadAllLines(path); - }); - Assert.Null(removedAtTheOpen.Modules); - Assert.True(removedAtTheOpen.PublicationUnavailable, - "an index that was there for the seal read and gone at the open is a publication " - + "being replaced — 503 + Retry-After, never 'republish the source'"); - Assert.Contains("being replaced right now", removedAtTheOpen.Refusal); - - // 2. never there, seal intact — PERMANENT, and it must keep saying so. - File.WriteAllText(seal, "Store.zip\n"); - Directory.CreateDirectory(modules); - var predates = PublishedBundleCatalogue.SealedModulesOf(directory); - Assert.Null(predates.Modules); - Assert.False(predates.PublicationUnavailable, - "a sealed publication that simply carries no module set is a permanent 404 telling " - + "the caller to republish — it must not wear the transient answer"); - Assert.Contains("predates module sealing", predates.Refusal); - } - finally - { - try { Directory.Delete(root, recursive: true); } catch { /* the test may have removed it */ } - } - } - - private sealed class PublicationReadLoggerFactory(Action beforeRead) : ILoggerFactory - { - private readonly ILoggerFactory inner = LoggerFactory.Create(_ => { }); - - public ILogger CreateLogger(string categoryName) - { - if (categoryName == typeof(PluginBundleEndpoints).FullName) - beforeRead(); - return inner.CreateLogger(categoryName); - } - - public void AddProvider(ILoggerProvider provider) => inner.AddProvider(provider); - public void Dispose() => inner.Dispose(); - } - - private static async Task Get( - WebApplication app, string route, string key, string? ifMatch = null) - { - using var request = new HttpRequestMessage(HttpMethod.Get, route); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - if (ifMatch is not null) - request.Headers.TryAddWithoutValidation("If-Match", ifMatch); - return await app.GetTestClient().SendAsync(request); - } -} diff --git a/test/Memex.Portal.Shared.Test/ProbeAnswersWithoutWalkingTheVolumeTest.cs b/test/Memex.Portal.Shared.Test/ProbeAnswersWithoutWalkingTheVolumeTest.cs deleted file mode 100644 index 0c39ec1ac4..0000000000 --- a/test/Memex.Portal.Shared.Test/ProbeAnswersWithoutWalkingTheVolumeTest.cs +++ /dev/null @@ -1,354 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A probe must answer inside its own timeout, which means it may not do the work -/// (MeshWeaver#4655 — the half #4608's memo left open). -/// -/// Memoising the module-volume walk behind a fingerprint fixed "every probe pays for it" and -/// left the two occasions that actually decide a rollout still paying it in full: the FIRST -/// probe of a fresh pod — which is the startupProbe, and a startup timeout is the one a -/// container cannot recover from — and the first probe after anything LANDS, i.e. exactly when the -/// module lane is doing the thing this check reports. On memex.systemorph.com the walk is -/// 6.5–10.1 s against a five-second probe budget, so both of those are a timeout rather than a slow -/// answer. This test pins the shape that removes them: the reading is TAKEN on an -/// and the probe READS it. -/// -/// Why a pool that refuses to run is the instrument. A stopwatch here would measure a -/// developer's SSD, where the whole walk is microseconds and the defect is invisible. A pool that -/// never runs the work makes the property exact instead: with no reading obtainable, a probe that -/// walks the volume would block forever or answer from the volume, and one that reads a reading -/// answers immediately with an explicit "not measured". -/// counts the walks, so the assertion is a number rather than a duration. -/// -/// 🚨 And the answer it gives while it has no reading must REFUSE. An unread volume -/// says nothing about whether a required module is here, and the branch that would otherwise run — -/// "the image lists it and it does not resolve, therefore the build lost the pack" — would be -/// reading a false that means "nobody asked". Two of the tests below are about the verdict, -/// not the cost: unmeasured classifies as , and -/// counts it, so a caller that has never heard of the -/// state still stalls the rollout rather than completing it over evidence nobody has. -/// -public class ProbeAnswersWithoutWalkingTheVolumeTest : IDisposable -{ - private const string StoreModule = "MeshWeaver.Social"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-probe-reading-" + Guid.NewGuid().ToString("N")); - private readonly ModuleLandingService landing; - - public ProbeAnswersWithoutWalkingTheVolumeTest() - { - Directory.CreateDirectory(root); - landing = new ModuleLandingService(baseDirectory: root); - } - - public void Dispose() - { - landing.Dispose(); - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - GC.SuppressFinalize(this); - } - - /// - /// 🚨 The property, stated as a count: a probe taken before any reading exists performs ZERO - /// walks of the volume and returns anyway. This is the first of the two occasions the memo left - /// exposed — the startup probe of a fresh pod — and it is the unrecoverable one. - /// - [Fact] - public async Task AProbeTakenBeforeAnyReading_WalksNothing_AndStillAnswers() - { - await LandWave(StoreModule); - var pool = new ControllableIoPool { Runs = false }; - var pending = new PendingModuleActivations(root) { IoPool = pool }; - - var inputs = pending.ReadProbeInputs(); - - Assert.Equal(0, pending.DiskReads); - Assert.Null(inputs.Activation); - Assert.Same(ModuleProbeInputs.VolumeNotRead, inputs.ResolvesFromDeployment); - Assert.Contains(inputs.Unreadable, reason => - reason.Contains("has not been read", StringComparison.OrdinalIgnoreCase)); - Assert.True(pool.Scheduled > 0, - "the probe answered without a reading and without asking for one — the next probe " - + "would answer 'not measured' too, for ever"); - } - - /// - /// 🚨 The SECOND occasion, and the one a memo cannot help with: a landing moves the fingerprint, - /// so the held reading is out of date — and the probe STILL does not walk. It answers from the - /// reading it has, which is at most one probe interval old, and asks the pool for a new one. - /// - /// Without this the fix would be "the first probe is slow instead of all of them", which - /// is a rollout gate that goes over budget precisely when a module is being delivered. - /// - [Fact] - public async Task AProbeAfterALanding_StillWalksNothing_AndAnswersFromTheReadingItHas() - { - await LandWave(StoreModule); - var pool = new ControllableIoPool(); - var pending = new PendingModuleActivations(root) { IoPool = pool }; - - // One reading, taken the way production takes it — on the pool, never by a probe. - await pending.Refresh().Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - Assert.Equal(1, pending.DiskReads); - var known = pending.ReadProbeInputs(); - Assert.Single(EnabledNames(known)); - - // A landing moves all three fingerprint directories, so the reading is now stale. - await LandWave("MeshWeaver.Speech"); - pool.Runs = false; - - var afterLanding = pending.ReadProbeInputs(); - - Assert.Equal(1, pending.DiskReads); - Assert.Single(EnabledNames(afterLanding)); - Assert.NotNull(afterLanding.Activation); - } - - /// - /// 🚨 The VERDICT half. An unread volume must not be reported as a finding: the entry is - /// , its reason says so, and it is NOT - /// — which would be a pod that has measured - /// nothing telling a rollout to carry on. - /// - [Fact] - public void AnUnreadVolume_IsUnmeasured_AndRefuses() - { - var inputs = ModuleProbeInputs.NotRead(root); - - var verdicts = RequiredModuleStatus.Classify( - [StoreModule + ".dll"], - baselineEntries: [], - loadedAssemblyNames: ImmutableHashSet.Empty, - inputs.ResolvesFromDeployment, - inputs.Activation, - inputs.LandedDllExists, - _ => null, - []); - - var verdict = Assert.Single(verdicts); - Assert.Equal(RequiredModuleState.Unmeasured, verdict.State); - Assert.Contains("has not read the module volume", verdict.Reason, StringComparison.OrdinalIgnoreCase); - - // 🚨 The bucket, not just the state: a surface that only knows the older states reads - // Absent + Incompatible + ExpectedLater, and it must land on a REFUSAL rather than on the - // one bucket that lets a rollout complete. - Assert.Contains(RequiredModuleStatus.Absent(verdicts), v => v.Name == StoreModule); - Assert.Empty(RequiredModuleStatus.ExpectedLater(verdicts)); - Assert.Empty(RequiredModuleStatus.Incompatible(verdicts)); - Assert.Contains(RequiredModuleStatus.Unmeasured(verdicts), v => v.Name == StoreModule); - } - - /// - /// 🚨 The NEGATIVE CONTROL for the state above, without which "unmeasured" would be a blanket - /// that refuses every rollout until a reading lands. What this process has LOADED is in-process - /// state, it costs nothing, and it settles the common case — so a required module that is - /// loaded here is even with no reading at all. - /// - [Fact] - public void AModuleLoadedInThisProcess_IsPresent_EvenWithNoReading() - { - var inputs = ModuleProbeInputs.NotRead(root); - - var verdicts = RequiredModuleStatus.Classify( - [StoreModule + ".dll"], - baselineEntries: [StoreModule + ".dll"], - loadedAssemblyNames: new HashSet(StringComparer.OrdinalIgnoreCase) { StoreModule }, - inputs.ResolvesFromDeployment, - inputs.Activation, - inputs.LandedDllExists, - _ => null, - []); - - Assert.Equal(RequiredModuleState.Present, Assert.Single(verdicts).State); - Assert.Empty(RequiredModuleStatus.Absent(verdicts)); - } - - /// - /// 🚨 The reading is taken at HOST START, so in production the window in which a probe answers - /// "not measured" is the boot itself rather than the first probe. This is the background half of - /// "a health check reads a registry; it does not do the work": the work has an owner, and the - /// owner is not a probe. - /// - [Fact] - public async Task TheFirstReading_IsTakenAtHostStart_NotByAProbe() - { - await LandWave(StoreModule); - var pool = new ControllableIoPool(); - var pending = new PendingModuleActivations(root) { IoPool = pool }; - using var service = new ModuleVolumeReadingHostedService(pending); - - Assert.Equal(0, pending.DiskReads); - - await service.StartAsync(TestContext.Current.CancellationToken); - await service.Taken.Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - - // From here a probe never has to measure: the reading exists before the first one arrives. - pool.Runs = false; - var inputs = pending.ReadProbeInputs(); - - Assert.Equal(1, pending.DiskReads); - Assert.NotNull(inputs.Activation); - Assert.NotSame(ModuleProbeInputs.VolumeNotRead, inputs.ResolvesFromDeployment); - Assert.Contains(StoreModule, EnabledNames(inputs), StringComparer.OrdinalIgnoreCase); - } - - /// - /// 🚨 Every caller shares ONE walk — including the host-start reading, which overlaps the - /// first probes BY CONSTRUCTION (Copilot's review of #4700). - /// - /// The host-start reading used to call Refresh() directly, past the collapse. On a - /// booting pod the first /health arrives while that reading is still crawling the share, - /// so the probe's own request found nothing in flight and started a SECOND concurrent walk of - /// the slow volume — doubling the load exactly during the boot this change exists to rescue, - /// and leaving the two snapshots free to land in either order. - /// - /// A second walk is observable as a COUNT, which is why the pool counts what it is handed - /// rather than what it ran: with the pool refusing to run, the first reading never completes, so - /// anything arriving after it is unambiguously "did this start another one?". - /// - [Fact] - public async Task ProbesDuringTheHostStartReading_JoinIt_RatherThanStartingASecondWalk() - { - await LandWave(StoreModule); - var pool = new ControllableIoPool { Runs = false }; - var pending = new PendingModuleActivations(root) { IoPool = pool }; - using var service = new ModuleVolumeReadingHostedService(pending); - - await service.StartAsync(TestContext.Current.CancellationToken); - Assert.Equal(1, pool.Scheduled); - - // Two probes land while that reading is still in flight. Each asks for a reading; neither - // may start one. - pending.ReadProbeInputs(); - pending.ReadProbeInputs(); - - Assert.Equal(1, pool.Scheduled); - Assert.Equal(0, pending.DiskReads); - } - - /// - /// 🚨 The pool's cancellation REACHES the walk (Copilot's review of #4700). It used to be - /// discarded — ReadDisk took no token and enumerated the share regardless — so a teardown - /// only unsubscribed and left a pool worker holding the whole slow walk. - /// - /// An already-cancelled token is the exact instrument: a walk that observes it performs no - /// enumeration at all (DiskReads stays 0) and ends as a cancellation; one that discards - /// it reads the volume and completes, which this assertion cannot mistake for the other. - /// - [Fact] - public async Task ThePoolsCancellation_ReachesTheWalk_RatherThanBeingDiscarded() - { - await LandWave(StoreModule); - using var cancelled = new CancellationTokenSource(); - await cancelled.CancelAsync(); - var pool = new ControllableIoPool { Token = cancelled.Token }; - var pending = new PendingModuleActivations(root) { IoPool = pool }; - - await Assert.ThrowsAnyAsync( - () => pending.Refresh().Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken)); - - Assert.Equal(1, pool.Scheduled); - Assert.Equal(0, pending.DiskReads); - - // 🚨 And the cancellation is not latched: the promise cache evicts a faulted reading, so the - // pod that survives a cancelled sweep can still take one. Without that, one teardown-shaped - // blip would leave every later probe answering "not measured" for the life of the process. - pool.Token = CancellationToken.None; - await pending.Refresh().Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - Assert.Equal(1, pending.DiskReads); - Assert.NotNull(pending.ReadProbeInputs().Activation); - } - - private static IReadOnlyList EnabledNames(ModuleProbeInputs inputs) => - (inputs.Activation?.Entries ?? []).Select(m => m.Name).ToList(); - - private async Task LandWave(params string[] names) - { - foreach (var name in names) - await landing.LandModule(name, [(name + ".dll", RealAssemblyBytes)]) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - await landing.ProposeModuleSet().Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - } - - private static byte[] RealAssemblyBytes => - File.ReadAllBytes(typeof(MeshWeaver.Plugin.Packaging.BundleReader).Assembly.Location); - - /// - /// A pool that can be told to stop running what it is handed — the instrument this test needs, - /// because "the probe did not walk the volume" is only provable when walking was impossible. - /// It also COUNTS what it was handed, so "answered without asking for a reading" and "answered - /// from the reading it had" stay different sentences. - /// - private sealed class ControllableIoPool : IIoPool - { - private int scheduled; - - /// Whether handed-over work actually runs. false hands back a leaf that - /// never produces, which is what a hung volume looks like from the caller's side. - public bool Runs { get; set; } = true; - - /// How many blocking leaves were handed over, run or not. - public int Scheduled => Volatile.Read(ref scheduled); - - /// Nothing is bounded here; the cap is not what this test measures. - public int CurrentInFlight => 0; - - /// The token handed to the work — the seam that says whether the subject - /// RECEIVES the pool's cancellation or discards it. - public CancellationToken Token { get; set; } = CancellationToken.None; - - public IObservable InvokeBlocking(Func work) - => Observable.Create(observer => - { - Interlocked.Increment(ref scheduled); - if (!Runs) - return Disposable.Empty; - // Faithful to the real pool: a throwing leaf becomes OnError, never an exception - // out of Subscribe — otherwise a cancelled walk would look like a broken promise - // rather than a cancelled one. - try - { - var value = work(Token); - observer.OnNext(value); - observer.OnCompleted(); - } - catch (Exception exception) - { - observer.OnError(exception); - } - - return Disposable.Empty; - }); - - public IObservable Invoke(Func> io) - => throw new NotSupportedException("this test's subject uses blocking leaves only"); - - public IObservable InvokeStream(Func> source) - => throw new NotSupportedException("this test's subject uses blocking leaves only"); - - public IObservable SubscribeThroughPool(IObservable source) - => throw new NotSupportedException("this test's subject uses blocking leaves only"); - } -} diff --git a/test/Memex.Portal.Shared.Test/ProbeSeparationTest.cs b/test/Memex.Portal.Shared.Test/ProbeSeparationTest.cs deleted file mode 100644 index a29656dad7..0000000000 --- a/test/Memex.Portal.Shared.Test/ProbeSeparationTest.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Memex.Portal.ServiceDefaults; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A failing LIVENESS check must not evict the pod from the Service (MeshWeaver#3330). -/// -/// What went wrong. The chart pointed both post-startup probes at /alive, which -/// was harmless only while /alive was the trivial process-up check it shipped as: nothing -/// carried the live tag its predicate filters on, so an empty check set answered 200 for any -/// process that could still accept a socket. MeshWeaver.Plugins#1234 then registered -/// ProcessProgressHealthCheck on that tag — correct, and the fix to a real blindness — and -/// READINESS inherited it silently, because two probes cannot be given different semantics while -/// they share a path. -/// -/// The arithmetic then does the damage: readiness trips at 10s × 3 = 30s and liveness -/// at 15s × 6 = 90s, so a GC-bound replica leaves the Service a full minute before anything -/// restarts it, and for that minute its traffic lands on siblings converging on the SAME memory -/// ceiling (measured 2026-09-04 in ns memex: two 28 h replicas at 9936Mi and 9409Mi, ratio -/// 1.06). One sick replica becomes a cascade — the 2026-07-21 death spiral, rebuilt out of the -/// containment that was supposed to prevent it. -/// -/// Why this test and not only a text guard. ProbeSemanticsGuard reads the chart -/// and the wiring and proves they are declared apart. This drives the two paths the CHART actually -/// names over a REAL HTTP pipeline through the REAL -/// composition, with a liveness check that is -/// genuinely failing — the running shape of the incident. It reads the paths out of the chart -/// rather than restating them, because a restatement would agree with itself while the deployment -/// probed something else. -/// -public class ProbeSeparationTest -{ - /// A check that fails the way a GC-bound process fails: reporting, not throwing. - private const string GcBound = - "GC-bound: pause share 71% over the last 120 s — the process is not making progress."; - - /// - /// 🚨 The invariant. A process failing its LIVENESS check must still answer READY: the remedy - /// for "not making progress" is a restart, never an eviction onto siblings at the same point on - /// the same curve. - /// - [Fact] - public async Task AFailingLivenessCheck_DoesNotTakeThePodOutOfRotation() - { - var probes = ChartProbePaths(); - - var (liveness, _) = await ProbeAsync(probes.Liveness, ProbeEndpoints.LiveTag); - var (readiness, _) = await ProbeAsync(probes.Readiness, ProbeEndpoints.LiveTag); - - Assert.True(liveness.StatusCode == HttpStatusCode.ServiceUnavailable, - $"the chart's livenessProbe path ('{probes.Liveness}') answered {(int)liveness.StatusCode} " - + "while a check tagged 'live' was Unhealthy. Liveness is the probe that must see a " - + "GC-bound process — this is #2194 item 4 and it must keep working."); - - Assert.True(readiness.StatusCode == HttpStatusCode.OK, - $"the chart's readinessProbe path ('{probes.Readiness}') answered " - + $"{(int)readiness.StatusCode} because a LIVENESS check was failing" - + (probes.Readiness == probes.Liveness - ? " — and it answered it on the very same path the livenessProbe uses, so the two " - + "probes cannot be given different semantics at all. " - : " — so readiness is wired to the liveness predicate even though the paths differ. ") - + "That evicts a GC-bound replica from the Service at 10s×3=30s and only restarts it at " - + "15s×6=90s; for the minute in between its traffic goes to siblings converging on the " - + "same memory ceiling (ratio 1.06, ns memex, 2026-09-04), turning one sick replica into " - + "a cascade. #3330: readiness needs its own path AND its own tag."); - } - - /// - /// 🚨 Non-vacuity, and it is the whole reason the readiness endpoint runs a check at all: a - /// MapHealthChecks whose predicate matches NOTHING answers 200 forever. That is exactly - /// how /alive stayed blind through the 2026-08-25 incident (#2194) — mapped, filtered, - /// and matching an empty set. Without this case the assertion above would be satisfied by a - /// readiness endpoint that can never fail, which is not a probe. - /// - [Fact] - public async Task TheReadinessPath_CanStillFail_WhenItsOwnCheckFails() - { - var probes = ChartProbePaths(); - - var (readiness, body) = await ProbeAsync(probes.Readiness, ProbeEndpoints.ReadyTag); - - Assert.True(readiness.StatusCode == HttpStatusCode.ServiceUnavailable, - $"the chart's readinessProbe path ('{probes.Readiness}') answered " - + $"{(int)readiness.StatusCode} ('{body}') with a check tagged " - + $"'{ProbeEndpoints.ReadyTag}' reporting Unhealthy. Its predicate is matching nothing, " - + "so it is vacuously healthy — a probe that cannot fail is not a probe, and that is " - + "the #2194 blindness rebuilt on a new path."); - } - - /// The healthy baseline: with nothing failing, both post-startup probes answer 200. - [Fact] - public async Task BothPostStartupProbes_AnswerWhenNothingIsWrong() - { - var probes = ChartProbePaths(); - - var (liveness, _) = await ProbeAsync(probes.Liveness, failingTag: null); - var (readiness, _) = await ProbeAsync(probes.Readiness, failingTag: null); - - Assert.True(liveness.StatusCode == HttpStatusCode.OK, - $"the chart's livenessProbe path ('{probes.Liveness}') answered " - + $"{(int)liveness.StatusCode} on a healthy process — the code does not map it. The " - + "kubelet reads a 404 as a failing probe, so this restarts every pod in a loop."); - Assert.True(readiness.StatusCode == HttpStatusCode.OK, - $"the chart's readinessProbe path ('{probes.Readiness}') answered " - + $"{(int)readiness.StatusCode} on a healthy process — the code does not map it. The pod " - + "then never reports Ready, the old replica keeps the traffic, and the roll stalls with " - + "nothing in the log naming the cause."); - } - - /// - /// The pipeline under test: the REAL - /// composition over a TestServer, with one extra check reporting Unhealthy under - /// (null = nothing failing). - /// - private static async Task<(HttpResponseMessage Response, string Body)> ProbeAsync( - string route, string? failingTag) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.AddDefaultHealthChecks(); - - if (failingTag is not null) - builder.Services.AddHealthChecks() - .AddCheck("progress", () => HealthCheckResult.Unhealthy(GcBound), [failingTag]); - - var app = builder.Build(); - app.MapDefaultEndpoints(); - - await using (app) - { - await app.StartAsync(TestContext.Current.CancellationToken); - using var client = app.GetTestClient(); - var response = await client.GetAsync(route, TestContext.Current.CancellationToken); - var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - return (response, body); - } - } - - /// - /// The two post-startup probe paths, read out of the CHART. Restating them here would let this - /// test agree with itself while the deployment probed something else. - /// - private static (string Readiness, string Liveness) ChartProbePaths() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - Assert.NotNull(dir); - - var path = Path.Combine(dir!.FullName, "deploy", "helm", "templates", - "memex-portal", "deployment.yaml"); - Assert.True(File.Exists(path), $"the portal deployment template is not at {path}"); - - // Comments stripped: the prose around these probes discusses both paths by name, so an - // un-stripped parse could read the explanation instead of the probe. - var yaml = string.Join("\n", File.ReadAllLines(path) - .Where(line => !line.TrimStart().StartsWith('#'))); - - return (PathOf(yaml, "readinessProbe"), PathOf(yaml, "livenessProbe")); - } - - private static string PathOf(string yaml, string probe) - { - // Bounded to lines indented deeper than the probe key: the liveness probe is the last in - // the template, so an unbounded body runs to EOF and a sidecar httpGet added later would - // be read as the liveness path — the guard silently changing subject. - var block = Regex.Match(yaml, $@"^(?[ ]*){probe}:[ ]*$(?(?:\n\k[ ].*)*)", - RegexOptions.Multiline); - Assert.True(block.Success, $"the deployment template declares no {probe}"); - - var body = block.Groups["body"].Value; - var inline = Regex.Match(body, @"httpGet:\s*\{[^}]*?\bpath:\s*(?

[^,}\s]+)"); - var nested = Regex.Match(body, @"httpGet:\s*(?:\r?\n\s+(?!path:)\w+:.*)*\r?\n\s+path:\s*(?

\S+)"); - var hit = inline.Success ? inline : nested; - Assert.True(hit.Success, - $"the deployment template's {probe} names no httpGet path this test can read — update " - + "the parse deliberately rather than letting it stop matching its subject."); - return hit.Groups["p"].Value.Trim(); - } -} diff --git a/test/Memex.Portal.Shared.Test/ProcessLivenessHeartbeatTest.cs b/test/Memex.Portal.Shared.Test/ProcessLivenessHeartbeatTest.cs deleted file mode 100644 index 5d7a5ddd0f..0000000000 --- a/test/Memex.Portal.Shared.Test/ProcessLivenessHeartbeatTest.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using Memex.Portal.ServiceDefaults; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -///

-/// 🚨 An instrument nobody arms is the same silence one step later. Everything here drives the -/// REAL composition over a real host and waits for -/// the heartbeat's own lines to arrive on a real logger — for the reason -/// states: a restatement of the wiring would agree with itself while -/// the deployment published nothing. -/// -/// The reading this exists for is MeshWeaver#4234. A portal logged nothing for the final 90 s -/// of an e2e shard; the page held "Subscribing to {path}…" and the report read the silence as -/// a layout subscription that never completed. The run's own Playwright trace then showed six -/// ordinary static-file GETs and the Blazor reconnect's POST /_blazor/negotiate hanging in the -/// same window — nothing mesh-shaped at all — so the failing unit was the whole HTTP server. Inside -/// the process, nothing could say which. This line can. -/// -public class ProcessLivenessHeartbeatTest -{ - /// - /// The cadence under test — a DRIVER INPUT, not a wait bound (every wait below is - /// ). Short so two ticks land promptly; the production default is - /// . - /// - private const string FastCadenceSeconds = "0.05"; - - [Fact] - public async Task AddServiceDefaults_ArmsTheHeartbeat_AndItKeepsTicking() - { - var lines = new ReplaySubject(); - using var host = Build(FastCadenceSeconds, lines); - await host.StartAsync(TestContext.Current.CancellationToken); - - var beats = lines.Where(l => l.Contains("[LIVENESS] tick=", StringComparison.Ordinal)); - - var first = await beats.Take(1).Should().Within(TestTimeouts.Quick) - .Emit("AddServiceDefaults must ARM the process-liveness heartbeat. No [LIVENESS] line " - + "at all means the host publishes nothing, and a silent window in its log can " - + "then never be told apart from a suspended process (#4234)."); - first.Should().Contain("gap=first"); - - var second = await beats.Skip(1).Take(1).Should().Within(TestTimeouts.Quick) - .Emit("the heartbeat stopped after one line. A single tick proves the thread started, " - + "not that it keeps running — and it is the CONTINUING line whose absence is the " - + "reading."); - - // The second tick is the first one that can carry a gap, and the gap is the field that - // makes a resumed stop self-reporting. - second.Should().Contain("gap="); - second.Should().NotContain("gap=first"); - second.Should().Contain("gen2="); - second.Should().Contain("gcPause="); - second.Should().Contain("poolPending="); - - await host.StopAsync(TestContext.Current.CancellationToken); - } - - /// - /// 🚨 The negative half, and it is not merely "off means off": a host with the heartbeat - /// switched off must SAY SO, so that "configured off" and "this build has no heartbeat" are - /// different bytes in the log. Same rule as the /health census tag one layer up. - /// - [Fact] - public async Task WithTheCadenceSetToZero_NothingTicks_ButTheLogSaysItIsOff() - { - var lines = new ReplaySubject(); - using var host = Build("0", lines); - await host.StartAsync(TestContext.Current.CancellationToken); - - await lines.Where(l => l.Contains("[LIVENESS] disabled", StringComparison.Ordinal)) - .Take(1).Should().Within(TestTimeouts.Quick) - .Emit("a heartbeat switched off must announce it — otherwise its absence is " - + "indistinguishable from a build that never had one."); - - await lines.Where(l => l.Contains("[LIVENESS] tick=", StringComparison.Ordinal)) - .Should().NotEmit(TestTimeouts.Quick / 10, - "the cadence was set to 0, so no tick may be published."); - - await host.StopAsync(TestContext.Current.CancellationToken); - } - - private static IHost Build(string cadenceSeconds, IObserver sink) - { - var builder = Host.CreateApplicationBuilder(); - builder.Configuration.AddInMemoryCollection( - new Dictionary - { - [ProcessLiveness.PeriodSecondsConfigKey] = cadenceSeconds, - // 🚨 INFORMATION, deliberately not Debug or Trace. test/appsettings.json pins - // `MeshWeaver: Warning` for the whole tree, so without this the heartbeat's lines - // are filtered and every assertion below would fail for the wrong reason. Setting - // it to Information rather than lower ALSO pins the contract: a heartbeat quietly - // demoted to Debug would stop appearing here — and would stop appearing in the - // portal logs the reading is taken from, which run MeshWeaver.* at Information. - ["Logging:LogLevel:MeshWeaver.Hosting"] = "Information", - }); - builder.Logging.ClearProviders(); - builder.Logging.AddProvider(new CapturingLoggerProvider(sink)); - builder.AddServiceDefaults(); - return builder.Build(); - } - - /// Collects formatted log messages onto an observer, from whichever thread wrote them. - private sealed class CapturingLoggerProvider(IObserver sink) : ILoggerProvider - { - private readonly object gate = new(); - - public ILogger CreateLogger(string categoryName) => new Collector(sink, gate); - - public void Dispose() { } - - private sealed class Collector(IObserver sink, object gate) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - var message = formatter(state, exception); - lock (gate) - sink.OnNext(message); - } - } - } -} diff --git a/test/Memex.Portal.Shared.Test/PublicHostRedirectAsksAsTheStrangerTest.cs b/test/Memex.Portal.Shared.Test/PublicHostRedirectAsksAsTheStrangerTest.cs deleted file mode 100644 index 601224fef0..0000000000 --- a/test/Memex.Portal.Shared.Test/PublicHostRedirectAsksAsTheStrangerTest.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.Net; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The app host's publicness decision () must ask -/// the mesh AS THE STRANGER it is deciding for — MeshWeaver#5227. -/// -/// The middleware is registered after authentication and BEFORE UserContextMiddleware -/// (it must not mint a guest identity for a request that only bounces), so when it runs no -/// has been established for the request. Its default decision, -/// , captures the ambient identity for the authoritative owner -/// read — and captured null. The read left portal/reads-{meshId} with no identity, the -/// never-null guard refused it (hub=portal/reads-…, message=GetDataRequest, target=Store was -/// posted with no AccessContext), the resolver's fail-open turned the refusal into "not -/// public", and the app host SERVED the public page instead of redirecting it. Measured on -/// memex.meshweaver.cloud 2026-09-26: every run of the prod synthetic probe logged -/// app host: 200 -> (no redirect) https://memex.meshweaver.cloud/Store within a second of -/// a target=Store refusal on the pod that answered. -/// -/// could not see it: it stubs the decision, which is the one -/// thing that was broken. This test runs the DEFAULT decision over a real mesh with real row-level -/// security and no ambient or host identity to fall back on. -/// -public class PublicHostRedirectAsksAsTheStrangerTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Public = "www.example.test"; - private const string App = "memex.example.test"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => ConfigureMeshBase(builder) - .AddMeshNodes( - new MeshNode("OpenSpace") { NodeType = "Space", Name = "Open space" }, - AssignmentNodeFactory.Policy("OpenSpace", new PartitionAccessPolicy { PublicRead = true }), - new MeshNode("Guide", "OpenSpace") - { - NodeType = "Markdown", Name = "Guide", - Content = new MarkdownContent { Content = "A public page." }, - }, - new MeshNode("ClosedSpace") { NodeType = "Space", Name = "Closed space" }); - - protected override Task SetupAccessRightsAsync() => Task.CompletedTask; - - private WebApplication Host() - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [PublicSite.PublicHostKey] = Public, - }); - // The portal's request services resolve the ROOT mesh hub for a request outside a circuit. - builder.Services.AddSingleton(Mesh); - var app = builder.Build(); - app.UseRouting(); - app.UsePublicHostRedirect(); - app.MapGet("/{**path}", (string? path) => Results.Text($"page:{path}", "text/html")); - app.Start(); - return app; - } - - private static HttpRequestMessage StrangerOnTheAppHost(string path) - { - var request = new HttpRequestMessage(HttpMethod.Get, path); - request.Headers.Host = App; - request.Headers.TryAddWithoutValidation("Accept", "text/html"); - return request; - } - - /// - /// Nothing to fall back on: no ambient context, no circuit context and no test-host identity — - /// the state of a request thread before UserContextMiddleware has run. - /// - /// 🚨 The path-resolution cache is WARMED first, as it is on any portal that has served a - /// page. A cold IPathResolver answers from its query's system-scoped continuation, so the - /// owner read would ride system-security and the old code would pass (the trap recorded in - /// Doc/Architecture/AccessContextPropagation for #5227's earlier repros). - /// - private async Task NoIdentityAnywhere(string path) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - await SeoResolver.Resolve(Mesh, path.Trim('/')).Should().Emit(); - access.ClearHostIdentity(); - access.SetContext(null); - access.SetCircuitContext(null); - return access; - } - - private void Restore(AccessService access) - { - access.SetContext(TestUsers.Admin); - access.SetHostIdentity(TestUsers.Admin); - } - - [Theory] - [InlineData("/OpenSpace")] - [InlineData("/OpenSpace/Guide")] - public async Task APublicPageAskedForOnTheAppHost_IsRedirectedToThePublicHost(string path) - { - var access = await NoIdentityAnywhere(path); - try - { - using var app = Host(); - var response = await app.GetTestClient().SendAsync(StrangerOnTheAppHost(path)); - - Assert.Equal(HttpStatusCode.MovedPermanently, response.StatusCode); - Assert.Equal($"https://{Public}{path}", response.Headers.Location?.ToString()); - } - finally - { - Restore(access); - } - } - - /// - /// The control that keeps the fix honest: asking as the stranger must not widen anything. A - /// page the anonymous gate refuses stays on the app host (its own sign-in handling answers). - /// - [Fact] - public async Task APrivatePage_StaysOnTheAppHost() - { - var access = await NoIdentityAnywhere("/ClosedSpace"); - try - { - using var app = Host(); - var response = await app.GetTestClient().SendAsync(StrangerOnTheAppHost("/ClosedSpace")); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("page:ClosedSpace", await response.Content.ReadAsStringAsync()); - } - finally - { - Restore(access); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/PublicSiteTest.cs b/test/Memex.Portal.Shared.Test/PublicSiteTest.cs deleted file mode 100644 index ef9c6afdf5..0000000000 --- a/test/Memex.Portal.Shared.Test/PublicSiteTest.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System.Net; -using System.Reactive.Linq; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Seo; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the two-host rule (): with a public host configured, the app host -/// sends a stranger's request for a public page to the public host with a permanent redirect, -/// declares itself off-limits to crawlers, and every absolute crawler-facing URL names the public -/// host — while a deployment with ONE host behaves exactly as before. Also pins that HEAD -/// is answered (it was a 405 from every page, measured 2026-09-11). -/// -public class PublicSiteTest -{ - private const string Public = "www.example.test"; - private const string App = "memex.example.test"; - - private static IConfiguration Config(string? publicHost, string? landing = null) - => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [PublicSite.PublicHostKey] = publicHost, - [PublicSite.LandingPathKey] = landing, - }).Build(); - - private static HttpRequest RequestOn(string host, string path = "/") - { - var http = new DefaultHttpContext(); - http.Request.Scheme = "https"; - http.Request.Host = new HostString(host); - http.Request.Path = path; - return http.Request; - } - - [Fact] - public void WithNoPublicHost_TheRequestHostIsCanonical_AndNothingIsAppOnly() - { - var configuration = Config(null); - Assert.Equal("https://memex.example.test", PublicSite.CanonicalBaseUrl(configuration, RequestOn(App))); - Assert.False(PublicSite.IsAppOnlyHost(configuration, RequestOn(App))); - } - - [Fact] - public void WithAPublicHost_ItIsCanonicalFromEveryHost_AndTheOtherHostIsAppOnly() - { - var configuration = Config(Public); - Assert.Equal("https://www.example.test", PublicSite.CanonicalBaseUrl(configuration, RequestOn(App))); - Assert.Equal("https://www.example.test", PublicSite.CanonicalBaseUrl(configuration, RequestOn(Public))); - Assert.True(PublicSite.IsAppOnlyHost(configuration, RequestOn(App))); - Assert.False(PublicSite.IsAppOnlyHost(configuration, RequestOn("WWW.EXAMPLE.TEST"))); - } - - [Fact] - public void TheRootIsTheLandingNode_OnlyWhenOneIsConfigured() - { - Assert.Equal("MeshWeaver", PublicSite.NodePathFor(Config(Public, "/MeshWeaver/"), "/")); - Assert.Equal("", PublicSite.NodePathFor(Config(Public), "/")); - Assert.Equal("Doc/Architecture", PublicSite.NodePathFor(Config(Public, "MeshWeaver"), "/Doc/Architecture/")); - } - - /// - /// The pipeline under test: the redirect and HEAD middleware over a TestServer, a stub - /// publicness decision (a real one asks the mesh through the anonymous gate), and one ordinary - /// page route so "not redirected" is proved against something that answers. - /// - private static WebApplication Host(string? publicHost, string? landing = null) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [PublicSite.PublicHostKey] = publicHost, - [PublicSite.LandingPathKey] = landing, - }); - var app = builder.Build(); - // HEAD→GET must run BEFORE routing: an endpoint is matched by method, and a GET-only page - // route never matches HEAD. Explicit UseRouting here is what the portal's pipeline has; - // without it WebApplication would insert routing ahead of every middleware. - app.UseHeadAsGet(); - app.UseRouting(); - app.UsePublicHostRedirect((_, nodePath) => - Observable.Return(nodePath is "PublicSpace" or "PublicSpace/Guide" or "Landing")); - app.MapSeo(); - app.MapGet("/{**path}", (string? path) => Results.Text($"page:{path}", "text/html")); - app.Start(); - return app; - } - - private static HttpRequestMessage On(string host, string path, string method = "GET", string accept = "text/html") - { - var request = new HttpRequestMessage(new HttpMethod(method), path); - request.Headers.Host = host; - request.Headers.TryAddWithoutValidation("Accept", accept); - return request; - } - - [Fact] - public async Task AStrangerAskingTheAppHostForAPublicPage_IsSentToThePublicHost_Permanently() - { - using var app = Host(Public); - var client = app.GetTestClient(); - var response = await client.SendAsync(On(App, "/PublicSpace/Guide?tab=1")); - Assert.Equal(HttpStatusCode.MovedPermanently, response.StatusCode); - Assert.Equal("https://www.example.test/PublicSpace/Guide?tab=1", response.Headers.Location?.ToString()); - } - - [Fact] - public async Task ThePublicHostServesThePage_AndNeverRedirectsToItself() - { - using var app = Host(Public); - var response = await app.GetTestClient().SendAsync(On(Public, "/PublicSpace/Guide")); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("page:PublicSpace/Guide", await response.Content.ReadAsStringAsync()); - } - - [Fact] - public async Task APrivatePage_AnApiRoute_AndANonHtmlRequest_StayOnTheAppHost() - { - using var app = Host(Public); - var client = app.GetTestClient(); - // Private: the decision says no → the app's own handling (sign-in) answers, no redirect. - Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(On(App, "/PrivateSpace"))).StatusCode); - // Not a node path. - Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(On(App, "/api/version"))).StatusCode); - // A JSON client is never bounced between hosts. - Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(On(App, "/PublicSpace", accept: "application/json"))).StatusCode); - } - - [Fact] - public async Task WithOneHost_NothingRedirects() - { - using var app = Host(publicHost: null); - var response = await app.GetTestClient().SendAsync(On(App, "/PublicSpace")); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task TheRootOnTheAppHost_IsTheLandingPage_AndRedirectsLikeOne() - { - using var app = Host(Public, landing: "Landing"); - var response = await app.GetTestClient().SendAsync(On(App, "/")); - Assert.Equal(HttpStatusCode.MovedPermanently, response.StatusCode); - Assert.Equal("https://www.example.test/", response.Headers.Location?.ToString()); - } - - [Fact] - public async Task RobotsOnTheAppHost_DisallowsEverything_AndPointsAtThePublicSitemap() - { - using var app = Host(Public); - var body = await (await app.GetTestClient().SendAsync(On(App, "/robots.txt", accept: "*/*"))).Content.ReadAsStringAsync(); - Assert.Contains("Disallow: /\n", body.Replace("\r\n", "\n") + "\n"); - Assert.Contains("Sitemap: https://www.example.test/sitemap.xml", body); - Assert.DoesNotContain("Disallow: /login", body); - } - - [Fact] - public async Task RobotsOnThePublicHost_KeepsThePageSurfaceOpen() - { - using var app = Host(Public); - var body = await (await app.GetTestClient().SendAsync(On(Public, "/robots.txt", accept: "*/*"))).Content.ReadAsStringAsync(); - Assert.Contains("Disallow: /login", body); - Assert.Contains("Disallow: /welcome", body); - Assert.Contains("Disallow: /api/", body); - Assert.DoesNotContain("Disallow: /\n", body.Replace("\r\n", "\n")); - Assert.Contains("Sitemap: https://www.example.test/sitemap.xml", body); - } - - [Fact] - public async Task Head_IsAnsweredLikeGet_WithNoBody() - { - using var app = Host(publicHost: null); - var response = await app.GetTestClient().SendAsync(On(App, "/Doc", method: "HEAD")); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("", await response.Content.ReadAsStringAsync()); - } -} diff --git a/test/Memex.Portal.Shared.Test/PublicationGenerationTest.cs b/test/Memex.Portal.Shared.Test/PublicationGenerationTest.cs deleted file mode 100644 index c4b187b77c..0000000000 --- a/test/Memex.Portal.Shared.Test/PublicationGenerationTest.cs +++ /dev/null @@ -1,467 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.IO.Compression; -using System.Linq; -using MeshWeaver.Hosting; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A publication lives in a GENERATION directory, and every reader resolves the pointer -/// before it composes a single path (#3461). -/// -/// The flat layout replaces a publication IN PLACE: the publisher deletes _complete, -/// uploads over the live files, and re-seals. Two publishers interleaved on one prefix therefore -/// both unseal, both upload, and the last to seal covers a directory holding SOME OF EACH one's -/// bytes — one seal, one generation, self-consistent to every consumer and wrong. #3496's -/// byte-level postcondition turns that from a silent mix into a loud refusal, but it is a -/// postcondition, not mutual exclusion. -/// -/// The layout that removes it writes each publication into its OWN directory and moves a -/// one-line pointer, _current, last. Two publishers never write the same bytes, so a mix -/// becomes unrepresentable rather than merely detectable, and a reader that resolves the -/// pointer a moment early gets the previous generation — intact, sealed, still on the shelf. -/// -/// 🚨 This is the READER half, and it lands FIRST -/// (Sealed Publication Generations, phase 1). No -/// producer writes a pointer yet. These cases are what make that phase real rather than dead code: -/// they build the generation layout by hand and assert the portal serves it — including the arm -/// that would catch the one silent way to get this wrong, a reader that resolves the pointer and -/// then composes its file paths under the SOURCE directory anyway. -/// -public class PublicationGenerationTest -{ - private const string Identity = "s3461generation00000000000000000"; - private const string Source = "plugins"; - private const string Gen1 = "Systemorph-MeshWeaver.Plugins-1111-1"; - private const string Gen2 = "Systemorph-MeshWeaver-2222-1"; - - /// - /// The whole point, in one case: the flat directory still holds a COMPLETE, SEALED, OLDER - /// publication (that is exactly what phase 2 leaves behind for readers pinned to an older - /// platform), the pointer names a generation, and every read — the seal's listing, the bundle - /// bytes, the module set, the module bytes — must come from the GENERATION. - /// - /// 🚨 A reader that resolves the pointer for the LISTING and then composes bundle paths - /// under the source directory passes a naive test and is wrong in the worst way: it serves the - /// flat publication's bytes under the generation's ETag, which is the mix the generation - /// exists to prevent, wearing a token that says it is not. So the fixture makes the two - /// publications differ in BYTES under the SAME file names. - /// - [Fact] - public void ThePointedToGeneration_IsWhatIsRead_BytesIncluded() - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - - // The flat publication — complete and sealed, and NOT what should be read. - WriteBundle(Path.Combine(source, "Store.zip"), "flat-store"); - Seal(source, "Store.zip"); - WriteModules(source, ("ai.module.nupkg", "flat-ai")); - - // The generation — same names, different bytes, a different module set. - var generation = Path.Combine(source, Gen1); - Directory.CreateDirectory(generation); - WriteBundle(Path.Combine(generation, "Store.zip"), "gen-store"); - WriteBundle(Path.Combine(generation, "Edu.zip"), "gen-edu"); - Seal(generation, "Store.zip", "Edu.zip"); - WriteModules(generation, ("ai.module.nupkg", "gen-ai"), ("maps.module.nupkg", "gen-maps")); - - Point(source, Gen1); - - Assert.Equal(generation, ShippedPrebuiltBundles.PublicationDirectoryOf(source)); - - var reading = PublishedBundleCatalogue.SealedPublicationOf(source); - Assert.Equal(generation, reading.Directory); - Assert.Equal(["Store.zip", "Edu.zip"], reading.Bundles); - // The flat seal listed ONE bundle; reading it would have answered a single-name list. - Assert.Equal("gen-store", PluginOf(Path.Combine(reading.Directory!, "Store.zip"))); - - var modules = PublishedBundleCatalogue.SealedModulesOf(source); - Assert.Equal(generation, modules.Directory); - Assert.Equal(["ai.module.nupkg", "maps.module.nupkg"], modules.Modules); - Assert.Equal("gen-ai", PluginOf(Path.Combine( - modules.Directory!, PublishedBundleCatalogue.ModulesDirectoryName, "ai.module.nupkg"))); - } - - /// - /// No pointer is the FLAT layout, unchanged — this is what every publication on every share - /// looks like today, and it must keep reading exactly as it did. The resolution is opt-in by - /// the writer, not by the reader. - /// - [Fact] - public void NoPointer_IsTheFlatLayout_Unchanged() - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - WriteBundle(Path.Combine(source, "Store.zip"), "flat-store"); - Seal(source, "Store.zip"); - - Assert.Equal(source, ShippedPrebuiltBundles.PublicationDirectoryOf(source)); - var reading = PublishedBundleCatalogue.SealedPublicationOf(source); - Assert.Equal(source, reading.Directory); - Assert.Equal(["Store.zip"], reading.Bundles); - } - - /// - /// 🚨 A pointer must never be able to address bytes OUTSIDE its own source directory. Each of - /// these is refused and falls back to the flat publication — never followed, and never an - /// exception that would take a boot down. The escape fixture puts a real, sealed publication - /// at the escape target, so "refused" is asserted as *those bytes were not served*, not merely - /// as "no crash". - /// - [Theory] - [InlineData("..")] - [InlineData(".")] - [InlineData("../secrets")] - [InlineData("..\\secrets")] - [InlineData("sub/dir")] - [InlineData("/etc")] - public void APointerThatIsNotABareName_IsRefused(string pointer) - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - WriteBundle(Path.Combine(source, "Store.zip"), "flat-store"); - Seal(source, "Store.zip"); - - // A complete, sealed publication a followed escape would reach. - var elsewhere = root.Source(Identity, "secrets"); - WriteBundle(Path.Combine(elsewhere, "Secret.zip"), "secret"); - Seal(elsewhere, "Secret.zip"); - - File.WriteAllText( - Path.Combine(source, ShippedPrebuiltBundles.PublicationPointerFileName), pointer + "\n"); - - Assert.Equal(source, ShippedPrebuiltBundles.PublicationDirectoryOf(source)); - var reading = PublishedBundleCatalogue.SealedPublicationOf(source); - Assert.Equal(["Store.zip"], reading.Bundles); - Assert.DoesNotContain("Secret.zip", reading.Bundles!); - } - - /// - /// A pointer naming a generation that is NOT on disk — a retention sweep that deleted what the - /// pointer still names, or a pointer written before its directory — falls back to the flat - /// publication rather than answering "torn". While the flat copy exists (phase 2) that is the - /// previous behaviour exactly; once it does not, the source directory carries no sentinel and - /// the reader answers "being republished right now", which every consumer already backs off - /// on. Never a mix, in either phase. - /// - [Fact] - public void APointerToAnAbsentGeneration_FallsBackToTheFlatPublication() - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - WriteBundle(Path.Combine(source, "Store.zip"), "flat-store"); - Seal(source, "Store.zip"); - Point(source, "Systemorph-MeshWeaver-swept-away-1"); - - Assert.Equal(source, ShippedPrebuiltBundles.PublicationDirectoryOf(source)); - Assert.Equal(["Store.zip"], PublishedBundleCatalogue.SealedPublicationOf(source).Bundles); - } - - /// - /// #7015 review — the same broken pointer over a SEALED flat fallback still serves the flat - /// bytes, but the offer-discovery walk reports the source SKIPPED: the publication the pointer - /// names was never read, so what it names is unknown, and a never-adopted type only it names - /// must not be classified as authored content. The control — the same flat publication with no - /// pointer at all — is a complete read. - /// - [Fact] - public void APointerToAnAbsentGeneration_OverASealedFallback_IsIncompleteDiscovery() - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - WriteBundle(Path.Combine(source, "Store.zip"), "flat-store"); - Seal(source, "Store.zip"); - Point(source, "Systemorph-MeshWeaver-swept-away-1"); - - var skipped = new System.Collections.Generic.List(); - var bundles = ShippedPrebuiltBundles.CompletePublishedBundlesOf( - Path.Combine(root.Path, Identity), logger: null, TestContext.Current.CancellationToken, - onSourceSkipped: skipped.Add); - - Assert.Equal(["Store.zip"], bundles.Select(Path.GetFileName)); - Assert.Equal([source], skipped); - } - - [Fact] - public void ASealedFlatPublicationWithNoPointer_IsACompleteRead() - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - WriteBundle(Path.Combine(source, "Store.zip"), "flat-store"); - Seal(source, "Store.zip"); - - var skipped = new System.Collections.Generic.List(); - var bundles = ShippedPrebuiltBundles.CompletePublishedBundlesOf( - Path.Combine(root.Path, Identity), logger: null, TestContext.Current.CancellationToken, - onSourceSkipped: skipped.Add); - - Assert.Equal(["Store.zip"], bundles.Select(Path.GetFileName)); - Assert.Empty(skipped); - } - - /// - /// And with NO flat copy behind it (phase 3), the same fallback lands on a directory with no - /// sentinel — which is read as the transient republish window, the 503 every consumer already - /// waits out. The point is that the degradation is to a WAIT, never to a partial read. - /// - [Fact] - public void APointerToAnAbsentGeneration_WithNoFlatCopy_ReadsAsTheRepublishWindow() - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - Point(source, "Systemorph-MeshWeaver-swept-away-1"); - - var reading = PublishedBundleCatalogue.SealedPublicationOf(source); - Assert.Null(reading.Bundles); - Assert.Contains("being republished right now", reading.TornReason); - } - - /// - /// 🚨 An empty or blank pointer is the ONE window this layout has: a pointer being replaced can - /// be read short. It resolves to the flat directory — the generation that applied a moment - /// ago — and never to a mix. That is the whole trade: ~90 seconds of an in-place replacement - /// becomes one small file write, and the failure mode changes from "a sealed mix nobody can - /// detect" to "read it again". - /// - [Theory] - [InlineData("")] - [InlineData("\n")] - [InlineData(" \n \n")] - public void ABlankPointer_ResolvesToTheDirectoryItIsIn(string content) - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - WriteBundle(Path.Combine(source, "Store.zip"), "flat-store"); - Seal(source, "Store.zip"); - File.WriteAllText( - Path.Combine(source, ShippedPrebuiltBundles.PublicationPointerFileName), content); - - Assert.Equal(source, ShippedPrebuiltBundles.PublicationDirectoryOf(source)); - Assert.Equal(["Store.zip"], PublishedBundleCatalogue.SealedPublicationOf(source).Bundles); - } - - /// - /// RETENTION: an older generation stays on the shelf so a reader that resolved the pointer - /// before the swap can still finish its N+1 read. It must be invisible to everyone else — the - /// listing, the bytes and the generation token all come from the pointed-to one, and moving - /// the pointer between the two is what a consumer's If-Match sees as a moved - /// publication (#3401). - /// - [Fact] - public void ARetainedOlderGeneration_IsNeverServed_AndMovingThePointerMovesTheGeneration() - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - - var older = Path.Combine(source, Gen1); - Directory.CreateDirectory(older); - WriteBundle(Path.Combine(older, "Store.zip"), "older"); - Seal(older, "Store.zip"); - - var newer = Path.Combine(source, Gen2); - Directory.CreateDirectory(newer); - WriteBundle(Path.Combine(newer, "Store.zip"), "newer"); - WriteBundle(Path.Combine(newer, "Edu.zip"), "newer-edu"); - Seal(newer, "Store.zip", "Edu.zip"); - - Point(source, Gen1); - var first = PublishedBundleCatalogue.SealedPublicationOf(source); - Assert.Equal(["Store.zip"], first.Bundles); - Assert.Equal("older", PluginOf(Path.Combine(first.Directory!, "Store.zip"))); - - Point(source, Gen2); - var second = PublishedBundleCatalogue.SealedPublicationOf(source); - Assert.Equal(["Store.zip", "Edu.zip"], second.Bundles); - Assert.Equal("newer", PluginOf(Path.Combine(second.Directory!, "Store.zip"))); - - // The generation token is what a consumer pins; a moved pointer MUST move it, or the - // 412 that stops an N+1 read spanning two publications never fires. - Assert.NotEqual(first.Generation, second.Generation); - - // …and the older generation is still readable by whoever already resolved it. That is the - // retention contract: a pointer swap must never delete what a reader is mid-way through. - Assert.Equal("older", PluginOf(Path.Combine(older, "Store.zip"))); - } - - /// - /// The BOOT SEEDER reads the same way. It walks <root>/<identity>/ for - /// source directories, and a generation directory is one level further down — so the seeder - /// must resolve, and must not mistake a retained generation for a source of its own. - /// - [Fact] - public void TheBootSeeder_SeesOnlyThePointedToGenerationsBundles() - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - WriteBundle(Path.Combine(source, "Store.zip"), "flat-store"); - Seal(source, "Store.zip"); - var generation = Path.Combine(source, Gen1); - Directory.CreateDirectory(generation); - WriteBundle(Path.Combine(generation, "Store.zip"), "gen-store"); - WriteBundle(Path.Combine(generation, "Edu.zip"), "gen-edu"); - Seal(generation, "Store.zip", "Edu.zip"); - Point(source, Gen1); - - // SealedBundlesOf is the rule the seeder and the registry share — "what may be served from - // this directory" — so pinning it here pins both. - var sealedNames = PublishedBundleCatalogue.SealedBundlesOf(source); - Assert.Equal(["Store.zip", "Edu.zip"], sealedNames); - var resolved = ShippedPrebuiltBundles.PublicationDirectoryOf(source); - Assert.All(sealedNames!, n => Assert.True(File.Exists(Path.Combine(resolved, n)))); - Assert.Equal("gen-store", PluginOf(Path.Combine(resolved, "Store.zip"))); - } - - // ── fixtures ────────────────────────────────────────────────────────────────────────────── - - /// - /// 🚨 The SEAL INDEX is a reader too, and phase 1 missed it (#3461, phase 3). - /// SealedPublicationIndex.ReadFor is what SealedSyncGate decides on and what - /// SeedPublishedRoot hands the sync reconciler; it read the three markers and the - /// sentinel straight out of the SOURCE directory while the same sweep read the BUNDLES through - /// PublicationDirectoryOf. Two readers of one source, answering about two different - /// publications. - /// - /// While the flat compatibility copy exists that is merely inconsistent — the same run - /// writes both, so the markers usually agree. Once the copy is dropped (phase 5) this reader - /// finds no sentinel at all, reports every source unsealed, and SealedSyncGate then sees - /// an EMPTY mine and returns Go for every repository — silently removing the - /// whole "advance only to the commit sealed for this instance" rule at the moment it matters - /// most. So the fixture makes the two publications name DIFFERENT COMMITS: the verdict is - /// which commit came back, not whether the call threw. - /// - [Fact] - public void TheSealIndex_ReadsTheGenerationsMarkers_NotTheFlatCopies() - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - - // The flat publication: complete, sealed, and at an OLDER commit. - WriteBundle(Path.Combine(source, "Store.zip"), "flat-store"); - Seal(source, "Store.zip"); - Marker(source, SealedPublicationIndex.SourceCommitMarkerFileName, "24c2d024"); - Marker(source, SealedPublicationIndex.RepositoryMarkerFileName, "Systemorph/MeshWeaver.Plugins"); - - // The generation the pointer names: the publication that actually applies. - var generation = Path.Combine(source, Gen1); - Directory.CreateDirectory(generation); - WriteBundle(Path.Combine(generation, "Store.zip"), "gen-store"); - Seal(generation, "Store.zip"); - Marker(generation, SealedPublicationIndex.SourceCommitMarkerFileName, "4b97be19"); - Marker(generation, SealedPublicationIndex.RepositoryMarkerFileName, "Systemorph/MeshWeaver.Plugins"); - - Point(source, Gen1); - - var sealed_ = SealedPublicationIndex.ReadFor(root.Path, Identity); - var only = Assert.Single(sealed_); - Assert.Equal(Source, only.Source); - Assert.True(only.IsSealed, only.Refusal); - Assert.Equal("4b97be19", only.SourceCommit); - } - - /// - /// The phase-5 shape, and the reason the case above is not cosmetic: with NO flat copy behind - /// the pointer, an unrouted index reports the source UNSEALED — which SealedSyncGate - /// reads as "no seal attributable to this repository", i.e. Go. A routed one reads the - /// generation and the gate keeps gating. - /// - [Fact] - public void TheSealIndex_WithNoFlatCopyBehindThePointer_StillReadsTheGenerationAsSealed() - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - var generation = Path.Combine(source, Gen1); - Directory.CreateDirectory(generation); - WriteBundle(Path.Combine(generation, "Store.zip"), "gen-store"); - Seal(generation, "Store.zip"); - Marker(generation, SealedPublicationIndex.SourceCommitMarkerFileName, "4b97be19"); - Point(source, Gen1); - - var only = Assert.Single(SealedPublicationIndex.ReadFor(root.Path, Identity)); - Assert.True(only.IsSealed, - "an unrouted index finds no sentinel here and reports the source unsealed — which " - + "SealedSyncGate reads as 'this gate does not apply' and lets every source of the " - + $"repository advance ungated. Refusal was: {only.Refusal}"); - Assert.Equal("4b97be19", only.SourceCommit); - } - - /// The control: with no pointer, the index reads the flat copy exactly as it always - /// has. Resolution is opt-in by the WRITER. - [Fact] - public void TheSealIndex_WithNoPointer_ReadsTheFlatCopy() - { - using var root = new TempRoot(); - var source = root.Source(Identity, Source); - WriteBundle(Path.Combine(source, "Store.zip"), "flat-store"); - Seal(source, "Store.zip"); - Marker(source, SealedPublicationIndex.SourceCommitMarkerFileName, "24c2d024"); - - var only = Assert.Single(SealedPublicationIndex.ReadFor(root.Path, Identity)); - Assert.True(only.IsSealed, only.Refusal); - Assert.Equal("24c2d024", only.SourceCommit); - } - - private static void Marker(string directory, string name, string value) => - File.WriteAllText(Path.Combine(directory, name), value + "\n"); - - private static void Seal(string directory, params string[] bundles) => - File.WriteAllText( - Path.Combine(directory, ShippedPrebuiltBundles.CompletionSentinelFileName), - string.Join("\n", bundles) + "\n"); - - private static void Point(string sourceDirectory, string generation) => - File.WriteAllText( - Path.Combine(sourceDirectory, ShippedPrebuiltBundles.PublicationPointerFileName), - generation + "\n"); - - private static void WriteModules(string publicationDirectory, params (string Name, string Plugin)[] modules) - { - var dir = Path.Combine(publicationDirectory, PublishedBundleCatalogue.ModulesDirectoryName); - Directory.CreateDirectory(dir); - foreach (var (name, plugin) in modules) - WriteBundle(Path.Combine(dir, name), plugin); - File.WriteAllText( - Path.Combine(dir, PublishedBundleCatalogue.ModulesIndexFileName), - string.Join("\n", modules.Select(m => m.Name)) + "\n"); - } - - /// A bundle whose MANIFEST names which fixture wrote it — so "which publication's - /// bytes were served" is a fact read off the archive, never inferred from the file name. - private static void WriteBundle(string path, string plugin) - { - using var zip = ZipFile.Open(path, ZipArchiveMode.Create); - using var w = new StreamWriter(zip.CreateEntry("meshweaver/manifest.json").Open()); - w.Write($"{{\"plugin\":\"{plugin}\"}}"); - } - - private static string PluginOf(string bundlePath) - { - using var zip = ZipFile.OpenRead(bundlePath); - using var r = new StreamReader(zip.GetEntry("meshweaver/manifest.json")!.Open()); - var json = r.ReadToEnd(); - return System.Text.Json.JsonDocument.Parse(json).RootElement.GetProperty("plugin").GetString()!; - } - - private sealed class TempRoot : IDisposable - { - public string Path { get; } = System.IO.Path.Combine( - System.IO.Path.GetTempPath(), "mw-gen-" + Guid.NewGuid().ToString("N")); - - public string Source(string identity, string source) - { - var dir = System.IO.Path.Combine(Path, identity, source); - Directory.CreateDirectory(dir); - return dir; - } - - public void Dispose() - { - try { Directory.Delete(Path, recursive: true); } catch { /* best effort */ } - } - } -} diff --git a/test/Memex.Portal.Shared.Test/PublicationSealReadTest.cs b/test/Memex.Portal.Shared.Test/PublicationSealReadTest.cs deleted file mode 100644 index 6723bd68aa..0000000000 --- a/test/Memex.Portal.Shared.Test/PublicationSealReadTest.cs +++ /dev/null @@ -1,74 +0,0 @@ -using MeshWeaver.Hosting; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// Missing seals are decided by the file open, including removal after observation. -public class PublicationSealReadTest : IDisposable -{ - private readonly string root = Path.Combine(Path.GetTempPath(), "mw-seal-read-" + Guid.NewGuid().ToString("N")); - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void ASealRemovedAfterObservation_IsAbsentAtTheOpen(bool removeDirectory) - { - var directory = Path.Combine(root, "identity", "plugins"); - Directory.CreateDirectory(directory); - var seal = Path.Combine(directory, ShippedPrebuiltBundles.CompletionSentinelFileName); - File.WriteAllText(seal, "Store.zip\n"); - Assert.True(File.Exists(seal)); - - // Remove at the filesystem operation, not before entering the reader. Even a reader - // that reinstates File.Exists observes a present seal before this operation removes it. - var opened = false; - var lines = PublishedBundleCatalogue.ReadSealLines(seal, path => - { - Assert.True(File.Exists(path)); - opened = true; - if (removeDirectory) - Directory.Delete(Path.Combine(root, "identity"), recursive: true); - else - File.Delete(path); - return File.ReadAllLines(path); - }); - Assert.True(opened); - Assert.Null(lines); - Assert.Null(PublishedBundleCatalogue.SealedPublicationOf(directory).Bundles); - Assert.Null(PublishedBundleCatalogue.SealedBundlesOf(directory)); - } - - [Fact] - public void AReadableSeal_PreservesItsListing() - { - Directory.CreateDirectory(root); - var seal = Path.Combine(root, ShippedPrebuiltBundles.CompletionSentinelFileName); - File.WriteAllText(seal, " Store.zip \n\nEdu.zip\n"); - File.WriteAllText(Path.Combine(root, "Store.zip"), "store"); - File.WriteAllText(Path.Combine(root, "Edu.zip"), "education"); - - var lines = PublishedBundleCatalogue.ReadSealLines(seal); - Assert.NotNull(lines); - Assert.Equal([" Store.zip ", "", "Edu.zip"], lines); - Assert.Equal(["Store.zip", "Edu.zip"], PublishedBundleCatalogue.SealedPublicationOf(root).Bundles); - Assert.Equal(["Store.zip", "Edu.zip"], PublishedBundleCatalogue.SealedBundlesOf(root)); - } - - [Fact] - public void AnUnreadableExistingSeal_IsNotReportedAsAbsent() - { - Directory.CreateDirectory(root); - var seal = Path.Combine(root, ShippedPrebuiltBundles.CompletionSentinelFileName); - File.WriteAllText(seal, "Store.zip\n"); - using var held = File.Open(seal, FileMode.Open, FileAccess.ReadWrite, FileShare.None); - - Assert.Throws(() => PublishedBundleCatalogue.ReadSealLines(seal)); - } - - public void Dispose() - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } -} diff --git a/test/Memex.Portal.Shared.Test/PublishStorageFaultIsNotAConflictTest.cs b/test/Memex.Portal.Shared.Test/PublishStorageFaultIsNotAConflictTest.cs deleted file mode 100644 index 0998d8ee26..0000000000 --- a/test/Memex.Portal.Shared.Test/PublishStorageFaultIsNotAConflictTest.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.IO; -using Memex.Portal.Shared.Api; -using Microsoft.AspNetCore.Http; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #6640 — a module publish whose shelf landing hit a STORAGE fault (measured: Access to the path -/// '/data/modules/activation.d/MeshWeaver.Markdown.Export.json' is denied.) was answered 409, which -/// reads as a version clash. A storage fault is the replica failing to write: 500, saying so. A real -/// refusal of the upload stays 409 — the negative control that the split is not "everything is 500". -/// -public class PublishStorageFaultIsNotAConflictTest -{ - private const string Denied = "Access to the path '/data/modules/activation.d/MeshWeaver.Markdown.Export.json' is denied."; - - public static TheoryData StorageFaults => new() - { - new UnauthorizedAccessException(Denied), - new IOException("The process cannot access the file because it is being used by another process."), - new InvalidOperationException("landing faulted", new UnauthorizedAccessException(Denied)), - new AggregateException(new UnauthorizedAccessException(Denied)), - }; - - /// Every shape of a storage fault is a 500 that names the underlying error. - [Theory] - [MemberData(nameof(StorageFaults))] - public void AStorageFault_IsA500_NamingTheError(Exception fault) - { - var result = Assert.IsAssignableFrom(PluginBundleEndpoints.LandingFailure(fault)); - Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); - var value = Assert.IsAssignableFrom(result).Value!; - var error = (string)value.GetType().GetProperty("error")!.GetValue(value)!; - Assert.Contains("infrastructure fault", error, StringComparison.Ordinal); - Assert.Contains(fault is UnauthorizedAccessException or AggregateException or InvalidOperationException - ? Denied : "being used by another process", error, StringComparison.Ordinal); - } - - /// A refusal of the upload itself stays a 409 carrying its own reason. - [Fact] - public void ARefusalOfTheUpload_StaysA409() - { - var refusal = new InvalidOperationException("module name 'x y' is malformed"); - var result = Assert.IsAssignableFrom(PluginBundleEndpoints.LandingFailure(refusal)); - Assert.Equal(StatusCodes.Status409Conflict, result.StatusCode); - } -} diff --git a/test/Memex.Portal.Shared.Test/PublishedModuleVersionShelfTest.cs b/test/Memex.Portal.Shared.Test/PublishedModuleVersionShelfTest.cs deleted file mode 100644 index 63c1ce4403..0000000000 --- a/test/Memex.Portal.Shared.Test/PublishedModuleVersionShelfTest.cs +++ /dev/null @@ -1,182 +0,0 @@ -#pragma warning disable CS1591 - -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #3996's registry-serving half: keeping a late older publication behind a newer activation head -/// must not turn that retained generation into dead stock. The real authenticated bundle routes -/// advertise both versions and return the bytes of the generation each URL names. -/// -public class PublishedModuleVersionShelfTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Plugin = "VersionedShelf"; - private const string Module = "MeshWeaver.VersionedShelf"; - private const string Source = "Plugins"; - private const string Instance = "versioned-shelf-consumer"; - private const string NewerVersion = "1.7.0"; - private const string OlderVersion = "1.6.1"; - private const string MarkerPath = "wwwroot/generation.txt"; - - private readonly string landingRoot = Path.Combine( - Path.GetTempPath(), "mw-versioned-shelf-" + Guid.NewGuid().ToString("N")); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - Directory.CreateDirectory(landingRoot); - return base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new ModuleLandingService(baseDirectory: landingRoot))); - } - - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - try - { - if (Directory.Exists(landingRoot)) - Directory.Delete(landingRoot, recursive: true); - } - catch - { - // Best-effort temp cleanup must never turn a passing distribution assertion red. - } - } - - private Task RegisterInstance(CancellationToken cancellationToken) => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [$"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:0"] = $"{Source}/*", - }) - .Build()) - .Register("shelf-owner", "Shelf Owner", "owner@test.com", Instance, Instance) - .Select(result => result.RawKey) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(cancellationToken); - - private async Task StartBundleHost(CancellationToken cancellationToken) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton( - Mesh.ServiceProvider.GetRequiredService()); - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(cancellationToken); - return app; - } - - private async Task Shelve(string version, string marker) - { - var assembly = File.ReadAllBytes(typeof(BundleReader).Assembly.Location); - await Mesh.ServiceProvider.GetRequiredService() - .ShelveModule( - Module, - [(Module + ".dll", assembly)], - frameworkMvid: "framework-" + version, - packagePath: $"{Source}/{Plugin}", - version: version, - staticAssets: [(MarkerPath, Encoding.UTF8.GetBytes(marker))]) - .FirstAsync() - .Timeout(TestTimeouts.Convergence).Await(); - } - - private Task InstallOlderRecord(CancellationToken cancellationToken) => - PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = Plugin, - Name = Plugin, - Kind = PackageKind.Content, - TargetPartition = Plugin, - SourceFolder = Plugin, - Version = "older-source-commit", - ReleasedVersion = OlderVersion, - Source = Source, - Module = Module, - }, - [new PackageFile($"{Plugin}/Doc.md", "# Versioned shelf")], - "HEAD") - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(cancellationToken); - - private static async Task Get( - WebApplication app, string route, string key) - { - using var request = new HttpRequestMessage(HttpMethod.Get, route); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - return await app.GetTestClient().SendAsync(request); - } - - [Fact(Timeout = 300_000)] - public async Task HeadAndFallback_AreIndexedNewestFirst_AndEachUrlServesItsGeneration() - { - await Shelve(NewerVersion, "newer"); - await Shelve(OlderVersion, "older"); - await InstallOlderRecord(TestContext.Current.CancellationToken); - var activation = Assert.Single(ModuleActivationSidecar.Read(landingRoot).Entries); - Assert.Equal(NewerVersion, activation.Version); - Assert.Equal(OlderVersion, activation.PreviousVersion); - - var key = await RegisterInstance(TestContext.Current.CancellationToken); - await using var app = await StartBundleHost(TestContext.Current.CancellationToken); - - using var indexResponse = await Get( - app, PluginBundleEndpoints.RoutePrefix + "/index.json", key); - Assert.Equal(HttpStatusCode.OK, indexResponse.StatusCode); - using var index = JsonDocument.Parse(await indexResponse.Content.ReadAsStringAsync()); - var entries = index.RootElement.GetProperty("bundles").EnumerateArray() - .Where(bundle => string.Equals( - bundle.GetProperty("plugin").GetString(), Plugin, - StringComparison.OrdinalIgnoreCase)) - .ToArray(); - Assert.Equal(2, entries.Length); - Assert.Equal(NewerVersion, entries[0].GetProperty("version").GetString()); - Assert.Equal(OlderVersion, entries[1].GetProperty("version").GetString()); - Assert.All(entries, entry => Assert.Equal(Module, - entry.GetProperty("module").GetString())); - - Assert.Equal("newer", await ServedMarker(app, NewerVersion, key)); - Assert.Equal("older", await ServedMarker(app, OlderVersion, key)); - } - - private static async Task ServedMarker( - WebApplication app, string version, string key) - { - using var response = await Get( - app, $"{PluginBundleEndpoints.RoutePrefix}/{Plugin}/{version}", key); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var assets = BundleReader.ReadModuleAssets(await response.Content.ReadAsByteArrayAsync()); - var marker = Assert.Single(assets, asset => asset.RelativePath == MarkerPath); - return Encoding.UTF8.GetString(marker.Bytes); - } -} diff --git a/test/Memex.Portal.Shared.Test/PublishedSurfaceTest.cs b/test/Memex.Portal.Shared.Test/PublishedSurfaceTest.cs deleted file mode 100644 index aa3fbed85b..0000000000 --- a/test/Memex.Portal.Shared.Test/PublishedSurfaceTest.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Linq; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The publication marker's contract: "published to the web" is a QUERY over the Anonymous Read -/// grant, not a location and not a second flag — and the human-facing list is built from the same -/// enumeration the sitemap renders, so the two cannot drift. -/// -public class PublishedSurfaceTest -{ - private static MeshNode Node(string path, string? name = null, object? content = null) => - new(path) { NodeType = "Store/Plugin", Name = name, Content = content }; - - private sealed record PluginLike(string? OgImage = null); - - /// - /// 🚨 PUBLISHING MOVES NOTHING. A page's public URL is its ordinary mesh path — no `Www/` - /// prefix, no relocation. Relocating public content would rewrite every shared link, every - /// canonical and every og:url, which is the opposite of what publishing well requires; this - /// pins that the row's URL is the path itself. - /// - [Fact] - public void ThePublicUrl_IsTheNodesOwnPath_NotARelocatedOne() - { - var page = new PublishedPage(Node("Claims", "Claims"), "Claims"); - - var row = PublishedSettingsTab.RowFor(page); - - Assert.Equal("/Claims", row.Url); - Assert.DoesNotContain("Www", row.Url, System.StringComparison.OrdinalIgnoreCase); - } - - /// - /// The share-image column answers the question people actually ask — is this the card we made, - /// or the one the portal drew? It uses the SAME precedence as the crawler-facing head, so the - /// column cannot claim one thing while the meta tag says another. - /// - [Fact] - public void TheShareImageColumn_ReportsTheAuthoredImageWhenThereIsOne() - { - var authored = new PublishedPage( - Node("Claims", "Claims", new PluginLike(OgImage: "/api/content/Claims/content/og.png")), - "Claims"); - - Assert.Equal("/api/content/Claims/content/og.png", PublishedSettingsTab.RowFor(authored).ShareImage); - } - - /// …and says so plainly when the portal generates one, rather than leaving it blank - /// (a blank column reads as "no card", which is exactly wrong now). - [Fact] - public void TheShareImageColumn_SaysGeneratedWhenNoneIsAuthored() - { - var page = new PublishedPage(Node("Chess", "Chess", new PluginLike()), "Chess"); - - Assert.Equal("generated card", PublishedSettingsTab.RowFor(page).ShareImage); - } - - /// A brochure page under a plugin keeps its full path — the list must be openable. - [Fact] - public void ANestedPublicSegment_KeepsItsFullPath() - { - var page = new PublishedPage( - Node("AgenticPrimer/01-TheMagicWish", "The Magic Wish"), "AgenticPrimer/01-TheMagicWish"); - - Assert.Equal("/AgenticPrimer/01-TheMagicWish", PublishedSettingsTab.RowFor(page).Url); - } - - /// Rows sort by URL so the list reads as a site map rather than in query order. - [Fact] - public void Rows_AreOrderedByUrl() - { - var pages = new[] - { - new PublishedPage(Node("Underwriting", "Underwriting"), "Underwriting"), - new PublishedPage(Node("Claims", "Claims"), "Claims"), - new PublishedPage(Node("AgenticPrimer", "Primer"), "AgenticPrimer"), - }; - - var urls = PublishedSettingsTab.RowsFor(pages).Select(r => r.Url).ToArray(); - - Assert.Equal(new[] { "/AgenticPrimer", "/Claims", "/Underwriting" }, urls); - } - - /// An empty surface is a real answer, not an error — and must not throw. - [Fact] - public void AnEmptySurface_ProducesNoRows() - => Assert.Empty(PublishedSettingsTab.RowsFor([])); -} diff --git a/test/Memex.Portal.Shared.Test/RedirectSinksUseOnePolicyGuard.cs b/test/Memex.Portal.Shared.Test/RedirectSinksUseOnePolicyGuard.cs deleted file mode 100644 index b36325f1a6..0000000000 --- a/test/Memex.Portal.Shared.Test/RedirectSinksUseOnePolicyGuard.cs +++ /dev/null @@ -1,152 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Every redirect target that comes from the request must go through ReturnUrlPolicy.Sanitize. -/// -/// Why a ratchet and not just tests. ReturnUrlPolicy was already correct and -/// already covered — its theory pins //evil, /\evil, absolute URLs and -/// javascript:. The defect (#2302) was not the policy; it was that this assembly grew THREE -/// separate copies of the rule and two were wrong: GitHubLoginEndpoints.SafeLocal rejected -/// //host but accepted /\host (browsers normalise the backslash, so it still became a -/// protocol-relative external redirect), and DevAuthController validated nothing at all at -/// two sites. Testing the policy harder would not have found any of that. -/// -/// So the invariant worth pinning is not "the policy is right" but "there is only one -/// policy, and every sink uses it". -/// -public class RedirectSinksUseOnePolicyGuard -{ - /// The names a request-supplied redirect target travels under in this assembly. - private static readonly string[] RequestTargetNames = - ["returnUrl", "returnTo", "returnPath", "redirectUrl", "redirectTo"]; - - /// An index-based protocol-relative test, e.g. u[1] == '/'. - private static readonly Regex ProtocolRelativeIndexCheck = - new(@"\[\s*1\s*\]\s*[=!]=\s*'[/\\\\]'", RegexOptions.Compiled); - - /// A call to the shared policy, or a helper that delegates to it by convention. - private static readonly Regex SanitizerCall = - new(@"\b(Sanitize|Safe[A-Za-z]*)\s*\(", RegexOptions.Compiled); - - private static string SharedRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - Assert.NotNull(dir); - var root = Path.Combine(dir!.FullName, "memex", "Memex.Portal.Shared"); - Assert.True(Directory.Exists(root), $"{root} not found — update this guard's path."); - return root; - } - - /// - /// A redirect whose argument mentions a request-supplied value must name the policy in the same - /// expression. Matching the ARGUMENT rather than the whole file is what makes this specific: - /// a file may redirect to a constant and separately mention returnUrl elsewhere. - /// - [Fact] - public void No_redirect_takes_a_request_value_without_the_shared_policy() - { - var offenders = new List(); - var sink = new Regex(@"Redirect\(\s*([^;]{0,200}?)\)\s*;", RegexOptions.Compiled); - - foreach (var file in Directory.EnumerateFiles(SharedRoot(), "*.cs", SearchOption.AllDirectories)) - { - var text = File.ReadAllText(file); - foreach (Match m in sink.Matches(text)) - { - var arg = m.Groups[1].Value; - // 🚨 Every name a request-supplied target travels under, not just the obvious two. - // The first version knew only returnUrl/returnTo and therefore did not ratchet - // GitHubConnectEndpoints, whose sink is called returnPath — and that one was a LIVE - // open redirect (it accepted "//host"). A guard that names its subjects by - // convention misses the sink that did not follow the convention. - var fromRequest = RequestTargetNames.Any( - n => arg.Contains(n, StringComparison.OrdinalIgnoreCase)); - if (!fromRequest) continue; - // 🚨 Look at the ENCLOSING REGION, not only the argument. Sanitizing into a local - // and redirecting that is correct and common — EaConsentController does exactly - // this, and an argument-only rule reported all four of its sites as open redirects - // when none of them is. Flagging correct code is how a guard gets suppressed. - // A string LITERAL is not a request value, however it is spelled. The first - // version flagged Redirect("/connect/github?returnPath=/") — a constant that merely - // mentions the parameter name. - if (arg.TrimStart().StartsWith('"')) continue; - - var from = Math.Max(0, m.Index - 1500); - var region = text[from..m.Index] + arg; - // Match the CONVENTION (Sanitize / Safe*) rather than an enumerated list of helper - // names. Enumerating them missed SafeReturn and would have missed the next one; the - // "only one implementation" test below is what stops a Safe*-named impostor. - if (SanitizerCall.IsMatch(region)) continue; - offenders.Add($"{Path.GetFileName(file)}: Redirect({arg.Trim()})"); - } - } - - Assert.True( - offenders.Count == 0, - "These redirects take a request-supplied target without routing it through " - + "ReturnUrlPolicy.Sanitize — that is an open redirect (#2302). Do not re-implement the " - + "check: a hand-written copy already shipped that rejected \"//host\" but accepted " - + "\"/\\host\", which browsers normalise back into a protocol-relative external URL.\n " - + string.Join("\n ", offenders)); - } - - /// - /// …and every local-URL helper DELEGATES rather than re-implements. - /// - /// 🚨 This is the invariant, and the weaker forms of it are worth recording because I - /// wrote both. Scanning for StartsWith("//") misses an index-based check - /// (u[1] == '/'), which is how one copy was written. And allowing any call named - /// Safe* to count as sanitisation is worse than useless: GitHubConnectEndpoints - /// had a SafeReturn that tested only for a leading / and therefore passed - /// //evil.example straight through to Redirect — a live open redirect wearing a - /// reassuring name. A guard that trusts the name certifies the impostor. - /// - /// So: find every Safe…/Sanitize… helper outside the policy itself, and - /// require its body to call . Delegation is checkable; - /// correctness of a hand-rolled copy is not. - /// - [Fact] - public void Every_local_url_helper_delegates_to_the_one_policy() - { - var decl = new Regex( - // Names in the local-URL family only. A bare `Sanitize` is not one: UpdatePolicySettingsTab - // has a markdown sanitiser by that name, and flagging it would be the kind of false - // positive that gets a guard suppressed rather than fixed. - @"(?:internal|private|public|protected)\s+static\s+string\??\s+((?:Safe|Sanitize)\w*(?:Return|Url|Local)\w*|SafeReturn)\s*\([^)]*\)", - RegexOptions.Compiled); - var offenders = new List(); - - foreach (var file in Directory.EnumerateFiles(SharedRoot(), "*.cs", SearchOption.AllDirectories)) - { - if (Path.GetFileName(file).Equals("ReturnUrlPolicy.cs", StringComparison.Ordinal)) continue; - var text = File.ReadAllText(file); - foreach (Match m in decl.Matches(text)) - { - // The body: from the declaration to the next declaration or 1200 chars, whichever - // is nearer. Enough to see a delegating one-liner or a hand-rolled block. - var bodyEnd = Math.Min(text.Length, m.Index + 1200); - var body = text[m.Index..bodyEnd]; - if (body.Contains("ReturnUrlPolicy.Sanitize", StringComparison.Ordinal)) continue; - offenders.Add($"{Path.GetFileName(file)}: {m.Groups[1].Value}"); - } - } - - Assert.True( - offenders.Count == 0, - "These local-URL helpers do not delegate to ReturnUrlPolicy.Sanitize. A hand-rolled " - + "copy is how three separate open redirects shipped (#2302) — one of them named " - + "SafeReturn while accepting \"//evil.example\". Delegate; do not re-implement: " - + string.Join(", ", offenders)); - } -} diff --git a/test/Memex.Portal.Shared.Test/RedirectSourcesMintLocalTargetsGuard.cs b/test/Memex.Portal.Shared.Test/RedirectSourcesMintLocalTargetsGuard.cs deleted file mode 100644 index 45837cfd8a..0000000000 --- a/test/Memex.Portal.Shared.Test/RedirectSourcesMintLocalTargetsGuard.cs +++ /dev/null @@ -1,211 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The other half of the redirect contract: every place that MINTS a ?returnUrl= must mint a -/// LOCAL path. -/// -/// Why a second guard. RedirectSinksUseOnePolicyGuard pins the SINK side — a -/// redirect that consumes a request-supplied target routes it through the one shared policy. That -/// guard was green throughout #5074, and correctly so: nothing consumed an unvalidated target. The -/// defect was at the SOURCE. /authorize minted its own return target as -/// {Scheme}://{Host}{Path}{QueryString}, and because every sink downstream refuses a -/// non-local target — which is the sinks doing their job — the value was DISCARDED rather than -/// followed. The login page substitutes nothing for a target it will not keep, so the sign-in -/// completed and landed on "/" with the original request gone: no authorization code, and an -/// MCP client waiting for a completion that could not arrive. -/// -/// So the two guards answer different questions. The sink guard asks "is an incoming target -/// validated?" — a security question. This one asks "is an OUTGOING target one that validation will -/// keep?" — a correctness question, whose failure mode is silent and looks like an expired -/// credential. Neither subsumes the other, and the first cannot see the second by construction. -/// -/// How the rule is decided. For each minting site the guard resolves the interpolated -/// value to its assignment IN THE SAME FILE and refuses one composed as an absolute URL (a scheme -/// separator, or the request's scheme read at all). A value that is an inbound parameter of the -/// enclosing method is a FORWARD, not a mint, and belongs to the sink guard. Anything the guard -/// cannot resolve to one of those two fails closed and says so — an unresolvable site is not a -/// clean one. -/// -public class RedirectSourcesMintLocalTargetsGuard -{ - /// - /// The names a redirect target travels under — the sink guard's list, unchanged. - /// ImmutableArray, not string[]: an array is a mutable collection whatever the - /// field's modifiers say, and the collections policy admits a static readonly only for a - /// constant lookup that cannot be written at runtime. - /// - private static readonly ImmutableArray TargetNames = - ["returnUrl", "returnTo", "returnPath", "redirectUrl", "redirectTo"]; - - /// - /// A MINT: the parameter name inside a string, immediately followed by an interpolation hole. - /// "?returnUrl={x}" mints; "?returnPath=/" is a constant and mints nothing. - /// - private static readonly Regex MintSite = new( - @"[?&](" + string.Join("|", TargetNames) + @")=\{([^{}]{1,200})\}", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - - /// Identifiers in an interpolated expression, innermost-first is not needed — all are checked. - private static readonly Regex Identifier = new(@"[A-Za-z_][A-Za-z0-9_]*", RegexOptions.Compiled); - - /// Names that are calls/types in the expression rather than the value being carried. - private static readonly ImmutableArray NotAValue = - ["Uri", "EscapeDataString", "ReturnUrlPolicy", "Sanitize", "string", "Join", "Format", - "LocalUrl", "LocalOrRoot", "LocalOrNull", "InstanceConnectFlow"]; - - private static IEnumerable Roots() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - Assert.NotNull(dir); - // Both trees that compose portal URLs: the auth/portal surface and the framework. - ImmutableArray relatives = [Path.Combine("memex", "Memex.Portal.Shared"), "src"]; - foreach (var relative in relatives) - { - var root = Path.Combine(dir!.FullName, relative); - Assert.True(Directory.Exists(root), $"{root} not found — update this guard's roots."); - yield return root; - } - } - - [Fact] - public void Every_minted_return_target_is_a_local_path() - { - // One pass, no accumulator: the sites are a SEQUENCE and both readings below are derived - // from it, so the vacuity check and the verdicts cannot disagree about what was scanned. - var sites = Roots() - .SelectMany(root => Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)) - .SelectMany(file => - { - var text = File.ReadAllText(file); - return MintSite.Matches(text) - // 🚨 Prose, not code. Both connect endpoints DOCUMENT their routes as - // `GET /connect/github?returnPath={path}` in an XML comment, where `{path}` is a - // placeholder for the reader and names no C# value at all — the guard's - // fail-closed branch reported it, correctly, as something it could not resolve. - // A comment mints nothing, so the subject is the code line. - .Where(m => !IsInAComment(text, m.Index)) - .Select(m => (Name: Path.GetFileName(file), Text: text, Match: m)); - }) - .ToImmutableArray(); - - var offenders = sites - .Select(site => Offence(site.Text, site.Name, site.Match)) - .Where(o => o is not null) - .ToImmutableArray(); - - // 🚨 The guard must have a subject. A refactor that renames these parameters, or moves the - // portal surface out of this repo, would otherwise leave it green having checked nothing. - Assert.True(!sites.IsEmpty, - "No returnUrl/returnPath minting site was found in either root. Either the roots above " - + "are stale or the parameter names changed — a guard with no subject passes vacuously."); - - Assert.True( - offenders.IsEmpty, - "These sites hand a redirect target onward that the shared local-only policy will NOT " - + "keep. Every sink refuses a non-local target (correctly — that is the open-redirect " - + "defence), so such a value is DISCARDED rather than followed: the user completes the " - + "flow and lands on \"/\" with the original request gone, which reads as the flow " - + "silently not working rather than as a rejected redirect (#5074). Mint " - + "{Request.Path}{Request.QueryString}, never {Request.Scheme}://{Request.Host}… — the " - + "login page and the endpoint are the same origin, so the absolute form carries no " - + "information and costs the whole flow.\n " - + string.Join("\n ", offenders)); - } - - /// - /// The finding for one minting site, or null when the site is clean. - /// - /// 🚨 The unresolvable branch tests Any, not All. All(v => !v.Resolved) - /// reports only when NOTHING resolved, so an expression combining a known local with one - /// unclassifiable member would pass — which contradicts the fail-closed contract this guard is - /// built on, and in the one branch whose whole purpose is not to assume. If any carried value - /// cannot be classified, the site is not clean. - /// - private static string? Offence(string text, string name, Match m) - { - var carried = Identifier.Matches(m.Groups[2].Value) - .Select(i => i.Value) - .Where(i => !NotAValue.Contains(i)) - .Distinct(StringComparer.Ordinal) - .ToImmutableArray(); - - if (carried.IsEmpty) - return $"{name}: {m.Value} — the guard could not tell what value this carries."; - - var verdicts = carried.Select(id => Classify(text, id, m.Index)).ToImmutableArray(); - - if (verdicts.Any(v => v.Absolute)) - return $"{name}: {m.Value} — carries {verdicts.First(v => v.Absolute).Detail}, " - + "which is an ABSOLUTE url."; - - var unresolved = verdicts.Where(v => !v.Resolved).ToImmutableArray(); - return unresolved.IsEmpty - ? null - : $"{name}: {m.Value} — no assignment or parameter found for " - + $"{string.Join("/", unresolved.Select(v => v.Detail))}; the guard fails closed " - + "rather than assuming it is local."; - } - - /// - /// True when the offset sits on a line whose code has not started — a // / /// - /// line comment, or a line inside a block comment. Deliberately line-based rather than a real - /// lexer: the only thing it has to separate is a documented ROUTE from a composed URL. - /// - private static bool IsInAComment(string text, int index) - { - var lineStart = text.LastIndexOf('\n', Math.Max(0, index - 1)) + 1; - var prefix = text[lineStart..index].TrimStart(); - return prefix.StartsWith("//", StringComparison.Ordinal) - || prefix.StartsWith("*", StringComparison.Ordinal) - || prefix.StartsWith("/*", StringComparison.Ordinal); - } - - /// What a carried identifier resolves to, looking only at the file it is used in. - private readonly record struct Verdict(bool Resolved, bool Absolute, string Detail); - - /// - /// Resolves to its nearest preceding assignment, or to an inbound - /// parameter of the enclosing method. An assignment whose right-hand side composes a scheme and - /// host is ABSOLUTE; a parameter is a forwarded inbound value (the sink guard's subject). - /// - private static Verdict Classify(string text, string id, int useIndex) - { - var before = text[..useIndex]; - - var assignment = new Regex( - @"\b(?:var|string\??)\s+" + Regex.Escape(id) + @"\s*=\s*([^;]{0,400});", - RegexOptions.Compiled); - var assignments = assignment.Matches(before); - if (assignments.Count > 0) - { - var rhs = assignments[^1].Groups[1].Value; - // Either spelling of "this is a whole URL": the separator itself, or the request's - // scheme, which in this codebase is only ever read to build one. - var absolute = rhs.Contains("://", StringComparison.Ordinal) - || rhs.Contains("Request.Scheme", StringComparison.Ordinal); - return new Verdict(true, absolute, absolute ? $"`{id}` = {rhs.Trim()}" : $"`{id}`"); - } - - // A parameter of the enclosing declaration — a forwarded inbound value, governed by the - // sink guard where it lands. Looked for in the nearest preceding parameter list. - var parameter = new Regex( - @"\(\s*(?:[^()]{0,600}?[,\s])?(?:\[[^\]]{0,80}\]\s*)?[A-Za-z_][A-Za-z0-9_.<>?\[\]]*\s+" - + Regex.Escape(id) + @"\s*[,)=]", - RegexOptions.Compiled); - return parameter.IsMatch(before) - ? new Verdict(true, false, $"`{id}` (inbound parameter)") - : new Verdict(false, false, $"`{id}`"); - } -} diff --git a/test/Memex.Portal.Shared.Test/RefusedReadLooksAbsentTest.cs b/test/Memex.Portal.Shared.Test/RefusedReadLooksAbsentTest.cs deleted file mode 100644 index c3d4023a7c..0000000000 --- a/test/Memex.Portal.Shared.Test/RefusedReadLooksAbsentTest.cs +++ /dev/null @@ -1,143 +0,0 @@ -#pragma warning disable CS1591 - -using System.Reactive.Linq; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A refused read answers exactly what an absent path answers on the caller-facing -/// read surfaces beyond get (which #6571 settled): render_area (and the -/// get {path}/area/… route built on it) and content/list. -/// -/// Before the fix both handed the owner's refusal on verbatim — "Error: Access denied: -/// user '…' lacks Read permission on '{path}'" — while an absent path answered -/// "Not found: …" / "Error: no matching node …". The difference alone told a caller -/// who may not read a node that one exists at that exact path. -/// -/// The comparison is made with the path masked, against TWO absent shapes: a path under the -/// same hidden parent, and a path under a root that does not exist. The grant holder renders the -/// node — the positive control. -/// -public class RefusedReadLooksAbsentTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Hidden = "RefusalShapeHidden"; - private const string ViewerUser = "refusal-viewer"; - private const string HolderUser = "refusal-holder"; - private const string SecretName = "Obsidianfern"; - private const string SecretPath = Hidden + "/" + SecretName; - private const string AbsentSibling = Hidden + "/NoSuchSibling"; - private const string AbsentRoot = "RefusalShapeNoSuchRoot/" + SecretName; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - // 🚨 ConfigureMeshBase, not base.ConfigureMesh: the latter grants Public the Admin role in every - // default partition, under which the viewer would read everything and the denial would be vacuous. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddMeshNodes( - new MeshNode(Hidden) { Name = "Hidden", NodeType = "Markdown" }, - AssignmentNodeFactory.UserRole(HolderUser, "Viewer", Hidden)); - - /// The secret node is WRITTEN, not declared — a declared node is a configuration node, - /// served without the row filter (see Doc/Architecture/AccessControl). - public override async ValueTask InitializeAsync() - { - await base.InitializeAsync(); - await Mesh.ServiceProvider.GetRequiredService() - .RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService() - .CreateNode(new MeshNode(SecretName, Hidden) { Name = SecretName, NodeType = "Markdown" })) - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - } - - private IMessageHub SessionHub() => SessionHubFactory.Resolve( - Mesh, "mcp", "refusalshape", - Mesh.ServiceProvider.GetRequiredService().CreateLogger(nameof(RefusedReadLooksAbsentTest))); - - private Task As(string userId, Func> read) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - access.SetContext(new AccessContext { ObjectId = userId, Name = userId }); - return read(new MeshOperations(SessionHub())) - .Catch((Exception ex) => Observable.Return($"Fault: {ex.GetType().Name}: {ex.Message}")) - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - } - - private const int RenderBudgetSeconds = 10; - - private Task RenderArea(string userId, string path) - => As(userId, ops => ops.RenderArea(path, timeoutSeconds: RenderBudgetSeconds)); - - private Task ContentList(string userId, string path) - => As(userId, ops => ops.ContentList(path + "/content")); - - private static string Masked(string answer, string path) => answer.Replace(path, ""); - - [Fact] - public async Task RenderArea_ARefusedNode_AnswersLikeAnAbsentOne() - { - var denied = await RenderArea(ViewerUser, SecretPath); - var sibling = await RenderArea(ViewerUser, AbsentSibling); - var root = await RenderArea(ViewerUser, AbsentRoot); - Output.WriteLine($"denied: {denied}"); - Output.WriteLine($"sibling: {sibling}"); - Output.WriteLine($"root: {root}"); - - denied.ToLowerInvariant().Should().NotContain("access denied", - "naming the refusal tells the caller a node exists at the path"); - Masked(denied, SecretPath).Should().Be(Masked(sibling, AbsentSibling), - "a refused render answers exactly what an absent sibling answers"); - Masked(denied, SecretPath).Should().Be(Masked(root, AbsentRoot), - "a refused render answers exactly what a path under no node answers"); - } - - [Fact] - public async Task RenderArea_TheGrantHolder_RendersIt() - { - var answer = await RenderArea(HolderUser, SecretPath); - Output.WriteLine($"holder: {answer}"); - - answer.Should().Contain(SecretName, - "the holder's grant covers the node — if this fails, the refusal above proves nothing"); - } - - [Fact] - public async Task ContentList_ARefusedNode_AnswersLikeAnAbsentOne() - { - var denied = await ContentList(ViewerUser, SecretPath); - var sibling = await ContentList(ViewerUser, AbsentSibling); - var root = await ContentList(ViewerUser, AbsentRoot); - Output.WriteLine($"denied: {denied}"); - Output.WriteLine($"sibling: {sibling}"); - Output.WriteLine($"root: {root}"); - - denied.ToLowerInvariant().Should().NotContain("access denied", - "naming the refusal tells the caller a node exists at the path"); - Masked(denied, SecretPath + "/content").Should().Be(Masked(sibling, AbsentSibling + "/content"), - "a refused listing answers exactly what an absent sibling answers"); - Masked(denied, SecretPath + "/content").Should().Be(Masked(root, AbsentRoot + "/content"), - "a refused listing answers exactly what a path under no node answers"); - } - - [Fact] - public async Task ContentList_TheGrantHolder_IsServedByTheOwner() - { - var answer = await ContentList(HolderUser, SecretPath); - Output.WriteLine($"holder: {answer}"); - - // The plain test mesh configures no content collection, so a served lookup says so. - answer.Should().Contain("collection 'content' not found", - "the holder's grant covers the node — if this fails, the refusal above proves nothing"); - } -} diff --git a/test/Memex.Portal.Shared.Test/RegistrationCouponTest.cs b/test/Memex.Portal.Shared.Test/RegistrationCouponTest.cs deleted file mode 100644 index 234ed02950..0000000000 --- a/test/Memex.Portal.Shared.Test/RegistrationCouponTest.cs +++ /dev/null @@ -1,298 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Reactive.Linq; -using System.Text.Json; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🎟️ COUPONS — a deployment registers on its plan from the beginning. A coupon is a -/// registration key with a plan, an expiry and a usage limit (), -/// minted for ONE client deployment through POST /api/instances/coupons — a credential scoped -/// to that act alone, never Bootstrap:Secret. Pinned over the real endpoints: a single-use -/// coupon registers exactly one instance, on its plan, and is then refused like an expired key; the -/// mint is absent until configured, refuses a wrong credential, and refuses a plan the registry's -/// ladder does not know before anything is written. -/// -public class RegistrationCouponTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string TierNodeType = "Store/Tier"; - private const string MintSecret = "test-coupon-mint-credential-0123456789abcdef"; - private const string Owner = "coupon-owner"; - - /// The Store's tier content shape, test-local (this fixture does not run the Store). - public record TierNode - { - public int Rank { get; init; } - public bool AllAccess { get; init; } - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .AddMeshNodes(new MeshNode(TierNodeType) - { - Name = "Tier", - IsSatelliteType = false, - HubConfiguration = config => config - .AddMeshDataSource(source => source.WithContentType()), - }) - .ConfigureHub(config => config.WithType(nameof(TierNode))); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - private Task SeedLadder(CancellationToken ct) => - new[] { ("free", 0, false), ("pro", 20, false), ("dedicated", 25, true) } - .Select(t => Access.RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService() - .CreateOrUpdateNode(new MeshNode(t.Item1, PlanTierLadder.Namespace) - { - Name = t.Item1, - NodeType = TierNodeType, - State = MeshNodeState.Active, - Content = new TierNode { Rank = t.Item2, AllAccess = t.Item3 }, - }))) - .Concat() - .LastAsync() - .Timeout(TimeSpan.FromSeconds(60)) - .Await(ct); - - private MeshWeaverInstanceService InstanceService() => new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - ["PluginCatalog:Sources:0:Name"] = "Plugins", - ["PluginCatalog:Sources:0:RepoPath"] = "/plugin-repos/plugins", - }).Build()); - - private async Task StartRegistry(bool configureMint, CancellationToken ct) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - if (configureMint) - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [RegistrationCouponPayloads.SecretConfigKey] = MintSecret, - [RegistrationCouponPayloads.OwnerConfigKey] = Owner, - }); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(InstanceService()); - var app = builder.Build(); - app.MapInstanceRegistration(); - await app.StartAsync(ct); - return app; - } - - private static async Task<(HttpStatusCode Status, JsonElement Body)> MintCoupon( - WebApplication app, string? secret, object body) - { - using var request = new HttpRequestMessage(HttpMethod.Post, RegistrationCouponPayloads.Route) - { - Content = JsonContent.Create(body), - }; - if (secret is not null) - request.Headers.TryAddWithoutValidation(RegistrationCouponPayloads.SecretHeader, secret); - using var response = await app.GetTestClient().SendAsync(request); - var text = await response.Content.ReadAsStringAsync(); - var json = string.IsNullOrWhiteSpace(text) ? default : JsonDocument.Parse(text).RootElement.Clone(); - return (response.StatusCode, json); - } - - private static Task Register(WebApplication app, string coupon, string instanceId) => - app.GetTestClient().PostAsJsonAsync( - InstanceRegistrationPayloads.Route, - new InstanceRegistrationPayloads.Request(coupon, instanceId, DisplayName: instanceId), - InstanceRegistrationPayloads.Json); - - // ── pure ──────────────────────────────────────────────────────────────────────────────────── - - [Fact] - public void AUsageLimit_IsEnforcedLikeExpiry_AndNoLimitStaysUnlimited() - { - var now = DateTimeOffset.UtcNow; - var coupon = new RegistrationKey { MaxUsages = 1 }; - coupon.IsUsable(now).Should().BeTrue("an unused single-use coupon registers"); - (coupon with { UsageCount = 1 }).IsUsable(now).Should().BeFalse("a spent coupon fails validation like an expired key"); - (coupon with { UsageCount = 1 }).IsUsedUp.Should().BeTrue(); - new RegistrationKey { UsageCount = 500 }.IsUsable(now).Should().BeTrue( - "a key with no limit (every key minted before coupons existed) is unchanged"); - } - - [Fact] - public void TheMintBounds_RefuseACouponWithoutPlanExpiryOrDescription() - { - RegistrationCouponPayloads.Refusal(new("dedicated", "fabrikam ramp-up", 14, 1)).Should().BeNull(); - RegistrationCouponPayloads.Refusal(new("dedicated", "fabrikam ramp-up", 14, null)).Should().BeNull("no limit stated ⇒ single use"); - RegistrationCouponPayloads.Refusal(new("", "fabrikam", 14, 1)).Should().Contain("tier is required"); - RegistrationCouponPayloads.Refusal(new("pro", " ", 14, 1)).Should().Contain("description is required"); - RegistrationCouponPayloads.Refusal(new("pro", "fabrikam", null, 1)).Should().Contain("expiresDays"); - RegistrationCouponPayloads.Refusal(new("pro", "fabrikam", 365, 1)).Should().Contain("expiresDays"); - RegistrationCouponPayloads.Refusal(new("pro", "fabrikam", 14, 0)).Should().Contain("maxUsages"); - RegistrationCouponPayloads.Refusal(new("pro", "fabrikam", 14, 1000)).Should().Contain("maxUsages"); - } - - // ── over the real endpoints ───────────────────────────────────────────────────────────────── - - [Fact(Timeout = 300_000)] - public async Task ASingleUseCoupon_RegistersOneInstanceOnItsPlan_ThenIsRefused() - { - var ct = TestContext.Current.CancellationToken; - var coupon = await Mesh.ServiceProvider.GetRequiredService() - .Mint(Owner, Owner, "", "fabrikam ramp-up", DateTimeOffset.UtcNow.AddDays(14), "dedicated", maxUsages: 1) - .Select(r => r.RawKey).FirstAsync().Timeout(TimeSpan.FromSeconds(60)).Await(ct); - var app = await StartRegistry(configureMint: false, ct); - await using var _ = app; - - using (var first = await Register(app, coupon, "fabrikam")) - { - first.StatusCode.Should().Be(HttpStatusCode.OK); - var issued = await first.Content.ReadFromJsonAsync( - InstanceRegistrationPayloads.Json, ct); - issued!.Plan.Should().Be("dedicated", "the instance enrols into the coupon's plan from the beginning"); - } - - using var second = await Register(app, coupon, "fabrikam-two"); - second.StatusCode.Should().Be(HttpStatusCode.Unauthorized, - "a single-use coupon is spent by its one registration and then refused like an expired key"); - } - - [Fact(Timeout = 300_000)] - public async Task TheMint_IsAbsentUntilConfigured() - { - var app = await StartRegistry(configureMint: false, TestContext.Current.CancellationToken); - await using var _ = app; - var (status, _) = await MintCoupon(app, MintSecret, new { tier = "pro", description = "fabrikam", expiresDays = 14 }); - status.Should().Be(HttpStatusCode.NotFound, "a registry that configures no mint credential has no mint"); - } - - [Fact(Timeout = 300_000)] - public async Task TheMint_RefusesAWrongCredential_AndAnUnknownPlan() - { - var ct = TestContext.Current.CancellationToken; - await SeedLadder(ct); - var app = await StartRegistry(configureMint: true, ct); - await using var _ = app; - - var (wrong, _) = await MintCoupon(app, "not-the-credential", new { tier = "pro", description = "fabrikam", expiresDays = 14 }); - wrong.Should().Be(HttpStatusCode.Unauthorized); - var (none, _) = await MintCoupon(app, null, new { tier = "pro", description = "fabrikam", expiresDays = 14 }); - none.Should().Be(HttpStatusCode.Unauthorized); - - var (unknown, body) = await MintCoupon(app, MintSecret, new { tier = "platinum", description = "fabrikam", expiresDays = 14 }); - unknown.Should().Be(HttpStatusCode.BadRequest, "a plan the ladder does not know licenses nothing — refused before anything is written"); - body.GetProperty("error").GetString().Should().Contain("not on this registry's tier ladder"); - } - - [Fact(Timeout = 300_000)] - public async Task AMintedCoupon_CarriesItsPlan_AndRegistersOnIt() - { - var ct = TestContext.Current.CancellationToken; - await SeedLadder(ct); - var app = await StartRegistry(configureMint: true, ct); - await using var _ = app; - - var (status, body) = await MintCoupon(app, MintSecret, - new { tier = "Dedicated", description = "fabrikam ramp-up", expiresDays = 14, maxUsages = 1 }); - status.Should().Be(HttpStatusCode.OK); - var coupon = body.GetProperty("coupon").GetString()!; - coupon.Should().StartWith("mwr_", "a coupon is a registration key"); - body.GetProperty("tier").GetString().Should().Be("dedicated", "the plan is stored canonical"); - body.GetProperty("maxUsages").GetInt32().Should().Be(1); - - using var registered = await Register(app, coupon, "fabrikam-minted"); - registered.StatusCode.Should().Be(HttpStatusCode.OK); - (await registered.Content.ReadFromJsonAsync( - InstanceRegistrationPayloads.Json, ct))!.Plan.Should().Be("dedicated"); - using var again = await Register(app, coupon, "fabrikam-minted-two"); - again.StatusCode.Should().Be(HttpStatusCode.Unauthorized, "single use"); - } -} - -/// -/// 🚨 The mint reads the registry's tier ladder AS SYSTEM. Its HTTP caller is anonymous (the mint -/// credential is a header, not an identity), and on a registry with Access:DenyAnonymous a -/// ladder query under that identity answers an EMPTY Initial — with a cold cache every plan then read -/// "not on the ladder" (400) and that empty ladder was cached for a minute. This fixture states the -/// switch, so the mint below reaches a COLD ladder as the anonymous HTTP caller. -/// -public class RegistrationCouponOnAClosedRegistryTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string TierNodeType = "Store/Tier"; - private const string MintSecret = "test-coupon-mint-credential-closed-registry"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .AddMeshNodes(new MeshNode(TierNodeType) - { - Name = "Tier", - IsSatelliteType = false, - HubConfiguration = config => config - .AddMeshDataSource(source => source.WithContentType()), - }) - .ConfigureHub(config => config.WithType(nameof(RegistrationCouponTest.TierNode))) - .ConfigureServices(services => services.AddSingleton( - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [AnonymousAccess.DenyAnonymousConfigKey] = "true", - }).Build())); - - [Fact(Timeout = 300_000)] - public async Task TheMint_ReadsTheLadderAsSystem_SoAnAnonymousCallerOnAClosedRegistryStillMints() - { - var ct = TestContext.Current.CancellationToken; - var access = Mesh.ServiceProvider.GetRequiredService(); - await access.RunAsSystem(() => Mesh.ServiceProvider.GetRequiredService() - .CreateOrUpdateNode(new MeshNode("dedicated", PlanTierLadder.Namespace) - { - Name = "dedicated", - NodeType = TierNodeType, - State = MeshNodeState.Active, - Content = new RegistrationCouponTest.TierNode { Rank = 25, AllAccess = true }, - })) - .FirstAsync().Timeout(TimeSpan.FromSeconds(60)).Await(ct); - - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [RegistrationCouponPayloads.SecretConfigKey] = MintSecret, - [RegistrationCouponPayloads.OwnerConfigKey] = "coupon-owner", - }); - builder.Services.AddSingleton(Mesh); - var app = builder.Build(); - app.MapInstanceRegistration(); - await app.StartAsync(ct); - await using var _ = app; - - using var request = new HttpRequestMessage(HttpMethod.Post, RegistrationCouponPayloads.Route) - { - Content = JsonContent.Create(new { tier = "dedicated", description = "fabrikam ramp-up", expiresDays = 14 }), - }; - request.Headers.TryAddWithoutValidation(RegistrationCouponPayloads.SecretHeader, MintSecret); - // The fixture logs a DevLogin HOST identity in for every flow without one; the real registry - // has none — its mint caller is anonymous. Clear it so the request runs exactly as anonymous. - access.ClearHostIdentity(); - using var response = await app.GetTestClient().SendAsync(request, ct); - var text = await response.Content.ReadAsStringAsync(ct); - response.StatusCode.Should().Be(HttpStatusCode.OK, - "a plan the ladder knows is minted even though the HTTP caller is anonymous on a closed registry: " + text); - } -} diff --git a/test/Memex.Portal.Shared.Test/RegistryAnswersOrRefusesTest.cs b/test/Memex.Portal.Shared.Test/RegistryAnswersOrRefusesTest.cs deleted file mode 100644 index 1818d8939c..0000000000 --- a/test/Memex.Portal.Shared.Test/RegistryAnswersOrRefusesTest.cs +++ /dev/null @@ -1,356 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Reactive.Assertions; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#4963 — the plugin registry ANSWERS or REFUSES; it never holds a consumer's request -/// open with nothing written. Production, 2026-10-07: consumers in two namespaces got 0 bytes -/// for 120 s from the registry while its replicas logged no line for those requests — because each -/// live read on the route was bounded and the REQUEST was not, so a stage with no bound of its own -/// held it for as long as it held. -/// -/// The repro is the production shape at the route: the REAL mesh, the real registration and -/// install path, the bundle routes on a TestServer, and ONE stage of the index that never answers -/// (the pushed-artifacts record — a host-supplied seam, so the stall needs no fault injection inside -/// the platform). Negative control: the same request with the deadline pushed out of reach is held -/// with nothing written — the defect, reproduced — so the green above is the fix's and not the -/// harness's. -/// -public class RegistryAnswersOrRefusesTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Package = "AnswerDeadlinePkg"; - private const string Source = "Plugins"; - private const string Instance = "answer-deadline-consumer"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - // ── the operator, on its own ──────────────────────────────────────────────────────────────── - - [Fact] - public async Task AnAnswerThatNeverComes_IsA503WithRetryAfter_NamingTheStageItWaitedOn() - { - var ct = TestContext.Current.CancellationToken; - var http = new DefaultHttpContext(); - var stages = RegistryAnswerDeadline.Stages(http); - var log = new CapturingLogger(); - - var result = await Observable.Return(0) - .InStage(stages, "instance-key authentication") - .SelectMany(_ => Observable.Never().InStage(stages, "module activation list (module records on the share)")) - .AnsweredWithin(http, TimeSpan.FromMilliseconds(300), 30, log) - .Should().Within(TestTimeouts.Quick) - .Emit("a route that cannot answer must refuse within its budget", cancellationToken: ct); - - var status = Assert.IsAssignableFrom(result); - Assert.Equal(StatusCodes.Status503ServiceUnavailable, status.StatusCode); - // The header is written when the refusal EXECUTES, against the context the host hands it. - await Execute(result, http); - Assert.Equal("30", http.Response.Headers.RetryAfter.ToString()); - Assert.Equal(StatusCodes.Status503ServiceUnavailable, http.Response.StatusCode); - var line = Assert.Single(log.Lines); - Assert.Contains("produced no answer within", line, StringComparison.Ordinal); - Assert.Contains("Still waiting on: module activation list (module records on the share)", line, StringComparison.Ordinal); - Assert.Contains("Finished: instance-key authentication", line, StringComparison.Ordinal); - Assert.Equal(["module activation list (module records on the share)"], stages.Pending()); - } - - [Fact] - public async Task AnAnswerInsideTheBudget_PassesThroughUntouched_AndLogsNothing() - { - var ct = TestContext.Current.CancellationToken; - var http = new DefaultHttpContext(); - var log = new CapturingLogger(); - var answer = Results.Ok("served"); - - var result = await Observable.Return(answer) - .InStage(RegistryAnswerDeadline.Stages(http), "installed packages (mesh query)") - .AnsweredWithin(http, TimeSpan.FromSeconds(5), 30, log) - .Should().Within(TestTimeouts.Quick) - .Emit("an answer inside the budget is the answer", cancellationToken: ct); - - Assert.Same(answer, result); - Assert.Empty(log.Lines); - Assert.True(string.IsNullOrEmpty(http.Response.Headers.RetryAfter.ToString())); - } - - /// - /// 🚨 The deadline firing AFTER the client hung up must not touch the request (#4963's incidental - /// defect, fleet registry 2026-10-09 20:03:29Z/20:03:32Z: ObjectDisposedException: - /// IFeatureCollection has been disposed at DefaultHttpRequest.get_Method()). The repro is - /// the production order: the request is subscribed while alive, the client aborts, the host - /// disposes the context's features, THEN the timer fires. The refusal must still be composed, and - /// the line must say the client had gone instead of claiming a 503 was answered. - /// - [Fact] - public async Task TheDeadlineFiringAfterTheClientLeft_TouchesNoDisposedRequest_AndSaysTheClientHadGone() - { - var ct = TestContext.Current.CancellationToken; - using var abort = new CancellationTokenSource(); - var http = new DefaultHttpContext { RequestAborted = abort.Token }; - http.Request.Method = "GET"; - http.Request.Path = "/api/plugins/bundles/index.json"; - var stages = RegistryAnswerDeadline.Stages(http); - var log = new CapturingLogger(); - - var refusal = Observable.Never() - .InStage(stages, "package origin anchor") - .AnsweredWithin(http, TimeSpan.FromMilliseconds(400), 30, log) - .Replay(1); - using var connection = refusal.Connect(); - - // The client goes, and the host tears the request down — before the budget runs out. - abort.Cancel(); - http.Uninitialize(); - - var result = await refusal.Should().Within(TestTimeouts.Quick) - .Emit("the deadline must still compose its refusal after the request is gone", cancellationToken: ct); - - Assert.Equal(StatusCodes.Status503ServiceUnavailable, Assert.IsAssignableFrom(result).StatusCode); - Assert.Empty(log.Lines); - var line = Assert.Single(log.Information); - Assert.Contains("GET /api/plugins/bundles/index.json", line, StringComparison.Ordinal); - Assert.Contains("client had already disconnected", line, StringComparison.Ordinal); - Assert.Contains("Still waiting on: package origin anchor", line, StringComparison.Ordinal); - } - - /// Negative control for the test above: the torn-down context it builds really is the - /// production shape — reading the request off it throws exactly what the registry logged, so the - /// green above is the capture's and not a context that was never disposed. - [Fact] - public void ATornDownRequest_ThrowsOnRead_TheShapeTheRegistryLogged() - { - var http = new DefaultHttpContext(); - http.Request.Method = "GET"; - http.Uninitialize(); - - var thrown = Assert.Throws(() => http.Request.Method); - Assert.Contains("IFeatureCollection", thrown.Message, StringComparison.Ordinal); - } - - /// The budget runs from the request's ARRIVAL (the auth filter started the ledger), so a - /// request that spent its budget before the handler ran is refused at once, not given a fresh one. - [Fact] - public async Task TheBudgetCountsFromArrival_NotFromTheHandler() - { - var ct = TestContext.Current.CancellationToken; - var http = new DefaultHttpContext(); - var stages = RegistryAnswerDeadline.Stages(http); - var spent = stages.Enter("instance-key authentication"); - await Observable.Timer(TimeSpan.FromMilliseconds(400)).Should().Within(TestTimeouts.Quick) - .Emit("the arrival-side clock must run", cancellationToken: ct); - spent.Finish(); - - var result = await Observable.Never() - .AnsweredWithin(http, TimeSpan.FromMilliseconds(300), 30, logger: null) - .Should().Within(TimeSpan.FromMilliseconds(250)) - .Emit("the budget was already spent before the handler ran", cancellationToken: ct); - - Assert.Equal(StatusCodes.Status503ServiceUnavailable, Assert.IsAssignableFrom(result).StatusCode); - } - - /// Negative control for the operator: the same never-answering stage WITHOUT the - /// deadline emits nothing — the shape every registry route had. - [Fact] - public async Task WithoutTheDeadline_TheSameStageHoldsTheAnswerForever() - { - var http = new DefaultHttpContext(); - await Observable.Never() - .InStage(RegistryAnswerDeadline.Stages(http), "module activation list (module records on the share)") - .Should().NotEmit(within: TimeSpan.FromMilliseconds(600), - because: "without a deadline nothing ends a stage that never answers", - cancellationToken: TestContext.Current.CancellationToken); - } - - // ── the route, end to end ─────────────────────────────────────────────────────────────────── - - [Fact(Timeout = 300_000)] - public async Task TheIndex_WithAStageThatNeverAnswers_RefusesWithinItsBudget_AndNamesTheStage() - { - var ct = TestContext.Current.CancellationToken; - await InstallPackage(ct); - var key = await RegisterInstance(ct); - var app = await StartBundleHost(budgetSeconds: 2, ct); - await using var _ = app; - - var clock = Stopwatch.StartNew(); - // The client's patience is the fixture's, not a guess: the refusal must arrive at the 2 s - // budget, long before it (asserted below), so its exact value only bounds a broken run. - var result = await Index(app, key, TestTimeouts.Quick, ct); - using var response = result.Response; - var elapsed = clock.Elapsed; - - Assert.False(result.ClientCut, "the registry must answer before the client's patience ends"); - Assert.NotNull(response); - Assert.Equal(HttpStatusCode.ServiceUnavailable, response!.StatusCode); - Assert.Equal(TimeSpan.FromSeconds(PluginBundleEndpoints.TransientRetryAfterSeconds), response.Headers.RetryAfter?.Delta); - using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); - var waitingOn = body.RootElement.GetProperty("waitingOn").EnumerateArray().Select(e => e.GetString()).ToArray(); - Assert.Equal(["pushed artifacts record"], waitingOn); - Assert.True(elapsed < TimeSpan.FromSeconds(15), - $"the refusal must come at the 2 s budget, not at the client's patience (took {elapsed.TotalSeconds:F1} s)"); - } - - /// Negative control for the route: the deadline out of reach, the same stalled stage — - /// the request is held with nothing written, exactly what the consumers measured. - [Fact(Timeout = 300_000)] - public async Task TheIndex_WithTheDeadlineOutOfReach_IsHeldWithNothingWritten() - { - var ct = TestContext.Current.CancellationToken; - await InstallPackage(ct); - var key = await RegisterInstance(ct); - var app = await StartBundleHost(budgetSeconds: 3600, ct); - await using var _ = app; - - var patience = TimeSpan.FromSeconds(5); - var result = await Index(app, key, patience, ct); - using var response = result.Response; - - // The client gave up first: the route wrote no status line in 5 s. On the TestServer the - // client's cancellation IS the request's RequestAborted, so the abandoned request may still - // complete AFTER the cut — the route's wait faults with the aborted token, the host records - // that as 499 Client Closed Request, and the TestServer can hand it back to a client that - // has already cancelled (it raced the abort on CI: run 37652733264). A 499 is therefore - // the same reading as no response — nothing the ROUTE answered, only the hang-up recorded — - // and both may only come once the client's patience has run out. - Assert.True(response is null || response.StatusCode == (HttpStatusCode)StatusCodes.Status499ClientClosedRequest, - $"the held request must not be answered by the route; got {(int?)response?.StatusCode}"); - Assert.True(result.ClientCut, - "nothing may come back before the client's own cancellation fires"); - } - - private Task RegisterInstance(CancellationToken ct) => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection([new KeyValuePair( - $"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:0", $"{Source}/*")]) - .Build()) - .Register("deadline-owner", "Deadline Owner", "owner@test.com", Instance, Instance) - .Select(r => r.RawKey) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(60)) - .Await(ct); - - private Task InstallPackage(CancellationToken ct) => - PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = Package, - Name = Package, - Kind = PackageKind.Content, - TargetPartition = Package, - SourceFolder = Package, - Version = "1.0.0", - ReleasedVersion = "1.0.0", - Source = Source, - }, - [new PackageFile($"{Package}/Doc.md", $"# {Package}")], - "HEAD") - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(120)) - .Await(ct); - - private async Task StartBundleHost(int budgetSeconds, CancellationToken ct) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Configuration.AddInMemoryCollection([new KeyValuePair( - RegistryAnswerDeadline.BudgetSecondsConfigKey, budgetSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture))]); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(new InstanceRegistryAuthenticator( - Mesh, Mesh.ServiceProvider.GetRequiredService>())); - // The stalled stage: a pushed-artifacts record that never answers. - builder.Services.AddSingleton(new NeverAnsweringArtifacts()); - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(ct); - return app; - } - - /// The index response, or null when the client's own ran out - /// with no status line received, together with whether that actual cancellation fired. - private static async Task<(HttpResponseMessage? Response, bool ClientCut)> Index( - WebApplication app, string key, TimeSpan patience, CancellationToken ct) - { - using var request = new HttpRequestMessage(HttpMethod.Get, PluginBundleEndpoints.RoutePrefix + "/index.json"); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - using var patienceCut = new CancellationTokenSource(); - using var cut = CancellationTokenSource.CreateLinkedTokenSource(ct, patienceCut.Token); - patienceCut.CancelAfter(patience); - try - { - var response = await app.GetTestClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cut.Token); - return (response, patienceCut.IsCancellationRequested); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - return (null, patienceCut.IsCancellationRequested); - } - } - - private sealed class NeverAnsweringArtifacts : IPublicationArtifacts - { - public IObservable> Read() => Observable.Never>(); - } - - private sealed class CapturingLogger : ILogger - { - private readonly ConcurrentQueue lines = new(); - private readonly ConcurrentQueue information = new(); - /// Warning and above. - public IReadOnlyList Lines => lines.ToArray(); - /// Information only. - public IReadOnlyList Information => information.ToArray(); - public IDisposable? BeginScope(TState state) where TState : notnull => null; - public bool IsEnabled(LogLevel logLevel) => true; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - if (logLevel >= LogLevel.Warning) - lines.Enqueue(formatter(state, exception)); - else if (logLevel == LogLevel.Information) - information.Enqueue(formatter(state, exception)); - } - } - - /// Executes against the way the host - /// would: with request services and a body to write into. - private static Task Execute(IResult result, DefaultHttpContext http) - { - http.RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider(); - http.Response.Body = new System.IO.MemoryStream(); - return result.ExecuteAsync(http); - } -} diff --git a/test/Memex.Portal.Shared.Test/RegistryFilesRouteAnswersOrRefusesTest.cs b/test/Memex.Portal.Shared.Test/RegistryFilesRouteAnswersOrRefusesTest.cs deleted file mode 100644 index 994d115754..0000000000 --- a/test/Memex.Portal.Shared.Test/RegistryFilesRouteAnswersOrRefusesTest.cs +++ /dev/null @@ -1,225 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#5825 — POST /api/plugins/files ANSWERS or REFUSES within the registry's -/// deadline, like every other registry route since #6253. It was the one route left without it: -/// a package-folder fetch that queued (behind a GitSync wave of whole-repository clones on the -/// process pool) held the consumer's request with nothing written and nothing logged until the -/// consumer's own 30 s attempt cut it — memex, 2026-10-10 07:44Z and 08:55Z, eight such cuts and -/// no registry-side line, after #6389 had removed every 503 from the catalog and index routes. -/// -/// The repro is the production shape at the route: the REAL mesh with the plugin catalog, -/// a configured node-repo URL source, a registered instance, the registry routes on a TestServer -/// — and a git transport whose LISTING answers while the package-folder FETCH never does. The -/// negative control is the same request with the deadline pushed out of reach: held with nothing -/// written, which is the defect, so the green above it is the fix's and not the harness's. -/// -public class RegistryFilesRouteAnswersOrRefusesTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Package = "FilesDeadlinePkg"; - private const string Source = "Plugins"; - private const string Repo = "https://github.com/Systemorph/FilesDeadlineFixture"; - private const string Instance = "files-deadline-consumer"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services.AddSingleton(new ListingOnlyRepoClient())); - - [Fact(Timeout = 300_000)] - public async Task TheFilesRoute_WithAFolderFetchThatNeverAnswers_RefusesWithinItsBudget_AndNamesTheStage() - { - var ct = TestContext.Current.CancellationToken; - var key = await RegisterInstance(ct); - var app = await StartRegistryHost(budgetSeconds: 2, ct); - await using var _ = app; - - var clock = Stopwatch.StartNew(); - var result = await Files(app, key, TestTimeouts.Quick, ct); - using var response = result.Response; - var elapsed = clock.Elapsed; - - Assert.False(result.ClientCut, "the registry must answer before the client's patience ends"); - Assert.NotNull(response); - Assert.Equal(HttpStatusCode.ServiceUnavailable, response!.StatusCode); - Assert.Equal(TimeSpan.FromSeconds(InstanceRegistryAuthenticator.RetryAfterSeconds), - response.Headers.RetryAfter?.Delta); - using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); - var waitingOn = body.RootElement.GetProperty("waitingOn").EnumerateArray().Select(e => e.GetString()).ToArray(); - // The listing ANSWERED (the package was found); the refusal names the folder fetch. - Assert.Equal([$"package files '{Package}' from '{Source}'"], waitingOn); - Assert.True(elapsed < TimeSpan.FromSeconds(15), - $"the refusal must come at the 2 s budget, not at the client's patience (took {elapsed.TotalSeconds:F1} s)"); - } - - /// Negative control: the deadline out of reach, the same stalled folder fetch — the - /// request is held with nothing written, exactly what the consumers measured. - [Fact(Timeout = 300_000)] - public async Task TheFilesRoute_WithTheDeadlineOutOfReach_IsHeldWithNothingWritten() - { - var ct = TestContext.Current.CancellationToken; - var key = await RegisterInstance(ct); - var app = await StartRegistryHost(budgetSeconds: 3600, ct); - await using var _ = app; - - var result = await Files(app, key, TimeSpan.FromSeconds(5), ct); - using var response = result.Response; - - // A 499 is the host recording the client's own hang-up (see RegistryAnswersOrRefusesTest): - // the same reading as no response — nothing the ROUTE answered. - Assert.True(response is null || response.StatusCode == (HttpStatusCode)StatusCodes.Status499ClientClosedRequest, - $"the held request must not be answered by the route; got {(int?)response?.StatusCode}"); - Assert.True(result.ClientCut, "nothing may come back before the client's own cancellation fires"); - } - - private Task RegisterInstance(CancellationToken ct) => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection([new KeyValuePair( - $"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:0", $"{Source}/*")]) - .Build()) - .Register("files-deadline-owner", "Files Deadline Owner", "owner@test.com", Instance, Instance) - .Select(r => r.RawKey) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(60)) - .Await(ct); - - private async Task StartRegistryHost(int budgetSeconds, CancellationToken ct) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [RegistryAnswerDeadline.BudgetSecondsConfigKey] = - budgetSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture), - ["PluginCatalog:Sources:0:Name"] = Source, - ["PluginCatalog:Sources:0:RepoPath"] = Repo, - ["PluginCatalog:Sources:0:Ref"] = "main", - ["PluginCatalog:Sources:0:Format"] = "node-repo", - }); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(new InstanceRegistryAuthenticator( - Mesh, Mesh.ServiceProvider.GetRequiredService>())); - var app = builder.Build(); - app.MapPluginRegistry(); - await app.StartAsync(ct); - return app; - } - - /// The files response, or null when the client's own ran - /// out with no status line received, together with whether that cancellation fired. - private static async Task<(HttpResponseMessage? Response, bool ClientCut)> Files( - WebApplication app, string key, TimeSpan patience, CancellationToken ct) - { - using var request = new HttpRequestMessage(HttpMethod.Post, RegistryPackageSource.RoutePrefix + "/files") - { - Content = new StringContent(JsonSerializer.Serialize(new { id = Package }), Encoding.UTF8, "application/json"), - }; - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - using var patienceCut = new CancellationTokenSource(); - using var cut = CancellationTokenSource.CreateLinkedTokenSource(ct, patienceCut.Token); - patienceCut.CancelAfter(patience); - try - { - var response = await app.GetTestClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cut.Token); - return (response, patienceCut.IsCancellationRequested); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - return (null, patienceCut.IsCancellationRequested); - } - } - - /// - /// The git transport, reduced to the production split: the LISTING read (a filter that keeps - /// only each folder's root and manifest) answers with one package; the package-FOLDER read (a - /// filter that keeps the folder's other files) never answers — the queued clone, held. - /// - private sealed class ListingOnlyRepoClient : IGitHubRepoClient - { - private static RepoSnapshot Listing() => new("0123456789abcdef0123456789abcdef01234567", - [ - new RepoFile($"{Package}/index.json", - "{\"nodeType\":\"Space\",\"name\":\"" + Package + "\"}"), - ]); - - public IObservable Fetch( - string repositoryUrl, string commitish, string? subdirectory, string accessToken) - => Observable.Never(); - - public IObservable Fetch( - string repositoryUrl, string commitish, string? subdirectory, string accessToken, - Func pathFilter) - => pathFilter($"{Package}/Doc.md") - ? Observable.Never() - : Observable.Return(Listing()); - - public IObservable Push(GitHubPushRequest request) => NotUsed(); - - public IObservable CreateBranch(GitHubCreateBranchRequest request) => NotUsed(); - - public IObservable OpenPullRequest(GitHubOpenPullRequestRequest request) => NotUsed(); - - public IObservable GetPullRequestStatus( - string repositoryUrl, int number, string accessToken) => NotUsed(); - - public IObservable> ListIssues( - string repositoryUrl, GitHubIssueState? state, string accessToken) => NotUsed>(); - - public IObservable GetIssue(string repositoryUrl, int number, string accessToken) => NotUsed(); - - public IObservable CreateIssue(GitHubCreateIssueRequest request) => NotUsed(); - - public IObservable CommentIssue( - string repositoryUrl, int number, string body, string accessToken) => NotUsed(); - - public IObservable SetIssueState( - string repositoryUrl, int number, GitHubIssueState state, string accessToken) => NotUsed(); - - public IObservable> ListPullRequests( - string repositoryUrl, PullRequestStatus? state, string accessToken) => NotUsed>(); - - public IObservable GetPullRequestDetail( - string repositoryUrl, int number, string accessToken) => NotUsed(); - - public IObservable CommentPullRequest( - string repositoryUrl, int number, string body, string accessToken) => NotUsed(); - - public IObservable MergePullRequest(GitHubMergePullRequestRequest request) => NotUsed(); - - private static IObservable NotUsed() => Observable.Throw( - new InvalidOperationException("The files-route test uses the fetches only.")); - } -} diff --git a/test/Memex.Portal.Shared.Test/RegistryNeverMislabelsModuleBytesTest.cs b/test/Memex.Portal.Shared.Test/RegistryNeverMislabelsModuleBytesTest.cs deleted file mode 100644 index ea3c596987..0000000000 --- a/test/Memex.Portal.Shared.Test/RegistryNeverMislabelsModuleBytesTest.cs +++ /dev/null @@ -1,278 +0,0 @@ -#pragma warning disable CS1591 - -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; -using PackagingManifest = MeshWeaver.Plugin.Packaging.PluginManifest; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#6067 — THE ORIGIN of the mislabel, pinned on the real authenticated routes. A -/// registry's install record advanced to 1.20.1 through the node-content path while its shelf -/// still held the module bytes published at 1.19.4. The index advertised the package at 1.20.1 -/// WITH a module section, and the 1.20.1 download served the 1.19.4 bytes — because a record -/// entry carries no shelf version and the null-version lookup followed the head. Consumers landed -/// those bytes stamped 1.20.1. -/// -/// The rule now: an entry's module section is served only from the generation shelved AT -/// that entry's version; the bytes stay advertised under the version they were shelved at, and -/// the served archive states that version for the consumer to compare. The consumer half — the -/// landing refuses a bundle whose declared version disagrees with the advertised one — is pinned -/// through the real client at the end. -/// -public class RegistryNeverMislabelsModuleBytesTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Plugin = "LabelledAi"; - private const string Module = "MeshWeaver.LabelledAi"; - private const string Source = "Plugins"; - private const string Instance = "label-truth-consumer"; - private const string ShelvedVersion = "1.19.4"; - private const string RecordVersion = "1.20.1"; - private const string MarkerPath = "wwwroot/generation.txt"; - - private readonly string landingRoot = Path.Combine( - Path.GetTempPath(), "mw-label-truth-" + Guid.NewGuid().ToString("N")); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - Directory.CreateDirectory(landingRoot); - return base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new ModuleLandingService(baseDirectory: landingRoot))); - } - - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - try - { - if (Directory.Exists(landingRoot)) - Directory.Delete(landingRoot, recursive: true); - } - catch - { - // Best-effort temp cleanup must never turn a passing assertion red. - } - } - - /// - /// The registry state measured on 2026-10-04: the shelf holds the module at 1.19.4, the - /// install record says 1.20.1. The index must not offer a module section at 1.20.1, the 1.20.1 - /// download must carry no module bytes, and the 1.19.4 bytes stay servable under 1.19.4 with - /// the archive stating that version. - /// - [Fact(Timeout = 300_000)] - public async Task ARecordAheadOfTheShelf_IsNeverServedTheShelfsBytes() - { - var ct = TestContext.Current.CancellationToken; - await Shelve(ShelvedVersion, "bytes-of-" + ShelvedVersion); - await InstallRecord(RecordVersion, ct); - Assert.Equal(ShelvedVersion, Assert.Single(ModuleActivationSidecar.Read(landingRoot).Entries).Version); - - var key = await RegisterInstance(ct); - await using var app = await StartBundleHost(ct); - - using var indexResponse = await Get(app, PluginBundleEndpoints.RoutePrefix + "/index.json", key); - Assert.Equal(HttpStatusCode.OK, indexResponse.StatusCode); - using var index = JsonDocument.Parse(await indexResponse.Content.ReadAsStringAsync(ct)); - var entries = index.RootElement.GetProperty("bundles").EnumerateArray() - .Where(b => string.Equals(b.GetProperty("plugin").GetString(), Plugin, StringComparison.OrdinalIgnoreCase)) - .ToDictionary(b => b.GetProperty("version").GetString()!, b => b.Clone()); - - // The record's version is still advertised (its NodeType content is real) — but with NO - // module section: the shelf holds no module bytes at that version. - Assert.True(entries.ContainsKey(RecordVersion)); - Assert.True( - !entries[RecordVersion].TryGetProperty("module", out var recordModule) - || recordModule.ValueKind == JsonValueKind.Null, - $"the index offers module bytes at {RecordVersion}, but the shelf only holds them at {ShelvedVersion}"); - // The shelved bytes stay advertised under the version they were shelved at. - Assert.Equal(Module, entries[ShelvedVersion].GetProperty("module").GetString()); - - // The download at the record's version carries no module bytes at all. - var (_, recordFiles) = BundleReader.ReadModule(await Bundle(app, RecordVersion, key, ct)); - Assert.Empty(recordFiles); - - // The download at the shelved version carries those bytes AND says which version they are. - var shelvedBundle = await Bundle(app, ShelvedVersion, key, ct); - var (shelvedManifest, shelvedFiles) = BundleReader.ReadModule(shelvedBundle); - Assert.NotEmpty(shelvedFiles); - Assert.Equal(ShelvedVersion, shelvedManifest!.Module!.Version); - var marker = Assert.Single(BundleReader.ReadModuleAssets(shelvedBundle), a => a.RelativePath == MarkerPath); - Assert.Equal("bytes-of-" + ShelvedVersion, Encoding.UTF8.GetString(marker.Bytes)); - } - - /// The control: when the shelf DOES hold the record's version, the record's entry - /// serves exactly those bytes, stated at that version — the healthy case is unchanged. - [Fact(Timeout = 300_000)] - public async Task ARecordMatchingTheShelf_IsServedItsOwnGeneration() - { - var ct = TestContext.Current.CancellationToken; - await Shelve(RecordVersion, "bytes-of-" + RecordVersion); - await InstallRecord(RecordVersion, ct); - - var key = await RegisterInstance(ct); - await using var app = await StartBundleHost(ct); - - var bundle = await Bundle(app, RecordVersion, key, ct); - var (manifest, files) = BundleReader.ReadModule(bundle); - Assert.NotEmpty(files); - Assert.Equal(RecordVersion, manifest!.Module!.Version); - var marker = Assert.Single(BundleReader.ReadModuleAssets(bundle), a => a.RelativePath == MarkerPath); - Assert.Equal("bytes-of-" + RecordVersion, Encoding.UTF8.GetString(marker.Bytes)); - } - - /// - /// The consumer half through the REAL client: an archive advertised at 1.20.1 whose module - /// section states 1.19.4 (what a registry that predates the origin fix could still hand out, - /// with this platform's manifest) lands NOTHING; the same archive advertised at its own version - /// lands, stamped with the version its bytes declare. - /// - [Fact(Timeout = 120_000)] - public async Task TheLanding_RefusesBytesAdvertisedUnderAnotherVersion() - { - var ct = TestContext.Current.CancellationToken; - var client = new PluginBundleClient(Mesh, "http://registry.invalid"); - var bytes = ServedShapeBundle(topLevelVersion: RecordVersion, moduleVersion: ShelvedVersion); - - var refused = await client - .LandFromBundle(Plugin, Module, $"{Source}/{Plugin}", RecordVersion, bytes, "s-advertised") - .FirstAsync().Await(ct); - Assert.Equal(0, refused); - Assert.Empty(ModuleActivationSidecar.Read(landingRoot).Entries); - - var landed = await client - .LandFromBundle(Plugin, Module, $"{Source}/{Plugin}", ShelvedVersion, - ServedShapeBundle(topLevelVersion: ShelvedVersion, moduleVersion: ShelvedVersion), "s-advertised") - .FirstAsync().Await(ct); - Assert.Equal(1, landed); - Assert.Equal(ShelvedVersion, Assert.Single(ModuleActivationSidecar.Read(landingRoot).Entries).Version); - } - - private async Task Shelve(string version, string marker) - { - var assembly = File.ReadAllBytes(typeof(BundleReader).Assembly.Location); - await Mesh.ServiceProvider.GetRequiredService() - .ShelveModule( - Module, - [(Module + ".dll", assembly)], - frameworkMvid: "framework-" + version, - packagePath: $"{Source}/{Plugin}", - version: version, - staticAssets: [(MarkerPath, Encoding.UTF8.GetBytes(marker))]) - .FirstAsync() - .Timeout(TestTimeouts.Convergence).Await(); - } - - private Task InstallRecord(string releasedVersion, CancellationToken ct) => - PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = Plugin, - Name = Plugin, - Kind = PackageKind.Content, - TargetPartition = Plugin, - SourceFolder = Plugin, - Version = "content-path-commit", - ReleasedVersion = releasedVersion, - Source = Source, - Module = Module, - }, - [new PackageFile($"{Plugin}/Doc.md", "# Label truth")], - "HEAD") - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - private Task RegisterInstance(CancellationToken ct) => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [$"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:0"] = $"{Source}/*", - }) - .Build()) - .Register("label-owner", "Label Owner", "owner@test.com", Instance, Instance) - .Select(result => result.RawKey) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - private async Task StartBundleHost(CancellationToken ct) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(Mesh.ServiceProvider.GetRequiredService()); - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(ct); - return app; - } - - private static async Task Get(WebApplication app, string route, string key) - { - using var request = new HttpRequestMessage(HttpMethod.Get, route); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - return await app.GetTestClient().SendAsync(request); - } - - private static async Task Bundle(WebApplication app, string version, string key, CancellationToken ct) - { - using var response = await Get(app, $"{PluginBundleEndpoints.RoutePrefix}/{Plugin}/{version}", key); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - return await response.Content.ReadAsByteArrayAsync(ct); - } - - /// The archive shape a registry serves: the URL's version at the top level, the - /// shelf generation's version on the module section, REAL assembly bytes (#3538). - private static byte[] ServedShapeBundle(string topLevelVersion, string moduleVersion) - { - var manifestJson = JsonSerializer.Serialize(new - { - plugin = Plugin, - version = topLevelVersion, - frameworkMvid = "s-requested-lane", - module = new - { - assemblyName = Module, - version = moduleVersion, - assemblies = new[] { Module + ".dll" }, - }, - }); - var buffer = new MemoryStream(); - NuGetPackageWriter.Write( - buffer, - new PackagingManifest(Plugin, "MeshWeaver.Plugin." + Plugin, topLevelVersion, Plugin, null, []), - "3.0.0", - [ - new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleEntryPathFor(Module + ".dll"), - () => new MemoryStream(File.ReadAllBytes(typeof(BundleReader).Assembly.Location))), - ], - manifestJson); - return buffer.ToArray(); - } -} diff --git a/test/Memex.Portal.Shared.Test/RegistryReconcileDeferralTest.cs b/test/Memex.Portal.Shared.Test/RegistryReconcileDeferralTest.cs deleted file mode 100644 index c407e8b4dc..0000000000 --- a/test/Memex.Portal.Shared.Test/RegistryReconcileDeferralTest.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A registry down for longer than the boot budget must DEFER the reconcile, not drop it -/// (Systemorph/MeshWeaver#2888). -/// -/// The 2026-08-31 fix taught to re-ask a 503 within -/// its startup budget. The same day a registry stayed down past that budget and the pod gave up -/// with one Error line — no durable record, no notification, and no path that would ever run the -/// skipped reconcile short of a restart (the log line's "a human opening the catalog page" only -/// RENDERS the feed). This fixture pins the two halves that close that gap, against the REAL -/// reconciler, the REAL HTTP path and a registry that answers -/// exactly as the incident's did: -/// -/// Exhausting the budget records the registry as -/// on the ledger node the reconciler owns and raises ONE Admin-anchored notification -/// naming the registry and its last answer. -/// The next successful feed read — made through the same class every catalog surface -/// uses, with no involvement from the reconciler's caller — drains the marker: the reconcile -/// runs from THAT read's packages (no second round-trip), the ledger clears, and the -/// installation's outdated package gets its "Update available" reminder. -/// -/// -public class RegistryReconcileDeferralTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string RegistryUrl = "http://registry.reconcile.test"; - private const string RegistryName = "Reconcile Test Registry"; - private const string PackageId = "ReconcileDeferralPkg"; - private const string InstalledModuleVersion = "mv-installed-7c1"; - private const string ServedModuleVersion = "mv-served-9e4"; - private const string Token = "mwi_reconcile_deferral_test"; - - private readonly FakeRegistry registry = new(); - - private System.Text.Json.JsonSerializerOptions Json => Mesh.JsonSerializerOptions; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - // The registry, as RegistryPackageSource reaches it: through the named HttpClient the - // catalog wires. Nothing between the reconciler and the wire is replaced. - .ConfigureServices(s => s.AddSingleton(new FakeRegistryClientFactory(registry))); - - [Fact] - public async Task RegistryDownPastTheBudget_DefersTheReconcile_AndTheNextFeedReadDrainsIt() - { - await SeedInstallRecord(); - registry.Serve(HttpStatusCode.ServiceUnavailable, []); - - var reconciler = Mesh.ServiceProvider.GetRequiredService(); - var reference = new PluginRegistryReference { Name = RegistryName, Url = RegistryUrl, Token = Token }; - - // ── 1. The boot reconcile, against a registry that 503s for the whole budget ──────────── - // Zero backoff: the budget is exhausted in milliseconds instead of ~26 s; the ATTEMPT count - // below proves every retry in it was spent before the reconciler deferred. - await reconciler.ReconcileRegistry(reference, _ => TimeSpan.Zero).Timeout(TestTimeouts.Convergence).Await(); - // The whole boot budget is spent before the reconcile is deferred. - Assert.Equal(RegistryUpdateReconciler.FeedReadRetries + 1, registry.Attempts); - - // ── 2a. The skipped reconcile is a durable fact on the node the reconciler owns ───────── - var pending = await LedgerEntries() - .Where(entries => entries.Any(e => e.Url == RegistryUrl && e.Pending)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - var deferred = pending.Single(e => e.Url == RegistryUrl); - Assert.Equal(RegistryName, deferred.Name); - Assert.Equal(RegistryUpdateReconciler.FeedReadRetries + 1, deferred.Attempts); - Assert.Contains("503", deferred.LastFault); - Assert.NotNull(deferred.PendingSince); - Assert.Null(deferred.LastReconciledAt); - - // ── 2b. Platform admins were told — ONE Admin-anchored bell pointing at the ledger ────── - var notifications = await AdminNotifications() - .Where(ns => ns.Any(n => n.TargetNodePath == RegistryUpdateReconciler.LedgerPath)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - var notification = notifications.Single(n => n.TargetNodePath == RegistryUpdateReconciler.LedgerPath); - Assert.Equal(NotificationType.System, notification.NotificationType); - Assert.Contains(RegistryName, notification.Title); - Assert.Contains(RegistryUrl, notification.Message); - Assert.Contains("503", notification.Message); - Assert.False(notification.IsRead); - - // ── 3. The registry recovers and somebody opens the catalog ───────────────────────────── - // A plain feed read through the class every catalog surface uses — the reconciler is not - // told anything by this caller. The served package carries a NEWER module version than the - // install record, so a reconcile that actually runs has something observable to do. - registry.Serve(HttpStatusCode.OK, [ServedManifest()]); - var served = await new RegistryPackageSource(Mesh, RegistryUrl, Token) - .ListPackages("HEAD").FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.Single(served, p => p.Id == PackageId); - - // ── 4. Drained: the ledger clears, and the reconcile ran ──────────────────────────────── - var drained = await LedgerEntries() - .Where(entries => entries.Any(e => e.Url == RegistryUrl && !e.Pending && e.LastReconciledAt is not null)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.Equal(RegistryReconcileEntry.ViaFeedRead, drained.Single(e => e.Url == RegistryUrl).LastReconciledVia); - - // The reconcile's own witness: the install record opted OUT of unattended updates, so a - // changed module raises the "Update available" reminder — carrying the provenance that - // names THIS registry. - // - // 🚨 It is read from the PLATFORM bell, not from the install record. Since - // MeshWeaver#3156 a notification is delivered to its ADDRESSEE, and only a platform admin - // can apply an update (the sibling refusal this same emitter raises says so in as many - // words), so it is addressed to `Admin` instead of being filed under - // `Plugins/{package}/_Notification` where every catalog reader saw it. The install record - // stays the click target, which is what `TargetNodePath` is asserted on below. - var reminder = await AdminNotifications() - .Select(ns => ns.FirstOrDefault(n => - n.Title.StartsWith("Update available", StringComparison.Ordinal))) - .Where(n => n is not null) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.Contains($"Served by registry '{RegistryName}'", reminder!.Message); - Assert.Equal($"{PackageInstaller.InstalledPartition}/{PackageId}", reminder.TargetNodePath); - Assert.Equal(StartupErrorNotifier.AdminPartition, reminder.Recipient); - - // The drain reconciled from the packages the catalog read returned: the boot's attempts - // plus that ONE read, and not a single extra round-trip to the registry. - // The deferred reconcile must run from the read that drained it, never re-read the feed. - Assert.Equal(RegistryUpdateReconciler.FeedReadRetries + 2, registry.Attempts); - } - - private async Task SeedInstallRecord() - { - var manifest = new PackageManifest - { - Id = PackageId, - Name = "Reconcile Deferral Package", - Version = "1.0.0", - ModuleVersion = InstalledModuleVersion, - TargetPartition = PackageId, - // The reminder path the drain's witness reads (step 4). Since policy - // packages-auto-update a seeded reminder-only record is moved to Auto by the boot repair - // pass (PackageAutoUpdateMigration) — and whether that pass lands before or after the - // drain decided whether this test saw a reminder or a failed auto-update. Only an - // administrator's DELIBERATE, stamped opt-out is kept, so that is what is seeded. - AutoUpdate = false, - UpdatePolicy = PackageUpdatePolicy.Notify, - UpdatePolicySetAt = DateTimeOffset.UtcNow, - }; - var record = MeshNode.FromPath($"{PackageInstaller.InstalledPartition}/{PackageId}") with - { - NodeType = PackageInstaller.PackageNodeType, - Name = manifest.Name, - State = MeshNodeState.Active, - Content = manifest, - }; - var access = Mesh.ServiceProvider.GetRequiredService(); - await access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode(record)) - .Timeout(TestTimeouts.Convergence).Await(); - } - - private static PackageManifest ServedManifest() => new() - { - Id = PackageId, - Name = "Reconcile Deferral Package", - Version = "1.0.1", - ModuleVersion = ServedModuleVersion, - TargetPartition = PackageId, - }; - - // The ledger, read LIVE through a children query (a query never storms on a node that does not - // exist yet, and it re-emits on every write), flattened to its registry entries. - private IObservable> LedgerEntries() => - Mesh.GetWorkspace() - .GetQuery("ledger|reconcile", - $"path:{PackageInstaller.InstalledPartition} scope:children nodeType:{RegistryUpdateReconciler.LedgerNodeType}") - .Select(ns => (IReadOnlyList)(ns ?? []) - .Select(n => n.ContentAs(Json)) - .Where(l => l is not null) - .SelectMany(l => l!.Registries) - .ToList()); - - private IObservable> AdminNotifications() => - Mesh.GetWorkspace() - .GetQuery("notif|Admin|reconcile", - $"path:{StartupErrorNotifier.AdminPartition}/_Notification scope:children nodeType:Notification") - .Select(ns => (IReadOnlyList)(ns ?? []) - .Select(n => n.ContentAs(Json)) - .Where(n => n is not null) - .Select(n => n!) - .ToList()); - - /// - /// The registry as the incident saw it: every request answered with the status the test set — - /// a 503 whose body names instance-key resolution as temporarily unavailable, exactly the - /// answer memex got on 2026-08-31 — or, once recovered, the real list payload. Counts attempts. - /// - private sealed class FakeRegistry : HttpMessageHandler - { - private volatile HttpStatusCode status = HttpStatusCode.ServiceUnavailable; - private volatile string body = ""; - private int attempts; - - public int Attempts => Volatile.Read(ref attempts); - - public void Serve(HttpStatusCode code, IReadOnlyList packages) - { - // Body before status: a reader that sees the new status sees the body it belongs to. - body = PluginRegistryPayloads.List(packages); - status = code; - } - - // HttpMessageHandler's contract is Task-shaped; nothing here bridges an observable. - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - Interlocked.Increment(ref attempts); - var current = status; - var content = current == HttpStatusCode.OK - ? body - : "{\"error\":\"Instance-key resolution is temporarily unavailable — retry shortly. This is NOT a statement about your key or your grant.\"}"; - return Task.FromResult(new HttpResponseMessage(current) - { - Content = new StringContent(content, Encoding.UTF8, "application/json"), - }); - } - } - - private sealed class FakeRegistryClientFactory(HttpMessageHandler handler) : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); - } -} diff --git a/test/Memex.Portal.Shared.Test/RegistryStatesTheServedBuildsModuleVersionTest.cs b/test/Memex.Portal.Shared.Test/RegistryStatesTheServedBuildsModuleVersionTest.cs deleted file mode 100644 index cb70b6ccdf..0000000000 --- a/test/Memex.Portal.Shared.Test/RegistryStatesTheServedBuildsModuleVersionTest.cs +++ /dev/null @@ -1,277 +0,0 @@ -#pragma warning disable CS1591 - -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#6831 — a registry's own-lane serve labels each assembly with the module version -/// THOSE BYTES were built at, never the version the package is advertised at. -/// -/// Measured on two portals: the registry advertised Personal at 1.1.1 while its -/// Personal/PersonalApp record served the 1.0.1 build (1.1.1's only bake had been cancelled), -/// and the consumer's record then read buildProvenance: AdoptedUnverified, -/// adoptedModuleVersion: 1.1.1 over the 1.0.1 MVID. BuildResult wrote the package -/// version as the manifest's only version, and PluginBundleClient stamped every assembly -/// with it. -/// -/// Real authenticated route, real install record, real assembly store. Two types in one -/// package at 1.1.1: one whose current build is an ADOPTION at 1.0.1 (the issue's record), and one -/// compiled here from the current source (the control: it IS 1.1.1). The consumer half is the real -/// , which is what PluginBundleClient seeds from. -/// -public class RegistryStatesTheServedBuildsModuleVersionTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Plugin = "LabelledPersonal"; - private const string Source = "Plugins"; - private const string Instance = "served-version-consumer"; - private const string AdvertisedVersion = "1.1.1"; - private const string BuiltVersion = "1.0.1"; - private const string AdoptedType = Plugin + "/PersonalApp"; - private const string CompiledType = Plugin + "/Fresh"; - private const string SameKeyType = Plugin + "/SameKey"; - private const string UnmatchedType = Plugin + "/Unmatched"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog(); - - [Fact(Timeout = 300_000)] - public async Task AnOlderBuildServedUnderANewerPackageVersion_IsLabelledWithItsOwnVersion() - { - var ct = TestContext.Current.CancellationToken; - await InstallRecord(ct); - await CreateTypeWithBuild(AdoptedType, storeVersion: 1, new NodeTypeDefinition - { - BuildProvenance = BuildProvenance.AdoptedUnverified, - AdoptedModuleVersion = BuiltVersion, - CurrentModuleVersion = AdvertisedVersion, - }, ct); - await CreateTypeWithBuild(CompiledType, storeVersion: 1, new NodeTypeDefinition - { - BuildProvenance = BuildProvenance.Compiled, - CurrentModuleVersion = AdvertisedVersion, - // The compile recorded the version of these bytes (the helper keys it to their MVID). - BuiltModuleVersion = AdvertisedVersion, - }, ct); - - var key = await RegisterInstance(ct); - await using var app = await StartBundleHost(ct); - var bundle = await Bundle(app, AdvertisedVersion, key, ct); - - var (manifest, payloads) = BundleReader.Read(bundle); - Assert.NotNull(manifest); - Assert.Equal(AdvertisedVersion, manifest!.Version); - - // The issue's record: the 1.0.1 bytes, served under the package's 1.1.1, must say 1.0.1 — - // what the consumer stamps as adoptedModuleVersion. - var adopted = Assert.Single(payloads, p => p.NodePath == AdoptedType); - Assert.Equal(BuiltVersion, adopted.ModuleVersion); - Assert.True(manifest.ModuleVersionPerAssembly == true, - "the registry states each assembly's own version, so an unstated one is unknown, never the package's"); - - var compiled = Assert.Single(payloads, p => p.NodePath == CompiledType); - Assert.Equal(AdvertisedVersion, compiled.ModuleVersion); - } - - /// - /// Review thread 4287471936: several builds share one store key, and the endpoint served the - /// NEWEST file under it while labelling it with the version the record states for the build it - /// NAMES. The record's build must be the one served (by its content path and MVID), and a file - /// whose MVID is not the record's must never carry the record's version. - /// - [Fact(Timeout = 300_000)] - public async Task ASameKeyBuildWithAnotherMvid_IsNeverServedUnderTheRecordsVersion() - { - var ct = TestContext.Current.CancellationToken; - await InstallRecord(ct); - var store = Mesh.ServiceProvider.GetRequiredService(); - var named = await File.ReadAllBytesAsync(typeof(BundleReader).Assembly.Location, ct); - var other = await File.ReadAllBytesAsync(typeof(PluginBundleEndpoints).Assembly.Location, ct); - var namedMvid = ServedBuildIdentity.OfBytes(named); - Assert.NotEqual(namedMvid, ServedBuildIdentity.OfBytes(other)); - - // SameKey: the record names build A at 1.0.1; build B lands later under the SAME key and is - // the newest file there. - await CreateTypeWithBuild(SameKeyType, storeVersion: 1, new NodeTypeDefinition - { - BuildProvenance = BuildProvenance.AdoptedUnverified, - AdoptedModuleVersion = BuiltVersion, - BuiltModuleVersion = BuiltVersion, - CurrentModuleVersion = AdvertisedVersion, - }, ct); - await PutNewest(store, SameKeyType, other, ct); - - // Unmatched: the record names bytes this store does not hold, so the lookup falls back to the - // key's newest file — which is not the record's build, so its version is unknown. - await CreateTypeWithBuild(UnmatchedType, storeVersion: 1, new NodeTypeDefinition - { - BuildProvenance = BuildProvenance.AdoptedUnverified, - AdoptedModuleVersion = BuiltVersion, - BuiltModuleVersion = BuiltVersion, - CurrentModuleVersion = AdvertisedVersion, - }, ct, recordedMvid: "ffffffffffffffffffffffffffffffff", bytes: other); - - var key = await RegisterInstance(ct); - await using var app = await StartBundleHost(ct); - var (_, payloads) = BundleReader.Read(await Bundle(app, AdvertisedVersion, key, ct)); - - var sameKey = Assert.Single(payloads, p => p.NodePath == SameKeyType); - Assert.Equal(namedMvid, ServedBuildIdentity.OfBytes(sameKey.Assembly)); - Assert.Equal(BuiltVersion, sameKey.ModuleVersion); - - var unmatched = Assert.Single(payloads, p => p.NodePath == UnmatchedType); - Assert.Null(unmatched.ModuleVersion); - } - - private static async Task PutNewest(IAssemblyStore store, string path, byte[] bytes, CancellationToken ct) - { - var location = await store.PutWithLocation(path, 1, bytes, null) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - File.SetLastWriteTimeUtc(location.LocalPath, DateTime.UtcNow.AddHours(1)); - } - - /// The pure rule the registry reads the version through, for a record written BEFORE - /// the version was keyed to the bytes' MVID: only a judged adoption can still say; every other - /// historical record is unknown, never a guess from the live source. The transitions that write - /// the keyed version are pinned in BuiltModuleVersionFollowsTheBytesTest. - [Theory] - [InlineData(BuildProvenance.AdoptedVerified, false, "1.0.1")] - [InlineData(BuildProvenance.AdoptedUnverified, false, "1.0.1")] - [InlineData(BuildProvenance.StaleAdopted, true, null)] - [InlineData(BuildProvenance.AdoptionRefused, true, null)] - [InlineData(BuildProvenance.Compiled, false, null)] - [InlineData(BuildProvenance.Compiled, true, null)] - public void TheServedBuildsVersion_ComesFromTheBuild_NeverFromTheSource( - BuildProvenance provenance, bool dirty, string? expected) - { - var live = System.Collections.Immutable.ImmutableDictionary.Empty.Add("T/Source/A", 2); - var def = new NodeTypeDefinition - { - BuildProvenance = provenance, - AdoptedModuleVersion = BuiltVersion, - CurrentModuleVersion = AdvertisedVersion, - CurrentSourceVersions = live, - CompiledSources = dirty ? live.SetItem("T/Source/A", 1) : live, - }; - Assert.Equal(expected, PrebuiltAssemblySeeder.ModuleVersionOfBuild(def)); - } - - /// A bake's sealed bundle states no per-assembly version and no flag: every assembly - /// was built at the bundle's version, exactly as before. - [Fact] - public void ABundleWithoutPerAssemblyVersions_KeepsTheBundleVersion() - { - var manifest = new BundleReader.Manifest(Plugin, AdvertisedVersion, "fw", []); - Assert.Equal(AdvertisedVersion, manifest.ModuleVersionOf(new BundleReader.AssemblyRef(AdoptedType, "a.dll"))); - var perAssembly = manifest with { ModuleVersionPerAssembly = true }; - Assert.Null(perAssembly.ModuleVersionOf(new BundleReader.AssemblyRef(AdoptedType, "a.dll"))); - } - - private async Task CreateTypeWithBuild( - string path, long storeVersion, NodeTypeDefinition shape, CancellationToken ct, - string? recordedMvid = null, byte[]? bytes = null) - { - var store = Mesh.ServiceProvider.GetRequiredService(); - bytes ??= await File.ReadAllBytesAsync(typeof(BundleReader).Assembly.Location, ct); - var mvid = recordedMvid ?? ServedBuildIdentity.OfBytes(bytes); - var location = await store.PutWithLocation(path, storeVersion, bytes, null) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - var node = MeshNode.FromPath(path) with - { - Name = path[(path.LastIndexOf('/') + 1)..], - NodeType = MeshNode.NodeTypePath, - State = MeshNodeState.Active, - Content = shape with - { - Configuration = "config => config", - CompilationStatus = CompilationStatus.Ok, - LastCompiledVersion = storeVersion, - LatestAssemblyCollection = location.Collection, - // A record naming bytes the store does not hold names no path either. - LatestAssemblyPath = recordedMvid is null ? location.ContentPath : null, - LatestAssemblyMvid = mvid, - BuiltModuleVersionMvid = shape.BuiltModuleVersion is null ? null : mvid, - CompiledFrameworkVersion = PrebuiltAssemblySeeder.LiveFrameworkMvid, - }, - }; - using (Mesh.ServiceProvider.GetRequiredService().ImpersonateAsSystem()) - await Mesh.ServiceProvider.GetRequiredService().CreateNode(node) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: ct); - } - - private Task InstallRecord(CancellationToken ct) => - PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = Plugin, - Name = Plugin, - Kind = PackageKind.Content, - TargetPartition = Plugin, - SourceFolder = Plugin, - Version = "content-path-commit", - ReleasedVersion = AdvertisedVersion, - Source = Source, - }, - [new PackageFile($"{Plugin}/Doc.md", "# Served version")], - "HEAD") - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - private Task RegisterInstance(CancellationToken ct) => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [$"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:0"] = $"{Source}/*", - }) - .Build()) - .Register("served-owner", "Served Owner", "owner@test.com", Instance, Instance) - .Select(result => result.RawKey) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - private async Task StartBundleHost(CancellationToken ct) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(Mesh.ServiceProvider.GetRequiredService()); - var app = builder.Build(); - app.MapPluginBundles(); - await app.StartAsync(ct); - return app; - } - - private static async Task Bundle(WebApplication app, string version, string key, CancellationToken ct) - { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{PluginBundleEndpoints.RoutePrefix}/{Plugin}/{version}"); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}"); - using var response = await app.GetTestClient().SendAsync(request, ct); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - return await response.Content.ReadAsByteArrayAsync(ct); - } -} diff --git a/test/Memex.Portal.Shared.Test/RegistryUpdateReconcilerTest.cs b/test/Memex.Portal.Shared.Test/RegistryUpdateReconcilerTest.cs deleted file mode 100644 index 3bfef70e63..0000000000 --- a/test/Memex.Portal.Shared.Test/RegistryUpdateReconcilerTest.cs +++ /dev/null @@ -1,366 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A module-published broadcast reconciles THAT package, within the process's lifetime — -/// not at its next boot (#3650, rule R3 of Doc/Architecture/ModuleAdoptionPolicy). -/// -/// The registry POSTs a record to a consumer's webhook inbox -/// the moment a bundle lands on its shelf. This fixture is the consumer half, against the REAL -/// (started as the hosted service it is), the REAL inbox -/// store (, the exact path the anonymous endpoint takes) and a -/// registry that answers exactly what a real one would for a package whose bundle this consumer -/// has never landed: -/// -/// The boot pass runs against the fake registry and lands nothing — the feed declares no -/// module for either installed package, so the boot's module lane has nothing to do — and the -/// reconcile ledger exists from then on, which is what makes it an inbox target. -/// A broadcast for package A lands in the ledger's _Inbox. The reconciler drains -/// it: it reads A's OWN install record (which does declare a module), consults the registry's -/// bundle index, decides Land, and asks the registry for A's bundle — and never for B's. The -/// ledger records the pass as broadcast, and the delivery is deleted. -/// A broadcast from a registry this installation does not consume is dropped and deleted -/// without a single registry request. -/// -/// The witness is the registry's own request log: which bundle was asked for is the one -/// thing a consumer cannot fake, and "asked for A, not B" is the whole claim. -/// -public class RegistryUpdateReconcilerTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string RegistryUrl = "http://registry.broadcast.test"; - private const string RegistryName = "Broadcast Test Registry"; - private const string PackageA = "BroadcastPkgA"; - private const string PackageB = "BroadcastPkgB"; - private const string ModuleVersion = "mv-installed-4b2"; - private const string Token = "mwi_broadcast_test"; - private const string ServedFramework = "s1234567890abcdef1234567890abcdef"; - - private readonly FakeRegistry registry = new(); - - private JsonSerializerOptions Json => Mesh.JsonSerializerOptions; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - // The generic webhook inbox the portal registers (MemexConfiguration) — the WebhookEvent - // node type a delivery is stored as. Same production registration, not a duplicate. - .AddWebhookInbox() - .ConfigureServices(s => s - .AddSingleton(new FakeRegistryClientFactory(registry)) - // The registry this installation consumes — what arms the reconciler's boot pass, - // its inbox watch and its safety net (left at its default; it never ticks here). - .AddSingleton(new PluginCatalogOptions - { - Registries = - [ - new PluginRegistryReference { Name = RegistryName, Url = RegistryUrl, Token = Token }, - ], - })); - - [Fact(Timeout = 240_000)] - public async Task ABroadcastForOnePackage_ReconcilesThatPackage_AndOnlyThat() - { - await SeedInstallRecord(PackageA, "ModA", TestContext.Current.CancellationToken); - await SeedInstallRecord(PackageB, "ModB", TestContext.Current.CancellationToken); - - // ── 1. The boot pass ran (the hosted service started with the mesh) ─────────────────── - await LedgerEntries() - .Where(entries => entries.Any(e => e.Url == RegistryUrl && e.LastReconciledVia == RegistryReconcileEntry.ViaBoot)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.Empty(registry.BundleDownloads); - var feedReadsAfterBoot = registry.FeedReads; - - // ── 2. The registry tells this installation that A's module was published ──────────── - var delivered = await WebhookInbox.Deliver( - Mesh, [new WebhookInbox.WebhookTarget(ModulePublished.InboxTarget)], - ModulePublished.InboxTarget, "application/json", [], - new ModulePublished - { - Registry = RegistryUrl, - Package = PackageA, - Module = "ModA", - Version = "1.0.0", - FrameworkMvid = ServedFramework, - PublishedAt = DateTimeOffset.UtcNow, - }.Serialize()) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.Equal(WebhookInbox.DeliveryStatus.Accepted, delivered.Status); - - // ── 3. The drain reconciled A: the registry was asked for A's bundle ────────────────── - await Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .Select(_ => registry.BundleDownloads) - .Where(downloads => downloads.Contains(PackageA)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - var drained = await LedgerEntries() - .Where(entries => entries.Any(e => e.Url == RegistryUrl && e.LastReconciledVia == RegistryReconcileEntry.ViaBroadcast)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.NotNull(drained.Single(e => e.Url == RegistryUrl).LastReconciledAt); - - // ── 4. …and only A. The broadcast is per package, and B was never asked about ───────── - Assert.Equal([PackageA], registry.BundleDownloads); - // A broadcast drain reads the bundle INDEX for its package — never the whole feed again. - Assert.Equal(feedReadsAfterBoot, registry.FeedReads); - - // ── 5. The delivery is consumed ─────────────────────────────────────────────────────── - await InboxDeliveries() - .Where(deliveries => deliveries.Count == 0) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - } - - /// - /// A broadcast for a package WITHOUT a compiled module still retries that package's NodeTypes - /// held for a bundle (): the module lane skips such a - /// package, and the publication carrying their bake must not be acknowledged untried. The first - /// broadcast, with nothing held, is the control: it attempts no adoption. - /// - [Fact(Timeout = 240_000)] - public async Task ABroadcastForAModulelessPackage_RetriesItsHeldNodeTypes() - { - await SeedInstallRecord(PackageB, null, TestContext.Current.CancellationToken); - await LedgerEntries() - .Where(entries => entries.Any(e => e.Url == RegistryUrl && e.LastReconciledVia == RegistryReconcileEntry.ViaBoot)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.Empty(registry.BundleDownloads); - - // Control: nothing is held — the broadcast is drained and asks for no bundle. - await DeliverPublished(PackageB); - await InboxDeliveries() - .Where(deliveries => deliveries.Count == 0) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - var adoptions = Mesh.ServiceProvider.GetRequiredService(); - Assert.DoesNotContain(adoptions.Outcomes, o => o.PluginId == PackageB); - - // A NodeType of B now waits in a delivery hold; the next publication of B asks for its - // bundle. The witness is the adoption ledger: this fake's index is baked for another - // framework, so the attempt ends as a named FrameworkDeclined rather than a download — - // what is pinned is that the attempt is MADE on the broadcast. - HoldNodeType($"{PackageB}/Submission"); - await DeliverPublished(PackageB); - await Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .Select(_ => adoptions.Outcomes) - .Where(outcomes => outcomes.Any(o => o.PluginId == PackageB)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - await InboxDeliveries() - .Where(deliveries => deliveries.Count == 0) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - } - - private async Task DeliverPublished(string packageId) - { - var delivered = await WebhookInbox.Deliver( - Mesh, [new WebhookInbox.WebhookTarget(ModulePublished.InboxTarget)], - ModulePublished.InboxTarget, "application/json", [], - new ModulePublished - { - Registry = RegistryUrl, - Package = packageId, - Version = "1.0.0", - FrameworkMvid = ServedFramework, - PublishedAt = DateTimeOffset.UtcNow, - }.Serialize()) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.Equal(WebhookInbox.DeliveryStatus.Accepted, delivered.Status); - } - - private void HoldNodeType(string typePath) - { - var now = DateTimeOffset.UtcNow; - var held = new NodeTypeDefinition - { - Configuration = "config => config", - CompilationStatus = CompilationStatus.Ok, - CompiledFrameworkVersion = PrebuiltAssemblySeeder.LiveFrameworkMvid, - BuildProvenance = BuildProvenance.StaleAdopted, - DeliveryHoldReason = "Serving the last build this mesh holds; the current source is waiting for a bundle.", - }; - Mesh.ServiceProvider.GetRequiredService().RecordLiveRecords( - NodeTypeLiveRecordCensus.Of([(typePath, (NodeTypeDefinition?)held)], - PrebuiltAssemblySeeder.LiveFrameworkMvid, now.AddHours(-1), now)); - } - - [Fact(Timeout = 240_000)] - public async Task ABroadcastFromAnUnknownRegistry_IsDroppedWithoutARequest() - { - await SeedInstallRecord(PackageA, "ModA", TestContext.Current.CancellationToken); - await LedgerEntries() - .Where(entries => entries.Any(e => e.Url == RegistryUrl && e.LastReconciledVia == RegistryReconcileEntry.ViaBoot)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - var requestsBefore = registry.Requests.Count; - - var delivered = await WebhookInbox.Deliver( - Mesh, [new WebhookInbox.WebhookTarget(ModulePublished.InboxTarget)], - ModulePublished.InboxTarget, "application/json", [], - new ModulePublished - { - Registry = "https://somebody-else.example", - Package = PackageA, - Version = "1.0.0", - PublishedAt = DateTimeOffset.UtcNow, - }.Serialize()) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.Equal(WebhookInbox.DeliveryStatus.Accepted, delivered.Status); - - // Consumed — and nothing was asked of the registry this installation does consume. - await InboxDeliveries() - .Where(deliveries => deliveries.Count == 0) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - Assert.Empty(registry.BundleDownloads); - Assert.Equal(requestsBefore, registry.Requests.Count); - } - - /// The host-based match: a consumer configured with a trailing slash or another scheme - /// still recognises its registry; a different host never does. Since #4094 the reader is the - /// platform's ONE registry-host rule (SelfUpdateOptions.HostOf, shared with the - /// self-updater's credential selection), so a value that names no http(s) host names no - /// registry — two copies of an unreadable string no longer "match", and a URL carrying - /// userinfo matches nothing rather than the host a human would not have read. - [Theory] - [InlineData("https://memex.meshweaver.cloud", "https://memex.meshweaver.cloud/", true)] - [InlineData("https://memex.meshweaver.cloud", "http://memex.meshweaver.cloud", true)] - [InlineData("https://MEMEX.meshweaver.cloud/", "https://memex.meshweaver.cloud", true)] - [InlineData("https://memex.meshweaver.cloud:443", "https://memex.meshweaver.cloud", true)] - [InlineData("https://memex.meshweaver.cloud", "https://memex.systemorph.com", false)] - [InlineData("http://registry:8080", "http://registry:9090", false)] - [InlineData("http://registry:8080", "http://registry", false)] - [InlineData("not a url", "not a url/", false)] - [InlineData("https://memex.meshweaver.cloud@evil.example.test", "https://evil.example.test", false)] - public void SameRegistry_MatchesByHost(string configured, string announced, bool expected) - => Assert.Equal(expected, RegistryUpdateReconciler.SameRegistry(configured, announced)); - - // ── harness ─────────────────────────────────────────────────────────────── - - private async Task SeedInstallRecord(string packageId, string? module, CancellationToken cancellationToken) - { - var manifest = new PackageManifest - { - Id = packageId, - Name = packageId, - Version = "1.0.0", - ModuleVersion = ModuleVersion, - TargetPartition = packageId, - Module = module, - AutoUpdate = true, - }; - var record = MeshNode.FromPath($"{PackageInstaller.InstalledPartition}/{packageId}") with - { - NodeType = PackageInstaller.PackageNodeType, - Name = manifest.Name, - State = MeshNodeState.Active, - Content = manifest, - }; - var access = Mesh.ServiceProvider.GetRequiredService(); - await access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode(record)) - .Timeout(TestTimeouts.Convergence) - .Await(cancellationToken); - } - - private IObservable> LedgerEntries() => - Mesh.GetWorkspace() - .GetQuery("ledger|broadcast", - $"path:{PackageInstaller.InstalledPartition} scope:children nodeType:{RegistryUpdateReconciler.LedgerNodeType}") - .Select(ns => (IReadOnlyList)(ns ?? []) - .Select(n => n.ContentAs(Json)) - .Where(l => l is not null) - .SelectMany(l => l!.Registries) - .ToList()); - - private IObservable> InboxDeliveries() => - Mesh.GetWorkspace() - .GetQuery("inbox|broadcast", - $"path:{ModulePublished.InboxTarget}/{WebhookInbox.InboxContainer} scope:children nodeType:{WebhookInbox.NodeType}") - .Select(ns => (IReadOnlyList)(ns ?? []).ToList()); - - /// - /// The registry, as both the feed client and the bundle client reach it. The feed lists both - /// packages at the installed content identity and declares NO module for either (so the boot's - /// module lane is idle); the bundle index serves a module for each; a bundle download answers - /// 404 — the "no prebuilt bundle" a consumer treats as a logged miss — and is RECORDED, which - /// is the witness. - /// - private sealed class FakeRegistry : HttpMessageHandler - { - private ImmutableList requests = ImmutableList.Empty; - private ImmutableList downloads = ImmutableList.Empty; - - public ImmutableList Requests => requests; - public ImmutableList BundleDownloads => downloads; - - /// How many times the whole feed (GET /api/plugins) was read. - public int FeedReads => requests.Count(r => r.StartsWith("GET /api/plugins?", StringComparison.Ordinal)); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var path = request.RequestUri!.PathAndQuery; - ImmutableInterlocked.Update(ref requests, r => r.Add($"{request.Method} {path}")); - - string? body = null; - var status = HttpStatusCode.NotFound; - if (request.Method == HttpMethod.Get && path.StartsWith("/api/plugins?", StringComparison.Ordinal)) - { - body = PluginRegistryPayloads.List([Served(PackageA), Served(PackageB)]); - status = HttpStatusCode.OK; - } - else if (request.Method == HttpMethod.Get && path.StartsWith($"{PluginBundleClient.RoutePrefix}/index.json", StringComparison.Ordinal)) - { - body = JsonSerializer.Serialize(new - { - frameworkMvid = ServedFramework, - bundles = new[] - { - new { plugin = PackageA, version = "1.0.0", url = $"{RegistryUrl}{PluginBundleClient.RoutePrefix}/{PackageA}/1.0.0", module = "ModA", frameworkMvid = ServedFramework }, - new { plugin = PackageB, version = "1.0.0", url = $"{RegistryUrl}{PluginBundleClient.RoutePrefix}/{PackageB}/1.0.0", module = "ModB", frameworkMvid = ServedFramework }, - }, - }, PluginRegistryPayloads.Json); - status = HttpStatusCode.OK; - } - else if (request.Method == HttpMethod.Get && path.StartsWith($"{PluginBundleClient.RoutePrefix}/", StringComparison.Ordinal)) - { - var package = path[(PluginBundleClient.RoutePrefix.Length + 1)..].Split('/')[0]; - ImmutableInterlocked.Update(ref downloads, d => d.Add(Uri.UnescapeDataString(package))); - } - - return Task.FromResult(new HttpResponseMessage(status) - { - Content = new StringContent(body ?? "{\"error\":\"not found\"}", Encoding.UTF8, "application/json"), - }); - } - - private static PackageManifest Served(string packageId) => new() - { - Id = packageId, - Name = packageId, - Version = "1.0.0", - ModuleVersion = ModuleVersion, - TargetPartition = packageId, - }; - } - - private sealed class FakeRegistryClientFactory(HttpMessageHandler handler) : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); - } -} diff --git a/test/Memex.Portal.Shared.Test/RejectedCertificateIsNotRetriedTest.cs b/test/Memex.Portal.Shared.Test/RejectedCertificateIsNotRetriedTest.cs deleted file mode 100644 index 94212f2139..0000000000 --- a/test/Memex.Portal.Shared.Test/RejectedCertificateIsNotRetriedTest.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Net.Security; -using System.Net.Sockets; -using System.Reactive.Linq; -using System.Security.Authentication; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Mesh; -using Microsoft.Extensions.DependencyInjection; -using Defaults = Memex.Portal.ServiceDefaults.ServiceDefaults; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A REJECTED CERTIFICATE IS NOT A TRANSIENT FAULT — Systemorph/MeshWeaver#5910. -/// -/// An agent web fetch of a site whose certificate had expired (NotTimeValid) was -/// attempted three times by the defaults pipeline (Standard-Retry), each logged at Error. -/// The first fix keyed on "an with no inner exception" and was -/// withdrawn: SslStream raises other, transient handshake failures in that same shape. The -/// marker is therefore raised where the verdict is made — the validation callback — as a typed -/// . -/// -/// Both directions run through the REAL defaults () -/// against a real TLS listener on loopback, counting the connections it accepts: a server whose -/// certificate has expired is contacted ONCE, and — the control — a server that drops the -/// connection mid-handshake is still retried. -/// -public class RejectedCertificateIsNotRetriedTest -{ - private const string Client = "cert-probe"; - - [Fact] - public async Task AnExpiredCertificate_IsContactedOnce_AndReportedAsTyped() - { - using var certificate = ExpiredSelfSigned(); - using var server = new LoopbackServer(certificate); - using var provider = Provider(); - var http = provider.GetRequiredService().CreateClient(Client); - - var fault = await Record(() => http.GetAsync(server.Url, TestContext.Current.CancellationToken)); - - fault.Should().BeOfType(); - var rejected = fault!.InnerException.Should().BeOfType( - "the defaults' validation callback reports the rejection as the typed marker").Subject; - rejected.ChainStatus.Should().Contain(nameof(X509ChainStatusFlags.NotTimeValid), - "the marker names what was wrong, as the runtime's own message did"); - server.Accepted.Should().Be(1, - "a certificate that failed validation cannot pass on a retry — the pipeline gives up after one attempt"); - } - - /// - /// 🚨 THE CONTROL — the case the withdrawn fix broke. A server that closes the connection during - /// the handshake produces an too, and that one IS transient: - /// it must still be retried. - /// - [Fact] - public async Task AHandshakeTheServerDrops_IsStillRetried() - { - using var server = new LoopbackServer(certificate: null); - using var provider = Provider(); - var http = provider.GetRequiredService().CreateClient(Client); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - - var request = http.GetAsync(server.Url, cts.Token); - await Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .Where(_ => server.Accepted >= 2) - .Take(1) - .Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken); - cts.Cancel(); - await Record(() => request); - - server.Accepted.Should().BeGreaterThanOrEqualTo(2, - "a dropped handshake is transient, and the defaults pipeline retries it"); - } - - [Fact] - public void TheVerdictIsTheDefaultOne_AndOnlyTheMarkerIsDeterministic() - { - Defaults.ValidateServerCertificate(SslPolicyErrors.None, chain: null).Should().BeTrue( - "a certificate the runtime found valid is accepted exactly as before"); - var rejected = Assert.Throws(() => - Defaults.ValidateServerCertificate(SslPolicyErrors.RemoteCertificateNameMismatch, chain: null)); - rejected.PolicyErrors.Should().Be(SslPolicyErrors.RemoteCertificateNameMismatch, - "any non-empty policy error is a rejection, reported with what was wrong"); - - Defaults.IsDeterministicTransportFailure(new HttpRequestException("tls", - new RemoteCertificateRejectedException(SslPolicyErrors.RemoteCertificateChainErrors, "NotTimeValid"))) - .Should().BeTrue("the typed marker, wrapped as HttpClient wraps it, is not retried"); - Defaults.IsDeterministicTransportFailure(new HttpRequestException("tls", - new AuthenticationException("Authentication failed because the remote party has closed the transport stream."))) - .Should().BeFalse("a bare AuthenticationException is not the marker — it may be transient"); - Defaults.IsDeterministicTransportFailure(null).Should().BeFalse(); - } - - /// - /// The opt-out a client gets by installing its own primary handler holds in BOTH registration - /// orders (Copilot review on #6418): the client's handler is never given the marker callback, - /// whether the client was registered before or after the defaults. Positive control: the - /// default primary handler of a client with no handler of its own DOES carry the callback. - /// - [Theory] - [InlineData(true)] - [InlineData(false)] - public void AClientWithItsOwnPrimaryHandler_IsLeftUnmarked_WhicheverWayItWasRegistered(bool clientFirst) - { - const string custom = "custom-primary"; - const string plain = "plain-primary"; - HttpClientHandler? created = null; - var services = new ServiceCollection(); - services.AddLogging(); - if (clientFirst) - services.AddHttpClient(custom).ConfigurePrimaryHttpMessageHandler(() => created = new HttpClientHandler()); - Defaults.AddHttpClientResilienceDefaults(services); - if (!clientFirst) - services.AddHttpClient(custom).ConfigurePrimaryHttpMessageHandler(() => created = new HttpClientHandler()); - services.AddHttpClient(plain); - using var provider = services.BuildServiceProvider(); - var handlers = provider.GetRequiredService(); - - handlers.CreateHandler(custom); - created.Should().NotBeNull("the client's own primary handler factory must have run"); - created!.ServerCertificateCustomValidationCallback.Should().BeNull( - $"a client that installs its own primary handler keeps it untouched (registered {(clientFirst ? "before" : "after")} the defaults)"); - - var primary = Primary(handlers.CreateHandler(plain)); - (primary is SocketsHttpHandler { SslOptions.RemoteCertificateValidationCallback: not null } - or HttpClientHandler { ServerCertificateCustomValidationCallback: not null }) - .Should().BeTrue($"the default primary handler must carry the marker callback, got {primary.GetType().Name}"); - } - - private static HttpMessageHandler Primary(HttpMessageHandler handler) - { - while (handler is DelegatingHandler { InnerHandler: { } inner }) - handler = inner; - return handler; - } - - // ── Harness ───────────────────────────────────────────────────────────────────────────────── - - private static ServiceProvider Provider() - { - var services = new ServiceCollection(); - services.AddLogging(); - Defaults.AddHttpClientResilienceDefaults(services); - services.AddHttpClient(Client); - return services.BuildServiceProvider(); - } - - private static async Task Record(Func action) - { - try { await action(); return null; } - catch (Exception ex) { return ex; } - } - - private static X509Certificate2 ExpiredSelfSigned() - { - using var rsa = RSA.Create(2048); - var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var san = new SubjectAlternativeNameBuilder(); - san.AddDnsName("localhost"); - san.AddIpAddress(IPAddress.Loopback); - request.CertificateExtensions.Add(san.Build()); - using var expired = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-10), DateTimeOffset.UtcNow.AddDays(-1)); - // Round-trip through PFX so the server side holds a usable private key on every platform. - return X509CertificateLoader.LoadPkcs12(expired.Export(X509ContentType.Pfx), password: null); - } - - /// - /// A loopback listener that counts accepted connections. With a certificate it completes the - /// server side of the handshake; without one it closes each connection at once, which the client - /// sees as a dropped handshake. - /// - private sealed class LoopbackServer : IDisposable - { - private readonly TcpListener listener = new(IPAddress.Loopback, 0); - private readonly X509Certificate2? certificate; - private readonly CancellationTokenSource stop = new(); - private int accepted; - - public LoopbackServer(X509Certificate2? certificate) - { - this.certificate = certificate; - listener.Start(); - _ = AcceptLoop(); - } - - public int Accepted => Volatile.Read(ref accepted); - - public Uri Url => new($"https://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}/"); - - private async Task AcceptLoop() - { - while (!stop.IsCancellationRequested) - { - TcpClient tcp; - try { tcp = await listener.AcceptTcpClientAsync(stop.Token); } - catch (Exception) { return; } - Interlocked.Increment(ref accepted); - _ = Serve(tcp); - } - } - - private async Task Serve(TcpClient tcp) - { - using (tcp) - { - if (certificate is null) - return; - using var ssl = new SslStream(tcp.GetStream()); - try { await ssl.AuthenticateAsServerAsync(certificate, false, false); } - catch (Exception) { /* the client rejects the certificate: the handshake ends here */ } - } - } - - public void Dispose() - { - stop.Cancel(); - listener.Stop(); - stop.Dispose(); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/ReleaseGateApplicabilityTest.cs b/test/Memex.Portal.Shared.Test/ReleaseGateApplicabilityTest.cs deleted file mode 100644 index be8eb03bf9..0000000000 --- a/test/Memex.Portal.Shared.Test/ReleaseGateApplicabilityTest.cs +++ /dev/null @@ -1,82 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Hosting; -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 "Cannot verify" and "verified as nothing to verify" are different states, and only the -/// first may hold. -/// -/// The release-availability gate (#1754) fails closed, and the first cut of its -/// unwired-gate hold drew that rule one state too wide: it held on ANY host with no gate -/// registered, including deployments the gate was never going to protect. Caught by -/// SelfUpdateRollFloorTest, whose three roll-expecting cases went red — and the serious one -/// was NeverRolled_IsNotHeld, because holding there means a first roll can never -/// happen. -/// -/// is the line between them, -/// and it is decided from CONFIGURATION alone — which is what lets a caller with no instance of -/// the service reach the same answer a registered one would. These pin that rule; the behavioural -/// halves live in SelfUpdateAvailabilityGateTest. -/// -public class ReleaseGateApplicabilityTest -{ - private static IConfiguration With(string? bundleRoot) => - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [ShippedPrebuiltBundles.PublishedRootConfigKey] = bundleRoot, - }).Build(); - - /// - /// A deployment that consumes CI bakes has something an unwired gate has failed to verify — - /// so it is unverifiable, and the hold stands. - /// - [Fact] - public void ADeploymentThatConsumesBakes_IsInScopeOfTheGate() - { - Assert.Null(ReleaseAvailabilityService.NotApplicableReason(With("/data/prebuilt-bundles"))); - } - - /// - /// A deployment with no bundle root already compiles its content at every boot, so a REGISTERED - /// gate answers NotEnforced for it. Its absence is therefore the same answer reached from - /// configuration, not an unanswered question — and holding on it would freeze an environment - /// the gate could never have protected. - /// - [Fact] - public void ADeploymentThatConsumesNoBakes_HasNothingToEnforce_AndSaysWhy() - { - var reason = ReleaseAvailabilityService.NotApplicableReason(With(null)); - - Assert.NotNull(reason); - // The reason must NAME the key, or an operator cannot tell "not applicable" from "broken". - Assert.Contains(ShippedPrebuiltBundles.PublishedRootConfigKey, reason); - } - - /// - /// Blank is absent. An empty PreWarm__PrebuiltBundleRoot= is a deployment that meant to - /// unset the key, never a request to gate against the process's working directory. - /// - [Theory] - [InlineData("")] - [InlineData(" ")] - public void ABlankBundleRoot_ReadsAsAbsent(string blank) - { - Assert.NotNull(ReleaseAvailabilityService.NotApplicableReason(With(blank))); - } - - /// - /// No configuration at all reads as "nothing configured" rather than throwing — the caller is a - /// poller tick, and an exception there would kill the tick that was supposed to decide. - /// - [Fact] - public void NoConfigurationAtAll_IsNotApplicable_NeverAThrow() - { - Assert.NotNull(ReleaseAvailabilityService.NotApplicableReason(null)); - } -} diff --git a/test/Memex.Portal.Shared.Test/ReleaseGateAuthTest.cs b/test/Memex.Portal.Shared.Test/ReleaseGateAuthTest.cs deleted file mode 100644 index 79e1becd52..0000000000 --- a/test/Memex.Portal.Shared.Test/ReleaseGateAuthTest.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The release gate answers with this environment's installed packages and the reasons a release is -/// unsafe for them — deployment inventory, not public information. So it fails closed, like the -/// bundle routes beside it. -/// -/// 🚨 Every assertion demands exactly 401, never merely "not 200". A typo'd route -/// answers 404, which satisfies "not success" while proving nothing — the guard would pass having -/// tested an endpoint that does not exist. 401 can only come from the authenticator, so it is -/// simultaneously the security assertion and the proof the route is mapped. -/// -/// No and no ReleaseAvailabilityService are registered, and -/// that is half the assertion: the rejection path must need nothing but the header. If the handler -/// ever resolved the service (or bound the hub as a parameter) before authenticating, an -/// unauthenticated request would answer 500 and these tests would catch it immediately. -/// -public class ReleaseGateAuthTest -{ - private static async Task Get(string route, string? authorization, HttpMethod? method = null) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Services.AddSingleton(sp => - new InstanceRegistryAuthenticator( - null!, - sp.GetRequiredService< - Microsoft.Extensions.Logging.ILogger>())); - - var app = builder.Build(); - app.MapReleaseGate(); - await app.StartAsync(); - - using var request = new HttpRequestMessage(method ?? HttpMethod.Get, route); - if (authorization is not null) - request.Headers.TryAddWithoutValidation("Authorization", authorization); - - return await app.GetTestClient().SendAsync(request); - } - - [Fact] - public async Task VerificationWriteAuthenticatesBeforeReadingTheBodyOrResolvingTheMesh() - { - using var response = await Get(ReleaseGateEndpoints.VerificationRoute, null, HttpMethod.Post); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task NoCredentialIsRejected() - { - using var response = await Get( - ReleaseGateEndpoints.Route + "?version=3.0.0", authorization: null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task AnUnparseableBearerIsRejected() - { - using var response = await Get( - ReleaseGateEndpoints.Route + "?version=3.0.0", "Bearer not-an-instance-key"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task AnUnparseableBasicIsRejected() - { - var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes("user:nope")); - - using var response = await Get( - ReleaseGateEndpoints.Route + "?version=3.0.0", "Basic " + basic); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task AMissingVersion_IsStillRejectedAsUnauthorized_NotAsABadRequest() - { - // Order matters: authenticate BEFORE validating the query. Answering 400 to an - // unauthenticated caller would confirm the route exists and leak its contract. - using var response = await Get(ReleaseGateEndpoints.Route, authorization: null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - // ── the SELECTOR route (#3479) ────────────────────────────────────────────────────────────── - // Same auth, same reason, and the same 401-not-404 discipline: this route answers with the - // environment's plugin COUNT and every release that cannot serve it, which is the same - // deployment inventory the verdict route protects. - - [Fact] - public async Task TheSelectorRouteRejectsAnUnauthenticatedCaller() - { - using var response = await Get(ReleaseGateEndpoints.SelectRoute, authorization: null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task TheSelectorRouteRejectsAnUnparseableBearer() - { - using var response = await Get( - ReleaseGateEndpoints.SelectRoute, "Bearer not-an-instance-key"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task TheSelectorRouteAuthenticatesBeforeItReadsTheRunningVersion() - { - // The handler resolves ReleaseAvailabilityService and reads the running platform version - // only INSIDE the authenticated branch — neither is registered here, so a 500 would mean - // the rejection path had grown a dependency and an unauthenticated caller could crash it. - using var response = await Get( - ReleaseGateEndpoints.SelectRoute + "?current=3.0.0-rc9.ci.7693", authorization: null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - // ── the COMBO route (#3544) ───────────────────────────────────────────────────────────────── - // The strictest of the three, and the one most worth pinning: its body names every module - // REPOSITORY and every pinned COMMIT this deployment carries. Same auth, same fail-closed - // order, same 401-not-404 discipline. - - [Fact] - public async Task TheComboRouteRejectsAnUnauthenticatedCaller() - { - using var response = await Get(ReleaseGateEndpoints.ComboRoute, authorization: null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task TheComboRouteRejectsAnUnparseableBearer() - { - using var response = await Get(ReleaseGateEndpoints.ComboRoute, "Bearer not-an-instance-key"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task TheComboRouteAuthenticatesBeforeItReadsTheInstanceCombo() - { - // InstanceComboReader is NOT registered here, so a 500 would mean the handler resolved it - // before authenticating — i.e. an unauthenticated caller could make the portal walk three - // mesh-wide queries. The reader is touched only inside the authenticated branch. - var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes("user:nope")); - - using var response = await Get(ReleaseGateEndpoints.ComboRoute, "Basic " + basic); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } -} diff --git a/test/Memex.Portal.Shared.Test/ReleaseGateDenominatorTest.cs b/test/Memex.Portal.Shared.Test/ReleaseGateDenominatorTest.cs deleted file mode 100644 index afab694307..0000000000 --- a/test/Memex.Portal.Shared.Test/ReleaseGateDenominatorTest.cs +++ /dev/null @@ -1,468 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Text.Json; -using MeshWeaver.Hosting; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The deployment gate's DENOMINATOR may not be read from the artifact under judgement -/// (#3441). -/// -/// Maintainer, 2026-09-06: "let's see that the deploy rolls only when all packages of all -/// plugins are baked" — "we kept rolling without edu being properly baked". -/// -/// The gate asked which installed packages are content-bearing by reading the sealed bundle -/// set of the identity the instance is running NOW. That is the same artifact store the gate is -/// about to judge, so the denominator was a function of the very thing that breaks: the moment -/// Education's bake stopped being produced, its packages stopped being asked about, and every -/// later roll was green about precisely the packages that had regressed. In the limit — an -/// identity with no publication at all — every installed package became non-content-bearing and -/// the content half of the gate checked NOTHING while reporting updatable. "0 expected, 0 found, -/// green" is the shape a completeness gate exists to make impossible. -/// -/// These tests are the falsification, both ways, on the reconstructed Education state. The -/// OLD reading is computed alongside the new one in -/// so the negative -/// control is executed rather than asserted: a test that only showed the new reading naming the -/// courses could not distinguish "the fix works" from "the fixture was always red". -/// -/// 🚨 What the denominator DECIDES changed in #3651; what it SEES did not. A course -/// with no bake for the target is now a COST the verdict names ("would recompile at boot: …") and -/// the roll proceeds — holding on it is what would have kept every portal on the morning build a -/// second day on 2026-09-07. The denominator still has to be independent and monotone for the -/// naming to be honest, which is what these keep pinning; the hold itself survives only under the -/// opt-in Modules:RequirePrebuilt, and that arm is asserted on the same fixture. -/// -public class ReleaseGateDenominatorTest : IDisposable -{ - /// Every published root this test built, removed on teardown — a leaked temp tree - /// bloats the CI agent and is one more way for two runs to interfere. - private readonly List roots = []; - - public void Dispose() - { - foreach (var root in roots) - { - try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } - } - GC.SuppressFinalize(this); - } - - private string Track(string root) - { - roots.Add(root); - return root; - } - - // Three framework identities: an EARLIER wave that baked everything, the identity the instance - // is RUNNING (Education's publication already torn), and the TARGET the roll would move to. - private const string Earlier = "s3441earlier00000000000000000000"; - private const string Live = "s3441live00000000000000000000000"; - private const string Target = "s3441target00000000000000000000"; - private const string TargetVersion = "3.0.0-rc9.ci.3441"; - - /// Education's nine courses — the repo's declared package set, measured 2026-09-06 - /// from the top-level directories carrying an index.json. - private static readonly string[] EducationPackages = - [ - "AdvancedBusinessRules", "AgenticBusiness", "AgenticEngineering", "AgenticOffice", - "AgenticPrimer", "AgenticPrimerDe", "DataImportExport", "DataModeling", "ThinkInStreams", - ]; - - private static readonly string[] PlatformPackages = ["Documentation", "Northwind"]; - private static readonly string[] PluginPackages = ["Store", "AI"]; - - /// A package that ships a compiled module and NO NodeType content: it produces no - /// bundle under ANY identity, ever. The exemption that made the old reading attractive, and - /// which the new reading must preserve — demanding a bundle here would freeze the environment - /// forever. - private const string ModuleOnlyPackage = "Hosting.Instance"; - - // ── the denominator itself ────────────────────────────────────────────────────────────────── - - [Fact] - public void EverSealed_ReadsEveryIdentity_NotOnlyTheLiveOne() - { - var root = EducationRegressedRoot(); - - var floor = PublishedBundleCatalogue.EverSealedBundles(root); - - // Non-zero, and printed here for the same reason the service logs it: the claim IS the - // count. Three identities published; the earlier one is where Education's courses live. - Assert.Equal(3, floor.Identities); - Assert.True(floor.ServesBakes); - foreach (var package in EducationPackages) - Assert.Contains(package, floor.Bundles); - Assert.Equal( - EducationPackages.Length + PlatformPackages.Length + PluginPackages.Length, - floor.Bundles.Count); - - // …and the module-only package is still absent, under every identity. The exemption is - // preserved: it is not that we now demand everything, it is that we no longer forget. - Assert.DoesNotContain(ModuleOnlyPackage, floor.Bundles); - } - - [Fact] - public void ARootThatServesNoBakes_IsNotEnforced_RatherThanASilentPass() - { - // A configured root holding no sealed publication under any identity. The old reading made - // this the WORST case — every package non-content-bearing, the content half checking - // nothing, and a green verdict over zero evidence. - var root = Track(Path.Combine(Path.GetTempPath(), "mw-3441-empty-" + Guid.NewGuid().ToString("N"))); - Directory.CreateDirectory(Path.Combine(root, PublishedBundleCatalogue.ReleaseMarkerDirectoryName)); - // A torn publication contributes nothing — it is not evidence that the root serves bakes. - Directory.CreateDirectory(Path.Combine(root, Live, "education")); - - var floor = PublishedBundleCatalogue.EverSealedBundles(root); - - Assert.Equal(0, floor.Identities); - Assert.False(floor.ServesBakes); - Assert.Empty(floor.Bundles); - } - - /// - /// 🚨 An ABSENT root is a refusal, not an exemption — the two produce the same empty - /// floor and mean opposite things. - /// - /// A deployment that configures a bundle root DECLARES that it consumes CI bakes. If that - /// root is not on disk, the volume did not mount or the path is mistyped: an availability - /// incident. Reading it as "serves no bakes" would clear the gate on a mis-mount — the very - /// vacuity this change removes, reintroduced one level up. Caught in review on this PR. - /// - [Fact] - public void AnAbsentRoot_Refuses_RatherThanReadingAsServesNoBakes() - { - var missing = Path.Combine(Path.GetTempPath(), "mw-3441-absent-" + Guid.NewGuid().ToString("N")); - Assert.False(Directory.Exists(missing)); - - var floor = PublishedBundleCatalogue.EverSealedBundles(missing); - - Assert.NotNull(floor.Refusal); - Assert.Contains(missing, floor.Refusal); - // 🚨 ServesBakes is false here TOO — which is exactly why a caller must test Refusal first. - Assert.False(floor.ServesBakes); - - // And the refusal becomes the HOLD the service actually produces — never an exemption. - // 🚨 This is the SAME construction ReleaseAvailabilityService.Verdict uses, deliberately: - // IsUpdatable(target, [], Unreadable(...)) would answer IsUpdatable=false but - // IsIndeterminate=FALSE (that property reads the per-package verdicts, and an empty list - // has none), which surfaces an availability incident as a compatibility verdict. - var verdict = UpdatabilityVerdict.Unavailable(floor.Refusal!); - Assert.False(verdict.IsUpdatable); - Assert.True(verdict.IsIndeterminate); - Assert.Equal(floor.Refusal, verdict.HoldReason); - Assert.All(verdict.Packages, p => - Assert.Equal(PackageAvailabilityKind.Indeterminate, p.Kind)); - } - - /// - /// 🚨 A publication pointer that cannot be followed REFUSES, because the only other answer - /// shrinks the denominator (#3461 phase 5). - /// - /// Every reading of a published source resolves _current and falls back to the - /// source directory. Until phase 5 that fall-back landed on the flat compatibility copy — a - /// sealed publication — so an unusable pointer cost nothing here. Since a generation - /// publication DISPOSES of that copy, the fall-back lands on a directory holding no - /// publication, and this source then declares NOTHING: the floor comes back smaller, which is - /// the one direction that EXEMPTS a package from the gate rather than holding it. - /// - [Fact] - public void APointerThatCannotBeFollowed_Refuses_RatherThanShrinkingTheFloor() - { - var root = EducationRegressedRoot(); - // The EARLIER identity is the one whose Education publication the floor is read from, so a - // fault here is measurable: without the refusal the nine courses simply drop out. - var source = Path.Combine(root, Earlier, "education"); - Assert.Contains( - EducationPackages[0], PublishedBundleCatalogue.EverSealedBundles(root).Bundles); - // The phase-5 state, and the reading a pointer caught mid-replacement gives: `_current` - // exists, names a generation that is not there, and no flat copy sits behind it. - foreach (var file in Directory.GetFiles(source)) - File.Delete(file); - File.WriteAllText( - Path.Combine(source, ShippedPrebuiltBundles.PublicationPointerFileName), - "Systemorph-MeshWeaver-9999-9\n"); - - var floor = PublishedBundleCatalogue.EverSealedBundles(root); - - Assert.NotNull(floor.Refusal); - Assert.Contains("pointer", floor.Refusal); - Assert.Contains("education", floor.Refusal); - Assert.Empty(floor.Bundles); - // The same construction the service uses: a refusal becomes a HOLD naming an availability - // incident, never a compatibility verdict over a floor read short. - var verdict = UpdatabilityVerdict.Unavailable(floor.Refusal!); - Assert.False(verdict.IsUpdatable); - Assert.True(verdict.IsIndeterminate); - } - - /// - /// The control: the SAME prefix with the pointer RESOLVING to a sealed generation reads the - /// generation's declaration — so the case above refuses on the fault, not on the layout. - /// - [Fact] - public void APointerThatResolves_ReadsTheGenerationsDeclaration() - { - var root = EducationRegressedRoot(); - var source = Path.Combine(root, Earlier, "education"); - var generation = Path.Combine(source, "Systemorph-MeshWeaver-1-1"); - Directory.CreateDirectory(generation); - foreach (var file in Directory.GetFiles(source)) - File.Move(file, Path.Combine(generation, Path.GetFileName(file))); - File.WriteAllText( - Path.Combine(source, ShippedPrebuiltBundles.PublicationPointerFileName), - "Systemorph-MeshWeaver-1-1\n"); - - var floor = PublishedBundleCatalogue.EverSealedBundles(root); - - Assert.Null(floor.Refusal); - foreach (var package in EducationPackages) - Assert.Contains(package, floor.Bundles); - } - - /// - /// 🚨 The denominator reads the sentinel's DECLARATION, so a publication that is sealed but has - /// LOST a bundle still counts that package as content-bearing. - /// - /// Inclusive on purpose, and it is the safe direction: keeping the package in the set the - /// gate asks about can only HOLD a roll, never exempt one. Verifying presence here would also - /// cost one stat per bundle per source per identity on a network share, against a 60 s verdict - /// budget — and a denominator that times out answers Indeterminate, which freezes every - /// environment. The presence check stays where it decides the verdict: the target identity. - /// - [Fact] - public void ASealedPublicationMissingABundle_StillCountsTowardTheDenominator() - { - var root = EducationRegressedRoot(); - // The earlier identity's Education publication loses a bundle's bytes but keeps its seal. - File.Delete(Path.Combine(root, Earlier, "education", EducationPackages[0] + ".zip")); - - var floor = PublishedBundleCatalogue.EverSealedBundles(root); - Assert.Contains(EducationPackages[0], floor.Bundles); - - // The full presence rule is unchanged where it decides the verdict — that identity's - // Education source is torn and contributes NOTHING to what can be adopted. - var adoptable = PublishedBundleCatalogue.SealedBundlesForIdentity(root, Earlier); - Assert.DoesNotContain(EducationPackages[0], adoptable); - Assert.DoesNotContain(EducationPackages[1], adoptable); - } - - // ── the verdict, both ways, on the reconstructed Education state ───────────────────────────── - - /// - /// 🚨 THE ACCEPTANCE CRITERION. One fixture, two denominators, opposite answers — so the - /// negative control is a measurement rather than a claim. With the old denominator the - /// verdict has NOTHING to say about Education; with the new one it names all nine courses as - /// the boot compile the roll will pay (#3651) — and, under Modules:RequirePrebuilt, as - /// the hold. - /// - [Fact] - public void TheOldDenominatorSaidNothing_TheNewOneNamesEveryUnbakedCourse() - { - var root = EducationRegressedRoot(); - var observation = PublishedBundleCatalogue.Read(root, TargetVersion); - Assert.Equal(Target, observation.Target.FrameworkIdentity); - - // ── the OLD reading: content-bearing = sealed under the identity running NOW ── - var old = PublishedBundleCatalogue.SealedBundlesForIdentity(root, Live); - foreach (var course in EducationPackages) - Assert.DoesNotContain(course, old); // already fallen out of the denominator - var oldVerdict = ReleaseAvailability.IsUpdatable( - observation.Target, Required(old), observation.Artifacts); - - // The control: with the old denominator this roll proceeds SAYING NOTHING about an - // Education publication that is not there. This is what "we kept rolling without edu - // being properly baked" was — the silence, not the roll. - Assert.True(oldVerdict.IsUpdatable); - Assert.Empty(oldVerdict.Blockers); - Assert.Empty(oldVerdict.BootCompiles); - Assert.Empty(oldVerdict.Advisories); - - // ── the NEW reading: content-bearing = sealed under ANY identity this root holds ── - var floor = PublishedBundleCatalogue.EverSealedBundles(root); - var newVerdict = ReleaseAvailability.IsUpdatable( - observation.Target, Required(floor.Bundles), observation.Artifacts); - - // It proceeds (#3651) — and names exactly which packages, and what the roll pays for them. - Assert.True(newVerdict.IsUpdatable, newVerdict.HoldReason); - Assert.Empty(newVerdict.Blockers); - Assert.Equal( - EducationPackages.OrderBy(p => p, StringComparer.Ordinal), - newVerdict.BootCompiles.OrderBy(p => p, StringComparer.Ordinal)); - var cost = Assert.Single(newVerdict.Advisories); - Assert.Contains("would recompile at boot", cost); - Assert.Contains(Target, cost); - var named = newVerdict.Packages.Where(p => p.Kind == PackageAvailabilityKind.ContentBakeMissing).ToList(); - Assert.Equal(EducationPackages.Length, named.Count); - Assert.All(named, p => Assert.True(p.IsAdvisory)); - - // The module-only package is NOT among them — the preserved exemption, asserted on the - // same run that names the courses, so "it names" cannot be hiding "it names everything". - Assert.DoesNotContain(ModuleOnlyPackage, named.Select(b => b.Package)); - - // ── the STRICT arm: the same denominator, under Modules:RequirePrebuilt, holds ── - var strict = ReleaseAvailability.IsUpdatable( - observation.Target, Required(floor.Bundles), observation.Artifacts, - new ReleaseGatePolicy(RequirePrebuilt: true)); - - Assert.False(strict.IsUpdatable); - var blockers = strict.Blockers.ToList(); - Assert.Equal(EducationPackages.Length, blockers.Count); - Assert.Equal( - EducationPackages.OrderBy(p => p, StringComparer.Ordinal), - blockers.Select(b => b.Package).OrderBy(p => p, StringComparer.Ordinal)); - Assert.All(blockers, b => - Assert.Equal(PackageAvailabilityKind.ContentBakeMissing, b.Kind)); - Assert.Contains(Target, strict.HoldReason); - Assert.DoesNotContain(ModuleOnlyPackage, blockers.Select(b => b.Package)); - } - - [Fact] - public void ACompleteEducationBake_Greens() - { - var root = EducationRegressedRoot(); - // The operator does what the refusal asks: re-run Education's bake for the target identity. - Seal(root, Target, "education", EducationPackages); - - var observation = PublishedBundleCatalogue.Read(root, TargetVersion); - var floor = PublishedBundleCatalogue.EverSealedBundles(root); - var verdict = ReleaseAvailability.IsUpdatable( - observation.Target, Required(floor.Bundles), observation.Artifacts); - - Assert.True(verdict.IsUpdatable); - Assert.Empty(verdict.Blockers); - Assert.Null(verdict.HoldReason); - Assert.Empty(verdict.BootCompiles); - Assert.Empty(verdict.Advisories); - - // 🚨 The denominator was non-zero on the green arm too. A pass over an empty expected set - // is exactly what this gate exists to prevent, so the green arm asserts WHAT WAS DEMANDED - // — 13 content-bearing packages of 14 installed — not merely that nothing blocked. - var demanded = Required(floor.Bundles).Count(p => p.HasContent); - Assert.Equal( - EducationPackages.Length + PlatformPackages.Length + PluginPackages.Length, demanded); - Assert.Equal(demanded + 1, verdict.Packages.Length); // + the exempt module-only package - Assert.All(verdict.Packages, p => - Assert.Equal(PackageAvailabilityKind.Available, p.Kind)); - } - - /// - /// A package that regressed to a PARTIAL bake — some courses sealed, some not — is named - /// package by package. The maintainer's ask is "all packages of all plugins", so a source that - /// seals four of nine must not read as a sealed source: the five are the boot-compile cost, - /// and under Modules:RequirePrebuilt the five are the blockers. - /// - [Fact] - public void APartialEducationBake_NamesTheMissingCoursesOnly() - { - var root = EducationRegressedRoot(); - var shipped = EducationPackages.Take(4).ToArray(); - Seal(root, Target, "education", shipped); - - var observation = PublishedBundleCatalogue.Read(root, TargetVersion); - var floor = PublishedBundleCatalogue.EverSealedBundles(root); - var verdict = ReleaseAvailability.IsUpdatable( - observation.Target, Required(floor.Bundles), observation.Artifacts); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Equal( - EducationPackages.Skip(4).OrderBy(p => p, StringComparer.Ordinal), - verdict.BootCompiles.OrderBy(p => p, StringComparer.Ordinal)); - - var strict = ReleaseAvailability.IsUpdatable( - observation.Target, Required(floor.Bundles), observation.Artifacts, - new ReleaseGatePolicy(RequirePrebuilt: true)); - - Assert.False(strict.IsUpdatable); - Assert.Equal( - EducationPackages.Skip(4).OrderBy(p => p, StringComparer.Ordinal), - strict.Blockers.Select(b => b.Package).OrderBy(p => p, StringComparer.Ordinal)); - } - - // ── fixture ───────────────────────────────────────────────────────────────────────────────── - - /// What the gate's inputs are, given a denominator: every installed package, plus the - /// module-only one that has never had a bundle. - private static IEnumerable Required(IReadOnlySet contentBearing) => - EducationPackages - .Concat(PlatformPackages) - .Concat(PluginPackages) - .Append(ModuleOnlyPackage) - .Select(id => new RequiredPackage(id, id, null, contentBearing.Contains(id))); - - /// - /// The measured shape of the incident: an EARLIER identity where every source baked, the LIVE - /// identity and the TARGET identity where Education's publication is torn (present as a - /// directory, no _complete sentinel — a bake that died before sealing, which is exactly - /// what the boot seeder and the gate both refuse to read). - /// - private string EducationRegressedRoot() - { - var root = Track(Path.Combine(Path.GetTempPath(), "mw-3441-" + Guid.NewGuid().ToString("N"))); - Directory.CreateDirectory(Path.Combine(root, PublishedBundleCatalogue.ReleaseMarkerDirectoryName)); - File.WriteAllText( - Path.Combine(root, PublishedBundleCatalogue.ReleaseMarkerDirectoryName, TargetVersion), - Target); - - foreach (var identity in new[] { Earlier, Live, Target }) - { - Seal(root, identity, "meshweaver-content", PlatformPackages); - Seal(root, identity, "plugins", PluginPackages); - } - // Education baked once, under the earlier identity, and has not sealed since. - Seal(root, Earlier, "education", EducationPackages); - Tear(root, Live, "education"); - Tear(root, Target, "education"); - return root; - } - - /// A SEALED source publication: the bundles, then the sentinel that lists them. - private static void Seal(string root, string identity, string source, IEnumerable bundles) - { - var directory = Path.Combine(root, identity, source); - Directory.CreateDirectory(directory); - var names = new List(); - foreach (var bundle in bundles) - { - WriteBundle(Path.Combine(directory, bundle + ".zip"), bundle, identity); - names.Add(bundle + ".zip"); - } - File.WriteAllText( - Path.Combine(directory, ShippedPrebuiltBundles.CompletionSentinelFileName), - string.Join('\n', names.Order(StringComparer.Ordinal)) + "\n"); - } - - /// A TORN publication: the directory exists, the sentinel does not. - private static void Tear(string root, string identity, string source) => - Directory.CreateDirectory(Path.Combine(root, identity, source)); - - /// A bundle carrying a real manifest with no module dependency, so the #3175 - /// sealed-set consistency rule has nothing to say and this test measures only completeness. - private static void WriteBundle(string path, string bundle, string identity) - { - var manifest = new BundleReader.Manifest( - bundle, "1.0", identity, - [ - new BundleReader.AssemblyRef( - $"{bundle}/Type", $"{bundle}_Type.dll", - new Dictionary(StringComparer.Ordinal) - { - ["MeshWeaver.Layout"] = MeshWeaver.Compiler.CompiledDependencies.RefAsmScheme + "abc", - }), - ]); - using var file = File.Create(path); - using var zip = new ZipArchive(file, ZipArchiveMode.Create); - using var stream = zip.CreateEntry(NuGetPackageWriter.ManifestEntry).Open(); - stream.Write(JsonSerializer.SerializeToUtf8Bytes( - manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web))); - } -} diff --git a/test/Memex.Portal.Shared.Test/ReleaseIdentityFromRegistryTest.cs b/test/Memex.Portal.Shared.Test/ReleaseIdentityFromRegistryTest.cs deleted file mode 100644 index 405f8812a7..0000000000 --- a/test/Memex.Portal.Shared.Test/ReleaseIdentityFromRegistryTest.cs +++ /dev/null @@ -1,786 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A published root no publisher writes to still learns a release's framework identity -/// (policy release-identity-from-registry). -/// -/// The estate under test is the registry-fed one: the published root holds the bundles of the -/// identity the running image reports and NO _releases directory, because the publisher -/// writes markers to storage this installation does not mount. Before this, every candidate release -/// answered "identity not resolvable" there and the installation held on the release it booted -/// with. What is pinned: the registry's plugins/releases:<version> record supplies the -/// identity when — and only when — the marker file is absent; the installation's key goes to the -/// artifact host the registry mount declares and to no other; and every way of not knowing leaves -/// the hold in place. -/// -public class ReleaseIdentityFromRegistryTest(ITestOutputHelper output) : MonolithMeshTestBase(output), IDisposable -{ - private const string RegistryUrl = "https://registry.release-identity.test"; - private const string ArtifactHost = "cr.release-identity.test"; - private const string Key = "mwi_release-identity-key"; - private const string Release = "3.1.10517"; - private const string Identity = "c003e001"; - private const string Package = "ReleaseIdentityPackage"; - private static readonly TimeSpan Budget = TimeSpan.FromSeconds(60); - - private readonly FakeBundleIndex index = new(new Uri(RegistryUrl).Host) { ArtifactRegistry = ArtifactHost }; - private readonly FakeReleaseRecords records = new(ArtifactHost, Key); - - // A SECOND registry mount, after the first in configuration order. It declares no artifact - // host (and so says nothing) unless a test points it at its own registry. - private const string MirrorUrl = "https://mirror.release-identity.test"; - private const string MirrorArtifactHost = "cr.mirror.release-identity.test"; - private readonly FakeBundleIndex mirrorIndex = new(new Uri(MirrorUrl).Host); - private readonly FakeReleaseRecords mirrorRecords = new(MirrorArtifactHost, Key); - private readonly string root = Path.Combine(Path.GetTempPath(), "rifr-" + Guid.NewGuid().ToString("N")); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(s => s - .AddSingleton(new HostRoutingClientFactory(index, records, mirrorIndex, mirrorRecords)) - .AddSingleton(new PluginCatalogOptions - { - Registries = - [ - new PluginRegistryReference { Name = "fleet", Url = RegistryUrl, Token = Key }, - new PluginRegistryReference { Name = "mirror", Url = MirrorUrl, Token = Key }, - ], - })); - - void IDisposable.Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch (IOException) { /* a temp directory the OS will reap; nothing under test depends on it */ } - } - - private RegistryReleaseIdentities Identities => new(Mesh); - - private Task Resolve(string version, CancellationToken ct) => - Identities.Resolve(version).FirstAsync().Timeout(Budget).Await(ct); - - // ── the record ──────────────────────────────────────────────────────────────────────────── - - [Fact] - public void ARecordIsParsed_OnlyWhenItNamesTheVersionAsked_WithAnIdentityThatIsOnePathSegment() - { - static RegistryReleaseIdentities.ReleaseRecord? Parse(string json, string version = Release) => - RegistryReleaseIdentities.ReleaseRecord.Parse(Encoding.UTF8.GetBytes(json), version); - - var plain = Parse($$"""{"identity":"{{Identity}}","version":"{{Release}}"}"""); - Assert.Equal(Identity, plain!.Identity); - Assert.Null(plain.ExpectedDbVersion); - - Assert.Equal(61, Parse($$"""{"identity":"{{Identity}}","version":"{{Release}}","expectedDbVersion":61}""")!.ExpectedDbVersion); - - // Another release's record under this tag is not this release's identity. - Assert.Null(Parse($$"""{"identity":"{{Identity}}","version":"3.1.1"}""")); - // The identity becomes a directory name under the published root. - Assert.Null(Parse($$"""{"identity":"../etc","version":"{{Release}}"}""")); - Assert.Null(Parse($$"""{"identity":"a/b","version":"{{Release}}"}""")); - Assert.Null(Parse($$"""{"identity":"..","version":"{{Release}}"}""")); - Assert.Null(Parse($$"""{"identity":"_releases","version":"{{Release}}"}""")); - Assert.Null(Parse($$"""{"identity":"","version":"{{Release}}"}""")); - Assert.Null(Parse($$"""{"version":"{{Release}}"}""")); - Assert.Null(Parse("""["not","a","record"]""")); - Assert.Null(Parse("not json")); - } - - // ── the registry read ───────────────────────────────────────────────────────────────────── - - [Fact(Timeout = 120_000)] - public async Task TheIdentityIsReadFromTheHostTheRegistryMountDeclares_WithThisInstallationsKey() - { - var ct = TestContext.Current.CancellationToken; - records.Publish(Release, $$"""{"identity":"{{Identity}}","version":"{{Release}}","expectedDbVersion":61}"""); - - var record = await Resolve(Release, ct); - - Assert.NotNull(record); - Assert.Equal(Identity, record.Identity); - Assert.Equal(61, record.ExpectedDbVersion); - Assert.Equal(Key, records.PresentedSecret); - Assert.Contains(records.Requests, r => r.EndsWith($"/v2/plugins/releases/manifests/{Release}", StringComparison.Ordinal)); - } - - [Fact(Timeout = 120_000)] - public async Task ARegistryThatDeclaresNoArtifactHost_IsNotGuessedAt() - { - var ct = TestContext.Current.CancellationToken; - records.Publish(Release, $$"""{"identity":"{{Identity}}","version":"{{Release}}"}"""); - index.ArtifactRegistry = null; - - Assert.Null(await Resolve(Release, ct)); - // The key was never presented anywhere but the mount it belongs to. - Assert.Empty(records.Requests); - } - - [Fact(Timeout = 120_000)] - public async Task EveryWayOfNotKnowing_AnswersNothing_AndNeverFaults() - { - var ct = TestContext.Current.CancellationToken; - // No record for the release at all — published before the record existed. - Assert.Null(await Resolve(Release, ct)); - - // A record that names another release. - records.Publish(Release, $$"""{"identity":"{{Identity}}","version":"3.1.1"}"""); - Assert.Null(await Resolve(Release, ct)); - - // Something else under the tag — not a release record. - records.Publish(Release, $$"""{"identity":"{{Identity}}","version":"{{Release}}"}""", mediaType: OciRegistryClient.PublicationConfigMediaType); - Assert.Null(await Resolve(Release, ct)); - - // Bytes that do not hash to the digest the manifest names. - records.Publish(Release, $$"""{"identity":"{{Identity}}","version":"{{Release}}"}"""); - records.TamperConfig = true; - Assert.Null(await Resolve(Release, ct)); - records.TamperConfig = false; - - // A body far larger than a record, under a descriptor that claims a small one. - records.Publish(Release, $$"""{"identity":"{{Identity}}","version":"{{Release}}","pad":"{{new string('x', 64 * 1024)}}"}""", claimedSize: 64); - Assert.Null(await Resolve(Release, ct)); - records.Publish(Release, $$"""{"identity":"{{Identity}}","version":"{{Release}}"}"""); - - // The registry mount is down. - index.Status = HttpStatusCode.ServiceUnavailable; - Assert.Null(await Resolve(Release, ct)); - index.Status = null; - - // A version that is not a tag is never sent anywhere. - var before = records.Requests.Count; - Assert.Null(await Resolve("../3.1.10517", ct)); - Assert.Null(await Resolve("3.1.10517+abc", ct)); - Assert.Equal(before, records.Requests.Count); - - // And with the record intact again, the same reader answers. - Assert.Equal(Identity, (await Resolve(Release, ct))!.Identity); - } - - [Fact] - public void ARegistryIsNamedInALogByHostOnly() - { - Assert.Equal("registry.example.test:8443", RegistryReleaseIdentities.HostOf("https://user:secret@registry.example.test:8443/path")); - Assert.DoesNotContain("secret", RegistryReleaseIdentities.HostOf("https://user:secret@registry.example.test")); - Assert.DoesNotContain("secret", RegistryReleaseIdentities.HostOf("user:secret@not a url")); - } - - // ── the catalogue ───────────────────────────────────────────────────────────────────────── - - [Fact] - public void TheRegistryIdentityStandsInForAnAbsentMarker_AndNeverOverridesOneThatExists() - { - Seal(root, Identity, "plugins", [Package]); - - // No marker, nothing from the registry: the answer every candidate got on this estate. - var unknown = PublishedBundleCatalogue.Read(root, Release); - Assert.Null(unknown.Target.FrameworkIdentity); - Assert.False(PublishedBundleCatalogue.HasReleaseMarker(root, Release), "established absent"); - - // No marker, the registry names the identity: the bundles sealed under it are observed. - var resolved = PublishedBundleCatalogue.ReadWithRegistryIdentity(root, Release, Identity); - Assert.Equal(Identity, resolved.Target.FrameworkIdentity); - Assert.Contains(Package, resolved.Artifacts.SealedBundles); - // Nothing was written — the root may be read-only, and the file would outlive the answer. - Assert.False(Directory.Exists(Path.Combine(root, SealedPublicationIndex.ReleaseMarkerDirectoryName))); - - // An identity nothing is published under clears nothing. - var elsewhere = PublishedBundleCatalogue.ReadWithRegistryIdentity(root, Release, "d00dfeed"); - Assert.Equal("d00dfeed", elsewhere.Target.FrameworkIdentity); - Assert.DoesNotContain(Package, elsewhere.Artifacts.SealedBundles); - - // The identity is a directory name: anything that is not one segment under the root is - // treated as not given, whoever the caller is. - foreach (var hostile in new[] { "../" + Identity, "..", "a/b", "_releases", " " }) - Assert.Null(PublishedBundleCatalogue.ReadWithRegistryIdentity(root, Release, hostile).Target.FrameworkIdentity); - - // A marker whose presence cannot be determined is not an absent one: something that is not - // a file sits where the marker would be, so the registry does not stand in. - Directory.CreateDirectory(Path.Combine(root, SealedPublicationIndex.ReleaseMarkerDirectoryName, "3.1.7")); - Assert.Null(PublishedBundleCatalogue.HasReleaseMarker(root, "3.1.7")); - Assert.Null(PublishedBundleCatalogue.ReadWithRegistryIdentity(root, "3.1.7", Identity).Target.FrameworkIdentity); - Assert.Null(PublishedBundleCatalogue.HasReleaseMarker(null, Release)); - Directory.Delete(Path.Combine(root, SealedPublicationIndex.ReleaseMarkerDirectoryName), recursive: true); - - // The publisher's marker wins over whatever the registry says. - Seal(root, "published", "plugins", [Package]); - var markers = Path.Combine(root, SealedPublicationIndex.ReleaseMarkerDirectoryName); - Directory.CreateDirectory(markers); - File.WriteAllText(Path.Combine(markers, Release), "published\n"); - Assert.True(PublishedBundleCatalogue.HasReleaseMarker(root, Release)); - Assert.Equal("published", - PublishedBundleCatalogue.ReadWithRegistryIdentity(root, Release, Identity).Target.FrameworkIdentity); - } - - // ── the gate ────────────────────────────────────────────────────────────────────────────── - - [Fact(Timeout = 240_000)] - public async Task TheGateHoldsWhileNothingNamesTheIdentity_AndClearsOnceTheRegistryDoes() - { - var ct = TestContext.Current.CancellationToken; - await PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = Package, Name = Package, Kind = PackageKind.Content, - TargetPartition = Package, SourceFolder = Package, - Version = "1.0.0", ReleasedVersion = "1.0.0", Source = "plugins", - }, - [new PackageFile($"{Package}/Doc.md", $"# {Package}")], - "HEAD") - .FirstAsync().Timeout(TimeSpan.FromSeconds(120)).Await(ct); - - // The registry-fed root: this image's identity, sealed, and no _releases directory. - Seal(root, Identity, "plugins", [Package]); - var gate = new ReleaseAvailabilityService( - Mesh, - new ConfigurationBuilder() - .AddInMemoryCollection(ImmutableDictionary.Empty - .Add(ShippedPrebuiltBundles.PublishedRootConfigKey, root)) - .Build()); - - var held = await gate.IsUpdatable(Release).FirstAsync().Timeout(Budget).Await(ct); - Assert.False(held.IsUpdatable); - // The sentence an operator reads on such an estate — the hold is the IDENTITY one, not a - // completeness or a link hold that would point somewhere else. - Assert.Contains($"the framework identity of release {Release} is not resolvable", held.HoldReason); - Assert.True(held.IsIndeterminate); - - records.Publish(Release, $$"""{"identity":"{{Identity}}","version":"{{Release}}"}"""); - records.PublishIndex("plugins", Identity, [Package]); - - var cleared = await gate.IsUpdatable(Release).FirstAsync().Timeout(Budget).Await(ct); - Assert.True(cleared.IsUpdatable, cleared.HoldReason); - } - - // ── the live publication (policy registry-fed-gate-reads-live-publication, #6622) ───────── - - /// - /// 🚨 The root is the SNAPSHOT this pod pulled at start; the registry tag has moved since. The - /// gate must count what the tag names NOW — the publication the next pod will pull. Both - /// directions are pinned, on an instance where a missing bake is a hold - /// (Modules:RequirePrebuilt), so the verdict itself — not an advisory — shows which of - /// the two the gate read. Reading the snapshot (the behaviour before #6622) inverts both. - /// - [Fact(Timeout = 240_000)] - public async Task TheGateCountsTheLivePublication_NotTheSnapshotThisPodPulledAtStart() - { - var ct = TestContext.Current.CancellationToken; - await InstallPackage(ct); - records.Publish(Release, $$"""{"identity":"{{Identity}}","version":"{{Release}}"}"""); - var gate = Gate(requirePrebuilt: true); - - // WITHDRAWN since this pod started: the snapshot still holds the package, the live - // publication of the same source no longer names it. The next pod would compile it at boot. - Seal(root, Identity, "plugins", [Package, "Other"]); - records.PublishIndex("plugins", Identity, ["Other"]); - - var withdrawn = await gate.IsUpdatable(Release).FirstAsync().Timeout(Budget).Await(ct); - Assert.False(withdrawn.IsUpdatable, "a package the live publication no longer names is not sealed"); - Assert.Contains(withdrawn.Packages, - p => p.Package == Package && p.Kind == PackageAvailabilityKind.ContentBakeMissing); - Assert.False(withdrawn.IsIndeterminate, "the publication WAS read — this is a verdict, not an unknown"); - Assert.Contains(records.Requests, r => r.EndsWith($"/v2/plugins/plugins/manifests/{Identity}", StringComparison.Ordinal)); - - // ADDED since this pod started: the snapshot lacks the package, the live publication names it. - Directory.Delete(root, recursive: true); - Seal(root, Identity, "plugins", ["Other"]); - records.PublishIndex("plugins", Identity, [Package, "Other"]); - - var added = await gate.IsUpdatable(Release).FirstAsync().Timeout(Budget).Await(ct); - Assert.True(added.IsUpdatable, added.HoldReason); - Assert.Contains(added.Packages, p => p.Package == Package && p.Kind == PackageAvailabilityKind.Available); - - // Nothing was written to the root, and the registry was only read. - Assert.False(Directory.Exists(Path.Combine(root, SealedPublicationIndex.ReleaseMarkerDirectoryName))); - Assert.All(records.Requests, r => Assert.StartsWith("GET ", r, StringComparison.Ordinal)); - } - - /// - /// NEGATIVE CONTROL: a source whose live publication really is missing, unreadable or not - /// countable still fails the gate — although this pod's snapshot holds the package sealed, - /// which is exactly the reading that used to clear it. - /// - [Fact(Timeout = 240_000)] - public async Task ASourceWhoseLivePublicationIsMissingOrUnreadable_StillFailsTheGate() - { - var ct = TestContext.Current.CancellationToken; - await InstallPackage(ct); - records.Publish(Release, $$"""{"identity":"{{Identity}}","version":"{{Release}}"}"""); - Seal(root, Identity, "plugins", [Package]); - var gate = Gate(requirePrebuilt: true); - - // No tag: an established absence — nothing is sealed for that source. A verdict. - var absent = await gate.IsUpdatable(Release).FirstAsync().Timeout(Budget).Await(ct); - Assert.False(absent.IsUpdatable); - Assert.Contains(absent.Packages, p => p.Package == Package && p.Kind == PackageAvailabilityKind.ContentBakeMissing); - Assert.False(absent.IsIndeterminate); - - // A 404 that does NOT say MANIFEST_UNKNOWN establishes nothing: NAME_UNKNOWN (an unknown - // repository, or a refused read concealed as one) and a bare 404 are "I could not look". - records.MissingTagCode = "NAME_UNKNOWN"; - var nameUnknown = await gate.IsUpdatable(Release).FirstAsync().Timeout(Budget).Await(ct); - Assert.False(nameUnknown.IsUpdatable); - Assert.True(nameUnknown.IsIndeterminate, nameUnknown.HoldReason); - records.MissingTagCode = null; - var bare = await gate.IsUpdatable(Release).FirstAsync().Timeout(Budget).Await(ct); - Assert.False(bare.IsUpdatable); - Assert.True(bare.IsIndeterminate, bare.HoldReason); - records.MissingTagCode = OciRegistryClient.ManifestUnknownCode; - - // The publication cannot be read: "I could not look" — never the snapshot instead. - records.PublishIndex("plugins", Identity, [Package]); - records.PublicationsDown = true; - var down = await gate.IsUpdatable(Release).FirstAsync().Timeout(Budget).Await(ct); - Assert.False(down.IsUpdatable); - Assert.True(down.IsIndeterminate, down.HoldReason); - records.PublicationsDown = false; - - // A bundle entry that names no package cannot be counted — unreadable, never a smaller set. - records.PublishIndex("plugins", Identity, [Package], annotate: false); - var unnamed = await gate.IsUpdatable(Release).FirstAsync().Timeout(Budget).Await(ct); - Assert.False(unnamed.IsUpdatable); - Assert.True(unnamed.IsIndeterminate, unnamed.HoldReason); - - // A bundle entry that is not a valid descriptor (malformed digest, no size) cannot be - // pulled — unreadable, never a sealed package. - records.PublishIndex("plugins", Identity, [Package], validDescriptors: false); - var invalid = await gate.IsUpdatable(Release).FirstAsync().Timeout(Budget).Await(ct); - Assert.False(invalid.IsUpdatable); - Assert.True(invalid.IsIndeterminate, invalid.HoldReason); - - // A tag whose body is not an OCI image index is not a publication index. - records.PublishIndex("plugins", Identity, [Package], indexMediaType: OciRegistryClient.ImageManifestMediaType); - var notAnIndex = await gate.IsUpdatable(Release).FirstAsync().Timeout(Budget).Await(ct); - Assert.False(notAnIndex.IsUpdatable); - Assert.True(notAnIndex.IsIndeterminate, notAnIndex.HoldReason); - - // And the same gate clears once the live publication names the package. - records.PublishIndex("plugins", Identity, [Package]); - var published = await gate.IsUpdatable(Release).FirstAsync().Timeout(Budget).Await(ct); - Assert.True(published.IsUpdatable, published.HoldReason); - } - - /// - /// 🚨 The mounts are asked in order and the FIRST Published ends the reading (MeshWeaver#6743): - /// a later mount cannot change that verdict, so reading it only lets a slow mount run the - /// gate's budget out and turn a decided Published into Indeterminate. Pinned by what the second - /// mount's registry was asked; the positive control shows the instrument sees a read of it. - /// - [Fact(Timeout = 120_000)] - public async Task TheFirstMountThatAnswersPublished_EndsTheReading() - { - var ct = TestContext.Current.CancellationToken; - mirrorIndex.ArtifactRegistry = MirrorArtifactHost; - mirrorRecords.PublishIndex("plugins", Identity, [Package]); - var mirrorTag = $"/v2/plugins/plugins/manifests/{Identity}"; - var reader = new RegistryLivePublications(Mesh); - - // Positive control: the first mount holds no tag (Absent), so the second IS read and answers. - var fromMirror = await reader.Read(Identity, ["plugins"]).FirstAsync().Timeout(Budget).Await(ct); - Assert.Null(fromMirror.Refusal); - Assert.Equal([Package], fromMirror.Packages); - Assert.Contains(mirrorRecords.Requests, r => r.EndsWith(mirrorTag, StringComparison.Ordinal)); - - // The first mount answers Published: the second is never asked. - records.PublishIndex("plugins", Identity, ["Other"]); - var before = mirrorRecords.Requests.Count(r => r.EndsWith(mirrorTag, StringComparison.Ordinal)); - var fromFirst = await reader.Read(Identity, ["plugins"]).FirstAsync().Timeout(Budget).Await(ct); - Assert.Null(fromFirst.Refusal); - Assert.Equal(["Other"], fromFirst.Packages); - Assert.Equal(before, mirrorRecords.Requests.Count(r => r.EndsWith(mirrorTag, StringComparison.Ordinal))); - } - - /// - /// 🚨 A publication index larger than one is refused WHILE it is read, never after it has been - /// held whole (MeshWeaver#6743). The served body is twice the bound with no stated length; the - /// reader stops having read at most the bound plus one copy buffer, and the source is Unreadable. - /// - [Fact(Timeout = 120_000)] - public async Task AnOversizedIndex_IsRefusedAsItArrives() - { - var ct = TestContext.Current.CancellationToken; - records.PublishIndex("plugins", Identity, [Package]); - records.OversizedIndexBytes = 2L * RegistryLivePublications.MaxIndexBytes; - - var reading = await new RegistryLivePublications(Mesh).Read(Identity, ["plugins"]) - .FirstAsync().Timeout(Budget).Await(ct); - - Assert.NotNull(reading.Refusal); - Assert.Empty(reading.Packages); - Assert.InRange(records.OversizedBytesRead, 1, RegistryLivePublications.MaxIndexBytes + 128 * 1024); - } - - /// - /// 🚨 The gate counts as sealed only what bundle-fetch would materialise - /// (MeshWeaver#6743): it pulls the WHOLE index and dies on a malformed descriptor anywhere in it - /// and on a publication without _complete. Each of those is Unreadable, and the whole, - /// well-formed publication (the control) is Published. - /// - [Fact(Timeout = 120_000)] - public async Task OnlyAPublicationBundleFetchWouldMaterialise_IsCountedSealed() - { - var ct = TestContext.Current.CancellationToken; - var reader = new RegistryLivePublications(Mesh); - Task Read() => reader.Read(Identity, ["plugins"]).FirstAsync().Timeout(Budget).Await(ct); - - records.PublishIndex("plugins", Identity, [Package]); - var whole = await Read(); - Assert.Null(whole.Refusal); - Assert.Equal([Package], whole.Packages); - - foreach (var shape in new[] - { - SidecarShape.InvalidDescriptor, SidecarShape.Missing, SidecarShape.NoComplete, - SidecarShape.EmptyComplete, SidecarShape.InvalidLayer, - }) - { - records.PublishIndex("plugins", Identity, [Package], sidecar: shape); - var reading = await Read(); - Assert.True(reading.Refusal is not null, $"{shape}: a publication bundle-fetch refuses is not sealed"); - Assert.Empty(reading.Packages); - } - } - - [Fact] - public void AMarkedRelease_IsNeverJudgedAgainstALiveReading() - { - Seal(root, "published", "plugins", [Package]); - var markers = Path.Combine(root, SealedPublicationIndex.ReleaseMarkerDirectoryName); - Directory.CreateDirectory(markers); - File.WriteAllText(Path.Combine(markers, Release), "published\n"); - var live = new LivePublicationReading("published", - [new LiveSourcePublication("plugins", "sha256:" + new string('0', 64), [], null)]); - - var read = PublishedBundleCatalogue.ReadWithLivePublication(root, Release, "published", live); - - // The publisher's marker wins, and with it the publisher's root: the live reading is ignored. - Assert.Contains(Package, read.Artifacts.SealedBundles); - Assert.Equal(ImmutableArray.Create("plugins"), PublishedBundleCatalogue.SourcesOnRoot(root)); - } - - private Task InstallPackage(CancellationToken ct) => - PackageInstaller.Install( - Mesh, - new PackageManifest - { - Id = Package, Name = Package, Kind = PackageKind.Content, - TargetPartition = Package, SourceFolder = Package, - Version = "1.0.0", ReleasedVersion = "1.0.0", Source = "plugins", - }, - [new PackageFile($"{Package}/Doc.md", $"# {Package}")], - "HEAD") - .FirstAsync().Timeout(TimeSpan.FromSeconds(120)).Await(ct); - - private ReleaseAvailabilityService Gate(bool requirePrebuilt) => - new( - Mesh, - new ConfigurationBuilder() - .AddInMemoryCollection(ImmutableDictionary.Empty - .Add(ShippedPrebuiltBundles.PublishedRootConfigKey, root) - .Add(PrebuiltAssemblySeeder.RequirePrebuiltConfigKey, requirePrebuilt ? "true" : "false")) - .Build()); - - // ── staging ─────────────────────────────────────────────────────────────────────────────── - - private static void Seal(string root, string identity, string source, IEnumerable bundles) - { - var directory = Path.Combine(root, identity, source); - Directory.CreateDirectory(directory); - var names = ImmutableList.Empty; - foreach (var bundle in bundles) - { - var manifest = new BundleReader.Manifest( - bundle, "1.0", identity, - [ - new BundleReader.AssemblyRef( - $"{bundle}/Type", $"{bundle}_Type.dll", - ImmutableDictionary.Create(StringComparer.Ordinal) - .Add("MeshWeaver.Layout", MeshWeaver.Compiler.CompiledDependencies.RefAsmScheme + "abc")), - ]); - using (var file = File.Create(Path.Combine(directory, bundle + ".zip"))) - using (var zip = new ZipArchive(file, ZipArchiveMode.Create)) - using (var stream = zip.CreateEntry(NuGetPackageWriter.ManifestEntry).Open()) - stream.Write(JsonSerializer.SerializeToUtf8Bytes( - manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web))); - names = names.Add(bundle + ".zip"); - } - File.WriteAllText( - Path.Combine(directory, ShippedPrebuiltBundles.CompletionSentinelFileName), - string.Join('\n', names.Order(StringComparer.Ordinal)) + "\n"); - } - - /// The plugin registry's bundle index, as far as this reader uses it: the artifact - /// host it declares, behind the instance key. - private sealed class FakeBundleIndex(string host) : HttpMessageHandler, IHostHandler - { - public string? ArtifactRegistry { get; set; } - public HttpStatusCode? Status { get; set; } - - public bool Serves(string candidate) => string.Equals(candidate, host, StringComparison.OrdinalIgnoreCase); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - if (Status is { } status) - return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent("down") }); - if (request.Headers.Authorization is not { Scheme: "Bearer", Parameter: Key }) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)); - if (!request.RequestUri!.AbsolutePath.EndsWith("/index.json", StringComparison.Ordinal)) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - var body = JsonSerializer.Serialize( - new { frameworkMvid = Identity, bundles = Array.Empty(), artifactRegistry = ArtifactRegistry }, - new JsonSerializerOptions(JsonSerializerDefaults.Web)); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }); - } - } - - /// An OCI registry holding plugins/releases: one layer-less manifest per tag - /// whose config blob is the release record, behind the bearer challenge the fleet's registry - /// speaks. It holds the protocol's invariants — a 401 without a bearer, a 401 on a wrong key, - /// content-addressed blobs — rather than replaying answers. - private sealed class FakeReleaseRecords(string host, string key) : HttpMessageHandler, IHostHandler - { - private const string Repository = RegistryReleaseIdentities.ReleaseRepository; - private ImmutableDictionary tags = - ImmutableDictionary.Empty; - private ImmutableList requests = ImmutableList.Empty; - - private ImmutableDictionary indexes = - ImmutableDictionary.Empty; - private long oversizedBytesRead; - - /// When set, every publication TAG answers a body of this many bytes, with no - /// stated length, counting how many of them the reader consumed. - public long? OversizedIndexBytes { get; set; } - - public long OversizedBytesRead => Interlocked.Read(ref oversizedBytesRead); - - public bool TamperConfig { get; set; } - public bool PublicationsDown { get; set; } - public string? PresentedSecret { get; private set; } - - /// Moves plugins/<source>:<identity> to a publication index naming - /// these packages, in the shape push-bundle-publication.sh writes. - public void PublishIndex( - string source, string identity, IEnumerable packages, bool annotate = true, - bool validDescriptors = true, string indexMediaType = OciRegistryClient.ImageIndexMediaType, - SidecarShape sidecar = SidecarShape.Whole) - { - var repository = $"plugins/{source.ToLowerInvariant()}"; - static ImmutableDictionary Layer(string title, int size, bool valid = true) => - ImmutableDictionary.Empty - .Add("mediaType", "text/plain") - .Add("digest", valid ? OciDigest.Of(Encoding.UTF8.GetBytes(title)) : "sha256:nope") - .Add("size", size) - .Add("annotations", ImmutableDictionary.Empty - .Add(RegistryLivePublications.TitleAnnotation, title)); - var layers = ImmutableArray.Create(Layer("source-commit.txt", 41, valid: sidecar != SidecarShape.InvalidLayer)); - if (sidecar is not SidecarShape.NoComplete) - layers = layers.Add(Layer(RegistryLivePublications.CompletionSentinel, sidecar == SidecarShape.EmptyComplete ? 0 : 12)); - var sidecarManifest = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(ImmutableDictionary.Empty - .Add("schemaVersion", 2) - .Add("mediaType", OciRegistryClient.ImageManifestMediaType) - .Add("artifactType", RegistryLivePublications.PublicationArtifactType) - .Add("config", ImmutableDictionary.Empty - .Add("mediaType", RegistryLivePublications.PublicationArtifactType) - .Add("digest", OciDigest.Of(Encoding.UTF8.GetBytes("{}"))) - .Add("size", 2)) - .Add("layers", layers))); - var sidecarDigest = OciDigest.Of(sidecarManifest); - ImmutableInterlocked.Update(ref indexes, i => i.SetItem( - $"{repository}:{sidecarDigest}", (sidecarManifest, OciRegistryClient.ImageManifestMediaType))); - var sidecars = ImmutableDictionary.Empty - .Add("mediaType", OciRegistryClient.ImageManifestMediaType) - .Add("digest", sidecar == SidecarShape.InvalidDescriptor ? "sha256:not-a-digest" : sidecarDigest) - .Add("size", sidecarManifest.Length) - .Add("artifactType", RegistryLivePublications.PublicationArtifactType) - .Add("annotations", ImmutableDictionary.Empty.Add("io.meshweaver.role", "sidecars")); - var bundles = packages.Select(package => ImmutableDictionary.Empty - .Add("mediaType", OciRegistryClient.ImageManifestMediaType) - .Add("digest", validDescriptors ? OciDigest.Of(Encoding.UTF8.GetBytes(package)) : "sha256:not-a-digest") - .Add("size", validDescriptors ? 2 : 0) - .Add("artifactType", RegistryLivePublications.BundleArtifactType) - .Add("annotations", annotate - ? ImmutableDictionary.Empty.Add(RegistryLivePublications.PackageAnnotation, package) - : ImmutableDictionary.Empty)); - var index = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(ImmutableDictionary.Empty - .Add("schemaVersion", 2) - .Add("mediaType", indexMediaType) - .Add("artifactType", RegistryLivePublications.PublicationArtifactType) - .Add("manifests", (sidecar == SidecarShape.Missing ? [] : ImmutableArray.Create(sidecars)).AddRange(bundles)))); - ImmutableInterlocked.Update(ref indexes, i => i.SetItem($"{repository}:{identity}", (index, OciRegistryClient.ImageIndexMediaType))); - } - - /// The OCI error code a missing publication tag answers 404 with; null answers a - /// bare 404 with no body. - public string? MissingTagCode { get; set; } = OciRegistryClient.ManifestUnknownCode; - - public ImmutableList Requests => requests; - - public bool Serves(string candidate) => string.Equals(candidate, host, StringComparison.OrdinalIgnoreCase); - - public void Publish(string tag, string record, string mediaType = RegistryReleaseIdentities.ReleaseConfigMediaType, int? claimedSize = null) - { - var config = Encoding.UTF8.GetBytes(record); - var manifest = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new - { - schemaVersion = 2, - mediaType = OciRegistryClient.ImageManifestMediaType, - artifactType = mediaType, - config = new { mediaType, digest = OciDigest.Of(config), size = claimedSize ?? config.Length }, - layers = Array.Empty(), - })); - ImmutableInterlocked.Update(ref tags, t => t.SetItem(tag, (manifest, config))); - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri!; - ImmutableInterlocked.Update(ref requests, r => r.Add($"{request.Method} {uri.AbsolutePath}")); - - if (uri.AbsolutePath == "/v2/token") - { - string? secret = null; - if (request.Headers.Authorization is { Scheme: "Basic", Parameter: { } basic }) - { - var pair = Encoding.UTF8.GetString(Convert.FromBase64String(basic)); - secret = pair[(pair.IndexOf(':') + 1)..]; - } - PresentedSecret = secret; - return Task.FromResult(secret == key - ? new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent($$"""{"token":"bearer-for-{{key}}"}""", Encoding.UTF8, "application/json"), - } - : new HttpResponseMessage(HttpStatusCode.Unauthorized)); - } - - if (request.Headers.Authorization is not { Scheme: "Bearer" } auth || auth.Parameter != $"bearer-for-{key}") - { - var challenge = new HttpResponseMessage(HttpStatusCode.Unauthorized); - challenge.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue( - "Bearer", $"realm=\"https://{host}/v2/token\",service=\"{host}\"")); - return Task.FromResult(challenge); - } - - // A source's publication index — any plugins/ repository but the release one. - var publication = System.Text.RegularExpressions.Regex.Match( - uri.AbsolutePath, "^/v2/(plugins/[a-z0-9._-]+)/manifests/([^/]+)$"); - if (publication.Success && publication.Groups[1].Value != Repository) - { - if (PublicationsDown) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); - if (OversizedIndexBytes is { } oversized && !OciDigest.IsWellFormed(publication.Groups[2].Value)) - { - var stream = new CountingFiller(oversized, read => Interlocked.Add(ref oversizedBytesRead, read)); - var big = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) }; - big.Content.Headers.ContentType = new MediaTypeHeaderValue(OciRegistryClient.ImageIndexMediaType); - return Task.FromResult(big); - } - if (!indexes.TryGetValue($"{publication.Groups[1].Value}:{publication.Groups[2].Value}", out var index)) - return Task.FromResult(MissingTagCode is { } code - ? new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent( - $$$"""{"errors":[{"code":"{{{code}}}","message":"not found"}]}""", - Encoding.UTF8, "application/json"), - } - : new HttpResponseMessage(HttpStatusCode.NotFound)); - var served = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(index.Bytes) }; - served.Content.Headers.ContentType = new MediaTypeHeaderValue(index.MediaType); - served.Headers.TryAddWithoutValidation("Docker-Content-Digest", OciDigest.Of(index.Bytes)); - return Task.FromResult(served); - } - - var manifests = $"/v2/{Repository}/manifests/"; - if (uri.AbsolutePath.StartsWith(manifests, StringComparison.Ordinal)) - { - if (!tags.TryGetValue(uri.AbsolutePath[manifests.Length..], out var entry)) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(entry.Manifest) }; - response.Content.Headers.ContentType = new MediaTypeHeaderValue(OciRegistryClient.ImageManifestMediaType); - response.Headers.TryAddWithoutValidation("Docker-Content-Digest", OciDigest.Of(entry.Manifest)); - return Task.FromResult(response); - } - - var blobs = $"/v2/{Repository}/blobs/"; - if (uri.AbsolutePath.StartsWith(blobs, StringComparison.Ordinal)) - { - var digest = uri.AbsolutePath[blobs.Length..]; - var config = tags.Values.Select(t => t.Config).FirstOrDefault(c => OciDigest.Of(c) == digest); - if (config is null) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - var bytes = config; - if (TamperConfig) - { - bytes = (byte[])config.Clone(); - bytes[bytes.Length / 2] ^= 0x01; - } - var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }; - response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); - response.Headers.TryAddWithoutValidation("Docker-Content-Digest", digest); - return Task.FromResult(response); - } - - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - } - - /// How a published index's sidecar manifest is shaped. - internal enum SidecarShape { Whole, Missing, InvalidDescriptor, NoComplete, EmptyComplete, InvalidLayer } - - /// A body of length bytes of JSON-ish filler, with no stated length, that - /// reports every read so a test can see how much of it a reader consumed. - private sealed class CountingFiller(long length, Action onRead) : Stream - { - private long position; - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => throw new NotSupportedException(); - public override long Position { get => position; set => throw new NotSupportedException(); } - public override void Flush() { } - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - - public override int Read(byte[] buffer, int offset, int count) - { - var n = (int)Math.Min(count, length - position); - buffer.AsSpan(offset, n).Fill((byte)' '); - position += n; - onRead(n); - return n; - } - } -} diff --git a/test/Memex.Portal.Shared.Test/ReleaseLinkGateTest.cs b/test/Memex.Portal.Shared.Test/ReleaseLinkGateTest.cs deleted file mode 100644 index a98f60fcba..0000000000 --- a/test/Memex.Portal.Shared.Test/ReleaseLinkGateTest.cs +++ /dev/null @@ -1,929 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using MeshWeaver.Compiler; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Mesh; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A platform roll is held only by a module that provably cannot load on the target (#3651; -/// maintainer rule of 2026-09-07, Doc/Architecture/ModuleAdoptionPolicy). -/// -/// On 2026-09-07 every production portal sat on its morning build all day: the declared -/// floors (a) declined every candidate (fixed by #3648), and a missing bake for the new identity -/// (b) would have held it again the moment (a) was lifted — while a boot compile of those courses -/// succeeds on every PR and every candidate would have loaded. These pin the rule that replaces -/// both: the ONLY hold on the module lane is MEASURED — a landed generation, for which the target -/// publishes no build, linked against the target's published type surface -/// () and found Unlinkable. A missing -/// bake is reported as a cost. A link check that could not be made is reported and decides -/// nothing. walks newest-first and stops at the first release with no -/// unloadable module. -/// -/// Nothing is mocked: the modules are REAL assemblies compiled with Roslyn, landed in a -/// generation directory of the shape boot loads from; the target's surface is the running -/// process's own document with one type removed (an older platform, verbatim the memex-cloud -/// shape of #3538); the observation is over a root on -/// disk plus ; the rule is -/// . -/// -public class ReleaseLinkGateTest : IDisposable -{ - private const string ContractAssembly = "MeshWeaver.Mesh.Contract"; - private const string Version = "3.0.0-ci.8100"; - private const string Identity = "s3651target000000000000000000000"; - private const string OlderVersion = "3.0.0-ci.8090"; - private const string OlderIdentity = "s3651older0000000000000000000000"; - - /// The module that binds MeshNode — loadable here, unloadable on a platform - /// whose contract does not carry that type. - private const string ViewPack = "MeshWeaver.Test.LinkGateViewPack"; - - /// A module binding nothing the target lacks — the positive control. - private const string PlainPack = "MeshWeaver.Test.LinkGatePlainPack"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-3651-" + Guid.NewGuid().ToString("N")); - - private readonly string modules; - - public ReleaseLinkGateTest() - { - Directory.CreateDirectory(root); - modules = Path.Combine(root, "modules"); - Directory.CreateDirectory(modules); - } - - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } catch { /* temp */ } - GC.SuppressFinalize(this); - } - - // ── the hold: measured, named ─────────────────────────────────────────────────────────────── - - // ── the exact-release surface (policy exact-release-surface) ──────────────────────────────── - - /// - /// 🚨 Each release is judged against ITS OWN surface — never the publication's shared one. - /// Two releases resolve ONE framework identity and therefore share ONE publication (one core - /// commit baked against two portal images whose module closures differ — the ci.9468 shape). - /// The publication's own platform-surface.json is whichever bake moved _current - /// last, which for two same-source bakes is unordered (the race reproduced on #5818); here it - /// is deliberately the WRONG answer for the release under judgement, in both directions. The - /// older release's image lacks MeshNode and must hold; the newer one carries it and must - /// clear — whatever the shared document says. - /// - [Theory] - [InlineData(true)] - [InlineData(false)] - public void EachReleaseIsJudgedAgainstItsOwnSurface_NeverThePublicationsShared(bool sharedSurfaceIsTheOlderImage) - { - const string NewerRelease = "3.0.0-ci.8101"; - var published = PublishedRoot(Version, Identity, surface: OlderPlatformSurface(Identity), - publicationSurface: sharedSurfaceIsTheOlderImage ? OlderPlatformSurface(Identity) : ThisPlatformSurface(Identity)); - WriteReleaseSurface(published, NewerRelease, Identity, ThisPlatformSurface(Identity)); - var landed = Land(ViewPack, ModuleBindingMeshNode(ViewPack)); - RequiredPackage[] required = - [ - new RequiredPackage("Views", "Views", HasContent: false) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]; - - var older = Judge(published, Version, required); - var newer = Judge(published, NewerRelease, required); - - Assert.False(older.IsUpdatable, "the older image does not carry MeshNode — its own surface says so"); - Assert.Equal(PackageAvailabilityKind.ModuleUnloadable, Assert.Single(older.Blockers).Kind); - Assert.True(newer.IsUpdatable, newer.HoldReason); - Assert.Empty(newer.Blockers); - // Non-vacuous: both releases really do share one publication and one identity. - Assert.Equal(Identity, PublishedBundleCatalogue.Read(published, Version).Target.FrameworkIdentity); - Assert.Equal(Identity, PublishedBundleCatalogue.Read(published, NewerRelease).Target.FrameworkIdentity); - } - - /// - /// A release that records NO surface of its own is unmeasured — even when the publication it - /// resolves carries one. No fallback: a surface measured on another image is the wrong answer - /// this contract retires, so the link check is Indeterminate (reported, not a hold), and the - /// detail names the missing per-release document. - /// - [Fact] - public void AReleaseWithNoSurfaceOfItsOwn_IsUnmeasured_EvenWhenThePublicationCarriesOne() - { - var published = PublishedRoot(Version, Identity, surface: null, - publicationSurface: OlderPlatformSurface(Identity)); - var landed = Land(ViewPack, ModuleBindingMeshNode(ViewPack)); - - var observation = PublishedBundleCatalogue.Read(published, Version); - Assert.Null(observation.Artifacts.PlatformSurface); - Assert.Contains($"{PublishedBundleCatalogue.ReleaseMarkerDirectoryName}/{PublishedBundleCatalogue.ReleaseSurfaceDirectoryName}/{Version}", - observation.Artifacts.PlatformSurfaceDetail!, StringComparison.Ordinal); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Views", "Views", HasContent: false) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]); - Assert.True(verdict.IsUpdatable, "the publication's surface (which lacks MeshNode) must not be read"); - Assert.Empty(verdict.Blockers); - Assert.Contains(verdict.Advisories, a => a.Contains("could not be determined", StringComparison.Ordinal)); - } - - /// A per-release document that names ANOTHER framework identity than the release - /// marker is not that release's surface — reported, never linked against. - [Fact] - public void AReleaseSurfaceNamingAnotherIdentity_IsNotLinkedAgainst() - { - var published = PublishedRoot(Version, Identity, surface: OlderPlatformSurface(OlderIdentity)); - var landed = Land(ViewPack, ModuleBindingMeshNode(ViewPack)); - - var observation = PublishedBundleCatalogue.Read(published, Version); - Assert.Null(observation.Artifacts.PlatformSurface); - Assert.Contains(OlderIdentity, observation.Artifacts.PlatformSurfaceDetail!, StringComparison.Ordinal); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Views", "Views", HasContent: false) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]); - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Empty(verdict.Blockers); - } - - /// - /// 🚨 THE HOLD. The environment has landed a view pack built against THIS platform; the target - /// publishes no build of it and its surface does not carry MeshWeaver.Mesh.MeshNode. - /// The roll is held, the hold names the module and the missing type, and it is a definite - /// incompatibility — never Indeterminate. - /// - [Fact] - public void AModuleUnlinkableAgainstTheTargetSurface_HoldsTheRoll_NamingTheModule() - { - var published = PublishedRoot(Version, Identity, surface: OlderPlatformSurface(Identity)); - var landed = Land(ViewPack, ModuleBindingMeshNode(ViewPack)); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Widget", "Widget"), - new RequiredPackage("Views", "Views", HasContent: false) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]); - - Assert.False(verdict.IsUpdatable); - Assert.False(verdict.IsIndeterminate, - "an unlinkable module is a MEASURED incompatibility, not an unreadability"); - var blocker = Assert.Single(verdict.Blockers); - Assert.Equal("Views", blocker.Package); - Assert.Equal(PackageAvailabilityKind.ModuleUnloadable, blocker.Kind); - Assert.Contains(ViewPack, blocker.Reason!, StringComparison.Ordinal); - Assert.Contains(typeof(MeshNode).FullName!, blocker.Reason!, StringComparison.Ordinal); - Assert.Contains(Version, blocker.Reason!, StringComparison.Ordinal); - Assert.Contains(ViewPack, verdict.HoldReason!, StringComparison.Ordinal); - // The content package beside it is fine and says so — the blast radius is one module. - Assert.Equal(PackageAvailabilityKind.Available, - Assert.Single(verdict.Packages, p => p.Package == "Widget").Kind); - // And the measurement is on the observation, denominator and all. - var link = Assert.Contains("Views", verdict.IsUpdatable ? [] : Links(published, Version, landed)); - Assert.Equal(ModuleLinkState.Unlinkable, link.State); - Assert.True(link.CheckedTypeReferences > 0, link.Report()); - } - - /// - /// 🚨 Direction (i) of #4083 — the ROLL candidate: a module already landed, bound to the - /// contract at this line's 3.0.0.0, measured against a candidate image whose published - /// surface carries the same assembly at a LOWER version (every type name present). The - /// candidate's copy is what loads there, and the loader refuses the bind — so the roll is - /// HELD as ModuleUnloadable, naming the module, both versions and the exception. A - /// measured incompatibility, never Indeterminate. - /// - [Fact] - public void AModuleBoundAboveTheTargetsVersion_HoldsTheRoll_NamingBothVersions() - { - var bound = typeof(MeshNode).Assembly.GetName().Version!.ToString(4); - var published = PublishedRoot(Version, Identity, - surface: WithIdentityVersion(ThisPlatformSurface(Identity), ContractAssembly, "1.0.0.0")); - var landed = Land(ViewPack, ModuleBindingMeshNode(ViewPack)); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Views", "Views", HasContent: false) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]); - - Assert.False(verdict.IsUpdatable); - Assert.False(verdict.IsIndeterminate, "a version the loader refuses is MEASURED, not unknown"); - var blocker = Assert.Single(verdict.Blockers); - Assert.Equal(PackageAvailabilityKind.ModuleUnloadable, blocker.Kind); - Assert.Contains(ViewPack, blocker.Reason!, StringComparison.Ordinal); - Assert.Contains($"{ContractAssembly} {bound}", blocker.Reason!, StringComparison.Ordinal); - Assert.Contains("1.0.0.0", blocker.Reason!, StringComparison.Ordinal); - Assert.Contains("FileLoadException", blocker.Reason!, StringComparison.Ordinal); - // 🚨 LEGIBLE (#4083): HoldReason is the exact string the self-update poller writes onto - // Admin/UpdatePolicy.heldReason (SelfUpdateHostedService: `HeldReason = verdict.HoldReason`), - // so it must name the module, the reference and BOTH versions on its own. - Assert.NotNull(verdict.HoldReason); - Assert.Contains(ViewPack, verdict.HoldReason, StringComparison.Ordinal); - Assert.Contains($"{ContractAssembly} {bound}", verdict.HoldReason, StringComparison.Ordinal); - Assert.Contains("1.0.0.0", verdict.HoldReason, StringComparison.Ordinal); - var link = Assert.Contains("Views", Links(published, Version, landed)); - Assert.Equal(ModuleLinkState.BindingConflict, link.State); - Assert.True(link.ComparedAssemblyReferences > 0, link.Report()); - } - - /// - /// The asymmetry at the roll: a candidate carrying the assembly at a HIGHER version than the - /// landed module was bound to rolls forward — the roll CLEARS, and the drift is on the - /// verdict's advisories naming the module, so it is on record and decides nothing. - /// - [Fact] - public void AModuleBoundBelowTheTargetsVersion_Clears_WithTheDriftAsAnAdvisory() - { - var published = PublishedRoot(Version, Identity, - surface: WithIdentityVersion(ThisPlatformSurface(Identity), ContractAssembly, "99.0.0.0")); - var landed = Land(ViewPack, ModuleBindingMeshNode(ViewPack)); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Views", "Views", HasContent: false) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Empty(verdict.Blockers); - var advisory = Assert.Single(verdict.Advisories); - Assert.Contains(ViewPack, advisory, StringComparison.Ordinal); - Assert.Contains("99.0.0.0", advisory, StringComparison.Ordinal); - Assert.Contains("rolls forward", advisory, StringComparison.Ordinal); - Assert.Equal(ModuleLinkState.Linkable, Assert.Contains("Views", Links(published, Version, landed)).State); - } - - /// The positive control: a landed module that links against the target clears, with - /// nothing on the advisories about it. - [Fact] - public void ALandedModuleThatLinksAgainstTheTarget_Clears() - { - var published = PublishedRoot(Version, Identity, surface: OlderPlatformSurface(Identity)); - var landed = Land(PlainPack, ModuleBindingNothingTheTargetLacks(PlainPack)); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Widget", "Widget"), - new RequiredPackage("Plain", "Plain", HasContent: false) - { ModuleName = PlainPack, LandedModulePath = landed }, - ]); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Empty(verdict.Blockers); - Assert.Empty(verdict.Advisories); - Assert.All(verdict.Packages, p => Assert.Equal(PackageAvailabilityKind.Available, p.Kind)); - } - - /// - /// 🚨 A landed module that references ANOTHER landed module is measured against the target's - /// platform PLUS that sibling — what boot loads beside it — and clears. Measured 2026-10-09: - /// memex.systemorph.com held every build after 3.0.0-ci.10310 because fifteen landed modules - /// (iMessage, WhatsApp, Chat, …) reference MeshWeaver.AI, itself a landed module and not in - /// the image, and the gate read each as "this deployment carries no such platform assembly". - /// - [Fact] - public void AModuleReferencingALandedSiblingModule_Clears() - { - var published = PublishedRoot(Version, Identity, surface: ThisPlatformSurface(Identity)); - var siblingBytes = SiblingCore(); - var sibling = Land(SiblingCoreName, siblingBytes); - var user = Land(SiblingUserName, SiblingUser(siblingBytes)); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Core", "Core", HasContent: false) { ModuleName = SiblingCoreName, LandedModulePath = sibling }, - new RequiredPackage("User", "User", HasContent: false) { ModuleName = SiblingUserName, LandedModulePath = user }, - ]); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Empty(verdict.Blockers); - } - - /// NEGATIVE CONTROL: the same module with its sibling NOT landed still holds the roll, - /// naming the sibling assembly — a reference to a module this instance does not carry is a real - /// load failure. - [Fact] - public void AModuleReferencingASiblingThatIsNotLanded_HoldsTheRoll() - { - var published = PublishedRoot(Version, Identity, surface: ThisPlatformSurface(Identity)); - var user = Land(SiblingUserName, SiblingUser(SiblingCore())); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("User", "User", HasContent: false) { ModuleName = SiblingUserName, LandedModulePath = user }, - ]); - - Assert.False(verdict.IsUpdatable); - var blocker = Assert.Single(verdict.Blockers); - Assert.Equal(PackageAvailabilityKind.ModuleUnloadable, blocker.Kind); - Assert.Contains(SiblingCoreName, blocker.Reason!, StringComparison.Ordinal); - } - - /// - /// 🚨 POST-ROLL, not pre-roll: when the target publishes a replacement for the sibling, boot - /// adopts THAT build at the roll, so the dependent is measured against it — and here the - /// replacement removed the TYPE the dependent calls (the release gate is a type-level verdict). The landed sibling still has it; measuring - /// against the landed bytes would clear a roll that throws at the first call. Held. - /// - [Fact] - public void ASiblingReplacedAtTheRollByABuildWithoutTheCalledApi_HoldsTheRoll() - { - var published = PublishedRoot(Version, Identity, surface: ThisPlatformSurface(Identity), - sealedModule: (SiblingCoreName, SiblingCore(withoutHello: true))); - var siblingBytes = SiblingCore(); - var sibling = Land(SiblingCoreName, siblingBytes); - var user = Land(SiblingUserName, SiblingUser(siblingBytes)); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Views", "Views", HasContent: false) { ModuleName = SiblingCoreName, LandedModulePath = sibling }, - new RequiredPackage("User", "User", HasContent: false) { ModuleName = SiblingUserName, LandedModulePath = user }, - ]); - - Assert.False(verdict.IsUpdatable); - var blocker = Assert.Single(verdict.Blockers); - Assert.Equal("User", blocker.Package); - Assert.Contains("SiblingCore.Api", blocker.Reason!, StringComparison.Ordinal); - } - - /// The same replacement that KEEPS the called API clears — the measurement reads the - /// published bytes, not merely "a replacement exists". - [Fact] - public void ASiblingReplacedAtTheRollByACompatibleBuild_Clears() - { - var published = PublishedRoot(Version, Identity, surface: ThisPlatformSurface(Identity), - sealedModule: (SiblingCoreName, SiblingCore())); - var siblingBytes = SiblingCore(); - var user = Land(SiblingUserName, SiblingUser(siblingBytes)); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Views", "Views", HasContent: false) { ModuleName = SiblingCoreName }, - new RequiredPackage("User", "User", HasContent: false) { ModuleName = SiblingUserName, LandedModulePath = user }, - ]); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - } - - /// A sibling's identity is read from its FILE even on a published (declared) surface, so - /// a dependent bound ABOVE the sibling's version is a binding conflict — the roll is held, never - /// cleared on type names alone. - [Fact] - public void ADependentBoundAboveItsSiblingsVersion_HoldsTheRoll() - { - var published = PublishedRoot(Version, Identity, surface: ThisPlatformSurface(Identity)); - var siblingV1 = SiblingCore(version: "1.0.0.0"); - var sibling = Land(SiblingCoreName, siblingV1); - var user = Land(SiblingUserName, SiblingUser(SiblingCore(version: "2.0.0.0"))); - - var surface = ModulePlatformSurface.FromJson(ThisPlatformSurface(Identity)).WithSiblingModules([sibling]); - Assert.Equal(new System.Version(1, 0, 0, 0), surface.IdentityOf(SiblingCoreName)?.Version); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Core", "Core", HasContent: false) { ModuleName = SiblingCoreName, LandedModulePath = sibling }, - new RequiredPackage("User", "User", HasContent: false) { ModuleName = SiblingUserName, LandedModulePath = user }, - ]); - - Assert.False(verdict.IsUpdatable); - Assert.Contains("2.0.0.0", Assert.Single(verdict.Blockers).Reason!, StringComparison.Ordinal); - } - - /// A sibling whose landed generation vanished mid-measurement makes its dependent - /// Indeterminate, named — never an exception out of the measurement. - [Fact] - public void AnUnreadableSibling_MakesTheDependentIndeterminate_NeverThrows() - { - var published = PublishedRoot(Version, Identity, surface: ThisPlatformSurface(Identity)); - var siblingBytes = SiblingCore(); - var sibling = Land(SiblingCoreName, siblingBytes); - var user = Land(SiblingUserName, SiblingUser(siblingBytes)); - Directory.Delete(Path.GetDirectoryName(sibling)!, recursive: true); - - var observation = PublishedBundleCatalogue.Read(published, Version); - var links = ModuleLinkObservation.Measure(observation.Artifacts, - [ - new RequiredPackage("Core", "Core", HasContent: false) { ModuleName = SiblingCoreName, LandedModulePath = sibling }, - new RequiredPackage("User", "User", HasContent: false) { ModuleName = SiblingUserName, LandedModulePath = user }, - ]).ModuleLinks; - - var userLink = Assert.Contains("User", links); - Assert.Equal(ModuleLinkState.Indeterminate, userLink.State); - Assert.Contains(SiblingCoreName, userLink.Detail!, StringComparison.Ordinal); - } - - private const string SiblingCoreName = "MeshWeaver.Test.LinkGateSiblingCore"; - private const string SiblingUserName = "MeshWeaver.Test.LinkGateSiblingUser"; - - private static byte[] SiblingCore(bool withoutHello = false, string version = "1.0.0.0") - => ModulePlatformSurfaceJsonTest.Emit(SiblingCoreName, $$""" - [assembly: System.Reflection.AssemblyVersion("{{version}}")] - namespace SiblingCore - { - {{(withoutHello - ? "public static class Replacement { public static string Bye() => \"bye\"; }" - : "public static class Api { public static string Hello() => \"hi\"; }")}} - } - """); - - private static byte[] SiblingUser(byte[] siblingCore) - { - var compilation = Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create( - SiblingUserName, - [Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText( - "public static class UsesSibling { public static string Call() => SiblingCore.Api.Hello(); }")], - PlatformReferences.Platform().Add(Microsoft.CodeAnalysis.MetadataReference.CreateFromImage(siblingCore)), - new Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions( - Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary)); - return StandInCompile.Emit(compilation); - } - - /// - /// A module the TARGET publishes a build of will be adopted at the roll (#3650), so the landed - /// generation — unloadable there or not — is not what keeps running and is not what decides. - /// The published build's consistency is the sealed-set rule's business (#3175), not this one's. - /// - [Fact] - public void AModuleWithABuildPublishedForTheTarget_IsNotHeldOnItsLandedGeneration() - { - var bytes = ModuleBindingMeshNode(ViewPack); - var published = PublishedRoot(Version, Identity, - surface: OlderPlatformSurface(Identity), sealedModule: (ViewPack, bytes)); - var landed = Land(ViewPack, bytes); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Views", "Views", HasContent: false) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Empty(verdict.Advisories); - } - - /// A module the environment has NOT landed cannot keep running across the roll, so - /// there is nothing to measure and nothing to hold on. - /// - /// 🚨 This case DOES speak now, and the distinction is the point (#3706). The LINK lane is - /// still silent — there are no bytes to link, which is what "is not measured" means and what - /// this test is named for. What speaks is the ORPHAN advisory: the target's sealed set does not - /// carry the module and nothing is landed, so no publisher produces it. That is a statement - /// about the INSTALL RECORD, not about the link, and it neither measures nor holds. The - /// assertion moved from "says nothing at all" to "says nothing about the link", because the - /// blanket form was written when a link advisory was the only one this lane could emit, and - /// blanket emptiness would now re-assert the silence #3706 was filed about. - [Fact] - public void AModuleWithNoLandedGeneration_IsNotMeasured() - { - var published = PublishedRoot(Version, Identity, surface: OlderPlatformSurface(Identity)); - - var verdict = Judge(published, Version, - [new RequiredPackage("Views", "Views", HasContent: false) { ModuleName = ViewPack }]); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.DoesNotContain(verdict.Advisories, - a => a.Contains("could not be determined", StringComparison.Ordinal) - || a.Contains("cannot load", StringComparison.Ordinal)); - Assert.Contains( - verdict.Advisories, - a => a.Contains("no publisher produces it", StringComparison.Ordinal)); - } - - // ── Indeterminate is REPORTED — neither clearance nor a hold ─────────────────────────────── - - /// - /// 🚨 A release with NO published surface — every publication sealed before #3651 — cannot - /// be linked against. That is Indeterminate for the link check ONLY: reported on the - /// advisories, naming the module and why, and the roll neither clears on it (the advisory is - /// there) nor holds on it (holding would freeze the fleet exactly as the floors did; the - /// boot-time probe and the keep-the-previous-generation fallback are the safety net). - /// - [Fact] - public void AReleaseWithNoPublishedSurface_IsIndeterminate_ReportedNeitherClearanceNorHold() - { - var published = PublishedRoot(Version, Identity, surface: null); - var landed = Land(ViewPack, ModuleBindingMeshNode(ViewPack)); - - var observation = PublishedBundleCatalogue.Read(published, Version); - Assert.Null(observation.Artifacts.PlatformSurface); - Assert.NotNull(observation.Artifacts.PlatformSurfaceDetail); - Assert.Contains(ModulePlatformSurface.PublishedFileName, observation.Artifacts.PlatformSurfaceDetail); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Views", "Views", HasContent: false) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]); - - Assert.True(verdict.IsUpdatable, "an unmeasurable link is not a hold"); - Assert.False(verdict.IsIndeterminate, "…and not the store-unreadable Indeterminate that holds"); - Assert.Empty(verdict.Blockers); - var advisory = Assert.Single(verdict.Advisories); - Assert.StartsWith("Views:", advisory, StringComparison.Ordinal); - Assert.Contains(ViewPack, advisory, StringComparison.Ordinal); - Assert.Contains("could not be determined", advisory, StringComparison.Ordinal); - Assert.Contains(ModulePlatformSurface.PublishedFileName, advisory, StringComparison.Ordinal); - Assert.Contains("not a hold", advisory, StringComparison.Ordinal); - } - - /// A published document that does not PARSE is the same answer — reported, with the - /// parse failure named, never an empty surface that would refuse every module. - [Fact] - public void AnUnreadablePublishedSurface_IsIndeterminate_NeverAnEmptySurface() - { - var published = PublishedRoot(Version, Identity, surface: "{ this is not a surface"); - var landed = Land(ViewPack, ModuleBindingMeshNode(ViewPack)); - - var observation = PublishedBundleCatalogue.Read(published, Version); - Assert.Null(observation.Artifacts.PlatformSurface); - Assert.Contains("could not be read", observation.Artifacts.PlatformSurfaceDetail!); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Views", "Views", HasContent: false) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Empty(verdict.Blockers); - Assert.Contains(verdict.Advisories, a => a.Contains("could not be read", StringComparison.Ordinal)); - } - - /// Landed bytes that cannot be read are the probe's own Indeterminate — reported - /// with the probe's detail, not a hold (a boot that cannot read them does not load them). - [Fact] - public void UnreadableLandedBytes_AreReported_NotHeld() - { - var published = PublishedRoot(Version, Identity, surface: OlderPlatformSurface(Identity)); - var landed = Land("MeshWeaver.Test.Garbage", [0x4D, 0x5A, 0x90]); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("Garbage", "Garbage", HasContent: false) - { ModuleName = "MeshWeaver.Test.Garbage", LandedModulePath = landed }, - ]); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - var advisory = Assert.Single(verdict.Advisories); - Assert.Contains("could not be determined", advisory, StringComparison.Ordinal); - Assert.Contains("BadImageFormatException", advisory, StringComparison.Ordinal); - } - - // ── the missing bake: a cost, not a hold ─────────────────────────────────────────────────── - - /// - /// 🚨 (b) of the 2026-09-07 hold. A content package with no sealed bake for the target is - /// NAMED ("would recompile at boot: …") and the roll proceeds: the compile is what every PR of - /// that content already proves green. Only a Modules:RequirePrebuilt instance — where - /// the seeder refuses the compile and the type would park — keeps it as the hold it used to - /// be everywhere, and the same fixture shows both arms. - /// - [Fact] - public void AMissingContentBake_IsACostTheVerdictNames_AndAHoldOnlyUnderRequirePrebuilt() - { - var published = PublishedRoot(Version, Identity, surface: OlderPlatformSurface(Identity)); - RequiredPackage[] required = - [ - new("Widget", "Widget"), - new("Education", "Education"), - new("Crm", "Crm"), - ]; - - var verdict = Judge(published, Version, required); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Empty(verdict.Blockers); - Assert.Equal(["Education", "Crm"], verdict.BootCompiles); - var cost = Assert.Single(verdict.Advisories); - Assert.Contains("would recompile at boot", cost, StringComparison.Ordinal); - Assert.Contains("Education, Crm", cost, StringComparison.Ordinal); - Assert.Contains(Identity, cost, StringComparison.Ordinal); - // The kind still SAYS what it is — the tab and the API read it — only its weight changed. - var education = Assert.Single(verdict.Packages, p => p.Package == "Education"); - Assert.Equal(PackageAvailabilityKind.ContentBakeMissing, education.Kind); - Assert.True(education.IsAdvisory); - Assert.True(education.IsAvailable); - Assert.Contains("recompile it at boot", education.Reason!, StringComparison.Ordinal); - - // The strict arm: the same observation, under the opt-in policy, is the hold it was. - var strict = Judge(published, Version, required, new ReleaseGatePolicy(RequirePrebuilt: true)); - Assert.False(strict.IsUpdatable); - Assert.Equal( - ["Education", "Crm"], - strict.Blockers.Select(b => b.Package).ToArray()); - Assert.All(strict.Blockers, b => - { - Assert.Equal(PackageAvailabilityKind.ContentBakeMissing, b.Kind); - Assert.False(b.IsAdvisory); - Assert.Contains(PrebuiltAssemblySeederKey, b.Reason!, StringComparison.Ordinal); - }); - Assert.Empty(strict.BootCompiles); - } - - private const string PrebuiltAssemblySeederKey = "Modules:RequirePrebuilt"; - - /// The unloadable module is not shadowed by a content advisory on the SAME package — - /// a mixed package (content plus a module, the SocialMedia shape) holds on its module. - [Fact] - public void AMixedPackage_HoldsOnItsUnloadableModule_NotOnItsMissingBake() - { - var published = PublishedRoot(Version, Identity, surface: OlderPlatformSurface(Identity)); - var landed = Land(ViewPack, ModuleBindingMeshNode(ViewPack)); - - var verdict = Judge(published, Version, - [ - new RequiredPackage("SocialMedia", "SocialMedia", HasContent: true) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]); - - Assert.False(verdict.IsUpdatable); - Assert.Equal(PackageAvailabilityKind.ModuleUnloadable, Assert.Single(verdict.Blockers).Kind); - Assert.Empty(verdict.BootCompiles); - } - - // ── the walk ──────────────────────────────────────────────────────────────────────────────── - - /// - /// 🚨 Until policy platform-rolls-independently-of-packages - /// walked past a release with an UNLOADABLE - /// module. An unloadable module is a statement about a PACKAGE, and the platform rolls - /// independently of its packages (policy platform-rolls-independently-of-packages): the - /// newest is selected after exactly one verdict, and the - /// module that will not load there is NAMED on the advisories — it keeps its previous - /// generation and catches up on its own. - /// - [Fact] - public async Task AnUnloadableModuleIsAnAdvisory_TheNewestIsSelected() - { - var published = PublishedRoot(Version, Identity, surface: OlderPlatformSurface(Identity)); - AddRelease(published, OlderVersion, OlderIdentity, surface: ThisPlatformSurface(OlderIdentity), bundles: []); - var landed = Land(ViewPack, ModuleBindingMeshNode(ViewPack)); - RequiredPackage[] required = - [ - new("Widget", "Widget"), - new RequiredPackage("Views", "Views", HasContent: false) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]; - var asked = ImmutableList.CreateBuilder(); - - var outcome = await RollSelection.Select( - new RollSelectionInputs( - "memex", "3.0.0-ci.8009", [Version, OlderVersion], - PluginInventory.Of(required, "the install records")), - version => - { - asked.Add(version); - return Observable.Return(Judge(published, version, required)); - }) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Assert.Equal(RollSelectionKind.Update, outcome.Kind); - Assert.Equal(Version, outcome.SelectedVersion); - Assert.Equal([Version], asked.ToImmutable()); - Assert.Empty(outcome.Declined); - Assert.Contains(outcome.Advisories, a => a.Contains("independently of packages", StringComparison.Ordinal) - && a.Contains(ViewPack, StringComparison.Ordinal)); - Assert.Contains(ViewPack, outcome.Summary, StringComparison.Ordinal); - } - - /// "No complete release" — every candidate's surface lacks the type — used to stay put. - /// Under policy platform-rolls-independently-of-packages the newest is selected and the - /// module is NAMED as the package that does not - /// follow the platform. - [Fact] - public async Task WhenEveryCandidateCannotLoadTheModule_TheNewestIsSelected_AndTheModuleIsNamed() - { - var published = PublishedRoot(Version, Identity, surface: OlderPlatformSurface(Identity)); - AddRelease(published, OlderVersion, OlderIdentity, surface: OlderPlatformSurface(OlderIdentity), bundles: ["Widget"]); - var landed = Land(ViewPack, ModuleBindingMeshNode(ViewPack)); - RequiredPackage[] required = - [ - new("Widget", "Widget"), - new RequiredPackage("Views", "Views", HasContent: false) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]; - - var outcome = await RollSelection.Select( - new RollSelectionInputs( - "memex", "3.0.0-ci.8009", [Version, OlderVersion], - PluginInventory.Of(required, "the install records")), - version => Observable.Return(Judge(published, version, required))) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Assert.Equal(RollSelectionKind.Update, outcome.Kind); - Assert.Equal(Version, outcome.SelectedVersion); - Assert.False(outcome.IsIndeterminate); - Assert.Empty(outcome.Declined); - Assert.Contains(ViewPack, outcome.Summary, StringComparison.Ordinal); - Assert.Contains("3.0.0-ci.8009", outcome.Summary, StringComparison.Ordinal); - Assert.Contains(outcome.Advisories, a => a.Contains(ViewPack, StringComparison.Ordinal)); - } - - /// A release whose surface is unpublished is walked PAST nothing and INTO nothing on - /// that account: with no unloadable module measured, the newest is selected, and the outcome - /// carries the unmeasured module as an advisory. - [Fact] - public async Task AReleaseWithNoSurface_IsSelectedWithTheUnmeasuredModuleReported() - { - var published = PublishedRoot(Version, Identity, surface: null); - var landed = Land(ViewPack, ModuleBindingMeshNode(ViewPack)); - RequiredPackage[] required = - [ - new("Widget", "Widget"), - new RequiredPackage("Views", "Views", HasContent: false) - { ModuleName = ViewPack, LandedModulePath = landed }, - ]; - - var outcome = await RollSelection.Select( - new RollSelectionInputs( - "memex", "3.0.0-ci.8009", [Version], - PluginInventory.Of(required, "the install records")), - version => Observable.Return(Judge(published, version, required))) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Assert.Equal(RollSelectionKind.Update, outcome.Kind); - Assert.Equal(Version, outcome.SelectedVersion); - Assert.Contains(outcome.Advisories, a => a.Contains(ViewPack, StringComparison.Ordinal) - && a.Contains("could not be determined", StringComparison.Ordinal)); - } - - // ── driving the SHARED predicate ──────────────────────────────────────────────────────────── - - /// The exact composition ReleaseAvailabilityService.Judge runs: read, measure, - /// decide. - private static UpdatabilityVerdict Judge( - string published, string version, IEnumerable required, - ReleaseGatePolicy? policy = null) - { - var packages = required.ToImmutableArray(); - var observation = PublishedBundleCatalogue.Read(published, version); - var measured = ModuleLinkObservation.Measure(observation.Artifacts, packages); - return ReleaseAvailability.IsUpdatable( - observation.Target, packages, measured, policy ?? ReleaseGatePolicy.Default); - } - - private static ImmutableDictionary Links( - string published, string version, string landed) - { - var observation = PublishedBundleCatalogue.Read(published, version); - return ModuleLinkObservation.Measure(observation.Artifacts, - [new RequiredPackage("Views", "Views", HasContent: false) { ModuleName = ViewPack, LandedModulePath = landed }]) - .ModuleLinks; - } - - // ── fixture: the published root ───────────────────────────────────────────────────────────── - - /// - /// One release under one identity, laid out as publish-bake-bundles.sh writes it: the - /// marker and the release's own surface (when given), one sealed plugins source with a - /// Widget content bundle, an empty module set (or one declaring - /// ), and _complete last. - /// - private string PublishedRoot( - string version, string identity, string? surface, (string Name, byte[] Bytes)? sealedModule = null, - string? publicationSurface = null) - { - var published = Path.Combine(root, "published"); - Directory.CreateDirectory(Path.Combine(published, PublishedBundleCatalogue.ReleaseMarkerDirectoryName)); - AddRelease(published, version, identity, surface, ["Widget"], sealedModule, publicationSurface); - return published; - } - - /// Records → and that release's - /// OWN surface (_releases/_surface/<version>), as publish-bake-bundles.sh - /// writes them on every run — without touching any publication. - private static void WriteReleaseSurface(string published, string version, string identity, string? surface) - { - var markers = Path.Combine(published, PublishedBundleCatalogue.ReleaseMarkerDirectoryName); - Directory.CreateDirectory(markers); - File.WriteAllText(Path.Combine(markers, version), identity); - if (surface is null) - return; - var surfaces = Path.Combine(markers, PublishedBundleCatalogue.ReleaseSurfaceDirectoryName); - Directory.CreateDirectory(surfaces); - File.WriteAllText(Path.Combine(surfaces, version), surface); - } - - /// The release's OWN surface — the one the gate reads. - /// The surface inside the publication (descriptive; no gate - /// reads it) — a decoy for the cases that prove it is not read. - private static void AddRelease( - string published, string version, string identity, string? surface, - string[] bundles, (string Name, byte[] Bytes)? sealedModule = null, string? publicationSurface = null) - { - WriteReleaseSurface(published, version, identity, surface); - var source = Path.Combine(published, identity, "plugins"); - var moduleDirectory = Path.Combine(source, PublishedBundleCatalogue.ModulesDirectoryName); - Directory.CreateDirectory(moduleDirectory); - - var index = new List(); - if (sealedModule is { } module) - { - WriteZip(Path.Combine(moduleDirectory, "views.module.nupkg"), - (NuGetPackageWriter.ManifestEntry, - Encoding.UTF8.GetBytes($$$"""{"plugin":"Views","module":{"assemblyName":"{{{module.Name}}}"}}""")), - ($"{NuGetPackageWriter.ModuleFolder}/{module.Name}.dll", module.Bytes)); - index.Add("views.module.nupkg"); - } - File.WriteAllLines(Path.Combine(moduleDirectory, PublishedBundleCatalogue.ModulesIndexFileName), index); - - var names = new List(); - foreach (var bundle in bundles) - { - var manifest = new BundleReader.Manifest( - bundle, "1.0", identity, - [ - new BundleReader.AssemblyRef($"{bundle}/Thing", $"{bundle}_Thing.dll", - new Dictionary(StringComparer.Ordinal) - { - ["MeshWeaver.Layout"] = CompiledDependencies.RefAsmScheme + "abc", - }), - ]); - WriteZip(Path.Combine(source, bundle + ".zip"), - (NuGetPackageWriter.ManifestEntry, - JsonSerializer.SerializeToUtf8Bytes(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web)))); - names.Add(bundle + ".zip"); - } - if (publicationSurface is not null) - File.WriteAllText(Path.Combine(source, PublishedBundleCatalogue.PlatformSurfaceFileName), publicationSurface); - File.WriteAllText( - Path.Combine(source, ShippedPrebuiltBundles.CompletionSentinelFileName), - string.Join('\n', names.Order(StringComparer.Ordinal)) + "\n"); - } - - /// The running platform's own surface, as the bake would publish it. - private static string ThisPlatformSurface(string identity) => - ModulePlatformSurface.OfRunningProcess(AppContext.BaseDirectory).ToJson(identity); - - /// A platform three days BEHIND this one: the same surface without MeshNode — - /// the memex-cloud shape of #3538, seen from the other side. - private static string OlderPlatformSurface(string identity) => - ModulePlatformSurfaceJsonTest.Without( - ThisPlatformSurface(identity), ContractAssembly, typeof(MeshNode).FullName!); - - /// The same document with one assembly's recorded VERSION replaced — a platform - /// carrying every type name at another manifest version (#4083's shape, seen from the gate). - private static string WithIdentityVersion(string json, string assemblyName, string version) - { - var node = JsonNode.Parse(json)!.AsObject(); - node["identities"]![assemblyName]!["version"] = version; - return node.ToJsonString(); - } - - // ── fixture: the landed generation ────────────────────────────────────────────────────────── - - /// Lands a module the way the landing service does: its own generation directory - /// under modules/, the entry DLL inside — the path boot resolves and loads. - private string Land(string name, byte[] bytes) - { - var generation = Path.Combine(modules, $"{name}@{Guid.NewGuid():N}"); - Directory.CreateDirectory(generation); - var path = Path.Combine(generation, name + ".dll"); - File.WriteAllBytes(path, bytes); - return path; - } - - private static byte[] ModuleBindingMeshNode(string name) => - ModulePlatformSurfaceJsonTest.Emit(name, """ - using MeshWeaver.Mesh; - public static class CodeViews { public static string Show(MeshNode node) => node.Id; } - """); - - private static byte[] ModuleBindingNothingTheTargetLacks(string name) => - ModulePlatformSurfaceJsonTest.Emit(name, """ - using MeshWeaver.Mesh; - public static class PlainViews - { - public static string Name(ModulePlatformSurface surface) => surface.Identity ?? ""; - } - """); - - private static void WriteZip(string path, params (string Entry, byte[] Bytes)[] entries) - { - using var file = File.Create(path); - using var zip = new ZipArchive(file, ZipArchiveMode.Create); - foreach (var (entry, bytes) in entries) - { - using var stream = zip.CreateEntry(entry).Open(); - stream.Write(bytes); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/ReleaseSchemaMarkerTest.cs b/test/Memex.Portal.Shared.Test/ReleaseSchemaMarkerTest.cs deleted file mode 100644 index 3618a5ed8c..0000000000 --- a/test/Memex.Portal.Shared.Test/ReleaseSchemaMarkerTest.cs +++ /dev/null @@ -1,149 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Linq; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 ExpectedDbVersion is readable from outside an image (#4764 (b3); policy -/// db-migration-planned) — and the reading is shaped so that a release published BEFORE the -/// field, a portal deployed BEFORE the field, and a garbled file can never be taken for a number. -/// -/// What is pinned: the file lives in a SUBDIRECTORY of _releases (so every existing -/// reader, which enumerates the marker directory's FILES and reads each body as a framework identity, -/// never sees it); absent/garbled is UNKNOWN, never zero; and the decision the two numbers feed — -/// — moves only -/// the two outcomes that establish nothing, in opposite directions. -/// -public class ReleaseSchemaMarkerTest : IDisposable -{ - private readonly string root = Path.Combine(Path.GetTempPath(), "rsm-" + Guid.NewGuid().ToString("N")); - - public ReleaseSchemaMarkerTest() => Directory.CreateDirectory(root); - - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch (IOException) { /* a temp directory the OS will reap; nothing under test depends on it */ } - } - - private void Publish(string version, string identity, string? schema) - { - var markers = Path.Combine(root, SealedPublicationIndex.ReleaseMarkerDirectoryName); - Directory.CreateDirectory(markers); - File.WriteAllText(Path.Combine(markers, version), identity + "\n"); - if (schema is null) - return; - var dir = Path.Combine(markers, ReleaseSchemaMarker.DirectoryName); - Directory.CreateDirectory(dir); - File.WriteAllText(Path.Combine(dir, version), schema + "\n"); - } - - [Fact] - public void APublishedNumberIsRead_AndBuildMetadataIsIgnored() - { - Publish("3.0.0-ci.9332", "sabc", "58"); - - Assert.Equal(58, ReleaseSchemaMarker.Read(root, "3.0.0-ci.9332")); - Assert.Equal(58, ReleaseSchemaMarker.Read(root, "3.0.0-ci.9332+39f49494eb")); - } - - [Fact] - public void AnAbsentOrGarbledNumberIsUnknown_NeverZero() - { - Publish("3.0.0-ci.9218", "sabc", null); - Publish("3.0.0-ci.9219", "sabc", "fifty-eight"); - Publish("3.0.0-ci.9220", "sabc", "0"); - - Assert.Null(ReleaseSchemaMarker.Read(root, "3.0.0-ci.9218")); - Assert.Null(ReleaseSchemaMarker.Read(root, "3.0.0-ci.9219")); - Assert.Null(ReleaseSchemaMarker.Read(root, "3.0.0-ci.9220")); - Assert.Null(ReleaseSchemaMarker.Read(root, "3.0.0-ci.1")); - Assert.Null(ReleaseSchemaMarker.Read(null, "3.0.0-ci.9218")); - } - - [Fact] - public void AVersionThatCouldEscapeTheDirectoryIsNeverAPath() - { - Assert.Null(ReleaseSchemaMarker.PathFor(root, "../etc/passwd")); - Assert.Null(ReleaseSchemaMarker.PathFor(root, "..")); - Assert.Null(ReleaseSchemaMarker.PathFor(root, "")); - } - - /// - /// 🚨 The reason the number lives in a SUBDIRECTORY: every reader of _releases that is - /// already deployed enumerates its FILES as versions and reads each body as an identity. A schema - /// file beside the markers would be listed as a release named "3.0.0-ci.9332.db" (or its body - /// "58" read as a framework identity) by every portal that predates this field. - /// - [Fact] - public void TheSchemaFileIsInvisibleToEveryExistingMarkerReader() - { - Publish("3.0.0-ci.9332", "sabc", "58"); - - var releases = PublishedBundleCatalogue.PublishedReleases(root); - Assert.Null(releases.Refusal); - Assert.Equal(new[] { "3.0.0-ci.9332" }, releases.Versions.ToArray()); - Assert.Equal("3.0.0-ci.9332", SealedPublicationIndex.ReleasesOf(root)["sabc"]); - } - - [Fact] - public void TheStepComparesTheRunningReleaseWithTheTarget() - { - Publish("3.0.0-ci.9218", "sold", "57"); - Publish("3.0.0-ci.9332", "snew", "58"); - Publish("3.0.0-ci.9333", "snew", "58"); - Publish("3.0.0-ci.9000", "sancient", null); - - var bump = ReleaseSchemaMarker.Step(root, "3.0.0-ci.9218+39f49494", "3.0.0-ci.9332"); - Assert.True(bump.MovesSchema); - Assert.False(bump.KeepsSchema); - Assert.Contains("expects db_version 58", bump.Describe()); - Assert.Contains("expects 57", bump.Describe()); - - var same = ReleaseSchemaMarker.Step(root, "3.0.0-ci.9332", "3.0.0-ci.9333"); - Assert.True(same.KeepsSchema); - Assert.False(same.MovesSchema); - - var unknown = ReleaseSchemaMarker.Step(root, "3.0.0-ci.9000", "3.0.0-ci.9332"); - Assert.False(unknown.Known); - Assert.False(unknown.MovesSchema); - Assert.False(unknown.KeepsSchema); - } - - /// - /// The decision the numbers feed. An UNKNOWN step must leave the blanket rule exactly as it was — - /// otherwise every release published before this field would change behaviour on the day it - /// shipped — and a known step moves only NotSupported (refused across a bump) and Forbidden - /// (allowed when nothing moves). Completed/Failed/TimedOut are decided by what the migration DID, - /// which no published number can overrule. - /// - [Theory] - [InlineData(MigrationRunOutcome.Completed, null, null, true)] - [InlineData(MigrationRunOutcome.Completed, 57, 58, true)] - [InlineData(MigrationRunOutcome.Failed, 58, 58, false)] - [InlineData(MigrationRunOutcome.TimedOut, 58, 58, false)] - [InlineData(MigrationRunOutcome.NotSupported, null, null, true)] - [InlineData(MigrationRunOutcome.NotSupported, 58, 58, true)] - [InlineData(MigrationRunOutcome.NotSupported, 57, 58, false)] - [InlineData(MigrationRunOutcome.Forbidden, null, null, false)] - [InlineData(MigrationRunOutcome.Forbidden, 57, 58, false)] - [InlineData(MigrationRunOutcome.Forbidden, 58, 58, true)] - [InlineData(MigrationRunOutcome.Forbidden, 58, 57, true)] - public void TheSharedDecisionMovesOnlyTheOutcomesThatEstablishNothing( - MigrationRunOutcome outcome, int? installed, int? target, bool mayPatch) - { - var step = new ReleaseSchemaStep("3.0.0-ci.1", installed, "3.0.0-ci.2", target); - - Assert.Equal(mayPatch, SelfUpdateVerdict.MayPatchAfter(outcome, step)); - if (installed is null && target is null) - Assert.Equal(SelfUpdateVerdict.MayPatchAfter(outcome), SelfUpdateVerdict.MayPatchAfter(outcome, step)); - } -} diff --git a/test/Memex.Portal.Shared.Test/RepairJsonTest.cs b/test/Memex.Portal.Shared.Test/RepairJsonTest.cs deleted file mode 100644 index dbbcd76532..0000000000 --- a/test/Memex.Portal.Shared.Test/RepairJsonTest.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Data; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// A model-written patch / create payload one closing brace short -/// ({"content":{"policy":"None"}) was refused with "Expected depth to be zero at the end of -/// the JSON payload": MeshOperations.RepairJson only TRIMMED trailing text back to a -/// closer and never appended the closers the model left off. It now closes the containers still open -/// at the end — counted outside string literals, honouring escapes — and accepts the result only if -/// it parses; anything it cannot balance by appending is returned untouched, so the caller reports -/// the ORIGINAL error. -/// -public class RepairJsonTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private static readonly TimeSpan Budget = TestTimeouts.Convergence; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddUpdatePolicyType(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - // ── the repair rules, measured directly ── - - /// A payload short of closers at the END gets exactly the closers its open containers - /// need, innermost first, and the result parses. - /// The payload as the model wrote it. - /// The repaired payload. - [Theory] - [InlineData("""{"content":{"policy":"None"}""", """{"content":{"policy":"None"}}""")] - [InlineData("""{"a":{"b":{"c":1""", """{"a":{"b":{"c":1}}}""")] - [InlineData("""[{"id":"a"},{"id":"b","tags":["x","y"]""", """[{"id":"a"},{"id":"b","tags":["x","y"]}]""")] - [InlineData("""{"list":[1,2,[3,4]""", """{"list":[1,2,[3,4]]}""")] - [InlineData("{\"a\":1 \n", "{\"a\":1}")] - public void MissingClosersAtTheEnd_AreAppended(string input, string expected) - { - var repaired = MeshOperations.RepairJson(input); - repaired.Should().Be(expected); - Parses(repaired).Should().BeTrue(); - } - - /// Closers and quotes inside string literals are text: they neither close a container - /// nor end the string when escaped. - /// The payload as the model wrote it. - /// The repaired payload. - [Theory] - // A closer INSIDE a string is text, not structure — it must neither close nor be counted. - [InlineData("""{"text":"a } and ] here","n":{"x":"{["}""", """{"text":"a } and ] here","n":{"x":"{["}}""")] - // An escaped quote does not end the string; an escaped backslash before a quote does. - [InlineData("""{"q":"say \"}\" now","p":{"r":"c:\\"}""", """{"q":"say \"}\" now","p":{"r":"c:\\"}}""")] - public void ClosersInsideStrings_AreIgnored_AndEscapesHonoured(string input, string expected) - { - var repaired = MeshOperations.RepairJson(input); - repaired.Should().Be(expected); - using var doc = JsonDocument.Parse(repaired); - doc.RootElement.EnumerateObject().Should().HaveCount(2, - "the closers inside the string literals must still be inside them after the repair"); - } - - /// A payload that no appended closer and no trimmed tail can make parse is returned - /// byte-identical, so the caller's parse reports the ORIGINAL error. - /// The malformed payload. - [Theory] - // Ends INSIDE a string: the value was truncated — closing it would invent content. - [InlineData("""{"content":{"text":"half a sent""")] - // A closer of the wrong kind: structure is wrong in the middle, not just short at the end. - [InlineData("""{"a":[1,2}""")] - // A trailing comma: appending closers would still not parse. - [InlineData("""{"a":1,""")] - // Not JSON at all. - [InlineData("""{name: unquoted""")] - // A missing value. - [InlineData("""{"a":""")] - public void GenuinelyMalformedInput_IsReturnedUntouched(string input) - { - MeshOperations.RepairJson(input).Should().Be(input, - "a payload that cannot be balanced by appending closers keeps its original text, so the " - + "caller's parse reports the original error rather than one about a guessed repair"); - } - - /// Text that already parses is never touched. - /// A valid JSON document (or the empty string). - [Theory] - [InlineData("""{"a":1}""")] - [InlineData("""[1,{"b":"}"}]""")] - [InlineData("\"just a string\"")] - [InlineData("")] - public void ValidJson_IsNeverChanged(string input) => - MeshOperations.RepairJson(input).Should().Be(input); - - /// Text after a complete value — a stray fence — is trimmed back to the longest prefix - /// that parses. - [Fact] - public void TrailingJunkAfterACompleteValue_IsStillTrimmed() => - MeshOperations.RepairJson("{\"a\":{\"b\":1}}\n```").Should().Be("{\"a\":{\"b\":1}}"); - - /// - /// A SURPLUS closer at the very end is trailing junk too: the closer step declines it (more - /// closers than openers), and the trim leg then returns the complete value in front of it. This - /// is NOT the "returned untouched" row — a closer of the wrong kind in the MIDDLE is - /// (, {"a":[1,2}). - /// - /// A complete value followed by one or more closers too many. - /// The complete value. - [Theory] - [InlineData("""{"a":1}}""", """{"a":1}""")] - [InlineData("""[1,2]]""", """[1,2]""")] - [InlineData("""{"a":{"b":1}}}]""", """{"a":{"b":1}}""")] - public void ASurplusCloserAtTheEnd_IsTrimmedAsTrailingJunk(string input, string expected) => - MeshOperations.RepairJson(input).Should().Be(expected); - - // ── end to end, through the tool the model calls ── - - /// End to end: the patch tool applies a payload one closing brace short. - [Fact] - public async Task Patch_OneClosingBraceShort_IsApplied() - { - var ct = TestContext.Current.CancellationToken; - var before = await Seed(UpdatePolicyKind.Stable, ct); - - var result = await Run(new MeshOperations(Mesh).Patch( - UpdatePolicyNodeType.NodePath, """{"content":{"policy":"None"}"""), ct); - - Output.WriteLine($"Patch tool returned: {result}"); - result.Should().StartWith("Patched:"); - var after = await ReadNode(ct, n => UpdatePolicyNodeType.Parse(n, Mesh.JsonSerializerOptions).Policy - == UpdatePolicyKind.None); - after.Version.Should().BeGreaterThan(before.Version); - } - - /// End to end: a payload truncated inside a string is refused with the parser's own - /// message for the ORIGINAL text, and the node is not written. - [Fact] - public async Task Patch_TruncatedInsideAString_KeepsTheOriginalError_AndWritesNothing() - { - var ct = TestContext.Current.CancellationToken; - var before = await Seed(UpdatePolicyKind.Stable, ct); - const string truncated = """{"content":{"policy":"No"""; - - string originalError; - try - { - // The same parser Patch uses, so the message is byte-identical. - _ = System.Text.Json.Nodes.JsonNode.Parse(truncated); - throw new InvalidOperationException("precondition: the payload must not parse"); - } - catch (JsonException ex) - { - originalError = ex.Message; - } - - var result = await Run(new MeshOperations(Mesh).Patch(UpdatePolicyNodeType.NodePath, truncated), ct); - - Output.WriteLine($"Patch tool returned: {result}"); - result.Should().StartWith("Invalid JSON:"); - result.Should().Contain(originalError, "the error describes the payload the model SENT"); - (await ReadNode(ct)).Version.Should().Be(before.Version); - } - - // ── helpers ── - - private static bool Parses(string json) - { - try - { - JsonDocument.Parse(json).Dispose(); - return true; - } - catch (JsonException) - { - return false; - } - } - - private static Task Run(IObservable op, CancellationToken ct) => - op.FirstAsync().Timeout(Budget).Await(ct); - - private async Task Seed(UpdatePolicyKind policy, CancellationToken ct) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = policy }, - }; - await Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(ct); - return await ReadNode(ct); - } - - private Task ReadNode(CancellationToken ct, Func? until = null) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null && (until is null || until(node))) - .Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(ct); -} diff --git a/test/Memex.Portal.Shared.Test/RetiredAndUnknownPlanTest.cs b/test/Memex.Portal.Shared.Test/RetiredAndUnknownPlanTest.cs deleted file mode 100644 index 666de53859..0000000000 --- a/test/Memex.Portal.Shared.Test/RetiredAndUnknownPlanTest.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System; -using System.Collections.Immutable; -using MeshWeaver.Mesh.Security; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A RETIRED plan id resolves to its successor, explicitly; an UNKNOWN plan id stays unknown and -/// is named (#5894). -/// -/// Measured on the public registry: a dedicated client instance stored the plan sme. -/// The plan had been renamed to dedicated in the licence documents only and its tier node -/// Admin/Tiers/sme deleted, so the ladder no longer knew sme; -/// decided it at the baseline, and the instance's ledger -/// listed 18 refused packages — Mail and Teams among them — each as "this instance is on free". -/// Nothing said the stored plan was unknown. -/// -public class RetiredAndUnknownPlanTest -{ - private static readonly DateTimeOffset Now = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero); - - /// The ladder as the registry reads it AFTER Admin/Tiers/sme was deleted: - /// free 0 · personal 10 · pro 20 · dedicated 25 (all-access) · enterprise 30. - private static readonly PlanTierRanks Ladder = PlanTierRanks.From(new[] - { - ("free", 0, false), ("personal", 10, false), ("pro", 20, false), - ("dedicated", 25, true), ("enterprise", 30, false), - }); - - [Fact] - public void TheRetiredSmeId_ResolvesToDedicated_AndCoversWhatDedicatedCovers() - { - Assert.Equal("dedicated", PlanTierRanks.Canonical("sme")); - Assert.Equal("dedicated", PlanTierRanks.Canonical(" SME ")); - Assert.Equal("dedicated", PlanTierRanks.SuccessorOf("sme")); - - // The defect: an instance on `sme` was refused every paid package. Dedicated is all-access. - Assert.True(Ladder.CoversInstance("sme", "enterprise"), - "an instance still storing the retired id stands on its successor, which is all-access"); - Assert.False(Ladder.IsUnknownPlan("sme"), "a retired id is not unknown — it has a successor"); - } - - [Fact] - public void ACurrentId_HasNoSuccessor_AndCanonicalLeavesItAlone() - { - foreach (var id in new[] { "free", "personal", "pro", "dedicated", "enterprise" }) - { - Assert.Null(PlanTierRanks.SuccessorOf(id)); - Assert.Equal(id, PlanTierRanks.Canonical(id)); - } - } - - [Fact] - public void ATierNodeStillCarryingTheRetiredId_DoesNotRedefineItsSuccessor() - { - // `sme` enumerated AFTER dedicated with a different rank and no all-access flag: if the - // ladder folded it onto `dedicated`, dedicated would lose its all-access standing. - var ladder = PlanTierRanks.From(new[] { ("dedicated", 25, true), ("sme", 15, false) }); - - Assert.Equal(25, ladder.RankOf("dedicated")); - Assert.True(ladder.IsAllAccess("dedicated")); - Assert.Single(ladder.Ids); - } - - [Fact] - public void AnUnknownPlan_IsNamedUnknown_AndStillDecidesAtTheBaseline() - { - Assert.True(Ladder.IsUnknownPlan("gold")); - // Fail closed, unchanged: an unknown plan never widens a licence. - Assert.False(Ladder.CoversInstance("gold", "pro")); - Assert.True(Ladder.CoversInstance("gold", "free")); - - // The controls: none of these is unknown. - Assert.False(Ladder.IsUnknownPlan(null)); - Assert.False(Ladder.IsUnknownPlan("")); - Assert.False(Ladder.IsUnknownPlan("free")); - Assert.False(Ladder.IsUnknownPlan("PRO")); - // A registry with no ladder at all: free is still the baseline, not unknown. - Assert.False(PlanTierRanks.Empty.IsUnknownPlan("free")); - } - - [Fact] - public void ARefusalForAnUnknownInstancePlan_NamesTheStoredPlan_NotFree() - { - var grant = new PluginGrant - { - InstanceId = "client", - Entries = ImmutableList.Create(new PluginGrantEntry { Source = "Plugins", PackageId = PluginGrantEntry.AllPackages }), - }; - - // Before #5894 this read "free" — the sentence then said "this instance is on free" for an - // instance whose record says something else entirely. - Assert.Equal("gold", grant.TierRefusal("Plugins", "Mail", "pro", Ladder, "gold", Now)); - // A known plan is reported as before. - Assert.Equal("free", grant.TierRefusal("Plugins", "Mail", "pro", Ladder, "free", Now)); - Assert.Equal("free", grant.TierRefusal("Plugins", "Mail", "pro", Ladder, null, Now)); - // The retired id is covered by its all-access successor: nothing is refused at all. - Assert.Null(grant.TierRefusal("Plugins", "Mail", "enterprise", Ladder, "sme", Now)); - } - - [Fact] - public void AnUnknownCap_IsStillReportedAsTheBaseline() - { - // The cap case is unchanged on purpose: a cap the ladder does not know is neither the - // instance's plan nor an upgrade target, so the sentence names the level that decided. - var grant = new PluginGrant - { - InstanceId = "client", - Entries = ImmutableList.Create(new PluginGrantEntry - { Source = "Plugins", PackageId = PluginGrantEntry.AllPackages, Tier = "gold" }), - }; - - Assert.Equal("free", grant.TierRefusal("Plugins", "Mail", "pro", Ladder, "pro", Now)); - } - - [Fact] - public void TheDeletionGuard_CountsABlankPlanAsTheBaseline_AndTheRetiredIdAsItsSuccessor() - { - Assert.True(TierInUseDeletionGuard.StandsOn(new MeshWeaverInstance { InstanceId = "a" }, "free")); - Assert.True(TierInUseDeletionGuard.StandsOn(new MeshWeaverInstance { InstanceId = "b", Plan = "sme" }, "dedicated")); - Assert.False(TierInUseDeletionGuard.StandsOn(new MeshWeaverInstance { InstanceId = "c", Plan = "pro" }, "free")); - } -} diff --git a/test/Memex.Portal.Shared.Test/ReturnUrlPolicyTest.cs b/test/Memex.Portal.Shared.Test/ReturnUrlPolicyTest.cs deleted file mode 100644 index b12a3685b2..0000000000 --- a/test/Memex.Portal.Shared.Test/ReturnUrlPolicyTest.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Memex.Portal.Shared.Authentication; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -public class ReturnUrlPolicyTest -{ - [Theory] - [InlineData(null, "/")] - [InlineData("", "/")] - [InlineData(" ", "/")] - [InlineData("/", "/")] - [InlineData("/Doc/Architecture", "/Doc/Architecture")] - [InlineData("/a?b=c#d", "/a?b=c#d")] - public void Local_paths_are_honoured_and_empty_falls_back(string? input, string expected) - => Assert.Equal(expected, ReturnUrlPolicy.Sanitize(input)); - - [Theory] - [InlineData("https://evil.example/phish")] - [InlineData("http://evil.example")] - [InlineData("//evil.example/protocol-relative")] - [InlineData("/\\evil.example/backslash-variant")] - [InlineData("evil.example")] - [InlineData("javascript:alert(1)")] - public void Anything_not_local_falls_back_to_root(string input) - => Assert.Equal("/", ReturnUrlPolicy.Sanitize(input)); -} diff --git a/test/Memex.Portal.Shared.Test/RetypedRootRecycleNeedsAJobTest.cs b/test/Memex.Portal.Shared.Test/RetypedRootRecycleNeedsAJobTest.cs deleted file mode 100644 index b10dcdf7d3..0000000000 --- a/test/Memex.Portal.Shared.Test/RetypedRootRecycleNeedsAJobTest.cs +++ /dev/null @@ -1,104 +0,0 @@ -#pragma warning disable CS1591 - -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 #3510 — a teardown that cannot accomplish anything must not happen. -/// -/// PackageInstaller recycles a package's root after an install so the hub that comes -/// back binds the package's OWN configuration rather than the fallback. That is worth a teardown -/// only when the root's in-package NodeType was actually rebuilt — and the two -/// RequestReleases waves the recycle sits between are both gated on -/// result.Written > 0 for exactly that reason. The recycle alone ran unconditionally. -/// -/// Measured on CD run 34190841613 (2026-09-08): package Video logged -/// 0 written, 21 unchanged and recycled its root anyway; Chess was recycled TWICE per -/// bake. Each teardown killed the correlated work in flight beneath the root — its own -/// PluginGating reconcile — which the quiesce could not answer and force-cancelled at its -/// 2 s bound, surfacing to the issuer as HubDisposedBeforeResponseException. -/// -/// 🚨 What this does NOT claim. It does not fix the case where an install DID write. -/// A recycle with a real job still tears down a root whose background pipeline is issuing -/// correlated requests, and a hub that keeps accepting work while quiescing is its own defect. -/// This removes only the class where the teardown was provably pointless, and it removes it by -/// making the recycle agree with its neighbours rather than by widening any bound. -/// -/// 🚨 It lives HERE, next to the self-update suites, rather than beside the installer: the -/// rule is an internal of MeshWeaver.PluginCatalog, and this is the only test project -/// in THIS repository that both references that assembly and appears in its -/// InternalsVisibleTo list (MeshWeaver.PluginCatalog.Test lives in -/// MeshWeaver.Plugins). Moving the assembly's tests is a bigger change than this fix. -/// -public class RetypedRootRecycleNeedsAJobTest -{ - /// - /// 🚨 THE REGRESSION CASE. A re-install that changed nothing rebuilt nothing, so the recycle - /// has nothing to rebind to — and the sentence has to SAY that, because a silent skip and a - /// skip nobody chose read identically in a log. - /// - [Fact] - public void AnInstallThatWroteNothing_DoesNotRecycleAndSaysWhy() - { - var reason = PackageInstaller.RecycleDeclineReason("Video", written: 0); - - Assert.NotNull(reason); - Assert.Contains("wrote nothing", reason); - Assert.Contains("not rebuilt", reason); - Assert.Contains("cancel the work in flight", reason); - } - - /// - /// 🚨 THE CONTROL. An install that wrote SOMETHING did rebuild the in-package type, and the - /// recycle is the whole point of the ordering it sits in (#1732). Without this case a - /// predicate that declined every recycle would satisfy the test above while silently - /// re-breaking the binding the recycle exists to fix. - /// - [Fact] - public void AnInstallThatWroteSomething_StillRecycles() - => Assert.Null(PackageInstaller.RecycleDeclineReason("Video", written: 1)); - - /// - /// No retyped root means the placeholder dance never ran, so there is no binding to replace - /// and the caller returns before it would log anything. Null — never a decline SENTENCE, which - /// would report a skip that is not a decision. - /// - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void NoRetypedRoot_IsNotADecline(string? rootPath) - { - Assert.Null(PackageInstaller.RecycleDeclineReason(rootPath, written: 0)); - Assert.Null(PackageInstaller.RecycleDeclineReason(rootPath, written: 5)); - } - - /// - /// 🚨 The recycle that DOES happen must announce itself IN THE VICTIM'S OWN LOG (#3510). - /// - /// The decline above is announced by the installer, to whoever is reading the install. - /// The recycle is read by somebody else entirely — whoever is looking at the root's - /// [QUIESCE-START], or at one of the per-node children the cascade takes with it, which - /// is where #3510's stranded writes were owed. That reader had nothing: attributing CD 7950's - /// Hosting took a full read of PackageInstaller plus an ordering argument, because - /// every candidate recycler announces itself at Information and none of them announced itself - /// where the teardown was visible. - /// - /// The sentence must name the DISCRIMINATOR the issue settled on, verbatim: "a root - /// recycling under a reconcile is usually benign … the discriminator is not the count — it is - /// whether the recycled root is the package currently installing". A reason saying only - /// "recycling a root" would satisfy a weaker test and leave the next reader exactly where #3510 - /// left them. - /// - [Fact] - public void TheRecycleThatProceeds_NamesTheInstallItIsRunningUnder() - { - var reason = PackageInstaller.RetypedRootRecycleReason("Hosting"); - - Assert.Contains("Hosting", reason); - Assert.Contains("SettleRetypedRoot", reason); - Assert.Contains("WHILE INSTALLING", reason); - } -} diff --git a/test/Memex.Portal.Shared.Test/RollGateReadinessOnlyTest.cs b/test/Memex.Portal.Shared.Test/RollGateReadinessOnlyTest.cs deleted file mode 100644 index 659ab60df1..0000000000 --- a/test/Memex.Portal.Shared.Test/RollGateReadinessOnlyTest.cs +++ /dev/null @@ -1,360 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using MeshWeaver.Hosting; -using MeshWeaver.Mesh; -using Memex.Portal.ServiceDefaults; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A roll gate — the NodeType bake gate, and the required-modules check — can NEVER fail -/// the startup probe (policies bake-gate-readiness-only, MeshWeaver#5544, and -/// required-modules-readiness-only). -/// -/// What went wrong. The host registered nodetype_bake untagged, so it landed on -/// /health, and /health is the startup probe. While the gate refused, the probe never -/// recorded a success and the kubelet killed every container at the end of its budget -/// (10 s × 1080 on memex, three hours), then restarted it into the same verdict. A restarted -/// pod of the PREVIOUS image had to pass the same probe and could not either, so the gate's own -/// safety net — "the previous image keeps serving" — was gone, and memex.systemorph.com was down -/// from 20:54Z to 04:07Z on 2026-09-25/26 (Doc/Architecture/TheBakeGateOnlyStallsARoll). -/// -/// The rule this pins. A roll gate's verdict is read by readiness alone: the pod stays -/// alive and out of the Service, the roll stalls, nothing is killed. The check is registered here -/// EXACTLY as the host registers it today — by name, with no tag — so the test proves the rule -/// holds without the host's cooperation. It drives the REAL -/// + -/// composition over a TestServer, on the probe PATHS the chart actually ships, for the reason -/// gives: a restatement of the wiring would agree with itself -/// while the deployment probed something else. -/// -public class RollGateReadinessOnlyTest -{ - private const string Refusal = - "NodeType bake regressed on this image — refusing readiness so the rollout stalls with the " - + "previous image still serving. 1 NodeType(s) regressed on this image: Demo/Type"; - - /// - /// 🚨 The invariant. A refusing bake gate leaves the startup probe GREEN (so nothing kills the - /// container), turns readiness RED (so the pod stays out of the Service and the roll stalls), - /// and still PRINTS on /health (so the operator's instrument is unchanged). - /// - [Fact] - public async Task ARefusingBakeGate_HoldsReadiness_AndNeverFailsTheStartupProbe() - { - var probes = ChartProbePaths(); - - var (startup, startupBody) = await ProbeAsync(probes.Startup, BakeGateRefusing); - var (readiness, readinessBody) = await ProbeAsync(probes.Readiness, BakeGateRefusing); - var (liveness, _) = await ProbeAsync(probes.Liveness, BakeGateRefusing); - - Assert.True(startup.StatusCode == HttpStatusCode.OK, - $"the chart's startupProbe path ('{probes.Startup}') answered {(int)startup.StatusCode} " - + $"because {NodeTypeBakeGateExtensions.HealthCheckName} refused. That is the 2026-09-25/26 " - + "outage: the kubelet kills every container at the end of the startup budget and a " - + "restarted pod of the PREVIOUS image cannot pass either, so nothing serves. A roll gate " - + $"must be read by readiness only. Body was:\n{startupBody}"); - // Degraded, not Healthy, in this bare host: the unconditional bake-report census has no - // report to read. Degraded is a 200 — what matters is that line one is not the gate's word. - Assert.False(startupBody.StartsWith("Unhealthy", StringComparison.Ordinal), - "line one of /health is the startup verdict every reader parses; it must agree with the " - + $"status code, which excludes the roll gate. Body was:\n{startupBody}"); - Assert.True(startupBody.Contains($"{NodeTypeBakeGateExtensions.HealthCheckName}: Unhealthy", StringComparison.Ordinal) - && startupBody.Contains("Demo/Type", StringComparison.Ordinal), - "the refusing gate vanished from /health's body. Moving the VERDICT off the startup probe " - + "must not remove the READING: /health is the public, past-RLS instrument operators and " - + $"the fleet watch read the gate on. Body was:\n{startupBody}"); - - Assert.True(readiness.StatusCode == HttpStatusCode.ServiceUnavailable, - $"the chart's readinessProbe path ('{probes.Readiness}') answered " - + $"{(int)readiness.StatusCode} while the bake gate refused, so the gate has NO reader: a " - + "regressed image would go Ready and take traffic. The readiness predicate must include " - + $"the roll gates. Body was:\n{readinessBody}"); - Assert.True(readinessBody.Contains(NodeTypeBakeGateExtensions.HealthCheckName, StringComparison.Ordinal), - "readiness refused without naming the gate, so the kubelet's probe-failure event says " - + $"'Unhealthy' and nothing else. Body was:\n{readinessBody}"); - - Assert.True(liveness.StatusCode == HttpStatusCode.OK, - $"the chart's livenessProbe path ('{probes.Liveness}') answered {(int)liveness.StatusCode} " - + "while the bake gate refused. Restarting the pod is the same container death on a " - + "different probe."); - } - - /// - /// The host cannot put the gate back on a killing probe by TAGGING it: a live tag on the - /// gate is stripped, so liveness never restarts a pod for a roll verdict. - /// - [Fact] - public async Task ABakeGateTaggedLive_StillCannotRestartThePod() - { - var probes = ChartProbePaths(); - - var (liveness, _) = await ProbeAsync(probes.Liveness, services => services.AddHealthChecks() - .AddCheck(NodeTypeBakeGateExtensions.HealthCheckName, - () => HealthCheckResult.Unhealthy(Refusal), [ProbeEndpoints.LiveTag])); - - Assert.True(liveness.StatusCode == HttpStatusCode.OK, - $"the chart's livenessProbe path ('{probes.Liveness}') answered {(int)liveness.StatusCode} " - + "for a bake gate a host had tagged 'live'. A roll gate that restarts the pod kills it " - + "exactly as the startup probe did."); - } - - /// - /// 🚨 A missing required module is a ROLL GATE too (policy - /// required-modules-readiness-only). Registered exactly as the host registers it today — - /// by name, no tag — an Unhealthy required_modules must leave the startup probe and - /// liveness GREEN (nothing kills the container, and a restarted pod of the previous image, whose - /// shelf the same registry outage empties, still boots) and turn readiness RED (the roll stalls - /// with the pod out of the Service). Its reading still prints on /health. - /// - [Fact] - public async Task AMissingRequiredModule_HoldsReadiness_AndNeverFailsTheStartupProbe() - { - var probes = ChartProbePaths(); - - var (startup, startupBody) = await ProbeAsync(probes.Startup, RequiredModulesMissing); - var (readiness, readinessBody) = await ProbeAsync(probes.Readiness, RequiredModulesMissing); - var (liveness, _) = await ProbeAsync(probes.Liveness, RequiredModulesMissing); - - Assert.True(startup.StatusCode == HttpStatusCode.OK, - $"the chart's startupProbe path ('{probes.Startup}') answered {(int)startup.StatusCode} " - + $"because {ProbeEndpoints.RequiredModulesCheckName} was Unhealthy. A missing required " - + "module must stall the roll, never kill the container: on the startup probe a registry " - + "outage kills every image's restarted pods alike. Body was:\n" + startupBody); - Assert.False(startupBody.StartsWith("Unhealthy", StringComparison.Ordinal), - $"line one of /health must agree with the startup status code. Body was:\n{startupBody}"); - Assert.True(startupBody.Contains($"{ProbeEndpoints.RequiredModulesCheckName}: Unhealthy", StringComparison.Ordinal) - && startupBody.Contains("Demo.Module", StringComparison.Ordinal), - "the missing-module reading vanished from /health's body; only the VERDICT moves off the " - + $"startup probe, never the READING. Body was:\n{startupBody}"); - - Assert.True(readiness.StatusCode == HttpStatusCode.ServiceUnavailable, - $"the chart's readinessProbe path ('{probes.Readiness}') answered " - + $"{(int)readiness.StatusCode} with a required module missing, so a pod without it would " - + $"go Ready and take traffic. Body was:\n{readinessBody}"); - Assert.True(readinessBody.Contains(ProbeEndpoints.RequiredModulesCheckName, StringComparison.Ordinal), - $"readiness refused without naming the check. Body was:\n{readinessBody}"); - - Assert.True(liveness.StatusCode == HttpStatusCode.OK, - $"the chart's livenessProbe path ('{probes.Liveness}') answered {(int)liveness.StatusCode} " - + "with a required module missing. Restarting the pod is the same container death on a " - + "different probe."); - } - - /// - /// A host cannot put required_modules on liveness by tagging it live: the tag is - /// stripped exactly as it is from the bake gate. - /// - [Fact] - public async Task ARequiredModulesCheckTaggedLive_StillCannotRestartThePod() - { - var probes = ChartProbePaths(); - - var (liveness, _) = await ProbeAsync(probes.Liveness, services => services.AddHealthChecks() - .AddCheck(ProbeEndpoints.RequiredModulesCheckName, - () => HealthCheckResult.Unhealthy(MissingModule), [ProbeEndpoints.LiveTag])); - - Assert.True(liveness.StatusCode == HttpStatusCode.OK, - $"the chart's livenessProbe path ('{probes.Liveness}') answered {(int)liveness.StatusCode} " - + "for a required_modules check a host had tagged 'live'."); - } - - /// - /// 🚨 A STORE-DELIVERED module that has not arrived yet (RequiredModuleStatus.ExpectedLater) - /// is reported Degraded by the host, and Degraded is a 200 on every probe — so it holds - /// NOTHING, before this change and after it. Only the host's Unhealthy verdicts (a module - /// the image should ship is absent, or a present module did not install against this platform) - /// hold readiness. This pins that boundary so the policy's scope cannot be read wider than it is. - /// - [Fact] - public async Task ARequiredModuleStillExpectedFromTheStore_HoldsNothing() - { - var probes = ChartProbePaths(); - Action expectedLater = services => services.AddHealthChecks() - .AddCheck(ProbeEndpoints.RequiredModulesCheckName, () => HealthCheckResult.Degraded( - "1 required module(s) are store-delivered and not here yet: Demo.Module")); - - var (startup, _) = await ProbeAsync(probes.Startup, expectedLater); - var (readiness, readinessBody) = await ProbeAsync(probes.Readiness, expectedLater); - - Assert.True(startup.StatusCode == HttpStatusCode.OK, - $"startup answered {(int)startup.StatusCode} for a Degraded required_modules"); - Assert.True(readiness.StatusCode == HttpStatusCode.OK, - $"readiness answered {(int)readiness.StatusCode} for a Degraded required_modules. Only the " - + "host's Unhealthy verdicts are meant to hold a roll; a module the store lane has yet to " - + $"deliver never did. Body was:\n{readinessBody}"); - } - - /// - /// 🚨 An unresolvable root forwarder is a ROLL GATE too (MeshWeaver#6847). The - /// module_forwarders check is registered by - /// itself, so this drives THAT registration — its own tags, plus whatever - /// adds — and only swaps its verdict to the incident's - /// Unhealthy one (the real check answers Healthy in a host with no module registry). Startup and - /// liveness stay GREEN (an Unhealthy reading before startup completes must not have the kubelet - /// restart the container), readiness turns RED, and the reading still prints on /health. - /// - [Fact] - public async Task AnUnresolvableRootForwarder_HoldsReadiness_AndNeverFailsTheStartupProbe() - { - var probes = ChartProbePaths(); - - var (startup, startupBody) = await ProbeAsync(probes.Startup, ForwarderUnresolvable); - var (readiness, readinessBody) = await ProbeAsync(probes.Readiness, ForwarderUnresolvable); - var (liveness, _) = await ProbeAsync(probes.Liveness, ForwarderUnresolvable); - - Assert.True(startup.StatusCode == HttpStatusCode.OK, - $"the chart's startupProbe path ('{probes.Startup}') answered {(int)startup.StatusCode} " - + $"because {ModuleForwarderHealthCheck.HealthCheckName} was Unhealthy. The forwarder check " - + "takes the replica out of rotation; on the startup probe it would have the kubelet restart " - + "the container. Body was:\n" + startupBody); - Assert.True(startupBody.Contains($"{ModuleForwarderHealthCheck.HealthCheckName}: Unhealthy", StringComparison.Ordinal) - && startupBody.Contains(ForwarderGap, StringComparison.Ordinal), - $"the forwarder reading vanished from /health's body. Body was:\n{startupBody}"); - - Assert.True(readiness.StatusCode == HttpStatusCode.ServiceUnavailable, - $"the chart's readinessProbe path ('{probes.Readiness}') answered {(int)readiness.StatusCode} " - + $"with a root forwarder unresolvable, so the replica would keep taking traffic it fails. " - + $"Body was:\n{readinessBody}"); - Assert.True(readinessBody.Contains(ModuleForwarderHealthCheck.HealthCheckName, StringComparison.Ordinal), - $"readiness refused without naming the check. Body was:\n{readinessBody}"); - - Assert.True(liveness.StatusCode == HttpStatusCode.OK, - $"the chart's livenessProbe path ('{probes.Liveness}') answered {(int)liveness.StatusCode} " - + "with a root forwarder unresolvable."); - } - - private const string ForwarderGap = - "'mw-module-service:10 (Demo.IRecentChats)' has not been registered in this generation's container"; - - private static void ForwarderUnresolvable(IServiceCollection services) => - // The registration AddDefaultHealthChecks made, tags untouched — only its verdict is replaced. - services.PostConfigure(options => - options.Registrations.Single(r => r.Name == ModuleForwarderHealthCheck.HealthCheckName).Factory = - _ => new DelegateHealthCheck(HealthCheckResult.Unhealthy(ForwarderGap))); - - private sealed class DelegateHealthCheck(HealthCheckResult result) : IHealthCheck - { - public Task CheckHealthAsync(HealthCheckContext context, System.Threading.CancellationToken cancellationToken = default) => - Task.FromResult(result); - } - - /// - /// Pins the CORE side only: the roll-gate allow-list names the literal the Plugins host - /// registers today (AddCheck<RequiredModulesHealthCheck>("required_modules") in - /// Memex.Portal.Distributed). It cannot see that host — if the host renames its registration, - /// this stays green while TagRollGates stops matching. Closing that needs the host to - /// register under . - /// - [Fact] - public void TheRollGateAllowList_NamesTheLiteralThePluginsHostRegistersToday() => - Assert.Equal("required_modules", ProbeEndpoints.RequiredModulesCheckName); - - /// - /// 🚨 The negative control that keeps the first test from being vacuous: an ordinary - /// startup-critical check (the host's db_version shape) that is Unhealthy STILL fails - /// the startup probe. Only roll gates moved; a portal that cannot boot must still not start. - /// - [Fact] - public async Task AStartupCriticalCheck_StillFailsTheStartupProbe() - { - var probes = ChartProbePaths(); - - var (startup, body) = await ProbeAsync(probes.Startup, services => services.AddHealthChecks() - .AddCheck("db_version", () => HealthCheckResult.Unhealthy("schema version 57, image needs 58"))); - - Assert.True(startup.StatusCode == HttpStatusCode.ServiceUnavailable, - $"the chart's startupProbe path ('{probes.Startup}') answered {(int)startup.StatusCode} " - + "with an ordinary Unhealthy check registered. The startup verdict excludes roll gates " - + $"ONLY; it must still refuse a portal ahead of its schema. Body was:\n{body}"); - Assert.True(body.StartsWith("Unhealthy", StringComparison.Ordinal), - $"line one must be the startup verdict. Body was:\n{body}"); - } - - private const string MissingModule = - "1 required module(s) absent from this pod's shelf: Demo.Module"; - - private static void RequiredModulesMissing(IServiceCollection services) => - // Registered exactly as Memex.Portal.Distributed registers it: by name, no tags. - services.AddHealthChecks().AddCheck(ProbeEndpoints.RequiredModulesCheckName, - () => HealthCheckResult.Unhealthy(MissingModule)); - - private static void BakeGateRefusing(IServiceCollection services) => - // Registered exactly as Memex.Portal.Distributed registers it: by name, no tags. - services.AddHealthChecks().AddCheck(NodeTypeBakeGateExtensions.HealthCheckName, - () => HealthCheckResult.Unhealthy(Refusal)); - - private static async Task<(HttpResponseMessage Response, string Body)> ProbeAsync( - string route, Action register) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.AddDefaultHealthChecks(); - register(builder.Services); - - var app = builder.Build(); - app.MapDefaultEndpoints(); - - await using (app) - { - await app.StartAsync(TestContext.Current.CancellationToken); - using var client = app.GetTestClient(); - var response = await client.GetAsync(route, TestContext.Current.CancellationToken); - var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - return (response, body); - } - } - - /// - /// The three probe paths the chart SHIPS: readiness and liveness from the template, startup - /// from probes.startup.path in the values (the template reads it from there). - /// - private static (string Startup, string Readiness, string Liveness) ChartProbePaths() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - Assert.NotNull(dir); - var helm = Path.Combine(dir!.FullName, "deploy", "helm"); - - var template = string.Join("\n", File.ReadAllLines( - Path.Combine(helm, "templates", "memex-portal", "deployment.yaml")) - .Where(line => !line.TrimStart().StartsWith('#'))); - Assert.True(Regex.IsMatch(template, - @"startupProbe:(?s).*?httpGet:[^\n]*?path:[^\n]*?\.Values\.probes\.startup\.path"), - "the startupProbe no longer reads its path from probes.startup.path — update this test " - + "DELIBERATELY; a guard that stops matching its subject passes having checked nothing."); - - var values = File.ReadAllText(Path.Combine(helm, "values.yaml")); - var probesBlock = Regex.Match(values, @"^probes:\s*$(?(?:\n(?:[ \t].*)?)+)", RegexOptions.Multiline); - Assert.True(probesBlock.Success, "deploy/helm/values.yaml has no top-level 'probes:' block"); - var startup = Regex.Match(probesBlock.Groups["body"].Value, - @"^\s+startup:\s*$(?:\n(?:\s*#.*|\s*))*?\n\s+path:\s*(?

\S+)", RegexOptions.Multiline); - Assert.True(startup.Success, "values.yaml probes.startup declares no path"); - - return (startup.Groups["p"].Value.Trim('"', '\''), - PathOf(template, "readinessProbe"), PathOf(template, "livenessProbe")); - } - - private static string PathOf(string yaml, string probe) - { - var block = Regex.Match(yaml, $@"^(?[ ]*){probe}:[ ]*$(?(?:\n\k[ ].*)*)", - RegexOptions.Multiline); - Assert.True(block.Success, $"the deployment template declares no {probe}"); - var hit = Regex.Match(block.Groups["body"].Value, @"httpGet:\s*\{[^}]*?\bpath:\s*(?

[^,}\s]+)"); - Assert.True(hit.Success, $"the deployment template's {probe} names no inline httpGet path"); - return hit.Groups["p"].Value.Trim(); - } -} diff --git a/test/Memex.Portal.Shared.Test/RollMidSessionDrainTest.cs b/test/Memex.Portal.Shared.Test/RollMidSessionDrainTest.cs deleted file mode 100644 index 026d527b06..0000000000 --- a/test/Memex.Portal.Shared.Test/RollMidSessionDrainTest.cs +++ /dev/null @@ -1,402 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.ServiceDefaults; -using MeshWeaver.Hosting; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -///

-/// 🚨 THE ROLL-MID-SESSION ACCEPTANCE — issue #1971 (split out of #1340), the one item that -/// nothing else covers: roll the mesh while a session is open and assert the session survives. -/// -/// Why this is not the guard we already have. -/// MeshWeaver.Documentation.Test.DrainDeadlineGuard pins the preStop arithmetic — that -/// the loop's deadline is drainSeconds − shutdownMarginSeconds. That guard would still pass -/// if the drain were pinned by something else entirely: a probe tool missing from the image, a -/// counter that never decrements, an endpoint that answers 500. The chart carries a scar of exactly -/// that class — preStop once probed with wget, which is absent from the image, so the loop -/// could never succeed and EVERY termination hung to the grace ceiling, strictly worse than the -/// abrupt teardown it exists to prevent. Reading caught it; no test could. This is that test. -/// -/// What "the session survives a roll" actually means. A rollout is surge-first -/// (maxUnavailable: 0): the new pod serves before the old one is removed, so new sessions go -/// elsewhere the moment this pod leaves the Service. The OLD pod's job is then to keep serving the -/// circuits it already has — a circuit's hubs live on the pod serving it -/// (MessageHubGrain is [PreferLocalPlacement]) — until they close. That is entirely a -/// property of preStop + /drain + ActiveCircuitTracker, and all three run here, -/// for real, over a real socket. -/// -/// What runs is the SHIPPED script. The command is lifted verbatim out of -/// deploy/helm/templates/memex-portal/deployment.yaml — not retyped — with exactly four -/// declared substitutions, each of which asserts the token it replaces was there (so a chart edit -/// that renames one fails here rather than silently running a different script). Everything else, -/// including the command -v curl guard whose failure mode was the wget incident, is -/// the text Kubernetes executes. -/// -public class RollMidSessionDrainTest -{ - private const string Deployment = "deploy/helm/templates/memex-portal/deployment.yaml"; - - /// - /// 🚨 THE ACCEPTANCE. A session that is open when the roll begins keeps being served, and - /// SIGTERM is withheld until it closes — which is the whole of "non-disruptive rollout". - /// - [Fact(Timeout = 180_000)] - public async Task ASessionOpenWhenTheRollBegins_KeepsBeingServedUntilItCloses() - { - await using var pod = await Pod.StartAsync(TestContext.Current.CancellationToken); - - // A user is working on this pod when Kubernetes deletes it. - pod.Circuits.Opened(); - - // The kubelet runs preStop. The deadline is deliberately long here — this fact is about the - // drain WAITING, and a deadline that fired would prove the opposite of what is claimed. - using var preStop = pod.StartPreStop(deadlineSeconds: 60); - - // The pod reports itself busy over the very endpoint preStop probes. - var probe = await pod.Client.GetAsync("/drain", TestContext.Current.CancellationToken); - probe.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable, - "/drain must answer 503 while a circuit is live — a 200 here is preStop's signal to " - + "return, and the session would be cut off mid-sentence"); - (await probe.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)) - .Should().Contain("1 circuit(s) still open"); - - // 🚨 THE SESSION SURVIVES: preStop is still polling, so SIGTERM has not been delivered and - // the process is still serving. A fixed wait is the sanctioned kind — this is a negative - // assertion, and there is no positive signal to filter for. - await Task.Delay(TimeSpan.FromSeconds(6), TestContext.Current.CancellationToken); - preStop.HasExited.Should().BeFalse( - "preStop must keep waiting while a circuit is open. If it returns here the kubelet " - + "SIGTERMs a pod that is still serving somebody, which is the 'the demo just crashed' " - + "report #1342 closed and #1971 exists to keep closed"); - pod.Circuits.Count.Should().Be(1, "nothing has closed the session"); - - // The user finishes and closes the tab. - pod.Circuits.Closed(); - - // Only now may the pod stop — and it must notice PROMPTLY, or a roll costs a poll interval - // per pod for nothing. - (await preStop.WaitForExitAsync(TimeSpan.FromSeconds(30))) - .Should().BeTrue($"preStop must return once the last circuit closes. {preStop.Diagnostics}"); - preStop.ExitCode.Should().Be(0); - - // And what SIGTERM finds is written down — the line whose ABSENCE is the evidence of a hard - // kill, and the reason #1971's first question was unanswerable from the logs in either - // direction. - await pod.StopAsync(); - pod.Log.Should().Contain(l => l.Contains("shutting down cleanly", StringComparison.Ordinal), - "ApplicationStopping must report a clean departure — the silo leaves membership in " - + "order, so no zombie entry is left for the cluster to place activations on. " - + $"Got:\n{pod.LogDump}"); - } - - /// - /// 🚨 The other half, and the defect #1971 was filed for: a session that outlives the drain must - /// NOT ride to the grace ceiling. preStop returns at its own deadline, so SIGTERM lands with the - /// shutdown margin intact and the silo departs; riding to the ceiling instead SIGKILLs the - /// process with a LIVE silo, which is what left zombie membership entries the cluster kept - /// placing activations on. - /// - [Fact(Timeout = 180_000)] - public async Task ASessionThatOutlivesTheDrain_ReturnsPreStopWithGraceStillLeft() - { - await using var pod = await Pod.StartAsync(TestContext.Current.CancellationToken); - - // A forgotten tab: this circuit never closes. - pod.Circuits.Opened(); - - using var preStop = pod.StartPreStop(deadlineSeconds: 4); - - (await preStop.WaitForExitAsync(TimeSpan.FromSeconds(60))) - .Should().BeTrue( - "preStop must bound its OWN wait. Polling until terminationGracePeriodSeconds means " - + "the kubelet SIGKILLs the process with a live Orleans silo — the host's 90 s " - + $"ShutdownTimeout never runs and ApplicationStopping never fires. {preStop.Diagnostics}"); - preStop.ExitCode.Should().Be(0, - "it must exit 0: a non-zero preStop is an event on the pod, not a faster shutdown"); - - pod.Circuits.Count.Should().Be(1, - "the straggler is still there — preStop returned because its deadline expired, not " - + "because the session ended. That trade is the point: cut off the last stragglers " - + "deliberately rather than keep everyone and then die abruptly"); - - await pod.StopAsync(); - pod.Log.Should().Contain(l => l.Contains("GIVING UP", StringComparison.Ordinal) - && l.Contains("STILL OPEN", StringComparison.Ordinal), - "the deliberate cut-off must be LOUD and counted — silently dropping the last sessions " - + $"is how a drain regression stays invisible. Got:\n{pod.LogDump}"); - } - - // ── the shipped preStop script ─────────────────────────────────────────── - - /// - /// The preStop command as the chart ships it, with the four declared substitutions applied. - /// Each substitution asserts the token it replaces is present, so a chart edit that renames one - /// fails HERE instead of quietly running a script this test made up. - /// - private static string PreStopScript(int port, int deadlineSeconds) - { - var script = ShippedPreStopCommand(); - - // 1. The INGRESS drain. Its value is asserted, not re-timed: fifteen seconds of sleeping - // would be added to every run of this file for a constant DrainDeadlineGuard already - // reads. What is under test here is the SESSION drain that follows it. - script = Substitute(script, "sleep 15;", "sleep 1;"); - // 2. The port. The container listens on 8080; this test's Kestrel takes an ephemeral one. - script = Substitute(script, "http://127.0.0.1:8080/drain", $"http://127.0.0.1:{port}/drain"); - // 3. The deadline — the Helm expression, evaluated here instead of by `helm template`. - script = Substitute(script, - new Regex(@"DEADLINE=\{\{[^}]*\}\}\}?;", RegexOptions.None, TimeSpan.FromSeconds(5)), - $"DEADLINE={deadlineSeconds};", - "DEADLINE={{ sub (.Values.portal.drainSeconds …) (.Values.portal.shutdownMarginSeconds …) }};"); - // 4. The poll interval, for the same reason as (1). - script = Substitute(script, "sleep 5;", "sleep 1;"); - - return script; - } - - /// Lifts lifecycle.preStop.exec.command's folded scalar out of the chart. - private static string ShippedPreStopCommand() - { - var chart = File.ReadAllText(Path.Combine(FindRepoRoot(), Deployment)); - var preStop = chart.IndexOf("preStop:", StringComparison.Ordinal); - Assert.True(preStop > 0, $"{Deployment} no longer has a preStop hook — without it a roll " - + "cuts every open circuit the moment the ingress window closes (#1342)."); - var folded = chart.IndexOf("- >-", preStop, StringComparison.Ordinal); - Assert.True(folded > 0, $"{Deployment}'s preStop command is no longer a folded scalar — " - + "this test lifts the SHIPPED text rather than retyping it."); - var end = chart.IndexOf("\n envFrom:", folded, StringComparison.Ordinal); - Assert.True(end > folded, $"{Deployment}: could not find the end of the preStop command block."); - - var lines = chart[(chart.IndexOf('\n', folded) + 1)..end] - .Split('\n') - .Select(l => l.Trim()) - .Where(l => l.Length > 0); - return string.Join(" ", lines); - } - - private static string Substitute(string script, string token, string replacement) - { - Assert.True(script.Contains(token, StringComparison.Ordinal), - $"{Deployment}'s preStop no longer contains `{token}`. This test substitutes it to run " - + "the shipped script against a test host; if the token changed, update the " - + "substitution — do NOT let the test run a script that is not what ships."); - return script.Replace(token, replacement, StringComparison.Ordinal); - } - - private static string Substitute(string script, Regex token, string replacement, string described) - { - Assert.True(token.IsMatch(script), - $"{Deployment}'s preStop no longer contains `{described}`. This test evaluates that " - + "Helm expression itself; if its shape changed, update the substitution rather than " - + "running a script that is not what ships."); - return token.Replace(script, replacement); - } - - private static string FindRepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".github"))) - dir = dir.Parent; - Assert.NotNull(dir); - return dir!.FullName; - } - - // ── the pod under test ─────────────────────────────────────────────────── - - /// - /// A real Kestrel host serving the REAL /drain endpoint over a real socket — a socket and - /// not a TestServer because the thing under test is the shipped shell script, and - /// curl cannot call an in-memory pipeline. - /// - private sealed class Pod : IAsyncDisposable - { - private readonly WebApplication app; - private readonly LogSink sink; - - private Pod(WebApplication app, LogSink sink, string baseUrl, int port) - { - this.app = app; - this.sink = sink; - Port = port; - Client = new HttpClient { BaseAddress = new Uri(baseUrl) }; - Circuits = app.Services.GetRequiredService(); - } - - public int Port { get; } - - public HttpClient Client { get; } - - public ActiveCircuitTracker Circuits { get; } - - public IReadOnlyCollection Log => sink.Lines; - - public string LogDump => string.Join("\n", sink.Lines); - - public static async Task StartAsync(CancellationToken cancellationToken) - { - // 🚨 Fail RED, never skip. The `command -v curl || exit 0` guard in the shipped script - // means a machine without curl would make every assertion below vacuously true — the - // script would exit 0 immediately and this test would report a passing drain it never - // exercised. That is the same "a gate that cannot fail" shape the drain itself had. - Assert.True(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), - "this test executes the shipped POSIX preStop script; run it on Linux or macOS."); - Assert.True(WhichExists("curl"), - "`curl` is not on PATH. The shipped preStop skips the drain entirely when its probe " - + "tool is missing (fail-open, deliberately), so without curl this test would pass " - + "having verified nothing. Install curl rather than skipping."); - - var sink = new LogSink(); - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseSetting("urls", "http://127.0.0.1:0"); - builder.Logging.ClearProviders(); - builder.Logging.AddProvider(sink); - // 🚨 The test bin's appsettings.json pins `MeshWeaver: Warning`, which would swallow the - // drain's Information lines — and the clean-shutdown line is Information while the - // give-up line is Warning, so without this the test would assert one narrative and - // silently skip the other. Raised for THIS host only; the shipped levels are untouched. - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["Logging:LogLevel:Default"] = "Trace", - ["Logging:LogLevel:MeshWeaver"] = "Trace", - [$"Logging:LogLevel:{typeof(DrainProgress).FullName}"] = "Trace", - }); - builder.Services.AddSingleton(); - - var app = builder.Build(); - app.MapDrainEndpoint(); - await app.StartAsync(cancellationToken); - - var address = app.Services.GetRequiredService() - .Features.Get()!.Addresses.First(); - return new Pod(app, sink, address, new Uri(address).Port); - } - - public PreStopRun StartPreStop(int deadlineSeconds) => - PreStopRun.Start(PreStopScript(Port, deadlineSeconds)); - - /// SIGTERM's arrival: runs ApplicationStopping, which is where the drain - /// writes down what it found. - public Task StopAsync() => app.StopAsync(); - - public async ValueTask DisposeAsync() - { - Client.Dispose(); - await app.DisposeAsync(); - } - - private static bool WhichExists(string tool) - { - using var probe = Process.Start(new ProcessStartInfo("/bin/sh", ["-c", $"command -v {tool}"]) - { - RedirectStandardOutput = true, RedirectStandardError = true, - })!; - probe.WaitForExit(10_000); - return probe.ExitCode == 0; - } - } - - /// One execution of the shipped preStop command, as the kubelet runs it. - private sealed class PreStopRun : IDisposable - { - private readonly Process process; - private readonly ConcurrentQueue output = new(); - - private PreStopRun(Process process, string script) - { - this.process = process; - Script = script; - } - - public string Script { get; } - - public bool HasExited => process.HasExited; - - public int ExitCode => process.ExitCode; - - public string Diagnostics => - $"Script:\n {Script}\nOutput:\n {string.Join("\n ", output)}"; - - public static PreStopRun Start(string script) - { - var info = new ProcessStartInfo("/bin/sh", ["-c", script]) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - var process = Process.Start(info)!; - var run = new PreStopRun(process, script); - process.OutputDataReceived += (_, e) => { if (e.Data is not null) run.output.Enqueue(e.Data); }; - process.ErrorDataReceived += (_, e) => { if (e.Data is not null) run.output.Enqueue(e.Data); }; - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - return run; - } - - /// Bounded, and it reports rather than throws — the caller states what a false - /// means, which is the whole assertion. - public async Task WaitForExitAsync(TimeSpan budget) - { - using var cts = new CancellationTokenSource(budget); - try - { - await process.WaitForExitAsync(cts.Token); - return true; - } - catch (OperationCanceledException) - { - return false; - } - } - - public void Dispose() - { - try - { - if (!process.HasExited) - process.Kill(entireProcessTree: true); - } - catch (InvalidOperationException) { /* already gone */ } - process.Dispose(); - } - } - - /// Captures the drain's own narrative so the shutdown report can be asserted. - private sealed class LogSink : ILoggerProvider - { - private readonly ConcurrentQueue lines = new(); - - public IReadOnlyCollection Lines => lines; - - public ILogger CreateLogger(string categoryName) => new SinkLogger(lines); - - public void Dispose() { } - - private sealed class SinkLogger(ConcurrentQueue lines) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) => - lines.Enqueue($"[{logLevel}] {formatter(state, exception)}"); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/RollSelectionInventoryTest.cs b/test/Memex.Portal.Shared.Test/RollSelectionInventoryTest.cs deleted file mode 100644 index ef7a84b33c..0000000000 --- a/test/Memex.Portal.Shared.Test/RollSelectionInventoryTest.cs +++ /dev/null @@ -1,332 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The roll selector's DENOMINATOR, read off a real mesh (#3479). -/// -/// pins the walk against hand-built inputs. This pins the half -/// that decides what those inputs ARE: ReleaseAvailabilityService.SelectRollTarget reading -/// this environment's install records through a real IMeshService, against a real published -/// bundle root on disk. Nothing is mocked, and this is the first test in the suite to drive that -/// service end to end rather than around it. -/// -/// Why the empty case is the point. The install records are the source the deployment -/// gate already uses, and they have measurably answered ZERO on a live portal that carried 42 -/// modules (memex, 2026-08-10 — the measurement that made InstanceComboReader fold two -/// shapes). With an empty denominator every release ships all zero plugins, so a selector that does -/// not refuse the case degenerates into "take the newest" — #3441's vacuity, one level up. Under -/// policy platform-rolls-independently-of-packages the platform rolls independently of its -/// packages, so the newest admitted release IS -/// taken — and the outcome says, in the summary and the advisory, that no package was checked. -/// -public class RollSelectionInventoryTest(ITestOutputHelper output) - : MonolithMeshTestBase(output), IDisposable -{ - private const string CompleteIdentity = "s3479inv0complete0000000000000000"; - private const string CompleteVersion = "3.0.0-rc9.ci.7647"; - private const string IncompleteIdentity = "s3479inv0incomplete00000000000000"; - private const string IncompleteVersion = "3.0.0-rc9.ci.7676"; - private const string RunningVersion = "3.0.0-rc9.ci.7693"; - - /// A slice of the environment's real install records; Feedback is the one the - /// measured hold named, and the one the newer publication here does not ship. - private static readonly string[] Installed = ["AI", "Essentials", "Feedback", "Store"]; - - private static readonly string[] ShippedByTheNewerRelease = ["AI", "Essentials", "Store"]; - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - // 🚨 The default install would write records this test did not ask for, and the - // denominator is exactly what is under test here. - .ConfigureServices(services => services - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false })); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - /// - /// 🚨 THE ALGORITHM, denominator and all: four install records, a newer release that ships three - /// of them and an older one that ships all four. The service selects the NEWER (#3651 — a - /// missing bake is a boot compile, not a decline) and the outcome NAMES Feedback as the cost. - /// - [Fact(Timeout = 240_000)] - public async Task SelectsTheNewestRelease_NamingThePackageItRecompilesAtBoot() - { - foreach (var id in Installed) - await Record(id); - - var root = StagedRoot(); - - var outcome = await Service(root) - .SelectRollTarget(RunningVersion, [IncompleteVersion, CompleteVersion]) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine(outcome.Summary); - - outcome.Kind.Should().Be(RollSelectionKind.Update); - outcome.SelectedVersion.Should().Be(IncompleteVersion); - outcome.Declined.Should().BeEmpty(); - outcome.RequiredPlugins.Should().Be( - Installed.Length, - "the denominator is the environment's install records, and it is stated rather than " - + "inferred from a pass"); - outcome.Summary.Should().Contain("install records"); - outcome.BootCompiles.Should().Equal(["Feedback"]); - outcome.Summary.Should().Contain("would recompile at boot: Feedback"); - } - - /// - /// The same fixture on an instance that opts into Modules:RequirePrebuilt — the strict - /// mode in which the seeder refuses a boot compile. Until policy - /// platform-rolls-independently-of-packages it declined the newer and - /// selected the older; under that policy it selects the NEWER anyway and names Feedback as - /// the package that does not follow — it parks under the strict seeder and catches up through - /// its own update policy. - /// - [Fact(Timeout = 240_000)] - public async Task UnderRequirePrebuilt_TheNewestIsSelected_AndTheUnpublishedPackageIsAnAdvisory() - { - foreach (var id in Installed) - await Record(id); - - var root = StagedRoot(); - - var outcome = await Service(root, requirePrebuilt: true) - .SelectRollTarget(RunningVersion, [IncompleteVersion, CompleteVersion]) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine(outcome.Summary); - - outcome.Kind.Should().Be(RollSelectionKind.Update); - outcome.SelectedVersion.Should().Be(IncompleteVersion); - outcome.Declined.Should().BeEmpty(); - outcome.RequiredPlugins.Should().Be(Installed.Length); - outcome.SatisfiedPlugins.Should().Be(ShippedByTheNewerRelease.Length); - outcome.Advisories.Should().NotBeEmpty(); - outcome.Advisories[0].Should().Contain("independently of packages"); - outcome.Advisories[0].Should().Contain("Feedback"); - outcome.Summary.Should().Contain("not following this roll"); - } - - /// - /// 🚨 THE VACUITY, on a real mesh: no install records at all. Every candidate would ship all zero - /// plugins. Under policy platform-rolls-independently-of-packages that is a statement - /// about PACKAGES and does not stop the platform - /// — the - /// newest admitted release is selected — but it is still never spelled like "all plugins - /// present": the summary and the advisory say the denominator out loud and that no package was - /// checked, and the shared predicate still answers the PACKAGE question with a refusal. - /// - [Fact(Timeout = 240_000)] - public async Task NoInstallRecordsSelectsTheNewest_AndSaysNoPackageWasChecked() - { - var root = StagedRoot(); - - var outcome = await Service(root) - .SelectRollTarget(RunningVersion, [IncompleteVersion, CompleteVersion]) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine(outcome.Summary); - - outcome.Kind.Should().Be(RollSelectionKind.Update); - outcome.SelectedVersion.Should().Be(IncompleteVersion, - "the platform rolls independently of packages — an unread denominator is reported, " - + "never a reason to stay"); - outcome.ShouldUpdate.Should().BeTrue(); - outcome.RequiredPlugins.Should().Be(0); - outcome.SatisfiedPlugins.Should().Be(0); - outcome.Summary.Should().Contain("0 plugins required"); - outcome.Summary.Should().Contain("no package was checked"); - - // 🚨 The SHARED predicate still answers the PACKAGE question truthfully — a gate that - // compares two empty sets is the vacuity #3441 removed — and the roll surfaces turn that - // refusal into an advisory (ForPlatformRoll), never a clearance of the packages. - var verdict = await Service(root).IsUpdatable(IncompleteVersion) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - verdict.IsUpdatable.Should().BeFalse( - "a gate that compares two empty sets is the vacuity #3441 removed one level down"); - verdict.IsIndeterminate.Should().BeTrue(); - verdict.HoldReason.Should().Contain("ZERO"); - var roll = verdict.ForPlatformRoll(); - roll.IsUpdatable.Should().BeTrue(); - roll.WaivedHold.Should().Contain("ZERO"); - } - - /// - /// With no candidate list the service derives one from the published root's own release markers - /// — so an environment can answer "which release should I be on" without listing a container - /// registry it may not be able to reach. Ordering is VersionSelect's, which is why the - /// NEWEST marker (ci.7676) is selected rather than a lexicographic accident — and, under policy - /// platform-rolls-independently-of-packages, selected even on the strict instance where - /// its missing Feedback bake is refused: - /// that refusal is an advisory on the platform roll. - /// - [Fact(Timeout = 240_000)] - public async Task DerivesItsCandidatesFromThePublishedReleaseMarkers() - { - foreach (var id in Installed) - await Record(id); - - var root = StagedRoot(); - - var outcome = await Service(root, requirePrebuilt: true) - .SelectRollTarget(RunningVersion) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine(outcome.Summary); - - outcome.SelectedVersion.Should().Be(IncompleteVersion); - outcome.Declined.Should().BeEmpty(); - outcome.Summary.Should().Contain("Feedback"); - } - - /// - /// A configured root that is not there is a mis-mount, not an empty publication history. Under - /// policy platform-rolls-independently-of-packages that unanswerable PACKAGE question - /// does not hold the platform: with the candidates - /// in hand the newest admitted one is selected, and the summary and the advisory NAME the - /// mis-mount — cannot determine is reported, never dressed up as "all plugins present". - /// - [Fact(Timeout = 240_000)] - public async Task AnAbsentPublishedRootSelectsTheNewest_AndNamesTheMisMount() - { - foreach (var id in Installed) - await Record(id); - - var missing = Track(Path.Combine(Path.GetTempPath(), "mw-3479-absent-" + Guid.NewGuid().ToString("N"))); - - var outcome = await Service(missing) - .SelectRollTarget(RunningVersion, [IncompleteVersion, CompleteVersion]) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine(outcome.Summary); - - outcome.Kind.Should().Be(RollSelectionKind.Update); - outcome.SelectedVersion.Should().Be(IncompleteVersion); - outcome.Summary.Should().Contain("does not exist"); - outcome.Summary.Should().Contain("no package was checked"); - outcome.Advisories.Should().ContainSingle() - .Which.Should().Contain("does not exist"); - } - - // ── fixture ───────────────────────────────────────────────────────────────────────────────── - - /// The service over a root, on an instance that does or does not opt into - /// Modules:RequirePrebuilt — the same configuration key the seeder reads, on the same - /// configuration the service reads its published root from. - private ReleaseAvailabilityService Service(string publishedRoot, bool requirePrebuilt = false) => - new(Mesh, - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [ShippedPrebuiltBundles.PublishedRootConfigKey] = publishedRoot, - [DeploymentReportService.DeploymentKey] = "memex", - [MeshWeaver.Graph.Configuration.PrebuiltAssemblySeeder.RequirePrebuiltConfigKey] = - requirePrebuilt ? "true" : null, - }).Build()); - - /// One install record, exactly as PackageInstaller writes it — the same nodes - /// the service's own query reads back. - private Task Record(string packageId) => - Access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode( - MeshNode.FromPath($"{PackageInstaller.InstalledPartition}/{packageId}") with - { - NodeType = PackageInstaller.PackageNodeType, - Name = packageId, - State = MeshNodeState.Active, - Content = new PackageManifest - { - Id = packageId, - Name = packageId, - Version = "1.0.0", - TargetPartition = packageId, - }, - })) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - /// Two publications: an older identity that sealed every installed package, a newer one - /// that did not seal Feedback. - private string StagedRoot() - { - var root = Track(Path.Combine(Path.GetTempPath(), "mw-3479-inv-" + Guid.NewGuid().ToString("N"))); - var markers = Path.Combine(root, PublishedBundleCatalogue.ReleaseMarkerDirectoryName); - Directory.CreateDirectory(markers); - File.WriteAllText(Path.Combine(markers, CompleteVersion), CompleteIdentity); - File.WriteAllText(Path.Combine(markers, IncompleteVersion), IncompleteIdentity); - Seal(root, CompleteIdentity, Installed); - Seal(root, IncompleteIdentity, ShippedByTheNewerRelease); - return root; - } - - private static void Seal(string root, string identity, IEnumerable bundles) - { - var directory = Path.Combine(root, identity, "plugins"); - Directory.CreateDirectory(directory); - var names = new List(); - foreach (var bundle in bundles) - { - WriteBundle(Path.Combine(directory, bundle + ".zip"), bundle, identity); - names.Add(bundle + ".zip"); - } - File.WriteAllText( - Path.Combine(directory, ShippedPrebuiltBundles.CompletionSentinelFileName), - string.Join('\n', names.Order(StringComparer.Ordinal)) + "\n"); - } - - private static void WriteBundle(string path, string bundle, string identity) - { - var manifest = new BundleReader.Manifest( - bundle, "1.0", identity, - [ - new BundleReader.AssemblyRef( - $"{bundle}/Type", $"{bundle}_Type.dll", - new Dictionary(StringComparer.Ordinal) - { - ["MeshWeaver.Layout"] = MeshWeaver.Compiler.CompiledDependencies.RefAsmScheme + "abc", - }), - ]); - using var file = File.Create(path); - using var zip = new ZipArchive(file, ZipArchiveMode.Create); - using var stream = zip.CreateEntry(NuGetPackageWriter.ManifestEntry).Open(); - stream.Write(JsonSerializer.SerializeToUtf8Bytes( - manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web))); - } - - private readonly List roots = []; - - private string Track(string root) - { - roots.Add(root); - return root; - } - - void IDisposable.Dispose() - { - foreach (var root in roots) - { - try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } - } - } -} diff --git a/test/Memex.Portal.Shared.Test/RollSelectionTest.cs b/test/Memex.Portal.Shared.Test/RollSelectionTest.cs deleted file mode 100644 index b576d75513..0000000000 --- a/test/Memex.Portal.Shared.Test/RollSelectionTest.cs +++ /dev/null @@ -1,510 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The roll-selection algorithm (#3479), against the shared predicate and a real published -/// root on disk. -/// -///
Maintainer, 2026-09-06: "Whenever a new platform / plugin is published, check for -/// each environment which is the latest platform version shipping all plugins, if different from -/// current version ⇒ update."
-/// -/// Nothing is mocked: each candidate is judged by -/// over a -/// observation of a root this test writes — the same code path -/// ReleaseAvailabilityService.IsUpdatable runs in production. What is under test is the WALK -/// and its denominator discipline. -/// -/// The fixture is a measured incident, not a synthetic one. It is the shape of -/// memex.meshweaver.cloud's own last availability verdict, read off -/// Admin/UpdatePolicy on 2026-09-06: -/// -/// heldTag = 3.0.0-rc9.ci.7676 -/// heldReason = "Analysis: no sealed content bake for framework identity -/// sf61a0f5d751c483216ea4a8d9883570f — the bundle 'Analysis' is not published for -/// release 3.0.0-rc9.ci.7676 …; …; Feedback: no sealed content bake for framework -/// identity sf61a0f5d751c483216ea4a8d9883570f — the bundle 'Feedback' is not -/// published for release 3.0.0-rc9.ci.7676 …" (38 packages, of 77 installed) -/// -/// That environment has been sitting on 3.0.0-rc9.ci.7693 with policy: None ever -/// since. The gate was right and had nothing to offer instead — there was no way to ask "then which -/// release SHOULD I be on". That is the gap these tests pin. -/// -/// 🚨 Since #3651 a missing bake is a COST, not a decline (maintainer rule of -/// 2026-09-07): the newest release is selected and the outcome names what it recompiles at boot. -/// The measured fixture above therefore selects ci.7676 by default, naming the eight — -/// and the walk that declines it survives under Modules:RequirePrebuilt, the opt-in strict -/// mode, which is the arm the laziness and "behind current" cases below run on because those -/// shapes need a decline to exist. The decline that exists everywhere now — an UNLOADABLE module — -/// is pinned in . -/// -/// 🚨 Under policy platform-rolls-independently-of-packages nothing declines a -/// candidate: the newest admitted release is always selected, -/// and what the strict policy, an unread or empty plugin list, or an unloadable module would have -/// declined it for is carried on the outcome's advisories and summary instead. -///
-public class RollSelectionTest : IDisposable -{ - // ── the measured fixture ──────────────────────────────────────────────────────────────────── - - /// The identity 3.0.0-rc9.ci.7676 resolved, quoted from the hold reason above. - private const string IncompleteIdentity = "sf61a0f5d751c483216ea4a8d9883570f"; - private const string IncompleteVersion = "3.0.0-rc9.ci.7676"; - - private const string CompleteIdentity = "s3479complete00000000000000000000"; - private const string CompleteVersion = "3.0.0-rc9.ci.7647"; - - private const string RunningVersion = "3.0.0-rc9.ci.7693"; - - /// - /// A twelve-package slice of the environment's real install records (77 of them, measured - /// 2026-09-06 via search 'namespace:Plugins scope:children nodeType:Package'). The first - /// eight are packages the real hold NAMED as missing; the last four it did not — so the fixture - /// reproduces a PARTIAL publication rather than an all-or-nothing one, which is the case a - /// presence check has to get right. - /// - private static readonly string[] NamedInTheHold = - ["Feedback", "Crm", "Edu", "Essentials", "Hosting", "Publish", "Store", "Training"]; - - private static readonly string[] NotNamedInTheHold = - ["AI", "Anthropic", "Mcp", "WebSearch"]; - - private static string[] AllPackages => [.. NamedInTheHold, .. NotNamedInTheHold]; - - private static ImmutableArray Installed => - [.. AllPackages.Select(id => new RequiredPackage(id, id))]; - - // ── the walk ──────────────────────────────────────────────────────────────────────────────── - - /// - /// 🚨 THE ALGORITHM, as of #3651. The newest release does not publish a bundle for eight of - /// the twelve installed packages; the one below it publishes all twelve. The selector takes - /// the NEWEST — a missing bake is a boot compile, not a reason to walk on — and the outcome - /// names the eight, Feedback among them, as the cost. - /// - [Fact] - public async Task SelectsTheNewestRelease_NamingThePackagesItRecompilesAtBoot() - { - var root = MeasuredRoot(); - - var outcome = await Select(root, RunningVersion, [IncompleteVersion, CompleteVersion]) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Assert.Equal(RollSelectionKind.Update, outcome.Kind); - Assert.Equal(IncompleteVersion, outcome.SelectedVersion); - Assert.Empty(outcome.Declined); - - // The denominator is stated, not inferred from the pass — the whole #3441 lesson. - Assert.Equal(AllPackages.Length, outcome.RequiredPlugins); - Assert.Equal(AllPackages.Length, outcome.SatisfiedPlugins); - Assert.Contains($"{AllPackages.Length} plugin(s) required", outcome.Summary); - - // …and so is the COST, on the outcome and in its summary. - Assert.Equal( - NamedInTheHold.Order(StringComparer.Ordinal), - outcome.BootCompiles.Order(StringComparer.Ordinal)); - Assert.Contains("would recompile at boot", outcome.Summary); - Assert.Contains("Feedback", outcome.Summary); - Assert.Contains(outcome.Advisories, a => a.Contains(IncompleteIdentity, StringComparison.Ordinal)); - } - - /// - /// 🚨 THE PLATFORM ROLLS INDEPENDENTLY OF ITS PACKAGES (policy - /// platform-rolls-independently-of-packages). Under the opt-in strict mode - /// (Modules:RequirePrebuilt) the predicate still REFUSES the newest release for the eight - /// missing bakes — and until policy platform-rolls-independently-of-packages the walk - /// declined it and selected the complete one below. - /// Now the newest admitted release is selected regardless, and the refusal survives as what it - /// is: a statement about packages, naming Feedback, on the advisories and the summary. - /// - [Fact] - public async Task UnderRequirePrebuilt_TheNewestIsStillSelected_AndTheMissingBakesBecomeAdvisories() - { - var root = MeasuredRoot(); - - var outcome = await Select(root, RunningVersion, [IncompleteVersion, CompleteVersion], Strict) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Assert.Equal(RollSelectionKind.Update, outcome.Kind); - Assert.Equal(IncompleteVersion, outcome.SelectedVersion); - Assert.True(outcome.ShouldUpdate); - Assert.Empty(outcome.Declined); - Assert.Equal(AllPackages.Length, outcome.RequiredPlugins); - // The denominator stays honest: only the four published packages are SATISFIED — the - // eight that are not follow on their own, and the count says so. - Assert.Equal(NotNamedInTheHold.Length, outcome.SatisfiedPlugins); - - var advisory = outcome.Advisories[0]; - Assert.Contains("independently of packages", advisory); - Assert.Contains("Feedback", advisory); - Assert.Contains(IncompleteIdentity, advisory); - Assert.Contains("not following this roll", outcome.Summary); - Assert.Contains("Feedback", outcome.Summary); - } - - /// - /// 🚨 THE NEGATIVE CONTROL. Run the SAME fixture with an empty denominator and the incomplete - /// release passes the shared predicate outright — "no plugins to check" and "all plugins - /// present" look alike to the predicate. - /// - /// Under policy platform-rolls-independently-of-packages an empty plugin list no - /// longer stops the PLATFORM (it is a statement - /// about packages), so the newest admitted release is selected — but the two states are still - /// never spelled the same way: the summary and the advisory say that NO package was checked, - /// and the denominator printed is zero. - /// - [Fact] - public async Task AnEmptyDenominatorSelectsTheNewest_AndSaysNoPackageWasChecked() - { - var root = MeasuredRoot(); - - // What the predicate says about the incomplete release when nothing is required of it. - var observation = PublishedBundleCatalogue.Read(root, IncompleteVersion); - var vacuous = ReleaseAvailability.IsUpdatable(observation.Target, [], observation.Artifacts); - Assert.True( - vacuous.IsUpdatable, - "with an empty denominator every release is vacuously complete — this is the state the " - + "selector must never turn into a selection"); - - var outcome = await RollSelection.Select( - new RollSelectionInputs( - "memex", - RunningVersion, - [IncompleteVersion, CompleteVersion], - PluginInventory.Of([], "a reader that answered with nothing")), - version => Verdict(root, [], version)) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Assert.Equal(RollSelectionKind.Update, outcome.Kind); - Assert.Equal(IncompleteVersion, outcome.SelectedVersion); - Assert.False(outcome.IsIndeterminate); - Assert.True(outcome.ShouldUpdate); - Assert.Equal(0, outcome.RequiredPlugins); - Assert.Equal(0, outcome.SatisfiedPlugins); - Assert.Contains("0 plugins required", outcome.Summary); - Assert.Contains("no package was checked", outcome.Summary); - Assert.Contains(outcome.Advisories, a => a.Contains("no package was checked", StringComparison.Ordinal)); - } - - /// - /// A plugin list that could not be READ is a different answer again — and under policy - /// platform-rolls-independently-of-packages it - /// does not hold the platform either. It is still never collapsed into "nothing to check": the - /// reason the read failed is in the summary and the advisory. - /// - [Fact] - public async Task AnUnreadableInventorySelectsTheNewest_AndSaysItCouldNotBeRead() - { - var root = MeasuredRoot(); - - var outcome = await RollSelection.Select( - new RollSelectionInputs( - "memex", - RunningVersion, - [IncompleteVersion, CompleteVersion], - PluginInventory.Unreadable("the install records", "the query timed out")), - version => Verdict(root, Installed, version)) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Assert.Equal(RollSelectionKind.Update, outcome.Kind); - Assert.Equal(IncompleteVersion, outcome.SelectedVersion); - Assert.Contains("the query timed out", outcome.Summary); - Assert.Contains("could not be read", outcome.Summary); - Assert.Contains(outcome.Advisories, a => a.Contains("the query timed out", StringComparison.Ordinal)); - } - - /// - /// When NO release ships all plugins, a strict instance used to stay put. Under policy - /// platform-rolls-independently-of-packages the - /// platform rolls to the newest admitted release anyway and NAMES the plugin that does not - /// follow — an advisory, never a silent one and never a hold. - /// - [Fact] - public async Task UnderRequirePrebuilt_WhenNoReleaseShipsEveryPlugin_TheNewestIsSelectedAndThePluginIsNamed() - { - var root = Track(NewRoot()); - Mark(root, IncompleteVersion, IncompleteIdentity); - Mark(root, CompleteVersion, CompleteIdentity); - // Both publications miss the same eight packages. - Seal(root, IncompleteIdentity, "plugins", NotNamedInTheHold); - Seal(root, CompleteIdentity, "plugins", NotNamedInTheHold); - - var outcome = await Select(root, RunningVersion, [IncompleteVersion, CompleteVersion], Strict) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Assert.Equal(RollSelectionKind.Update, outcome.Kind); - Assert.Equal(IncompleteVersion, outcome.SelectedVersion); - Assert.True(outcome.ShouldUpdate); - Assert.False(outcome.IsIndeterminate); - Assert.Empty(outcome.Declined); - Assert.Contains("Feedback", outcome.Summary); - Assert.Contains($"{AllPackages.Length} plugin(s) required", outcome.Summary); - Assert.Contains(RunningVersion, outcome.Summary); - Assert.Contains(outcome.Advisories, a => a.Contains("Feedback", StringComparison.Ordinal)); - } - - /// - /// 🚨 The running release does not ship all its plugins, and older releases do. Until policy - /// platform-rolls-independently-of-packages the walk reported the older complete release - /// as BEHIND current; now no package - /// verdict walks the platform past anything, so the environment is told it already runs the - /// newest admitted release — and the packages that do not follow it are named. Rolling - /// backwards is still never selected. - /// - [Fact] - public async Task ARunningReleaseMissingPackagesIsNeverWalkedPast_ThePackagesAreNamed() - { - var root = MeasuredRoot(); - // The running version publishes nothing at all — a marker with an identity that holds no - // publication, exactly what PublishedBundleCatalogue answers ContentBakeMissing for. The - // strict policy is what makes that a decline (by default it is a boot compile and the - // running version is AlreadyCurrent). - Mark(root, RunningVersion, "s3479running0000000000000000000"); - Directory.CreateDirectory(Path.Combine(root, "s3479running0000000000000000000")); - - var outcome = await Select( - root, RunningVersion, [RunningVersion, IncompleteVersion, CompleteVersion], Strict) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Assert.Equal(RollSelectionKind.AlreadyCurrent, outcome.Kind); - Assert.Equal(RunningVersion, outcome.SelectedVersion); - Assert.False(outcome.ShouldUpdate); - Assert.Contains("already runs", outcome.Summary); - Assert.Contains("not following this roll", outcome.Summary); - } - - /// - /// The steady state is SAID, not left as a silence: the newest complete release is the one - /// already running. - /// - [Fact] - public async Task AnEnvironmentAlreadyOnTheNewestCompleteReleaseIsToldSo() - { - var root = Track(NewRoot()); - Mark(root, CompleteVersion, CompleteIdentity); - Seal(root, CompleteIdentity, "plugins", AllPackages); - - var outcome = await Select(root, CompleteVersion, [CompleteVersion]) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Assert.Equal(RollSelectionKind.AlreadyCurrent, outcome.Kind); - Assert.Equal(CompleteVersion, outcome.SelectedVersion); - Assert.False(outcome.ShouldUpdate); - Assert.Contains("already runs", outcome.Summary); - } - - /// - /// 🚨 The measured LIMIT of this algorithm, pinned so nobody has to rediscover it. - /// - /// #3479 proposed 3.0.0-ci.7926 as the acceptance case: memex.systemorph.com rolled - /// to it at 19:17Z, its bake gate found Feedback/Feedback regressed, and every deal and - /// offer page died (#3472/#3478). The stated expectation was that a completeness selector would - /// have declined 7926 naming Feedback. - /// - /// It would not have, and the reason is measured. CD run 7926 - /// (actions/runs/34050278127, all 26 jobs green) resolved framework identity - /// sc273ee39fdccbfc088f9aaf1fc548a9a and its bake+seal job logged, on both storage - /// targets: - /// - /// ok Feedback/Feedback [5 source(s), 9 dependency record entr(ies)] - /// bake: Feedback → 1 assembly(ies) + 15 node file(s) → /bake/Feedback.zip - /// [PASS] Feedback (13 node(s), 1 type(s)) ok Feedback/Feedback: compile=Ok render=ok tests=ok - /// published: …/prebuilt-bundles/sc273ee39fdccbfc088f9aaf1fc548a9a/plugins/Feedback.zip - /// sealed: …/prebuilt-bundles/sc273ee39fdccbfc088f9aaf1fc548a9a/plugins/_complete (36 bundle(s)) - /// - /// 7926 shipped Feedback. What failed was the INSTANCE: memex.systemorph.com's own - /// copy of the package is missing one source node (Feedback/Feedback/Source/FeedbackContent, - /// absent since 2026-08-26 — the node's compile record shows 4 matched Code nodes and - /// src=4), which had been masked for eleven days by an AdoptedUnverified prebuilt - /// assembly. The identity change made that assembly stale, the pre-warmer compiled the live — - /// incomplete — source set, and 135 diagnostics later the readiness gate refused. - /// - /// So the selector's reach stops at the PUBLICATION, and this test says so out loud: a - /// release that ships every plugin is selected even when this instance will fail to build one of - /// them. The checks that DO see that are instance-side — the combo gate - /// (Doc/Architecture/ComboGateWiring) and the bake sweep itself — and #3478's rule that a - /// refused process must not join the mesh. Weakening the selector to cover it would be - /// guesswork; recording the boundary is not. - /// - [Fact] - public async Task AReleaseThatShipsEveryPluginIsSelectedEvenWhenTheInstanceCannotBuildOne() - { - const string Ci7926 = "3.0.0-ci.7926"; - const string Identity7926 = "sc273ee39fdccbfc088f9aaf1fc548a9a"; - - var root = Track(NewRoot()); - Mark(root, Ci7926, Identity7926); - Seal(root, Identity7926, "plugins", AllPackages); - - var outcome = await Select(root, RunningVersion, [Ci7926]) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Assert.Equal(RollSelectionKind.Update, outcome.Kind); - Assert.Equal(Ci7926, outcome.SelectedVersion); - Assert.Empty(outcome.Declined); - Assert.Equal(AllPackages.Length, outcome.SatisfiedPlugins); - } - - /// - /// The walk is LAZY: under policy platform-rolls-independently-of-packages every verdict - /// is accepted (a package refusal becomes an - /// advisory), so exactly ONE verdict is asked — about the newest admitted release — even when - /// the strict policy refuses it. That keeps a selection over a long publication history one - /// directory read rather than hundreds on a network share against a bounded budget. - /// - [Fact] - public async Task TheWalkAsksExactlyOneVerdict_AboutTheNewest() - { - var root = MeasuredRoot(); - var asked = ImmutableList.CreateBuilder(); - - var outcome = await RollSelection.Select( - new RollSelectionInputs( - "memex", - RunningVersion, - [IncompleteVersion, CompleteVersion, "3.0.0-rc9.ci.7600"], - PluginInventory.Of(Installed, "the install records")), - // No guard around the builder: the walk is sequential by construction — the next - // candidate's verdict is only asked for inside the previous one's decline — and - // that is precisely what this test measures. Strict, so that a decline exists. - version => - { - asked.Add(version); - return Verdict(root, Installed, version, Strict); - }) - .Should().Within(TestTimeouts.Convergence).Emit(); - - Assert.Equal(IncompleteVersion, outcome.SelectedVersion); - Assert.Equal([IncompleteVersion], asked.ToImmutable()); - } - - // ── driving the SHARED predicate ──────────────────────────────────────────────────────────── - - /// The opt-in strict mode — Modules:RequirePrebuilt — under which a missing - /// bake is still a decline (#3651). - private static readonly ReleaseGatePolicy Strict = new(RequirePrebuilt: true); - - /// The selection, judged by exactly the predicate the deployment gate runs. - private static IObservable Select( - string root, string? current, ImmutableArray candidatesNewestFirst, - ReleaseGatePolicy? policy = null) => - RollSelection.Select( - new RollSelectionInputs( - "memex", - current, - candidatesNewestFirst, - PluginInventory.Of(Installed, "the install records (Plugins/*, nodeType:Package)")), - version => Verdict(root, Installed, version, policy)); - - /// 🚨 The SHARED predicate, not a copy of it: the same - /// + + - /// - /// composition ReleaseAvailabilityService runs in production. - private static IObservable Verdict( - string root, ImmutableArray required, string version, - ReleaseGatePolicy? policy = null) => - Observable.Defer(() => - { - var observation = PublishedBundleCatalogue.Read(root, version); - var measured = ModuleLinkObservation.Measure(observation.Artifacts, required); - return Observable.Return(ReleaseAvailability.IsUpdatable( - observation.Target, required, measured, policy ?? ReleaseGatePolicy.Default)); - }); - - // ── fixture ───────────────────────────────────────────────────────────────────────────────── - - /// The measured shape: an older identity that sealed every package, and the newer one - /// that sealed only the four the real hold did not name. - private string MeasuredRoot() - { - var root = Track(NewRoot()); - Mark(root, IncompleteVersion, IncompleteIdentity); - Mark(root, CompleteVersion, CompleteIdentity); - Seal(root, CompleteIdentity, "plugins", AllPackages); - Seal(root, IncompleteIdentity, "plugins", NotNamedInTheHold); - return root; - } - - private static string NewRoot() => - Path.Combine(Path.GetTempPath(), "mw-3479-" + Guid.NewGuid().ToString("N")); - - /// The release marker: version → the framework identity that release resolves. The one - /// way anything outside the image can learn it. - private static void Mark(string root, string version, string identity) - { - var markers = Path.Combine(root, PublishedBundleCatalogue.ReleaseMarkerDirectoryName); - Directory.CreateDirectory(markers); - File.WriteAllText(Path.Combine(markers, version), identity); - } - - /// A SEALED source publication: the bundles, then the sentinel that lists them. - private static void Seal(string root, string identity, string source, IEnumerable bundles) - { - var directory = Path.Combine(root, identity, source); - Directory.CreateDirectory(directory); - var names = new List(); - foreach (var bundle in bundles) - { - WriteBundle(Path.Combine(directory, bundle + ".zip"), bundle, identity); - names.Add(bundle + ".zip"); - } - File.WriteAllText( - Path.Combine(directory, ShippedPrebuiltBundles.CompletionSentinelFileName), - string.Join('\n', names.Order(StringComparer.Ordinal)) + "\n"); - } - - /// A bundle carrying a real manifest with no module dependency, so the #3175 sealed-set - /// consistency rule has nothing to say and these tests measure only completeness. - private static void WriteBundle(string path, string bundle, string identity) - { - var manifest = new BundleReader.Manifest( - bundle, "1.0", identity, - [ - new BundleReader.AssemblyRef( - $"{bundle}/Type", $"{bundle}_Type.dll", - new Dictionary(StringComparer.Ordinal) - { - ["MeshWeaver.Layout"] = MeshWeaver.Compiler.CompiledDependencies.RefAsmScheme + "abc", - }), - ]); - using var file = File.Create(path); - using var zip = new ZipArchive(file, ZipArchiveMode.Create); - using var stream = zip.CreateEntry(NuGetPackageWriter.ManifestEntry).Open(); - stream.Write(JsonSerializer.SerializeToUtf8Bytes( - manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web))); - } - - /// Every published root this test built, removed on teardown — a leaked temp tree - /// bloats the CI agent and is one more way for two runs to interfere. - private readonly List roots = []; - - private string Track(string root) - { - roots.Add(root); - return root; - } - - public void Dispose() - { - foreach (var root in roots) - { - try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } - } - GC.SuppressFinalize(this); - } -} diff --git a/test/Memex.Portal.Shared.Test/RollTargetIsThePollersSelectionTest.cs b/test/Memex.Portal.Shared.Test/RollTargetIsThePollersSelectionTest.cs deleted file mode 100644 index 5ad8242263..0000000000 --- a/test/Memex.Portal.Shared.Test/RollTargetIsThePollersSelectionTest.cs +++ /dev/null @@ -1,239 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 /api/plugins/roll-target names the release the POLLER would take (#4413). -/// -/// Measured: combo verification asked memex for its roll target, was told -/// 3.1.11003, and failed docker pull memex-portal-ai:3.1.11003 … manifest unknown. -/// The route selected over the published root's _releases/<version> markers, which -/// main-cd's publish-bake writes after promote and before arm writes the portal -/// tag — so a set the fleet had not armed was "the roll target" while the poller, which selects over -/// the registry's portal tags, could never take it. These tests drive the real route over a real -/// poller and a real mesh, with the availability gate answering the MARKER universe when it is asked -/// to choose on its own: before the fix the route answered the marker-only release. -/// -public class RollTargetIsThePollersSelectionTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// A release whose portal tag exists in the registry (armed). - private const string Armed = "9999.0.0-ci.1"; - - /// A release with a published-root marker and NO portal tag (promoted, not armed). - private const string MarkerOnly = "9999.0.0-ci.2"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddUpdatePolicyType(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - [Fact(Timeout = 240_000)] - public async Task TheRollTarget_IsTheNewestPortalTag_NeverAMarkerWithoutAnImage() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - - var root = await AskRollTarget(new MarkerUniverseGate(Mesh), new Registry([Armed]), ct); - - root.GetProperty("selected").GetString().Should().Be(Armed, - "the route names what the poller would roll to — a release with a portal image — and " - + $"never {MarkerOnly}, whose marker exists while its portal tag does not"); - root.GetProperty("shouldUpdate").GetBoolean().Should().BeTrue(); - root.GetProperty("indeterminate").GetBoolean().Should().BeFalse(); - } - - [Fact(Timeout = 240_000)] - public async Task AGateThatSelectsNothing_StillNamesTheTagThePollerTakes() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - - var root = await AskRollTarget(new NotEnforcedGate(Mesh), new Registry([Armed]), ct); - - root.GetProperty("selected").GetString().Should().Be(Armed, - "a deployment that consumes no CI bakes still rolls to the newest admitted tag, so that " - + "is the target to verify — 'nothing to verify' would pass a roll nobody checked"); - root.GetProperty("advisories").EnumerateArray().Should().NotBeEmpty( - "why no package was checked is said on the selection"); - } - - [Fact(Timeout = 240_000)] - public async Task AListingThatFaults_IsIndeterminate_NeverAnEmptyChoice() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - - var root = await AskRollTarget(new MarkerUniverseGate(Mesh), new Registry(null), ct); - - root.GetProperty("selected").ValueKind.Should().Be(JsonValueKind.Null); - root.GetProperty("indeterminate").GetBoolean().Should().BeTrue( - "a registry that could not be listed is 'could not look', never 'nothing to roll to'"); - root.GetProperty("environment").GetString().Should().Contain("registry unreachable"); - } - - [Fact(Timeout = 240_000)] - public async Task NothingNewerInTheRegistry_SelectsNothing_AndSaysSo() - { - var ct = TestContext.Current.CancellationToken; - await Seed(ct); - - var root = await AskRollTarget( - new MarkerUniverseGate(Mesh), new Registry([ShippedReleaseSeed.InstalledPlatformVersion]), ct); - - root.GetProperty("selected").ValueKind.Should().Be(JsonValueKind.Null, - "the running tag is the newest the registry lists, so the poller rolls nowhere"); - root.GetProperty("indeterminate").GetBoolean().Should().BeFalse(); - root.GetProperty("kind").GetString().Should().Be(nameof(RollSelectionKind.NoCandidates)); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Harness - // ══════════════════════════════════════════════════════════════════════════ - - private async Task AskRollTarget( - ReleaseAvailabilityService gate, IAcrTagLister registry, CancellationToken ct) - { - var poller = new GatedPoller(Mesh, registry, gate); - var http = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .AddSingleton(gate) - .AddSingleton(poller) - .BuildServiceProvider(), - }; - using var body = new MemoryStream(); - http.Response.Body = body; - - var result = await ReleaseGateEndpoints.Choose(http, current: null) - .FirstAsync().Timeout(Budget).Await(ct); - await result.ExecuteAsync(http); - - http.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - using var json = JsonDocument.Parse(body.ToArray()); - Output.WriteLine(json.RootElement.ToString()); - return json.RootElement.Clone(); - } - - /// The registry's portal tag listing; a null listing is a registry that faults. - private sealed class Registry(ImmutableArray? tags) : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - tags is { } listed - ? Task.FromResult>(listed) - : Task.FromException>(new InvalidOperationException("registry unreachable")); - } - - private static UpdatabilityVerdict Updatable(string version) => - ReleaseAvailability.IsUpdatable( - new ReleaseTarget(version, "sid"), [new RequiredPackage("Crm", "Crm")], ReleaseArtifacts.Of(["Crm"])); - - /// - /// The availability gate as it behaves on an instance whose published root carries a marker - /// for : asked to choose ON ITS OWN (no candidates), it selects over - /// the markers and names ; handed the poller's candidates, it walks them. - /// - private sealed class MarkerUniverseGate(IMessageHub hub) - : ReleaseAvailabilityService(hub, new ConfigurationBuilder().Build()) - { - public override IObservable IsUpdatable(string? targetVersion) => - Observable.Return(Updatable(targetVersion ?? Armed)); - - public override IObservable SelectRollTarget( - string? currentVersion, - IReadOnlyList? candidatesNewestFirst = null, - Func? condemned = null, - UpdatePolicyKind policy = UpdatePolicyKind.Continuous, - bool requireCiGreen = true) => - RollSelection.Select( - new RollSelectionInputs( - "test", currentVersion, - [.. candidatesNewestFirst ?? [MarkerOnly, Armed]], - PluginInventory.Of([new RequiredPackage("Crm", "Crm")], "test")), - version => Observable.Return(Updatable(version))); - } - - /// The gate on a deployment that consumes no CI bakes: it selects nothing. - private sealed class NotEnforcedGate(IMessageHub hub) - : ReleaseAvailabilityService(hub, new ConfigurationBuilder().Build()) - { - public override IObservable SelectRollTarget( - string? currentVersion, - IReadOnlyList? candidatesNewestFirst = null, - Func? condemned = null, - UpdatePolicyKind policy = UpdatePolicyKind.Continuous, - bool requireCiGreen = true) => - Observable.Return(new RollSelectionOutcome( - RollSelectionKind.NotEnforced, null, currentVersion, 0, 0, [], - "test: no release is selected — this deployment consumes no CI bakes")); - } - - private sealed class NoPatch : IDeploymentUpdater - { - public bool CanPatch => false; - - public Task LastRolledAtAsync(CancellationToken ct) => - Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) => - throw new InvalidOperationException("the roll-target route must never patch"); - } - - private sealed class GatedPoller(IMessageHub hub, IAcrTagLister registry, ReleaseAvailabilityService gate) - : SelfUpdateHostedService(hub, registry, new NoPatch(), new SelfUpdateOptions - { - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - }) - { - protected override ReleaseAvailabilityService? ResolveAvailabilityGate() => gate; - } - - /// Seeds the policy node: Continuous, following the ci line. - private Task Seed(CancellationToken cancellationToken) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, Pattern = "*-ci*" }, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } -} diff --git a/test/Memex.Portal.Shared.Test/RunTestsBudgetCoversThePreflightTest.cs b/test/Memex.Portal.Shared.Test/RunTestsBudgetCoversThePreflightTest.cs deleted file mode 100644 index 874dc85231..0000000000 --- a/test/Memex.Portal.Shared.Test/RunTestsBudgetCoversThePreflightTest.cs +++ /dev/null @@ -1,71 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.Testing.InMesh; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// MeshWeaver.Plugins#3513: run_tests's timeoutSeconds bounds the WHOLE run, the -/// preflight included. The preflight (read the versions under test, converge the mesh to its target -/// set) used to run OUTSIDE the budget, so a preflight that took longer than the budget held the -/// activity at its title line with no Failed — measured on a portal: dispatched with 300 s, -/// silent for 8 min 35 s. Here the preflight NEVER answers; the run must still end Failed -/// at its budget, naming the phase it was stuck in. -/// -public class RunTestsBudgetCoversThePreflightTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Node = "RunTestsPreflightProbe"; - - /// A preflight that never decides — the shape of a convergence pass outliving the run's budget. - private sealed class NeverAnsweringPreflight : ITestRunPreflight - { - public IObservable Prepare(bool mayConverge) => Observable.Never(); - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddMeshNodes(TestUsers.SampleUsers()) - .AddMeshNodes(new MeshNode(Node) { Name = "Probe" }) - .ConfigureServices(services => services.AddSingleton(new NeverAnsweringPreflight())) - .ConfigureDefaultNodeHub(config => config.AddLayout(layout => layout - .WithView("Tests", (LayoutAreaHost host, RenderingContext _) => - MeshTestRunner.Area(host, "Probe", [MeshTestCase.Of("passes", () => { })])))); - - [HubFact] - public async Task RunTests_PreflightOutlivingTheBudget_EndsFailed_NamingThePreflight() - { - var answer = await new MeshOperations(Mesh).RunTests("@" + Node, timeoutSeconds: 2) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - using var doc = JsonDocument.Parse(answer); - Assert.Equal("Dispatched", doc.RootElement.GetProperty("status").GetString()); - var activityPath = doc.RootElement.GetProperty("activityPath").GetString()!; - - // The budget is 2 s; the bound here only has to outlast it plus the activity write-back. - var log = await Mesh.GetMeshNodeStream(activityPath) - .Select(node => node.ContentAs(Mesh.JsonSerializerOptions)) - .Where(l => l?.Status is ActivityStatus.Succeeded or ActivityStatus.Failed) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(20)).Await(); - - Assert.Equal(ActivityStatus.Failed, log!.Status); - var lines = log.Messages.Select(m => m.Message).ToList(); - Assert.Contains(lines, l => l.Contains("within 2s", StringComparison.Ordinal) - && l.Contains("preflight", StringComparison.Ordinal)); - Assert.DoesNotContain(lines, l => l.Contains("cases passed", StringComparison.Ordinal)); - } -} diff --git a/test/Memex.Portal.Shared.Test/RunTestsStreamsIntoAnActivityTest.cs b/test/Memex.Portal.Shared.Test/RunTestsStreamsIntoAnActivityTest.cs deleted file mode 100644 index bdb0b6a830..0000000000 --- a/test/Memex.Portal.Shared.Test/RunTestsStreamsIntoAnActivityTest.cs +++ /dev/null @@ -1,73 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using MeshWeaver.Testing.InMesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// RunTests (the run_tests tool, memex tests) keeps ONE subscription to a -/// node's streaming Tests area and turns its frames into an activity log: a line per case as -/// it runs and as it lands, the case's output, and a terminal status that is the verdict. -/// -/// Why it exists: rendering the area RUNS the suite, and a one-shot get …/area/Tests -/// opens a fresh subscription each time — polling it re-runs every live case (measured on -/// memex.meshweaver.cloud: each read filed the Maintenance suite's request nodes again). -/// -public class RunTestsStreamsIntoAnActivityTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Node = "RunTestsProbe"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddMeshNodes(TestUsers.SampleUsers()) - .AddMeshNodes(new MeshNode(Node) { Name = "Probe" }) - .ConfigureDefaultNodeHub(config => config.AddLayout(layout => layout - .WithView("Tests", (LayoutAreaHost host, RenderingContext _) => - MeshTestRunner.Area(host, "Probe", - [ - MeshTestCase.Live("slow", log => - { - log("contacted the service"); - return Observable.Timer(TimeSpan.FromMilliseconds(2500)).Select(_ => Unit.Default); - }), - MeshTestCase.Of("fails", () => throw new InvalidOperationException("the assertion message")), - ])))); - - [HubFact] - public async Task RunTests_LogsEveryCase_AndEndsWithTheVerdict() - { - var answer = await new MeshOperations(Mesh).RunTests("@" + Node, timeoutSeconds: 60) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - using var doc = JsonDocument.Parse(answer); - Assert.Equal("Dispatched", doc.RootElement.GetProperty("status").GetString()); - var activityPath = doc.RootElement.GetProperty("activityPath").GetString()!; - - var log = await Mesh.GetMeshNodeStream(activityPath) - .Select(node => node.ContentAs(Mesh.JsonSerializerOptions)) - .Where(l => l?.Status is ActivityStatus.Succeeded or ActivityStatus.Failed) - .FirstAsync() - .Timeout(TimeSpan.FromSeconds(60)).Await(); - - var lines = log!.Messages.Select(m => m.Message).ToList(); - Assert.Equal(ActivityStatus.Failed, log.Status); - Assert.Contains(lines, l => l.StartsWith("▶ slow", StringComparison.Ordinal) && l.Contains("contacted the service")); - Assert.Contains(lines, l => l.StartsWith("✅", StringComparison.Ordinal) && l.Contains("slow")); - Assert.Contains(lines, l => l.StartsWith("❌", StringComparison.Ordinal) && l.Contains("the assertion message")); - Assert.Contains(lines, l => l.Contains("1 of 2 cases passed", StringComparison.Ordinal)); - } -} diff --git a/test/Memex.Portal.Shared.Test/SealedBundleFloorCacheTest.cs b/test/Memex.Portal.Shared.Test/SealedBundleFloorCacheTest.cs deleted file mode 100644 index be508afe09..0000000000 --- a/test/Memex.Portal.Shared.Test/SealedBundleFloorCacheTest.cs +++ /dev/null @@ -1,530 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using MeshWeaver.Fixture; -using MeshWeaver.Mesh.Threading; -using Microsoft.Extensions.Logging; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The deployment gate's denominator may not be re-opened from the share on every tick -/// (#4742). -/// -/// PublishedBundleCatalogue.EverSealedBundles walks EVERY framework-identity directory -/// the published root has ever held, listing each one's sources and opening each source's pointer -/// and completion sentinel — and it is the first thing the availability gate and the roll selector -/// do, inside a 60 s verdict budget, against an Azure Files share. The store is append-only and CD -/// cuts roughly sixty sets a day, each moving the framework identity, so the walk's cost only grows. -/// Measured on memex.meshweaver.cloud 2026-09-18 at 18:45:36Z and 19:16:36Z, on two freshly sealed -/// candidates: the availability check did not answer within 60s both times. That is a HOLD, so -/// the public instance stayed on a six-day-old build while CD kept sealing, with eight Store -/// NodeTypes parked at compilationStatus: Error as a result. -/// -/// The control is EXECUTED, not asserted. -/// runs the OLD -/// reading and the new one over the same sabotaged fixture in one test, so it cannot pass because -/// the fixture happened to be empty: the open-every-time reading collapses to nothing while the -/// remembered one still answers. -/// -/// 🚨 And fail-closed is the other half of the subject, because a cache that served a -/// stale "available" would be far worse than the freeze it removes. Each clause has its own test: a -/// refusal is never remembered; a source ADDED under an identity already read is picked up; a source -/// REPUBLISHED IN PLACE that adds a package is picked up (the case an identity-level stamp cannot -/// see, and the reason the signal lives on the source directory); a pruned identity is forgotten -/// while a FAILED read evicts nothing; and the one direction the cache may err in is a LARGER -/// denominator, which can only hold. -/// -public class SealedBundleFloorCacheTest : IDisposable -{ - /// Every published root this test built, removed on teardown — a leaked temp tree - /// bloats the CI agent and is one more way for two runs to interfere. - private readonly List roots = []; - - public void Dispose() - { - foreach (var root in roots) - { - try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } - } - GC.SuppressFinalize(this); - } - - /// The bundle every generated identity seals, so a floor can be recognised. - private const string Platform = "Documentation"; - - /// The source every generated identity seals it under. - private const string PlatformSource = "meshweaver-content"; - - // ── the fix: O(new or changed sources), not O(every publication ever) ─────────────────────── - - [Fact] - public void ASourceAlreadyReadIsNotOpenedAgain_AndANewOneAlwaysIs() - { - var root = RootWithIdentities(12); - var cache = new SealedBundleFloorCache(); - - var first = cache.Read(root); - - // The whole store on a cold cache — this is the reading that used to happen every tick. - Assert.Equal(12, first.Identities); - Assert.Equal(12L, cache.SourcesRead); - Assert.Equal(0L, cache.SourcesRecalled); - - var second = cache.Read(root); - - // 🚨 THE CLAIM: not one publication re-opened, and the same answer. The old reading has no - // such property by construction — it opens every pointer and every sentinel, every time. - Assert.Equal(12L, cache.SourcesRead); - Assert.Equal(12L, cache.SourcesRecalled); - Assert.Equal(first.Identities, second.Identities); - Assert.True(first.Bundles.SetEquals(second.Bundles)); - - // A NEW identity — what CD produces about sixty times a day — costs exactly itself. - SealIdentity(root, "s4742new0000000000000000000000000", "plugins", ["Store"]); - var third = cache.Read(root); - - Assert.Equal(13L, cache.SourcesRead); - Assert.Equal(13, third.Identities); - Assert.Contains("Store", third.Bundles); - } - - [Fact] - public void ARememberedSourceIsNotOpenedAgain_MeasuredAgainstTheReadingThatDoes() - { - var root = RootWithIdentities(6); - var cache = new SealedBundleFloorCache(); - - var warm = cache.Read(root); - Assert.Equal(6, warm.Identities); - Assert.Contains(Platform, warm.Bundles); - - // Empty every seal IN PLACE. Rewriting an existing file changes no directory ENTRY, so no - // source directory's write stamp moves — which is precisely the one class of change the - // remembered reading is documented not to follow, and the one that can only ever SHRINK a - // declaration (the direction that holds, never exempts). - foreach (var sentinel in Directory.EnumerateFiles( - root, - ShippedPrebuiltBundles.CompletionSentinelFileName, - SearchOption.AllDirectories)) - File.WriteAllText(sentinel, string.Empty); - - // ── the NEGATIVE CONTROL, executed ────────────────────────────────────────────────────── - // The reading that opens every sentinel on every tick now sees nothing at all. If this line - // ever stops failing that way, the assertions below are measuring nothing. - var opened = PublishedBundleCatalogue.EverSealedBundles(root); - Assert.Null(opened.Refusal); - Assert.Equal(0, opened.Identities); - Assert.Empty(opened.Bundles); - - // ── the fix ───────────────────────────────────────────────────────────────────────────── - var readBefore = cache.SourcesRead; - var again = cache.Read(root); - - Assert.Equal(readBefore, cache.SourcesRead); - Assert.Equal(warm.Identities, again.Identities); - Assert.True(warm.Bundles.SetEquals(again.Bundles)); - } - - [Fact] - public void TheCachedFloorIsTheSameFloorTheFullWalkReads() - { - var root = RootWithIdentities(4); - SealIdentity(root, "s4742extra0000000000000000000000", "education", ["ThinkInStreams"]); - - var walked = PublishedBundleCatalogue.EverSealedBundles(root); - var cached = new SealedBundleFloorCache().Read(root); - - Assert.Equal(walked.Identities, cached.Identities); - Assert.True(walked.Bundles.SetEquals(cached.Bundles)); - Assert.Equal(walked.Refusal, cached.Refusal); - Assert.Equal(walked.ServesBakes, cached.ServesBakes); - } - - // ── fail-closed ───────────────────────────────────────────────────────────────────────────── - - [Fact] - public void ASourceSealedUnderAnIdentityAlreadyReadIsPickedUp() - { - // Core's CD seals /meshweaver-content, and a satellite repo bakes - // /education against that SAME platform identity later. Missing the second one - // would shrink the denominator, which is the one direction that EXEMPTS a package (#3461). - var root = RootWithIdentities(3); - var cache = new SealedBundleFloorCache(); - - Assert.DoesNotContain("ThinkInStreams", cache.Read(root).Bundles); - - Seal(FirstIdentityOf(root), "education", ["ThinkInStreams"]); - - var after = cache.Read(root); - Assert.Contains("ThinkInStreams", after.Bundles); - Assert.Equal(3, after.Identities); - } - - [Fact] - public void ARepublishedSourceThatAddsAPackageIsPickedUp() - { - // 🚨 THE CASE AN IDENTITY-LEVEL STAMP CANNOT SEE, and the reason the signal lives on the - // SOURCE directory. A satellite re-baking into a platform identity that has not moved is a - // routine daily event: the same source directory is republished with a longer package list, - // and the IDENTITY directory's own stamp does not move at all. Keying on it would freeze the - // new package out of the denominator for as long as that identity stayed newest, which - // exempts it from the gate. This test fails against that design and passes against this one. - var root = RootWithIdentities(3); - var cache = new SealedBundleFloorCache(); - - Assert.DoesNotContain("Northwind", cache.Read(root).Bundles); - - var identity = FirstIdentityOf(root); - var identityStampBefore = Directory.GetLastWriteTimeUtc(identity); - Seal(identity, PlatformSource, [Platform, "Northwind"]); - - // The identity directory is untouched by a republication of a source it already held — said - // out loud, because it is the whole premise of this test rather than a detail of it. - Assert.Equal(identityStampBefore, Directory.GetLastWriteTimeUtc(identity)); - - var after = cache.Read(root); - Assert.Contains("Northwind", after.Bundles); - Assert.Contains(Platform, after.Bundles); - Assert.Equal(3, after.Identities); - } - - [Fact] - public void APrunedIdentityIsForgotten_AndAFailedReadEvictsNothing() - { - var root = RootWithIdentities(4); - var cache = new SealedBundleFloorCache(); - - Assert.Equal(4, cache.Read(root).Identities); - Assert.Equal(4, cache.Remembered); - Assert.Equal(0L, cache.SourcesForgotten); - - // Retention removes an identity. Its publication must leave memory too — otherwise every - // publication this process has ever seen stays rooted for the life of the mesh. - Directory.Delete(FirstIdentityOf(root), recursive: true); - - Assert.Equal(3, cache.Read(root).Identities); - Assert.Equal(3, cache.Remembered); - Assert.Equal(1L, cache.SourcesForgotten); - - // 🚨 And the other half: a read that FAILED must evict nothing. A share we could not finish - // reading is not evidence that anything left it — treating it as such would be the - // fail-open direction wearing tidying's clothes. - var broken = Path.Combine(root, "s4742pointer00000000000000000000", "plugins"); - Directory.CreateDirectory(broken); - File.WriteAllText( - Path.Combine(broken, ShippedPrebuiltBundles.PublicationPointerFileName), - "nested/elsewhere\n"); - - Assert.NotNull(cache.Read(root).Refusal); - Assert.Equal(3, cache.Remembered); - Assert.Equal(1L, cache.SourcesForgotten); - } - - [Fact] - public void AnUnfollowablePointerRefuses_AndTheRefusalIsNeverRemembered() - { - var root = RootWithIdentities(2); - var cache = new SealedBundleFloorCache(); - Assert.Null(cache.Read(root).Refusal); - - // A publication pointer this reader will not follow, with no sealed publication behind it: - // what that source declares could not be read, so the whole floor refuses. - var identity = Path.Combine(root, "s4742pointer00000000000000000000"); - var source = Path.Combine(identity, "plugins"); - Directory.CreateDirectory(source); - File.WriteAllText( - Path.Combine(source, ShippedPrebuiltBundles.PublicationPointerFileName), - "nested/elsewhere\n"); - - var refused = cache.Read(root); - Assert.NotNull(refused.Refusal); - Assert.False(refused.ServesBakes); - - // 🚨 The refusal must not latch. A cached terminal turns one transient share fault into a - // permanent freeze — the exact failure PromiseCache exists to prevent (#1369), here on a - // read that repeats rather than one that happens once. - File.Delete(Path.Combine(source, ShippedPrebuiltBundles.PublicationPointerFileName)); - Seal(identity, "plugins", ["Store"]); - - var recovered = cache.Read(root); - Assert.Null(recovered.Refusal); - Assert.Contains("Store", recovered.Bundles); - Assert.Equal(3, recovered.Identities); - } - - [Fact] - public void AnAbsentRootRefuses_AndIsNotAnEmptyFloor() - { - var root = Track(Path.Combine(Path.GetTempPath(), "mw-4742-" + Guid.NewGuid().ToString("N"))); - var cache = new SealedBundleFloorCache(); - - // A deployment that declares it consumes CI bakes and whose storage is not there is a - // mis-mount, never evidence that nothing is published. Cannot determine ≠ clear to proceed. - var missing = cache.Read(root); - Assert.NotNull(missing.Refusal); - Assert.False(missing.ServesBakes); - Assert.Empty(missing.Bundles); - - SealIdentity(root, "s4742mounted00000000000000000000", "plugins", ["Store"]); - - var mounted = cache.Read(root); - Assert.Null(mounted.Refusal); - Assert.True(mounted.ServesBakes); - Assert.Contains("Store", mounted.Bundles); - } - - [Fact] - public void ARootThatIsReadableAndHoldsNothingIsNotARefusal() - { - // The one stated applicability exemption, which the cache must keep telling apart from - // "I could not look": ZERO identities, no refusal. - var root = Track(Path.Combine(Path.GetTempPath(), "mw-4742-" + Guid.NewGuid().ToString("N"))); - Directory.CreateDirectory(Path.Combine(root, PublishedBundleCatalogue.ReleaseMarkerDirectoryName)); - - var floor = new SealedBundleFloorCache().Read(root); - - Assert.Null(floor.Refusal); - Assert.Equal(0, floor.Identities); - Assert.False(floor.ServesBakes); - } - - [Fact] - public void TwoRootsInOneProcessDoNotEvictEachOther() - { - // Entries carry the root they were read under, so a complete enumeration of one root can - // never prune another's — the cache is an instance, but an instance can still serve two - // mounts (a test host, a registry answering for more than one store). - var first = RootWithIdentities(3); - var second = RootWithIdentities(2); - var cache = new SealedBundleFloorCache(); - - cache.Read(first); - cache.Read(second); - Assert.Equal(5, cache.Remembered); - - cache.Read(first); - Assert.Equal(5, cache.Remembered); - Assert.Equal(0L, cache.SourcesForgotten); - } - - // ── the secondary half: the budget is a configuration key, with the same fail-closed default ─ - - [Fact] - public void TheAnswerBudgetIsConfigurable_AndItsDefaultIsTheOneThatShipped() - { - Assert.Equal( - SelfUpdateOptions.DefaultAvailabilityAnswerBudget, - new SelfUpdateOptions().AvailabilityAnswerBudget); - - const string Declared = "00:03:00"; - var options = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [$"{SelfUpdateOptions.SectionName}:AvailabilityAnswerBudget"] = Declared, - }) - .Build() - .GetSection(SelfUpdateOptions.SectionName) - .Get(); - - Assert.NotNull(options); - Assert.Equal(TimeSpan.Parse(Declared), options!.AvailabilityAnswerBudget); - } - - [Fact] - public void ACancelledPointerReadStopsBeforeAnAbsentPointerBecomesAFallback() - { - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); - var source = Track(Path.Combine(Path.GetTempPath(), "mw-5719-" + Guid.NewGuid().ToString("N"))); - - var failure = Assert.Throws(() => - ShippedPrebuiltBundles.ResolvePublicationPointer(source, cancellation.Token)); - - Assert.Equal(cancellation.Token, failure.CancellationToken); - } - - [Fact] - public void CancellationDuringPointerResolutionDoesNotReturnAPublicationFault() - { - var root = RootWithIdentities(1); - var source = Path.Combine(FirstIdentityOf(root), PlatformSource); - File.WriteAllText(Path.Combine(source, ShippedPrebuiltBundles.PublicationPointerFileName), "../outside\n"); - using var cancellation = new CancellationTokenSource(); - var logger = new PointerWarningLogger(cancellation.Cancel); - - Assert.Throws(() => - ShippedPrebuiltBundles.ResolvePublicationPointer(source, cancellation.Token, logger)); - - Assert.True(logger.SawPointerWarning); - } - - [Fact] - public void ACancelledReadStopsBeforeAnAbsentRootBecomesAnAvailabilityVerdict() - { - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); - var root = Track(Path.Combine(Path.GetTempPath(), "mw-5719-" + Guid.NewGuid().ToString("N"))); - var cache = new SealedBundleFloorCache(); - - var failure = Assert.Throws(() => cache.Read(root, cancellation.Token)); - - Assert.Equal(cancellation.Token, failure.CancellationToken); - Assert.Equal(0, cache.Remembered); - } - - [Fact] - public void CancellationDuringAPublicationReadDoesNotRememberItOrPruneUnvisitedSources() - { - var root = RootWithIdentities(2); - var cache = new SealedBundleFloorCache(); - Assert.Null(cache.Read(root).Refusal); - var remembered = cache.Remembered; - var reads = cache.SourcesRead; - Directory.Delete(FirstIdentityOf(root), recursive: true); - - // The real pointer reader warns and falls back to this sealed flat publication. - // Cancel at that boundary, after the walk started, without timing a directory race. - const string identity = "a5719cancel"; - SealIdentity(root, identity, "plugins", ["Store"]); - File.WriteAllText(Path.Combine(root, identity, "plugins", - ShippedPrebuiltBundles.PublicationPointerFileName), "../outside\n"); - using var cancellation = new CancellationTokenSource(); - var logger = new PointerWarningLogger(cancellation.Cancel); - - Assert.Throws(() => cache.Read(root, cancellation.Token, logger)); - - Assert.True(logger.SawPointerWarning); - Assert.Equal(remembered, cache.Remembered); - Assert.Equal(reads, cache.SourcesRead); - Assert.Equal(0L, cache.SourcesForgotten); - - // Cancellation must not become a cached refusal; the next uncancelled read is whole. - var next = cache.Read(root); - Assert.Null(next.Refusal); - Assert.Contains("Store", next.Bundles); - Assert.Equal(2, next.Identities); - Assert.Equal(1L, cache.SourcesForgotten); - } - - [Fact] - public async Task EndingTheRealPooledObservationCancelsItsWalkBeforeItCachesThePublication() - { - var root = RootWithIdentities(1); - var source = Path.Combine(FirstIdentityOf(root), PlatformSource); - File.WriteAllText(Path.Combine(source, ShippedPrebuiltBundles.PublicationPointerFileName), "../outside\n"); - var cache = new SealedBundleFloorCache(); - using var pool = new IoPool(1); - using var entered = new AsyncSubject(); - var release = 0; - var logger = new PointerWarningLogger(() => - { - entered.OnNext(Unit.Default); - entered.OnCompleted(); - Assert.True(SpinWait.SpinUntil(() => Volatile.Read(ref release) == 1, TestTimeouts.Quick), - "the test must release the publication reader"); - }); - Exception? observedError = null; - using var observation = cache.Observe(pool, root, logger).Subscribe(_ => { }, ex => observedError = ex); - try - { - await entered.Should().Within(TestTimeouts.Quick).Emit( - "the real filesystem worker must have reached the pointer read", - cancellationToken: TestContext.Current.CancellationToken); - observation.Dispose(); // Cancels the actual InvokeBlocking worker token. - } - finally - { - observation.Dispose(); - Volatile.Write(ref release, 1); - pool.Dispose(); - await pool.Disposed.Should().Within(TestTimeouts.Quick).Emit( - "join the filesystem worker before asserting or deleting its fixture", - cancellationToken: TestContext.Current.CancellationToken); - } - - Assert.Null(observedError); // An ended Rx subscription receives no terminal. - Assert.True(logger.SawPointerWarning); - Assert.Equal(0, cache.Remembered); - Assert.Equal(0L, cache.SourcesRead); - } - - private sealed class PointerWarningLogger(Action onWarning) : ILogger - { - public bool SawPointerWarning { get; private set; } - public IDisposable? BeginScope(TState state) where TState : notnull => null; - public bool IsEnabled(LogLevel logLevel) => true; - public void Log(LogLevel logLevel, EventId eventId, TState state, - Exception? exception, Func formatter) - { - if (logLevel != LogLevel.Warning || !formatter(state, exception).Contains("publication pointer")) - return; - SawPointerWarning = true; - onWarning(); - } - } - - // ── fixture ───────────────────────────────────────────────────────────────────────────────── - - private string Track(string root) - { - roots.Add(root); - return root; - } - - /// The lowest-ordinal framework-identity directory — the release markers are not one. - private static string FirstIdentityOf(string root) => - Directory.EnumerateDirectories(root) - .Where(d => !string.Equals( - Path.GetFileName(d), - PublishedBundleCatalogue.ReleaseMarkerDirectoryName, - StringComparison.Ordinal)) - .OrderBy(d => d, StringComparer.Ordinal) - .First(); - - /// A published root carrying distinct framework identities, - /// each with one sealed source — the shape the share accumulates, one identity per set. - private string RootWithIdentities(int count) - { - var root = Track(Path.Combine(Path.GetTempPath(), "mw-4742-" + Guid.NewGuid().ToString("N"))); - Directory.CreateDirectory(Path.Combine(root, PublishedBundleCatalogue.ReleaseMarkerDirectoryName)); - for (var i = 0; i < count; i++) - SealIdentity(root, $"s4742{i:D27}", PlatformSource, [Platform]); - return root; - } - - private static void SealIdentity( - string root, string identity, string source, IEnumerable bundles) => - Seal(Path.Combine(root, identity), source, bundles); - - /// A SEALED source publication: the bundle files, then the sentinel that lists them, - /// written strictly last — the publisher's own order, which is what makes the sentinel the - /// atomic "this publication is whole" fact. - private static void Seal(string identityDirectory, string source, IEnumerable bundles) - { - var directory = Path.Combine(identityDirectory, source); - Directory.CreateDirectory(directory); - var names = new List(); - foreach (var bundle in bundles) - { - File.WriteAllText(Path.Combine(directory, bundle + ".zip"), bundle); - names.Add(bundle + ".zip"); - } - File.WriteAllText( - Path.Combine(directory, ShippedPrebuiltBundles.CompletionSentinelFileName), - string.Join('\n', names.Order(StringComparer.Ordinal)) + "\n"); - } -} diff --git a/test/Memex.Portal.Shared.Test/SealedModuleCompositionTest.cs b/test/Memex.Portal.Shared.Test/SealedModuleCompositionTest.cs deleted file mode 100644 index 6c71dc1ec9..0000000000 --- a/test/Memex.Portal.Shared.Test/SealedModuleCompositionTest.cs +++ /dev/null @@ -1,245 +0,0 @@ -using System.IO.Compression; -using System.Reactive.Linq; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Cli; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A composition reads ONE publication, or none (MeshWeaver#3401). -/// -/// A consumer of a sealed publication does N+1 reads — the module-set index, then each -/// bundle that index names — of a directory the publisher unseals, rewrites and re-seals under it. -/// The window is roughly a minute and a half per target per publish, and the plugins prefix -/// has TWO writers (core CD's plugins-bake and the MeshWeaver.Plugins satellite's own -/// publish-bake), so they can land inside each other's. -/// -/// #3409 gave the BUNDLE lane a generation and made the window answer 503 instead of 404. The -/// MODULE lane kept reading unpinned, in both of its consumers (memex build plugin here, and -/// .github/scripts/compose-sealed-modules.sh) — and a mix there is worse than a red: module -/// bytes from two publications are the mvid mismatch that DECLINES every NodeType assembly at -/// adoption ("built against mvid:…, live is mvid:…"), which is invisible until a portal boots. -/// -/// The publisher below reseals between the first module fetch and the second — the exact -/// production interleave, made deterministic rather than raced. Unpinned, the composition ends up -/// holding one module from each publication and says nothing. Pinned, the server refuses the stale -/// read and the consumer re-reads the publication that now applies, so every module carries ONE -/// generation. -/// -public class SealedModuleCompositionTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Identity = "s0123456789abcdef0123456789abcdef"; - private const string Source = "plugins"; - - /// - /// The falsifiable assertion is that every composed module carries the SAME generation marker. - /// Reading unpinned — RegistryGet(http, moduleUrl, null, ct), which is what this lane did - /// before this change — leaves MeshWeaver.AI on generation 1 and - /// MeshWeaver.Essentials on generation 2, and nothing in any status code says so. - /// - [Fact(Timeout = 120_000)] - public async Task AResealBetweenTwoModuleFetches_IsRefusedAndReRead_NotSilentlyMixed() - { - var root = Path.Combine(Path.GetTempPath(), "mw-sealed-modules-" + Guid.NewGuid().ToString("N")); - var extDir = Path.Combine(root, "ext"); - var dir = Path.Combine(root, Identity, Source); - var modules = Path.Combine(dir, PublishedBundleCatalogue.ModulesDirectoryName); - Directory.CreateDirectory(modules); - var sentinel = Path.Combine(dir, ShippedPrebuiltBundles.CompletionSentinelFileName); - try - { - Publish(dir, modules, sentinel, "gen1"); - - var key = await RegisterInstance("composer", $"{Source}/*"); - - // THE PUBLISHER, mid-composition: the first module has been fetched and written, and - // the publication is replaced before the second is asked for. Everything from here on - // belongs to a different publication — which is the whole point. - var resealed = 0; - await using var app = await StartHost(root, (ctx, next) => - { - if (IsSecondModuleFetch(ctx) && Interlocked.Exchange(ref resealed, 1) == 0) - Publish(dir, modules, sentinel, "gen2"); - return next(ctx); - }); - - var log = new StringWriter(); - var composed = await new BuildPluginCommand(log, log).ComposeSealedModules( - Client(app, key), Source, Identity, "AI Essentials", extDir, TestContext.Current.CancellationToken); - - Assert.Equal(1, resealed); // the interleave actually happened - Assert.True(composed is not null, log.ToString()); - Assert.Equal(4, composed.Count); - Assert.Contains("/ext/MeshWeaver.AI/MeshWeaver.AI.dll", composed); - Assert.Contains("/ext/MeshWeaver.Essentials/MeshWeaver.Essentials.dll", composed); - - // 🚨 THE ASSERTION THAT FAILS WITHOUT THE PRECONDITION: one publication, or none. - // Unpinned, MeshWeaver.AI comes back "gen1" and MeshWeaver.Essentials "gen2" — two - // publications in one compile surface, exit 0, nothing said. - Assert.Equal("gen2", StagedMarker(extDir, "MeshWeaver.AI")); - Assert.Equal("gen2", StagedMarker(extDir, "MeshWeaver.Essentials")); - - // …and the consumer SAW the move rather than absorbing it, so an operator reading the - // log knows which publication was finally read. - Assert.Contains("was resealed while its module set was being composed", log.ToString()); - } - finally - { - try { Directory.Delete(root, recursive: true); } catch (IOException) { /* best effort */ } - } - } - - /// - /// The bound is what keeps the re-read a READ and not a poll: a publisher that reseals under - /// every attempt is a real condition, and the consumer must go RED naming itself rather than - /// spin. It must also never hand back a half-composition — null, and an empty ext dir. - /// - [Fact(Timeout = 120_000)] - public async Task APublisherThatResealsUnderEveryAttempt_RefusesLoudly_AndStagesNothing() - { - var root = Path.Combine(Path.GetTempPath(), "mw-sealed-modules-storm-" + Guid.NewGuid().ToString("N")); - var extDir = Path.Combine(root, "ext"); - var dir = Path.Combine(root, Identity, Source); - var modules = Path.Combine(dir, PublishedBundleCatalogue.ModulesDirectoryName); - Directory.CreateDirectory(modules); - var sentinel = Path.Combine(dir, ShippedPrebuiltBundles.CompletionSentinelFileName); - try - { - Publish(dir, modules, sentinel, "gen1"); - var key = await RegisterInstance("storm-composer", $"{Source}/*"); - - var reseals = 0; - await using var app = await StartHost(root, (ctx, next) => - { - if (IsSecondModuleFetch(ctx)) - Publish(dir, modules, sentinel, $"gen{Interlocked.Increment(ref reseals) + 1}"); - return next(ctx); - }); - - var log = new StringWriter(); - var composed = await new BuildPluginCommand(log, log).ComposeSealedModules( - Client(app, key), Source, Identity, "AI Essentials", extDir, TestContext.Current.CancellationToken); - - Assert.Null(composed); - Assert.Equal(BuildPluginCommand.MaxPublicationRestarts + 1, reseals); - Assert.Contains("MeshWeaver#3401", log.ToString()); - // Nothing from a publication this composition refused may reach the compile surface. - Assert.Empty(Directory.EnumerateDirectories(extDir)); - } - finally - { - try { Directory.Delete(root, recursive: true); } catch (IOException) { /* best effort */ } - } - } - - /// The publish lands BETWEEN the two module reads: the first bundle is already - /// fetched and written when the second is asked for, which is precisely the N+1 window. - private static bool IsSecondModuleFetch(HttpContext ctx) => - ctx.Request.Path.Value?.EndsWith("/modules/essentials.module.nupkg", StringComparison.Ordinal) - == true; - - // ── the publisher, and what it publishes ────────────────────────────────────────────────── - - /// - /// One publication of the plugins source: two module bundles carrying - /// , their index, and the completion sentinel written LAST — the same - /// order .github/scripts/publish-bake-bundles.sh writes them in. The sentinel's write - /// time is what moves the generation, so a reseal to an identical NAME list is still a - /// different publication — which it is, because the bytes behind those names have changed. - /// - private static void Publish(string dir, string modules, string sentinel, string marker) - { - // Monotone, so a reseal within the same wall-clock second still moves the generation on a - // filesystem whose timestamps are coarse. The publication genuinely moved; its token must. - var previous = File.Exists(sentinel) - ? File.GetLastWriteTimeUtc(sentinel) - : DateTime.UtcNow.AddMinutes(-10); - WriteBundle(Path.Combine(dir, "Store.zip"), "Store"); - WriteModule(Path.Combine(modules, "ai.module.nupkg"), "MeshWeaver.AI", marker); - WriteModule(Path.Combine(modules, "essentials.module.nupkg"), "MeshWeaver.Essentials", marker); - File.WriteAllText( - Path.Combine(modules, PublishedBundleCatalogue.ModulesIndexFileName), - "ai.module.nupkg\nessentials.module.nupkg\n"); - File.WriteAllText(sentinel, "Store.zip\n"); - File.SetLastWriteTimeUtc(sentinel, previous.AddSeconds(1)); - } - - private static void WriteBundle(string path, string plugin) - { - if (File.Exists(path)) File.Delete(path); - using var zip = ZipFile.Open(path, ZipArchiveMode.Create); - using var w = new StreamWriter(zip.CreateEntry("meshweaver/manifest.json").Open()); - w.Write($"{{\"plugin\":\"{plugin}\"}}"); - } - - /// A module bundle in the layout StageModuleBundle reads: the manifest names - /// the entry assembly, and meshweaver/modules/<name>.dll carries the marker this - /// test reads back to tell the two publications apart. - private static void WriteModule(string path, string assembly, string marker) - { - if (File.Exists(path)) File.Delete(path); - using var zip = ZipFile.Open(path, ZipArchiveMode.Create); - using (var w = new StreamWriter(zip.CreateEntry("meshweaver/manifest.json").Open())) - w.Write($"{{\"module\":{{\"assemblyName\":\"{assembly}\"}}}}"); - using (var w = new StreamWriter(zip.CreateEntry($"meshweaver/modules/{assembly}.dll").Open())) - w.Write(marker); - } - - private static string StagedMarker(string extDir, string assembly) => - File.ReadAllText(Path.Combine(extDir, assembly, $"{assembly}.dll")); - - // ── the registry, and the caller ────────────────────────────────────────────────────────── - - private static HttpClient Client(WebApplication app, string key) - { - var http = app.GetTestClient(); - http.DefaultRequestHeaders.Authorization = new("Bearer", key); - return http; - } - - private MeshWeaverInstanceService InstanceService(params string[] defaultGrants) => - new(Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder() - .AddInMemoryCollection(defaultGrants.Select((entry, i) => - new KeyValuePair($"{MeshWeaverInstanceService.DefaultGrantsConfigKey}:{i}", entry))) - .Build()); - - private Task RegisterInstance(string instanceId, params string[] defaultGrants) => - InstanceService(defaultGrants) - .Register("owner", "Owner", "owner@test.com", instanceId, instanceId) - .Select(r => r.RawKey).FirstAsync().Timeout(TimeSpan.FromSeconds(60)).Await(TestContext.Current.CancellationToken); - - private async Task StartHost( - string publishedRoot, Func publisher) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [PublishedBundleCatalogue.PublishedRootConfigKey] = publishedRoot, - }); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(new InstanceRegistryAuthenticator( - Mesh, Mesh.ServiceProvider.GetRequiredService>())); - var app = builder.Build(); - app.Use(publisher); - app.MapPluginBundles(); - await app.StartAsync(); - return app; - } -} diff --git a/test/Memex.Portal.Shared.Test/SealedSetConsistencyTest.cs b/test/Memex.Portal.Shared.Test/SealedSetConsistencyTest.cs deleted file mode 100644 index 64aa5a64cc..0000000000 --- a/test/Memex.Portal.Shared.Test/SealedSetConsistencyTest.cs +++ /dev/null @@ -1,684 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Text; -using System.Text.Json; -using MeshWeaver.Compiler; -using MeshWeaver.Hosting; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 Availability is a CONSISTENT sealed set, not the presence of a file (#3175). -/// -/// memex-cloud rolled to ci.7621 on a verdict that checked presence — every installed package -/// had a sealed bundle under the target's identity — and the portal then DECLINED SocialMedia at -/// adoption: its NodeTypes recorded MeshWeaver.Markdown.Collaboration at one MVID while the -/// module sealed for the same identity carried another. The same morning every satellite gate -/// declined four map galleries whose records named MeshWeaver.Maps at an MVID nothing -/// composed. Both are one defect seen twice: two producers of one module for one identity, and a -/// gate that could not see it because it never read the bytes that carry the answer. -/// -/// These pin — the observation -/// now reads each sealed module bundle's entry assembly for its MVID and each sealed bundle's -/// dependency records — and 's rule over them. Both -/// failure directions are pinned on purpose: a false "available" rolls a fleet onto a half-broken -/// platform; a false "hold" freezes every install (#1754), so an UNREADABLE module set is -/// and a module the set does not carry is not -/// judged at all. -/// -public class SealedSetConsistencyTest -{ - private const string Identity = "s3175consistency0000000000000000"; - private const string Version = "3.0.0-rc9.ci.3175"; - private const string Module = "MeshWeaver.ProbeModule"; - private const string Bundle = "Widget"; - - /// The module bundle carries a REAL managed assembly under the module folder — this - /// test assembly's dependency, chosen because its MVID is knowable from the running process — - /// so the observation's PE read is exercised against genuine bytes, not a stub. - private static readonly System.Reflection.Assembly ModuleBytesOf = typeof(ReleaseAvailability).Assembly; - - private static string SealedMvid => MvidOf(ModuleBytesOf); - - private static string AnotherBuild => - CompiledDependencies.MvidScheme + Guid.NewGuid().ToString("N"); - - /// The module-owned sibling of the #3221 fixtures — a REAL managed assembly, distinct - /// from the entry's, so the observation's PE read answers a genuine MVID for the riding copy. - /// Its simple name comes from the FILE name the bundle carries it under, which is exactly how the - /// loader and the observation both name it. - private const string Sibling = "MeshWeaver.ProbeSibling"; - - private static byte[] SiblingBytes => File.ReadAllBytes(typeof(BundleReader).Assembly.Location); - - private static string SiblingMvid => MvidOf(typeof(BundleReader).Assembly); - - /// A DIFFERENT real build to stand in for "the same assembly, compiled in another - /// wave" — the divergence the negative control needs. - private static byte[] AnotherBuildBytes => - File.ReadAllBytes(typeof(CompiledDependencies).Assembly.Location); - - private static string AnotherBuildMvid => MvidOf(typeof(CompiledDependencies).Assembly); - - private static string MvidOf(System.Reflection.Assembly assembly) => - CompiledDependencies.MvidScheme + assembly.ManifestModule.ModuleVersionId.ToString("N"); - - // ── the observation: the bytes on disk become a module set and dependency records ─────────── - - [Fact] - public void ASealedPublication_IsObservedWithItsModuleSetAndItsRecords() - { - var root = PublishedRoot(recorded: SealedMvid); - try - { - var observation = PublishedBundleCatalogue.Read(root, Version); - - Assert.Equal(Identity, observation.Target.FrameworkIdentity); - Assert.Null(observation.Artifacts.ReadFailure); - Assert.Contains(Bundle, observation.Artifacts.SealedBundles); - - var modules = observation.Artifacts.Modules; - Assert.NotNull(modules); - Assert.Null(modules.Refusal); - Assert.Empty(modules.Conflicts); - Assert.Equal(SealedMvid, modules.MvidByModule[Module]); - - var record = Assert.Single(observation.Artifacts.DependencyRecords); - Assert.Equal(Bundle, record.Bundle); - Assert.Equal("Widget/Thing", record.NodePath); - Assert.Equal(SealedMvid, record.Dependencies[Module]); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - [Fact] - public void ABundleBuiltAgainstTheSealedModule_IsAvailable() - { - var root = PublishedRoot(recorded: SealedMvid); - try - { - var verdict = Verdict(root); - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - Assert.Equal(PackageAvailabilityKind.Available, Assert.Single(verdict.Packages).Kind); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - /// THE incident: sealed, present, and built against a module build the same identity's - /// sealed set does not carry. Presence said "available"; the portal declined it at adoption. - [Fact] - public void ABundleBuiltAgainstAnotherBuildOfTheSealedModule_IsHeld_NamingBothBuilds() - { - var recorded = AnotherBuild; - var root = PublishedRoot(recorded); - try - { - var verdict = Verdict(root); - Assert.False(verdict.IsUpdatable); - var package = Assert.Single(verdict.Packages); - Assert.Equal(PackageAvailabilityKind.SealedSetInconsistent, package.Kind); - Assert.False(verdict.IsIndeterminate, - "two builds of one module is a DEFINITE inconsistency of the set, not an unreadability"); - Assert.NotNull(package.Reason); - Assert.Contains(Bundle, package.Reason, StringComparison.Ordinal); - Assert.Contains(Module, package.Reason, StringComparison.Ordinal); - Assert.Contains(recorded, package.Reason, StringComparison.Ordinal); - Assert.DoesNotContain("nothing rolls", package.Reason, StringComparison.Ordinal); - Assert.Contains(SealedMvid, package.Reason, StringComparison.Ordinal); - Assert.Contains("dependency record mismatch", package.Reason, StringComparison.Ordinal); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - /// - /// 🚨 THE case that could falsify the fix: one sealed bundle that is NOT a readable archive — a - /// file of literal bytes, deliberately not a zip — beside a readable, INCONSISTENT one. The read - /// must still resolve the identity, count the unreadable bundle for presence (nothing for the - /// consistency check to judge, as for a legacy bundle), and still JUDGE the rest of the set: - /// the readable bundle built against another module build holds, by name. The first cut let the - /// unreadable file throw out of Read's outer catch, turning the WHOLE identity unreadable - /// and losing its resolved identity — MeshWeaver.Plugins' catalogue tests (which stage bundles as - /// literal bytes) went red on main within the hour of #3187 merging, and every portal reading - /// such a root would have held every update with a reason nobody could act on. - /// - [Fact] - public void AnUnreadableBundle_CountsForPresence_AndTheRestOfTheSetIsStillJudged() - { - var recorded = AnotherBuild; - var root = PublishedRoot(recorded); - try - { - // A second source whose only bundle is NOT an archive, sealed with an EMPTY module set - // ("composed nothing" — a legitimate seal, distinct from "predates module sealing"). - var education = Path.Combine(root, Identity, "education"); - Directory.CreateDirectory(Path.Combine(education, PublishedBundleCatalogue.ModulesDirectoryName)); - File.WriteAllText(Path.Combine(education, "Doc.zip"), "bytes"); - File.WriteAllText( - Path.Combine(education, PublishedBundleCatalogue.ModulesDirectoryName, - PublishedBundleCatalogue.ModulesIndexFileName), string.Empty); - File.WriteAllText( - Path.Combine(education, ShippedPrebuiltBundles.CompletionSentinelFileName), "Doc.zip\n"); - - var observation = PublishedBundleCatalogue.Read(root, Version); - - Assert.Equal(Identity, observation.Target.FrameworkIdentity); - Assert.Null(observation.Artifacts.ReadFailure); - Assert.Contains("Doc", observation.Artifacts.SealedBundles); - Assert.Contains(Bundle, observation.Artifacts.SealedBundles); - // The unreadable bundle contributed no records; the readable one did. - Assert.Equal([Bundle], observation.Artifacts.DependencyRecords.Select(r => r.Bundle).Distinct()); - Assert.NotNull(observation.Artifacts.Modules); - Assert.Null(observation.Artifacts.Modules.Refusal); - - var verdict = ReleaseAvailability.IsUpdatable( - observation.Target, - [new RequiredPackage("Doc", "Doc"), Required], - observation.Artifacts); - - Assert.False(verdict.IsUpdatable); - Assert.False(verdict.IsIndeterminate, "an unreadable BUNDLE is not an unreadable SET"); - var doc = Assert.Single(verdict.Packages, p => p.Package == "Doc"); - Assert.Equal(PackageAvailabilityKind.Available, doc.Kind); - var widget = Assert.Single(verdict.Packages, p => p.Package == Bundle); - Assert.Equal(PackageAvailabilityKind.SealedSetInconsistent, widget.Kind); - Assert.Contains(recorded, widget.Reason!, StringComparison.Ordinal); - Assert.Contains(SealedMvid, widget.Reason!, StringComparison.Ordinal); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - // ── #3221: a module-owned sibling RIDES a second bundle, and every copy must be one build ──── - - /// - /// 🚨 THE SANCTIONED SHAPE, and the reason the closure rule was NOT changed (#3221). A - /// module-owned MeshWeaver.* sibling rides every bundle that references it, so one - /// assembly name is sealed by several bundles at once — measured on MeshWeaver.Plugins - /// main 2026-09-03, 19 of 37 module bundles carry a copy of an assembly another package - /// declares as its module. This must NOT hold: refusing it would freeze more than half the - /// fleet's modules, and excluding declared modules from the closure would invert the package - /// graph (AI requires only Store yet would need Essentials' module, while - /// Essentials requires AI). - /// - [Fact] - public void ASiblingRidingASecondBundle_AtTheSameBuild_IsAvailable() - { - var root = PublishedRootWithRide( - recorded: SiblingMvid, declared: SiblingBytes, riding: SiblingBytes); - try - { - var observation = PublishedBundleCatalogue.Read(root, Version); - Assert.Empty(observation.Artifacts.Modules!.Conflicts); - Assert.Null(observation.Artifacts.Modules.Refusal); - // The DECLARED module defines the set; the ride agrees with it. - Assert.Equal(SiblingMvid, observation.Artifacts.Modules.MvidByModule[Sibling]); - - var verdict = ReleaseAvailability.IsUpdatable( - observation.Target, [Required], observation.Artifacts); - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - /// - /// 🚨 THE NEGATIVE CONTROL for #3221. The same set, with the RIDING copy a different build - /// from the one its owning package declares. Every declared entry is still distinct and - /// self-consistent — MeshWeaver.ProbeModule is sealed once, MeshWeaver.ProbeSibling - /// is sealed once as a declared module — so the entry-only reading #3187 shipped sees a perfectly - /// consistent set and answers AVAILABLE. Only a reading that accounts for the RIDING copy can - /// fail here, which is what makes this a proof that the check binds to the sibling path and not - /// merely to the path already covered. - /// - /// The failure it prevents is the one memex-cloud suffered on ci.7621: the loader binds - /// MeshWeaver.* by a strictly synchronised AssemblyVersion, so two copies under one - /// simple name collapse to whichever loaded first and every NodeType that recorded the other is - /// declined at adoption — "dependency record mismatch — built against mvid:A, live is - /// mvid:B". - /// - [Fact] - public void ASiblingRidingASecondBundle_AtAnotherBuild_IsHeld_NamingBothProducers() - { - var root = PublishedRootWithRide( - recorded: SiblingMvid, declared: SiblingBytes, riding: AnotherBuildBytes); - try - { - var observation = PublishedBundleCatalogue.Read(root, Version); - - // The set itself says so, naming both producers and the ROLE of each. - var conflict = Assert.Single(observation.Artifacts.Modules!.Conflicts); - Assert.StartsWith($"module {Sibling}:", conflict, StringComparison.Ordinal); - Assert.Contains(AnotherBuildMvid, conflict, StringComparison.Ordinal); - Assert.Contains(SiblingMvid, conflict, StringComparison.Ordinal); - Assert.Contains("as a sibling riding 'probe.module.nupkg'", conflict, StringComparison.Ordinal); - Assert.Contains("as the declared module of 'probe.sibling.nupkg'", conflict, StringComparison.Ordinal); - // A name carried at two builds defines nothing — whichever loads first is a coin toss. - Assert.DoesNotContain(Sibling, observation.Artifacts.Modules.MvidByModule.Keys); - Assert.Null(observation.Artifacts.Modules.Refusal); - - var verdict = ReleaseAvailability.IsUpdatable( - observation.Target, [Required], observation.Artifacts); - - Assert.False(verdict.IsUpdatable); - Assert.False(verdict.IsIndeterminate, - "two builds of one assembly name is a DEFINITE inconsistency of the set, not an unreadability"); - var package = Assert.Single(verdict.Packages); - Assert.Equal(PackageAvailabilityKind.SealedSetInconsistent, package.Kind); - Assert.Contains(conflict, package.Reason!, StringComparison.Ordinal); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - /// - /// 🚨 The reading a ride must NOT change. Only the DECLARED module defines - /// : an instance registers declared modules as - /// InstalledModuleAssembly, and that registration is what - /// NodeTypeCompilationHelpers.ModuleMvidsOf reports back as "live". An assembly that only - /// ever RIDES is registered nowhere, so letting it define the set would start judging records - /// against bytes the instance never reports — a false HOLD, the expensive failure direction - /// (#1754). Here MeshWeaver.ProbeSibling rides but nothing declares it, and a record naming - /// it is left unjudged exactly as a module outside the sealed set is. - /// - [Fact] - public void ARideOnlyAssembly_DoesNotDefineTheModuleSet() - { - var root = PublishedRootWithRide( - recorded: AnotherBuildMvid, declared: null, riding: SiblingBytes); - try - { - var observation = PublishedBundleCatalogue.Read(root, Version); - Assert.Empty(observation.Artifacts.Modules!.Conflicts); - Assert.Null(observation.Artifacts.Modules.Refusal); - Assert.DoesNotContain(Sibling, observation.Artifacts.Modules.MvidByModule.Keys); - Assert.Contains(Module, observation.Artifacts.Modules.MvidByModule.Keys); - - var verdict = ReleaseAvailability.IsUpdatable( - observation.Target, [Required], observation.Artifacts); - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - /// A non-MeshWeaver.* package riding two bundles at two builds is NOT a conflict: - /// a third-party diamond rides by design (Module Closure Accounting) and versions independently, - /// so it does not collapse to one identity the way a strictly-version-synchronised - /// MeshWeaver.* assembly does. Judging it would hold every bundle in the fleet. - [Fact] - public void AThirdPartyDiamondRidingTwoBundles_IsNotJudged() - { - var root = PublishedRootWithRide( - recorded: SiblingMvid, declared: SiblingBytes, riding: SiblingBytes, - thirdParty: ("Contoso.Sdk", AnotherBuildBytes)); - try - { - var observation = PublishedBundleCatalogue.Read(root, Version); - Assert.Empty(observation.Artifacts.Modules!.Conflicts); - Assert.DoesNotContain("Contoso.Sdk", observation.Artifacts.Modules.MvidByModule.Keys); - - var verdict = ReleaseAvailability.IsUpdatable( - observation.Target, [Required], observation.Artifacts); - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - // ── the rule, on hand-built observations ──────────────────────────────────────────────────── - - [Fact] - public void TwoSourcesSealingTwoBuildsOfOneModule_IsInconsistent_ForEveryBundleBindingIt() - { - var conflict = $"module {Module}: source 'plugins' sealed mvid:aaaa, source 'socialmedia' sealed mvid:bbbb"; - var artifacts = ReleaseArtifacts.Of([Bundle + ".zip"]) with - { - Modules = new SealedModuleSet( - ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal), - [conflict], - null), - DependencyRecords = [Record(Module, "mvid:aaaa")], - }; - - var verdict = ReleaseAvailability.IsUpdatable(Target, [Required], artifacts); - - Assert.False(verdict.IsUpdatable); - var package = Assert.Single(verdict.Packages); - Assert.Equal(PackageAvailabilityKind.SealedSetInconsistent, package.Kind); - Assert.Contains(conflict, package.Reason!, StringComparison.Ordinal); - } - - /// 🚨 The other failure direction. A module set that could not be read is NOT an - /// incompatibility and NOT a pass — it is "cannot determine", which holds and says so. - [Fact] - public void AnUnreadableModuleSet_IsIndeterminate_NeverAnIncompatibilityVerdict() - { - var artifacts = ReleaseArtifacts.Of([Bundle + ".zip"]) with - { - Modules = new SealedModuleSet( - ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal), - [], - "source 'plugins': the publication is sealed but carries no module set"), - DependencyRecords = [Record(Module, "mvid:aaaa")], - }; - - var verdict = ReleaseAvailability.IsUpdatable(Target, [Required], artifacts); - - Assert.False(verdict.IsUpdatable); - Assert.True(verdict.IsIndeterminate); - var package = Assert.Single(verdict.Packages); - Assert.Equal(PackageAvailabilityKind.Indeterminate, package.Kind); - Assert.Contains("never read as consistent", package.Reason!, StringComparison.Ordinal); - } - - [Fact] - public void AModuleSetThatWasNeverObserved_IsIndeterminate_WhenARecordNamesAModule() - { - var artifacts = ReleaseArtifacts.Of([Bundle + ".zip"]) with - { - Modules = null, - DependencyRecords = [Record(Module, "mvid:aaaa")], - }; - - var verdict = ReleaseAvailability.IsUpdatable(Target, [Required], artifacts); - - Assert.True(verdict.IsIndeterminate); - Assert.Equal(PackageAvailabilityKind.Indeterminate, Assert.Single(verdict.Packages).Kind); - } - - /// A module the sealed set does not carry is one the instance lands from the registry - /// outside any publication. The gate cannot see those bytes and does not pretend to. - [Fact] - public void AModuleOutsideTheSealedSet_IsNotJudged() - { - var artifacts = ReleaseArtifacts.Of([Bundle + ".zip"]) with - { - Modules = new SealedModuleSet( - ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal) - .Add("MeshWeaver.SomethingElse", "mvid:cccc"), - [], - null), - DependencyRecords = [Record(Module, "mvid:aaaa")], - }; - - var verdict = ReleaseAvailability.IsUpdatable(Target, [Required], artifacts); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - } - - /// Platform surfaces (ref:) and the reserved toolchain entry are not modules; - /// a record binding only those needs no module set at all. - [Fact] - public void ARecordBindingNoModule_NeedsNoModuleSet() - { - var artifacts = ReleaseArtifacts.Of([Bundle + ".zip"]) with - { - Modules = null, - DependencyRecords = - [ - new BundleDependencyRecord(Bundle, "Widget/Thing", - ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal) - .Add("MeshWeaver.Layout", CompiledDependencies.RefAsmScheme + "abc") - .Add(CompiledDependencies.ToolchainKey, CompiledDependencies.MvidScheme + "toolchain")), - ], - }; - - var verdict = ReleaseAvailability.IsUpdatable(Target, [Required], artifacts); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - } - - // ── #3934: a module entry is a FLOOR, and this gate must not go quiet on it ────────────────── - // - // A record's module entry is now `min:` wherever the module states a version. The - // predicate that enumerates module entries therefore had to widen, and the risk of the change - // is entirely in one direction: a gate that silently stops seeing its input reports GREEN over - // a torn publication, which is the one failure a roll gate may not have. Both surviving checks - // are pinned on the floor shape, and the one that is deliberately RETIRED is pinned too, so a - // future reader can tell "retired on purpose" from "quietly stopped firing". - - /// 🚨 A floor-shaped record must STILL be held by a set carrying one name at two - /// builds. If the module-lane predicate had stayed mvid:-only, this would answer - /// AVAILABLE over a publication the loader resolves by coin toss. - [Fact] - public void AFloorShapedRecord_IsStillHeldByASealedSetConflict() - { - var conflict = $"module {Module}: source 'plugins' sealed mvid:aaaa, source 'socialmedia' sealed mvid:bbbb"; - var artifacts = ReleaseArtifacts.Of([Bundle + ".zip"]) with - { - Modules = new SealedModuleSet( - ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal), - [conflict], - null), - DependencyRecords = [Record(Module, CompiledDependencies.MinVersionScheme + "3.0.0")], - }; - - var verdict = ReleaseAvailability.IsUpdatable(Target, [Required], artifacts); - - Assert.False(verdict.IsUpdatable); - var package = Assert.Single(verdict.Packages); - Assert.Equal(PackageAvailabilityKind.SealedSetInconsistent, package.Kind); - Assert.Contains(conflict, package.Reason!, StringComparison.Ordinal); - } - - /// 🚨 And an unreadable set is still INDETERMINATE for a floor-shaped record — "I - /// could not check" is not clearance, floor or pin. - [Fact] - public void AFloorShapedRecord_IsStillIndeterminateWhenTheSetCannotBeRead() - { - var artifacts = ReleaseArtifacts.Of([Bundle + ".zip"]) with - { - Modules = null, - DependencyRecords = [Record(Module, CompiledDependencies.MinVersionScheme + "3.0.0")], - }; - - var verdict = ReleaseAvailability.IsUpdatable(Target, [Required], artifacts); - - Assert.True(verdict.IsIndeterminate); - Assert.Equal(PackageAvailabilityKind.Indeterminate, Assert.Single(verdict.Packages).Kind); - } - - /// - /// 🚨 THE ONE CHECK THE FLOOR RETIRES, pinned so nobody has to guess whether it was retired or - /// merely broken. "The sealed set carries a DIFFERENT build of this module" was a hold because - /// an instance declined that at adoption; under a floor it does not, so holding a whole fleet's - /// roll for it would be refusing on a fact nothing downstream acts on. - /// - /// Its falsification arm is : - /// the same set, an mvid:-shaped record, still held. - /// - [Fact] - public void AFloorShapedRecord_IsNotHeldMerelyBecauseTheSealedBuildDiffers() - { - var artifacts = ReleaseArtifacts.Of([Bundle + ".zip"]) with - { - Modules = new SealedModuleSet( - ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal) - .Add(Module, "mvid:whatever-this-wave-produced"), - [], - null), - DependencyRecords = [Record(Module, CompiledDependencies.MinVersionScheme + "3.0.0")], - }; - - var verdict = ReleaseAvailability.IsUpdatable(Target, [Required], artifacts); - - Assert.True(verdict.IsUpdatable, verdict.HoldReason); - } - - // ── fixtures ──────────────────────────────────────────────────────────────────────────────── - - private static ReleaseTarget Target => new(Version, Identity); - - private static RequiredPackage Required => new(Bundle, Bundle); - - private static UpdatabilityVerdict Verdict(string root) - { - var observation = PublishedBundleCatalogue.Read(root, Version); - return ReleaseAvailability.IsUpdatable(observation.Target, [Required], observation.Artifacts); - } - - private static BundleDependencyRecord Record(string module, string id) => - new(Bundle, "Widget/Thing", - ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal) - .Add(module, id) - .Add(CompiledDependencies.ToolchainKey, CompiledDependencies.MvidScheme + "toolchain")); - - /// - /// One sealed publication under one identity, exactly as publish-bake-bundles.sh lays it - /// out: the release marker, the module set (index + one module bundle carrying a real assembly), - /// one content bundle whose manifest records what its NodeType was built against, and the - /// _complete sentinel written last. - /// - private static string PublishedRoot(string recorded) - { - var root = Path.Combine(Path.GetTempPath(), "mw-sealed-set-" + Guid.NewGuid().ToString("N")); - var source = Path.Combine(root, Identity, "plugins"); - var modules = Path.Combine(source, PublishedBundleCatalogue.ModulesDirectoryName); - Directory.CreateDirectory(Path.Combine(root, PublishedBundleCatalogue.ReleaseMarkerDirectoryName)); - Directory.CreateDirectory(modules); - File.WriteAllText( - Path.Combine(root, PublishedBundleCatalogue.ReleaseMarkerDirectoryName, Version), Identity); - - WriteZip(Path.Combine(modules, "probe.module.nupkg"), - (NuGetPackageWriter.ManifestEntry, - Encoding.UTF8.GetBytes($$$"""{"plugin":"Probe","module":{"assemblyName":"{{{Module}}}"}}""")), - ($"{NuGetPackageWriter.ModuleFolder}/{Module}.dll", File.ReadAllBytes(ModuleBytesOf.Location))); - File.WriteAllText( - Path.Combine(modules, PublishedBundleCatalogue.ModulesIndexFileName), "probe.module.nupkg\n"); - - var manifest = new BundleReader.Manifest( - Bundle, "1.0", Identity, - [ - new BundleReader.AssemblyRef("Widget/Thing", "Widget_Thing.dll", - new Dictionary(StringComparer.Ordinal) - { - [Module] = recorded, - ["MeshWeaver.Layout"] = CompiledDependencies.RefAsmScheme + "abc", - [CompiledDependencies.ToolchainKey] = CompiledDependencies.MvidScheme + "toolchain", - }), - ]); - WriteZip(Path.Combine(source, Bundle + ".zip"), - (NuGetPackageWriter.ManifestEntry, - JsonSerializer.SerializeToUtf8Bytes(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web)))); - File.WriteAllText( - Path.Combine(source, ShippedPrebuiltBundles.CompletionSentinelFileName), Bundle + ".zip\n"); - return root; - } - - /// - /// The #3221 layout: ONE identity, ONE source, and an assembly name reaching it from TWO module - /// bundles — the live shape of MeshWeaver.Plugins, where 19 of 37 bundles carry a copy of an - /// assembly some other package declares. - /// - /// - /// probe.module.nupkg — the AI-shaped bundle: it DECLARES - /// and RIDES as . - /// probe.sibling.nupkg — the Essentials-shaped bundle: it DECLARES - /// as . Omitted entirely when - /// is null, which is the ride-only case. - /// - /// - /// The index lists the riding bundle FIRST, so the ride is the copy seen first and the declared - /// module the second — both roles are exercised in one conflict line. - /// - private static string PublishedRootWithRide( - string recorded, byte[]? declared, byte[] riding, - (string Name, byte[] Bytes)? thirdParty = null) - { - var root = Path.Combine(Path.GetTempPath(), "mw-sealed-ride-" + Guid.NewGuid().ToString("N")); - var source = Path.Combine(root, Identity, "plugins"); - var modules = Path.Combine(source, PublishedBundleCatalogue.ModulesDirectoryName); - Directory.CreateDirectory(Path.Combine(root, PublishedBundleCatalogue.ReleaseMarkerDirectoryName)); - Directory.CreateDirectory(modules); - File.WriteAllText( - Path.Combine(root, PublishedBundleCatalogue.ReleaseMarkerDirectoryName, Version), Identity); - - var ridingEntries = new List<(string Entry, byte[] Bytes)> - { - (NuGetPackageWriter.ManifestEntry, - Encoding.UTF8.GetBytes($$$"""{"plugin":"Probe","module":{"assemblyName":"{{{Module}}}"}}""")), - ($"{NuGetPackageWriter.ModuleFolder}/{Module}.dll", File.ReadAllBytes(ModuleBytesOf.Location)), - ($"{NuGetPackageWriter.ModuleFolder}/{Sibling}.dll", riding), - }; - if (thirdParty is { } third) - ridingEntries.Add(($"{NuGetPackageWriter.ModuleFolder}/{third.Name}.dll", third.Bytes)); - WriteZip(Path.Combine(modules, "probe.module.nupkg"), [.. ridingEntries]); - - var index = new List { "probe.module.nupkg" }; - if (declared is not null) - { - var declaredEntries = new List<(string Entry, byte[] Bytes)> - { - (NuGetPackageWriter.ManifestEntry, - Encoding.UTF8.GetBytes($$$"""{"plugin":"Sibling","module":{"assemblyName":"{{{Sibling}}}"}}""")), - ($"{NuGetPackageWriter.ModuleFolder}/{Sibling}.dll", declared), - }; - if (thirdParty is { } alsoThird) - // The SAME third-party name at the SAME bytes as the entry's other copy would be no - // test at all — give this one the module's bytes so the two copies genuinely differ. - declaredEntries.Add(( - $"{NuGetPackageWriter.ModuleFolder}/{alsoThird.Name}.dll", - File.ReadAllBytes(ModuleBytesOf.Location))); - WriteZip(Path.Combine(modules, "probe.sibling.nupkg"), [.. declaredEntries]); - index.Add("probe.sibling.nupkg"); - } - File.WriteAllLines( - Path.Combine(modules, PublishedBundleCatalogue.ModulesIndexFileName), index); - - var manifest = new BundleReader.Manifest( - Bundle, "1.0", Identity, - [ - new BundleReader.AssemblyRef("Widget/Thing", "Widget_Thing.dll", - new Dictionary(StringComparer.Ordinal) - { - [Sibling] = recorded, - [CompiledDependencies.ToolchainKey] = CompiledDependencies.MvidScheme + "toolchain", - }), - ]); - WriteZip(Path.Combine(source, Bundle + ".zip"), - (NuGetPackageWriter.ManifestEntry, - JsonSerializer.SerializeToUtf8Bytes(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web)))); - File.WriteAllText( - Path.Combine(source, ShippedPrebuiltBundles.CompletionSentinelFileName), Bundle + ".zip\n"); - return root; - } - - private static void WriteZip(string path, params (string Entry, byte[] Bytes)[] entries) - { - using var file = File.Create(path); - using var zip = new ZipArchive(file, ZipArchiveMode.Create); - foreach (var (entry, bytes) in entries) - { - using var stream = zip.CreateEntry(entry).Open(); - stream.Write(bytes); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/SearchBasePathKeepsTheQuerysLocationTest.cs b/test/Memex.Portal.Shared.Test/SearchBasePathKeepsTheQuerysLocationTest.cs deleted file mode 100644 index 6a42008ce9..0000000000 --- a/test/Memex.Portal.Shared.Test/SearchBasePathKeepsTheQuerysLocationTest.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 basePath never rewrites the query's OWN location (#6828). Measured on a portal: a -/// query anchored at one space (namespace:<space B> …) sent with basePath at -/// another reported coverage.partitions of the base's partition only — because -/// MeshOperations.Search deleted every namespace: from the text before prefixing the -/// base, turning the query's location into a FREE-TEXT term read inside the wrong partition, and -/// an exclusion (-namespace:X) into a search FOR X. -/// -/// Now a query that names its own location keeps it when it lies under the base, is refused -/// naming both when it does not, and only a query that names none is scoped by the base. -/// -public class SearchBasePathKeepsTheQuerysLocationTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Alpha = "BaseAlpha"; - private const string Beta = "BaseBeta"; - private const string Person = "Jane Q Example"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddMeshNodes( - new MeshNode(Alpha) { Name = "Alpha", NodeType = "Markdown" }, - new MeshNode(Beta) { Name = "Beta", NodeType = "Markdown" }, - new MeshNode("Deal", Beta) { Name = "Deal", NodeType = "Markdown" }, - new MeshNode("Jane", $"{Beta}/Deal") { Name = Person, NodeType = "Markdown" }, - new MeshNode("Archive", Beta) { Name = "Archive", NodeType = "Markdown" }, - new MeshNode("Jane", $"{Beta}/Archive") { Name = Person, NodeType = "Markdown" }, - new MeshNode("Spaced", Beta) { Name = SpacedPerson, NodeType = "Markdown" }, - new MeshNode("Colon", Beta) { Name = ColonPerson, NodeType = "Markdown" }); - - private const string SpacedPerson = "Jane Q Example"; - private const string ColonPerson = "Jane namespace: Example"; - - private Task Search(string query, string? basePath, CancellationToken cancellationToken) - => new MeshOperations(Mesh).Search(query, basePath, 50) - .FirstAsync().Timeout(Budget).Await(cancellationToken); - - private static string[] Paths(string answer) - => JsonDocument.Parse(answer).RootElement.GetProperty("results").EnumerateArray() - .Select(r => r.GetProperty("path").GetString()!).OrderBy(p => p, StringComparer.Ordinal).ToArray(); - - private static string[] Partitions(string answer) - => JsonDocument.Parse(answer).RootElement.GetProperty("coverage").GetProperty("partitions") - .EnumerateArray().Select(p => p.GetString()!).ToArray(); - - /// - /// The measured shape: the query names space B, the base is space A. It used to be answered - /// inside A, with A's coverage. No answer can honour both, so it is refused, naming both. - /// - [Fact(Timeout = 60_000)] - public async Task AQueryAnchoredOutsideTheBaseIsRefused_NotReadInTheBasesPartition() - { - var answer = await Search($"namespace:{Beta} scope:descendants name:\"{Person}\"", $"@{Alpha}", - TestContext.Current.CancellationToken); - - Output.WriteLine(answer); - answer.Should().StartWith("Error:", "a query read against a partition it did not name is the defect"); - answer.Should().Contain(Alpha).And.Contain(Beta, "the refusal names both locations"); - answer.Should().NotContain("\"count\""); - } - - /// A query anchored INSIDE the base keeps its own, narrower anchor. - [Fact(Timeout = 60_000)] - public async Task AQueryAnchoredUnderTheBaseKeepsItsOwnAnchor() - { - var answer = await Search($"namespace:{Beta}/Deal name:\"{Person}\"", $"@{Beta}", - TestContext.Current.CancellationToken); - - Output.WriteLine(answer); - Paths(answer).Should().Equal(new[] { $"{Beta}/Deal/Jane" }, "the query's own namespace, not the whole base"); - Partitions(answer).Should().Equal(Beta); - } - - /// An exclusion stays an exclusion; it used to become a free-text search FOR the namespace. - [Fact(Timeout = 60_000)] - public async Task AnExclusionUnderABaseStillExcludes() - { - var answer = await Search($"-namespace:{Beta}/Archive name:\"{Person}\"", $"@{Beta}", - TestContext.Current.CancellationToken); - - Output.WriteLine(answer); - Paths(answer).Should().Equal(new[] { $"{Beta}/Deal/Jane" }, "the archived twin is excluded, the live one is not"); - } - - /// - /// A MULTI-VALUE exclusion keeps the base (review of #6837): the parser used to defer - /// -namespace:A|B with the positive alternation and clear the anchor, so the composed - /// query lost namespace:{base} and was refused as unanchored. Both list spellings. - /// - [Theory(Timeout = 60_000)] - [InlineData($"-namespace:{Beta}/Archive|{Beta}/Old")] - [InlineData($"-namespace:({Beta}/Archive OR {Beta}/Old)")] - public async Task AMultiValueExclusionUnderABaseKeepsTheBase(string exclusion) - { - var answer = await Search($"{exclusion} name:\"{Person}\"", $"@{Beta}", - TestContext.Current.CancellationToken); - - Output.WriteLine(answer); - Paths(answer).Should().Equal(new[] { $"{Beta}/Deal/Jane" }, "the archived twin is excluded and the base still anchors"); - Partitions(answer).Should().Equal(Beta); - } - - /// - /// A location named in ONE OR branch does not bound the other (review of #6837): - /// namespace:{base}/* OR nodeType:Markdown ran its right branch everywhere and returned - /// Alpha. Refused, since no answer can keep both the query and the base. - /// - [Fact(Timeout = 60_000)] - public async Task ALocationInOnlyOneOrBranchIsRefusedUnderABase() - { - var answer = await Search($"namespace:{Beta}/* OR nodeType:Markdown partitions:all", $"@{Beta}", - TestContext.Current.CancellationToken); - - Output.WriteLine(answer); - answer.Should().StartWith("Error:", "the right branch would read outside the base"); - answer.Should().NotContain($"\"{Alpha}\""); - } - - /// - /// The base's own composition keeps the query text verbatim (review of #6837): a split-and-rejoin - /// collapsed the spaces inside a quoted value and dropped a namespace: written inside one, - /// so an exact-name search ran on another name — here, on 's two nodes. - /// - [Theory(Timeout = 60_000)] - [InlineData(SpacedPerson, $"{Beta}/Spaced")] - [InlineData(ColonPerson, $"{Beta}/Colon")] - public async Task AQuotedValueIsSearchedVerbatimUnderABase(string name, string expected) - { - var answer = await Search($"namespace: name:\"{name}\"", $"@{Beta}", - TestContext.Current.CancellationToken); - - Output.WriteLine(answer); - Paths(answer).Should().Equal(expected); - } - - /// - /// The controls on the base itself: a query with no location of its own is scoped to the whole - /// subtree, and an empty namespace: (callers' "the base's children" idiom) is no location. - /// - [Fact(Timeout = 60_000)] - public async Task AQueryWithNoLocationIsScopedByTheBase() - { - var ct = TestContext.Current.CancellationToken; - - var both = await Search($"name:\"{Person}\"", $"@{Beta}", ct); - Output.WriteLine(both); - Paths(both).Should().Equal($"{Beta}/Archive/Jane", $"{Beta}/Deal/Jane"); - Partitions(both).Should().Equal(Beta); - - var elsewhere = await Search($"name:\"{Person}\"", $"@{Alpha}", ct); - JsonDocument.Parse(elsewhere).RootElement.GetProperty("count").GetInt32().Should().Be(0, "nobody of that name lives in Alpha"); - - var children = await Search("namespace:", $"@{Beta}", ct); - Output.WriteLine(children); - Paths(children).Should().Contain(new[] { $"{Beta}/Deal", $"{Beta}/Archive" }); - Paths(children).Should().OnlyContain(p => p.StartsWith(Beta + "/", StringComparison.Ordinal)); - } -} diff --git a/test/Memex.Portal.Shared.Test/SearchRefusesUnanchoredQueriesTest.cs b/test/Memex.Portal.Shared.Test/SearchRefusesUnanchoredQueriesTest.cs deleted file mode 100644 index 894ee02ddd..0000000000 --- a/test/Memex.Portal.Shared.Test/SearchRefusesUnanchoredQueriesTest.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 THE MCP search TOOL REFUSES AN UNANCHORED QUERY AND STATES ITS COVERAGE. MeshWeaver #4274. -/// -/// What production showed (memex.systemorph.com, 2026-09-14, one credential, calls -/// seconds apart): nodeType:LogIncident → count: 0, truncated: false; -/// namespace:Admin scope:descendants nodeType:LogIncident → truncated at any limit. Same -/// portal, same nodes, and get @Admin/_LogIncident/… returned them stamped with that exact -/// type. The bare form was served by the Postgres planner's ServeAndReport policy: a -/// cross-schema fan-out over public.searchable_schemas, which by construction never holds the -/// system schemas (admin, auth, …) and is narrowed to the partitions the caller can -/// read — and reported at Error in the portal's OWN log, where the caller never sees it. The -/// envelope the caller got was indistinguishable from a genuine empty result. AGENTS.md's -/// pre-deploy NodeType sweep was written in that form. -/// -/// The fix is at the entry point, before any backend. -/// judges the query with the planner's own CI verdict — -/// or a routing rule names the partition, else refused with the remedy — and the envelope carries -/// coverage: the scope of the read and the partitions it covered, so a zero is read against -/// a denominator. Both halves have a negative control here: the SAME query is answered the moment -/// it anchors or declares, so the refusal cannot be "search is broken", and the deep node is found -/// under basePath only because the scope widened, so the coverage cannot be decorative. -/// -public class SearchRefusesUnanchoredQueriesTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Partition = "SweepPartition"; - private const string Marker = "UnanchoredSearchMarker"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - // A node two levels below the partition root: `namespace:{Partition}` alone (immediate - // children) cannot reach it; `scope:descendants` can. That gap is what basePath used to fall - // into (#4274, property 1). - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddMeshNodes( - new MeshNode(Partition) { Name = "Sweep", NodeType = "Markdown" }, - new MeshNode("Deep", Partition) { Name = "Deep", NodeType = "Markdown" }, - new MeshNode("Leaf", $"{Partition}/Deep") { Name = Marker, NodeType = "Markdown" }); - - private Task Search( - string query, string? basePath = null, int limit = 50, CancellationToken cancellationToken = default) - => new MeshOperations(Mesh).Search(query, basePath, limit) - .FirstAsync().Timeout(Budget).Await(cancellationToken); - - // ————————————————————————— refusal - - /// - /// The shape #4274 measured: a bare nodeType: filter. Refused, naming both remedies — - /// never a count: 0 envelope that reads as "none exist". - /// - [Fact(Timeout = 60_000)] - public async Task AnUnanchoredQueryIsRefused_NotAnsweredWithAZero() - { - var answer = await Search($"nodeType:Markdown name:{Marker}", cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine(answer); - answer.Should().StartWith("Error:", "a partial answer presented as a total is the defect; a refusal is an answer"); - answer.Should().Contain("not sufficiently specified"); - answer.Should().Contain("namespace:", "the remedy names the anchor"); - answer.Should().Contain(ParsedQuery.CrossPartitionQualifier, "and the declaration"); - answer.Should().NotContain("\"count\"", "no envelope, so nothing that could be read as a count"); - } - - /// - /// The positive control on the refusal: the SAME filter, anchored, is answered and finds the - /// node. Without this the refusal test would pass over a search that refuses everything. - /// - [Fact(Timeout = 60_000)] - public async Task TheSameQueryAnchoredIsAnswered() - { - var answer = await Search( - $"namespace:{Partition} scope:descendants nodeType:Markdown name:{Marker}", - cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine(answer); - var envelope = JsonDocument.Parse(answer).RootElement; - envelope.GetProperty("count").GetInt32().Should().Be(1, "the anchored read reaches the leaf"); - envelope.GetProperty("results")[0].GetProperty("path").GetString().Should().Be($"{Partition}/Deep/Leaf"); - var coverage = envelope.GetProperty("coverage"); - coverage.GetProperty("scope").GetString().Should().Be("partition"); - coverage.GetProperty("partitions").EnumerateArray().Select(p => p.GetString()) - .Should().Equal(new[] { Partition }, "an anchored search states the partition it read"); - } - - /// - /// The other remedy: declaring the fan-out is served. On a backend whose provider does not - /// report the partitions it read (the in-memory store here; a Postgres image before its - /// reporting half), the coverage says so EXPLICITLY — partitions: null — rather than - /// implying "all" (the same reasoning as search_chunks' missing count under - /// "searched": false, #2741). - /// - [Fact(Timeout = 60_000)] - public async Task ADeclaredFanOutIsAnswered_AndAnUnreportedDenominatorIsNull() - { - var answer = await Search( - $"nodeType:Markdown name:{Marker} {ParsedQuery.CrossPartitionQualifier}", - cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine(answer); - var envelope = JsonDocument.Parse(answer).RootElement; - envelope.GetProperty("count").GetInt32().Should().Be(1); - var coverage = envelope.GetProperty("coverage"); - coverage.GetProperty("scope").GetString().Should().Be("declared"); - coverage.GetProperty("partitions").ValueKind.Should().Be(JsonValueKind.Null, - "no provider reported what it read, and an unknown denominator must never read as 'all'"); - } - - /// - /// A query a routing rule pins (nodeType:User → the Auth mirror, registered by - /// UserNodeType) is served, exactly as the planner serves it, and the envelope names the - /// partition the rule chose — which is how a reader learns that a zero there was read against - /// the mirror and not against the mesh. - /// - [Fact(Timeout = 60_000)] - public async Task AQueryARoutingRulePinsIsServed_AndTheEnvelopeNamesThatPartition() - { - var answer = await Search("nodeType:User", cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine(answer); - answer.Should().NotStartWith("Error:", "the planner itself serves a rule-routed query"); - var coverage = JsonDocument.Parse(answer).RootElement.GetProperty("coverage"); - coverage.GetProperty("scope").GetString().Should().Be("routed"); - coverage.GetProperty("partitions").EnumerateArray().Select(p => p.GetString()) - .Should().Equal(new[] { "Auth" }, "UserNodeType's rule routes a path-less nodeType:User to the auth mirror"); - } - - // ————————————————————————— basePath - - /// - /// basePath means "search FROM here" — the whole subtree. It used to become a bare - /// namespace:, i.e. immediate children, so basePath: @Admin + - /// nodeType:LogIncident answered 0 for nodes at Admin/_LogIncident/…: the - /// parameter whose documented purpose is scoping did not reach what it scoped to. - /// - [Fact(Timeout = 60_000)] - public async Task BasePathReachesTheWholeSubtree() - { - var answer = await Search( - $"nodeType:Markdown name:{Marker}", $"@{Partition}", cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine(answer); - var envelope = JsonDocument.Parse(answer).RootElement; - envelope.GetProperty("count").GetInt32().Should().Be(1, "the leaf is two levels below the base path"); - envelope.GetProperty("coverage").GetProperty("partitions").EnumerateArray().Select(p => p.GetString()) - .Should().Equal(Partition); - } - - /// - /// truncated is a measurement — "a further match exists" — not "the page was full". The - /// subtree under the base path holds exactly two Markdown nodes: a limit of 2 returns both and - /// is NOT truncated; a limit of 1 returns one and IS. - /// - [Fact(Timeout = 60_000)] - public async Task TruncatedMeansAFurtherMatchExists_NotThatThePageWasFull() - { - var full = JsonDocument.Parse(await Search( - "nodeType:Markdown", $"@{Partition}", limit: 2, cancellationToken: TestContext.Current.CancellationToken)).RootElement; - full.GetProperty("count").GetInt32().Should().Be(2, "Deep and Leaf are the two Markdown nodes under the base path"); - full.GetProperty("truncated").GetBoolean().Should().BeFalse("the page is full AND complete — nothing lies beyond it"); - - var clipped = JsonDocument.Parse(await Search( - "nodeType:Markdown", $"@{Partition}", limit: 1, cancellationToken: TestContext.Current.CancellationToken)).RootElement; - clipped.GetProperty("count").GetInt32().Should().Be(1); - clipped.GetProperty("truncated").GetBoolean().Should().BeTrue("a second match exists beyond the page"); - } - - /// - /// The control that keeps the widening honest: a query that states its OWN scope keeps it, so - /// the same base path with scope:exact (immediate children) does NOT reach the leaf. - /// - [Fact(Timeout = 60_000)] - public async Task ACallerStatedScopeIsKept() - { - var answer = await Search( - $"nodeType:Markdown name:{Marker} scope:exact", $"@{Partition}", cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine(answer); - JsonDocument.Parse(answer).RootElement.GetProperty("count").GetInt32() - .Should().Be(0, "scope:exact under the base path is its immediate children, and the leaf is deeper"); - } -} diff --git a/test/Memex.Portal.Shared.Test/SearchReportsPartialReadsTest.cs b/test/Memex.Portal.Shared.Test/SearchReportsPartialReadsTest.cs deleted file mode 100644 index 8cc22bd87e..0000000000 --- a/test/Memex.Portal.Shared.Test/SearchReportsPartialReadsTest.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 THE MCP search ENVELOPE NAMES THE PARTITIONS IT READ ONLY IN PART. MeshWeaver #6564. -/// -/// A pre-deploy sweep answered count: 0 with the partition in -/// coverage.partitions, while a NodeType under a sub-path the caller held no grant on sat at -/// Error. The provider now reports those partitions -/// () when the request asks -/// (), and search renders them as -/// coverage.partial. The merge tests stop before this serialization layer, so these cases pin -/// the envelope itself: the three verdicts stay distinct, the hint appears only for a non-empty -/// list, and the opt-in reaches the provider (it reports only when asked, so a dropped flag turns -/// the non-empty case null). -/// -public class SearchReportsPartialReadsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Unassessed = "PartialReadUnassessed"; - private const string Covered = "PartialReadCovered"; - private const string InPart = "PartialReadInPart"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - /// - /// Stands in for a provider behind row-level security. It answers only the three marker - /// queries, reports reading alpha and beta, and states its partial-read verdict - /// ONLY when the request opted in, so the non-empty case also proves the flag reached it. - /// - private sealed class PartialReportingProvider : IMeshQueryProvider - { - public string Name => nameof(PartialReportingProvider); - - public bool Matches(IReadOnlyList queryNamespaces) => true; - - public IObservable> Query(MeshQueryRequest request, JsonSerializerOptions options) - { - var text = request.Query ?? string.Empty; - IReadOnlyList? partitions = null; - IReadOnlyList? partial = null; - if (text.Contains(Unassessed)) - partitions = ["alpha", "beta"]; - else if (text.Contains(Covered)) - (partitions, partial) = (["alpha", "beta"], request.ReportPartialReads ? [] : null); - else if (text.Contains(InPart)) - (partitions, partial) = (["alpha", "beta"], request.ReportPartialReads ? ["beta"] : null); - - return Observable.Return(new QueryResultChange - { - ChangeType = QueryChangeType.Initial, - Items = Array.Empty(), - Timestamp = DateTimeOffset.UtcNow, - Partitions = partitions, - PartiallyReadPartitions = partial, - }); - } - - public IObservable> Query(MeshQueryRequest request, JsonSerializerOptions options) - => Observable.Return((IReadOnlyCollection)Array.Empty()); - - public IObservable> Autocomplete( - string basePath, string prefix, JsonSerializerOptions options, - AutocompleteMode mode = AutocompleteMode.RelevanceFirst, int limit = 10, - string? contextPath = null, string? context = null) - => Observable.Return((IReadOnlyCollection)Array.Empty()); - - public IObservable Select(string path, string property, JsonSerializerOptions options) - => Observable.Return(default); - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .ConfigureServices(services => services.AddSingleton()); - - private async Task<(JsonElement Coverage, JsonElement Envelope)> Read(string marker, CancellationToken cancellationToken) - { - var answer = await new MeshOperations(Mesh) - .Search($"nodeType:Markdown name:{marker} {ParsedQuery.CrossPartitionQualifier}", null, 50) - .FirstAsync().Timeout(Budget).Await(cancellationToken); - Output.WriteLine(answer); - var envelope = JsonDocument.Parse(answer).RootElement; - var coverage = envelope.GetProperty("coverage"); - coverage.GetProperty("partitions").EnumerateArray().Select(p => p.GetString()) - .Should().Equal(new[] { "alpha", "beta" }, "the provider's denominator reaches the envelope"); - return (coverage, envelope); - } - - /// Not assessed: partial is JSON null (unknown), never an empty list. - [Fact(Timeout = 60_000)] - public async Task AnUnassessedVerdict_IsNull_NotEmpty() - { - var (coverage, envelope) = await Read(Unassessed, TestContext.Current.CancellationToken); - - coverage.GetProperty("partial").ValueKind.Should().Be(JsonValueKind.Null, - "a provider that did not assess must never read as 'none partial'"); - envelope.TryGetProperty("partialHint", out _).Should().BeFalse(); - } - - /// Assessed and covered: an empty list, distinct from null, and no hint. - [Fact(Timeout = 60_000)] - public async Task AnAssessedCoveredVerdict_IsEmpty_AndCarriesNoHint() - { - var (coverage, envelope) = await Read(Covered, TestContext.Current.CancellationToken); - - coverage.GetProperty("partial").ValueKind.Should().Be(JsonValueKind.Array, - "search opted in, so the provider assessed; an empty list is the only passing verdict"); - coverage.GetProperty("partial").GetArrayLength().Should().Be(0); - envelope.TryGetProperty("partialHint", out _).Should().BeFalse("nothing was read in part"); - } - - /// - /// Read in part: the partition is named and the hint says the count is a floor there. The - /// provider reports only when asked, so this also proves search set the opt-in. - /// - [Fact(Timeout = 60_000)] - public async Task APartialVerdict_NamesThePartition_AndCarriesTheHint() - { - var (coverage, envelope) = await Read(InPart, TestContext.Current.CancellationToken); - - coverage.GetProperty("partial").EnumerateArray().Select(p => p.GetString()) - .Should().Equal(new[] { "beta" }, "search sets ReportPartialReads, and the verdict survives into the envelope"); - envelope.GetProperty("partialHint").GetString().Should() - .Contain("coverage.partial").And.Contain("floor"); - } -} diff --git a/test/Memex.Portal.Shared.Test/SearchResolvesInQueryLocationsTest.cs b/test/Memex.Portal.Shared.Test/SearchResolvesInQueryLocationsTest.cs deleted file mode 100644 index 13772b4449..0000000000 --- a/test/Memex.Portal.Shared.Test/SearchResolvesInQueryLocationsTest.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 An exact-name search for a person who exists must find them, however the agent spells the -/// location (#6828). Measured on a portal: name:"First Last" basePath:@space answered -/// count: 0 for a contact two levels below the space and for a node in the user's own home, -/// so the assistant reported the person as unfindable. The name half was never the problem — -/// name: is a case-insensitive exact match on MeshNode.Name, quoted multi-word -/// values included, on both executors. The LOCATION half was: @space written into the query -/// text kept its @ and anchored at a partition literally called @space, and -/// basePath: was not a qualifier but a filter on a property no node has. -/// -/// The control on every positive: the same location with a name that does not exist answers -/// zero, so a passing positive is the name filter AND the location working, not a filter dropped. -/// -public class SearchResolvesInQueryLocationsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Space = "NameSearchSpace"; - private const string Person = "Jane Q Example"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - // The contact sits two levels below the space root, as a contact under an opportunity does. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddMeshNodes( - new MeshNode(Space) { Name = "Name search space", NodeType = "Markdown" }, - new MeshNode("Deal", Space) { Name = "A deal", NodeType = "Markdown" }, - new MeshNode("Jane", $"{Space}/Deal") { Name = Person, NodeType = "Markdown" }); - - private Task Search( - string query, string? basePath = null, CancellationToken cancellationToken = default) - => new MeshOperations(Mesh).Search(query, basePath, 50) - .FirstAsync().Timeout(Budget).Await(cancellationToken); - - public static TheoryData Shapes => new() - { - // The measured shape: basePath written into the query text AND passed as the argument. - { $"name:\"{{0}}\" basePath:@{Space}", $"@{Space}" }, - // basePath written only into the query text. - { $"name:\"{{0}}\" basePath:@{Space}", null }, - // The reference prefix on an ordinary namespace anchor. - { $"namespace:@{Space} scope:descendants name:\"{{0}}\"", null }, - { $"path:@/{Space} scope:descendants name:\"{{0}}\"", null }, - // The argument alone — the shape that always worked, kept as the baseline. - { $"name:\"{{0}}\"", $"@{Space}" }, - }; - - [Theory(Timeout = 60_000)] - [MemberData(nameof(Shapes))] - public async Task AnExactNameIsFound_WhereverTheLocationIsWritten(string shape, string? basePath) - { - var ct = TestContext.Current.CancellationToken; - - var found = await Search(string.Format(shape, Person), basePath, ct); - Output.WriteLine(found); - found.Should().NotStartWith("Error:", "the query names its location"); - var envelope = JsonDocument.Parse(found).RootElement; - envelope.GetProperty("results").EnumerateArray().Select(r => r.GetProperty("path").GetString()) - .Should().Equal(new[] { $"{Space}/Deal/Jane" }, "the person exists two levels below the space"); - envelope.GetProperty("coverage").GetProperty("partitions").EnumerateArray().Select(p => p.GetString()) - .Should().Equal(new[] { Space }, "the read ran against the space the query named"); - - // Case-insensitive, as both executors compare names. - var lower = await Search(string.Format(shape, Person.ToLowerInvariant()), basePath, ct); - JsonDocument.Parse(lower).RootElement.GetProperty("count").GetInt32().Should().Be(1); - - // Control: the same location with a name nobody has answers zero, so the positive above - // is the name filter working, not a filter that was dropped. - var absent = await Search(string.Format(shape, "Nobody Of That Name"), basePath, ct); - Output.WriteLine(absent); - JsonDocument.Parse(absent).RootElement.GetProperty("count").GetInt32().Should().Be(0); - } -} diff --git a/test/Memex.Portal.Shared.Test/SelfUpdateChecksOnlyAtDecisionPointsTest.cs b/test/Memex.Portal.Shared.Test/SelfUpdateChecksOnlyAtDecisionPointsTest.cs deleted file mode 100644 index a18471aaca..0000000000 --- a/test/Memex.Portal.Shared.Test/SelfUpdateChecksOnlyAtDecisionPointsTest.cs +++ /dev/null @@ -1,343 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 #3790 — "Self-update check runs on every build completion on every replica under -/// UpdatePolicy=None, and the replicas fight over Admin/UpdatePolicy.lastCheckedAt". -/// -/// Measured on memex 2026-09-09, 01:30–06:36Z: 158 checks in 5 h, every one of them -/// reporting "updates are disabled on this install (Admin/UpdatePolicy = None)" — a verdict -/// the record already carried — and every one of them writing LastCheckedAt cross-hub into -/// the same leaf from both replicas. 14 [MergeGuard] refused stale/reordered, 10 -/// OWNER_NACK_REENQUEUE, five STALE_MIRROR, three ADVANCE_WITHOUT_HANDOFF and -/// two ten-second [UpdateQueue] FAILED stalls in the hub that also carries user writes, with -/// Admin/UpdatePolicy standing at version 62,671. -/// -/// The cause is a rate, and the rate is not this install's: BuildCompletion ticks once -/// per publication ANYWHERE in the fleet. Under None such a check is decision-free by -/// construction — is false for -/// UpdatesDisabled, so it cannot roll and cannot restart either. -/// -/// 🚨 The line these tests walk: #2553 removed a Where that dropped every check -/// under None and left an admin-pinned install indistinguishable from a broken one. So the -/// suite asserts BOTH directions — the fleet-paced triggers stop, and the record still carries the -/// disabled verdict, still moves on the safety net, and still wakes on a policy change. -/// -/// Fails on unfixed code: sees the -/// second check its control proves the driver is capable of. -/// -public class SelfUpdateChecksOnlyAtDecisionPointsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private static TimeSpan Budget => TestTimeouts.Convergence; - - private static string Installed => ShippedReleaseSeed.InstalledPlatformVersion; - - private static string InstalledTag => Installed.Split('+')[0]; - - /// A registry with nothing newer than this install, in which its own tag resolves — - /// so under Continuous a check reaches "no newer release" and rolls nothing. - private static string[] NothingNewer => ["1.9.0-ci.0", "2.0.0-ci.0", InstalledTag]; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddUpdatePolicyType().AddGitHubSyncTypes(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - // ══════════════════════════════════════════════════════════════════════════ - // The rule, pinned without a mesh - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The whole truth table, every trigger × every policy — because the defect and the - /// regression that would undo #2553 are one enum member apart, and a test that only checked - /// the None/BuildCompletion cell would pass on a predicate that returned - /// false for everything. - /// - [Theory] - // Under None the FLEET-paced triggers decide nothing: RunOnce short-circuits to UpdatesDisabled - // before it lists a tag, and MayRestartAfter(UpdatesDisabled) is false. - [InlineData(UpdatePolicyKind.None, SelfUpdateTrigger.BuildCompletion, false)] - [InlineData(UpdatePolicyKind.None, SelfUpdateTrigger.ModuleSetProposed, false)] - // …but the three this install paces itself still run, and each one is a #2553 obligation: - // Startup puts the disabled verdict on the record at all, PolicyChange is how updates get - // enabled, SafetyNet is what keeps LastCheckedAt moving so a dead checker reads as stale. - [InlineData(UpdatePolicyKind.None, SelfUpdateTrigger.Startup, true)] - [InlineData(UpdatePolicyKind.None, SelfUpdateTrigger.PolicyChange, true)] - [InlineData(UpdatePolicyKind.None, SelfUpdateTrigger.SafetyNet, true)] - // Under every policy that can act, every trigger is a decision point. This half is what stops - // the predicate from being "narrow the poller until the log is quiet". - [InlineData(UpdatePolicyKind.Continuous, SelfUpdateTrigger.BuildCompletion, true)] - [InlineData(UpdatePolicyKind.Continuous, SelfUpdateTrigger.ModuleSetProposed, true)] - [InlineData(UpdatePolicyKind.Continuous, SelfUpdateTrigger.Startup, true)] - [InlineData(UpdatePolicyKind.Continuous, SelfUpdateTrigger.PolicyChange, true)] - [InlineData(UpdatePolicyKind.Continuous, SelfUpdateTrigger.SafetyNet, true)] - [InlineData(UpdatePolicyKind.Stable, SelfUpdateTrigger.BuildCompletion, true)] - [InlineData(UpdatePolicyKind.Stable, SelfUpdateTrigger.ModuleSetProposed, true)] - [InlineData(UpdatePolicyKind.Stable, SelfUpdateTrigger.Startup, true)] - [InlineData(UpdatePolicyKind.Stable, SelfUpdateTrigger.PolicyChange, true)] - [InlineData(UpdatePolicyKind.Stable, SelfUpdateTrigger.SafetyNet, true)] - public void TheDecisionPointRule(UpdatePolicyKind policy, SelfUpdateTrigger trigger, bool expected) => - SelfUpdateHostedService.IsDecisionPoint(trigger, new UpdatePolicyContent { Policy = policy }) - .Should().Be(expected); - - // ══════════════════════════════════════════════════════════════════════════ - // The same driver, under two policies — a controlled experiment - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The acceptance criterion. Policy None; the startup check lands; a build - /// completion is then pushed through the REAL trigger seam — and no second check happens. - /// - /// The bound below would also be satisfied by a service that had simply stopped, which is - /// why pushes the identical event - /// through the identical seam and asserts the second check DOES arrive. Neither test means - /// anything alone. - /// - [Fact(Timeout = 240_000)] - public async Task UnderNone_ABuildCompletion_IsNotACheck() - { - await Seed(UpdatePolicyKind.None, TestContext.Current.CancellationToken); - await using var run = await Start(); - - await run.FirstCheck; - - run.PushBuildCompletion(); - - await run.LaterChecks.Should().NotEmit(TestTimeouts.Quick, - "under None a build completion can neither roll nor restart (MayRestartAfter is false " - + "for UpdatesDisabled), so all it could do is rewrite a verdict the node already " - + "carries — 158 times in 5 h on memex, from both replicas, onto one leaf", cancellationToken: TestContext.Current.CancellationToken); - } - - /// - /// 🚨 The positive control, and the guard against "fix" by making the service deaf: the - /// same seam, the same push, a policy that can act — and the check happens. - /// - [Fact(Timeout = 240_000)] - public async Task UnderContinuous_ABuildCompletion_IsACheck() - { - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - await using var run = await Start(); - - await run.FirstCheck; - - run.PushBuildCompletion(); - - await run.LaterChecks.FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - } - - /// - /// 🚨 The #2553 obligation, which is the whole reason the predicate is a filter on the TRIGGER - /// and not a filter on the check: an install pinned to None still says so, durably, on - /// the node — "an admin pinned this" and "the updater is broken" must never look alike again. - /// - [Fact(Timeout = 240_000)] - public async Task UnderNone_TheRecordStillCarriesTheDisabledVerdict() - { - await Seed(UpdatePolicyKind.None, TestContext.Current.CancellationToken); - await using var run = await Start(); - - await run.FirstCheck; - var content = await WaitForContent(c => c.LastCheckVerdict is not null, TestContext.Current.CancellationToken); - - content.LastCheckVerdict.Should().Contain("updates are disabled", - "the durable half of the report is what an operator reads on the Updates tab"); - content.LastCheckedAt.Should().NotBeNull( - "a stamp is what distinguishes an install that checked from one that never did"); - content.LastCheckTrigger.Should().Be(nameof(SelfUpdateTrigger.Startup)); - } - - /// - /// 🚨 And the stamp keeps MOVING, on this service's own period: the safety net still runs under - /// None, so a checker that has died still reads as a stale LastCheckedAt rather - /// than as one frozen by design. This is the cell that stops the fix from becoming a freeze. - /// - [Fact(Timeout = 240_000)] - public async Task UnderNone_TheSafetyNet_StillChecks() - { - await Seed(UpdatePolicyKind.None, TestContext.Current.CancellationToken); - await using var run = await Start(); - - await run.FirstCheck; - - run.PushSafetyNet(); - - await run.LaterChecks.FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - var content = await WaitForContent( - c => c.LastCheckTrigger == nameof(SelfUpdateTrigger.SafetyNet), TestContext.Current.CancellationToken); - content.LastCheckVerdict.Should().Contain("updates are disabled"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Harness - // ══════════════════════════════════════════════════════════════════════════ - - private sealed class FakeAcrTagLister(IReadOnlyList tags) : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult(tags); - } - - /// Detect-and-notify: this suite is about which triggers reach a check, never about - /// what a check then does to a cluster. - private sealed class InertUpdater : IDeploymentUpdater - { - public bool CanPatch => false; - - public Task LastRolledAtAsync(CancellationToken ct) => - Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) => Task.CompletedTask; - } - - private sealed class AlwaysAvailable(IMessageHub hub, IConfiguration configuration) - : ReleaseAvailabilityService(hub, configuration) - { - public override IObservable IsUpdatable(string? targetVersion) => - Observable.Return(UpdatabilityVerdict.NotEnforced("this test host consumes no CI bakes")); - } - - /// The poller with its two fleet-paced trigger seams driven by hand, so a build - /// completion is an EVENT the test pushes rather than a timer it waits out. - private sealed class DrivenSelfUpdateService( - IMessageHub hub, - IAcrTagLister acr, - SelfUpdateOptions options, - ILogger? logger, - ReleaseAvailabilityService gate, - IObservable builds, - IObservable safetyNet) - : SelfUpdateHostedService(hub, acr, new InertUpdater(), options, logger) - { - public IObservable Evaluations => ChecksReported; - - protected override ReleaseAvailabilityService? ResolveAvailabilityGate() => gate; - - protected override ComboVerificationGate? ResolveComboGate() => null; - - protected override ModuleLandingService? ResolveLandingService() => null; - - protected override IObservable BuildCompletionTicks() => builds; - - protected override IObservable SafetyNetTicks() => safetyNet; - } - - /// One running service plus the two things every test here needs: a signal that the - /// startup check has been REPORTED, and a stream of every check after it. - /// - /// 🚨 The checks are Replay()ed and connected BEFORE StartAsync. A live - /// Publish() would make every assertion here a race against the startup check landing in - /// the gap between subscribe and start — and the shape that race fails in is a HANG, which is - /// how a suite about a rate would end up bounding one. - private sealed class Run( - SelfUpdateHostedService service, - Subject builds, - Subject safetyNet, - IObservable checks, - IDisposable connection) : IAsyncDisposable - { - /// The startup check, reported. Replayed, so awaiting it after the fact works. - public Task FirstCheck => checks.FirstAsync().Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - /// Every check reported after the startup one. - public IObservable LaterChecks => checks.Skip(1); - - public void PushBuildCompletion() => builds.OnNext(SelfUpdateTrigger.BuildCompletion); - - public void PushSafetyNet() => safetyNet.OnNext(SelfUpdateTrigger.SafetyNet); - - public async ValueTask DisposeAsync() - { - await service.StopAsync(CancellationToken.None); - connection.Dispose(); - builds.Dispose(); - safetyNet.Dispose(); - } - } - - private async Task Start() - { - var builds = new Subject(); - var safetyNet = new Subject(); - var service = new DrivenSelfUpdateService( - Mesh, new FakeAcrTagLister(NothingNewer), - new SelfUpdateOptions - { - RetryInterval = TimeSpan.FromMilliseconds(500), - EventCoalesceWindow = TimeSpan.FromMilliseconds(50), - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - }, - Mesh.ServiceProvider.GetService>(), - new AlwaysAvailable(Mesh, new ConfigurationBuilder().Build()), - builds, safetyNet); - var checks = service.Evaluations.Replay(); - var connection = checks.Connect(); - await service.StartAsync(CancellationToken.None); - return new Run(service, builds, safetyNet, checks, connection); - } - - private Task Seed(UpdatePolicyKind policy, CancellationToken cancellationToken) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent - { - Policy = policy, - Pattern = policy == UpdatePolicyKind.Continuous ? "*-ci*" : null, - }, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } - - private Task WaitForContent( - Func predicate, CancellationToken cancellationToken) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace() - .GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .Subscribe(observer); - }) - .Where(predicate) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/SelfUpdateFailureIsPublicTest.cs b/test/Memex.Portal.Shared.Test/SelfUpdateFailureIsPublicTest.cs deleted file mode 100644 index 18feb50cc2..0000000000 --- a/test/Memex.Portal.Shared.Test/SelfUpdateFailureIsPublicTest.cs +++ /dev/null @@ -1,289 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.ServiceDefaults; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 Policy control-first-never-silent — a self-update that FAILS is published on -/// /health, where the CD arming reads it. -/// -/// Measured 2026-10-07/08: control.systemorph.com's Admin/UpdatePolicy.lastCheckVerdict -/// read check FAILED: CredentialUnavailableException … ManagedIdentityCredential authentication -/// unavailable. The requested identity has not been assigned to this resource on every check -/// (Plugins#2994), control took no build for 10+ hours, control-first therefore offered memex and -/// memex-cloud nothing — and control's public /health said nothing about any of it. The -/// failure lived on one authenticated node nobody watches. -/// -/// These tests drive the REAL poller through a registry read that faults exactly that way and -/// assert the delivery on all three surfaces the one classification -/// () feeds: the census the /health entry reads, the -/// /health body line in the exact shape .github/scripts/arm-promoted-set.py parses -/// (self_update: Degraded — …), and Admin/UpdatePolicy.lastCheckFailed. The control is -/// the same driver with a registry that answers: Healthy, and still PRINTED (a census entry), never -/// silent. -/// -/// Fails on unfixed code: before this change the poller recorded nothing outside the -/// node, so stays null and the body has no -/// self_update line at all. -/// -public class SelfUpdateFailureIsPublicTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private static TimeSpan Budget => TestTimeouts.Convergence; - - private static string InstalledTag => ShippedReleaseSeed.InstalledPlatformVersion.Split('+')[0]; - - /// The exception text measured on control (the managed-identity leg of the chain). - private const string MeasuredCredentialFault = - "DefaultAzureCredential failed to retrieve a token from the included credentials.\n" - + "- ManagedIdentityCredential authentication unavailable. The requested identity has not been assigned to this resource."; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddUpdatePolicyType().AddGitHubSyncTypes() - .ConfigureServices(services => - { - // Exactly what AddSelfUpdate registers; this suite constructs the poller by hand. - services.TryAddSingleton(); - return services; - }); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - private SelfUpdateCheckCensus Census => Mesh.ServiceProvider.GetRequiredService(); - - [Fact(Timeout = 240_000)] - public async Task AFailingCheck_IsDegradedOnHealth_InTheShapeTheArmingParses() - { - await Seed(TestContext.Current.CancellationToken); - await using var run = await Start(new FaultingTagLister(MeasuredCredentialFault)); - await run.FirstCheck; - - var reading = Census.Last; - reading.Should().NotBeNull("the reporting site records every check into the census /health reads"); - reading!.Failed.Should().BeTrue("a check that faulted is the failure control-first must not hide"); - reading.Outcome.Should().Be(nameof(SelfUpdateOutcome.CheckFailed)); - - var entry = SelfUpdateHealthCheck.Evaluate(Census, DateTimeOffset.UtcNow); - entry.Status.Should().Be(HealthStatus.Degraded, "Degraded — a 200, never a reason to pull the pod"); - - var line = HealthLine(entry); - line.Should().StartWith("self_update: Degraded — check FAILED: InvalidOperationException: DefaultAzureCredential failed", - "arm-promoted-set.py reads `self_update: — ` off the public body"); - line.Should().NotContain("\n", "a /health body is parsed line by line"); - - var content = await WaitForContent(c => c.LastCheckFailed is not null, TestContext.Current.CancellationToken); - content.LastCheckFailed.Should().BeTrue("the fleet console flags the same classification off the node"); - content.LastCheckVerdict.Should().Contain("The requested identity has not been assigned to this resource", - "the node keeps the FULL verdict; /health carries its first line"); - } - - /// The control: the same driver, a registry that answers — Healthy, and still printed. - [Fact(Timeout = 240_000)] - public async Task ACleanCheck_IsHealthyOnHealth_AndStillPrinted() - { - await Seed(TestContext.Current.CancellationToken); - await using var run = await Start(new FixedTagLister(["1.9.0-ci.0", InstalledTag])); - await run.FirstCheck; - - Census.Last!.Failed.Should().BeFalse(); - var entry = SelfUpdateHealthCheck.Evaluate(Census, DateTimeOffset.UtcNow); - entry.Status.Should().Be(HealthStatus.Healthy); - HealthLine(entry).Should().StartWith("self_update: Healthy — no newer release", - "a census entry prints whatever its status: 'checked and nothing newer' must read differently from silence"); - - var content = await WaitForContent(c => c.LastCheckFailed is not null, TestContext.Current.CancellationToken); - content.LastCheckFailed.Should().BeFalse(); - } - - /// No reading and no census are each said as what they are — never as a clean check. - [Fact] - public void AnAbsentReading_IsSaidAsAbsent() - { - SelfUpdateHealthCheck.Evaluate(new SelfUpdateCheckCensus(), DateTimeOffset.UtcNow).Description - .Should().Contain("absence of measurement"); - SelfUpdateHealthCheck.Evaluate(null, DateTimeOffset.UtcNow).Description - .Should().Contain("no self-updater is registered"); - } - - /// The one classification: the warning set, the node flag and /health agree. - [Theory] - [InlineData(SelfUpdateOutcome.CheckFailed, true)] - [InlineData(SelfUpdateOutcome.NoOutcome, true)] - [InlineData(SelfUpdateOutcome.HandoverFailed, true)] - [InlineData(SelfUpdateOutcome.RestartHandoverFailed, true)] - [InlineData(SelfUpdateOutcome.InstalledTagWithdrawn, true)] - [InlineData(SelfUpdateOutcome.RestartUnavailable, true)] - [InlineData(SelfUpdateOutcome.MigrationFailed, true)] - [InlineData(SelfUpdateOutcome.MigrationUnavailable, true)] - [InlineData(SelfUpdateOutcome.NoNewerRelease, false)] - [InlineData(SelfUpdateOutcome.HandedOver, false)] - [InlineData(SelfUpdateOutcome.Applied, false)] - [InlineData(SelfUpdateOutcome.Held, false)] - [InlineData(SelfUpdateOutcome.Deferred, false)] - [InlineData(SelfUpdateOutcome.UpdatesDisabled, false)] - public void TheFailureClassification(SelfUpdateOutcome outcome, bool failure) => - new SelfUpdateVerdict(outcome, "x").IsFailure.Should().Be(failure); - - /// A whole credential-chain dump becomes ONE bounded line. - [Fact] - public void AMultiLineVerdict_BecomesOneBoundedLine() - { - SelfUpdateCheckCensus.OneLine("check FAILED: X\n- a\n- b").Should().Be("check FAILED: X"); - SelfUpdateCheckCensus.OneLine(new string('y', 2000)).Length.Should().Be(SelfUpdateCheckCensus.MaxLineLength); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Harness - // ══════════════════════════════════════════════════════════════════════════ - - /// The /health body line for , through the real body writer. - private static string HealthLine(HealthCheckResult entry) - { - var report = new HealthReport( - new Dictionary - { - [SelfUpdateCheckCensus.HealthCheckName] = new(entry.Status, entry.Description, TimeSpan.Zero, null, null, - [ProbeEndpoints.CensusTag]), - }, - TimeSpan.Zero); - return Memex.Portal.ServiceDefaults.ServiceDefaults.HealthBodyLines(report, Memex.Portal.ServiceDefaults.ServiceDefaults.StartupStatus(report)) - .Single(l => l.StartsWith(SelfUpdateCheckCensus.HealthCheckName + ":", StringComparison.Ordinal)); - } - - private sealed class FaultingTagLister(string message) : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromException>(new InvalidOperationException(message)); - } - - private sealed class FixedTagLister(IReadOnlyList tags) : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult(tags); - } - - private sealed class InertUpdater : IDeploymentUpdater - { - public bool CanPatch => false; - - public Task LastRolledAtAsync(CancellationToken ct) => Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) => Task.CompletedTask; - } - - private sealed class AlwaysAvailable(IMessageHub hub, IConfiguration configuration) - : ReleaseAvailabilityService(hub, configuration) - { - public override IObservable IsUpdatable(string? targetVersion) => - Observable.Return(UpdatabilityVerdict.NotEnforced("this test host consumes no CI bakes")); - } - - /// The poller with its fleet-paced triggers silenced: only the startup check runs. - private sealed class DrivenSelfUpdateService( - IMessageHub hub, IAcrTagLister acr, SelfUpdateOptions options, - ILogger? logger, ReleaseAvailabilityService gate) - : SelfUpdateHostedService(hub, acr, new InertUpdater(), options, logger) - { - public IObservable Evaluations => ChecksReported; - - protected override ReleaseAvailabilityService? ResolveAvailabilityGate() => gate; - - protected override ComboVerificationGate? ResolveComboGate() => null; - - protected override ModuleLandingService? ResolveLandingService() => null; - - protected override IObservable BuildCompletionTicks() => Observable.Never(); - - protected override IObservable SafetyNetTicks() => Observable.Never(); - } - - private sealed class Run(SelfUpdateHostedService service, IObservable checks, IDisposable connection) - : IAsyncDisposable - { - public Task FirstCheck => checks.FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - - public async ValueTask DisposeAsync() - { - await service.StopAsync(CancellationToken.None); - connection.Dispose(); - } - } - - private async Task Start(IAcrTagLister lister) - { - var service = new DrivenSelfUpdateService( - Mesh, lister, - new SelfUpdateOptions - { - RetryInterval = TimeSpan.FromMilliseconds(500), - EventCoalesceWindow = TimeSpan.FromMilliseconds(50), - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - }, - Mesh.ServiceProvider.GetService>(), - new AlwaysAvailable(Mesh, new ConfigurationBuilder().Build())); - var checks = service.Evaluations.Replay(); - var connection = checks.Connect(); - await service.StartAsync(CancellationToken.None); - return new Run(service, checks, connection); - } - - private Task Seed(CancellationToken cancellationToken) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, Pattern = "*-ci*" }, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } - - private Task WaitForContent( - Func predicate, CancellationToken cancellationToken) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace() - .GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .Subscribe(observer); - }) - .Where(predicate) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/SelfUpdateHandoverTest.cs b/test/Memex.Portal.Shared.Test/SelfUpdateHandoverTest.cs deleted file mode 100644 index 70994715e1..0000000000 --- a/test/Memex.Portal.Shared.Test/SelfUpdateHandoverTest.cs +++ /dev/null @@ -1,410 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Text.Json; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Graph.Configuration; -using Microsoft.Extensions.Configuration; -using Xunit; -using Route = Memex.Portal.Shared.SelfUpdate.SelfUpdateHandover.Route; - -namespace Memex.Portal.Shared.Test; - -/// -/// The PURE rules of the control-lane hand-over (MeshWeaver#4098): who applies, which route, what -/// is missing, what the event looks like and how it is signed — pinned without a mesh, so the -/// decision the poller takes on the fleet is the decision written here. -/// -public class SelfUpdateHandoverTest -{ - private const string Deployment = "build"; - private const string InboxUrl = "https://memex.systemorph.com/api/hooks/Hosting/PlatformBuilds"; - - // ── who applies ───────────────────────────────────────────────────────── - - /// The chart AND the updater must both allow a self-patch; either alone is not a right. - [Theory] - [InlineData(true, true, Route.Post, SelfUpdateApply.SelfPatch)] - [InlineData(true, true, Route.None, SelfUpdateApply.SelfPatch)] - [InlineData(false, true, Route.Post, SelfUpdateApply.ControlLane)] - [InlineData(true, false, Route.Post, SelfUpdateApply.ControlLane)] - [InlineData(false, false, Route.Local, SelfUpdateApply.ControlLane)] - [InlineData(false, true, Route.None, SelfUpdateApply.DetectOnly)] - [InlineData(true, false, Route.None, SelfUpdateApply.DetectOnly)] - public void WhoApplies_IsTheChartAndTheUpdaterTogether_ElseTheControlLane_ElseNobody( - bool chartCanPatch, bool updaterCanPatch, Route route, SelfUpdateApply expected) => - SelfUpdateHandover.ApplyModeFor(chartCanPatch, updaterCanPatch, route).Should().Be(expected); - - // ── which route ───────────────────────────────────────────────────────── - - [Fact] - public void ARecordIdIsRequiredOnEveryRoute() - { - var post = new SelfUpdateHandover.Settings("", InboxUrl, SecretPresent: true, LocalTargetListed: true, LocalSecretPresent: true, null); - SelfUpdateHandover.RouteFor(post).Should().Be(Route.None, - "an event naming no record on the control instance is not a hand-over — the control plane routes by it"); - SelfUpdateHandover.Missing(post).Should().Contain(SelfUpdateHandover.DeploymentKey); - } - - [Fact] - public void APostNeedsTheUrlAndTheSecret_AndIsPreferredOverLocal() - { - SelfUpdateHandover.RouteFor(new(Deployment, InboxUrl, true, false, false, null)).Should().Be(Route.Post); - SelfUpdateHandover.RouteFor(new(Deployment, InboxUrl, true, true, true, null)).Should().Be(Route.Post, - "an instance that declares a control inbox is a consumer, whatever it also lists"); - SelfUpdateHandover.RouteFor(new(Deployment, InboxUrl, false, false, false, null)).Should().Be(Route.None); - SelfUpdateHandover.RouteFor(new(Deployment, null, true, false, false, null)).Should().Be(Route.None); - } - - [Fact] - public void TheControlInstanceDeliversLocally_OnlyWithItsTargetListedDeclaringAKeyWhoseSecretIsPresent() - { - SelfUpdateHandover.RouteFor(new(Deployment, null, false, true, true, null) { LocalSecretKey = "Hosting:PlatformWebhookSecret" }) - .Should().Be(Route.Local); - SelfUpdateHandover.RouteFor(new(Deployment, null, false, true, false, null) { LocalSecretKey = "Hosting:PlatformWebhookSecret" }) - .Should().Be(Route.None); - SelfUpdateHandover.RouteFor(new(Deployment, null, false, false, true, null) { LocalSecretKey = "Hosting:PlatformWebhookSecret" }) - .Should().Be(Route.None); - // 🚨 The route itself requires the DECLARED key: settings assembled by hand as "listed and - // present" with no key must not reach local delivery either. - SelfUpdateHandover.RouteFor(new(Deployment, null, false, true, true, null)).Should().Be(Route.None, - "a listed target without a declared SecretConfigKey is an unsigned target — the route, not only the reader, refuses it"); - } - - /// - /// 🚨 A DECLARED control inbox is EXCLUSIVE: the three shapes of an instance that names one are - /// (secret present), a REFUSAL that names the missing secret (absent), - /// and nothing else — never a fall-through to self-delivery, whatever - /// the instance also lists. This is the doc comment's own rule ("an instance that declares a - /// control inbox is a consumer, whatever it also lists") applied to the case that actually - /// happens rather than only to the one where the secret is there. - /// - [Theory] - [InlineData(InboxUrl, true, Route.Post)] - [InlineData(InboxUrl, false, Route.None)] - [InlineData(null, false, Route.Local)] - public void ADeclaredControlInbox_IsExclusive_NeverFallingThroughToLocalDelivery( - string? url, bool secretPresent, Route expected) => - SelfUpdateHandover.RouteFor( - new(Deployment, url, secretPresent, LocalTargetListed: true, LocalSecretPresent: true, null) - { - UrlFrom = url is null ? SelfUpdateHandover.UrlSource.None : SelfUpdateHandover.UrlSource.Derived, - LocalSecretKey = SelfUpdateHandover.LocalSecretKey, - }) - .Should().Be(expected, - "a consumer whose signing secret is absent hands over to nobody — and saying so is the only " - + "reading that can fail; delivering into its own inbox reads like a success. Local stays the " - + "control instance's route: it declares no ReportTo and no ControlInbox:Url"); - - /// - /// 🚨 The measured build shape (MeshWeaver#4098, live record v12 read 2026-09-15): the inbox - /// URL is DERIVED from Hosting:ReportTo, Hosting:ControlInbox:Secret is ABSENT, and - /// build legitimately LISTS Hosting/PlatformBuilds with a declared secret key whose - /// secret IS mounted — it owns the fleet's build queue. Falling through to the local route there - /// stores the release in build's OWN inbox, whose watcher classifies a self-update-available - /// event as a non-build event and DELETES it, while the boot line reports apply=control-lane - /// with a verified delivery and names nothing. The route - /// must refuse and the refusal must name the absent key, so the boot line is a reading that can - /// fail. The config-only alternative (mount the secret on build) fixes one record and leaves - /// the silent self-delivery reachable for the next instance that owns an inbox. - /// - [Fact] - public void AnInstanceThatNamesAControlInstance_AndOwnsAnInbox_RefusesRatherThanTalkingToItself() - { - var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [SelfUpdateHandover.DeploymentKey] = Deployment, - [SelfUpdateHandover.ReportToKey] = "https://memex.systemorph.com", - // No Hosting:ControlInbox:Secret — the record does not map it. - ["WebhookInbox:Targets:0"] = SelfUpdateHandover.InboxTarget, - ["WebhookInbox:Targets:0:SecretConfigKey"] = SelfUpdateHandover.LocalSecretKey, - [SelfUpdateHandover.LocalSecretKey] = "fleet-secret", - }).Build(); - - var settings = SelfUpdateHandover.ReadSettings(config); - - settings.Url.Should().Be(InboxUrl, "Hosting:ReportTo names the control instance"); - settings.SecretPresent.Should().BeFalse(); - settings.LocalTargetListed.Should().BeTrue("build owns the fleet's build queue and drains this target itself"); - settings.LocalSecretPresent.Should().BeTrue(); - - SelfUpdateHandover.RouteFor(settings).Should().Be(Route.None, - "an instance that names a control instance is a CONSUMER — its own inbox is not a control plane, " - + "and the event it stores there is deleted by its own watcher"); - SelfUpdateHandover.ApplyModeFor(false, false, SelfUpdateHandover.RouteFor(settings)) - .Should().Be(SelfUpdateApply.DetectOnly, - "the boot line must read detect-only naming a key, never control-lane naming this instance's own inbox"); - SelfUpdateHandover.Missing(settings) - .Should().Contain(SelfUpdateHandover.SecretKey).And.Contain(SelfUpdateHandover.ReportToKey) - .And.NotContain(SelfUpdateHandover.InboxTarget, - "the absent signing secret is what stands between this install and the control lane, " - + "not anything about the target it drains for the fleet"); - } - - /// The missing sentence names the KEY that was actually read — never a value, and never - /// a key the operator did not use: a URL derived from ReportTo is blamed on ReportTo, a listed - /// local target on its own declared secret key. Null once a route exists. - [Fact] - public void WhatIsMissing_NamesTheKeyThatWasRead() - { - SelfUpdateHandover.Missing(new(Deployment, InboxUrl, true, false, false, null)).Should().BeNull(); - SelfUpdateHandover.Missing(new(Deployment, null, false, false, false, null)) - .Should().Contain(SelfUpdateHandover.UrlKey).And.Contain(SelfUpdateHandover.ReportToKey); - SelfUpdateHandover.Missing(new(Deployment, InboxUrl, false, false, false, null) { UrlFrom = SelfUpdateHandover.UrlSource.Declared }) - .Should().Contain(SelfUpdateHandover.UrlKey).And.Contain(SelfUpdateHandover.SecretKey) - .And.NotContain(InboxUrl.Substring(8, 5), "no value is repeated, only keys"); - SelfUpdateHandover.Missing(new(Deployment, InboxUrl, false, false, false, null) { UrlFrom = SelfUpdateHandover.UrlSource.Derived }) - .Should().Contain(SelfUpdateHandover.ReportToKey).And.Contain(SelfUpdateHandover.SecretKey) - .And.NotContain(SelfUpdateHandover.UrlKey, "the operator never set the declared URL key — blaming it sends them to the wrong line"); - SelfUpdateHandover.Missing(new(Deployment, null, false, true, false, null) { LocalSecretKey = "Hosting:PlatformWebhookSecret" }) - .Should().Contain("Hosting:PlatformWebhookSecret").And.Contain(SelfUpdateHandover.InboxTarget) - .And.NotContain(SelfUpdateHandover.UrlKey, "a listed local target is the control instance's shape; the URL keys are not what is missing"); - SelfUpdateHandover.Missing(new(Deployment, null, false, true, false, null)) - .Should().Contain(WebhookInbox.SecretConfigKeyName).And.Contain("unverified", - "a listed target that declares no secret key would store the event unverified — that is the defect to name"); - } - - // ── the URL ───────────────────────────────────────────────────────────── - - [Fact] - public void TheInboxUrl_IsDeclared_OrDerivedFromTheControlInstance() - { - SelfUpdateHandover.ResolveUrl(InboxUrl, "https://other.example").Should().Be(InboxUrl, "a declared URL wins"); - SelfUpdateHandover.ResolveUrl(null, "https://memex.systemorph.com/").Should().Be(InboxUrl, - "the control instance the inventory report goes to serves the same inbox"); - SelfUpdateHandover.ResolveUrl("", "").Should().BeNull(); - } - - /// 🚨 A value carrying userinfo, or that is not http(s), declares no inbox rather than half of one. - [Fact] - public void AUrlThatCannotBeReadWhole_DeclaresNoInbox() - { - SelfUpdateHandover.ResolveUrl("https://memex.systemorph.com@evil.example/api/hooks/x", null).Should().BeNull(); - SelfUpdateHandover.ResolveUrl(null, "memex.systemorph.com").Should().BeNull("a bare host is not an absolute URL here"); - SelfUpdateHandover.ResolveUrl("mailto:ops@example.test", null).Should().BeNull(); - } - - // ── the event ─────────────────────────────────────────────────────────── - - [Fact] - public void TheBody_IsTheInboxContract_EventFirst_CamelCase_NullsOmitted() - { - var body = SelfUpdateHandover.Body(new SelfUpdateHandover.Announcement - { - Deployment = Deployment, - CurrentVersion = "3.0.0-ci.8411", - NewVersion = "3.0.0-ci.8460", - CurrentImage = "cr.meshweaver.cloud/memex-portal-ai:3.0.0-ci.8411", - NewImage = "cr.meshweaver.cloud/memex-portal-ai:3.0.0-ci.8460", - Policy = "Continuous", - Pattern = "3.0.0-ci*", - Trigger = "SafetyNet", - DetectedAt = "2026-09-14T08:00:00Z", - }); - - body.Should().StartWith("{\"event\":\"self-update-available\"", "the router keys on the first field"); - using var doc = JsonDocument.Parse(body); - var root = doc.RootElement; - root.GetProperty("deployment").GetString().Should().Be(Deployment); - root.GetProperty("newImage").GetString().Should().EndWith(":3.0.0-ci.8460"); - root.GetProperty("policy").GetString().Should().Be("Continuous"); - root.GetProperty("reporter").GetString().Should().Be(SelfUpdateHandover.Reporter); - root.TryGetProperty("reason", out _).Should().BeFalse("a release announcement carries no restart reason"); - root.TryGetProperty("instance", out _).Should().BeFalse("nulls are omitted, not written"); - } - - [Fact] - public void ARestartAnnouncement_NamesNoNewImage() - { - var body = SelfUpdateHandover.Body(new SelfUpdateHandover.Announcement - { - Event = SelfUpdateHandover.RestartEvent, - Deployment = Deployment, - CurrentVersion = "3.0.0-ci.8411", - CurrentImage = "cr.meshweaver.cloud/memex-portal-ai:3.0.0-ci.8411", - Reason = "a landed module generation is pending activation", - DetectedAt = "2026-09-14T08:00:00Z", - }); - using var doc = JsonDocument.Parse(body); - doc.RootElement.GetProperty("event").GetString().Should().Be("self-update-restart-pending"); - doc.RootElement.TryGetProperty("newImage", out _).Should().BeFalse(); - doc.RootElement.GetProperty("reason").GetString().Should().Contain("pending activation"); - } - - /// The signature is the inventory report's — GitHub's shape — and verifies with the inbox's own verifier. - [Fact] - public void TheSignature_IsGitHubsShape_AndTheInboxVerifiesIt() - { - SelfUpdateHandover.Sign("The quick brown fox jumps over the lazy dog", "key") - .Should().Be("sha256=f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"); - var body = SelfUpdateHandover.Body(new SelfUpdateHandover.Announcement { Deployment = Deployment }); - WebhookInbox.VerifyHmacSha256(SelfUpdateHandover.Sign(body, "s3cret"), body, "s3cret").Should().BeTrue(); - WebhookInbox.VerifyHmacSha256(SelfUpdateHandover.Sign(body, "s3cret"), body, "other").Should().BeFalse(); - } - - // ── reading the settings ─────────────────────────────────────────────── - - /// Settings carry PRESENCE of a secret, never the secret — a loggable record. - [Fact] - public void TheSettings_CarryPresenceOfTheSecret_NeverItsValue() - { - const string secret = "mwi_never_printed"; - var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [SelfUpdateHandover.DeploymentKey] = Deployment, - [SelfUpdateHandover.ReportToKey] = "https://memex.systemorph.com", - [SelfUpdateHandover.SecretKey] = secret, - ["PluginCatalog:HomeUrl"] = "https://build.meshweaver.cloud", - }).Build(); - - var settings = SelfUpdateHandover.ReadSettings(config); - - settings.Should().Be(new SelfUpdateHandover.Settings( - Deployment, InboxUrl, SecretPresent: true, LocalTargetListed: false, LocalSecretPresent: false, - "https://build.meshweaver.cloud") { UrlFrom = SelfUpdateHandover.UrlSource.Derived }); - settings.ToString().Should().NotContain(secret, "the record is what a log line would print"); - SelfUpdateHandover.RouteFor(settings).Should().Be(Route.Post); - } - - /// The control instance: the target listed under WebhookInbox:Targets with its own secret key present. - [Fact] - public void TheControlInstance_ReadsAsLocal() - { - var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [SelfUpdateHandover.DeploymentKey] = "memex", - ["WebhookInbox:Targets:0"] = SelfUpdateHandover.InboxTarget, - ["WebhookInbox:Targets:0:SecretConfigKey"] = SelfUpdateHandover.LocalSecretKey, - [SelfUpdateHandover.LocalSecretKey] = "fleet-secret", - }).Build(); - - var settings = SelfUpdateHandover.ReadSettings(config); - - settings.LocalTargetListed.Should().BeTrue(); - settings.LocalSecretKey.Should().Be(SelfUpdateHandover.LocalSecretKey, "the key the target DECLARES, read back for the diagnostic"); - settings.LocalSecretPresent.Should().BeTrue(); - settings.Url.Should().BeNull(); - SelfUpdateHandover.RouteFor(settings).Should().Be(Route.Local); - } - - /// 🚨 A listed target that declares NO SecretConfigKey is an unsigned target by the inbox's - /// contract (#3312) — never "the default key". The local route must not be selected for it, or the - /// event is stored with its signature never checked. - [Fact] - public void ABareLocalTarget_IsNotALocalRoute() - { - var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [SelfUpdateHandover.DeploymentKey] = "memex", - ["WebhookInbox:Targets:0"] = SelfUpdateHandover.InboxTarget, - [SelfUpdateHandover.LocalSecretKey] = "fleet-secret", - }).Build(); - - var settings = SelfUpdateHandover.ReadSettings(config); - - settings.LocalTargetListed.Should().BeTrue(); - settings.LocalSecretKey.Should().BeNull(); - settings.LocalSecretPresent.Should().BeFalse("a secret that happens to exist under the fleet's usual name is not one the target declares"); - SelfUpdateHandover.RouteFor(settings).Should().Be(Route.None); - SelfUpdateHandover.Missing(settings).Should().Contain(WebhookInbox.SecretConfigKeyName); - } - - /// A URL derived from ReportTo is recorded as derived — the diagnostic reads it. - [Fact] - public void ADerivedUrl_IsRecordedAsDerived() - { - SelfUpdateHandover.ResolveUrlAndSource(null, "https://memex.systemorph.com").Source - .Should().Be(SelfUpdateHandover.UrlSource.Derived); - SelfUpdateHandover.ResolveUrlAndSource(InboxUrl, null).Source - .Should().Be(SelfUpdateHandover.UrlSource.Declared); - SelfUpdateHandover.ResolveUrlAndSource("", "").Source - .Should().Be(SelfUpdateHandover.UrlSource.None); - } - - /// 🚨 Only an inbox answer that says the delivery was ACCEPTED and the signature VERIFIED - /// is a hand-over. "not-required" (the target declares no secret key, so nothing was checked), a - /// status other than accepted, and a body that is not the inbox contract are refusals — the sender - /// must not record a delivery the receiver may drop. - [Fact] - public void OnlyAnAcceptedVerifiedAnswerIsAHandover() - { - SelfUpdateHandover.InboxAnswerOf("{\"status\":\"accepted\",\"signature\":\"verified\"}").Should().Be(("accepted", "verified")); - SelfUpdateHandover.InboxAnswerOf("{\"status\":\"accepted\",\"signature\":\"not-required\"}").Should().Be(("accepted", "not-required")); - SelfUpdateHandover.InboxAnswerOf("{\"status\":\"rejected\",\"signature\":\"verified\"}").Should().Be(("rejected", "verified"), - "a verified signature on a delivery the inbox did not accept is not a delivery — the caller requires both halves"); - SelfUpdateHandover.InboxAnswerOf("{\"signature\":\"verified\"}").Should().Be(((string?)null, (string?)"verified")); - SelfUpdateHandover.InboxAnswerOf("").Should().Be(((string?)null, (string?)null)); - SelfUpdateHandover.InboxAnswerOf("OK").Should().Be(((string?)null, (string?)null)); - SelfUpdateHandover.InboxAnswerOf("{\"status\":\"accepted\"}").Should().Be(((string?)"accepted", (string?)null)); - } - - [Fact] - public void NothingConfigured_ReadsAsNoRoute_NamingTheRecordIdFirst() - { - var settings = SelfUpdateHandover.ReadSettings(new ConfigurationBuilder().Build()); - SelfUpdateHandover.RouteFor(settings).Should().Be(Route.None); - SelfUpdateHandover.Missing(settings).Should().Contain(SelfUpdateHandover.DeploymentKey); - } - - /// The restart hand-over and the release hand-over are pinned as outcomes a check can reach. - [Fact] - public void TheHandedOverVerdicts_AreNewerReleaseFindings_AndTakeNoRestart() - { - var handed = SelfUpdateVerdict.HandedOver("3.0.0-ci.9", InboxUrl, "accepted (200)"); - handed.FoundNewerRelease.Should().BeTrue("the dead-event-channel report must still fire on a handed-over release"); - SelfUpdateVerdict.MayRestartAfter(handed).Should().BeFalse("the Roll the control plane opens restarts the pods"); - var failed = SelfUpdateVerdict.HandoverFailed("3.0.0-ci.9", "401 Unauthorized"); - failed.FoundNewerRelease.Should().BeTrue(); - failed.Message.Should().Contain("FAILED").And.Contain("401"); - SelfUpdateVerdict.MayRestartAfter(failed).Should().BeTrue( - "a hand-over that failed handed nothing to anyone — a pending module restart is still considered, never skipped"); - var detect = SelfUpdateVerdict.DetectOnly("3.0.0-ci.9", "no control inbox: Hosting:Deployment is not set"); - detect.Outcome.Should().Be(SelfUpdateOutcome.DetectOnly); - detect.Message.Should().Contain("Hosting:Deployment"); - } - - // ── the Updates tab ──────────────────────────────────────────────────── - - private static string Echo(string key, object?[] args) => key + "[" + string.Join(",", args) + "]"; - - /// 🚨 A release handed to the control instance must not read as "update available" for - /// ever on the Updates tab: the durable HandedOver* fields render as WHERE and WHEN it went, in - /// the viewer's zone, for the tag the tab is about. - [Fact] - public void TheUpdatesTab_SaysWhereAHandedOverReleaseWent() - { - var content = new UpdatePolicyContent - { - LatestAvailableTag = "3.0.0-ci.9", - CheckedAt = new DateTimeOffset(2026, 9, 14, 8, 0, 0, TimeSpan.Zero), - HandedOverTag = "3.0.0-ci.9", - HandedOverAt = new DateTimeOffset(2026, 9, 14, 8, 1, 0, TimeSpan.Zero), - HandedOverTo = InboxUrl, - }; - - var markdown = Memex.Portal.Shared.Settings.UpdatePolicySettingsTab.StatusMarkdown(content, Echo, "Europe/Zurich"); - - markdown.Should().Contain("ui.updateLatestAvailable[3.0.0-ci.9]"); - markdown.Should().Contain($"ui.updateHandedOverLine[3.0.0-ci.9,{InboxUrl},2026-09-14 10:01]", - "the tag, the destination and the instant in the viewer's zone"); - } - - /// A hand-over of an OLDER tag is history: the current tag renders without the line. - [Fact] - public void TheUpdatesTab_DoesNotAttributeAnOldHandoverToANewTag() - { - var content = new UpdatePolicyContent - { - LatestAvailableTag = "3.0.0-ci.10", - HandedOverTag = "3.0.0-ci.9", - HandedOverAt = DateTimeOffset.UtcNow, - HandedOverTo = InboxUrl, - }; - - var markdown = Memex.Portal.Shared.Settings.UpdatePolicySettingsTab.StatusMarkdown(content, Echo); - - markdown.Should().Contain("ui.updateLatestAvailable[3.0.0-ci.10]"); - markdown.Should().NotContain("ui.updateHandedOverLine"); - } -} diff --git a/test/Memex.Portal.Shared.Test/SelfUpdateHandsOverToTheControlLaneTest.cs b/test/Memex.Portal.Shared.Test/SelfUpdateHandsOverToTheControlLaneTest.cs deleted file mode 100644 index 94d9960376..0000000000 --- a/test/Memex.Portal.Shared.Test/SelfUpdateHandsOverToTheControlLaneTest.cs +++ /dev/null @@ -1,457 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A portal that cannot patch itself hands a detected release to the control lane — and -/// patches nothing (MeshWeaver#4098, P3b of Hosting/AksOperationsViaActions). -/// -/// The chart renders SelfUpdate__CanPatch=false beside NO self-patch Role. From then -/// on the poller keeps detecting (registry, policy, gates) and replaces the Kubernetes PATCH with -/// one signed self-update-available event into the control instance's inbox, from which the -/// control plane opens the Roll. This suite pins that against a real monolith mesh with an -/// in-process inbox standing in for the network: the event arrives, it verifies with the shared -/// secret, it names the record and the image, the updater is never asked to patch, and the -/// policy node says where the release went. -/// -/// Fails on unfixed code: -/// sees a patched updater and an empty inbox. The negatives are what prove the guard guards: a -/// chart that still allows self-patch keeps patching and posts nothing; an inbox that refuses is -/// a FAILED hand-over, never a patch; no inbox at all is detect-only naming the key. -/// -/// Only the documented seams are injected — the ACR listing, the k8s patcher, the -/// availability gate, the hand-over's settings and HTTP client. The hub, the workspace, the policy -/// node, every stream.Update, the body, the signature and the whole apply decision are real. -/// -public class SelfUpdateHandsOverToTheControlLaneTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string CandidateTag = "9999.0.0-ci.1"; - private const string Deployment = "unit-fleet-instance"; - private const string InboxUrl = "https://control.example/api/hooks/Hosting/PlatformBuilds"; - private const string Secret = "unit-control-inbox-secret"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - private static string Installed => ShippedReleaseSeed.InstalledPlatformVersion; - - /// The secret the POST signs with is read off the mesh's configuration at delivery time, - /// exactly as in production; the seamed settings only say that it is PRESENT. - private static IConfiguration Environment() => - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [SelfUpdateHandover.SecretKey] = Secret, - }).Build(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddUpdatePolicyType().AddGitHubSyncTypes() - .ConfigureServices(services => services.AddSingleton(Environment())); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - // ══════════════════════════════════════════════════════════════════════════ - // The acceptance - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The acceptance criterion. The chart says "no self-patch" (the updater — the AKS - /// module still installed — would still say it can), a control inbox is configured, the - /// registry holds a newer release: ONE signed event reaches the inbox, nothing is patched, and - /// the policy node records the hand-over. - /// - [Fact(Timeout = 240_000)] - public async Task AControlLaneInstall_AnnouncesTheRelease_AndPatchesNothing() - { - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - var inbox = new FakeControlInbox(HttpStatusCode.OK); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, inbox, chartCanPatch: false, PostSettings()); - - updater.Tags.Should().BeEmpty("a portal on the control lane holds no credential that changes the cluster"); - inbox.Deliveries.Should().HaveCount(1, "one check, one announcement"); - var delivery = inbox.Deliveries[0]; - WebhookInbox.VerifyHmacSha256(delivery.Signature, delivery.Body, Secret).Should().BeTrue( - "the control inbox verifies X-Hub-Signature-256 over the raw body with the shared secret"); - using var doc = JsonDocument.Parse(delivery.Body); - var root = doc.RootElement; - root.GetProperty("event").GetString().Should().Be(SelfUpdateHandover.ReleaseEvent); - root.GetProperty("deployment").GetString().Should().Be(Deployment, "the control plane routes by the record id"); - root.GetProperty("newVersion").GetString().Should().Be(CandidateTag); - root.GetProperty("newImage").GetString().Should().EndWith($"/memex-portal-ai:{CandidateTag}"); - root.GetProperty("currentVersion").GetString().Should().Be(Installed); - root.GetProperty("policy").GetString().Should().Be(nameof(UpdatePolicyKind.Continuous)); - root.GetProperty("trigger").GetString().Should().Be(nameof(SelfUpdateTrigger.Startup)); - - content.LastCheckVerdict.Should().Contain("handed to the control lane").And.Contain(InboxUrl); - // The verdict states the rule the control plane actually applies to a ROUTED roll - // (Plugins ActionsExecutor.AdmittedUnattended): unattended under a Continuous record whose - // pattern admits the tag. It must never tell an operator that a PIN restores unattended — - // records do not pin an image (policy platform-backwards-compatibility). - content.LastCheckVerdict.Should().Contain("runs UNATTENDED when the deployment record's update policy is Continuous") - .And.NotContain("pinned tag", "no record pins an image; the old wording described a rule no longer in force"); - content.HandedOverTag.Should().Be(CandidateTag, "the Updates tab must say where the release went"); - content.HandedOverTo.Should().Be(InboxUrl); - content.HandedOverAt.Should().NotBeNull(); - } - - // ══════════════════════════════════════════════════════════════════════════ - // The negatives — what proves the guard guards - // ══════════════════════════════════════════════════════════════════════════ - - /// The control: a chart that still renders the Role (canPatch true) keeps the in-pod - /// roll and announces nothing — the transition is the CHART's, namespace by namespace. - [Fact(Timeout = 240_000)] - public async Task AChartThatStillAllowsSelfPatch_Patches_AndAnnouncesNothing() - { - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - var inbox = new FakeControlInbox(HttpStatusCode.OK); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, inbox, chartCanPatch: true, PostSettings()); - - updater.Tags.Should().Equal(new[] { CandidateTag }, "self-patch is still the apply while the chart allows it"); - inbox.Deliveries.Should().BeEmpty("a self-patching install must not also open a Roll — two writers"); - content.LastCheckVerdict.Should().Contain("applied update"); - content.HandedOverTag.Should().BeNull(); - } - - /// 🚨 An inbox that refuses (the pairing drifted) is a FAILED hand-over that says so — - /// the release is known, nothing is patched, and the next check announces again. - [Fact(Timeout = 240_000)] - public async Task AnInboxThatRefuses_IsAFailedHandover_NeverAPatch() - { - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - var inbox = new FakeControlInbox(HttpStatusCode.Unauthorized); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, inbox, chartCanPatch: false, PostSettings()); - - updater.Tags.Should().BeEmpty("a refused hand-over must not fall back to a self-patch"); - inbox.Deliveries.Should().HaveCount(1); - content.LastCheckVerdict.Should().Contain("hand-over to the control lane FAILED") - .And.Contain("401") - .And.Contain(SelfUpdateHandover.SecretKey, "a 401 names the pairing to check, never a value"); - content.HandedOverTag.Should().BeNull("a refused announcement is not a hand-over"); - } - - /// 🚨 An inbox that ACCEPTS but did not verify the signature ("not-required": the control - /// instance declares no secret key for the target) is a failed hand-over too — the receiver may - /// still drop the event, and a recorded success would be a delivery nobody checked (#3312). - [Fact(Timeout = 240_000)] - public async Task AnInboxThatAcceptsWithoutVerifying_IsAFailedHandover() - { - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - var inbox = new FakeControlInbox(HttpStatusCode.OK, "{\"status\":\"accepted\",\"signature\":\"not-required\"}"); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, inbox, chartCanPatch: false, PostSettings()); - - updater.Tags.Should().BeEmpty(); - inbox.Deliveries.Should().HaveCount(1); - content.LastCheckVerdict.Should().Contain("hand-over to the control lane FAILED") - .And.Contain("not-required") - .And.Contain(WebhookInbox.SecretConfigKeyName); - content.HandedOverTag.Should().BeNull("an unverified delivery is not a hand-over"); - } - - /// 🚨 A verified signature on a delivery the inbox did NOT accept is not a delivery: both - /// halves of the answer are required before a hand-over is recorded. - [Fact(Timeout = 240_000)] - public async Task AnInboxThatVerifiesButDoesNotAccept_IsAFailedHandover() - { - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - var inbox = new FakeControlInbox(HttpStatusCode.OK, "{\"status\":\"rejected\",\"signature\":\"verified\"}"); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, inbox, chartCanPatch: false, PostSettings()); - - updater.Tags.Should().BeEmpty(); - content.LastCheckVerdict.Should().Contain("hand-over to the control lane FAILED").And.Contain("rejected"); - content.HandedOverTag.Should().BeNull(); - } - - /// No control inbox and no self-patch: detect-only, and the verdict names the KEY that - /// would make this a control-lane install — the state build sat in on 2026-09-12 with nothing - /// saying why. - [Fact(Timeout = 240_000)] - public async Task NoControlInbox_IsDetectOnly_NamingTheMissingKey() - { - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - var inbox = new FakeControlInbox(HttpStatusCode.OK); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, inbox, chartCanPatch: false, - new SelfUpdateHandover.Settings(Deployment, null, false, false, false, null)); - - updater.Tags.Should().BeEmpty(); - inbox.Deliveries.Should().BeEmpty(); - content.LastCheckVerdict.Should().Contain("detect-and-notify") - .And.Contain(SelfUpdateHandover.UrlKey); - } - - /// A landed module generation pending activation on a control-lane install is handed - /// over as a RESTART — the unattended class on the control lane — and the pods are not rolled - /// from inside the pod. - [Fact(Timeout = 240_000)] - public async Task APendingRestartOnAControlLaneInstall_IsHandedOverAsARestart() - { - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - using var root = ModuleRoot.WithPendingRestart(); - var inbox = new FakeControlInbox(HttpStatusCode.OK); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, inbox, chartCanPatch: false, PostSettings(), - registry: ["1.9.0-ci.0", Installed.Split('+')[0]], landing: root.Landing); - - updater.Restarts.Should().Be(0, "this install does not roll its own pods"); - updater.Tags.Should().BeEmpty(); - inbox.Deliveries.Should().HaveCount(1); - using var doc = JsonDocument.Parse(inbox.Deliveries[0].Body); - doc.RootElement.GetProperty("event").GetString().Should().Be(SelfUpdateHandover.RestartEvent); - doc.RootElement.GetProperty("deployment").GetString().Should().Be(Deployment); - content.LastCheckVerdict.Should().Contain("no newer release").And.Contain("handed to the control lane"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Harness - // ══════════════════════════════════════════════════════════════════════════ - - private static SelfUpdateHandover.Settings PostSettings() => - new(Deployment, InboxUrl, SecretPresent: true, LocalTargetListed: false, LocalSecretPresent: false, - "https://unit.example"); - - /// The network, substituted: records every delivery and answers one status. - private sealed class FakeControlInbox(HttpStatusCode status, string? answer = null) : HttpMessageHandler - { - private ImmutableList deliveries = ImmutableList.Empty; - - public ImmutableList Deliveries => deliveries; - - public sealed record Delivery(string Url, string Body, string? Signature); - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - var body = await request.Content!.ReadAsStringAsync(ct); - request.Headers.TryGetValues(WebhookInbox.SignatureHeader, out var signatures); - ImmutableInterlocked.Update(ref deliveries, current => current.Add( - new Delivery(request.RequestUri!.ToString(), body, signatures?.FirstOrDefault()))); - return new HttpResponseMessage(status) - { - Content = new StringContent(answer ?? (status == HttpStatusCode.OK - ? "{\"status\":\"accepted\",\"signature\":\"verified\"}" - : "")), - }; - } - } - - /// The hand-over with its two seams supplied: the settings (so no second mesh is stood - /// up for a one-key difference) and the HTTP client (the fake inbox). The secret the POST signs - /// with is read from the mesh's configuration, exactly as in production. - private sealed class SeamedHandover(IMessageHub hub, SelfUpdateHandover.Settings settings, HttpMessageHandler inbox) - : SelfUpdateHandover(hub, null, new HttpClient(inbox)) - { - public override Settings ReadSettings() => settings; - } - - private sealed class FakeAcrTagLister(IReadOnlyList tags) : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult(tags); - } - - /// The k8s seam — an updater that CAN patch (the AKS module is still installed), so - /// that only the chart's declaration decides. - private sealed class RecordingUpdater : IDeploymentUpdater - { - private ImmutableList tags = ImmutableList.Empty; - private int restarts; - - public ImmutableList Tags => tags; - public int Restarts => Volatile.Read(ref restarts); - public bool CanPatch => true; - - public Task LastRolledAtAsync(CancellationToken ct) => Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) - { - ImmutableInterlocked.Update(ref tags, current => current.Add(versionTag)); - return Task.CompletedTask; - } - - public Task RestartAsync(CancellationToken ct) - { - Interlocked.Increment(ref restarts); - return Task.FromResult(true); - } - } - - private sealed class AlwaysAvailable(IMessageHub hub, IConfiguration configuration) - : ReleaseAvailabilityService(hub, configuration) - { - public override IObservable IsUpdatable(string? targetVersion) => - Observable.Return(UpdatabilityVerdict.NotEnforced("this test host consumes no CI bakes")); - } - - /// A module root in a temp directory carrying the REAL pending-restart marker. - private sealed class ModuleRoot : IDisposable - { - private readonly string directory; - - public ModuleLandingService Landing { get; } - - private ModuleRoot() - { - directory = Path.Combine(Path.GetTempPath(), "mw-4098-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - ModuleActivationSidecar.SetPendingRestart(directory, true); - Landing = new ModuleLandingService(logger: null, baseDirectory: directory); - } - - public static ModuleRoot WithPendingRestart() => new(); - - public void Dispose() - { - Landing.Dispose(); - try - { - Directory.Delete(directory, recursive: true); - } - catch (IOException) - { - // A temp directory that outlives the test is harmless. - } - } - } - - private sealed class SeamedSelfUpdateService( - IMessageHub hub, - IAcrTagLister acr, - IDeploymentUpdater updater, - SelfUpdateOptions options, - ILogger? logger, - ReleaseAvailabilityService gate, - SelfUpdateHandover handover, - IConfiguration configuration, - ModuleLandingService? landing) - : SelfUpdateHostedService(hub, acr, updater, options, logger) - { - public IObservable Evaluations => ChecksReported; - - protected override ReleaseAvailabilityService? ResolveAvailabilityGate() => gate; - - protected override ComboVerificationGate? ResolveComboGate() => null; - - protected override SelfUpdateHandover ResolveHandover() => handover; - - protected override IConfiguration? ResolveConfiguration() => configuration; - - protected override ModuleLandingService? ResolveLandingService() => landing; - } - - private static SelfUpdateOptions FastPoll(bool chartCanPatch) => new() - { - RetryInterval = TimeSpan.FromMilliseconds(500), - EventCoalesceWindow = TimeSpan.FromMilliseconds(50), - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - CanPatch = chartCanPatch, - Registry = "cr.example", - }; - - private async Task RunOneCheck( - IDeploymentUpdater updater, - FakeControlInbox inbox, - bool chartCanPatch, - SelfUpdateHandover.Settings settings, - IReadOnlyList? registry = null, - ModuleLandingService? landing = null) - { - var ct = TestContext.Current.CancellationToken; - var configuration = Mesh.ServiceProvider.GetRequiredService(); - var handover = new SeamedHandover(Mesh, settings, inbox); - var service = new SeamedSelfUpdateService( - Mesh, new FakeAcrTagLister(registry ?? [CandidateTag]), updater, FastPoll(chartCanPatch), - Mesh.ServiceProvider.GetService>(), - new AlwaysAvailable(Mesh, new ConfigurationBuilder().Build()), - handover, configuration, landing); - - await service.StartAsync(CancellationToken.None); - try - { - await service.Evaluations.FirstAsync().Timeout(Budget).Await(ct); - return await WaitForContent(c => c.LastCheckVerdict is not null); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - } - - private Task Seed(UpdatePolicyKind policy, CancellationToken cancellationToken) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent - { - Policy = policy, - Pattern = policy == UpdatePolicyKind.Continuous ? "*-ci*" : null, - }, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } - - private Task WaitForContent(Func predicate) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace() - .GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .Subscribe(observer); - }) - .Where(predicate) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/SelfUpdateMigratesBeforeItRollsTest.cs b/test/Memex.Portal.Shared.Test/SelfUpdateMigratesBeforeItRollsTest.cs deleted file mode 100644 index 380133de45..0000000000 --- a/test/Memex.Portal.Shared.Test/SelfUpdateMigratesBeforeItRollsTest.cs +++ /dev/null @@ -1,183 +0,0 @@ -using System; -using System.IO; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The schema moves BEFORE the image, and a migration that did not land REFUSES the roll. -/// -/// The database migration is a run-once Job named by helm release revision, so only -/// helm upgrade could ever mint one; the self-updater rolls the portal with a -/// strategic-merge PATCH and never could. The first automatic roll across a db_version -/// boundary (MeshWeaver.Plugins #1216, V55) therefore put both AKS portals on a build whose pods -/// refused to start — DbVersionGate, correctly — behind an old ReplicaSet that still -/// answered HTTP 200. memex sat there for seven hours; memex-cloud sat there while its old pods ran -/// the very cross-partition fan-out storm the new build fixes. Nothing reported it, because from -/// the front door nothing was wrong. -/// -/// This pins the ORDER and the REFUSAL, which is where the correctness lives: rolling the -/// image after a failed migration would only reproduce the wedge, so -/// SelfUpdateHostedService must call RunMigrationAsync first and return a -/// MigrationFailed verdict — without patching — on Failed/TimedOut. -/// -/// 🚨 And on Forbidden (#4764). That outcome used to patch "loudly", which is -/// how a roll across a schema bump still reached the unrecoverable state: measured on memex-cloud -/// 2026-09-19, CrashLoopBackOff on DbVersionGate at 3.3 s while four old pods kept -/// answering 200, nothing converging, nothing rolling back, and the record still saying the roll was -/// made. It refuses now, because a 403 establishes nothing about the schema and the helm -/// upgrade that grants the missing batch/jobs permission runs the migration Job itself — -/// so the refusal asks nothing of an operator that was not already owed. -/// -/// NotSupported — the ONE outcome that still rolls — must NOT refuse: an install whose -/// updater has no migration mechanism at all would freeze for ever, and silently, which is the worse -/// failure shape (#2553). It is instead RECORDED, as a roll qualified UNMIGRATED, so the -/// policy node distinguishes a roll whose schema moved from one that rolled blind. -/// -/// Read as text rather than driven through a fake updater because the subject IS the source -/// order — a fake proves the call happened, not that it happened first. The KUBERNETES half (the -/// migration is a Job this updater MINTS, never a Deployment it patches) is pinned in -/// MeshWeaver.Plugins by SelfUpdatePatchesOnlyPatchableWorkloadsGuard. -/// -/// Fails on unfixed code: RunMigrationAsync is never called, and -/// MigrationFailed does not exist. -/// -public class SelfUpdateMigratesBeforeItRollsTest -{ - private static string ReadPollerSource() => - ReadSource("memex", "Memex.Portal.Shared", "SelfUpdate", "SelfUpdateHostedService.cs"); - - /// One repository-relative source file, or a SKIP when the tree is not reachable — - /// a source guard runs in-repo only and must not silently pass outside one. - private static string ReadSource(params string[] parts) - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - Assert.SkipWhen(dir is null, "repository tree not reachable — source guard runs in-repo only"); - var path = Path.Combine([dir!.FullName, .. parts]); - Assert.True(File.Exists(path), $"expected the guarded source at {path}"); - return File.ReadAllText(path); - } - - [Fact] - public void TheMigrationRunsBeforeThePortalImageIsPatched() - { - var source = ReadPollerSource(); - - var migrate = source.IndexOf("_updater.RunMigrationAsync(", StringComparison.Ordinal); - var patch = source.IndexOf("_updater.PatchToVersionAsync(", StringComparison.Ordinal); - - Assert.True(migrate > -1, - "the poller never runs the database migration. A self-update that moves the image but " - + "not the schema leaves the new pods refusing to start on DbVersionGate behind a portal " - + "that still answers 200 — memex and memex-cloud, 2026-09-03, seven hours. " - + "See Doc/Architecture/DatabaseMigrationProcedure."); - Assert.True(patch > -1, "the poller no longer patches the portal image — this guard's subject moved."); - Assert.True(migrate < patch, - "the migration must be run BEFORE the portal image is patched. Patching first and " - + "migrating after is exactly the ordering that produced the wedge, and it cannot be " - + "recovered from in-process: the pod that would finish the work restarts into the gate."); - } - - [Fact] - public void AMigrationThatDidNotLandRefusesTheRoll() - { - var source = ReadPollerSource(); - - Assert.Contains("SelfUpdateVerdict.MigrationFailed(", source); - foreach (var terminal in new[] { "MigrationRunOutcome.Failed", "MigrationRunOutcome.TimedOut" }) - Assert.True(source.Contains(terminal, StringComparison.Ordinal), - $"{terminal} must be handled explicitly: the schema demonstrably did not move, so the " - + "image must not either."); - - Assert.True(source.Contains("MigrationRunOutcome.Forbidden", StringComparison.Ordinal), - "MigrationRunOutcome.Forbidden must be handled explicitly."); - Assert.True(source.Contains("MigrationRunOutcome.NotSupported", StringComparison.Ordinal), - "MigrationRunOutcome.NotSupported must be handled explicitly, and must NOT refuse the " - + "roll: freezing every install whose updater can never migrate — silently — is the " - + "worse failure shape (#2553)."); - } - - /// - /// 🚨 A 403 on the migration Job REFUSES the roll, and a roll taken without a migration says - /// so on the record (#4764). The behaviour is driven end-to-end against a real mesh by - /// SelfUpdateSchemaBumpRefusalTest; what is pinned HERE is the source order that no fake - /// can prove — that the Forbidden branch RETURNS before reaching - /// PatchToVersionAsync, rather than falling through to it as it did until this change. - /// - /// Fails on unfixed code: the Forbidden branch breaks into the patch - /// and MigrationUnavailable does not exist. - /// - [Fact] - public void A403OnTheMigrationJob_ReturnsBeforeThePatch() - { - var source = ReadPollerSource(); - - var forbidden = source.IndexOf("case MigrationRunOutcome.Forbidden:", StringComparison.Ordinal); - var notSupported = source.IndexOf("case MigrationRunOutcome.NotSupported:", StringComparison.Ordinal); - var patch = source.IndexOf("_updater.PatchToVersionAsync(", StringComparison.Ordinal); - Assert.True(forbidden > -1 && notSupported > -1 && patch > -1, - "this guard's subject moved — the poller no longer switches on the migration outcome " - + "before patching."); - Assert.True(forbidden < notSupported && notSupported < patch, - "the two 'could not even try' branches must precede the patch — a branch after it " - + "cannot stop it."); - - var refusal = source.IndexOf( - "SelfUpdateVerdict.MigrationUnavailable(", forbidden, StringComparison.Ordinal); - Assert.True(refusal > -1 && refusal < notSupported, - "the Forbidden branch must RETURN a MigrationUnavailable verdict, inside its own case, " - + "before the NotSupported branch: the cluster refused the Job, so nothing established " - + "that the schema is where the target needs it — and patching anyway is the " - + "DbVersionGate crash-loop behind a 200 measured on memex-cloud 2026-09-19 (#4764). " - + "The helm upgrade that grants batch/jobs runs the migration itself, so refusing asks " - + "for nothing an operator did not already owe."); - - Assert.True(source.IndexOf(".Unmigrated(", notSupported, StringComparison.Ordinal) > -1, - "the roll that IS still taken (NotSupported) must be recorded as UNMIGRATED. #4764's " - + "second ask: lastCheckVerdict reported the patch as done while the crash-loop lived " - + "only on the pod, so a migrated roll and a blind one were the same recorded sentence."); - } - - /// - /// 🚨 EVERY route that patches migrates first — the Updates tab's manual Apply included. - /// - /// The poller was fixed and the BUTTON was not: UpdatePolicySettingsTab honoured the - /// release-availability gate, the combo gate and the control-lane route, then called - /// PatchToVersionAsync directly. So an admin click still made the image-only roll across a - /// db_version boundary — and worse than the poller's old blind branch, because a click - /// leaves no verdict anywhere to inspect afterwards. Found by the automatic review on the PR that - /// fixed the poller, which is precisely the failure mode a per-route switch statement has. - /// - /// Pinned as SOURCE ORDER for the same reason as the poller's: a fake proves the call - /// happened, never that it happened FIRST. The shared decision itself - /// (SelfUpdateVerdict.MayPatchAfter) is pinned behaviourally by - /// SelfUpdateSchemaBumpRefusalTest. - /// - /// Fails on unfixed code: the tab never calls RunMigrationAsync. - /// - [Fact] - public void TheManualApplyMigratesBeforeItPatches_AndReadsTheSharedDecision() - { - var source = ReadSource("memex", "Memex.Portal.Shared", "Settings", "UpdatePolicySettingsTab.cs"); - - var migrate = source.IndexOf("RunMigrationAsync(", StringComparison.Ordinal); - var patch = source.IndexOf("PatchToVersionAsync(", StringComparison.Ordinal); - - Assert.True(migrate > -1, - "the Updates tab's manual Apply never runs the database migration. A gate only the " - + "unattended path respects is not a gate — and this button is exactly the moment an " - + "operator, seeing an update that never applied, would force the roll (#4764)."); - Assert.True(patch > -1, "the tab no longer patches the image — this guard's subject moved."); - Assert.True(migrate < patch, - "the migration must be run BEFORE the image is patched here too. Patching first cannot " - + "be recovered from in-process: the pod that would finish the work restarts into " - + "DbVersionGate."); - - var decision = source.IndexOf("SelfUpdateVerdict.MayPatchAfter(", StringComparison.Ordinal); - Assert.True(decision > -1 && decision < patch, - "and it must read the SHARED decision before patching, not re-enumerate the outcomes: " - + "two copies of that rule drift, which is exactly how this route ended up with none."); - } -} diff --git a/test/Memex.Portal.Shared.Test/SelfUpdatePendingRestartTest.cs b/test/Memex.Portal.Shared.Test/SelfUpdatePendingRestartTest.cs deleted file mode 100644 index 74e518f790..0000000000 --- a/test/Memex.Portal.Shared.Test/SelfUpdatePendingRestartTest.cs +++ /dev/null @@ -1,337 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A module swap is a restart, and the restart HAPPENS (#3650) — the poller half, against a -/// real monolith mesh. -/// -/// A landed module generation loads only at a restart (restart-as-activation). Until this -/// change the restart was whatever platform roll came next: a module could ship, land, raise -/// PendingRestart and sit unloaded for days behind a fleet with nothing newer to roll to — -/// which is the opposite of the maintainer's rule that a module version that ships is in use. The -/// self-updater now treats a pending restart like a roll of the image it runs: after the platform -/// half of every check, when that half patched nothing, it reads the activation record and — paced -/// by the same MinRollInterval floor as any roll — asks the deployment updater to restart the -/// workloads on the same image. -/// -/// Fails on unfixed code: sees an -/// updater that was never asked to restart and a verdict reading only "no newer release". -/// -/// Only the documented IO seams are faked — the ACR listing, the k8s patcher, and the module -/// root the landing service reads (a temp directory carrying the real marker file). The hub, the -/// workspace, the policy node, every stream.Update and the whole decision path are real. -/// -public class SelfUpdatePendingRestartTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private static TimeSpan Budget => TestTimeouts.Convergence; - - private static string Installed => ShippedReleaseSeed.InstalledPlatformVersion; - - private static string InstalledTag => Installed.Split('+')[0]; - - /// Two releases behind this install plus the tag it runs — a registry with nothing - /// newer, in which the installed image still resolves. The premise is asserted per scenario. - private static string[] NothingNewer => ["1.9.0-ci.0", "2.0.0-ci.0", InstalledTag]; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddUpdatePolicyType().AddGitHubSyncTypes(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - // ══════════════════════════════════════════════════════════════════════════ - // The restart, and the rules it obeys - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The acceptance criterion. Nothing newer is in the registry, the module activation - /// record says a restart is pending, and the install never rolled: the check restarts the - /// workloads on the image they run, patches NO image, and says so on the policy node. - /// - [Fact(Timeout = 240_000)] - public async Task APendingRestart_RollsTheRunningImage() - { - AssertBehindInstalled(NothingNewer.Take(2)); - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - using var root = ModuleRoot.WithPendingRestart(); - var updater = new RecordingUpdater { LastRolledAt = null }; - - var content = await RunOneCheck(updater, root.Landing, NothingNewer); - - updater.Restarts.Should().Be(1, - "a landed module generation loads only at a restart, and the restart must not wait for " - + "an unrelated platform roll"); - updater.Tags.Should().BeEmpty("nothing newer exists — the image stays, only the pods roll"); - content.LastCheckVerdict.Should().Contain("no newer release", - "the platform half of the check still has to say what it found"); - content.LastCheckVerdict.Should().Contain($"RESTARTED on {Installed}", - "the restart is the durable second sentence of the same verdict"); - } - - /// - /// 🚨 A restart is a roll — it drops the same live circuits — so the pacing floor applies to it - /// exactly as to a roll: an install that rolled five minutes ago, inside a one-hour floor, - /// restarts nothing now and says the next check re-decides it. - /// - [Fact(Timeout = 240_000)] - public async Task APendingRestart_InsideTheRollFloor_IsDeferred() - { - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - using var root = ModuleRoot.WithPendingRestart(); - var updater = new RecordingUpdater { LastRolledAt = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5) }; - - var content = await RunOneCheck(updater, root.Landing, NothingNewer, - options => options with { MinRollInterval = TimeSpan.FromHours(1) }); - - updater.Restarts.Should().Be(0, "the floor defers a restart exactly as it defers a roll"); - content.LastCheckVerdict.Should().Contain("deferring the restart"); - } - - /// The control: no pending restart, no restart — and the verdict is the plain - /// up-to-date sentence, untouched. - [Fact(Timeout = 240_000)] - public async Task NoPendingRestart_RestartsNothing() - { - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - using var root = ModuleRoot.Clean(); - var updater = new RecordingUpdater { LastRolledAt = null }; - - var content = await RunOneCheck(updater, root.Landing, NothingNewer); - - updater.Restarts.Should().Be(0); - content.LastCheckVerdict.Should().Contain("no newer release"); - content.LastCheckVerdict.Should().NotContain("RESTARTED"); - } - - /// - /// 🚨 An updater that predates the restart seam cannot restart on the same image (a same-tag - /// image patch rolls nothing on Kubernetes). That is a state an operator has to see: the - /// verdict names it and names the move, instead of folding it into "no newer release". - /// - [Fact(Timeout = 240_000)] - public async Task AnUpdaterWithoutTheRestartSeam_ReportsTheRestartAsUnavailable() - { - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - using var root = ModuleRoot.WithPendingRestart(); - var updater = new LegacyUpdater(); - - var content = await RunOneCheck(updater, root.Landing, NothingNewer); - - content.LastCheckVerdict.Should().Contain("cannot restart itself"); - content.LastCheckVerdict.Should().Contain("kubectl rollout restart", - "an unactionable pending restart is a module that never loads, with everything green"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Harness - // ══════════════════════════════════════════════════════════════════════════ - - private static void AssertBehindInstalled(IEnumerable tags) - { - foreach (var tag in tags) - VersionSelect.IsNewer(tag, Installed).Should().BeFalse( - $"the scenario needs {tag} to be BEHIND the running {Installed}; otherwise the check " - + "would roll forward and never reach the restart decision under test"); - } - - /// A module root in a temp directory, carrying the REAL marker the landing lane - /// raises (modules/activation.d/.pending-restart) — or not. - private sealed class ModuleRoot : IDisposable - { - private readonly string directory; - - public ModuleLandingService Landing { get; } - - private ModuleRoot(bool pendingRestart) - { - directory = Path.Combine(Path.GetTempPath(), "mw-3650-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - if (pendingRestart) - ModuleActivationSidecar.SetPendingRestart(directory, true); - Landing = new ModuleLandingService(logger: null, baseDirectory: directory); - } - - public static ModuleRoot WithPendingRestart() => new(pendingRestart: true); - - public static ModuleRoot Clean() => new(pendingRestart: false); - - public void Dispose() - { - Landing.Dispose(); - try - { - Directory.Delete(directory, recursive: true); - } - catch (IOException) - { - // A temp directory that outlives the test is harmless. - } - } - } - - private sealed class FakeAcrTagLister(IReadOnlyList tags) : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult(tags); - } - - /// The k8s seam, recording every image patch and every same-image restart. - private sealed class RecordingUpdater : IDeploymentUpdater - { - private ImmutableList tags = ImmutableList.Empty; - private int restarts; - - public ImmutableList Tags => tags; - public int Restarts => Volatile.Read(ref restarts); - public DateTimeOffset? LastRolledAt { get; init; } - public bool CanPatch => true; - - public Task LastRolledAtAsync(CancellationToken ct) => Task.FromResult(LastRolledAt); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) - { - ImmutableInterlocked.Update(ref tags, current => current.Add(versionTag)); - return Task.CompletedTask; - } - - public Task RestartAsync(CancellationToken ct) - { - Interlocked.Increment(ref restarts); - return Task.FromResult(true); - } - } - - /// An updater compiled before the seam: the interface default answers "cannot". - private sealed class LegacyUpdater : IDeploymentUpdater - { - public bool CanPatch => true; - - public Task LastRolledAtAsync(CancellationToken ct) => Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) => Task.CompletedTask; - } - - private sealed class AlwaysAvailable(IMessageHub hub, IConfiguration configuration) - : ReleaseAvailabilityService(hub, configuration) - { - public override IObservable IsUpdatable(string? targetVersion) => - Observable.Return(UpdatabilityVerdict.NotEnforced("this test host consumes no CI bakes")); - } - - /// The poller with the three seams supplied directly: the availability gate, no combo - /// gate, and the module root under test. - private sealed class SeamedSelfUpdateService( - IMessageHub hub, - IAcrTagLister acr, - IDeploymentUpdater updater, - SelfUpdateOptions options, - ILogger? logger, - ReleaseAvailabilityService gate, - ModuleLandingService landing) - : SelfUpdateHostedService(hub, acr, updater, options, logger) - { - public IObservable Evaluations => ChecksReported; - - protected override ReleaseAvailabilityService? ResolveAvailabilityGate() => gate; - - protected override ComboVerificationGate? ResolveComboGate() => null; - - protected override ModuleLandingService? ResolveLandingService() => landing; - } - - private static SelfUpdateOptions FastPoll() => new() - { - RetryInterval = TimeSpan.FromMilliseconds(500), - EventCoalesceWindow = TimeSpan.FromMilliseconds(50), - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - }; - - private async Task RunOneCheck( - IDeploymentUpdater updater, ModuleLandingService landing, IReadOnlyList registry, - Func? configure = null) - { - var ct = TestContext.Current.CancellationToken; - var options = configure?.Invoke(FastPoll()) ?? FastPoll(); - var service = new SeamedSelfUpdateService( - Mesh, new FakeAcrTagLister(registry), updater, options, - Mesh.ServiceProvider.GetService>(), - new AlwaysAvailable(Mesh, new ConfigurationBuilder().Build()), - landing); - - await service.StartAsync(CancellationToken.None); - try - { - await service.Evaluations.FirstAsync().Timeout(Budget).Await(ct); - return await WaitForContent(c => c.LastCheckVerdict is not null); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - } - - private Task Seed(UpdatePolicyKind policy, CancellationToken cancellationToken) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - // 2026-09-08: a continuous build is eligible only when a pattern admits it. - Content = new UpdatePolicyContent - { - Policy = policy, - Pattern = policy == UpdatePolicyKind.Continuous ? "*-ci*" : null, - }, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } - - private Task WaitForContent(Func predicate) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace() - .GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .Subscribe(observer); - }) - .Where(predicate) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/SelfUpdateRecordIsAuthoritativeTest.cs b/test/Memex.Portal.Shared.Test/SelfUpdateRecordIsAuthoritativeTest.cs deleted file mode 100644 index ec132237d4..0000000000 --- a/test/Memex.Portal.Shared.Test/SelfUpdateRecordIsAuthoritativeTest.cs +++ /dev/null @@ -1,277 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reactive; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The deployment record's update policy is AUTHORITATIVE when it declares one — policy -/// self-update-record-authoritative. -/// -/// The build instance's record said Continuous + 3.0.0-ci*; its -/// Admin/UpdatePolicy, created before the record's policy was rendered into configuration, -/// said Stable with no pattern — and the configuration keys were seed-only, so every check -/// answered "none newer than the installed 3.0.0-ci.9412" while 3.0.0-ci.9564 was armed -/// (Doc/Architecture/SelfUpdateFreeze, "The build instance"). -/// -/// Fails on unfixed code: -/// waits for a Continuous node that never comes. The negatives are what prove the change is -/// scoped: no declared key leaves an admin's node alone, an already-matching node is not written, -/// and the convergence touches the policy and pattern ONLY. -/// -/// Real monolith mesh throughout — the hub, the workspace, the node and every -/// stream.Update are real; only the registry listing and the k8s patcher are the platform's -/// own "unavailable" fallbacks. -/// -public class SelfUpdateRecordIsAuthoritativeTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string FleetPattern = "3.0.0-ci*"; - private const string CandidateTag = "3.0.0-ci.9564"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddUpdatePolicyType(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - private static IConfiguration Record(string? policy, string? pattern) => - new ConfigurationBuilder().AddInMemoryCollection(new Dictionary - { - [UpdatePolicyNodeType.DeclaredPolicyConfigKey] = policy, - [UpdatePolicyNodeType.DeclaredPatternConfigKey] = pattern, - }).Build(); - - // ══════════════════════════════════════════════════════════════════════════ - // (a) The acceptance — through the poller, as production runs it - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// The build instance's state: node Stable, no pattern; record Continuous + - /// 3.0.0-ci*. Starting the poller converges the node to the record. - /// - [Fact(Timeout = 240_000)] - public async Task AStaleNode_ConvergesToTheDeclaredPolicy_WhenThePollerStarts() - { - var ct = TestContext.Current.CancellationToken; - await Seed(new UpdatePolicyContent { Policy = UpdatePolicyKind.Stable }, ct); - - var converged = await WithPoller( - Record("Continuous", FleetPattern), - _ => WaitForContent(c => c.DeclaredPolicy == UpdatePolicyKind.Continuous)); - - converged.Pattern.Should().Be(FleetPattern, "the record renders the pattern together with the policy"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // (b) Nothing declared — seed-only, exactly as before - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// A non-fleet install: no SelfUpdate:DefaultPolicy key. The options still carry a - /// default (the binder's Stable, here an explicit Continuous to make the point - /// sharper) — and the admin's existing None is left exactly as it was, after a full - /// check has run. - /// - [Fact(Timeout = 240_000)] - public async Task NoDeclaredPolicy_LeavesTheExistingNodeAlone() - { - var ct = TestContext.Current.CancellationToken; - await Seed(new UpdatePolicyContent { Policy = UpdatePolicyKind.None, Pattern = "9.9.*" }, ct); - - UpdatePolicyNodeType.DeclaredByDeployment(Record(null, FleetPattern)) - .Should().BeNull("a pattern without a declared policy declares nothing"); - UpdatePolicyNodeType.DeclaredByDeployment(Record(" ", null)) - .Should().BeNull("a blank policy key is an unset one"); - - // A reported check is only reachable AFTER the seeding stage (where convergence runs) has - // completed and the live policy has been read — so it is the positive signal that the - // stage ran and chose not to write. - var after = await WithPoller( - Record(null, null), - async service => - { - await service.Evaluations.FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - return await CurrentContent(); - }); - - after.DeclaredPolicy.Should().Be(UpdatePolicyKind.None, "an admin's choice stands where no record declares one"); - after.Pattern.Should().Be("9.9.*"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // (c) Already equal — no write - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// A node that already carries the record's policy and pattern is not written — its - /// does not move. - /// - [Fact(Timeout = 240_000)] - public async Task AnAlreadyMatchingNode_IsNotWritten() - { - var ct = TestContext.Current.CancellationToken; - await Seed(new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, Pattern = FleetPattern }, ct); - var versionBefore = (await CurrentNode()).Version; - - var declared = UpdatePolicyNodeType.DeclaredByDeployment(Record("continuous", " 3.0.0-ci* ")); - declared.Should().Be(new UpdatePolicyNodeType.DeploymentDeclaration(UpdatePolicyKind.Continuous, FleetPattern), - "the policy name is read case-insensitively and the pattern normalised"); - - if (declared is not { } matching) - throw new InvalidOperationException("the declaration was asserted non-null above"); - await Converge(matching); - - (await CurrentNode()).Version.Should().Be(versionBefore, "a node that already carries the record's values is not written"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // (d) Only policy and pattern move - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// The convergence moves the policy and the pattern ONLY: RequireCiGreen and every - /// bookkeeping field (latest tag, last verdict and trigger, combo verdicts) survive it. - /// - [Fact(Timeout = 240_000)] - public async Task Convergence_PreservesEveryOtherField() - { - var ct = TestContext.Current.CancellationToken; - var verdictAt = new DateTimeOffset(2026, 9, 28, 15, 0, 0, TimeSpan.Zero); - await Seed(new UpdatePolicyContent - { - Policy = UpdatePolicyKind.Stable, - RequireCiGreen = false, - LatestAvailableTag = "3.0.0-ci.9412", - LastCheckVerdict = "no newer release", - LastCheckTrigger = "SafetyNet", - ComboVerifications = - [ - new ComboVerification - { - CandidateTag = CandidateTag, - Verdict = ComboVerdictKind.Red, - VerifiedAt = verdictAt, - }, - ], - }, ct); - - await Converge(new UpdatePolicyNodeType.DeploymentDeclaration(UpdatePolicyKind.Continuous, FleetPattern)); - - var after = await WaitForContent(c => c.DeclaredPolicy == UpdatePolicyKind.Continuous); - after.Pattern.Should().Be(FleetPattern); - after.RequireCiGreen.Should().BeFalse("the record declares policy and pattern, nothing else"); - after.LatestAvailableTag.Should().Be("3.0.0-ci.9412"); - after.LastCheckVerdict.Should().Be("no newer release"); - after.LastCheckTrigger.Should().Be("SafetyNet"); - after.ComboVerifications.Should().ContainSingle() - .Which.CandidateTag.Should().Be(CandidateTag); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Helpers - // ══════════════════════════════════════════════════════════════════════════ - - private sealed class SeamedService(IMessageHub hub, IConfiguration configuration, ILogger? logger) - : SelfUpdateHostedService( - hub, - new UnavailableUpdateMechanics.NoRegistry(logger), - new UnavailableUpdateMechanics.DetectOnly(), - new SelfUpdateOptions - { - RetryInterval = TimeSpan.FromMilliseconds(500), - EventCoalesceWindow = TimeSpan.FromMilliseconds(50), - // What the options binder would carry — the service must NOT treat it as declared. - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - }, - logger) - { - public IObservable Evaluations => ChecksReported; - - protected override IConfiguration? ResolveConfiguration() => configuration; - } - - private async Task WithPoller(IConfiguration configuration, Func> wait) - { - var service = new SeamedService(Mesh, configuration, - Mesh.ServiceProvider.GetService>()); - await service.StartAsync(CancellationToken.None); - try - { - return await wait(service); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - } - - private Task Converge(UpdatePolicyNodeType.DeploymentDeclaration declared) => - UpdatePolicyNodeType.ConvergeToDeclaration(Mesh, Access, declared) - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - private Task Seed(UpdatePolicyContent content, CancellationToken cancellationToken) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = content, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } - - private IObservable NodeStream() => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace() - .GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .SelectMany(node => node is { } present - ? Observable.Return(present) - : Observable.Empty()) - .Subscribe(observer); - }); - - private Task CurrentNode() => - NodeStream().FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - - private Task CurrentContent() => - WaitForContent(_ => true); - - private Task WaitForContent(Func predicate) => - NodeStream() - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .Where(predicate) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/SelfUpdateSchemaBumpRefusalTest.cs b/test/Memex.Portal.Shared.Test/SelfUpdateSchemaBumpRefusalTest.cs deleted file mode 100644 index bab5ea6dfd..0000000000 --- a/test/Memex.Portal.Shared.Test/SelfUpdateSchemaBumpRefusalTest.cs +++ /dev/null @@ -1,473 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Reactive; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A roll the install cannot migrate is REFUSED, and a roll it takes without a migration SAYS -/// SO on the record (#4764). -/// -/// The migration is a run-once Job: only helm upgrade ever rendered one, and the -/// self-updater patches container images with a strategic merge. So the roll and the schema move are -/// two different acts, and a roll that makes the first without the second is unrecoverable -/// in-process — DbVersionGate correctly refuses the new pods (db_version=N < expected -/// N+1, LogCritical, exit 1), Kubernetes restarts them for ever, and the previous -/// ReplicaSet keeps answering HTTP 200 the whole time. Measured on memex-cloud 2026-09-19: -/// CrashLoopBackOff with restarts=2 by 05:06Z, 3319 ms to the exit, desired 4 / ready 4 all on -/// the OLD set, updated 1, unavailable 1 — nothing converging, nothing rolling back, and the record -/// still saying the roll was made. -/// -/// What is under test is the DECISION, in both directions — the rule being that only -/// Completed proves the schema moved: -/// -/// Forbidden (the cluster refused the Job, 403) ⇒ REFUSED, nothing patched, and -/// the verdict names the one command that fixes it. It used to patch "loudly", which is the branch -/// #4764 measured. -/// Failed ⇒ REFUSED too, and with a DIFFERENT sentence: the migration ran and -/// broke, so the operator reads a Job log rather than running helm. -/// NotSupported ⇒ still ROLLS — an install whose updater can never migrate would -/// otherwise freeze for ever and silently (#2553) — but the roll is recorded UNMIGRATED, -/// naming the module to update. -/// Completed ⇒ rolls, and carries NO qualifier: a schema that provably moved must not -/// read like one that did not, or the qualifier stops meaning anything. -/// -/// -/// Driven against a real monolith mesh, with only the two documented IO seams faked (the -/// registry list and the Kubernetes updater). The hub, the workspace, the policy node, the live -/// stream and every stream.Update are real, and the refusal is asserted POSITIVELY — an -/// unpatched updater PLUS the sentence on Admin/UpdatePolicy — never as "no patch happened", -/// which would also pass against a poller that simply died. -/// -/// Fails on unfixed code: -/// reds because the Forbidden branch patches and records "applied update", and -/// reds because -/// that roll is recorded as a plain Applied with nothing to distinguish it from a migrated -/// one. -/// -public class SelfUpdateSchemaBumpRefusalTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string CandidateTag = "9999.0.0-ci.1"; - - /// 🚨 , never a literal: a hand-written 30 s is - /// both a guess about machine speed AND the framework's own write bound, so a test that waits it - /// gives up one second before the mesh can explain itself (#2819). - private static TimeSpan Budget => TestTimeouts.Convergence; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - // AddGitHubSyncTypes registers the BuildCompletion satellite the self-update watch reacts - // to — types only, the same production registration rather than a duplicate declaration. - => base.ConfigureMesh(builder).AddUpdatePolicyType().AddGitHubSyncTypes(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - // ══════════════════════════════════════════════════════════════════════════ - // The refusals - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The acceptance criterion for #4764. The cluster answered 403 to the migration Job, so - /// NOTHING was established about the schema — and the one move that cannot be undone from inside - /// the process is to patch the image anyway. The refusal must be visible where an operator looks, - /// and it must name the remedy, because a 403 means this install's chart predates the - /// batch/jobs grant and the helm upgrade that adds it runs the migration itself. - /// - [Fact(Timeout = 240_000)] - public async Task A403OnTheMigrationJob_RefusesTheRoll_AndNamesTheHelmRemedy() - { - await Seed(TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(MigrationRunOutcome.Forbidden); - - var content = await RunOneCheck(updater); - - updater.Migrations.Should().Contain(CandidateTag, - "the schema moves FIRST — a poller that patches without even asking is the 2026-09-03 " - + "wedge, and this test would otherwise pass against one"); - updater.Tags.Should().BeEmpty( - "nothing established that the schema is where this build needs it, and a build expecting " - + "a newer db_version than the database has crash-loops on DbVersionGate behind pods that " - + "keep answering 200 — the state measured on memex-cloud 2026-09-19"); - content.LastCheckVerdict.Should().Contain("REFUSED", - "a refusal that leaves no trace on the policy node is the silent freeze this whole area " - + "must never become — and a log line depends on a level a deployment may never have set"); - content.LastCheckVerdict.Should().Contain("batch/jobs", - "an unnamed refusal is unactionable: the operator has to know WHICH permission is missing"); - content.LastCheckVerdict.Should().Contain("helm upgrade", - "the remedy is the one command that both grants the permission and runs the migration"); - content.LastCheckVerdict.Should().NotContain("applied update", - "the record said the roll was made while the crash-loop lived only on the pod — that is " - + "#4764's second ask, and this is the sentence it was about"); - } - - /// - /// A migration that RAN and broke refuses too — pre-existing behaviour, pinned here beside the - /// new refusal because the two sentences must stay DIFFERENT. "Read the Job log" and "run a helm - /// upgrade" are different next moves, and one message for both would send an operator to the - /// wrong one. - /// - [Fact(Timeout = 240_000)] - public async Task AMigrationThatRanAndFailed_RefusesTheRoll_AndPointsAtTheJobLog() - { - await Seed(TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(MigrationRunOutcome.Failed); - - var content = await RunOneCheck(updater); - - updater.Tags.Should().BeEmpty("the schema demonstrably did not move, so the image must not"); - content.LastCheckVerdict.Should().Contain("REFUSED"); - content.LastCheckVerdict.Should().Contain("memex-migration-su-", - "the operator's next move is to read THAT Job's log, so the verdict has to name it"); - content.LastCheckVerdict.Should().NotContain("batch/jobs", - "this migration was created and ran — pointing at a missing permission would be a lie " - + "that costs an operator the time to disprove"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // The two rolls — one blind and recorded as such, one proven and unqualified - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The #2553 side of the rule. An updater that has no migration mechanism at all — an - /// installed MeshWeaver.SelfUpdate.Aks generation predating the seam, which is what the - /// interface default answers — must NOT be frozen: refusing every roll for ever, silently, is the - /// worse failure shape. What must change is that the roll stops being indistinguishable from one - /// whose schema moved. - /// - [Fact(Timeout = 240_000)] - public async Task AnUpdaterThatCannotMigrate_StillRolls_AndTheRecordSaysItRolledBlind() - { - await Seed(TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(MigrationRunOutcome.NotSupported); - - var content = await RunOneCheck(updater); - - updater.Tags.Should().Contain(CandidateTag, - "refusing on 'this install can NEVER migrate' would freeze it until an operator acts, " - + "silently — the worse failure shape (#2553)"); - content.LastCheckVerdict.Should().Contain("UNMIGRATED", - "an unmigrated roll that leaves no durable trace is indistinguishable from a migrated " - + "one, which is exactly what #4764 measured on the policy node"); - content.LastCheckVerdict.Should().Contain("MeshWeaver.SelfUpdate.Aks", - "the state is only actionable if the record names what to update"); - content.LastCheckVerdict.Should().Contain("DbVersionGate", - "and names what will happen if this build does expect a newer schema"); - } - - /// - /// The other side of the change: a migration that COMPLETED rolls with no qualifier at all. A - /// proven schema move reading like an unproven one would make the qualifier meaningless, which is - /// the same failure as not recording it. - /// - [Fact(Timeout = 240_000)] - public async Task AMigrationThatCompleted_RollsUnqualified() - { - await Seed(TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(MigrationRunOutcome.Completed); - - var content = await RunOneCheck(updater); - - updater.Migrations.Should().Contain(CandidateTag); - updater.Tags.Should().Contain(CandidateTag); - content.LastCheckVerdict.Should().Contain("applied update"); - content.LastCheckVerdict.Should().NotContain("UNMIGRATED", - "the migration Job for this tag succeeded — the schema IS where the build needs it, and " - + "qualifying that would train operators to ignore the qualifier"); - content.LastCheckVerdict.Should().NotContain("REFUSED"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // The published schema step (#4764 (b3)) — two numbers instead of a blanket rule - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The b3 acceptance criterion. With the target PUBLISHED to expect a newer schema than - /// the running release, an install that cannot migrate is REFUSED — naming both numbers — instead - /// of rolling blind into the DbVersionGate crash-loop measured on memex 2026-09-24/25 (a pod on - /// ci.9332 restarting 91 times on db_version=57 < expected 58). - /// - [Fact(Timeout = 240_000)] - public async Task ACannotMigrateInstall_AcrossAPublishedSchemaBump_IsRefusedNamingBothNumbers() - { - await Seed(TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(MigrationRunOutcome.NotSupported); - - var content = await RunOneCheck(updater, new ReleaseSchemaStep("3.0.0-ci.1", 57, CandidateTag, 58)); - - updater.Tags.Should().BeEmpty( - "the published markers say this roll crash-loops on DbVersionGate, and this install cannot " - + "move the schema — rolling is no longer the lesser harm"); - content.LastCheckVerdict.Should().Contain("REFUSED"); - content.LastCheckVerdict.Should().Contain("db_version 58", - "a refusal that names the two numbers says exactly what a person has to move"); - content.LastCheckVerdict.Should().Contain("expects 57"); - content.LastCheckVerdict.Should().NotContain("applied update"); - } - - /// - /// The other direction: a target PUBLISHED to keep the schema needs no migration, so neither a - /// missing mechanism nor a missing grant may block it — and the roll is not recorded as blind. - /// - [Fact(Timeout = 240_000)] - public async Task ATargetThatKeepsTheSchema_RollsUnqualified_EvenWithoutAMigration() - { - await Seed(TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(MigrationRunOutcome.Forbidden); - - var content = await RunOneCheck(updater, new ReleaseSchemaStep("3.0.0-ci.1", 58, CandidateTag, 58)); - - updater.Tags.Should().Contain(CandidateTag, - "the database already has what the target expects — the running pods passed DbVersionGate " - + "at that number — so the refused Job blocks nothing that matters"); - content.LastCheckVerdict.Should().Contain("applied update"); - content.LastCheckVerdict.Should().NotContain("UNMIGRATED"); - content.LastCheckVerdict.Should().NotContain("REFUSED"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // One decision, two routes - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The poller's behaviour IS , for every - /// outcome. - /// - /// There are two routes that patch — this poller and the Updates tab's manual Apply — and - /// the second one skipped the migration entirely until #4764, which is what a per-route switch - /// statement costs. The tab now reads the predicate; this drives the poller end-to-end over all - /// five outcomes and asserts it agrees, so an outcome ADDED to the enum cannot be handled one way - /// in one route and another in the other without a red test. - /// - /// Not a restatement of the predicate: the predicate is the tab's decision, and what is - /// measured here is whether a REAL check, against a real mesh, patched — read off the fake - /// updater's recorded tags rather than off any verdict text. - /// - [Theory(Timeout = 240_000)] - [InlineData(MigrationRunOutcome.Completed, null, null)] - [InlineData(MigrationRunOutcome.Failed, null, null)] - [InlineData(MigrationRunOutcome.TimedOut, null, null)] - [InlineData(MigrationRunOutcome.Forbidden, null, null)] - [InlineData(MigrationRunOutcome.NotSupported, null, null)] - [InlineData(MigrationRunOutcome.NotSupported, 57, 58)] - [InlineData(MigrationRunOutcome.NotSupported, 58, 58)] - [InlineData(MigrationRunOutcome.Forbidden, 57, 58)] - [InlineData(MigrationRunOutcome.Forbidden, 58, 58)] - [InlineData(MigrationRunOutcome.Completed, 57, 58)] - public async Task ThePollerPatchesExactlyWhenTheSharedDecisionSaysItMay( - MigrationRunOutcome outcome, int? installedExpected, int? targetExpected) - { - await Seed(TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(outcome); - var step = new ReleaseSchemaStep("3.0.0-ci.1", installedExpected, CandidateTag, targetExpected); - - await RunOneCheck(updater, step); - - updater.Migrations.Should().Contain(CandidateTag, - "every roll asks about the schema first, whatever the answer turns out to be"); - if (SelfUpdateVerdict.MayPatchAfter(outcome, step)) - updater.Tags.Should().Contain(CandidateTag, - "MayPatchAfter says this outcome permits the patch, and the manual Apply route will " - + "patch on it — the poller must not be stricter than the predicate the other route " - + "reads, or the two disagree about the same release"); - else - updater.Tags.Should().BeEmpty( - "MayPatchAfter refuses this outcome, and the manual Apply route will refuse on it — " - + "the poller must not be more permissive than the predicate the other route reads, " - + "which is precisely how the button ended up making the roll the poller had stopped " - + "making (#4764)"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Harness — the ComboGateRollTest shapes, with the migration seam driven - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// Starts the poller, waits for ONE check to have been evaluated AND reported, then reads the - /// policy content that check produced. - /// - /// 🚨 Waits for the service to have EVALUATED, never for a state to appear inside a bound: - /// this service is event-driven, so "the state is not there yet" and "the first check has not run - /// yet" are indistinguishable from outside, and on a loaded shard the second is what actually - /// happens — reported as if it were a wrong verdict. - /// - private async Task RunOneCheck(RecordingUpdater updater, ReleaseSchemaStep? step = null) - { - var ct = TestContext.Current.CancellationToken; - var service = new GatedSelfUpdateService( - Mesh, new FakeAcrTagLister(), updater, FastPoll(), - Mesh.ServiceProvider.GetService>(), - new AlwaysAvailable(Mesh, new ConfigurationBuilder().Build()), - new ComboVerificationGate(Mesh), - step ?? ReleaseSchemaStep.Unknown(CandidateTag)); - - await service.StartAsync(CancellationToken.None); - try - { - await service.Evaluations.FirstAsync().Timeout(Budget).Await(ct); - // The check's own verdict stamp is the LAST write of a check, so a content carrying it - // carries every earlier write of that check too. - return await WaitForContent(c => c.LastCheckVerdict is not null); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - } - - private static SelfUpdateOptions FastPoll() => new() - { - RetryInterval = TimeSpan.FromMilliseconds(500), - EventCoalesceWindow = TimeSpan.FromMilliseconds(50), - DefaultPolicy = UpdatePolicyKind.Continuous, - DefaultPattern = "*-ci*", - }; - - /// Fake registry (the documented IO seam): one build newer than anything installed. - private sealed class FakeAcrTagLister : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult>([CandidateTag]); - } - - /// - /// Fake Kubernetes updater (the documented IO seam) — records every tag it was asked to MIGRATE - /// and every tag it was asked to PATCH, separately, because the whole question here is whether - /// the second happens when the first did not succeed. - /// - private sealed class RecordingUpdater(MigrationRunOutcome outcome) : IDeploymentUpdater - { - private ImmutableList tags = ImmutableList.Empty; - private ImmutableList migrations = ImmutableList.Empty; - - public ImmutableList Tags => tags; - - public ImmutableList Migrations => migrations; - - public bool CanPatch => true; - - public Task LastRolledAtAsync(CancellationToken ct) => - Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) - { - ImmutableInterlocked.Update(ref tags, current => current.Add(versionTag)); - return Task.CompletedTask; - } - - public Task RunMigrationAsync(string versionTag, CancellationToken ct) - { - ImmutableInterlocked.Update(ref migrations, current => current.Add(versionTag)); - return Task.FromResult(outcome); - } - } - - /// - /// The availability gate, pinned to "nothing to enforce here". The gates are INDEPENDENT and this - /// suite is about the migration step — leaving another gate free to decide would make every - /// assertion here ambiguous about which one produced the outcome. - /// - private sealed class AlwaysAvailable(IMessageHub hub, IConfiguration configuration) - : ReleaseAvailabilityService(hub, configuration) - { - public override IObservable IsUpdatable(string? targetVersion) => - Observable.Return(UpdatabilityVerdict.NotEnforced( - "this test host consumes no CI bakes")); - } - - /// The poller with both gates supplied directly. Production resolves them from the - /// mesh's service provider; injecting keeps the test from rebuilding the mesh to register two - /// singletons, without changing which code path decides. The combo gate is the portal-pod shape, - /// whose "no verdict recorded" answer neither clears nor refuses — so the roll reaches the - /// migration step, which is the step under test. - private sealed class GatedSelfUpdateService( - IMessageHub hub, - IAcrTagLister acr, - IDeploymentUpdater updater, - SelfUpdateOptions options, - ILogger? logger, - ReleaseAvailabilityService? gate, - ComboVerificationGate? combo, - ReleaseSchemaStep step) - : SelfUpdateHostedService(hub, acr, updater, options, logger) - { - /// The published schema step, supplied directly: the markers live on a mounted - /// share (the documented IO seam), and the decision under test is what the poller does with - /// the two numbers, not how a file is read — ReleaseSchemaMarkerTest pins the read. - protected override IObservable ReadSchemaStep(string target) => Observable.Return(step); - - /// Surfaces the base class's per-check completion. The base member is - /// protected internal and this assembly is not in its friend list, so a derived-class - /// forward is the local way to reach it. - public IObservable Evaluations => ChecksReported; - - protected override ReleaseAvailabilityService? ResolveAvailabilityGate() => gate; - - protected override ComboVerificationGate? ResolveComboGate() => combo; - } - - // ── mesh helpers (the ComboGateRollTest shapes) ── - - private Task Seed(CancellationToken cancellationToken) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - // The candidate is a ci build, eligible only under a pattern that admits it. - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent - { - Policy = UpdatePolicyKind.Continuous, - Pattern = "*-ci*", - }, - }; - // System scope opened/closed SYNCHRONOUSLY around the subscribe — impersonation is an - // AsyncLocal, so Observable.Using would restore it on the wrong thread. - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } - - /// The first reconciled content matching — the - /// wait-on-the-condition read, never a bare first emission (which can predate the write). - private Task WaitForContent(Func predicate) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace() - .GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .Subscribe(observer); - }) - .Where(predicate) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/SelfUpdateStrandRecoveryTest.cs b/test/Memex.Portal.Shared.Test/SelfUpdateStrandRecoveryTest.cs deleted file mode 100644 index 10b9ee8491..0000000000 --- a/test/Memex.Portal.Shared.Test/SelfUpdateStrandRecoveryTest.cs +++ /dev/null @@ -1,326 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 An install whose own tag was withdrawn is STRANDED, not up to date (#3543) — the poller -/// half, against a real monolith mesh. -/// -/// On 2026-09-07 both AKS portals rolled themselves onto 3.1.0-ci.7841 — a version line -/// published on 2026-09-05 from a reverted bump and older in content than the live 3.0.0-ci.79xx -/// line — and then could not leave. The 3.1.0 tags were untagged from ACR, so the image the -/// Deployments named no longer existed and no new pod could start from it. The self-updater kept -/// reporting "no newer release: 500 tag(s) listed, none newer than the installed -/// 3.1.0-ci.7841", which is the SAME sentence a perfectly current install prints; both portals -/// had to be moved by an operator kubectl set image. -/// -/// It was unrecoverable by construction, not by bad luck: the only question the check asked was -/// "is anything newer than what I run", and nothing is ever newer than a tag that already outranks -/// everything left. So the check now asks a SECOND question of the listing it has already fetched — -/// does the tag I run still resolve? — and treats the three answers as three answers. -/// -/// Fails on unfixed code: -/// sees an unpatched updater and a verdict reading "no newer release", exactly as production did. -/// -/// Only the two documented IO seams are faked — the ACR listing and the k8s PATCH. The hub, the -/// workspace, the policy node, the live stream, every stream.Update and the whole decision -/// path are real. -/// -public class SelfUpdateStrandRecoveryTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// 🚨 , never a literal: a hand-written 30 s is - /// both a guess about machine speed AND the framework's own write bound (#2819). - private static TimeSpan Budget => TestTimeouts.Convergence; - - /// - /// What this test host reports as its running platform version — 3.0.0+<sha> on a CI - /// build, 3.0.0-ci.0 locally. The scenarios are built RELATIVE to it rather than around a - /// hard-coded string, and each one asserts its own premise (see ), - /// so a future change to how a build stamps itself fails this file loudly instead of quietly - /// turning it into a test of nothing. - /// - private static string Installed => ShippedReleaseSeed.InstalledPlatformVersion; - - /// The installed version as a REGISTRY TAG would spell it: the running - /// InformationalVersion carries +build.<ticks> / +<sha> build - /// metadata that no image tag has. - private static string InstalledTag => Installed.Split('+')[0]; - - /// - /// Two published releases, both strictly BEHIND this install in CD lineage and in SemVer, and - /// neither of them the tag this install runs — the registry a withdrawal leaves behind. The - /// newest of them, 2.0.0-ci.0, is the only thing a stranded install can recover to. - /// - private static readonly string[] WithoutTheInstalledTag = ["1.9.0-ci.0", "2.0.0-ci.0"]; - - private const string RecoveryTarget = "2.0.0-ci.0"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - // AddGitHubSyncTypes registers the BuildCompletion satellite the self-update watch reacts to - // — types only, the same production registration rather than a duplicate declaration. - => base.ConfigureMesh(builder).AddUpdatePolicyType().AddGitHubSyncTypes(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - // ══════════════════════════════════════════════════════════════════════════ - // The strand, and the way out - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The acceptance criterion. Nothing in the registry is newer, and the tag this install - /// runs is not in the registry at all. That is not "up to date" — the workloads name an image no - /// pod can start from — so the check takes the best AVAILABLE release, backwards in lineage, and - /// says on the policy node that it did. - /// - [Fact(Timeout = 240_000)] - public async Task AWithdrawnInstalledTag_RollsToTheBestAvailableRelease() - { - AssertBehindInstalled(WithoutTheInstalledTag); - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, WithoutTheInstalledTag); - - updater.Tags.Should().Contain(RecoveryTarget, - "an install pointing at an untagged image cannot start a new pod, and no publication can " - + "ever rescue it by being 'newer' — the only way out is the best release that exists"); - content.UnresolvedInstalledTag.Should().Be(Installed, - "the strand has to be VISIBLE on Admin/UpdatePolicy: a log line depends on a per-category " - + "level a deployment may never have set, a node write does not"); - content.LastCheckVerdict.Should().Contain("RECOVERY", - "a roll that went BACKWARDS must say why it did, or the next reader sees an unexplained " - + "downgrade"); - content.LastCheckVerdict.Should().NotContain("no newer release", - "printing the up-to-date sentence over a strand is the whole of #3543"); - } - - /// - /// 🚨 The control, and the half that makes the recovery safe: with the installed tag PRESENT in - /// the listing and nothing newer, the check must report exactly what it reported before and patch - /// NOTHING. A recovery reachable from anything but a proven withdrawal would roll the whole fleet - /// backwards on every check. - /// - [Fact(Timeout = 240_000)] - public async Task ACurrentInstall_StillReportsNoNewerRelease_AndRollsNothing() - { - string[] registry = [.. WithoutTheInstalledTag, InstalledTag]; - AssertBehindInstalled(WithoutTheInstalledTag); - await Seed(UpdatePolicyKind.Continuous, TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, registry); - - updater.Tags.Should().BeEmpty("nothing is newer and the installed image still exists"); - content.UnresolvedInstalledTag.Should().BeNull( - "the field is written on EVERY check, so a healed strand disappears instead of lingering " - + "as a stale scare"); - content.LastCheckVerdict.Should().Contain("no newer release", - "the up-to-date sentence is still the right one when the install really is up to date"); - } - - /// - /// 🚨 The terminal strand: the tag is gone AND the policy leaves nothing eligible to recover to - /// (Stable considers only clean releases; this registry publishes none). Nothing in the process - /// can fix that, so the verdict says so and names the operator's move — rather than printing the - /// sentence that made both portals look healthy for hours. - /// - [Fact(Timeout = 240_000)] - public async Task AStrandWithNothingToRecoverTo_SaysSo_InsteadOfLookingHealthy() - { - await Seed(UpdatePolicyKind.Stable, TestContext.Current.CancellationToken); - var updater = new RecordingUpdater(); - - var content = await RunOneCheck(updater, WithoutTheInstalledTag); - - updater.Tags.Should().BeEmpty("there is no eligible release to roll to"); - content.UnresolvedInstalledTag.Should().Be(Installed); - content.LastCheckVerdict.Should().Contain("STRANDED"); - content.LastCheckVerdict.Should().Contain("kubectl set image", - "an unactionable strand is how an install stays down for hours with everything green"); - } - - // ══════════════════════════════════════════════════════════════════════════ - // Harness - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 The premise, asserted rather than assumed. Every scenario here depends on the listed tags - /// being BEHIND what this host runs; if a future change to the build stamp made one of them look - /// newer, the recovery path would never be reached and the tests above would pass having measured - /// the ordinary update path instead. That is the shape of a verification step that cannot fail, - /// so it is checked out loud. - /// - private static void AssertBehindInstalled(IEnumerable tags) - { - foreach (var tag in tags) - VersionSelect.IsNewer(tag, Installed).Should().BeFalse( - $"the scenario needs {tag} to be BEHIND the running {Installed}; it is not, so this " - + "test would exercise the ordinary update path and prove nothing about a strand. " - + "Move the fixture tags down, do not delete the assertion."); - } - - /// Fake registry (the documented IO seam): exactly the tags a scenario hands it. - private sealed class FakeAcrTagLister(IReadOnlyList tags) : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult(tags); - } - - /// Fake k8s patcher (the documented IO seam) — records every applied tag. - private sealed class RecordingUpdater : IDeploymentUpdater - { - private ImmutableList tags = ImmutableList.Empty; - - public ImmutableList Tags => tags; - public bool CanPatch => true; - - public Task LastRolledAtAsync(CancellationToken ct) => - Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) - { - ImmutableInterlocked.Update(ref tags, current => current.Add(versionTag)); - return Task.CompletedTask; - } - } - - /// - /// The availability gate, pinned to "nothing to enforce here". The gates are INDEPENDENT of the - /// selection this suite is about — leaving one free to decide would make every assertion here - /// ambiguous about which decision produced the outcome. - /// - private sealed class AlwaysAvailable(IMessageHub hub, IConfiguration configuration) - : ReleaseAvailabilityService(hub, configuration) - { - public override IObservable IsUpdatable(string? targetVersion) => - Observable.Return(UpdatabilityVerdict.NotEnforced( - "this test host consumes no CI bakes")); - } - - /// The poller with the availability gate supplied directly and no combo gate — the same - /// injection seams ComboGateRollTest uses, so the DECISION path under test is the production - /// one. - private sealed class SeamedSelfUpdateService( - IMessageHub hub, - IAcrTagLister acr, - IDeploymentUpdater updater, - SelfUpdateOptions options, - ILogger? logger, - ReleaseAvailabilityService gate) - : SelfUpdateHostedService(hub, acr, updater, options, logger) - { - public IObservable Evaluations => ChecksReported; - - protected override ReleaseAvailabilityService? ResolveAvailabilityGate() => gate; - - protected override ComboVerificationGate? ResolveComboGate() => null; - } - - private static SelfUpdateOptions FastPoll(UpdatePolicyKind policy) => new() - { - RetryInterval = TimeSpan.FromMilliseconds(500), - EventCoalesceWindow = TimeSpan.FromMilliseconds(50), - DefaultPolicy = policy, - DefaultPattern = policy == UpdatePolicyKind.Continuous ? "*-ci*" : null, - }; - - /// - /// Starts the poller, waits for ONE check to have been evaluated AND reported, then reads the - /// policy content the check produced. - /// - /// 🚨 Waits for the service to have EVALUATED, never for a state to appear inside a bound: - /// this service is event-driven, so "the state is not there yet" and "the first check has not run - /// yet" are indistinguishable from outside, and on a loaded shard the second is what actually - /// happens — reported as if it were a wrong verdict. - /// - private async Task RunOneCheck( - RecordingUpdater updater, IReadOnlyList registry) - { - var ct = TestContext.Current.CancellationToken; - var service = new SeamedSelfUpdateService( - Mesh, new FakeAcrTagLister(registry), updater, FastPoll(UpdatePolicyKind.Continuous), - Mesh.ServiceProvider.GetService>(), - new AlwaysAvailable(Mesh, new ConfigurationBuilder().Build())); - - await service.StartAsync(CancellationToken.None); - try - { - await service.Evaluations.FirstAsync().Timeout(Budget).Await(ct); - // The check's own verdict stamp is the LAST write of a check, so a content carrying it - // carries every earlier write of that check too. - return await WaitForContent(c => c.LastCheckVerdict is not null); - } - finally - { - await service.StopAsync(CancellationToken.None); - } - } - - private Task Seed(UpdatePolicyKind policy, CancellationToken cancellationToken) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - // 2026-09-08: a continuous build is eligible only when a pattern admits it — a - // Continuous seed without one is Stable and would roll to no ci tag at all. - Content = new UpdatePolicyContent - { - Policy = policy, - Pattern = policy == UpdatePolicyKind.Continuous ? "*-ci*" : null, - }, - }; - // System scope opened/closed SYNCHRONOUSLY around the subscribe — impersonation is an - // AsyncLocal, so Observable.Using would restore it on the wrong thread. - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return (IDisposable)meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } - - /// The first reconciled content matching — the - /// wait-on-the-condition read, never a bare first emission (which can predate the write). - private Task WaitForContent(Func predicate) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace() - .GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .Subscribe(observer); - }) - .Where(predicate) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/SelfUpdateVerdictTest.cs b/test/Memex.Portal.Shared.Test/SelfUpdateVerdictTest.cs deleted file mode 100644 index 4a2a713a8a..0000000000 --- a/test/Memex.Portal.Shared.Test/SelfUpdateVerdictTest.cs +++ /dev/null @@ -1,470 +0,0 @@ -using System; -using System.Linq; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Hosting.SelfUpdate; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The pure half of #2553: the verdict a self-update check reports. No hub, no logger, no Rx — -/// these are the judgements the reporting site depends on, and they are worth pinning here rather -/// than re-deriving them inside an integration test that also has to stand up a mesh. -/// -public class SelfUpdateVerdictTest -{ - /// - /// 🚨 The load-bearing discriminator. The dead-event-channel warning fires only when a - /// safety-net check FOUND a release nobody announced, and this predicate is what "found" means. - /// Get it wrong in the permissive direction and every quiet install warns hourly until people - /// stop reading the line; get it wrong in the strict direction and the one report that would - /// have named #2494 never fires. - /// - [Theory] - [InlineData(SelfUpdateOutcome.Applied, true)] - [InlineData(SelfUpdateOutcome.Held, true)] - [InlineData(SelfUpdateOutcome.Deferred, true)] - [InlineData(SelfUpdateOutcome.DetectOnly, true)] - // A combo refusal is a release that WAS waiting — the dead-event-channel report must fire for - // it exactly as it does for an availability hold: something published and nothing told us. - [InlineData(SelfUpdateOutcome.ComboBlocked, true)] - // A migration refusal is a release that WAS waiting and could not be taken — the report must - // fire for it exactly as for a hold; a schema that did not move is not "nothing newer". - [InlineData(SelfUpdateOutcome.MigrationFailed, true)] - // A migration that could not even be ATTEMPTED refused a release that WAS waiting, for the same - // reason (#4764) — the report must fire for it as for any other refusal. - [InlineData(SelfUpdateOutcome.MigrationUnavailable, true)] - [InlineData(SelfUpdateOutcome.NoNewerRelease, false)] - [InlineData(SelfUpdateOutcome.UpdatesDisabled, false)] - [InlineData(SelfUpdateOutcome.CheckFailed, false)] - [InlineData(SelfUpdateOutcome.NoOutcome, false)] - // A restart activates a MODULE this install already landed — nothing newer was waiting in - // the registry, so a safety-net check that restarts must never fire the dead-channel report. - [InlineData(SelfUpdateOutcome.Restarted, false)] - [InlineData(SelfUpdateOutcome.RestartDeferred, false)] - [InlineData(SelfUpdateOutcome.RestartUnavailable, false)] - // A release handed to the control lane WAS waiting (#4098) — the report must fire for it as for - // an applied roll; a hand-over that failed is still a release nobody announced to this install. - [InlineData(SelfUpdateOutcome.HandedOver, true)] - [InlineData(SelfUpdateOutcome.HandoverFailed, true)] - [InlineData(SelfUpdateOutcome.RestartHandedOver, false)] - // A module that went LIVE was already landed here — nothing newer was waiting in the registry. - [InlineData(SelfUpdateOutcome.ActivatedLive, false)] - // A restart hand-over that failed activates an already-landed module — nothing newer was waiting. - [InlineData(SelfUpdateOutcome.RestartHandoverFailed, false)] - // A marker with nothing pending here (#6620) found no release either. - [InlineData(SelfUpdateOutcome.NothingToActivate, false)] - public void FoundNewerRelease_IsTrueExactlyWhenAReleaseWasWaiting( - SelfUpdateOutcome outcome, bool expected) - => Assert.Equal(expected, new SelfUpdateVerdict(outcome, "…").FoundNewerRelease); - - /// - /// Every outcome must be reachable through a factory. An enum member with no constructor is a - /// state the service can never report, which is the same silence one level up. - /// - [Fact] - public void EveryOutcome_HasAFactoryThatProducesIt() - { - SelfUpdateVerdict[] all = - [ - SelfUpdateVerdict.UpdatesDisabled(), - SelfUpdateVerdict.NoNewerRelease(7, "3.0.0"), - SelfUpdateVerdict.Held("3.0.1", "no sealed bake"), - SelfUpdateVerdict.Deferred("3.0.1", TimeSpan.FromMinutes(5), TimeSpan.FromHours(1)), - SelfUpdateVerdict.DetectOnly("3.0.1"), - SelfUpdateVerdict.Applied("3.0.1", "3.0.0", null), - SelfUpdateVerdict.CheckFailed(new InvalidOperationException("boom")), - SelfUpdateVerdict.NoOutcome(), - SelfUpdateVerdict.ComboBlocked("3.0.1", "'Widget' does not compile against it"), - SelfUpdateVerdict.MigrationFailed("3.0.1", MigrationRunOutcome.TimedOut), - SelfUpdateVerdict.MigrationUnavailable( - "3.0.1", MigrationRunOutcome.Forbidden, "run a helm upgrade for this instance."), - SelfUpdateVerdict.InstalledTagWithdrawn("3.1.0-ci.7841", "it is not in the registry"), - SelfUpdateVerdict.Restarted(SelfUpdateVerdict.NoNewerRelease(7, "3.0.0"), "3.0.0", null), - SelfUpdateVerdict.RestartDeferred( - SelfUpdateVerdict.NoNewerRelease(7, "3.0.0"), "3.0.0", TimeSpan.FromMinutes(5), TimeSpan.FromHours(1)), - SelfUpdateVerdict.RestartUnavailable( - SelfUpdateVerdict.NoNewerRelease(7, "3.0.0"), "3.0.0", "this install does not self-patch"), - SelfUpdateVerdict.HandedOver("3.0.1", "https://control.example/api/hooks/Hosting/PlatformBuilds", "accepted (200)"), - SelfUpdateVerdict.HandoverFailed("3.0.1", "401 Unauthorized"), - SelfUpdateVerdict.RestartHandedOver( - SelfUpdateVerdict.NoNewerRelease(7, "3.0.0"), "3.0.0", "https://control.example/api/hooks/Hosting/PlatformBuilds", "accepted (200)"), - SelfUpdateVerdict.ActivatedLive( - SelfUpdateVerdict.NoNewerRelease(7, "3.0.0"), "went live without a restart: MeshWeaver.Test.Widget"), - SelfUpdateVerdict.RestartHandoverFailed( - SelfUpdateVerdict.NoNewerRelease(7, "3.0.0"), "3.0.0", "the control inbox was unreachable"), - SelfUpdateVerdict.NothingToActivate( - SelfUpdateVerdict.NoNewerRelease(7, "3.0.0"), "nothing landed is pending activation in this process"), - ]; - - Assert.Equal( - Enum.GetValues().OrderBy(o => o), - all.Select(v => v.Outcome).OrderBy(o => o)); - Assert.All(all, v => Assert.False(string.IsNullOrWhiteSpace(v.Message))); - } - - /// - /// 🚨 "Nothing newer" has to say WHAT IT LOOKED AT. "No newer release" alone is the sentence a - /// broken checker would also produce; naming the number of tags listed and the installed - /// version is what makes it evidence rather than a reassurance. - /// - [Fact] - public void NoNewerRelease_NamesWhatItActuallyLookedAt() - { - var verdict = SelfUpdateVerdict.NoNewerRelease(12, "3.0.0-rc8.ci.6183"); - - Assert.Contains("12 tag(s) listed", verdict.Message, StringComparison.Ordinal); - Assert.Contains("3.0.0-rc8.ci.6183", verdict.Message, StringComparison.Ordinal); - } - - /// A hold with no recorded reason must still read as a hold, never as an empty - /// sentence — a null reason is exactly the case an operator most needs to see. - [Fact] - public void AHoldWithNoReason_StillNamesTheTagAndTheHold() - { - var verdict = SelfUpdateVerdict.Held("3.0.1", null); - - Assert.Equal(SelfUpdateOutcome.Held, verdict.Outcome); - Assert.Contains("HOLDING 3.0.1", verdict.Message, StringComparison.Ordinal); - Assert.Equal("3.0.1", verdict.Tag); - } - - /// - /// 🚨 A combo refusal is a DIFFERENT incident from an availability hold, and its sentence has to - /// say so — the two are fixed in different places (re-verify the candidate vs publish the - /// missing artifact), and a message that blurred them would send an operator to the wrong one. - /// - [Fact] - public void ComboBlocked_NamesTheGateAndTheReason() - { - var verdict = SelfUpdateVerdict.ComboBlocked( - "3.0.1", "'Widget' does not compile against it"); - - Assert.Equal(SelfUpdateOutcome.ComboBlocked, verdict.Outcome); - Assert.Contains("combo gate", verdict.Message, StringComparison.Ordinal); - Assert.Contains("Widget", verdict.Message, StringComparison.Ordinal); - Assert.Equal("3.0.1", verdict.Tag); - } - - /// - /// 🚨 An unverified roll must leave a DURABLE trace, not only a log line: a log line depends on - /// a per-category level a deployment may never have set, and that is exactly how an install sat - /// three builds behind for hours with nothing able to say so. The qualification rides the - /// verdict, so it lands on LastCheckVerdict — and it never erases what the check did. - /// - [Fact] - public void Unverified_QualifiesAVerdictWithoutErasingIt() - { - var applied = SelfUpdateVerdict.Applied("3.0.1", "3.0.0", null); - - var qualified = applied.Unverified("no combo verification has been recorded for '3.0.1'"); - - Assert.Equal(applied.Outcome, qualified.Outcome); - Assert.Equal(applied.Tag, qualified.Tag); - Assert.Contains("applied update 3.0.1", qualified.Message, StringComparison.Ordinal); - Assert.Contains("UNVERIFIED", qualified.Message, StringComparison.Ordinal); - Assert.Contains("no combo verification has been recorded", qualified.Message, - StringComparison.Ordinal); - } - - /// - /// 🚨 A strand is not a "nothing newer" (#3543). The verdict has to name the version that stopped - /// resolving, say what is wrong in a word an operator can search for, and name the move — because - /// nothing in the process can make this one better on its own: no publication is ever "newer" - /// than a tag that already outranks everything left in the registry. - /// - [Fact] - public void InstalledTagWithdrawn_NamesTheVersion_AndTheOperatorsMove() - { - var verdict = SelfUpdateVerdict.InstalledTagWithdrawn( - "3.1.0-ci.7841", "the installed 3.1.0-ci.7841 is NOT among the 500 platform tag(s)"); - - Assert.Equal(SelfUpdateOutcome.InstalledTagWithdrawn, verdict.Outcome); - Assert.Contains("STRANDED on 3.1.0-ci.7841", verdict.Message, StringComparison.Ordinal); - Assert.Contains("kubectl set image", verdict.Message, StringComparison.Ordinal); - Assert.Equal("3.1.0-ci.7841", verdict.UnresolvedInstalledTag); - Assert.False(verdict.FoundNewerRelease, - "a strand is the opposite of 'a release was waiting' — reporting it as one would fire the " - + "dead-event-channel warning at an install whose event channel is fine"); - } - - /// - /// 🚨 A recovery roll goes BACKWARDS in lineage, on purpose, and must say so on the verdict — not - /// only in a log line, whose level a deployment may never have set. It qualifies what the check - /// did without erasing it, exactly as Unverified does, and carries the version that stopped - /// resolving so the Updates tab can render the state too. - /// - [Fact] - public void Recovering_QualifiesTheRoll_AndCarriesTheWithdrawnVersion() - { - var applied = SelfUpdateVerdict.Applied("3.0.0-ci.7977", "3.1.0-ci.7841", null); - - var qualified = applied.Recovering( - "3.1.0-ci.7841", "the installed 3.1.0-ci.7841 is NOT among the 500 platform tag(s)"); - - Assert.Equal(SelfUpdateOutcome.Applied, qualified.Outcome); - Assert.Equal("3.0.0-ci.7977", qualified.Tag); - Assert.Contains("applied update 3.0.0-ci.7977", qualified.Message, StringComparison.Ordinal); - Assert.Contains("RECOVERY", qualified.Message, StringComparison.Ordinal); - Assert.Equal("3.1.0-ci.7841", qualified.UnresolvedInstalledTag); - } - - /// - /// 🚨 The third state has to be SAID. "Nothing newer" over a listing that could not answer whether - /// the installed tag still exists is a claim with no evidence behind half of it — the same defect - /// #2553 removed one level up, and the reason that sentence is qualified rather than reused. - /// - [Fact] - public void InstalledTagUnchecked_SaysTheQuestionWasNotAnswered() - { - var verdict = SelfUpdateVerdict.NoNewerRelease(0, "3.0.0-ci.7977") - .InstalledTagUnchecked("the registry listing carried no platform version tags at all"); - - Assert.Equal(SelfUpdateOutcome.NoNewerRelease, verdict.Outcome); - Assert.Contains("NOT established", verdict.Message, StringComparison.Ordinal); - Assert.Null(verdict.UnresolvedInstalledTag); - } - - /// The structural backstop names itself as a defect in the service, not as a state of - /// the install — otherwise it would be read as one more thing about the deployment. - [Fact] - public void NoOutcome_BlamesTheService_NotTheInstall() - { - var verdict = SelfUpdateVerdict.NoOutcome(); - - Assert.Contains("SelfUpdateHostedService", verdict.Message, StringComparison.Ordinal); - Assert.False(verdict.FoundNewerRelease); - } - - // ══════════════════════════════════════════════════════════════════════════ - // One decision for every route that patches (#4764) - // ══════════════════════════════════════════════════════════════════════════ - - /// - /// 🚨 Only Completed PROVES the schema moved, and only NotSupported is permitted - /// without that proof — an install with no migration mechanism at all, which would otherwise - /// freeze for ever and silently (#2553) and whose roll is recorded UNMIGRATED instead. - /// - /// Pinned pure and separately from the poller because TWO routes read it: the poller and the - /// Updates tab's manual Apply, which had no migration step at all until #4764. Get - /// Forbidden wrong in the permissive direction and a click reproduces the crash-loop; - /// get NotSupported wrong in the strict direction and every install whose updater predates - /// the seam stops updating with no way to notice. - /// - [Theory] - [InlineData(MigrationRunOutcome.Completed, true)] - [InlineData(MigrationRunOutcome.NotSupported, true)] - [InlineData(MigrationRunOutcome.Failed, false)] - [InlineData(MigrationRunOutcome.TimedOut, false)] - [InlineData(MigrationRunOutcome.Forbidden, false)] - public void MayPatchAfter_PermitsOnlyAProvenOrImpossibleMigration( - MigrationRunOutcome outcome, bool expected) - => Assert.Equal(expected, SelfUpdateVerdict.MayPatchAfter(outcome)); - - /// Every outcome the enum can hold is decided — an outcome added later must be - /// classified deliberately rather than inheriting whichever side the pattern happens to fall - /// on. - [Fact] - public void MayPatchAfter_DecidesEveryOutcomeTheEnumCanHold() - { - var decided = new[] - { - MigrationRunOutcome.Completed, MigrationRunOutcome.NotSupported, - MigrationRunOutcome.Failed, MigrationRunOutcome.TimedOut, MigrationRunOutcome.Forbidden, - }; - - Assert.Equal(Enum.GetValues().OrderBy(o => o), decided.OrderBy(o => o)); - } - - // ══════════════════════════════════════════════════════════════════════════ - // A pending restart rolls the same image, within the interval rules (#3650) - // ══════════════════════════════════════════════════════════════════════════ - - private static readonly SelfUpdateVerdict UpToDate = SelfUpdateVerdict.NoNewerRelease(7, "3.0.0"); - - private static readonly DateTimeOffset Now = new(2026, 9, 8, 12, 0, 0, TimeSpan.Zero); - - /// - /// 🚨 Only a check that PATCHED nothing has a restart to take. An applied roll IS the restart; - /// a refused migration leaves the image where it is on purpose; a failed check decided - /// nothing; a disabled policy means never; and the structural backstop is not a verdict at - /// all. Every other outcome — up to date, held, deferred, detect-only, combo-blocked, a strand - /// — may be followed by the restart of the image that IS running. - /// - [Theory] - [InlineData(SelfUpdateOutcome.NoNewerRelease, true)] - [InlineData(SelfUpdateOutcome.Held, true)] - [InlineData(SelfUpdateOutcome.Deferred, true)] - [InlineData(SelfUpdateOutcome.DetectOnly, true)] - [InlineData(SelfUpdateOutcome.ComboBlocked, true)] - [InlineData(SelfUpdateOutcome.InstalledTagWithdrawn, true)] - [InlineData(SelfUpdateOutcome.Applied, false)] - [InlineData(SelfUpdateOutcome.MigrationFailed, false)] - // 🚨 And MigrationUnavailable is TRUE, unlike MigrationFailed (#4764). A restart re-creates the - // pods on the image ALREADY running, whose schema the database already satisfies; the wall is - // about the TARGET build. A broken migration is fixed in minutes — a missing migration - // MECHANISM waits for an operator's helm upgrade, and holding a landed module generation - // unactivated for all of that would be a second freeze caused by the first. - [InlineData(SelfUpdateOutcome.MigrationUnavailable, true)] - [InlineData(SelfUpdateOutcome.CheckFailed, false)] - [InlineData(SelfUpdateOutcome.UpdatesDisabled, false)] - [InlineData(SelfUpdateOutcome.NoOutcome, false)] - // A release handed to the control lane is a roll in flight there — its Roll restarts the pods, - // and a second request for the same instance would only race it (#4098). A hand-over that - // FAILED handed nothing to anyone: the pending restart is still considered, never skipped. - [InlineData(SelfUpdateOutcome.HandedOver, false)] - [InlineData(SelfUpdateOutcome.HandoverFailed, true)] - public void MayRestartAfter_OnlyWhenTheCheckPatchedNothing(SelfUpdateOutcome outcome, bool expected) - => Assert.Equal(expected, SelfUpdateVerdict.MayRestartAfter(new SelfUpdateVerdict(outcome, "…"))); - - /// - /// MeshWeaver#6829: the LIVE half may follow a hand-over even though the restart may not — a live - /// swap races no roll. Everywhere else it follows exactly the restart rule: an applied roll, a - /// refused migration, a failed or absent check and a disabled policy take neither. - /// - [Theory] - [InlineData(SelfUpdateOutcome.HandedOver, true)] - [InlineData(SelfUpdateOutcome.NoNewerRelease, true)] - [InlineData(SelfUpdateOutcome.HandoverFailed, true)] - [InlineData(SelfUpdateOutcome.Applied, false)] - [InlineData(SelfUpdateOutcome.MigrationFailed, false)] - [InlineData(SelfUpdateOutcome.CheckFailed, false)] - [InlineData(SelfUpdateOutcome.UpdatesDisabled, false)] - [InlineData(SelfUpdateOutcome.NoOutcome, false)] - public void MayActivateLiveAfter_AlsoAfterAHandOver(SelfUpdateOutcome outcome, bool expected) - => Assert.Equal(expected, SelfUpdateVerdict.MayActivateLiveAfter(new SelfUpdateVerdict(outcome, "…"))); - - /// - /// MeshWeaver#6829: the hand-over's live verdict keeps the - /// outcome in all three cases, and a pass that swapped nothing in and left nothing for a restart - /// (the marker outlives a successful swap until boot) says nothing is pending here — never that an - /// activation is still pending. - /// - [Theory] - [InlineData(1, false, "went LIVE", "nothing landed is pending")] - [InlineData(0, true, "is pending activation and nothing went live", "nothing landed is pending")] - [InlineData(0, false, "nothing landed is pending in this process", "generation is pending activation")] - public void LiveWhileRollHandedOver_SaysWhatHappened(int wentLive, bool needsRestart, string says, string neverSays) - { - var handedOver = SelfUpdateVerdict.HandedOver("3.0.1", "https://control.example/api/hooks/Hosting/PlatformBuilds", "accepted (200)"); - var verdict = SelfUpdateVerdict.LiveWhileRollHandedOver(handedOver, wentLive, needsRestart, "detail"); - - Assert.Equal(SelfUpdateOutcome.HandedOver, verdict.Outcome); - Assert.Contains(says, verdict.Message, StringComparison.Ordinal); - Assert.DoesNotContain(neverSays, verdict.Message, StringComparison.Ordinal); - Assert.Equal(needsRestart, verdict.Message.Contains("activated by the handed-over roll", StringComparison.Ordinal)); - } - - /// Inside the floor the restart is deferred, naming how long ago the install rolled - /// and the floor it sits inside — the same sentence shape a deferred roll uses. - [Fact] - public void RestartDeferredBy_InsideTheFloor_Defers() - { - var verdict = SelfUpdateVerdict.RestartDeferredBy( - UpToDate, "3.0.0", Now - TimeSpan.FromMinutes(5), TimeSpan.FromHours(1), Now); - - Assert.NotNull(verdict); - Assert.Equal(SelfUpdateOutcome.RestartDeferred, verdict!.Outcome); - Assert.Contains("00:05:00", verdict.Message, StringComparison.Ordinal); - Assert.Contains("01:00:00", verdict.Message, StringComparison.Ordinal); - Assert.Contains("deferring the restart", verdict.Message, StringComparison.Ordinal); - Assert.Equal("3.0.0", verdict.Tag); - } - - /// Past the floor, at the floor, never rolled, floor disabled: the restart proceeds. - [Fact] - public void RestartDeferredBy_OutsideTheFloor_NeverRolled_OrFloorOff_Proceeds() - { - var floor = TimeSpan.FromHours(1); - - Assert.Null(SelfUpdateVerdict.RestartDeferredBy(UpToDate, "3.0.0", Now - TimeSpan.FromHours(2), floor, Now)); - Assert.Null(SelfUpdateVerdict.RestartDeferredBy(UpToDate, "3.0.0", Now - floor, floor, Now)); - Assert.Null(SelfUpdateVerdict.RestartDeferredBy(UpToDate, "3.0.0", lastRolledAt: null, floor, Now)); - Assert.Null(SelfUpdateVerdict.RestartDeferredBy(UpToDate, "3.0.0", Now - TimeSpan.FromSeconds(1), TimeSpan.Zero, Now)); - } - - /// - /// A restart verdict KEEPS the platform verdict it followed: the record still has to say what - /// the check found about the registry, and the restart is the second sentence, not a - /// replacement for the first. - /// - [Fact] - public void Restarted_CarriesThePlatformVerdict_AndNamesTheImage() - { - var verdict = SelfUpdateVerdict.Restarted(UpToDate, "3.0.0", Now); - - Assert.Equal(SelfUpdateOutcome.Restarted, verdict.Outcome); - Assert.StartsWith(UpToDate.Message, verdict.Message, StringComparison.Ordinal); - Assert.Contains("RESTARTED on 3.0.0", verdict.Message, StringComparison.Ordinal); - Assert.Equal("3.0.0", verdict.Tag); - Assert.False(verdict.FoundNewerRelease); - } - - /// - /// 🚨 A landed module nothing will ever activate is a state an operator has to see and can - /// act on: the verdict names why this install cannot restart itself and the move. - /// - [Fact] - public void RestartUnavailable_NamesTheReason_AndTheOperatorsMove() - { - var verdict = SelfUpdateVerdict.RestartUnavailable( - UpToDate, "3.0.0", "this install does not self-patch (detect-and-notify)"); - - Assert.Equal(SelfUpdateOutcome.RestartUnavailable, verdict.Outcome); - Assert.Contains("does not self-patch", verdict.Message, StringComparison.Ordinal); - Assert.Contains("kubectl rollout restart", verdict.Message, StringComparison.Ordinal); - } - - /// - /// 🚨 A FAILED restart hand-over is its own outcome, not - /// (MeshWeaver#6172): the restart path exists and this attempt failed, so a module reload retries - /// it (Faulted) instead of ending (Failed). The message still names the cause. - /// - [Fact] - public void ARestartHandoverFailure_IsItsOwnOutcome_NamingTheCause() - { - var verdict = SelfUpdateVerdict.RestartHandoverFailed(UpToDate, "3.0.0", "the control inbox was unreachable"); - - Assert.Equal(SelfUpdateOutcome.RestartHandoverFailed, verdict.Outcome); - Assert.Contains("the hand-over to the control lane failed: the control inbox was unreachable", - verdict.Message, StringComparison.Ordinal); - } - - /// A restart taken on the recovery path keeps the strand visible: the withdrawn - /// tag the platform verdict carried survives onto the restart verdict. - [Fact] - public void ARestartVerdict_KeepsTheUnresolvedInstalledTag() - { - var stranded = SelfUpdateVerdict.NoNewerRelease(0, "3.1.0-ci.7841") - .Recovering("3.1.0-ci.7841", "the installed tag is NOT among the listed tags"); - - Assert.Equal("3.1.0-ci.7841", - SelfUpdateVerdict.Restarted(stranded, "3.1.0-ci.7841", null).UnresolvedInstalledTag); - Assert.Equal("3.1.0-ci.7841", - SelfUpdateVerdict.RestartUnavailable(stranded, "3.1.0-ci.7841", "detect-only").UnresolvedInstalledTag); - } - - /// - /// #6620: a pass whose every outcome is UpToDate — a second, overlapping check finding the - /// generation the first one already swapped in — swapped nothing and must not read "went LIVE". - /// Negative control: a pass with one Live outcome is ActivatedLive. - /// - [Fact] - public void LiveVerdict_IsNothingToActivate_WhenEveryOutcomeIsUpToDate() - { - var platform = SelfUpdateVerdict.NoNewerRelease(7, "3.0.0"); - var upToDate = new MeshWeaver.PluginCatalog.ModuleLiveActivationResult( - [new MeshWeaver.Graph.Configuration.ModuleSwapOutcome("MeshWeaver.Test.Widget", - MeshWeaver.Graph.Configuration.ModuleSwapKind.UpToDate, "already serving")]); - var live = new MeshWeaver.PluginCatalog.ModuleLiveActivationResult( - [new MeshWeaver.Graph.Configuration.ModuleSwapOutcome("MeshWeaver.Test.Widget", - MeshWeaver.Graph.Configuration.ModuleSwapKind.Live, "swapped")]); - - var none = SelfUpdateHostedService.LiveVerdict(platform, upToDate); - Assert.Equal(SelfUpdateOutcome.NothingToActivate, none.Outcome); - Assert.DoesNotContain("went LIVE", none.Message); - Assert.Contains("already serving the landed generation", none.Message); - - Assert.Equal(SelfUpdateOutcome.ActivatedLive, SelfUpdateHostedService.LiveVerdict(platform, live).Outcome); - } -} diff --git a/test/Memex.Portal.Shared.Test/SeoIconLinksTest.cs b/test/Memex.Portal.Shared.Test/SeoIconLinksTest.cs deleted file mode 100644 index 26e322507f..0000000000 --- a/test/Memex.Portal.Shared.Test/SeoIconLinksTest.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Linq; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The set of <link> elements a node page's head should carry -/// (). -/// -/// 🚨 THE DEFECT (issue #2075, item 3): the head declared exactly one icon — the node's own -/// mark as an image/svg+xml data URI — and Safari renders no SVG favicon at all. So -/// on every Mac and iPhone the per-content favicon was invisible: each tab wore the portal mark, -/// in and out of circuit. The cure is not to give up the scalable icon but to declare BOTH and let -/// each browser take the one it can read. -/// -public class SeoIconLinksTest -{ - private const string AuthoredMark = - "" - + ""; - - private static MeshNode Node(string? icon, string path = "Chess") => - new(path) { NodeType = "Store/Plugin", Icon = icon }; - - /// - /// An svg mark yields three links: the scalable icon (unchanged, so a browser that reads SVG - /// still gets the crisp one), the PNG favicon Safari will actually take, and the - /// apple-touch-icon — a SEPARATE channel that Safari does not fall back to the favicon - /// for, so with none declared it draws its own letter tile instead of the node's mark. - /// - [Fact] - public void AnSvgMark_DeclaresTheScalableIcon_APngFallback_AndAnAppleTouchIcon() - { - var links = SeoResolver.ResolveIconLinks(Node(AuthoredMark)); - - Assert.Equal(3, links.Count); - - var svg = links[0]; - Assert.Equal("icon", svg.Rel); - Assert.Equal("image/svg+xml", svg.Type); - Assert.StartsWith("data:image/svg+xml,", svg.Href); - // A scalable icon declares no size: telling a browser that ranks icons by size that this - // one is 32 px would be a lie about the only thing that makes it worth preferring. - Assert.Null(svg.Sizes); - - var png = links[1]; - Assert.Equal("icon", png.Rel); - Assert.Equal("image/png", png.Type); - Assert.Equal("/api/icon/Chess.png?size=32", png.Href); - Assert.Equal("32x32", png.Sizes); - - var touch = links[2]; - Assert.Equal("apple-touch-icon", touch.Rel); - Assert.Equal("image/png", touch.Type); - Assert.Equal("/api/icon/Chess.png?size=180", touch.Href); - Assert.Equal("180x180", touch.Sizes); - } - - /// - /// 🚨 The raster links point at the SAME svg the first link carries. One resolution - /// () feeds both, so the tab's PNG and the head's data - /// URI can never become pictures of different things. - /// - [Fact] - public void ThePngAndTheDataUri_AreTheSameMark() - { - var node = Node(AuthoredMark); - - var fromLink = System.Uri.UnescapeDataString( - SeoResolver.ResolveIconLinks(node)[0].Href["data:image/svg+xml,".Length..]); - - Assert.Equal(AuthoredMark, fromLink); - Assert.Equal(AuthoredMark, SeoResolver.ResolveIconSvg(node)); - } - - /// A nested page keeps its full mesh path in the icon URL — publishing moves nothing, - /// and the route is a catch-all, so no separator is escaped. - [Fact] - public void ANestedNode_KeepsItsFullPath_Unescaped() - { - var links = SeoResolver.ResolveIconLinks(Node(AuthoredMark, "AgenticPrimer/01-TheMagicWish")); - - Assert.Equal("/api/icon/AgenticPrimer/01-TheMagicWish.png?size=32", links[1].Href); - } - - /// A mark that is already a raster image needs no PNG fallback — Safari reads it — so - /// the head declares the one link it always did. - [Fact] - public void ARasterMark_StillDeclaresExactlyOneLink() - { - var links = SeoResolver.ResolveIconLinks(Node("https://cdn.example.org/mark.png")); - - var only = Assert.Single(links); - Assert.Equal("https://cdn.example.org/mark.png", only.Href); - Assert.Equal("icon", only.Rel); - } - - /// - /// 🚨 Nothing is synthesised, exactly as before. A node with no mark of its own declares NO - /// icon links, so the portal favicon stays — the honest answer for a page with no mark, and the - /// reason the raster route's 404 is never reached from a page we serve. - /// - [Fact] - public void NoMarkOfItsOwn_DeclaresNothing() - { - Assert.Empty(SeoResolver.ResolveIconLinks(Node(null))); - Assert.Empty(SeoResolver.ResolveIconLinks(Node(""))); - Assert.Empty(SeoResolver.ResolveIconLinks(Node("Document"))); - Assert.Empty(SeoResolver.ResolveIconLinks(Node("📊"))); - } - - /// - /// The single-icon accessor the head has used since 2026-08-11 is unchanged — this change ADDS - /// links, it does not move the existing one. - /// - [Fact] - public void TheExistingSingleIconAccessor_StillAnswersTheSame() - { - var icon = SeoResolver.ResolveIcon(Node(AuthoredMark)); - - Assert.NotNull(icon); - Assert.Equal("image/svg+xml", icon.Type); - Assert.Equal(AuthoredMark, System.Uri.UnescapeDataString(icon.Href["data:image/svg+xml,".Length..])); - Assert.Equal(SeoResolver.ResolveIconLinks(Node(AuthoredMark)).First(), icon); - } -} diff --git a/test/Memex.Portal.Shared.Test/SeoOwnerReadContextTest.cs b/test/Memex.Portal.Shared.Test/SeoOwnerReadContextTest.cs deleted file mode 100644 index dd54edd838..0000000000 --- a/test/Memex.Portal.Shared.Test/SeoOwnerReadContextTest.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Reactive.Linq; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A permission fold can emit off the HTTP/circuit flow. The authoritative SEO read must still -/// carry the viewer who began resolution, rather than dropping the body at the posting guard. -/// -public class SeoOwnerReadContextTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => ConfigureMeshBase(builder) - .ConfigureHub(configuration => - { - var evaluate = configuration.Get() - ?? throw new InvalidOperationException("The fixture requires real row-level security."); - return configuration.WithPermissionEvaluator((hub, path, userId) => - evaluate(hub, path, userId).Do(_ => - { - if (userId != WellKnownUsers.Anonymous) - return; - // Model the scheduler boundary AFTER the real permission fold has decided. - // No verdict is replaced. A production server has no test-host fallback. - var access = hub.ServiceProvider.GetRequiredService(); - access.SetContext(null); - access.SetCircuitContext(null); - })); - }) - .AddMeshNodes( - new MeshNode("SeoContext") { NodeType = "Space" }, - AssignmentNodeFactory.Policy("SeoContext", new PartitionAccessPolicy { PublicRead = true }), - new MeshNode("Page", "SeoContext") - { - NodeType = "Markdown", Name = "Current title", - Content = new MarkdownContent { Content = "Current **body**" }, - PreRenderedHtml = "Stale body", - }, - new MeshNode("PrivateSeoContext") { NodeType = "Space" }); - - protected override Task SetupAccessRightsAsync() => Task.CompletedTask; - - [Theory] - [InlineData(WellKnownUsers.Anonymous, false)] - [InlineData("seo-viewer", false)] - [InlineData("seo-viewer", true)] - public async Task CurrentBodySurvivesTheGateEmissionWithoutAnAmbientIdentity(string userId, bool circuitOnly) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - access.ClearHostIdentity(); - var viewer = new AccessContext { ObjectId = userId, Name = userId }; - access.SetContext(circuitOnly ? null : viewer); - access.SetCircuitContext(circuitOnly ? viewer : null); - try - { - var result = await SeoResolver.Resolve(Mesh, "SeoContext/Page").Should().Emit(); - - Assert.NotNull(result); - Assert.Equal("Current title", result.Node.Name); - Assert.Equal("

Current body

\n", result.Body); - } - finally - { - access.SetContext(TestUsers.Admin); - access.SetHostIdentity(TestUsers.Admin); - } - } - - [Fact] - public async Task ASignedInViewerStillCannotPublishAPrivatePage() - { - var access = Mesh.ServiceProvider.GetRequiredService(); - access.ClearHostIdentity(); - access.SetContext(TestUsers.Admin); - try - { - Assert.Null(await SeoResolver.Resolve(Mesh, "PrivateSeoContext").Should().Emit()); - } - finally - { - access.SetContext(TestUsers.Admin); - access.SetHostIdentity(TestUsers.Admin); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/SeoPageBodyTest.cs b/test/Memex.Portal.Shared.Test/SeoPageBodyTest.cs deleted file mode 100644 index a1b03f7c91..0000000000 --- a/test/Memex.Portal.Shared.Test/SeoPageBodyTest.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins — the page text a crawler reads from the FIRST HTTP response. -/// Measured 2026-09-11 on memex.meshweaver.cloud (#4056): every public page shipped a rich head -/// and an empty body, because the only body source was the node's mirrored -/// , which a plugin cover never carries and a cross-partition -/// read drops. The body must therefore come from the CONTENT, in every shape content arrives in. -/// -public class SeoPageBodyTest -{ - private static MeshNode Node(object? content) => - new("Page", "Space") { Name = "Page", Content = content }; - - private static JsonElement Json(object value) => - JsonSerializer.SerializeToElement(value); - - [Fact] - public void ANodeWithoutSource_StillServesItsMirror() - { - var node = Node(null) with { PreRenderedHtml = "

mirrored

" }; - Assert.Equal("

mirrored

", new SeoPageData(node, null, null).Body); - } - - [Fact] - public void AnEmptyMirror_WithoutSource_FallsThroughToTheContentCache() - { - var node = Node(Json(new { prerenderedHtml = "

Title

" })) with { PreRenderedHtml = "" }; - Assert.Equal("

Title

", new SeoPageData(node, null, null).Body); - } - - [Theory] - [InlineData("markdown")] - [InlineData("json-content")] - [InlineData("json-content-pascal")] - [InlineData("body")] - [InlineData("json-body")] - [InlineData("string")] - [InlineData("json-string")] - [InlineData("json-object")] - [InlineData("json-value")] - public void AuthoredSource_WinsOverBothStaleHtmlCaches(string shape) - { - const string source = "Current **page**"; - const string stale = "

Outdated cache

"; - var content = SourceContent(shape, source, stale); - var node = Node(content) with { PreRenderedHtml = stale }; - var html = new SeoPageData(node, null, null).Body; - - Assert.Contains("Current page", html); - Assert.DoesNotContain("Outdated", html); - // The content cache cannot win when the transient node-level mirror is absent either. - Assert.Equal(html, new SeoPageData(node with { PreRenderedHtml = null }, null, null).Body); - } - - private sealed record Cover(string Body, string? PrerenderedHtml); - - private static object SourceContent(string shape, string source, string stale) => shape switch - { - "markdown" => new MarkdownContent { Content = source, PrerenderedHtml = stale }, - "json-content" => Json(new { content = source, prerenderedHtml = stale }), - "json-content-pascal" => Json(new { Content = source, PrerenderedHtml = stale }), - "body" => new Cover(source, stale), - "json-body" => Json(new { body = source, prerenderedHtml = stale }), - "string" => source, - "json-string" => Json(source), - "json-object" => new JsonObject { ["body"] = source, ["prerenderedHtml"] = stale }, - "json-value" => JsonValue.Create(source)!, - _ => throw new ArgumentOutOfRangeException(nameof(shape)), - }; - - [Fact] - public void TypedMarkdown_WithNoPrerender_IsRenderedNow() - { - var body = new SeoPageData(Node(new MarkdownContent { Content = "Hello **world**" }), null, null).Body; - Assert.NotNull(body); - Assert.Contains("world", body); - } - - [Theory] - [InlineData("markdown", "")] - [InlineData("json-content", "")] - [InlineData("json-content-pascal", "")] - [InlineData("body", "")] - [InlineData("json-body", "")] - [InlineData("string", "")] - [InlineData("json-string", "")] - [InlineData("json-object", "")] - [InlineData("json-value", "")] - [InlineData("markdown", " \n\t")] - [InlineData("json-body", " \n\t")] - public void ClearingTheSource_DoesNotResurrectCachedContent(string shape, string source) - { - const string stale = "

Deleted content

"; - var node = Node(SourceContent(shape, source, stale)) with { PreRenderedHtml = stale }; - Assert.True(string.IsNullOrWhiteSpace(new SeoPageData(node, null, null).Body)); - } - - [Fact] - public void EditingTheSource_UsesTheInteractiveRenderer_WithoutRefreshingCachedHtml() - { - var original = MarkdownContent.Parse("Original page", "Space/Page", "Space/Page"); - var node = Node(original) with { PreRenderedHtml = original.PrerenderedHtml }; - Assert.Contains("Original page", new SeoPageData(node, null, null).Body); - - const string changed = "## Current page\n\nSee [next](Next).\n\n
Authored layout
"; - node = node with { Content = original with { Content = changed } }; - var html = new SeoPageData(node, null, null).Body; - Assert.Equal(MarkdownViewLogic.Render(changed, node.Path, node.Path).Html, html); - Assert.Contains("Current page", html); - Assert.Contains("class=\"hero\"", html); - Assert.DoesNotContain("Original page", html); - } - - [Fact] - public void APluginCover_RendersItsBodyMarkdown() - { - // A store plugin's cover (a course, the reinsurance suite) is `PluginContent { body }` — - // markdown, never prerendered. This is the page a search for "agentic underwriting" must - // be able to read. - var node = Node(Json(new - { - body = "## Agentic underwriting\n\nTreaties, acceptances and sections as first-class nodes.", - price = 900, - })); - var body = new SeoPageData(node, null, null).Body; - Assert.NotNull(body); - Assert.Contains("Agentic underwriting", body); - Assert.Contains("

Treaties, acceptances and sections as first-class nodes.

", body); - } - - [Fact] - public void ADataNode_HasNoBody() - { - Assert.Null(new SeoPageData(Node(Json(new { amount = 12, currency = "CHF" })), null, null).Body); - Assert.Null(new SeoPageData(Node(null), null, null).Body); - } - - [Fact] - public void RenderBody_IsPure_AndRewritesLinksAgainstTheNodePath() - { - // Rendering goes through the portal's own markdown pipeline, anchored on the node — the - // same HTML a signed-in visitor sees, not a second renderer that drifts from it. - var html = SeoResolver.RenderBody(Node(Json(new { content = "See [next](Next)." }))); - Assert.NotNull(html); - Assert.Contains("", html); - } -} diff --git a/test/Memex.Portal.Shared.Test/SeoPublicAncestorCardTest.cs b/test/Memex.Portal.Shared.Test/SeoPublicAncestorCardTest.cs deleted file mode 100644 index 696403fc5f..0000000000 --- a/test/Memex.Portal.Shared.Test/SeoPublicAncestorCardTest.cs +++ /dev/null @@ -1,256 +0,0 @@ -using System.Threading.Tasks; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 WHAT A LINK INTO A GATED SUBTREE UNFURLS AS — the whole feature, gate included, on a live -/// mesh. -/// -/// The measurement that motivates it. On www.meshweaver.cloud, 2026-09-20: -/// /InitechReporting unfurled completely (og:title "Fund Reporting", a description, -/// og:image /api/og/InitechReporting.png) while every descendant — …/Funds, -/// …/Funds/InsuranceCore, …/Funds/InsuranceCore/2026-06-30 — fell back to -/// og:title "MeshWeaver" and /api/og.png. It was never depth: deep public pages -/// (/Doc/Architecture/AccessControl) unfurled fully on the same host at the same minute. It -/// was ACCESS — a public root over gated content — so the fixture below is that shape and not a -/// deep one: Reporting carries PublicRead, Reporting/Funds caps Read off, and -/// the nodes under it are the ones a share link names. -/// -/// Controls on BOTH sides. A public page still resolves exactly as it did -/// () — this feature must be invisible to every -/// page that already unfurled. A gated page under a public root gets the ancestor's card; a gated -/// page with NO public ancestor still gets nothing, which is what keeps the caller on the site -/// card. And the security control names the withheld nodes' own words and asserts they are in NO -/// part of the card — the one assertion that would catch this feature turning into a disclosure. -/// -public class SeoPublicAncestorCardTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - // The withheld nodes' own words. They are deliberately unmistakable: the security control - // searches the WHOLE card for them, so a substring of a real sentence would not do. - private const string WithheldFundName = "WITHHELD-FUND-NAME"; - private const string WithheldFundDescription = "WITHHELD-FUND-DESCRIPTION"; - private const string WithheldQuarterName = "WITHHELD-QUARTER-NAME"; - private const string WithheldQuarterDescription = "WITHHELD-QUARTER-DESCRIPTION"; - - private const string PublicRootDescription = "Quarterly fund reporting, one record per quarter."; - - // The usual test configuration grants Public Admin, which would make every path below readable - // and every control here vacuous. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder).AddMeshNodes( - // A PUBLIC listing over GATED content — the measured shape. - new MeshNode("Reporting") - { - NodeType = "Markdown", Name = "Fund Reporting", Description = PublicRootDescription, - }, - AssignmentNodeFactory.Policy("Reporting", new PartitionAccessPolicy - { - PublicRead = true, - // The owner SAYING there is a way in. This is what earns the call to action. - RedirectOnDenied = "Reporting/Subscribe", - }), - new MeshNode("Funds", "Reporting") - { - NodeType = "Markdown", Name = WithheldFundName, Description = WithheldFundDescription, - }, - AssignmentNodeFactory.Policy("Reporting/Funds", new PartitionAccessPolicy { Read = false }), - new MeshNode("InsuranceCore", "Reporting/Funds") - { - NodeType = "Markdown", Name = WithheldQuarterName, - Description = WithheldQuarterDescription, - }, - - // A public root with NO redirect: a card, but no advice that leads nowhere. - new MeshNode("Library") { NodeType = "Markdown", Name = "Library", Description = "Open shelves." }, - AssignmentNodeFactory.Policy("Library", new PartitionAccessPolicy { PublicRead = true }), - new MeshNode("Closed", "Library") { NodeType = "Markdown", Name = "WITHHELD-CLOSED-NAME" }, - AssignmentNodeFactory.Policy("Library/Closed", new PartitionAccessPolicy { Read = false }), - - // Private all the way up: no grant anywhere, so anonymous is denied by default. - new MeshNode("Vault") { NodeType = "Markdown", Name = "WITHHELD-VAULT-NAME" }, - new MeshNode("Ledger", "Vault") { NodeType = "Markdown", Name = "WITHHELD-LEDGER-NAME" }); - - private Task Page(string path) => - SeoResolver.Resolve(Mesh, path).Should().Emit(); - - private Task Card(string path, string? locale = null) => - SeoResolver.ResolvePublicAncestor(Mesh, path, locale).Should().Emit(); - - /// - /// THE REGRESSION CONTROL, and the other side of the change: a page the gate admits resolves to - /// its OWN card exactly as before — same title, same description, same drawn card — and never - /// reaches the ancestor walk at all. - /// - [Fact] - public async Task APublicPage_StillResolvesItsOwnCard() - { - var page = await Page("Reporting"); - - Assert.NotNull(page); - Assert.Equal("Fund Reporting", page.Node.Name); - Assert.Equal(PublicRootDescription, page.Description); - Assert.Equal("/api/og/Reporting.png", page.Image); - Assert.Null(page.Remainder); - } - - /// - /// The gated page is still withheld — this feature changes NOTHING about who may read what. It - /// changes only what is said about a page nobody may read. - /// - [Fact] - public async Task AGatedPage_IsStillWithheld() - { - Assert.Null(await Page("Reporting/Funds")); - Assert.Null(await Page("Reporting/Funds/InsuranceCore")); - } - - /// - /// 🚨 THE FEATURE: the nearest public ancestor's card, captioned with the path segments the - /// sharer pasted, and the partition's call to action because its policy declares a redirect. - /// - [Fact] - public async Task AGatedPageUnderAPublicRoot_GetsTheAncestorsCard() - { - var card = await Card("Reporting/Funds/InsuranceCore"); - - Assert.NotNull(card); - Assert.Equal("Reporting", card.AncestorPath); - Assert.Equal("Fund Reporting · Funds / InsuranceCore", card.Title); - Assert.Equal("/api/og/Reporting.png", card.Image); - Assert.Equal( - PublicRootDescription + " This page is not public. Follow the link to sign in or get access.", - card.Description); - } - - /// - /// 🚨 THE SECURITY CONTROL. The card is built from a node the gate ADMITTED plus the URL's own - /// segments; the withheld nodes' names and descriptions must appear in NO field of it. A tail - /// that reads "Funds / InsuranceCore" is the URL, which the sharer pasted and the reader is - /// already looking at — the node BEHIND those segments is called something else, and that is - /// what must not be here. - /// - [Fact] - public async Task TheWithheldNodesOwnWords_AppearNowhereOnTheCard() - { - var card = await Card("Reporting/Funds/InsuranceCore"); - - Assert.NotNull(card); - var everything = string.Join( - "\n", card.Title, card.Description ?? "", card.Image, card.AncestorPath); - foreach (var withheld in new[] - { - WithheldFundName, WithheldFundDescription, - WithheldQuarterName, WithheldQuarterDescription, - }) - { - Assert.DoesNotContain(withheld, everything, System.StringComparison.OrdinalIgnoreCase); - } - } - - /// - /// The redirect target is NOT on the card. It is the one piece of this that is not already in - /// the URL the sharer pasted, so printing it would disclose something new — and the link on the - /// card already goes there for whoever clicks it. - /// - [Fact] - public async Task TheRedirectTarget_IsNotNamedOnTheCard() - { - var card = await Card("Reporting/Funds/InsuranceCore"); - - Assert.NotNull(card); - Assert.DoesNotContain("Subscribe", card.Description ?? ""); - Assert.DoesNotContain("Subscribe", card.Title); - } - - /// - /// A public root that offers no way in gets the card and NO call to action: without a - /// nobody has published a route, and - /// "sign in" would be advice that leads nowhere. - /// - [Fact] - public async Task WithoutARedirectOnDenied_TheCardCarriesNoCallToAction() - { - var card = await Card("Library/Closed"); - - Assert.NotNull(card); - Assert.Equal("Library · Closed", card.Title); - Assert.Equal("Open shelves.", card.Description); - } - - /// - /// 🚨 THE HONEST FLOOR: nothing above it is public either, so there is no card — and the caller - /// keeps the site card it already had. A private page under a private root says only what its - /// URL already said. - /// - [Fact] - public async Task AGatedPageWithNoPublicAncestor_GetsNoCard() - { - Assert.Null(await Card("Vault/Ledger")); - } - - /// - /// A URL that matches no node at any prefix costs ONE resolution and stops. ResolvePath - /// already falls back to the nearest EXISTING ancestor, so "no resolution" means there is - /// nothing above it to find — not that the walk should start guessing at URL segments. - /// - [Fact] - public async Task APathThatMatchesNoNode_GetsNoCard() - { - Assert.Null(await Card("NoSuchPartition/NoSuchPage")); - } - - /// - /// The call to action is platform-owned text, so it follows the VIEWER's language — resolved - /// from the locale the caller reads off the AccessContext, never from an ambient culture. The - /// ancestor's own description is AUTHORED content and stays exactly as authored. - /// - [Fact] - public async Task TheCallToAction_FollowsTheViewersLanguage() - { - var card = await Card("Reporting/Funds/InsuranceCore", "de-CH"); - - Assert.NotNull(card); - Assert.Contains(PublicRootDescription, card.Description ?? ""); - Assert.Contains( - LocalizationCatalog.Get(SeoResolver.CallToActionKey, "de-CH"), card.Description ?? ""); - Assert.DoesNotContain("not public", card.Description ?? ""); - } - - /// - /// A layout-area route under a gated node — /{node}/{area}/{id} — shares the same card as - /// the node: the resolution falls back to the deepest node that EXISTS, and the walk starts - /// above THAT, so an area name in the URL cannot smuggle the walk past a gate. - /// - [Fact] - public async Task ALayoutAreaRouteUnderAGatedNode_GetsTheSameAncestorCard() - { - var card = await Card("Reporting/Funds/InsuranceCore/Holdings/main"); - - Assert.NotNull(card); - Assert.Equal("Reporting", card.AncestorPath); - Assert.Equal("Fund Reporting · Funds / InsuranceCore / Holdings / main", card.Title); - } - - /// - /// The composition is PURE and is given only a page the gate admitted: no ancestor description - /// means the card carries the call to action alone rather than an empty one, and a request for - /// the ancestor ITSELF carries no caption to add. - /// - [Fact] - public void ComposeAncestorCard_HandlesTheEdgesWithoutInventingText() - { - var bare = new SeoPageData( - new MeshNode("Root") { Name = "Root", NodeType = "Markdown" }, null, "/api/og/Root.png"); - - Assert.Equal("Root · Deep / Page", SeoResolver.ComposeAncestorCard(bare, "Root/Deep/Page", null).Title); - Assert.Null(SeoResolver.ComposeAncestorCard(bare, "Root/Deep", null).Description); - Assert.Equal("go on", SeoResolver.ComposeAncestorCard(bare, "Root/Deep", "go on").Description); - Assert.Equal("Root", SeoResolver.ComposeAncestorCard(bare, "Root", null).Title); - } -} diff --git a/test/Memex.Portal.Shared.Test/SeoPublicPreviewOptInTest.cs b/test/Memex.Portal.Shared.Test/SeoPublicPreviewOptInTest.cs deleted file mode 100644 index b1d90c15f9..0000000000 --- a/test/Memex.Portal.Shared.Test/SeoPublicPreviewOptInTest.cs +++ /dev/null @@ -1,383 +0,0 @@ -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 THE OPT-IN: , which lets a page whose content -/// stays GATED describe ITSELF in a link preview — its own name, its own authored summary, its own -/// mark — to whoever holds the URL. -/// -/// Why it is an opt-in and not a behaviour. A blanket version of this would be a -/// disclosure surface wearing a feature's colours — the same objection that made /health print -/// the PARTITION rather than the node's own name (#4258/#3890). The difference here is consent: the -/// owner of the data states it, per scope, in the same _Policy that governs everything else -/// about that subtree. So the control that matters most in this file is the NEGATIVE one — with the -/// flag unset, which is every partition until somebody sets it, nothing changes at all. -/// -/// What it is measured against. A link into a partition that is gated all the way up: -/// on a live control instance, /Initech/LocalHardwareOffer AND /Initech both unfurled as the -/// bare site card, so there was no public ancestor anywhere on that chain and the ancestor fallback -/// () correctly had nothing to offer. The fixture below is that shape: -/// Offers has no grant of any kind, and only the flag makes its pages say anything. -/// -public class SeoPublicPreviewOptInTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string OfferName = "Local Hardware Offer"; - private const string OfferSummary = "Hardware, sourced locally, quoted per quarter."; - private const string OfferBody = "BODY-THAT-MUST-NEVER-BE-DISCLOSED"; - private const string SealedName = "SEALED-NAME"; - private const string Mark = - "" - + ""; - - // The usual test configuration grants Public Admin, which would make every path here readable - // and every control in this file vacuous. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder).AddMeshNodes( - // OPTED IN: gated all the way up — no grant anywhere — and the owner has said its page - // names and summaries may travel. - new MeshNode("Offers") { NodeType = "Markdown", Name = "Offers" }, - AssignmentNodeFactory.Policy("Offers", new PartitionAccessPolicy - { - PublicPreview = true, - RedirectOnDenied = "Offers/Subscribe", - }), - new MeshNode("LocalHardware", "Offers") - { - NodeType = "Markdown", Name = OfferName, Description = OfferSummary, Icon = Mark, - Content = System.Text.Json.JsonSerializer.SerializeToElement( - new { content = OfferBody }), - }, - // 🚨 The shapes a review found this feature broken for, and it is the COMMON pair: a store - // plugin authors its og image as /api/content/… and its icon as a content: reference. - // Both routes still require Read, so declaring either for a gated node promises a picture - // that 404s — worse than none, because unfurlers drop a preview whose image fails. - new MeshNode("Authored", "Offers") - { - NodeType = "Markdown", Name = "Authored Offer", Description = "Authored art.", - Icon = "content:brand/mark.svg", - Content = System.Text.Json.JsonSerializer.SerializeToElement( - new { ogImage = "/api/content/Offers/content/og.png" }), - }, - // The other side: an image on SOMEBODY ELSE's host is fetchable on its own terms. - new MeshNode("Offsite", "Offers") - { - NodeType = "Markdown", Name = "Offsite Offer", - Content = System.Text.Json.JsonSerializer.SerializeToElement( - new { ogImage = "https://cdn.example.org/offer.png" }), - }, - - // A deeper scope opting back OUT of the inherited flag. - new MeshNode("Sealed", "Offers") { NodeType = "Markdown", Name = SealedName }, - AssignmentNodeFactory.Policy("Offers/Sealed", - new PartitionAccessPolicy { PublicPreview = false }), - new MeshNode("Bid", "Offers/Sealed") { NodeType = "Markdown", Name = "SEALED-BID-NAME" }, - - // NOT opted in — the control. Same shape, no flag anywhere. - new MeshNode("Vault") { NodeType = "Markdown", Name = "VAULT-NAME" }, - new MeshNode("Ledger", "Vault") - { - NodeType = "Markdown", Name = "LEDGER-NAME", Description = "LEDGER-SUMMARY", - Icon = Mark, - }, - - // Public, for the no-regression side: a readable page must behave exactly as before. - new MeshNode("Open") { NodeType = "Markdown", Name = "Open", Description = "Open page." }, - AssignmentNodeFactory.Policy("Open", new PartitionAccessPolicy { PublicRead = true })); - - private Task Preview(string path, string? locale = null) => - SeoResolver.ResolvePreview(Mesh, path, locale).Should().Emit(); - - private Task Shareable(string path) => - SeoResolver.ResolveShareableNode(Mesh, path).Should().Emit(); - - private Task Page(string path) => SeoResolver.Resolve(Mesh, path).Should().Emit(); - - private Task Flag(string path) => Mesh.GetPublicPreview(path).Should().Emit(); - - /// - /// 🚨 THE FEATURE, in the user's own words: couldn't it say the name of the node? And the - /// description? And the icon? On an opted-in scope it does — and the page is STILL gated. - /// - [Fact] - public async Task AnOptedInGatedPage_DescribesItself() - { - var card = await Preview("Offers/LocalHardware"); - - Assert.NotNull(card); - Assert.Equal(OfferName, card.Title); - Assert.Equal("Offers/LocalHardware", card.NodePath); - Assert.StartsWith(OfferSummary, card.Description); - // Its OWN card, not an ancestor's and not the site's. - Assert.Equal("/api/og/Offers/LocalHardware.png", card.Image); - // Its OWN mark: the svg data URI plus the two raster channels Safari needs. - Assert.NotEmpty(card.Icons); - Assert.Contains(card.Icons, i => i.Href.StartsWith("data:image/svg+xml", System.StringComparison.Ordinal)); - Assert.Contains(card.Icons, i => i.Href == "/api/icon/Offers/LocalHardware.png?size=32"); - } - - /// - /// 🚨 THE CONTROL THAT MATTERS MOST: with no flag anywhere, every answer is what it was before - /// this feature existed — no preview card, no shareable node for the image routes, and the page - /// still withheld. A node with a name, a summary AND a mark is used, so the test would fail if - /// any of the three leaked. - /// - [Fact] - public async Task WithoutTheFlag_NothingIsDisclosed() - { - Assert.False(await Flag("Vault/Ledger")); - Assert.Null(await Preview("Vault/Ledger")); - Assert.Null(await Shareable("Vault/Ledger")); - Assert.Null(await Page("Vault/Ledger")); - } - - /// - /// The page stays GATED on an opted-in scope — the flag is a disclosure decision, never a read - /// grant. is what every content-serving consumer asks, and it - /// still refuses, which is also what keeps the node out of the published surface and the sitemap - /// (PublicSite reads that same call). - /// - [Fact] - public async Task OptingIn_GrantsNoRead() - { - Assert.True(await Flag("Offers/LocalHardware")); - Assert.Null(await Page("Offers/LocalHardware")); - } - - /// - /// 🚨 THE BODY IS NEVER DISCLOSED, and the card is structurally incapable of carrying it: a - /// holds four strings and the icon links, no - /// and no — so the crawler-facing body - /// component, which renders a SeoPageData, has nothing to render from. The summary that - /// IS disclosed is an authored summary: reads six - /// summary members and never content. - /// - [Fact] - public async Task TheBodyIsNeverDisclosed_EvenWhenOptedIn() - { - var card = await Preview("Offers/LocalHardware"); - - Assert.NotNull(card); - Assert.DoesNotContain(OfferBody, card.Description ?? ""); - Assert.DoesNotContain(OfferBody, card.Title); - // And the one type that CAN carry a body is still refused for this path. - Assert.Null(await Page("Offers/LocalHardware")); - } - - /// - /// The flag inherits down a partition, nearest scope first — so a root opts its whole subtree in - /// with one line, and a deeper scope opts back OUT with an explicit false, which then - /// governs everything under IT too. - /// - [Fact] - public async Task TheFlagInheritsDown_AndADeeperScopeCanOptBackOut() - { - Assert.True(await Flag("Offers")); - Assert.True(await Flag("Offers/LocalHardware")); - Assert.False(await Flag("Offers/Sealed")); - Assert.False(await Flag("Offers/Sealed/Bid")); - } - - /// - /// The opted-out subtree discloses nothing — the names under it never reach a card. - /// - [Fact] - public async Task AnOptedOutSubtree_DisclosesNothing() - { - Assert.Null(await Preview("Offers/Sealed")); - Assert.Null(await Preview("Offers/Sealed/Bid")); - Assert.Null(await Shareable("Offers/Sealed/Bid")); - } - - /// - /// 🚨 THE PICTURE HAS TO BE FETCHABLE. Both image routes ask - /// — the same one predicate the head's card block - /// asks — so a previewed page's declared og:image and icon actually resolve. Without this - /// the head would promise a card that 404s, and several unfurlers drop the whole preview when the - /// image they were promised does not fetch. - /// - [Fact] - public async Task TheImageRoutesServeAPreviewedNode_SoTheDeclaredCardFetches() - { - var cleared = await Shareable("Offers/LocalHardware"); - - Assert.NotNull(cleared); - Assert.Equal("Offers/LocalHardware", cleared.Node.Path); - // Cleared by the POLICY, not by the gate — which is what decides its cache directive. - Assert.False(cleared.AnonymousReadable); - // The endpoint's own mapping, from the node the predicate cleared: name and authored summary - // reach the drawn card, and nothing else can. - var card = SeoEndpoints.CardContent(cleared.Node); - Assert.Equal(OfferName, card.Title); - Assert.Equal(OfferSummary, card.Description); - Assert.DoesNotContain(OfferBody, card.Description ?? ""); - // And the icon route answers with a real PNG rather than the 404 it gave before. - var icon = SeoEndpoints.IconResult( - new DefaultHttpContext(), cleared.Node, 32, null, cleared.AnonymousReadable); - Assert.IsType(icon); - } - - /// - /// A readable page is untouched by any of this: same card, same description, same drawn image, - /// and it never consults the flag (it cannot tell whether one is set). - /// - [Fact] - public async Task APublicPage_IsUnaffected() - { - var page = await Page("Open"); - - Assert.NotNull(page); - Assert.Equal("Open", page.Node.Name); - Assert.Equal("Open page.", page.Description); - Assert.Equal("/api/og/Open.png", page.Image); - // The preview surface answers null for it — a readable page has no use for a preview card, - // and the caller already has Resolve. - Assert.Null(await Preview("Open")); - // The image routes still serve it, as they always did. - Assert.NotNull(await Shareable("Open")); - } - - /// - /// The partition's call to action rides along when its policy declares a - /// — the same localized sentence and the - /// same key the ancestor card uses, so a previewed page tells the reader there is a way in. The - /// redirect TARGET is not named: it is not in the URL the sharer pasted. - /// - [Fact] - public async Task TheCallToAction_RidesAlong_AndFollowsTheViewersLanguage() - { - var english = await Preview("Offers/LocalHardware"); - var german = await Preview("Offers/LocalHardware", "de-CH"); - - Assert.NotNull(english); - Assert.NotNull(german); - Assert.Contains(LocalizationCatalog.Get(SeoResolver.CallToActionKey, null), english.Description ?? ""); - Assert.Contains(LocalizationCatalog.Get(SeoResolver.CallToActionKey, "de-CH"), german.Description ?? ""); - Assert.DoesNotContain("Subscribe", english.Description ?? ""); - } - - /// - /// A path that matches no node discloses nothing and costs no policy read — there is no node to - /// describe, and the flag is never consulted for one. - /// - [Fact] - public async Task APathThatMatchesNoNode_DisclosesNothing() - { - Assert.Null(await Preview("NoSuchPartition/NoSuchPage")); - Assert.Null(await Shareable("NoSuchPartition/NoSuchPage")); - } - - /// - /// The composition is PURE and takes the node, so what a previewed page can say is decided in - /// one place: no summary means the card carries the call to action alone rather than an empty - /// string, and a node with no mark yields no icon links rather than a synthesised one. - /// - [Fact] - public void ComposePreviewCard_InventsNothing() - { - var bare = new MeshNode("Offers/Bare") { NodeType = "Markdown", Name = "Bare" }; - - var alone = SeoResolver.ComposePreviewCard(bare, null); - Assert.Equal("Bare", alone.Title); - Assert.Null(alone.Description); - Assert.Empty(alone.Icons); - - Assert.Equal("go on", SeoResolver.ComposePreviewCard(bare, "go on").Description); - } - - /// - /// 🚨 A REVIEW FINDING, KEPT AS A TEST: an authored image on the portal's own - /// /api/content/… route is Read-gated, which this opt-in deliberately does not open - /// (that route serves file BYTES, not the four strings the flag consents to). Declaring it for a - /// gated node would promise an og:image anonymous unfurlers get a 404 for — worse than no - /// card, since several then drop the preview entirely. The preview therefore falls back to the - /// DRAWN card, which the same flag does serve. - /// - [Fact] - public async Task AnAuthoredImageOnTheContentRoute_FallsBackToTheDrawnCard() - { - var card = await Preview("Offers/Authored"); - - Assert.NotNull(card); - Assert.Equal("/api/og/Offers/Authored.png", card.Image); - Assert.DoesNotContain("/api/content/", card.Image); - // The card still says what it is — only the picture changed. - Assert.Equal("Authored Offer", card.Title); - Assert.StartsWith("Authored art.", card.Description); - } - - /// - /// The other side of that control: an ABSOLUTE authored image is another host's bytes, so it is - /// not replaced by the drawn card — it is RE-SERVED from this origin as /api/og/{node}.jpg, - /// exactly as a public page re-serves it (see ), and that route - /// answers under the same PublicPreview flag. Declared raw, such a picture unfurled in WhatsApp as - /// an empty large card. - /// - [Fact] - public async Task AnAbsoluteAuthoredImage_IsReServedFromThisOrigin() - { - var card = await Preview("Offers/Offsite"); - - Assert.NotNull(card); - Assert.Equal("/api/og/Offers/Offsite.jpg", card.Image); - Assert.Equal("image/jpeg", SeoResolver.CardMediaType(card.Image)); - } - - /// - /// 🚨 THE SECOND REVIEW FINDING: a content: icon resolves to /api/content/…, still - /// Read-gated, and ResolveIconLinks yields that ONE link and no raster channels for - /// it — so a previewed page would publish exactly one icon link and it would be broken. Such a - /// node gets NO icon link, which is the honest fallback the icon route already documents: the - /// portal favicon stays rather than a link that 404s. - /// - [Fact] - public async Task AContentBackedIcon_YieldsNoIconLinkAtAll() - { - var card = await Preview("Offers/Authored"); - - Assert.NotNull(card); - Assert.Empty(card.Icons); - Assert.DoesNotContain(card.Icons, i => i.Href.Contains("/api/content/", System.StringComparison.Ordinal)); - } - - /// - /// 🚨 THE CACHE DIRECTIVE FOLLOWS THE DECISION, because a preview is REVOCABLE and a shared cache - /// never re-asks the origin: a day of public, max-age would leave a withdrawn disclosure - /// publicly retrievable after its owner withdrew it. A gate-admitted node keeps the shared - /// directive it has always had. - /// - [Fact] - public async Task APreviewedPictureIsNotSharedCacheable_AndAPublicOneStillIs() - { - var previewed = await Shareable("Offers/LocalHardware"); - var open = await Shareable("Open"); - - Assert.NotNull(previewed); - Assert.NotNull(open); - Assert.False(previewed.AnonymousReadable); - Assert.True(open.AnonymousReadable); - - // The endpoint's OWN header decision, reached from the node it cleared — not a copy of the - // rule beside it. - var previewHttp = new DefaultHttpContext(); - SeoEndpoints.IconResult(previewHttp, previewed.Node, 32, null, previewed.AnonymousReadable); - Assert.Equal("private, no-store", previewHttp.Response.Headers.CacheControl.ToString()); - - var publicHttp = new DefaultHttpContext(); - SeoEndpoints.IconResult(publicHttp, WithMark(open.Node), 32, null, open.AnonymousReadable); - Assert.Equal("public, max-age=86400", publicHttp.Response.Headers.CacheControl.ToString()); - } - - /// The public fixture node carries no mark, so give it one for the header assertion — - /// IconResult 404s (and sets no cache header) for a node it cannot draw. - private static MeshNode WithMark(MeshNode node) => node with { Icon = Mark }; -} diff --git a/test/Memex.Portal.Shared.Test/SeoResolverContentTest.cs b/test/Memex.Portal.Shared.Test/SeoResolverContentTest.cs deleted file mode 100644 index c7de624b8d..0000000000 --- a/test/Memex.Portal.Shared.Test/SeoResolverContentTest.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Text.Json; -using Memex.Portal.Shared.Seo; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins 's content-member reads across BOTH content shapes. A node's -/// Content reaches the SEO surface either as an untyped (node-native -/// types the portal hub hasn't registered) or as a TYPED record when the hub knows the CLR type — -/// a markdown page resolves as . Reading only the JsonElement shape -/// silently dropped og:image for every markdown node's thumbnail (UWDeepfield / -/// ClaimsDeepfield Overview share cards, 2026-07). -/// -public class SeoResolverContentTest -{ - private sealed record FakePluginContent(string? Poster, decimal? Price); - private sealed record FakeShareOverrides(string? OgTitle, string? OgDescription); - private sealed record FakeSocialPost(string Text, string? MediaUrl); - - private static MeshNode Node(object? content, string? description = null) => - new("Overview", "Space") { NodeType = "Markdown", Description = description, Content = content }; - - private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value); - - [Fact] - public void ExtractImage_UntypedJson_ReadsPosterThenThumbnail() - { - Assert.Equal("/api/content/S/p.png", SeoResolver.ExtractImage(Node(Json(new { poster = "/api/content/S/p.png" })))); - Assert.Equal("/api/content/S/t.png", SeoResolver.ExtractImage(Node(Json(new { thumbnail = "/api/content/S/t.png" })))); - } - - [Fact] - public void ExtractImage_SocialPost_ReadsMediaUrl() - { - const string media = "https://cdn.example.com/post-images/visual.png"; - Assert.Equal(media, SeoResolver.ExtractImage(Node(Json(new { text = "post", mediaUrl = media })))); - Assert.Equal(media, SeoResolver.ExtractImage(Node(new FakeSocialPost("post", media)))); - // An explicitly authored share image still wins over the post's visual. - Assert.Equal("/api/content/S/og.png", - SeoResolver.ExtractImage(Node(Json(new { ogImage = "/api/content/S/og.png", mediaUrl = media })))); - } - - [Fact] - public void ShareTitleAndDescription_DefaultToTheNode() - { - var node = Node(Json(new { text = "body" }), description: "The node's own summary.") with { Name = "Node Name" }; - - Assert.Equal("Node Name", SeoResolver.ShareTitle(node)); - Assert.Equal("The node's own summary.", SeoResolver.ShareDescription(node)); - Assert.Equal("Overview", SeoResolver.ShareTitle(Node(Json(new { text = "body" })))); - } - - [Fact] - public void ShareTitleAndDescription_AuthoredOverridesWin_PageDescriptionUnchanged() - { - var node = Node(Json(new { ogTitle = "Share Headline", ogDescription = "Share blurb." }), - description: "The node's own summary.") with { Name = "Node Name" }; - - Assert.Equal("Share Headline", SeoResolver.ShareTitle(node)); - Assert.Equal("Share blurb.", SeoResolver.ShareDescription(node)); - // The search snippet keeps the page's own description; the override is for the unfurl. - Assert.Equal("The node's own summary.", SeoResolver.ExtractDescription(node)); - - var typed = Node(new FakeShareOverrides("Typed Headline", "Typed blurb.")) with { Name = "Node Name" }; - Assert.Equal("Typed Headline", SeoResolver.ShareTitle(typed)); - Assert.Equal("Typed blurb.", SeoResolver.ShareDescription(typed)); - } - - [Fact] - public void ShareOverrides_ReachThePreviewAndAncestorCards() - { - var node = Node(Json(new { ogTitle = "Share Headline", ogDescription = "Share blurb." }), - description: "The node's own summary.") with { Name = "Node Name" }; - - var preview = SeoResolver.ComposePreviewCard(node, null); - Assert.Equal("Share Headline", preview.Title); - Assert.Equal("Share blurb.", preview.Description); - - var ancestor = SeoResolver.ComposeAncestorCard( - new SeoPageData(node, SeoResolver.ExtractDescription(node), null), "Space/Overview/Child", null); - Assert.Equal("Share Headline · Child", ancestor.Title); - Assert.Equal("Share blurb.", ancestor.Description); - } - - [Fact] - public void ExtractImage_TypedMarkdownContent_ReadsThumbnail() - { - var content = new MarkdownContent { Content = "# page", Thumbnail = "/api/content/S/videos/x.poster.png" }; - - Assert.Equal("/api/content/S/videos/x.poster.png", SeoResolver.ExtractImage(Node(content))); - } - - [Fact] - public void ExtractImage_TypedContent_ReadsPoster() - { - Assert.Equal("/api/content/S/og.png", SeoResolver.ExtractImage(Node(new FakePluginContent("/api/content/S/og.png", 900m)))); - } - - [Fact] - public void ExtractImage_RejectsNonRootedCandidates_BothShapes() - { - Assert.Null(SeoResolver.ExtractImage(Node(Json(new { thumbnail = "relative.png" })))); - Assert.Null(SeoResolver.ExtractImage(Node(new MarkdownContent { Content = "x", Thumbnail = "relative.png" }))); - } - - [Fact] - public void ExtractImage_NullAndMemberlessContent_ReturnNull() - { - Assert.Null(SeoResolver.ExtractImage(Node(null))); - Assert.Null(SeoResolver.ExtractImage(Node(Json(new { other = 1 })))); - Assert.Null(SeoResolver.ExtractImage(Node(new MarkdownContent { Content = "x" }))); - } - - /// - /// A catalog root carries sales copy, not a description: the Store's root has a - /// headline and a tagline and nothing else, so its og:description and its - /// share card were empty (2026-09-18). The tagline is the sentence, the headline the slogan; - /// the sentence reads better under a title, so it is preferred. An authored description still - /// beats both. - /// - [Fact] - public void ExtractDescription_FallsBackToTagline_ThenHeadline() - { - Assert.Equal("Courses, plugins and tools.", - SeoResolver.ExtractDescription(Node(Json(new { headline = "Everything that plugs in", tagline = "Courses, plugins and tools." })))); - Assert.Equal("Everything that plugs in", - SeoResolver.ExtractDescription(Node(Json(new { headline = "Everything that plugs in" })))); - Assert.Equal("The one-line summary.", - SeoResolver.ExtractDescription(Node(Json(new { summary = "The one-line summary.", headline = "Slogan" })))); - Assert.Equal("Authored.", - SeoResolver.ExtractDescription(Node(Json(new { description = "Authored.", tagline = "Copy." })))); - Assert.Equal("Node desc", - SeoResolver.ExtractDescription(Node(Json(new { tagline = "Copy." }), description: "Node desc"))); - } - - [Fact] - public void ExtractDescription_TypedMarkdownContent_FallsBackToAbstract() - { - var content = new MarkdownContent { Content = "# page", Abstract = "The one-line summary." }; - - Assert.Equal("The one-line summary.", SeoResolver.ExtractDescription(Node(content))); - // The node's own Description column still wins over the content member. - Assert.Equal("Node desc", SeoResolver.ExtractDescription(Node(content, description: "Node desc"))); - } - - [Fact] - public void ContentDecimal_BothShapes_ReadPrice() - { - Assert.Equal(900m, SeoResolver.ContentDecimal(Node(Json(new { price = 900 })), "price")); - Assert.Equal(900m, SeoResolver.ContentDecimal(Node(new FakePluginContent(null, 900m)), "price")); - Assert.Null(SeoResolver.ContentDecimal(Node(new FakePluginContent(null, null)), "price")); - } -} diff --git a/test/Memex.Portal.Shared.Test/SeoResolverIconTest.cs b/test/Memex.Portal.Shared.Test/SeoResolverIconTest.cs deleted file mode 100644 index b0b285ab6e..0000000000 --- a/test/Memex.Portal.Shared.Test/SeoResolverIconTest.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Memex.Portal.Shared.Seo; -using MeshWeaver.Mesh; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins — the page-icon channel of the SEO head. -/// -/// 🚨 THE DEFECT: every page of a Blazor portal serves ONE site-wide -/// <link rel="icon">, so every link preview of every node — the portal's own -/// OgCard grid, and equally a Slack / Teams / LinkedIn unfurl — drew the same MeshWeaver -/// logo, even though each node already carries a distinctive icon that the app renders everywhere -/// internally. -/// -/// The rule these tests pin is deliberately narrow: it is THE ICON ON THE NODE, and nothing -/// else. No synthesised badge, no letter tile, no NodeType stand-in — a node that carries no icon -/// of its own keeps the portal favicon, which is the honest answer for a page with no mark. -/// -public class SeoResolverIconTest -{ - private static MeshNode Node(string? icon, string id = "Ifrs17", string? ns = null) => - new(id, ns) { NodeType = "Store/Plugin", Icon = icon }; - - /// - /// The real shape the failing cards had: an inline <svg> on the node. It is markup, - /// not a location, so it travels as a data URI — and the svg inside it is the node's icon - /// UNCHANGED except for the one thing a standalone document needs: the SVG namespace. A - /// favicon is an image-loaded SVG, parsed as XML, and without xmlns it renders nothing. - /// - [Fact] - public void InlineSvgIcon_BecomesADataUriCarryingThatExactSvg() - { - const string svg = ""; - - var icon = SeoResolver.ResolveIcon(Node(svg)); - - Assert.NotNull(icon); - Assert.Equal("image/svg+xml", icon.Type); - Assert.StartsWith("data:image/svg+xml,", icon.Href); - Assert.Equal( - "", - Uri.UnescapeDataString(icon.Href["data:image/svg+xml,".Length..])); - } - - /// A content: reference resolves the SAME way the in-app icon does — through - /// the access-controlled content route, never /static. - [Fact] - public void ContentReferenceIcon_ResolvesToTheAccessControlledContentUrl() - { - var icon = SeoResolver.ResolveIcon(Node("content:mark.svg", id: "Page", ns: "Space")); - - Assert.NotNull(icon); - Assert.Contains("mark.svg", icon.Href); - Assert.DoesNotContain("/static/", icon.Href); - Assert.Equal("image/svg+xml", icon.Type); - } - - /// A URL the node carries is used as written; the media type is declared only when the - /// value pins one down, because a consumer that ranks icons by type must not be told a lie. - [Fact] - public void UrlIcon_UsedAsWritten_TypeOnlyWhenTheValuePinsItDown() - { - var png = SeoResolver.ResolveIcon(Node("https://cdn.example.org/mark.png")); - Assert.NotNull(png); - Assert.Equal("https://cdn.example.org/mark.png", png.Href); - Assert.Null(png.Type); - - var svg = SeoResolver.ResolveIcon(Node("https://cdn.example.org/mark.svg")); - Assert.Equal("image/svg+xml", svg!.Type); - - var dataPng = SeoResolver.ResolveIcon(Node("data:image/png;base64,iVBORw0KGgo=")); - Assert.Null(dataPng!.Type); - } - - /// - /// 🚨 Nothing is invented. A node with no icon, an emoji, or a legacy Fluent icon NAME yields - /// no page icon at all — the portal favicon stays. Emitting a drawn badge or a NodeType glyph - /// here would put a picture in the head that the node never chose. - /// - [Fact] - public void NoIconOfItsOwn_YieldsNothing_SoThePortalFaviconStays() - { - Assert.Null(SeoResolver.ResolveIcon(Node(null))); - Assert.Null(SeoResolver.ResolveIcon(Node(""))); - Assert.Null(SeoResolver.ResolveIcon(Node(" "))); - // An emoji is a character, not a picture — no href can carry it. - Assert.Null(SeoResolver.ResolveIcon(Node("📊"))); - // A legacy Fluent icon name is a component reference, not a location. - Assert.Null(SeoResolver.ResolveIcon(Node("Document"))); - } -} diff --git a/test/Memex.Portal.Shared.Test/ServedActivationIsMaintainedNotScannedTest.cs b/test/Memex.Portal.Shared.Test/ServedActivationIsMaintainedNotScannedTest.cs deleted file mode 100644 index 175b515718..0000000000 --- a/test/Memex.Portal.Shared.Test/ServedActivationIsMaintainedNotScannedTest.cs +++ /dev/null @@ -1,235 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Reactive.Assertions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#4963 — the registry's bundle index must read a MAINTAINED activation list, not -/// derive it from the volume on every request. -/// -/// Measured on the fleet registry (memex.meshweaver.cloud, 2026-09-26): one derivation of -/// the activation list over its 44 modules on the Azure Files share took 7.3 s and 8.7 s, and -/// /api/plugins/bundles/index.json ran it TWICE per request — ~16 s of the 17.8 s a -/// consumer measured for one index read, for a list that had not changed between any two -/// requests. is the index's reader now. -/// -/// Deterministic: two landing services on one root stand in for two replicas on the shared -/// volume (the #4026 shape), and the freshness bound is measured on a test clock the test moves. -/// "Not re-derived" is observed as what a re-derivation could not have answered: a record another -/// replica wrote is ON the volume (the authoritative read sees it) and the served read does not -/// report it until the snapshot is due. -/// -public class ServedActivationIsMaintainedNotScannedTest : IDisposable -{ - private const string Module = "MeshWeaver.Test.ServedActivation"; - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-served-activation-" + Guid.NewGuid().ToString("N")); - - /// Creates the shared landing root. - public ServedActivationIsMaintainedNotScannedTest() => Directory.CreateDirectory(root); - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - /// - /// A served read answers from the snapshot: a landing another replica recorded since is on - /// the volume, and the served read does not rescan to find it while the snapshot is fresh. - /// - [Fact] - public async Task AServedReadDoesNotRescanTheVolume() - { - var ct = TestContext.Current.CancellationToken; - var clock = new TestClock(); - using var registry = new ModuleLandingService(baseDirectory: root) { ServedActivationClock = clock }; - using var otherReplica = new ModuleLandingService(baseDirectory: root); - - var primed = await registry.GetServedActivation().Should().Within(TestTimeouts.Convergence) - .Emit("the first served read derives the list", cancellationToken: ct); - Assert.DoesNotContain(primed.Entries, IsTheModule); - - await Shelve(otherReplica, "1.0.0").Should().Within(TestTimeouts.Convergence) - .Emit("the other replica's landing must complete", cancellationToken: ct); - - // Positive control: the landing IS on the volume, so a derivation would report it. - var authoritative = await registry.GetActivation().Should().Within(TestTimeouts.Convergence) - .Emit("the authoritative read derives the list", cancellationToken: ct); - Assert.Contains(authoritative.Entries, IsTheModule); - - // 🚨 THE ASSERTION. Twice, because the index used to read twice per request. - for (var i = 0; i < 2; i++) - { - var served = await registry.GetServedActivation().Should().Within(TestTimeouts.Quick) - .Emit("a served read answers from the maintained snapshot", cancellationToken: ct); - Assert.DoesNotContain(served.Entries, IsTheModule); - } - } - - /// - /// A landing THIS replica makes invalidates its snapshot before it is announced, so the replica - /// that took a publish serves it on the very next read — no freshness wait. - /// - [Fact] - public async Task ThisReplicasOwnLandingIsServedAtOnce() - { - var ct = TestContext.Current.CancellationToken; - var clock = new TestClock(); - using var registry = new ModuleLandingService(baseDirectory: root) { ServedActivationClock = clock }; - - var primed = await registry.GetServedActivation().Should().Within(TestTimeouts.Convergence) - .Emit("the first served read derives the list", cancellationToken: ct); - Assert.DoesNotContain(primed.Entries, IsTheModule); - - await Shelve(registry, "1.0.0").Should().Within(TestTimeouts.Convergence) - .Emit("the landing must complete", cancellationToken: ct); - - var served = await registry.GetServedActivation().Should().Within(TestTimeouts.Convergence) - .Emit("the served read after this replica's own landing", cancellationToken: ct); - var head = Assert.Single(served.Entries, IsTheModule); - Assert.Equal("1.0.0", head.Version); - } - - /// - /// Another replica's landing reaches the served list once the snapshot is due: the first read - /// past the bound still answers at once (from the snapshot) and starts the one re-derivation; - /// the list then carries the landing. - /// - [Fact] - public async Task AnotherReplicasLandingIsServedOnceTheSnapshotIsDue() - { - var ct = TestContext.Current.CancellationToken; - var clock = new TestClock(); - using var registry = new ModuleLandingService(baseDirectory: root) { ServedActivationClock = clock }; - using var otherReplica = new ModuleLandingService(baseDirectory: root); - - await registry.GetServedActivation().Should().Within(TestTimeouts.Convergence) - .Emit("the first served read derives the list", cancellationToken: ct); - await Shelve(otherReplica, "1.0.0").Should().Within(TestTimeouts.Convergence) - .Emit("the other replica's landing must complete", cancellationToken: ct); - - clock.Advance(registry.ServedActivationFreshness); - - var stale = await registry.GetServedActivation().Should().Within(TestTimeouts.Quick) - .Emit("a due snapshot still answers at once — revalidation runs beside the answer", - cancellationToken: ct); - Assert.DoesNotContain(stale.Entries, IsTheModule); - - // The re-derivation the due read started lands in the snapshot; wait on that condition, - // re-reading the served list (a request/response source), never on a sleep. - await Observable.Interval(TimeSpan.FromMilliseconds(50)).StartWith(0L) - .SelectMany(_ => registry.GetServedActivation()) - .Where(list => list.Entries.Any(IsTheModule)) - .Should().Within(TestTimeouts.Convergence) - .Emit("the re-derivation the due read started must reach the served list", - cancellationToken: ct); - } - - /// - /// 🚨 A derivation that finishes LATE never evicts its successor (Copilot's review of #5768): - /// generation 0's derivation is parked, a local landing moves to generation 1 and a reader - /// starts generation 1's derivation (parked too); generation 0 then finishes. A reader arriving - /// now must SHARE generation 1's derivation — exactly two derivations in all — and gets the - /// landed module. - /// - [Fact] - public async Task ALateDerivationDoesNotEvictItsSuccessor() - { - var ct = TestContext.Current.CancellationToken; - var derivations = 0; - var releaseFirst = 0; - var releaseSecond = 0; - var firstParked = new AsyncSubject(); - var secondParked = new AsyncSubject(); - using var registry = new ModuleLandingService(baseDirectory: root) - { - BeforeServedPublish = generation => - { - Interlocked.Increment(ref derivations); - // The deliberate parks — the subject of this test. Bounded, and released in the - // finally below, so a failing assertion can never strand a pool thread. - if (generation == 0) - { - firstParked.OnNext(Unit.Default); - firstParked.OnCompleted(); - SpinWait.SpinUntil(() => Volatile.Read(ref releaseFirst) == 1, TestTimeouts.Convergence); - } - else - { - secondParked.OnNext(Unit.Default); - secondParked.OnCompleted(); - SpinWait.SpinUntil(() => Volatile.Read(ref releaseSecond) == 1, TestTimeouts.Convergence); - } - }, - }; - - try - { - var first = new ReplaySubject(); - using var firstRead = registry.GetServedActivation().Subscribe(first); - await firstParked.Should().Within(TestTimeouts.Convergence) - .Emit("generation 0's derivation must reach its publish point", cancellationToken: ct); - - await Shelve(registry, "1.0.0").Should().Within(TestTimeouts.Convergence) - .Emit("the local landing must complete", cancellationToken: ct); - - var second = new ReplaySubject(); - using var secondRead = registry.GetServedActivation().Subscribe(second); - await secondParked.Should().Within(TestTimeouts.Convergence) - .Emit("generation 1's derivation must reach its publish point", cancellationToken: ct); - - // Generation 0 finishes while generation 1 is still in flight. - Volatile.Write(ref releaseFirst, 1); - await first.Should().Within(TestTimeouts.Convergence) - .Emit("generation 0's reader is answered", cancellationToken: ct); - - // 🚨 THE ASSERTION. A reader arriving now shares generation 1's derivation. - var third = new ReplaySubject(); - using var thirdRead = registry.GetServedActivation().Subscribe(third); - Volatile.Write(ref releaseSecond, 1); - - var served = await third.Should().Within(TestTimeouts.Convergence) - .Emit("the late reader is answered by generation 1", cancellationToken: ct); - Assert.Contains(served.Entries, IsTheModule); - Assert.Equal(2, Volatile.Read(ref derivations)); - } - finally - { - Volatile.Write(ref releaseFirst, 1); - Volatile.Write(ref releaseSecond, 1); - } - } - - private static bool IsTheModule(ModuleActivationEntry entry) => - string.Equals(entry.Name, Module, StringComparison.OrdinalIgnoreCase); - - private static IObservable Shelve(ModuleLandingService landing, string version) => - landing.ShelveModule( - Module, - [(Module + ".dll", File.ReadAllBytes(typeof(BundleReader).Assembly.Location))], - frameworkMvid: "test-build", packagePath: "Plugins/ServedActivation", version: version); - - /// A clock the test moves — the freshness bound is measured on it. - private sealed class TestClock : TimeProvider - { - private DateTimeOffset now = new(2026, 9, 26, 12, 0, 0, TimeSpan.Zero); - - public override DateTimeOffset GetUtcNow() => now; - - public void Advance(TimeSpan by) => now += by; - } -} diff --git a/test/Memex.Portal.Shared.Test/ServedModuleBytesTest.cs b/test/Memex.Portal.Shared.Test/ServedModuleBytesTest.cs deleted file mode 100644 index a50a83b9e0..0000000000 --- a/test/Memex.Portal.Shared.Test/ServedModuleBytesTest.cs +++ /dev/null @@ -1,413 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Text; -using MeshWeaver.Compiler; -using MeshWeaver.Hosting; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// ONE PRODUCER IN TIME (#3244): a bundle download must carry the build of the module its own -/// assemblies were compiled against. -/// -/// 🚨 Why this is pinned as a pure decision. Every branch of it fails SILENTLY. Serving -/// the shelf's build beside assemblies sealed against another one returns 200, writes a -/// well-formed archive, lands cleanly on the consumer — and is then declined type by type at the -/// consumer's boot ("dependency record mismatch — built against mvid:…, live is mvid:…"), which is -/// how memex.meshweaver.cloud adopted 0 of 4 SocialMedia bundles on ci.7621 with no diff in any -/// repo. Nothing upstream of the consumer's log can see it: ReleaseAvailability compares the -/// SEALED module set against the sealed bundles' records and passes, because both halves it reads -/// come from the same publication while the instance runs the registry's content-versioned shelf. -/// Same reasoning, same shape, as for the NodeType half -/// of the very same handler. -/// -/// The MVIDs are REAL: each fixture's module bytes are a genuine assembly this test process -/// has loaded, so the PE reads under test read what they would read in production. Two different -/// assemblies give two different builds without having to fabricate metadata. -/// -public class ServedModuleBytesTest : IDisposable -{ - private const string Module = "MeshWeaver.Widget"; - private const string Package = "Widget"; - private const string Identity = "s0123456789abcdef0123456789abcdef"; - private const string Source = "plugins"; - - /// The build the SEAL carries — what the served assemblies will record. - private static byte[] SealedBytes => File.ReadAllBytes(typeof(ReleaseAvailability).Assembly.Location); - - private static string SealedMvid => MvidOf(typeof(ReleaseAvailability).Assembly); - - /// A DIFFERENT build — what the registry's own content-versioned shelf holds. - private static byte[] ShelfBytes => File.ReadAllBytes(typeof(BundleReader).Assembly.Location); - - private static string ShelfMvid => MvidOf(typeof(BundleReader).Assembly); - - private static string MvidOf(System.Reflection.Assembly assembly) => - CompiledDependencies.MvidScheme + assembly.ManifestModule.ModuleVersionId.ToString("N"); - - private static readonly byte[] AssetBytes = Encoding.UTF8.GetBytes(".mw-widget{color:#bada55}"); - - private static readonly byte[] NestedAssetBytes = - Encoding.UTF8.GetBytes("export function init(){ return 'mw-widget'; }"); - - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-served-module-" + Guid.NewGuid().ToString("N")); - - /// - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - GC.SuppressFinalize(this); - } - - /// - /// THE defect. The shelf holds one build, the assemblies about to be shipped record another, and - /// the publication sealed for the caller's identity carries the recorded one — so the recorded - /// one is what goes in the archive. - /// - [Fact] - public void WhenTheShelfIsNotTheRecordedBuild_TheSealedBuildIsServed() - { - var shelf = Shelf(ShelfBytes); - PublishedRoot(bundleName: ServedModuleBytes.BundleNameFor(Package), moduleBytes: SealedBytes); - - var served = ServedModuleBytes.Resolve( - Module, Package, shelf.Files, shelf.Assets, - new RecordedModuleId(SealedMvid, null), - PublishedRootPath, Identity); - - Assert.Null(served.Divergence); - Assert.Equal(Module + ".dll", served.Files[0].FileName); - Assert.Equal(SealedBytes, Read(served.Files[0])); - Assert.Contains(Source, served.Provenance, StringComparison.Ordinal); - Assert.Contains(Identity, served.Provenance, StringComparison.Ordinal); - } - - /// - /// 🚨 The sealed build's STATIC WEB ASSETS ride with it. Switching which producer supplies the - /// module must not re-create #2221: a view pack whose wwwroot is dropped lands, loads and - /// renders unstyled with nothing in any log, and the seal's bundle carries its assets under - /// meshweaver/moduleassets/ exactly as the pack lane wrote them - /// (ModulePackCommand). Asserted byte-exact, and by RELATIVE path — a component asks for - /// _content/<pack>/Components/x.razor.js, so a flattened asset 404s like a missing one. - /// - [Fact] - public void TheSealedBuildsStaticAssetsRideWithIt() - { - var shelf = Shelf(ShelfBytes); - PublishedRoot( - bundleName: ServedModuleBytes.BundleNameFor(Package), - moduleBytes: SealedBytes, - assets: [("wwwroot/MeshWeaver.Widget.styles.css", AssetBytes), - ("wwwroot/Components/Widget.razor.js", NestedAssetBytes)]); - - var served = ServedModuleBytes.Resolve( - Module, Package, shelf.Files, shelf.Assets, - new RecordedModuleId(SealedMvid, null), - PublishedRootPath, Identity); - - Assert.Null(served.Divergence); - Assert.Equal(SealedBytes, Read(served.Files[0])); - Assert.Equal( - ["wwwroot/Components/Widget.razor.js", "wwwroot/MeshWeaver.Widget.styles.css"], - served.Assets.Select(a => a.RelativePath).ToArray()); - Assert.Equal(NestedAssetBytes, ReadAsset(served.Assets[0])); - Assert.Equal(AssetBytes, ReadAsset(served.Assets[1])); - } - - /// - /// The seal is found even when the module bundle is NOT named by the pack lane's convention — - /// the naming is a contract of the producing lane, and a control that could only see - /// <lower(package)>.module.nupkg would report "no matching build" for a seal that - /// carries exactly the right bytes under another name. - /// - [Fact] - public void TheSealedBuildIsFound_EvenUnderAnUnconventionalBundleName() - { - var shelf = Shelf(ShelfBytes); - PublishedRoot(bundleName: "essentials.module.nupkg", moduleBytes: SealedBytes); - - var served = ServedModuleBytes.Resolve( - Module, Package, shelf.Files, shelf.Assets, - new RecordedModuleId(SealedMvid, null), - PublishedRootPath, Identity); - - Assert.Null(served.Divergence); - Assert.Equal(SealedBytes, Read(served.Files[0])); - } - - /// - /// The healthy case, and the one that must not change: the shelf already IS the recorded build, - /// so the shelf's own bytes are served and no publication is consulted. - /// - [Fact] - public void WhenTheShelfIsTheRecordedBuild_TheShelfIsServed() - { - var shelf = Shelf(ShelfBytes); - PublishedRoot(bundleName: ServedModuleBytes.BundleNameFor(Package), moduleBytes: SealedBytes); - - var served = ServedModuleBytes.Resolve( - Module, Package, shelf.Files, shelf.Assets, - new RecordedModuleId(ShelfMvid, null), - PublishedRootPath, Identity); - - Assert.Null(served.Divergence); - Assert.Equal(ShelfBytes, Read(served.Files[0])); - Assert.Contains("shelf", served.Provenance, StringComparison.Ordinal); - } - - /// - /// Nothing in the bundle binds the module, so nothing constrains which build ships: the shelf - /// answers exactly as it did before this decision existed. A module-only package has no NodeType - /// records at all, and re-pointing it at a seal would be a change with no evidence behind it. - /// - [Fact] - public void WhenNothingRecordsTheModule_TheShelfIsServedUnchanged() - { - var shelf = Shelf(ShelfBytes); - PublishedRoot(bundleName: ServedModuleBytes.BundleNameFor(Package), moduleBytes: SealedBytes); - - var served = ServedModuleBytes.Resolve( - Module, Package, shelf.Files, shelf.Assets, - new RecordedModuleId(null, null), - PublishedRootPath, Identity); - - Assert.Null(served.Divergence); - Assert.Equal(ShelfBytes, Read(served.Files[0])); - } - - /// - /// No publication for this identity carries the recorded build. The bytes served are unchanged — - /// the registry cannot conjure a build it does not hold — but the answer is NAMED, quoting both - /// MVIDs, so "this lane cannot be satisfied" is stated by the producer instead of being - /// discovered as a per-type decline in a consumer's log. - /// - [Fact] - public void WhenNoSealCarriesTheRecordedBuild_TheDivergenceIsNamed() - { - var shelf = Shelf(ShelfBytes); - PublishedRoot( - bundleName: ServedModuleBytes.BundleNameFor(Package), - moduleBytes: ShelfBytes); - var absent = CompiledDependencies.MvidScheme + new string('a', 32); - - var served = ServedModuleBytes.Resolve( - Module, Package, shelf.Files, shelf.Assets, - new RecordedModuleId(absent, null), - PublishedRootPath, Identity); - - Assert.NotNull(served.Divergence); - Assert.Contains(absent, served.Divergence!, StringComparison.Ordinal); - Assert.Contains(ShelfMvid, served.Divergence!, StringComparison.Ordinal); - Assert.Equal(ShelfBytes, Read(served.Files[0])); - } - - /// - /// A deployment that mounts no published bundle root has one producer by construction — the - /// decision must not fault there, it must answer the shelf. - /// - [Fact] - public void WithNoPublishedRoot_TheShelfIsServed() - { - var shelf = Shelf(ShelfBytes); - - var served = ServedModuleBytes.Resolve( - Module, Package, shelf.Files, shelf.Assets, - new RecordedModuleId(SealedMvid, null), - publishedRoot: null, Identity); - - Assert.NotNull(served.Divergence); - Assert.Equal(ShelfBytes, Read(served.Files[0])); - } - - /// - /// Two assemblies in one bundle recording two builds of one module cannot both be satisfied, so - /// the answer is the named disagreement rather than a silent pick — choosing either would - /// decline the other's NodeTypes. - /// - [Fact] - public void AssembliesRecordingTwoBuilds_AreNamedRatherThanResolved() - { - var recorded = ServedModuleBytes.RecordedFor( - [ - new Dictionary(StringComparer.Ordinal) { [Module] = SealedMvid }, - new Dictionary(StringComparer.Ordinal) { [Module] = ShelfMvid }, - ], - Module); - - Assert.Null(recorded.Mvid); - Assert.NotNull(recorded.Disagreement); - Assert.Contains(SealedMvid, recorded.Disagreement!, StringComparison.Ordinal); - Assert.Contains(ShelfMvid, recorded.Disagreement!, StringComparison.Ordinal); - } - - /// - /// The reading that drives everything above: the single mvid: the served assemblies agree - /// on. Reserved '!' keys and ref: ids are not module builds and must not be read as one. - /// - [Fact] - public void RecordedFor_ReadsTheAgreedModuleBuildAndNothingElse() - { - var recorded = ServedModuleBytes.RecordedFor( - [ - new Dictionary(StringComparer.Ordinal) - { - [Module] = SealedMvid, - ["MeshWeaver.Graph"] = CompiledDependencies.RefAsmScheme + "surface", - [CompiledDependencies.ToolchainKey] = CompiledDependencies.MvidScheme + "toolchain", - }, - new Dictionary(StringComparer.Ordinal) { [Module] = SealedMvid }, - null, - ], - Module); - - Assert.Equal(SealedMvid, recorded.Mvid); - Assert.Null(recorded.Disagreement); - - var refOnly = ServedModuleBytes.RecordedFor( - [ - new Dictionary(StringComparer.Ordinal) - { - [Module] = CompiledDependencies.RefAsmScheme + "surface", - }, - ], - Module); - Assert.Null(refOnly.Mvid); - Assert.Null(refOnly.Disagreement); - } - - /// - /// 🚨 #4126, stage 3 — the module's RID-specific NATIVE payloads must survive BOTH serve - /// routes, and this is the SEALED one. Switching which producer supplies the module must not - /// drop the engine: the consumer lands what this archive carries and nothing else, so a native - /// left behind here is a module that lands, loads, and throws DllNotFoundException at its - /// first P/Invoke — the #2221 shape one step harder, because the asset version merely renders - /// unstyled. - /// - [Fact] - public void TheSealedBuildsNativePayloadsRideWithIt() - { - var shelf = Shelf(ShelfBytes); - PublishedRoot( - bundleName: ServedModuleBytes.BundleNameFor(Package), - moduleBytes: SealedBytes, - natives: [("runtimes/linux-x64/native/libe_sqlite3.so", NativeBytes), - // A payload at a layout the loader never probes: the seal is produced by the - // bake, not by this process, so re-serving one would hand the consumer a - // bundle its own landing REFUSES — turning "no engine" into "nothing lands". - ("runtimes/linux-x64/other/native/libbogus.so", NativeBytes)]); - - var served = ServedModuleBytes.Resolve( - Module, Package, shelf.Files, shelf.Assets, - new RecordedModuleId(SealedMvid, null), - PublishedRootPath, Identity); - - Assert.Null(served.Divergence); - Assert.Equal( - ["runtimes/linux-x64/native/libe_sqlite3.so"], - served.Natives.Select(n => n.RelativePath).ToArray()); - Assert.Equal(NativeBytes, ReadAsset(served.Natives[0])); - } - - /// - /// And the SHELF route: when the registry's own build IS the recorded one — the healthy case, - /// and the one every ordinary download takes — the natives it holds are what goes out. - /// - [Fact] - public void TheShelfsNativePayloadsAreServed() - { - var shelf = Shelf(ShelfBytes); - var nativeFile = Path.Combine( - root, "shelf", "modules", Module, "runtimes", "linux-x64", "native", "libe_sqlite3.so"); - Directory.CreateDirectory(Path.GetDirectoryName(nativeFile)!); - File.WriteAllBytes(nativeFile, NativeBytes); - - var served = ServedModuleBytes.Resolve( - Module, Package, shelf.Files, shelf.Assets, - new RecordedModuleId(ShelfMvid, null), - PublishedRootPath, Identity, logger: null, - shelfNatives: [("runtimes/linux-x64/native/libe_sqlite3.so", nativeFile)]); - - Assert.Null(served.Divergence); - Assert.Contains("shelf", served.Provenance, StringComparison.Ordinal); - Assert.Equal(NativeBytes, ReadAsset(Assert.Single(served.Natives))); - } - - private static readonly byte[] NativeBytes = Encoding.UTF8.GetBytes("ELF-linux-x64-engine"); - - private string PublishedRootPath => Path.Combine(root, "published"); - - /// This registry's own modules/ shelf, holding one build of the module. - private (IReadOnlyList Files, IReadOnlyList<(string RelativePath, string FullPath)> Assets) - Shelf(byte[] bytes) - { - var folder = Path.Combine(root, "shelf", "modules", Module); - Directory.CreateDirectory(folder); - var entry = Path.Combine(folder, Module + ".dll"); - File.WriteAllBytes(entry, bytes); - return ([entry], []); - } - - /// A SEALED publication for whose module set carries one bundle - /// declaring at the given build. - private void PublishedRoot( - string bundleName, byte[] moduleBytes, - IReadOnlyList<(string RelativePath, byte[] Bytes)>? assets = null, - IReadOnlyList<(string RelativePath, byte[] Bytes)>? natives = null) - { - var source = Path.Combine(PublishedRootPath, Identity, Source); - var modules = Path.Combine(source, PublishedBundleCatalogue.ModulesDirectoryName); - Directory.CreateDirectory(modules); - - var entries = new List<(string Entry, byte[] Bytes)> - { - (NuGetPackageWriter.ManifestEntry, - Encoding.UTF8.GetBytes($$$"""{"plugin":"{{{Package}}}","module":{"assemblyName":"{{{Module}}}"}}""")), - ($"{NuGetPackageWriter.ModuleFolder}/{Module}.dll", moduleBytes), - }; - foreach (var (relative, bytes) in assets ?? []) - entries.Add((NuGetPackageWriter.ModuleAssetEntryPathFor(relative), bytes)); - foreach (var (relative, bytes) in natives ?? []) - entries.Add((NuGetPackageWriter.ModuleNativeEntryPathFor(relative), bytes)); - WriteZip(Path.Combine(modules, bundleName), [.. entries]); - File.WriteAllLines( - Path.Combine(modules, PublishedBundleCatalogue.ModulesIndexFileName), [bundleName]); - - // The seal itself — SealedModulesOf refuses a module set whose publication is torn. - WriteZip( - Path.Combine(source, Package + ".zip"), - (NuGetPackageWriter.ManifestEntry, Encoding.UTF8.GetBytes("{}"))); - File.WriteAllText( - Path.Combine(source, ShippedPrebuiltBundles.CompletionSentinelFileName), Package + ".zip\n"); - } - - private static byte[] Read(ServedModuleFile file) => Drain(file.Open); - - private static byte[] ReadAsset(ServedModuleAsset asset) => Drain(asset.Open); - - private static byte[] Drain(Func open) - { - using var stream = open(); - using var buffer = new MemoryStream(); - stream.CopyTo(buffer); - return buffer.ToArray(); - } - - private static void WriteZip(string path, params (string Entry, byte[] Bytes)[] entries) - { - using var file = File.Create(path); - using var zip = new ZipArchive(file, ZipArchiveMode.Create); - foreach (var (entry, bytes) in entries) - { - using var stream = zip.CreateEntry(entry).Open(); - stream.Write(bytes); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/ServiceIdentityTest.cs b/test/Memex.Portal.Shared.Test/ServiceIdentityTest.cs deleted file mode 100644 index fc89633f0b..0000000000 --- a/test/Memex.Portal.Shared.Test/ServiceIdentityTest.cs +++ /dev/null @@ -1,311 +0,0 @@ -using System.Reactive.Linq; -using System.Security.Claims; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Service identities (Doc/Architecture/ServiceIdentities) against a REAL mesh: a non-person -/// principal a global admin creates, grants and issues tokens for, whose tokens authenticate as the -/// SERVICE — never as a person — and which never holds platform administration. -/// -/// 🚨 Built on ConfigureMeshBase, NOT base.ConfigureMesh: the latter grants -/// Public the Admin role everywhere, under which "a service token can create a node only where -/// granted" would pass vacuously. The only standing grant here is the harness admin's (Roland's) own -/// root grant, which is the identity every setup step runs under. -/// -public class ServiceIdentityTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string GrantedScope = TestPartition + "/Granted"; - private const string OtherScope = TestPartition + "/Other"; - - /// A service whose Admin-partition grant is seeded STATICALLY — the one road around the - /// write guard — so the read-side refusal can be measured on its own. - private const string SeededAdminService = "svc-seeded-admin"; - - /// A PERSON with the same static Admin-partition grant: the positive control. - private const string SeededAdminPerson = "seeded-admin-person"; - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddMeshNodes( - new MeshNode("Granted", TestPartition) { Name = "Granted", NodeType = "Markdown" }, - new MeshNode("Other", TestPartition) { Name = "Other", NodeType = "Markdown" }, - AdminPartitionGrant(SeededAdminService), - AdminPartitionGrant(SeededAdminPerson)); - - private static MeshNode AdminPartitionGrant(string subject) => new($"{subject}_Access", "Admin/_Access") - { - NodeType = AccessAssignmentNodeType.NodeType, - Name = $"{subject} — Admin", - MainNode = "Admin", - Content = new AccessAssignment - { - AccessObject = subject, - DisplayName = subject, - Roles = [new RoleAssignment { Role = "Admin" }], - }, - }; - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - private ApiTokenService Tokens() => new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService(), - Mesh.ServiceProvider.GetRequiredService>()); - - /// Both contexts — see NonAdminUpdateStatusTest.Become for why. - private void Become(AccessContext user) - { - Access.SetContext(user); - Access.SetHostIdentity(user); - } - - private void BecomeAdmin() => Become(TestUsers.Admin); - - /// The error message of a stream that should fail, or null when it emitted. - private async Task Refusal(IObservable operation) - { - var message = await operation - .Take(1) - .Select(_ => (string?)null) - .Catch(ex => Observable.Return(ex.Message)) - .Should().Emit(); - Output.WriteLine($"refusal: {message ?? "(none — the operation succeeded)"}"); - return message; - } - - /// Creates an identity and issues it a token, as the admin. Returns the raw token. - private async Task<(string ObjectId, TokenCreationResult Token)> ServiceWithToken( - ApiTokenService tokens, string name) - { - BecomeAdmin(); - var identity = await ServiceIdentities.Create(Mesh, name, "test service").Should().Emit(); - identity.Path.Should().Be(ServiceIdentity.PathFor(identity.Id)); - var token = await tokens.CreateServiceToken(identity.Id, "ci").Should().Emit(); - return (identity.Id, token); - } - - /// - /// The acceptance test: a service token authenticates as the SERVICE, and that principal can - /// create a node exactly where it was granted — not one scope over — with audit naming it. - /// - [Fact] - public async Task AServiceToken_CreatesANodeOnlyWhereGranted_AndAuditShowsTheService() - { - var tokens = Tokens(); - var (serviceId, token) = await ServiceWithToken(tokens, "Name Check"); - serviceId.Should().Be("svc-name-check"); - - await ServiceIdentities.Grant(Mesh, serviceId, GrantedScope, "Editor").Should().Emit(); - - // Both validation paths agree: the auth handler's (storage-direct) and the middleware's (hub). - var validated = await tokens.Validate(token.RawToken).Should().Emit(); - validated.Status.Should().Be(TokenValidationStatus.Valid); - validated.Token.Should().NotBeNull(); - validated.Token?.UserId.Should().Be(serviceId, - "a service token authenticates as the service principal, never as the admin who issued it"); - validated.Token?.ServiceIdentityPath.Should().Be(ServiceIdentity.PathFor(serviceId)); - - var viaHub = await UserContextMiddleware.ValidateTokenViaHub(token.RawToken, Mesh).Should().Emit(); - viaHub.Should().NotBeNull(); - viaHub?.Success.Should().BeTrue(); - viaHub?.IsService.Should().BeTrue(); - viaHub?.UserId.Should().Be(serviceId); - - Become(new AccessContext - { - ObjectId = viaHub?.UserId ?? "", - Name = viaHub?.UserName ?? "", - IsApiToken = true, - IsService = true, - }); - - var created = await NodeFactory.CreateNode(new MeshNode("by-service", GrantedScope) - { - Name = "Written by the service", - NodeType = "Markdown", - }).Should().Emit("the service holds Editor at this scope"); - created.CreatedBy.Should().Be(serviceId, "audit must show the service id, not a person"); - - var refused = await Refusal(NodeFactory.CreateNode(new MeshNode("by-service", OtherScope) - { - Name = "Must not be written", - NodeType = "Markdown", - })); - refused.Should().NotBeNull("the service was granted nothing at the sibling scope"); - refused.Should().Contain("Access denied", "it is the permission check that refuses, not anything else"); - } - - [Fact] - public async Task RevokingTheIdentity_StopsEveryTokenItHolds() - { - var tokens = Tokens(); - var (serviceId, token) = await ServiceWithToken(tokens, "Revoked Bot"); - (await tokens.Validate(token.RawToken).Should().Emit()).Status.Should().Be(TokenValidationStatus.Valid); - - await ServiceIdentities.Revoke(Mesh, serviceId).Should().Emit(); - - var after = await tokens.Validate(token.RawToken).Should().Emit(); - after.Status.Should().Be(TokenValidationStatus.Invalid, - "validation reads the identity on every use — revoking it revokes its tokens at once"); - var viaHub = await UserContextMiddleware.ValidateTokenViaHub(token.RawToken, Mesh).Should().Emit(); - viaHub?.Success.Should().BeFalse("the middleware path must agree with the auth handler's"); - - (await Refusal(tokens.CreateServiceToken(serviceId, "after revoke"))).Should().NotBeNull( - "a revoked identity is issued nothing"); - } - - /// - /// An admin may DELETE a record instead of revoking it. Both validation paths must still answer - /// a definitive negative — the hub path used to route to an absent node, which is a NotFound - /// (or a stall), not a verdict. - /// - [Fact] - public async Task DeletingTheIdentityRecord_RefusesItsTokens_OnBothPaths() - { - var tokens = Tokens(); - var (serviceId, token) = await ServiceWithToken(tokens, "Deleted Bot"); - (await tokens.Validate(token.RawToken).Should().Emit()).Status.Should().Be(TokenValidationStatus.Valid); - - BecomeAdmin(); - await NodeFactory.DeleteNode(ServiceIdentity.PathFor(serviceId)).Should().Emit(); - - (await tokens.Validate(token.RawToken).Should().Emit()).Status.Should().Be(TokenValidationStatus.Invalid, - "an absent identity record authenticates nobody"); - var viaHub = await UserContextMiddleware.ValidateTokenViaHub(token.RawToken, Mesh).Should().Emit(); - viaHub?.Success.Should().BeFalse(); - viaHub?.IsUnavailable.Should().BeFalse("an absent record is a verdict, not an outage"); - } - - [Fact] - public async Task RevokingAnUnknownIdentity_IsRefusedByName() - { - BecomeAdmin(); - var refusal = await Refusal(ServiceIdentities.Revoke(Mesh, "svc-typo")); - refusal.Should().Contain("No service identity 'svc-typo'", - "a typo'd or deleted id is refused, never reported as revoked"); - } - - [Fact] - public async Task RotatingAToken_IssuesANewOne_AndRevokesTheOld() - { - var tokens = Tokens(); - var (serviceId, token) = await ServiceWithToken(tokens, "Rotating Bot"); - - var rotated = await ServiceIdentities - .Rotate(tokens, serviceId, token.Node.Path, "ci", null).Should().Emit(); - - (await tokens.Validate(rotated.RawToken).Should().Emit()).Status.Should().Be(TokenValidationStatus.Valid); - (await tokens.Validate(token.RawToken).Should().Emit()).Status.Should().Be(TokenValidationStatus.Invalid, - "the old token is revoked once the new one exists"); - } - - [Fact] - public async Task UsingAServiceToken_StampsLastUsed() - { - var tokens = Tokens(); - var (_, token) = await ServiceWithToken(tokens, "Stamped Bot"); - - (await tokens.Validate(token.RawToken).Should().Emit()).Status.Should().Be(TokenValidationStatus.Valid); - - BecomeAdmin(); - await Mesh.GetWorkspace().GetMeshNodeStream(token.Node.Path) - .Select(n => n?.ContentAs(Mesh.JsonSerializerOptions)?.LastUsedAt) - .Should().Within(TimeSpan.FromSeconds(20)) - .Match(lastUsed => lastUsed is not null, - "the last-used stamp lands on the token row beneath the service record"); - } - - /// The service surface names only services; the person surface never mints for one. - [Fact] - public async Task IssuingForAPerson_OrForAnUnknownService_IsRefused() - { - var tokens = Tokens(); - BecomeAdmin(); - - (await Refusal(tokens.CreateServiceToken(TestUsers.Admin.ObjectId, "impersonation"))) - .Should().NotBeNull("the service surface can never be pointed at a person"); - (await Refusal(tokens.CreateServiceToken("svc-never-created", "ghost"))) - .Should().NotBeNull("no record, no token"); - (await Refusal(tokens.CreateToken("svc-never-created", "x", "", "via the person path"))) - .Should().NotBeNull("a person's token surface never mints for a service principal"); - } - - [Fact] - public async Task AServicePrincipal_NeverHoldsGlobalAdmin() - { - (await Mesh.IsGlobalAdmin(SeededAdminPerson).Should().Emit()).Should().BeTrue( - "positive control: the same static Admin-partition grant makes a PERSON a global admin"); - (await Mesh.IsGlobalAdmin(SeededAdminService).Should().Emit()).Should().BeFalse( - "a service principal never administers the platform, whatever a grant says"); - - // The CURRENT-caller form every admin gate calls (AdminOnlyTab, AdminMenuGate) — it resolves - // the ambient caller and delegates to the explicit overload, so the refusal holds there too. - Become(new AccessContext - { - ObjectId = SeededAdminService, Name = SeededAdminService, IsApiToken = true, IsService = true, - }); - (await Mesh.IsGlobalAdmin().Should().Emit()).Should().BeFalse( - "the current-caller form must refuse a service exactly as the explicit one does"); - Become(new AccessContext { ObjectId = SeededAdminPerson, Name = SeededAdminPerson }); - (await Mesh.IsGlobalAdmin().Should().Emit()).Should().BeTrue( - "positive control for the current-caller form"); - - var tokens = Tokens(); - var (serviceId, _) = await ServiceWithToken(tokens, "Would Be Admin"); - BecomeAdmin(); - var refused = await Refusal(NodeFactory.CreateNode(AdminPartitionGrant(serviceId))); - refused.Should().NotBeNull("no Admin-partition grant may name a service principal"); - refused.Should().Contain("service principal", "the refusal is the guard's, not an unrelated failure"); - - // The seeded service DOES hold Admin rights on the Admin partition (the static grant goes - // around the write guard), so RLS would let it write there — the guard is what refuses. - Become(new AccessContext - { - ObjectId = SeededAdminService, Name = SeededAdminService, IsApiToken = true, IsService = true, - }); - var selfWrite = await Refusal(ServiceIdentities.Create(Mesh, "Minted By A Service", null)); - selfWrite.Should().NotBeNull("a service principal writes nothing in the Admin partition"); - selfWrite.Should().Contain("may not write in the Admin partition", - "the refusal is the service guard's, even though RLS alone would have allowed the write"); - } - - [Fact] - public void OnlyTheTokenSchemeCanAssertTheServiceKind() - { - Claim[] claims = - [ - new("preferred_username", "svc-name-check"), - new(ServiceIdentity.PrincipalKindClaim, ServiceIdentity.ServicePrincipalKind), - ]; - UserContextMiddleware.IsServiceTokenIdentity( - new ClaimsPrincipal(new ClaimsIdentity(claims, ServiceIdentity.TokenAuthenticationType))) - .Should().BeTrue(); - UserContextMiddleware.IsServiceTokenIdentity( - new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies"))) - .Should().BeFalse("a cookie or an external provider cannot make a session a service"); - } - - [Theory] - [InlineData("Name Check", "svc-name-check")] - [InlineData("svc-namecheck", "svc-namecheck")] - [InlineData(" CI / Bot ", "svc-ci-bot")] - [InlineData("???", null)] - public void ObjectIdsAreSlugsWithTheServicePrefix(string name, string? expected) - => ServiceIdentity.ObjectIdFor(name).Should().Be(expected); -} diff --git a/test/Memex.Portal.Shared.Test/SessionDenialIsAnAnswerTest.cs b/test/Memex.Portal.Shared.Test/SessionDenialIsAnAnswerTest.cs deleted file mode 100644 index 1d4906e0b3..0000000000 --- a/test/Memex.Portal.Shared.Test/SessionDenialIsAnAnswerTest.cs +++ /dev/null @@ -1,436 +0,0 @@ -#pragma warning disable CS1591 - -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A PERMISSION DENIAL IS AN ANSWER THE TOOL SURFACE RENDERS, NEVER AN UNHANDLED EXCEPTION. -/// Issue #3121. -/// -/// What production showed. An MCP caller without write access invoked recycle -/// on Store/Catalog; the mesh refused correctly, and the refusal reached the client as -/// "recycle" threw an unhandled exception — System.UnauthorizedAccessException: Access denied: -/// user 'rbuergi' lacks Update permission on 'Store/Catalog', top frame -/// McpToolResult.AsToolResult. The caller cannot tell "you were refused" from "the tool is -/// broken", and the server logs a stack trace for a decision it made on purpose. -/// -/// Why the operation's own legible refusal never ran — the root cause, measured. -/// MeshOperations.Recycle has carried an actionable refusal envelope since #2901, gated on -/// hub.CheckPermissionOutcome(path, Update). That pre-flight was DEAD CODE on every session -/// surface. HubPermissionExtensions.ResolveEvaluator reads -/// hub.Configuration.Get<EffectivePermissionsDelegate>() ?? DefaultEvaluator, does not -/// walk the parent chain, and the default returns — while a hub's -/// configuration starts EMPTY, inheriting nothing. — the ONE -/// factory behind MCP, REST, gRPC and the CLI — never copied the mesh's evaluator, so every -/// client-side check a session issued answered GRANTED, for every caller, on every path. The only -/// gate that ever fired was the OWNER's, whose DeliveryFailure{Unauthorized} arrives as an -/// Rx fault. -/// pins that directly; -/// MeshExtensions.NodeOperationExecutionHub had already been fixed for the identical reason, -/// and MeshOperations.Export works around it by resolving the mesh hub by hand. -/// -/// And the two shapes it produced were different, which is why both are pinned. -/// Measured on this tree before the fix — with a throwaway probe whose fixture is the one below, -/// under its own path names — a Viewer (Read|Execute|Api) calling Recycle through a -/// real session hub got: -/// -/// a NodeType node — the stamp is a real write, the owner refuses it, -/// and RecycleCore re-raised: THREW System.UnauthorizedAccessException: Access -/// denied: user '…-viewer' lacks Update permission on '…/Ty' — the production line's -/// exception type and message template, reproduced. -/// a non-NodeType node — the stamp is an identity update that writes -/// nothing, so NOTHING was gated and the answer was -/// {"status":"Recycled"}: the DisposeRequest went out for a caller with no write -/// access at all. The 2026-08-30 guarantee that "a refused recycle no longer tears the hub -/// down" held only for NodeTypes, because the authorization was a SIDE EFFECT of the stamp -/// rather than a decision the operation made. -/// -/// -/// The fix has two halves and both are load-bearing. The session's pre-flight becomes -/// a real gate (so a refusal is the normal, legible path and the destructive half never runs), and -/// the OWNER's verdict — which remains the authority, at a different hub and a later moment — is -/// rendered in the same envelope instead of escaping as a fault. A pre-flight can only ever be -/// check-then-act; it does not make the owner's answer optional. -/// -/// 🚨 A verdict and a non-verdict are not the same answer (#974, #3017), and this -/// change must not blur them: see , which pins that only a -/// decisive refusal renders as "you lack permission" and that a hub going away or an unevaluated -/// check keeps its own, retryable answer. -/// -public class SessionDenialIsAnAnswerTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Partition = "SessionDenial"; - - /// An ordinary node: its recycle stamp writes NOTHING, so nothing gates it. - private const string PlainPath = Partition + "/Doc"; - - /// A NodeType: its recycle stamp is a real write, and the owner refuses it. - private const string NodeTypePath = Partition + "/Ty"; - - /// - /// A second partition, holding an Activity satellite whose MainNode points back - /// into . The split is what makes the case sharp: a satellite under its - /// own main node inherits that node's grants by path, so the raw check and the rule agree by - /// construction and nothing is being tested. MainNode is a POINTER, not a parent — the - /// hazard MainNodeRebasing documents — and SatelliteAccessRule delegates to - /// wherever it points. - /// - private const string Elsewhere = "SessionDenialElsewhere"; - - /// - /// The satellite: Update on its own path is DENIED for the Editor, and its registered - /// SatelliteAccessRule ("a satellite's write is Update on its MainNode") GRANTS. - /// - private const string SatellitePath = Elsewhere + "/_Activity/run1"; - - /// Read|Execute|Api — no Update anywhere in this partition. - private const string ViewerUser = "session-viewer"; - - /// - /// Update but no Delete — the entitlement that makes the satellite case sharp: the raw check on - /// the satellite's OWN path denies, and the node type's rule grants via the MainNode. - /// - private const string EditorUser = "session-editor"; - - /// 🚨 , never a literal — CI-scaled, and it reports what it - /// was waiting for instead of dying anonymously. - private static TimeSpan Budget => TestTimeouts.Convergence; - - // 🚨 ConfigureMeshBase, not base.ConfigureMesh: the latter chains PublicAdminAccess(), which - // grants Public the Admin role in every default partition — under it the Viewer below would - // hold Update outright and every assertion here would pass vacuously. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddMeshNodes( - new MeshNode(Partition) { Name = "Session Denial", NodeType = "Markdown" }, - new MeshNode("Doc", Partition) { Name = "Doc", NodeType = "Markdown" }, - new MeshNode("Ty", Partition) { Name = "Ty", NodeType = MeshNode.NodeTypePath }, - new MeshNode(Elsewhere) { Name = "Elsewhere", NodeType = "Markdown" }, - new MeshNode("run1", Elsewhere + "/_Activity") - { - Name = "Compile run", - NodeType = "Activity", - // The satellite pointer, into the OTHER partition. Without it - // SatelliteAccessRule takes its degenerate branch and falls back to the plain - // path check — i.e. the answer with no rule at all — so this line is what makes - // the case a satellite whose rule DISAGREES with the raw path. - MainNode = PlainPath, - }, - AssignmentNodeFactory.UserRole(ViewerUser, "Viewer", Partition), - // Scoped to `Partition` ONLY: Update on `SessionDenial/Doc`, nothing at all in - // `SessionDenialElsewhere`. - AssignmentNodeFactory.UserRole(EditorUser, "Editor", Partition)); - - /// - /// The session hub every API surface issues on, materialised exactly as MCP materialises it — - /// through , which is the subject here, not a stand-in for it. - /// - private IMessageHub SessionHub() => SessionHubFactory.Resolve( - Mesh, - "mcp", - "denial", - Mesh.ServiceProvider.GetRequiredService().CreateLogger(nameof(SessionDenialIsAnAnswerTest))); - - private void ActAs(string userId) => Mesh.ServiceProvider.GetRequiredService() - .SetContext(new AccessContext { ObjectId = userId, Name = userId }); - - private void ActAsViewer() => ActAs(ViewerUser); - - /// - /// 🚨 THE ROOT CAUSE, pinned as one comparison: the SAME question, asked on the session hub and - /// on the mesh hub, must get the SAME answer. - /// - /// Pre-fix the session hub answered granted=True and the mesh hub - /// granted=False — a gate that cannot fail, which AGENTS.md names as its own bug class. - /// Asserting the AGREEMENT rather than the denial is deliberate: a fix that disabled the mesh's - /// evaluator would satisfy "both deny" and is caught by the second assertion, which pins that - /// the shared answer is the RESTRICTIVE one. - /// - [Fact] - public async Task ASessionsPermissionCheckAnswersTheSameAsTheMeshs() - { - ActAsViewer(); - - var onSession = await SessionHub().CheckPermissionOutcome(PlainPath, Permission.Update) - .Take(1).Timeout(Budget).Await(TestContext.Current.CancellationToken); - var onMesh = await Mesh.CheckPermissionOutcome(PlainPath, ViewerUser, Permission.Update) - .Take(1).Timeout(Budget).Await(TestContext.Current.CancellationToken); - - onSession.IsGranted.Should().Be(onMesh.IsGranted, - "a session hub is where MCP / REST / gRPC / CLI issue every operation, so a permission " - + "check there must reach the mesh's verdict — not DefaultEvaluator's Permission.All"); - onMesh.IsGranted.Should().BeFalse( - "a Viewer holds Read|Execute|Api and no Update — if this flips, the agreement above is " - + "vacuous and the gate has been widened rather than wired"); - } - - /// - /// The production line, reproduced: a NodeType whose recycle stamp is a real write the owner - /// refuses. Pre-fix this threw out of the operation, - /// past MeshOperations, into the transport's one Task bridge. - /// - [Fact] - public async Task RecyclingANodeTypeWithoutUpdateAnswersInsteadOfThrowing() - { - var answer = await RecycleAsViewer(NodeTypePath); - answer.Should().Be(MeshOperations.RecycleDeniedMessage, - "a denial is something the mesh DECIDED — the operation renders it in its own envelope, " - + "never as an unhandled exception the MCP server logs with a stack trace"); - // The denial's CONTENT is the contract too, not only its identity: it must not send the - // caller to a platform admin (who holds no standing write on a system-owned partition and - // is refused the same way), and it must name what DOES re-bind such a type. - answer.Should().NotContain("(or a platform admin)"); - answer.Should().Contain("A platform admin holds no standing write"); - answer.Should().Contain("on a package install or update, and on a roll"); - } - - /// - /// The half the 2026-08-30 fix could not reach: an ordinary node, whose recycle stamp writes - /// nothing and is therefore gated by nothing. Pre-fix this answered {"status":"Recycled"} - /// and posted the DisposeRequest for a caller with no write access. - /// - /// The envelope IS the assertion that nothing was performed, and deliberately so: the - /// DisposeRequest is posted on exactly one code path, the one that returns - /// {"status":"Recycled"}. A refusal envelope therefore proves that path was not taken. - /// The alternative — waiting to observe that no dispose arrived — is settle-by-silence, which - /// passes for the wrong reason on a slow mesh and reds for the wrong reason on a fast one. - /// - [Fact] - public async Task RecyclingAPlainNodeWithoutUpdateIsRefusedNotPerformed() - => (await RecycleAsViewer(PlainPath)).Should().Be(MeshOperations.RecycleDeniedMessage, - "recycling disposes the node's hub — a caller who may not write the node may not tear " - + "it down either, and that must not depend on whether the stamp happens to be a write"); - - /// - /// 🚨 THE OTHER DIRECTION, and the regression this change had to avoid CREATING. Making the - /// pre-flight live is only safe if it asks the question the OWNER asks — and since #3061/#3100 - /// the owner's delivery gate re-decides a denied raw check through the node's registered - /// INodeTypeAccessRule. A pre-flight that stopped at the raw path would refuse an editor - /// the owner grants: a NEW false refusal, introduced by a fix for a legibility bug. - /// - /// The fixture is a satellite whose MainNode points into ANOTHER partition, which - /// is the only shape where the two answers can differ: a satellite living UNDER its main node - /// inherits that node's grants by path, so raw and rule agree by construction and the case - /// would be vacuous. MainNode is a pointer, not a parent. - /// - /// So: the Editor holds Update in and nothing in - /// . Raw Update on the satellite's own path — DENIED. - /// SatelliteAccessRule — "a satellite's write is Update on its MainNode" — GRANTED. - /// The recycle must go through. - /// - [Fact] - public async Task RecyclingASatelliteFollowsItsNodeTypesRule_NotTheRawPath() - { - ActAs(EditorUser); - - var rawCheck = await Mesh.CheckPermissionOutcome(SatellitePath, EditorUser, Permission.Update) - .Take(1).Timeout(Budget).Await(TestContext.Current.CancellationToken); - rawCheck.IsGranted.Should().BeFalse( - "the Editor's grant is scoped to the other partition — if the raw path check GRANTS, " - + "the rule is never consulted and this test proves nothing"); - - var answer = await new MeshOperations(SessionHub()).Recycle(SatellitePath) - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - Output.WriteLine($"Recycle({SatellitePath}) → {answer}"); - - using var envelope = JsonDocument.Parse(answer); - envelope.RootElement.GetProperty("status").GetString().Should().Be("Recycled", - "the satellite's own access rule grants what the raw path check denies, and the " - + "pre-flight must reach the same verdict the owner's delivery gate would"); - } - - /// - /// 🚨 ONLY A VERDICT READS AS A DENIAL. is the seam - /// that keeps "we checked; you may not" apart from "we could not check" / "this activation is - /// going away", and it decides on the TYPED failure — never by matching a message, which drifts - /// the moment someone rewords a banner. - /// - /// Pinned as a unit because the production shape that needs it — the owner refusing a - /// write the caller's own pre-flight granted — is a two-evaluator, two-moment race (a revoked - /// assignment between the check and the write; a cross-silo fold; an identity lost across a - /// scheduler hop). It is real and it is why the owner-side arm must stay, but it is not - /// reproducible fast and deterministically in a single-silo mesh, so the PREDICATE is pinned - /// directly. - /// - /// - [Theory] - [MemberData(nameof(WriteFailures))] - public void OnlyAVerdictReadsAsADenial(string shape, Exception failure, bool isDenial) - => MeshOperations.IsWriteDenial(failure).Should().Be(isDenial, shape); - - public static TheoryData WriteFailures() => new() - { - { - "a decisive refusal — the ONLY thing the write path mints UnauthorizedAccessException for", - new UnauthorizedAccessException("Access denied: user 'x' lacks Update permission on 'p'"), - true - }, - { - "the same verdict nested, as a late denial dispatched through LatePatchResponseRegistry arrives", - new InvalidOperationException("write failed", new UnauthorizedAccessException("Access denied")), - true - }, - { - "the NACK itself, before UpdateRemote maps it", - new DeliveryFailureException(Nack(ErrorType.Unauthorized, "Access denied: user 'x' lacks Update")), - true - }, - { - "a hub that is GONE — transient by contract, and the caller must RE-PROBE, not go asking for rights", - new DeliveryFailureException(Nack(ErrorType.ShuttingDown, "… is shutting down. Rejecting now")), - false - }, - { - "a fold that reached NO verdict — #974's whole point: this is not a statement about the user's rights", - new DeliveryFailureException(Nack(ErrorType.Unavailable, "Permission check unavailable — no verdict was reached")), - false - }, - { - "the owner never answered within the bound", - new TimeoutException("The operation has timed out."), - false - }, - { - "an unrecognised fault — the default must never accuse", - new InvalidOperationException("something else entirely"), - false - }, - }; - - private static DeliveryFailure Nack(ErrorType errorType, string message) - => new(new MessageDelivery - { - Message = new RawJson("{}"), - Sender = new Address("test/sender"), - Target = new Address(NodeTypePath), - }) - { - ErrorType = errorType, - Message = message - }; - - /// - /// Runs Recycle the way MCP runs it — as the Viewer, on a real session hub — and returns - /// the message field of the JSON envelope. A THROW propagates: the whole point is that - /// the operation answers. - /// - /// - /// 🚨 Issue #3128 — Compile carried NO permission check at all. - /// - /// Recycle requires Update on the target and Export requires - /// Export per node; Compile required nothing, so a caller who may only READ a - /// NodeType could trigger a Roslyn build of it — and mint an _Activity node underneath - /// it. This was measured BOTH before and after #3127: that PR fixed the session hub's inert - /// evaluator, which is why create / recycle / export changed answer there - /// and Compile did not. There was no check for the repaired evaluator to answer. - /// - /// The refusal envelope is itself the proof that nothing ran: the activity is minted - /// inside the compile body, which the denial arm returns in front of. - /// - [Fact] - public async Task CompilingANodeTypeWithoutCompilePermissionIsRefusedNotPerformed() - { - ActAsViewer(); - var answer = await Compile(NodeTypePath); - Output.WriteLine($"Compile({NodeTypePath}) as viewer → {answer}"); - - using var envelope = JsonDocument.Parse(answer); - envelope.RootElement.GetProperty("status").GetString().Should().Be("Error", - "the operation ran and produced a refusal, so its envelope must say so — a raised " - + "exception is not an answer"); - envelope.RootElement.GetProperty("message").GetString().Should().Be( - MeshOperations.CompileDeniedMessage, - "a Viewer holds Read|Execute|Api and no Compile — pre-fix this answered " - + "{\"status\":\"Ok\",\"message\":\"Compile SUCCEEDED.\"} and left an _Activity node " - + "behind it (#3128)"); - } - - /// - /// 🚨 The case that could have FALSIFIED the one above. A gate that refused everybody would - /// satisfy it just as well, and would be a worse bug than the hole it closes — Compile is - /// how a Space editor ships a release. - /// - /// Asserts the entitlement split the role table already declares: Role.Editor - /// carries Permission.Compile ("Space editors ship releases by default"), - /// Role.Viewer does not. Note that Compile is deliberately excluded from - /// Permission.All, which is exactly why Admin and PlatformAdmin add it explicitly — so - /// "does the holder have it?" cannot be answered by an All check. - /// - /// Deliberately does NOT assert a successful compile: whether Roslyn then succeeds is not - /// what this gate decides, and pinning it here would make an access test fail for a compiler - /// reason. Not-the-denial is the whole claim. - /// - [Fact] - public async Task AnEditorHoldsCompile_AndTheGateLetsItThrough() - { - var editorMayCompile = await Mesh - .CheckPermissionOutcome(NodeTypePath, EditorUser, Permission.Compile) - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - - editorMayCompile.IsGranted.Should().BeTrue( - "Role.Editor is declared with Permission.Compile — if this flips, the gate above is " - + "refusing the very role the permission was modelled for"); - - ActAs(EditorUser); - var answer = await Compile(NodeTypePath); - Output.WriteLine($"Compile({NodeTypePath}) as editor → {answer}"); - - using var envelope = JsonDocument.Parse(answer); - var message = envelope.RootElement.TryGetProperty("message", out var m) ? m.GetString() : null; - message.Should().NotBe(MeshOperations.CompileDeniedMessage, - "an Editor holds Compile, so the gate must pass the call through to the compile body — " - + "a gate that refuses everyone would pass the Viewer fact and break every release"); - message.Should().NotBe(MeshOperations.CompileUndeterminedMessage, - "the fold reached a verdict for this caller a line above, so 'we could not check' here " - + "would mean the gate is asking a different question than the assertion that armed it"); - } - - /// - /// A patch that would change NOTHING skips the write, and the write is where the owner's Update - /// check runs — so the no-change answer must take the same pre-flight itself. A Viewer (Read, no - /// Update) re-sending the node's own name must be refused, never told No change, which would - /// answer a write question for a caller who may not write. - /// - [Fact] - public async Task ANoChangePatchWithoutUpdateIsRefusedNotReportedAsNoChange() - { - ActAsViewer(); - var answer = await new MeshOperations(SessionHub()).Patch(PlainPath, """{"name":"Doc"}""") - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - Output.WriteLine($"Patch({PlainPath}) → {answer}"); - - answer.Should().StartWith("Error:", - "the Viewer holds no Update, and an identical patch must not bypass the check a " - + "changing patch meets at the owner"); - answer.Should().Contain("access denied"); - answer.Should().NotContain("No change"); - } - - private async Task Compile(string path) => - await new MeshOperations(SessionHub()).Compile(path) - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - - private async Task RecycleAsViewer(string path) - { - ActAsViewer(); - var answer = await new MeshOperations(SessionHub()).Recycle(path) - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - Output.WriteLine($"Recycle({path}) → {answer}"); - using var envelope = JsonDocument.Parse(answer); - envelope.RootElement.GetProperty("status").GetString().Should().Be("Error", - "the operation ran and produced a refusal, so its envelope must say so"); - return envelope.RootElement.GetProperty("message").GetString(); - } -} diff --git a/test/Memex.Portal.Shared.Test/SettingsRowActionsActOnTheClickedRowTest.cs b/test/Memex.Portal.Shared.Test/SettingsRowActionsActOnTheClickedRowTest.cs deleted file mode 100644 index eb6405cacf..0000000000 --- a/test/Memex.Portal.Shared.Test/SettingsRowActionsActOnTheClickedRowTest.cs +++ /dev/null @@ -1,266 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Threading.Tasks; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Client; -using MeshWeaver.Layout.DataGrid; -using MeshWeaver.Messaging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The admin settings lists with a per-row action — Inbox (Archive), Invitations (Revoke) and the -/// service-identity tokens (Rotate, Revoke) — carry that action as a ROW-SCOPED button -/// (Doc/GUI/DataBinding → "Row-scoped actions"): ONE button declared in a template column, whose -/// click says which row it came from. Before, the first data-binding conversion had downgraded each -/// to "select the row, then press the button below the grid". -/// -/// Each grid's action is handed in, so this pins exactly the part that changed: which row the -/// action receives. Clicking row k acts on row k for every k; a list that changes between the render -/// and the click still acts on the row that was clicked; a click with no row does nothing (negative -/// control). -/// -public class SettingsRowActionsActOnTheClickedRowTest(ITestOutputHelper output) : HubTestBase(output) -{ - private const string InboxArea = "Inbox"; - private const string InvitationsArea = "Invitations"; - private const string TokensArea = "Tokens"; - - private static InboxSettingsTab.MailRow Mail(string id) - => new($"{id} <{id}@example.com>", $"Subject {id}", "2026-10-01 10:00", "New", "…", - $"Admin/Inbox/{id}", $"{id}@example.com", IsArchived: false); - - private static InvitationsSettingsTab.InvitationRow Invitation(string id) - => new($"{id}@example.com", "Pending", "2026-10-01", "admin", "", "", "", "", - $"Admin/Invitation/{id}", IsPending: true); - - private static ServiceIdentitiesSettingsTab.TokenRow Token(string id) - => new("svc", id, "mw_ab12", "Active", "2026-10-01", "∞", "∞", - $"Admin/ServiceIdentity/svc/_Api/{id}", IsRevoked: false, DateTimeOffset.UnixEpoch, null); - - private static readonly ImmutableList Ids = ["alpha", "bravo", "charlie"]; - - private readonly BehaviorSubject> mails = new(Ids.Select(Mail)); - private readonly BehaviorSubject> invitations = new(Ids.Select(Invitation)); - private readonly BehaviorSubject> tokens = new(Ids.Select(Token)); - - /// Every action an owner ran, as "action:path" — hot: each click subscribes before it submits. - private readonly Subject acted = new(); - - /// The targets whose action keeps its click RUNNING until the test releases . - private ImmutableHashSet holdOpen = []; - - /// The tracked source a held click waits on; completed in a finally so no click is stranded. - private readonly Subject held = new(); - - /// The action every grid is handed: records which row it acted on, and — for a held target — - /// keeps its click session open by tracking , exactly as an action that started - /// longer work would (Doc/GUI/ButtonPendingState). - private void Act(UiActionContext ctx, string action, string target) - { - if (holdOpen.Contains(target)) - ctx.Track(held); - acted.OnNext($"{action}:{target}"); - } - - /// - protected override MessageHubConfiguration ConfigureHost(MessageHubConfiguration configuration) - => base.ConfigureHost(configuration) - .WithTypes(typeof(InboxSettingsTab.MailRow), typeof(InvitationsSettingsTab.InvitationRow), - typeof(ServiceIdentitiesSettingsTab.TokenRow)) - .AddLayout(layout => layout - .WithView(InboxArea, (host, _) => InboxSettingsTab.MailGrid(host, mails, - (ctx, row) => Act(ctx, "archive", row.Path))) - .WithView(InvitationsArea, (host, _) => InvitationsSettingsTab.InvitationGrid(host, invitations, - (ctx, row) => Act(ctx, "revoke", row.Path))) - .WithView(TokensArea, (host, _) => ServiceIdentitiesSettingsTab.TokenGrid(host, tokens, - rotate: (ctx, row) => Act(ctx, "rotate", row.NodePath), - revoke: (ctx, row) => Act(ctx, "revoke", row.NodePath)))); - - /// - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) - => base.ConfigureClient(configuration).AddLayoutClient(d => d); - - /// The grids under test: area, data slot, the row property naming the target, and each - /// row-scoped button as (column, action name). - public static TheoryData Buttons => new() - { - { InboxArea, "inboxList", "path", InboxSettingsTab.ArchiveColumn, "archive" }, - { InvitationsArea, "invitationList", "path", InvitationsSettingsTab.RevokeColumn, "revoke" }, - { TokensArea, "serviceIdentityTokens", "nodePath", ServiceIdentitiesSettingsTab.RotateColumn, "rotate" }, - { TokensArea, "serviceIdentityTokens", "nodePath", ServiceIdentitiesSettingsTab.RevokeColumn, "revoke" }, - }; - - /// The button in row k acts on row k, for every k. - [Theory] - [MemberData(nameof(Buttons))] - public async Task TheButtonInRowKActsOnRowK(string area, string dataId, string key, int column, string action) - { - var (stream, buttonArea, rowKey) = await OpenGrid(area, column); - var rendered = await RenderedRows(stream, dataId, key, Ids.Count); - - for (var k = 0; k < Ids.Count; k++) - { - var target = rendered[k].GetProperty(key).GetString(); - var done = await ClickAndReadAction(stream, buttonArea, AsTheGridRendersIt(rendered[k], rowKey)); - done.Should().Be($"{action}:{target}", $"the {action} button in row {k} was clicked"); - } - } - - /// The list changes between the render and the click — a row inserted above, the clicked - /// one gone — and the click still acts on the row that was clicked. - [Theory] - [MemberData(nameof(Buttons))] - public async Task AfterAChangeTheClickStillActsOnTheClickedRow( - string area, string dataId, string key, int column, string action) - { - var (stream, buttonArea, rowKey) = await OpenGrid(area, column); - var clickedRow = (await RenderedRows(stream, dataId, key, Ids.Count))[1]; // bravo - var clickedTarget = clickedRow.GetProperty(key).GetString(); - - string[] next = ["aardvark", "abacus", "charlie"]; // bravo gone, slot 1 is abacus - Push(area, next); - await RenderedRows(stream, dataId, key, next.Length, firstContains: "aardvark"); - - var done = await ClickAndReadAction(stream, buttonArea, AsTheGridRendersIt(clickedRow, rowKey)); - done.Should().Be($"{action}:{clickedTarget}", - "the person clicked bravo; the row that has since moved into its slot is not theirs to act on"); - } - - /// - /// Two rows of one grid are two clicks. While row 0's action is still RUNNING (held open by a - /// tracked source), the button in row 1 runs row 1's action — it is not refused as "another action - /// on this control is still running". The grid declares its row key, the client carries the row's - /// value of it as , and the owner keys each row's click by it - /// (Doc/GUI/ButtonPendingState → "Idempotence"). Without a declared key the rows are known only by - /// value, and this second click is refused until the first one settles. - /// NEGATIVE CONTROL: row 0 clicked again meanwhile is the SAME click — joined, never re-run. - /// - [Theory] - [MemberData(nameof(Buttons))] - public async Task AClickOnAnotherRowRunsWhileTheFirstIsStillRunning( - string area, string dataId, string key, int column, string action) - { - var (stream, buttonArea, rowKey) = await OpenGrid(area, column); - var rendered = await RenderedRows(stream, dataId, key, Ids.Count); - var first = rendered[0].GetProperty(key).GetString()!; - var second = rendered[1].GetProperty(key).GetString()!; - holdOpen = [first]; - try - { - (await ClickAndReadAction(stream, buttonArea, AsTheGridRendersIt(rendered[0], rowKey))) - .Should().Be($"{action}:{first}", "row 0's click runs, and stays running on the held source"); - - (await ClickAndReadAction(stream, buttonArea, AsTheGridRendersIt(rendered[1], rowKey))) - .Should().Be($"{action}:{second}", - "row 1 is another row of the grid — its click is its own, not refused behind row 0's"); - - var again = new ReplaySubject(1); - using var _ = acted.Subscribe(again); - Submit(stream, buttonArea, AsTheGridRendersIt(rendered[0], rowKey)); - await again.Should().NotEmit(500.Milliseconds(), - "row 0 clicked again while its click still runs is the same click — joined, never re-run", - TestContext.Current.CancellationToken); - } - finally - { - holdOpen = []; - held.OnCompleted(); - } - } - - /// NEGATIVE CONTROL: the same button clicked with no row does nothing — the target comes - /// from the row on the click and from nothing else. - [Theory] - [MemberData(nameof(Buttons))] - public async Task AClickWithNoRowDoesNothing(string area, string dataId, string key, int column, string action) - { - var (stream, buttonArea, _) = await OpenGrid(area, column); - await RenderedRows(stream, dataId, key, Ids.Count); - - var next = new ReplaySubject(1); - using var _ = acted.Subscribe(next); - Submit(stream, buttonArea, row: null); - - await next.Should().NotEmit(500.Milliseconds(), - $"with no row the {action} action cannot know which item, and must not guess", - TestContext.Current.CancellationToken); - } - - // ── Harness ───────────────────────────────────────────────────────────────────────────────── - - private void Push(string area, string[] ids) - { - switch (area) - { - case InboxArea: mails.OnNext(ids.Select(Mail)); break; - case InvitationsArea: invitations.OnNext(ids.Select(Invitation)); break; - default: tokens.OnNext(ids.Select(Token)); break; - } - } - - /// Opens the grid's area and returns its stream, the action button's area and the grid's - /// declared row key () as the client reads it. - private async Task<(ISynchronizationStream Stream, string ButtonArea, string? RowKey)> OpenGrid( - string area, int column) - { - var stream = GetClient().GetWorkspace().GetRemoteStream( - CreateHostAddress(), new LayoutAreaReference(area)); - var grid = await stream.GetControlStream(area).Should().Within(10.Seconds()).Match( - c => c is DataGridControl, "the area renders the grid", TestContext.Current.CancellationToken); - var buttonArea = $"{area}/{DataGridControl.TemplateColumnArea(column)}"; - await stream.GetControlStream(buttonArea).Should().Within(10.Seconds()).Match( - c => c is ButtonControl, - "the action column's button is rendered into the template column's area", - TestContext.Current.CancellationToken); - return (stream, buttonArea, ((DataGridControl)grid!).RowKey?.ToString()); - } - - private static async Task> RenderedRows( - ISynchronizationStream stream, string dataId, string key, int count, string? firstContains = null) - { - var all = await stream.GetDataStream( - new JsonPointerReference(LayoutAreaReference.GetDataPointer(dataId))) - .Should().Within(10.Seconds()).Match( - r => r.ValueKind == JsonValueKind.Array && r.GetArrayLength() == count - && (firstContains is null - || (r[0].GetProperty(key).GetString() ?? "").Contains(firstContains, StringComparison.Ordinal)), - "the client mirror holds the rows the grid renders", - TestContext.Current.CancellationToken); - return [.. all.EnumerateArray().Select(r => r.Clone())]; - } - - /// The grid client's row, built exactly as the grid views build it: a JSON object with no - /// pointer (a grid sorts and pages), keyed by the grid's declared when it has one. - private static RowContext AsTheGridRendersIt(JsonElement row, string? rowKey) - { - var value = JsonObject.Create(row)!; - return new() { Value = value, Key = rowKey is { Length: > 0 } property ? DataGridRowSelection.KeyOf(value, property) : null }; - } - - private static void Submit(ISynchronizationStream stream, string area, RowContext? row, - Action? onRefused = null) - => stream.SubmitUserAction(new ClickedEvent(area, stream.StreamId) { Row = row }, - actingUser: null, onRefused: onRefused, onAccepted: null); - - private async Task ClickAndReadAction(ISynchronizationStream stream, string area, RowContext row) - { - var next = new ReplaySubject(1); - using var _ = acted.Subscribe(next); - // A refused click reports itself at once, carrying the owner's reason, instead of a silent wait. - Submit(stream, area, row, onRefused: reason => next.OnNext($"refused:{reason}")); - var done = await next.Should().Within(10.Seconds()).Emit( - "the row's button runs its action", TestContext.Current.CancellationToken); - return done!; - } -} diff --git a/test/Memex.Portal.Shared.Test/SettingsUseStandardControlsTest.cs b/test/Memex.Portal.Shared.Test/SettingsUseStandardControlsTest.cs deleted file mode 100644 index 15d268ee17..0000000000 --- a/test/Memex.Portal.Shared.Test/SettingsUseStandardControlsTest.cs +++ /dev/null @@ -1,354 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Client; -using MeshWeaver.Layout.DataGrid; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The Settings pages are composed from the platform's standard controls only (maintainer, -/// 2026-10-17: "pls use standard components everywhere"). Every tab of the person app — -/// Profile, Account, Preferences, Notifications, API tokens, Security, Connected instances, Sharing, -/// Storage — and the menu around it, plus a Space's node settings (Metadata, Check access), render -/// as a frame in which NO area is an (hand-built markup, which -/// Controls.Title also emits) and no control carries the retired page-private classes -/// (settings-menu-search, settings-section). -/// -/// Read the way a person and an MCP get @{user}/area/Settings/{tab} read it: the -/// layout stream's frames, as the owner, through the layout client. A frame is judged once it has -/// SETTLED — the tab's nav link is active and every direct child of the content pane has rendered — -/// so a frame caught before its content cannot pass by being empty. -/// -public class SettingsUseStandardControlsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Person = "stdcontrols-person"; - private const string Space = "StdControlsSpace"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - private const string FailingTabId = "FailingProbe"; - - /// A tab whose builder throws — the negative control for the failed-tab check. - private static SettingsMenuItemDefinition FailingTab { get; } = new( - Id: FailingTabId, - Label: "Failing probe", - ContentBuilder: (_, _, _) => throw new InvalidOperationException("probe tab failed on purpose"), - Order: PersonApp.SharingOrder + 1, - RequiredPermission: Permission.None); - - /// The page-private CSS classes the Settings page used to hand-roll its look with. - private static readonly string[] RetiredClasses = ["settings-menu-search", "settings-section"]; - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .ConfigureServices(services => services - .AddSingleton() - .AddSingleton()) - // The person-app tabs the portal registers on every node hub (MemexConfiguration). - .ConfigureDefaultNodeHub(config => config - .AddApiTokensSettingsTab() - .AddStepUpSettingsTab() - .AddInstancesSettingsTab() - .AddNotificationsSettingsTab() - .AddPersonAppTab(FailingTab)) - .AddMeshNodes( - new MeshNode(Person) - { - NodeType = UserNodeType.NodeType, - Name = "Standard Controls Person", - State = MeshNodeState.Active, - Content = new User { FullName = "Standard Controls Person", Email = $"{Person}@meshweaver.io" }, - }, - AssignmentNodeFactory.UserRole(Person, "Admin", Person), - new MeshNode(Space) { Name = "Standard Controls Space", NodeType = "Space" }, - AssignmentNodeFactory.UserRole(Person, "Admin", Space)); - - /// - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) - => base.ConfigureClient(configuration).AddLayoutClient(); - - private IObservable Frames(string address, string? tab) - => Open(address, tab).Select(change => change.Value); - - private ISynchronizationStream Open(string address, string? tab) - { - TestUsers.DevLogin(Mesh, new AccessContext - { ObjectId = Person, Name = "Standard Controls Person", Email = $"{Person}@meshweaver.io" }); - return GetClient().GetWorkspace() - .GetRemoteStream(new Address(address), - new LayoutAreaReference(MeshNodeLayoutAreas.SettingsArea) { Id = tab }); - } - - /// Every area of a frame: its id and its control. - internal static IEnumerable<(string Id, JsonElement Control)> Areas(JsonElement frame) - => frame.TryGetProperty("areas", out var areas) && areas.ValueKind == JsonValueKind.Object - ? areas.EnumerateObject() - .Where(a => a.Value.ValueKind == JsonValueKind.Object) - .Select(a => (JsonSerializer.Deserialize(a.Name) ?? a.Name, a.Value)) - : []; - - private static string? TypeOf(JsonElement control) - => control.TryGetProperty("$type", out var t) ? t.GetString() : null; - - /// - /// The non-standard parts of a frame: every area that is an , and every - /// control carrying one of the retired page-private classes. - /// - internal static IReadOnlyList NonStandard(JsonElement frame) - => [.. Areas(frame).SelectMany(a => - { - var found = new List(); - if (TypeOf(a.Control) == nameof(HtmlControl)) - found.Add($"{a.Id}: HtmlControl"); - if (a.Control.TryGetProperty("class", out var cls) && cls.ValueKind == JsonValueKind.String - && RetiredClasses.Any(r => cls.GetString()!.Split(' ').Contains(r))) - found.Add($"{a.Id}: class '{cls.GetString()}'"); - return found; - })]; - - /// - /// The tab-failure banners of a frame: catches a builder that - /// throws and paints an Error instead — a frame showing one has - /// not rendered its tab, however standard the banner is. - /// - internal static IReadOnlyList Failures(JsonElement frame) - => [.. Areas(frame) - .Where(a => TypeOf(a.Control) == nameof(MessageBarControl) - && a.Control.TryGetProperty("intent", out var intent) - && string.Equals(intent.GetString(), MessageBarIntent.Error, StringComparison.OrdinalIgnoreCase)) - .Select(a => a.Id)]; - - /// - /// Settled: the nav link of is active, and the content pane and each of - /// its direct children are in the frame. - /// - private static bool Settled(JsonElement frame, string tabUrlSuffix) - { - var areas = Areas(frame).ToDictionary(a => a.Id, a => a.Control); - var active = areas.Values.Any(c => TypeOf(c) == nameof(NavLinkControl) - && c.TryGetProperty("isActive", out var on) && on.ValueKind == JsonValueKind.True - && c.TryGetProperty("url", out var url) && (url.GetString() ?? "").EndsWith(tabUrlSuffix, StringComparison.Ordinal)); - var pane = areas.FirstOrDefault(a => a.Key.EndsWith("/2", StringComparison.Ordinal) - && a.Key.Count(ch => ch == '/') == 1 && TypeOf(a.Value) == nameof(StackControl)); - if (!active || pane.Value.ValueKind != JsonValueKind.Object - || !pane.Value.TryGetProperty("areas", out var children) || children.GetArrayLength() == 0) - return false; - return children.EnumerateArray().All(child => - child.TryGetProperty("area", out var id) && areas.ContainsKey(id.GetString() ?? "")); - } - - private async Task AssertStandard(string address, string tab, System.Threading.CancellationToken ct) - { - var frame = await Frames(address, tab) - .Should().Within(Budget) - .Match(f => Settled(f, $"/Settings/{tab}"), $"the '{tab}' tab of {address}'s settings renders", ct); - - Failures(frame).Should().BeEmpty($"the '{tab}' tab rendered rather than failing"); - NonStandard(frame).Should().BeEmpty( - $"the '{tab}' tab and the menu around it are composed from the platform's standard controls only"); - Areas(frame).Should().Contain(a => TypeOf(a.Control) == nameof(TextFieldControl) - && a.Control.TryGetProperty("placeholder", out var p) && p.GetString() == "Search settings…", - "the menu search is the platform's text field"); - } - - [Theory(Timeout = 120000)] - [InlineData(PersonApp.ProfileTab)] - [InlineData(WhoAmISettingsTab.AccountTabId)] - [InlineData(PersonApp.PreferencesTab)] - [InlineData(NotificationsSettingsTab.TabId)] - [InlineData(ApiTokensSettingsTab.TabId)] - [InlineData(StepUpSettingsTab.TabId)] - [InlineData(InstancesSettingsTab.TabId)] - [InlineData(PersonApp.SharingTab)] - [InlineData(MeshWeaver.Graph.Storage.StorageSettingsTab.TabId)] - public Task EveryPersonAppTab_IsComposedOfStandardControls(string tab) => AssertStandard(Person, tab, TestContext.Current.CancellationToken); - - [Theory(Timeout = 120000)] - [InlineData("Metadata")] - [InlineData("EffectiveAccess")] - public Task ASpacesNodeSettings_AreComposedOfStandardControls(string tab) => AssertStandard(Space, tab, TestContext.Current.CancellationToken); - - /// - /// Check access AFTER a check: the verdict card (H4 heading + DataGrid bound to the rows the click - /// wrote) is standard controls too, and so is the blank-user warning. The initial page alone - /// never renders either. - /// - [Fact(Timeout = 120000)] - public async Task CheckAccess_VerdictAndWarning_AreComposedOfStandardControls() - { - var ct = TestContext.Current.CancellationToken; - const string tab = "EffectiveAccess"; - var stream = Open(Space, tab); - var initial = await stream.Select(c => c.Value).Should().Within(Budget) - .Match(f => Settled(f, $"/Settings/{tab}") && CheckButton(f) is not null, - "Check access renders its form", ct); - var button = CheckButton(initial)!; - - // Blank user → the warning bar. - stream.SubmitUserAction(new ClickedEvent(button, stream.StreamId), actingUser: null, onRefused: null, onAccepted: null); - var warned = await stream.Select(c => c.Value).Should().Within(Budget) - .Match(f => Areas(f).Any(a => TypeOf(a.Control) == nameof(MessageBarControl) - && a.Control.TryGetProperty("intent", out var i) && i.GetString() == MessageBarIntent.Warning), - "a check without a user id shows the warning bar", ct); - NonStandard(warned).Should().BeEmpty("the blank-user warning is a standard message bar"); - - // A user → the verdict card with its rows. - stream.UpdatePointer(System.Text.Json.Nodes.JsonValue.Create(Person), - LayoutAreaReference.GetDataPointer($"effectiveAccess_{Space}"), new JsonPointerReference("userId")); - stream.SubmitUserAction(new ClickedEvent(button, stream.StreamId), actingUser: null, onRefused: null, onAccepted: null); - var verdict = await stream.Select(c => c.Value).Should().Within(Budget) - .Match(f => Areas(f).Any(a => TypeOf(a.Control) == nameof(DataGridControl)) - && Rows(f, $"effectiveAccessRows_{Space}") == SettingsLayoutArea.CheckedPermissions.Count, - "the check renders the verdict grid over one row per permission", ct); - - NonStandard(verdict).Should().BeEmpty("the verdict card and grid are standard controls"); - Failures(verdict).Should().BeEmpty(); - Areas(verdict).Should().Contain(a => TypeOf(a.Control) == nameof(LabelControl) - && a.Control.TryGetProperty("data", out var d) && (d.GetString() ?? "").Contains(Person, StringComparison.Ordinal), - "the card is headed with the user the check ran for"); - } - - /// The area of the Check button in a Check access frame, or null. - private static string? CheckButton(JsonElement frame) - => Areas(frame).FirstOrDefault(a => TypeOf(a.Control) == nameof(ButtonControl) - && a.Control.TryGetProperty("data", out var t) && t.ValueKind == JsonValueKind.String && t.GetString() == "Check").Id; - - /// How many rows the data slot holds in the frame. - private static int Rows(JsonElement frame, string dataId) - => frame.TryGetProperty("data", out var data) - && data.TryGetProperty(JsonSerializer.Serialize(dataId), out var rows) - && rows.ValueKind == JsonValueKind.Array - ? rows.GetArrayLength() - : -1; - - /// - /// The negative control for : a tab whose builder throws settles with an - /// Error banner, and the check sees it — so a green tab above is a tab that rendered. - /// - [Fact(Timeout = 120000)] - public async Task AFailedTab_IsDetected_NotPassedAsStandard() - { - var frame = await Frames(Person, FailingTabId) - .Should().Within(Budget) - .Match(f => Settled(f, $"/Settings/{FailingTabId}"), "the failing probe tab settles", - TestContext.Current.CancellationToken); - - Failures(frame).Should().NotBeEmpty("the builder threw, so the pane holds the failure banner"); - } - - /// - /// The Profile tab EMBEDS the profile editor (EditProfile) through a - /// , which runs on its own layout stream — so the Settings frame - /// alone does not see inside it. Read that stream too, settled on its own sections. - /// - [Fact(Timeout = 120000)] - public async Task TheEmbeddedProfileEditor_IsComposedOfStandardControls() - { - TestUsers.DevLogin(Mesh, new AccessContext - { ObjectId = Person, Name = "Standard Controls Person", Email = $"{Person}@meshweaver.io" }); - var frame = await GetClient().GetWorkspace() - .GetRemoteStream(new Address(Person), - new LayoutAreaReference(UserActivityLayoutAreas.EditProfileArea)) - .Select(change => change.Value) - .Should().Within(Budget) - .Match(f => Areas(f).Any(a => TypeOf(a.Control) == nameof(MeshNodeContentEditorControl)) - && Areas(f).Count(a => TypeOf(a.Control) == nameof(MarkdownEditorControl)) >= 2, - "the profile editor renders its sections", TestContext.Current.CancellationToken); - - NonStandard(frame).Should().BeEmpty("the embedded profile editor is standard controls only"); - } - - /// - /// The home-page editor (the user node's Edit area): its heading is an H4 label and its - /// "saved automatically" line a , around the platform's Markdown editor. - /// - [Fact(Timeout = 120000)] - public async Task TheHomeEditor_IsComposedOfStandardControls() - { - TestUsers.DevLogin(Mesh, new AccessContext - { ObjectId = Person, Name = "Standard Controls Person", Email = $"{Person}@meshweaver.io" }); - var frame = await GetClient().GetWorkspace() - .GetRemoteStream(new Address(Person), - new LayoutAreaReference(MeshNodeLayoutAreas.EditArea)) - .Select(change => change.Value) - .Should().Within(Budget) - .Match(f => Areas(f).Any(a => TypeOf(a.Control) == nameof(MarkdownEditorControl)) - && Areas(f).Any(a => TypeOf(a.Control) == nameof(LabelControl) - && a.Control.TryGetProperty("data", out var d) && d.GetString() == "Edit your home page"), - "the home editor renders its header and editor", TestContext.Current.CancellationToken); - - NonStandard(frame).Should().BeEmpty("the home editor holds no hand-built markup"); - Areas(frame).Should().Contain(a => TypeOf(a.Control) == nameof(LabelControl) - && a.Control.TryGetProperty("data", out var d) && d.GetString() == "Changes are saved automatically" - && a.Control.TryGetProperty("color", out var c) && c.GetString() == Controls.HintColor, - "the autosave note is a hint label"); - } - - /// - /// The API tokens and Connected instances forms are platform cards (), not - /// inline-styled boxes. - /// - [Theory(Timeout = 120000)] - [InlineData(ApiTokensSettingsTab.TabId)] - [InlineData(InstancesSettingsTab.TabId)] - public async Task TheCreateForms_AreCards(string tab) - { - var frame = await Frames(Person, tab) - .Should().Within(Budget) - .Match(f => Settled(f, $"/Settings/{tab}"), $"the '{tab}' tab renders", - TestContext.Current.CancellationToken); - - Areas(frame).Should().Contain(a => a.Control.TryGetProperty("skins", out var skins) - && skins.ValueKind == JsonValueKind.Array - && skins.EnumerateArray().Any(s => s.TryGetProperty("$type", out var t) && t.GetString() == nameof(CardSkin)), - "the form is a section on the platform's card skin"); - Areas(frame).Should().NotContain(a => a.Control.TryGetProperty("style", out var st) - && st.ValueKind == JsonValueKind.String && st.GetString()!.Contains("border-radius"), - "no control emulates a card with inline CSS"); - } - - /// The detector itself: a frame that DOES hold hand-built markup or a retired class is flagged. - [Fact] - public void TheDetector_FlagsHtmlAndRetiredClasses_AndNothingElse() - { - var frame = JsonDocument.Parse(""" - {"areas":{ - "\"a\"":{"$type":"HtmlControl","data":"

x

"}, - "\"b\"":{"$type":"TextFieldControl","class":"settings-menu-search"}, - "\"c\"":{"$type":"StackControl","class":"settings-content-pane"}, - "\"d\"":{"$type":"LabelControl","data":"Account"} - }} - """).RootElement; - - Assert.Equal(["a: HtmlControl", "b: class 'settings-menu-search'"], NonStandard(frame)); - } - - /// The Check access verdict rows: one per checked permission, Allowed exactly where granted. - [Fact] - public void ThePermissionRows_ReadTheVerdict() - { - var rows = SettingsLayoutArea.PermissionRows(Permission.Read | Permission.Update, "Allowed", "Denied"); - - rows.Select(r => r.Permission).Should().Equal(SettingsLayoutArea.CheckedPermissions.Select(p => p.ToString())); - rows.Where(r => r.Status == "Allowed").Select(r => r.Permission).Should().Equal("Read", "Update"); - rows.Where(r => r.Status == "Denied").Select(r => r.Permission).Should().Equal("Create", "Delete", "Comment"); - } -} diff --git a/test/Memex.Portal.Shared.Test/SetupCompositionTest.cs b/test/Memex.Portal.Shared.Test/SetupCompositionTest.cs deleted file mode 100644 index 0ef7262bcd..0000000000 --- a/test/Memex.Portal.Shared.Test/SetupCompositionTest.cs +++ /dev/null @@ -1,300 +0,0 @@ -using System.Collections.Immutable; -using Memex.Portal.Shared.Setup; -using MeshWeaver.AI; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The first-run wizard's decision: answers in, instance manifest out — or a refusal. -/// -/// Every refusal asserted here is one the operator can still fix while the mistake is cheap. -/// After this the answer is DURABLE and the process restarts, so the same mistake surfaces as a -/// boot failure whose message names a symptom (Unknown storage type, a 400 at a token -/// endpoint, a blank model picker) rather than the choice that caused it. -/// -public class SetupCompositionTest -{ - private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-09-03T10:00:00Z"); - private static readonly SetupStrings Strings = new("en"); - - private static readonly IProviderKeyProtector Protector = - new ProviderKeyProtector(new LiteralMasterKeyProvider(Convert.ToBase64String(new byte[32]))); - - private static readonly StorageBackendCatalog Backends = - new(["Sqlite", "PostgreSql", "FileSystem"]); - - private static readonly SetupCatalog Catalog = new( - Storage: - [ - new SetupStorageOption("Sqlite", "SQLite"), - new SetupStorageOption("PostgreSql", "PostgreSQL", NeedsConnectionString: true), - new SetupStorageOption("FileSystem", "Files", NeedsBasePath: true), - ], - SignIn: - [ - new SetupSignInOption("Dev", "Developer login", "Authentication", IsSwitch: true), - new SetupSignInOption("Microsoft", "Microsoft", "Authentication:Microsoft", HasTenant: true), - new SetupSignInOption("GitHub", "GitHub", "GitHub:OAuth"), - ], - Ai: - [ - new SetupAiOption("Anthropic", "Anthropic", "Anthropic"), - new SetupAiOption("OpenAICompatible", "Local / OpenAI-compatible", "OpenAICompatible", - RequiresApiKey: false, TakesEndpoint: true), - ], - Modules: - [ - new SetupModuleOption("MeshWeaver.Hosting.Grpc.dll", "gRPC", PreSelected: true), - new SetupModuleOption("MeshWeaver.Speech.dll", "Speech"), - ]); - - private static SetupPlan Compose(SetupAnswers answers) - => SetupComposition.Compose(answers, Catalog, Backends, Protector, Strings, Now); - - /// Composes with NO protector — the state of an install that could neither be given a - /// master key nor mint one. Separate from because a nullable parameter - /// defaulting to the real protector cannot express "explicitly none", which is the whole case - /// under test. - private static SetupPlan ComposeUnprotected(SetupAnswers answers) - => SetupComposition.Compose(answers, Catalog, Backends, protector: null, Strings, Now); - - private static SetupAnswers Minimal() => new() - { - StorageType = "Sqlite", - EnableDevLogin = true, - DevAdminUsers = "roland", - EmbeddingEndpoint = "http://localhost:11434/v1", - }; - - [Fact] - public void TheDefaultAnswers_ProduceACompleteManifest() - { - var plan = Compose(Minimal()); - - Assert.Empty(plan.Problems); - Assert.NotNull(plan.Manifest); - Assert.Equal(InstanceSetupState.Complete, plan.Manifest!.State); - Assert.Equal("Sqlite", plan.Manifest.Storage!.Type); - Assert.True(plan.Manifest.SignIn!.EnableDevLogin); - Assert.Equal(Now, plan.Manifest.SetUpAt); - } - - [Fact] - public void ABackendTheImageDoesNotShip_IsRefusedHere_NotAtTheNextBoot() - { - // 🚨 Recording an unshipped backend is a DURABLE answer that fails after the wizard is - // gone, with "Unknown storage type: 'Cosmos'" and no surface left to correct it on. - var plan = Compose(Minimal() with { StorageType = "Cosmos" }); - - Assert.Null(plan.Manifest); - var problem = Assert.Single(plan.Problems); - Assert.Contains("Cosmos", problem); - // Naming what IS available turns a dead end into a next step. - Assert.Contains("Sqlite", problem); - } - - [Fact] - public void ABackendThatNeedsAConnectionString_IsRefusedWithout() - { - var plan = Compose(Minimal() with { StorageType = "PostgreSql" }); - - Assert.Null(plan.Manifest); - Assert.Contains(plan.Problems, p => p.Contains("PostgreSQL")); - } - - [Fact] - public void NoSignInRouteAtAll_IsRefused_BecauseNobodyCouldEnter() - { - var plan = Compose(Minimal() with { EnableDevLogin = false, DevAdminUsers = null }); - - Assert.Null(plan.Manifest); - Assert.Contains(plan.Problems, p => p.Contains("sign in")); - } - - [Fact] - public void AProviderWithAClientIdButNoSecret_IsRefused() - { - // 🚨 Strictly worse than an absent provider: the button renders, the operator believes - // sign-in works, and it fails at the token exchange the first time somebody uses it. - var plan = Compose(Minimal() with - { - SignIn = [new SetupSignInAnswer("Microsoft", "client-id", null, null)], - }); - - Assert.Null(plan.Manifest); - Assert.Contains(plan.Problems, p => p.Contains("Microsoft") && p.Contains("secret")); - } - - [Fact] - public void AConfiguredProvider_IsStoredEncrypted_WithTheCatalogsSection() - { - var plan = Compose(Minimal() with - { - SignIn = [new SetupSignInAnswer("GitHub", "gh-client", null, "gh-secret")], - }); - - Assert.Empty(plan.Problems); - var provider = Assert.Single(plan.Manifest!.SignIn!.Providers); - Assert.Equal("GitHub", provider.Name); - // The section comes from the catalog, so GitHub's hand-rolled OAuth keys land on - // GitHub:OAuth rather than a composed-from-the-name Authentication:GitHub that nothing reads. - Assert.Equal("GitHub:OAuth", provider.Section); - // 🚨 Never the plaintext. A manifest is copied, backed up and pasted into issues. - Assert.StartsWith("enc:v1:", provider.ClientSecret); - Assert.DoesNotContain("gh-secret", provider.ClientSecret); - } - - [Fact] - public void WithNoMasterKey_ASecretIsRefused_NeverStoredInTheClear() - { - // The 2026-08-24 shape: an unconfigured deployment persisted a live provider key in - // cleartext with nothing failing and nothing logged. Refusing loudly is the cure. - var plan = ComposeUnprotected( - Minimal() with { SignIn = [new SetupSignInAnswer("GitHub", "gh", null, "gh-secret")] }); - - Assert.Null(plan.Manifest); - Assert.Contains(plan.Problems, p => p.Contains("MasterKey")); - } - - [Fact] - public void AModelProviderRequiringAKey_IsRefusedWithout_ButAnUntouchedRowIsSimplyNotChosen() - { - // An untouched row must not become an empty provider that then looks configured-but-broken - // in the model picker — so "no key AND no endpoint" is "not chosen", not an error. - var untouched = Compose(Minimal() with - { - Ai = [new SetupAiAnswer("Anthropic", null, null)], - }); - Assert.Empty(untouched.Problems); - Assert.Empty(untouched.Manifest!.Ai!.Providers); - - // …but an endpoint with no key on a provider that requires one is a half-answer. - var half = Compose(Minimal() with - { - Ai = [new SetupAiAnswer("Anthropic", null, "https://api.anthropic.com/v1/messages")], - }); - Assert.Null(half.Manifest); - Assert.Contains(half.Problems, p => p.Contains("Anthropic")); - } - - [Fact] - public void AKeylessProvider_IsAcceptedOnItsEndpointAlone() - { - // A local Ollama has no API key by design; demanding one would make the offline - // configuration impossible to express. - var plan = Compose(Minimal() with - { - Ai = [new SetupAiAnswer("OpenAICompatible", null, "http://localhost:11434/v1")], - }); - - Assert.Empty(plan.Problems); - var provider = Assert.Single(plan.Manifest!.Ai!.Providers); - Assert.Null(provider.ApiKey); - Assert.Equal("http://localhost:11434/v1", provider.Endpoint); - } - - [Fact] - public void NoEmbeddingsEndpoint_Warns_BecauseSearchWouldDegradeSilently() - { - // 🚨 This warning is the whole reason the embeddings question is asked. Without an embedder - // every write stores a NULL embedding and the vector provider contributes nothing — search - // falls back to lexical with no error, no log line, and no way to tell from the outside. - var plan = Compose(Minimal() with { EmbeddingEndpoint = null }); - - Assert.Empty(plan.Problems); - Assert.NotNull(plan.Manifest); - Assert.Contains(plan.Warnings, w => w.Contains("meaning")); - Assert.Null(plan.Manifest!.Ai!.Embeddings); - } - - [Fact] - public void AnEmbeddingsEndpointWithNoModel_TakesTheSameDefaultTheCodeDoes() - { - var plan = Compose(Minimal() with { EmbeddingModel = null }); - - Assert.Equal( - InstanceEmbeddingsSelection.DefaultModel, - plan.Manifest!.Ai!.Embeddings!.Model); - } - - [Fact] - public void DevLoginWithNoAdministrator_Warns_ButIsAllowed() - { - // Legal — someone else may hold Auth:GlobalAdmins — so a warning, not a refusal. But it is - // the "portal you can log into and administer nothing on" state, worth saying out loud. - var plan = Compose(Minimal() with { DevAdminUsers = null }); - - Assert.NotNull(plan.Manifest); - Assert.Contains(plan.Warnings, w => w.Contains("administrator")); - } - - [Fact] - public void AModuleTheImageDoesNotShip_IsRefused() - { - var plan = Compose(Minimal() with { BootModules = ["Nope.dll"] }); - - Assert.Null(plan.Manifest); - Assert.Contains(plan.Problems, p => p.Contains("Nope.dll")); - } - - [Fact] - public void EveryProblemIsReported_NotJustTheFirst() - { - // An operator who fixes one refusal and resubmits into the next is being made to walk a - // list one item at a time — on a form that restarts the instance when it finally passes. - var plan = Compose(new SetupAnswers { StorageType = "Cosmos", BootModules = ["Nope.dll"] }); - - Assert.Null(plan.Manifest); - Assert.True(plan.Problems.Count >= 3, $"expected storage + sign-in + module, got: {string.Join(" | ", plan.Problems)}"); - } - - [Fact] - public void NoPackagesChosen_FallsBackToTheDefaultPattern_NotToNothing() - { - // 🚨 A hand-typed package list is stale the moment the next package ships, and its symptom - // lands nowhere near its cause: the new package simply is not there. - var plan = Compose(Minimal()); - - Assert.Equal(InstanceSetupDefaults.ProvisionPackages, plan.Manifest!.ProvisionPackages); - } - - [Fact] - public void TheComposedManifest_RoundTripsThroughDiskAndProjectsBack() - { - // The two halves joined: what the wizard writes is what the next boot reads as configuration. - var plan = Compose(Minimal() with - { - SignIn = [new SetupSignInAnswer("Microsoft", "m-client", null, "m-secret")], - Ai = [new SetupAiAnswer("Anthropic", "sk-ant-xyz", null)], - }); - Assert.Empty(plan.Problems); - - var root = Path.Combine(Path.GetTempPath(), "mw-setup-" + Guid.NewGuid().ToString("N")); - try - { - plan.Manifest!.Write(root); - var entries = InstanceManifestProjection.ToConfiguration( - InstanceManifest.Read(root), Protector); - - Assert.Equal("Sqlite", entries["Graph:Storage:Type"]); - Assert.Equal("true", entries["Authentication:EnableDevLogin"]); - Assert.Equal("m-client", entries["Authentication:Microsoft:ClientId"]); - Assert.Equal("m-secret", entries["Authentication:Microsoft:ClientSecret"]); - Assert.Equal("common", entries["Authentication:Microsoft:TenantId"]); - Assert.Equal("sk-ant-xyz", entries["Anthropic:ApiKey"]); - Assert.Equal("http://localhost:11434/v1", entries["Embedding:Endpoint"]); - - // …and the file on disk holds no plaintext secret anywhere. - var raw = File.ReadAllText(InstanceManifest.PathFor(root)); - Assert.DoesNotContain("m-secret", raw); - Assert.DoesNotContain("sk-ant-xyz", raw); - } - finally - { - try { Directory.Delete(root, recursive: true); } catch { /* best effort */ } - } - } -} diff --git a/test/Memex.Portal.Shared.Test/SetupHandOffVerifierTest.cs b/test/Memex.Portal.Shared.Test/SetupHandOffVerifierTest.cs deleted file mode 100644 index a64cb4ac50..0000000000 --- a/test/Memex.Portal.Shared.Test/SetupHandOffVerifierTest.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Text; -using Memex.Portal.Shared.Authentication; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The door in front of the one endpoint that carries a client's secrets. -/// -/// A signature alone would be enough for an inbox event and is not enough here: it signs the -/// body and nothing about WHEN, so a captured request stays valid forever. The three checks are -/// asserted together — authentic, fresh, and not seen before — along with the property that a -/// replay cannot be spent by an unauthentic caller. -/// -[Collection("handoff")] -public class SetupHandOffVerifierTest -{ - private static readonly byte[] Body = Encoding.UTF8.GetBytes("""{"Deployment":"fabrikam"}"""); - private const string Secret = "shared-secret"; - private static readonly DateTimeOffset Now = new(2026, 9, 16, 12, 0, 0, TimeSpan.Zero); - - private static string Stamp(DateTimeOffset at) => at.ToUnixTimeSeconds().ToString(); - - public SetupHandOffVerifierTest() => SetupHandOffVerifier.Reset(); - - [Fact] - public void AnAuthenticFreshUnseenRequestIsAccepted() - { - Assert.Equal(HandOffRefusal.None, SetupHandOffVerifier.Verify( - Secret, Body, SetupHandOffVerifier.Sign(Body, Secret), Stamp(Now), "nonce-1", Now)); - } - - [Fact] - public void WithNoSecretConfiguredTheEndpointDoesNotExist() - { - // NotConfigured answers 404, so a control instance that does not offer this never advertises it. - Assert.Equal(HandOffRefusal.NotConfigured, SetupHandOffVerifier.Verify( - null, Body, SetupHandOffVerifier.Sign(Body, Secret), Stamp(Now), "n", Now)); - } - - [Fact] - public void AWrongSignatureAndAMissingOneAreDistinguished() - { - Assert.Equal(HandOffRefusal.Unsigned, SetupHandOffVerifier.Verify( - Secret, Body, null, Stamp(Now), "n", Now)); - Assert.Equal(HandOffRefusal.Unsigned, SetupHandOffVerifier.Verify( - Secret, Body, SetupHandOffVerifier.Sign(Body, Secret), Stamp(Now), nonce: null, Now)); - Assert.Equal(HandOffRefusal.BadSignature, SetupHandOffVerifier.Verify( - Secret, Body, SetupHandOffVerifier.Sign(Body, "another-secret"), Stamp(Now), "n", Now)); - } - - [Fact] - public void ATamperedBodyIsRefused() - { - var signature = SetupHandOffVerifier.Sign(Body, Secret); - var tampered = Encoding.UTF8.GetBytes("""{"Deployment":"memex"}"""); - Assert.Equal(HandOffRefusal.BadSignature, SetupHandOffVerifier.Verify( - Secret, tampered, signature, Stamp(Now), "n", Now)); - } - - [Fact] - public void AStaleOrFutureTimestampIsRefused() - { - var signature = SetupHandOffVerifier.Sign(Body, Secret); - - // Too old: this is what bounds how long a captured request stays useful. - Assert.Equal(HandOffRefusal.Stale, SetupHandOffVerifier.Verify( - Secret, Body, signature, Stamp(Now.AddMinutes(-6)), "n1", Now)); - - // From the future: otherwise a captured request's life could simply be extended. - Assert.Equal(HandOffRefusal.Stale, SetupHandOffVerifier.Verify( - Secret, Body, signature, Stamp(Now.AddMinutes(6)), "n2", Now)); - - // Unparseable is stale, not "unsigned": there is no timestamp to judge. - Assert.Equal(HandOffRefusal.Stale, SetupHandOffVerifier.Verify( - Secret, Body, signature, "not-a-number", "n3", Now)); - - // Ordinary clock skew still passes. - Assert.Equal(HandOffRefusal.None, SetupHandOffVerifier.Verify( - Secret, Body, signature, Stamp(Now.AddMinutes(-4)), "n4", Now)); - } - - [Fact] - public void TheSameRequestIsAcceptedOnceAndRefusedAfterwards() - { - var signature = SetupHandOffVerifier.Sign(Body, Secret); - Assert.Equal(HandOffRefusal.None, SetupHandOffVerifier.Verify( - Secret, Body, signature, Stamp(Now), "nonce-replay", Now)); - Assert.Equal(HandOffRefusal.Replayed, SetupHandOffVerifier.Verify( - Secret, Body, signature, Stamp(Now), "nonce-replay", Now)); - } - - [Fact] - public void AnUnauthenticCallerCannotBurnANonce() - { - // The replay check runs LAST. Otherwise anybody who can reach the endpoint could spend the - // nonces of requests they do not own, and the real instance's hand-off would be refused. - Assert.Equal(HandOffRefusal.BadSignature, SetupHandOffVerifier.Verify( - Secret, Body, SetupHandOffVerifier.Sign(Body, "wrong"), Stamp(Now), "victim-nonce", Now)); - - Assert.Equal(HandOffRefusal.None, SetupHandOffVerifier.Verify( - Secret, Body, SetupHandOffVerifier.Sign(Body, Secret), Stamp(Now), "victim-nonce", Now)); - } - - [Fact] - public void ANonceOutsideTheWindowIsForgotten() - { - var signature = SetupHandOffVerifier.Sign(Body, Secret); - Assert.Equal(HandOffRefusal.None, SetupHandOffVerifier.Verify( - Secret, Body, signature, Stamp(Now), "old-nonce", Now)); - - // Well past the window the nonce is dropped — a request signed then is Stale anyway, so the - // cache never has to grow without bound to stay correct. - var later = Now.AddMinutes(30); - Assert.Equal(HandOffRefusal.None, SetupHandOffVerifier.Verify( - Secret, Body, SetupHandOffVerifier.Sign(Body, Secret), Stamp(later), "old-nonce", later)); - } - - [Fact] - public void ASecretNeverPrintsItselfOnTheReceivingSideEither() - { - var secret = new SetupHandOffSecret("Authentication__Microsoft__ClientSecret", "s3cret", "fabrikam-Authentication-Microsoft-ClientSecret"); - Assert.DoesNotContain("s3cret", secret.ToString()); - Assert.Contains("redacted", secret.ToString()); - } -} diff --git a/test/Memex.Portal.Shared.Test/SetupIdentityPhaseTest.cs b/test/Memex.Portal.Shared.Test/SetupIdentityPhaseTest.cs deleted file mode 100644 index 8070029e47..0000000000 --- a/test/Memex.Portal.Shared.Test/SetupIdentityPhaseTest.cs +++ /dev/null @@ -1,477 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Memex.Portal.Shared.Setup; -using Memex.Portal.Shared.Test.Fakes; -using MeshWeaver.Mesh; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// PHASE ONE of the first-run wizard: name this instance, claim an id, register, and come back -/// entitled to a plugin catalog. -/// -/// The order is the point. Registration is what gives the instance a plan, the plan is what -/// lists the plugins, and the plugins are where the remaining answers — the database above all — -/// come from. Asking "which database?" first would be asking before there is anything to choose. -/// -/// Every registry call here goes to : a real registration claims an -/// id GLOBALLY and the platform never re-issues one, so a suite pointed at the live registry would -/// burn a permanent id per run on shared infrastructure. -/// -public class SetupIdentityPhaseTest : IDisposable -{ - private readonly string root = - Path.Combine(Path.GetTempPath(), "mw-identity-" + Guid.NewGuid().ToString("N")); - - private readonly FakeRegistry registry = new(); - - public SetupIdentityPhaseTest() => Directory.CreateDirectory(root); - - public void Dispose() - { - try { Directory.Delete(root, recursive: true); } - catch { /* temp cleanup is the OS's problem, never a test failure */ } - } - - private static readonly SetupCatalog Catalog = new( - Storage: [new SetupStorageOption("Sqlite", "SQLite")], - SignIn: [new SetupSignInOption("Dev", "Developer login", "Authentication", IsSwitch: true)], - Ai: [], - Modules: []); - - private WebApplication BuildApp() - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [ModuleRoot.ConfigKey] = root, - // The image ships Sqlite and PostgreSql; ExoticDb it cannot open. - ["PluginCatalog:RegistryUrl"] = "https://registry.example", - }); - builder.Services.AddSingleton(new InstanceSetupStatusAccessor(static () => true)); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(new StorageBackendCatalog(["Sqlite", "PostgreSql"])); - builder.Services.AddSingleton(new StubCatalog(Catalog)); - builder.Services.AddHttpClient() - .ConfigurePrimaryHttpMessageHandler(() => registry); - - var app = builder.Build(); - app.MapInstanceSetup(); - app.Start(); - return app; - } - - private static string TokenOf(WebApplication app) => - app.Services.GetRequiredService().Value; - - [Fact] - public async Task AFreshInstance_IsAskedForItsNameFirst_NotItsDatabase() - { - using var app = BuildApp(); - - var html = await app.GetTestClient().GetStringAsync("/setup"); - - Assert.Contains("Welcome", html); - Assert.Contains("Instance name", html); - // 🚨 The database question must NOT be on this page. It cannot be answered yet — the options - // come from what the registration entitles the instance to. - Assert.DoesNotContain("name=\"storage.type\"", html); - } - - [Fact] - public async Task TheIdIsAGuid_MintedForTheOperator_AndNotEditable() - { - using var app = BuildApp(); - - var html = await app.GetTestClient().GetStringAsync("/setup"); - - var match = System.Text.RegularExpressions.Regex.Match( - html, @"name=""identity\.id""[^>]*value=""(?[^""]+)"""); - Assert.True(match.Success, "the page must show the id it is about to claim"); - var id = match.Groups["id"].Value; - - Assert.True(Guid.TryParse(id, out _), $"expected a guid, got '{id}'"); - Assert.True(InstanceIdRules.IsWellFormed(id), $"the minted id must be registrable: '{id}'"); - // Read-only, because it is claimed permanently: an operator must not be able to type one - // they might type again on another install. - Assert.Matches(@"name=""identity\.id""[^>]*\breadonly\b", html); - } - - [Fact] - public async Task Registering_ClaimsTheId_AndRecordsTheIssuedKeyEncrypted() - { - using var app = BuildApp(); - var client = app.GetTestClient(); - var html = await client.GetStringAsync("/setup"); - var id = System.Text.RegularExpressions.Regex - .Match(html, @"name=""identity\.id""[^>]*value=""(?[^""]+)""").Groups["id"].Value; - - var response = await client.PostAsync("/setup/identity", new FormUrlEncodedContent( - Ownership(TokenOf(app), id, "Roland laptop"))); - - Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); - - var sent = Assert.Single(registry.Registrations); - Assert.Equal(id, sent.InstanceId); - Assert.Equal("Roland laptop", sent.DisplayName); - // An OPEN registration — no key — is the ordinary case and what enrols the free plan. - Assert.Equal("", sent.BootstrapKey); - - var manifest = InstanceManifest.Read(root); - Assert.NotNull(manifest); - Assert.Equal(id, manifest!.Identity!.Id); - Assert.Equal("Roland laptop", manifest.Identity.Name); - Assert.Equal("free", manifest.Identity.Plan); - // 🚨 The registry issues the key ONCE and never again. It must be on disk before anything - // else can fail — and encrypted, like every other secret the wizard collects. - Assert.StartsWith("enc:v1:", manifest.Identity.InstanceKey); - Assert.DoesNotContain("mwi_faketestkey", File.ReadAllText(InstanceManifest.PathFor(root))); - // Still in setup: identity answers who, not how. - Assert.Equal(InstanceSetupState.AwaitingModules, manifest.State); - } - - [Fact] - public async Task AfterRegistering_ThePluginListIsShown_AndPostgresIsOfferedAsADatabase() - { - using var app = BuildApp(); - var client = app.GetTestClient(); - await RegisterAsync(app, client); - - var html = await client.GetStringAsync("/setup"); - - // The sections now appear, led by who this instance is. - Assert.Contains("Roland laptop", html); - Assert.Contains("Plan: free", html); - // The plan's plugins. - Assert.Contains("Store", html); - Assert.Contains("PostgreSQL", html); - // 🚨 And Postgres is offered as a DATABASE, because the image can open it — this is the - // step the whole ordering exists for. - Assert.Contains("value=\"PostgreSql\"", html); - // The listing was made with the issued key, not anonymously. - Assert.Contains("mwi_faketestkey", registry.ListTokens); - } - - [Fact] - public async Task AnInstanceNameIsEscapedWhereItIsRendered() - { - // The name is operator input echoed back on every page of phase two. It is the one field - // here a person types freely, so it is the one that must not be able to inject markup. - using var app = BuildApp(); - var client = app.GetTestClient(); - var page = await client.GetStringAsync("/setup"); - var id = System.Text.RegularExpressions.Regex - .Match(page, @"name=""identity\.id""[^>]*value=""(?[^""]+)""").Groups["id"].Value; - await client.PostAsync("/setup/identity", new FormUrlEncodedContent( - Ownership(TokenOf(app), id, ""))); - - var html = await client.GetStringAsync("/setup"); - - Assert.DoesNotContain(""; - - public static TheoryData Pages() => new() - { - { "passkey", StepUpPages.Passkey(English, "/auth/step-up/enroll") }, - { "totp", StepUpPages.Totp(English, "/auth/step-up/totp", "/auth/step-up/enroll") }, - { "message", StepUpPages.Message(English, "Refused.", "/") }, - { "enroll-needed", StepUpPages.EnrollNeeded(English, "/auth/step-up/enroll") }, - { "enroll", StepUpPages.Enroll(English, PasskeyOnly(), mayEnrollTotp: true, HostileReceipt, null, false, "/") }, - { "enroll-confirm-first", StepUpPages.Enroll(English, null, false, null, "/confirm", false, "/") }, - }; - - [Theory] - [MemberData(nameof(Pages))] - public void NoPageCarriesAnExecutableInlineScriptOrAnInlineHandler(string page, string html) - { - Assert.True(InlineScriptViolations(html).Count == 0, - $"{page}: {string.Join(" | ", InlineScriptViolations(html))}"); - } - - [Fact] - public void ThePassKeyPageLoadsTheScriptFileAndNamesItsBehaviour() - { - var html = StepUpPages.Passkey(English, "/auth/step-up/enroll"); - Assert.Contains($"", html); - Assert.Equal("passkey", Data(html).GetProperty("page").GetString()); - Assert.False(string.IsNullOrEmpty(Data(html).GetProperty("texts").GetProperty("working").GetString())); - } - - [Fact] - public void TheEnrollPageCarriesItsReceiptAsDataThatCannotCloseTheBlock() - { - var html = StepUpPages.Enroll(English, PasskeyOnly(), true, HostileReceipt, null, false, "/"); - var data = Data(html); - Assert.Equal("enroll", data.GetProperty("page").GetString()); - // The value survives as DATA, byte for byte… - Assert.Equal(HostileReceipt, data.GetProperty("values").GetProperty("receipt").GetString()); - // …and the page holds exactly the two script elements it means to: the data block and the file. - Assert.Equal(2, Regex.Matches(html, "()!.Template; - var get = action.GetCustomAttribute()!.Template; - Assert.Equal(StepUpPages.ScriptPath, "/" + route + "/" + get); - Assert.NotNull(action.GetCustomAttribute()); - var result = new StepUpController(null!, null!, null!, null!, null!, null!).PageScript(); - Assert.Equal(StepUpPages.ScriptSource, result.Content); - Assert.StartsWith("text/javascript", result.ContentType); - } - - /// - /// Negative control: the scanner is the instrument every assertion above rests on, so it must - /// FIND the shapes it is there to find — the inline block these pages used to emit, an inline - /// handler and a javascript: URL. - /// - [Fact] - public void TheScannerFindsAnInlineScriptAHandlerAndAJavascriptUrl() - { - Assert.Single(InlineScriptViolations("

x

")); - Assert.Single(InlineScriptViolations("")); - Assert.Single(InlineScriptViolations("
x")); - Assert.Empty(InlineScriptViolations("")); - } - - /// - /// Negative control for the attribute parse: a name that merely CONTAINS src or - /// type is not that attribute. An inline script carrying data-src has no - /// src, and one carrying data-type="application/json" is still executable — both - /// must be reported. Likewise data-onclick is not a handler and data-href is not a URL. - /// - [Fact] - public void LookalikeAttributeNamesAreNotTheAttributes() - { - Assert.Single(InlineScriptViolations("")); - Assert.Single(InlineScriptViolations("")); - Assert.Single(InlineScriptViolations("")); - Assert.Empty(InlineScriptViolations("
x
")); - // …and the exact names still count, in any case and quoting. - Assert.Empty(InlineScriptViolations("")); - } - - private static JsonElement Data(string html) - { - var match = Regex.Match(html, - $"", - RegexOptions.Singleline); - Assert.True(match.Success, "the page carries no data block"); - return JsonDocument.Parse(match.Groups[1].Value).RootElement; - } - - /// - /// Every construct a script-src without 'unsafe-inline' refuses: a <script> - /// with no src whose type the browser executes, an on* attribute, and a - /// javascript: URL. Attributes are parsed per tag and compared by EXACT name, so - /// data-src is not src, data-type is not type and - /// data-onclick is not a handler. - /// - internal static IReadOnlyList InlineScriptViolations(string html) - { - var found = new List(); - foreach (Match tag in Regex.Matches(html, @"<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^\s=/>]+(?:\s*=\s*(?:""[^""]*""|'[^']*'|[^\s>]+))?)*)\s*/?>")) - { - var name = tag.Groups[1].Value; - var attributes = Attributes(tag.Groups[2].Value); - if (name.Equals("script", StringComparison.OrdinalIgnoreCase) - && !attributes.ContainsKey("src") - && IsExecutableType(attributes.TryGetValue("type", out var type) ? type : "")) - found.Add("inline "); - foreach (var (attribute, value) in attributes) - { - if (attribute.Length > 2 && attribute.StartsWith("on", StringComparison.Ordinal)) - found.Add("inline handler " + attribute); - if (attribute is "href" or "src" or "action" or "formaction" - && System.Net.WebUtility.HtmlDecode(value).TrimStart().StartsWith("javascript:", StringComparison.OrdinalIgnoreCase)) - found.Add("javascript: URL in " + attribute); - } - } - return found; - } - - /// One tag's attributes, by lower-cased exact name (the first occurrence wins, as in HTML). - private static Dictionary Attributes(string text) - { - var attributes = new Dictionary(StringComparer.Ordinal); - foreach (Match a in Regex.Matches(text, @"([^\s=/>]+)(?:\s*=\s*(?:""([^""]*)""|'([^']*)'|([^\s>]+)))?")) - { - var key = a.Groups[1].Value.ToLowerInvariant(); - var value = a.Groups[2].Success ? a.Groups[2].Value - : a.Groups[3].Success ? a.Groups[3].Value - : a.Groups[4].Value; - attributes.TryAdd(key, value); - } - return attributes; - } - - private static bool IsExecutableType(string type) => - type.Length == 0 - || type.Equals("module", StringComparison.OrdinalIgnoreCase) - || type.Equals("importmap", StringComparison.OrdinalIgnoreCase) - || type.Contains("javascript", StringComparison.OrdinalIgnoreCase) - || type.Contains("ecmascript", StringComparison.OrdinalIgnoreCase); -} diff --git a/test/Memex.Portal.Shared.Test/StorageSectionWithoutTypeIsNotConfiguredTest.cs b/test/Memex.Portal.Shared.Test/StorageSectionWithoutTypeIsNotConfiguredTest.cs deleted file mode 100644 index 054c753a8c..0000000000 --- a/test/Memex.Portal.Shared.Test/StorageSectionWithoutTypeIsNotConfiguredTest.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Memex.Portal.Shared.Setup; -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A Graph:Storage section that exists but names no Type means NO STORAGE — it does -/// not mean "file system". -/// -/// 🚨 Two changes that are each correct combine into a silent disaster without this. -/// The deployed image states Graph:Storage:UnanchoredQueryPolicy deliberately, so that no -/// chart, ConfigMap or environment can forget it (the image ci.7658 incident, where every signed-in -/// request answered 503). The same image deliberately states no Graph:Storage:Type, so the -/// first-run setup wizard is reachable. The section therefore EXISTS while answering nothing about -/// storage — and GraphStorageConfig.Type carries the initializer FileSystem, so -/// anything that BINDS that section reads a working-looking file-system store. -/// -/// The consequences of getting this wrong are both silent: the wizard becomes unreachable -/// again, and a real instance is pointed at container-ephemeral disk — issue #435's shape, arriving -/// from a file nobody edited. So both readers test the RAW key, and this pins that they do. -/// -public class StorageSectionWithoutTypeIsNotConfiguredTest -{ - private static IConfiguration Config(params (string Key, string? Value)[] entries) - => new ConfigurationBuilder() - .AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => e.Value)) - .Build(); - - [Fact] - public void APolicyOnlySection_ReadsAsAwaitingSetup() - { - // Exactly the deployed image's shape after the two changes met. - var configuration = Config(("Graph:Storage:UnanchoredQueryPolicy", "ServeAndReport")); - - Assert.True(SetupOnlyHost.IsAwaitingSetup(configuration), - "a section carrying only a query policy states no storage — binding it would yield " - + "Type=FileSystem from the record's initializer and boot a real instance onto " - + "container-ephemeral disk."); - } - - [Fact] - public void ABlankType_ReadsAsAwaitingSetup() - // An environment variable cannot be null, only empty — "" is the deployed shape of unset, - // and it is exactly what the chart emits for a values file that states nothing. - => Assert.True(SetupOnlyHost.IsAwaitingSetup( - Config(("Graph:Storage:Type", ""), ("Graph:Storage:UnanchoredQueryPolicy", "ServeAndReport")))); - - [Fact] - public void NoSectionAtAll_ReadsAsAwaitingSetup() - => Assert.True(SetupOnlyHost.IsAwaitingSetup(Config())); - - [Fact] - public void ARealBackend_ReadsAsConfigured() - { - // The negative control: without it every assertion above would pass on a predicate that - // simply always answers true. - Assert.False(SetupOnlyHost.IsAwaitingSetup( - Config(("Graph:Storage:Type", "PostgreSql"), - ("Graph:Storage:UnanchoredQueryPolicy", "ServeAndReport")))); - Assert.False(SetupOnlyHost.IsAwaitingSetup(Config(("Graph:Storage:Type", "Sqlite")))); - } -} diff --git a/test/Memex.Portal.Shared.Test/StoreBootstrapLandsModulesFirstTest.cs b/test/Memex.Portal.Shared.Test/StoreBootstrapLandsModulesFirstTest.cs deleted file mode 100644 index d304befe08..0000000000 --- a/test/Memex.Portal.Shared.Test/StoreBootstrapLandsModulesFirstTest.cs +++ /dev/null @@ -1,718 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Plugin.Packaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using PackagingManifest = MeshWeaver.Plugin.Packaging.PluginManifest; - -namespace Memex.Portal.Shared.Test; - -/// -/// The store's own bootstrap on an image that ships NONE of the modules the store delivers -/// (policies default-install-modules-first and declared-package-set-is-exact) — the shape of a fresh control instance after -/// MeshWeaver.Plugins#2970 took the modules out of the images. -/// -/// Measured there, 2026-10-13: Install of 'Store' failed: NodeType(s) not registered: Skill, -/// Install of 'AI' failed: NodeType 'Store/Plugin' is not registered, 0 installed, 0 up to -/// date, 48 failed, and an empty /data/modules. Store's nodes need Skill, which -/// only the MeshWeaver.AI module (delivered by AI) registers; AI's root is typed -/// Store/Plugin, which only Store declares. A module landed only after its package's -/// content validated, so nothing could install first and no module ever reached the volume. -/// -/// The registry here is in-process and serves the REAL wire shapes (the listing, the package -/// files, the bundle index and a module bundle carrying real assembly bytes) to the REAL -/// and , so the install lane under -/// test is the production one end to end: 's default -/// install, its selection, the install funnel and the module landing. -/// -public abstract class StoreBootstrapTestBase(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// The registry's URL — never resolved; the fake handler answers every request. - protected const string RegistryUrl = "http://registry.bootstrap.test"; - - /// The registry's source name, as the deployment's patterns name it. - protected const string SourceName = "Plugins"; - - /// The module the AI stand-in delivers (the one that registers Skill). - protected const string AiModule = "Bootstrap.Test.AI"; - - /// The module the EntityViews stand-in delivers. - protected const string ViewsModule = "Bootstrap.Test.EntityViews"; - - /// The module the Chat stand-in delivers. - protected const string ChatModule = "Bootstrap.Test.Chat"; - - /// Per-test landing root — the deployment's modules/ tree, never the test host's bin. - protected readonly string LandingRoot = - Path.Combine(Path.GetTempPath(), "mw-bootstrap-" + Guid.NewGuid().ToString("N")); - - /// - /// The store as the registry serves it: Store (declares Store/Plugin, carries a - /// Skill node), AI (module, rooted on Store/Plugin), EntityViews and - /// Chat (modules, rooted on Store/Plugin, Chat requiring AI), and Games — a - /// preInstalled package nobody names. - /// - protected static readonly ImmutableArray Repo = - [ - new("Store/index.json", - """{"$type":"MeshNode","id":"Store","namespace":"","path":"Store","mainNode":"Store","name":"Store","nodeType":"Space","state":"Active"}"""), - new("Store/Plugin.json", - """{"$type":"MeshNode","id":"Plugin","namespace":"Store","path":"Store/Plugin","mainNode":"Store/Plugin","name":"Plugin","nodeType":"NodeType","state":"Active"}"""), - new("Store/Guide.json", - """{"$type":"MeshNode","id":"Guide","namespace":"Store","path":"Store/Guide","mainNode":"Store/Guide","name":"Guide","nodeType":"Skill","state":"Active"}"""), - Root("AI"), - Page("AI"), - Root("EntityViews"), - Page("EntityViews"), - Root("Chat"), - Page("Chat"), - new("Games/index.json", - """{"$type":"MeshNode","id":"Games","namespace":"","path":"Games","mainNode":"Games","name":"Games","nodeType":"Space","state":"Active"}"""), - Page("Games"), - ]; - - // The root carries NO content: the registry's listing (BootstrapRegistry) is what declares the - // module and the requirements, and core registers no `PluginContent` type — an unresolvable - // `$type` here would be content nothing can read (the untyped-content gate). - private static RepoFile Root(string id) => - new($"{id}/index.json", - "{\"$type\":\"MeshNode\",\"id\":\"" + id + "\",\"namespace\":\"\",\"path\":\"" + id - + "\",\"mainNode\":\"" + id + "\",\"name\":\"" + id - + "\",\"nodeType\":\"Store/Plugin\",\"state\":\"Active\"}"); - - private static RepoFile Page(string id) => - new($"{id}/Guide.json", - $$"""{"$type":"MeshNode","id":"Guide","namespace":"{{id}}","path":"{{id}}/Guide","mainNode":"{{id}}/Guide","name":"Guide","nodeType":"Markdown","state":"Active","content":"# {{id}}"}"""); - - /// Every content-file fetch and module-bundle download the registry served, in order - /// (files:{package} / bundle:{package}). - protected ConcurrentQueue Requests { get; } = new(); - - /// - /// The catalog options — the control instance's shape by default: an EXPLICIT set, and nothing - /// said about the preInstalled baseline. One instance, read by every pass, so a fixture can - /// change the declared set between passes. - /// - protected PluginCatalogOptions Options { get; } = new() - { - Registries = [new PluginRegistryReference { Name = SourceName, Url = RegistryUrl, Token = "mwi_bootstrap" }], - InstallByDefault = [$"{SourceName}/EntityViews", $"{SourceName}/Chat"], - }; - - /// What the registry's Chat declares it needs of AI. - protected virtual string ChatRequiresAi => "AI@^1.0.0"; - - /// Whether the registry serves the AI module's bytes (it always advertises them). - protected virtual bool ServesAiBytes => true; - - /// What the boot pass is configured to install — the control instance's explicit set. - protected virtual IReadOnlyList BootSet => [$"{SourceName}/EntityViews", $"{SourceName}/Chat"]; - - /// The packages the registry lists with defaultApp: true — none unless a fixture says. - protected virtual IReadOnlyList DefaultApps => []; - - /// PluginCatalog:InstallPreInstalledPackages as CONFIGURED; null = not set. - protected virtual bool? ConfiguredPreInstalled => null; - - /// What every derived fixture adds to the mesh — the stand-in for "the module loaded". - protected virtual MeshBuilder Arrange(MeshBuilder builder) => builder; - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - Directory.CreateDirectory(LandingRoot); - Options.InstallByDefault = [.. BootSet]; - Options.InstallPreInstalledPackagesConfigured = ConfiguredPreInstalled; - var registry = new BootstrapRegistry(Requests, ChatRequiresAi, ServesAiBytes, [.. DefaultApps]); - return Arrange(base.ConfigureMesh(builder)) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new ModuleLandingService(baseDirectory: LandingRoot)) - .AddSingleton(new HandlerClientFactory(registry)) - .AddSingleton(Options)); - } - - /// - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - try - { - if (Directory.Exists(LandingRoot)) - Directory.Delete(LandingRoot, recursive: true); - } - catch (IOException) - { - // Best-effort cleanup of a temp tree. - } - } - - /// The boot's first default-install pass. - protected Task FirstPass(CancellationToken cancellationToken) => - Mesh.ServiceProvider.GetRequiredService().Completed - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(cancellationToken); - - /// - /// The registry, serving the four routes the consumer reads: GET /api/plugins, - /// POST /api/plugins/files, GET /api/plugins/bundles/index.json and - /// GET /api/plugins/bundles/{plugin}/{version} — the listing stamped with the source name - /// the registry stamps, the files exactly as a node-repo source serves them (repo-relative paths). - /// - private sealed class BootstrapRegistry( - ConcurrentQueue requests, string chatRequiresAi, bool servesAiBytes, ImmutableHashSet defaultApps) - : HttpMessageHandler - { - private static readonly ImmutableDictionary Modules = ImmutableDictionary.CreateRange( - StringComparer.Ordinal, - [ - KeyValuePair.Create("AI", AiModule), - KeyValuePair.Create("EntityViews", ViewsModule), - KeyValuePair.Create("Chat", ChatModule), - ]); - - private PackageManifest Package(string id, bool preInstalled, params string[] requires) => new() - { - DefaultApp = defaultApps.Contains(id), - Id = id, - Name = id, - Kind = PackageKind.NodeRepo, - TargetPartition = id, - SourceFolder = id, - Version = "commit-bootstrap", - Source = SourceName, - PreInstalled = preInstalled, - Requires = [.. requires], - Module = Modules.TryGetValue(id, out var module) ? module : null, - }; - - private ImmutableArray Listing => - [ - Package("Store", preInstalled: false), - Package("AI", preInstalled: true, "Store@^1.0.0"), - Package("EntityViews", preInstalled: true, "Store@^1.0.0"), - Package("Chat", preInstalled: true, "Store@^1.0.0", chatRequiresAi), - Package("Games", preInstalled: true), - ]; - - // HttpMessageHandler's contract is Task-shaped; every answer here is computed synchronously. - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var path = request.RequestUri?.AbsolutePath ?? ""; - if (request.Method == HttpMethod.Get && path == RegistryPackageSource.RoutePrefix) - return Task.FromResult(Json(PluginRegistryPayloads.List(Listing))); - if (request.Method == HttpMethod.Post && path == RegistryPackageSource.RoutePrefix + "/files") - { - using var body = JsonDocument.Parse(request.Content!.ReadAsStream(cancellationToken)); - var id = body.RootElement.GetProperty("id").GetString(); - requests.Enqueue($"files:{id}"); - var prefix = id + "/"; - return Task.FromResult(Json(PluginRegistryPayloads.Files(Repo - .Where(f => f.Path.StartsWith(prefix, StringComparison.Ordinal)) - .Select(f => new PackageFile(f.Path, f.Content)) - .ToList()))); - } - if (request.Method == HttpMethod.Get && path == PluginBundleClient.RoutePrefix + "/index.json") - return Task.FromResult(Json(JsonSerializer.Serialize( - new PluginBundleClient.BundleIndex(null, Modules - .Select(m => new PluginBundleClient.BundleRef( - m.Key, "1.0.0", $"{RegistryUrl}{PluginBundleClient.RoutePrefix}/{m.Key}/1.0.0", m.Value)) - .ToList()), - PluginRegistryPayloads.Json))); - if (request.Method == HttpMethod.Get && path.StartsWith(PluginBundleClient.RoutePrefix + "/", StringComparison.Ordinal)) - { - var plugin = path[(PluginBundleClient.RoutePrefix.Length + 1)..].Split('/')[0]; - requests.Enqueue($"bundle:{plugin}"); - if (Modules.TryGetValue(plugin, out var module) && (servesAiBytes || plugin != "AI")) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(ModuleBundle(plugin, module)), - }); - } - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent($"the bootstrap registry serves no {request.Method} {path}"), - }); - } - - private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK) - { - Content = new StringContent(json, Encoding.UTF8, "application/json"), - }; - } - - /// A packed module bundle carrying REAL assembly bytes — the landing link-probes them, - /// so a byte stand-in would be refused as unreadable. - private static byte[] ModuleBundle(string plugin, string module) - { - var manifestJson = JsonSerializer.Serialize(new - { - plugin, - version = "1.0.0", - module = new { assemblyName = module, assemblies = new[] { module + ".dll" } }, - }); - var buffer = new MemoryStream(); - NuGetPackageWriter.Write( - buffer, - new PackagingManifest(plugin, "MeshWeaver.Plugin." + plugin, "1.0.0", plugin, null, []), - "3.0.0", - [ - new NuGetPackageWriter.Entry( - NuGetPackageWriter.ModuleEntryPathFor(module + ".dll"), - () => new MemoryStream(File.ReadAllBytes(typeof(BundleReader).Assembly.Location))), - ], - manifestJson); - return buffer.ToArray(); - } - - private sealed class HandlerClientFactory(HttpMessageHandler handler) : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); - } -} - -/// -/// 🚨 The FIRST boot of an image that carries none of the store's modules: the module of every -/// selected package lands BEFORE any package's nodes are validated — so the cycle has an entry point — -/// and the selection is exactly the declared set and its requires. -/// -public class StoreBootstrapLandsModulesFirstTest(ITestOutputHelper output) : StoreBootstrapTestBase(output) -{ - [Fact(Timeout = 180_000)] - public async Task AFreshImage_LandsEveryModuleFirst_AndSelectsOnlyTheDeclaredSet() - { - var pass = await FirstPass(TestContext.Current.CancellationToken); - Output.WriteLine($"pass 1: {pass}"); - - // The selection: the two named packages and what they require — never the preInstalled - // package nobody named (it used to add every preInstalled package of the registry: 48). - pass.Packages.Order(StringComparer.Ordinal).Should().Equal(new[] { "Store", "AI", "EntityViews", "Chat" }.Order(StringComparer.Ordinal)); - pass.Packages.Should().NotContain("Games"); - - // The cycle, as production saw it: Store's nodes need `Skill`, which no module in this - // process registers yet. The validation is NOT weakened — the package fails, named. - pass.Failures.Should().Contain("Store"); - pass.FailureCauses.Single(c => c.Package == "Store").Cause.Should().Contain("Skill"); - - // …and yet every module of the selection reached the volume, ahead of the content: the - // next process start registers their NodeTypes. Before the fix the sidecar stayed EMPTY — - // a module landed only after its own content validated, and none ever did. - var activation = ModuleActivationSidecar.Read(LandingRoot); - activation.Entries.Select(e => e.Name).Order(StringComparer.Ordinal).Should().Equal(new[] { AiModule, ViewsModule, ChatModule }.Order(StringComparer.Ordinal)); - activation.Entries.Should().OnlyContain(e => e.Enabled); - activation.PendingRestart.Should().BeTrue("restart-as-activation — the self-updater takes the marker"); - // 🚨 The ORDER is the fix, not just the end state: every module of the selection was - // downloaded before the FIRST package content was fetched. Module-after-its-own-content would - // land the same end state on a later pass and never break the cycle. - var log = Requests.ToList(); - Output.WriteLine($"registry requests: {string.Join(" → ", log)}"); - var firstFiles = log.FindIndex(r => r.StartsWith("files:", StringComparison.Ordinal)); - firstFiles.Should().BeGreaterThan(-1, "the content phase ran"); - foreach (var plugin in new[] { "AI", "EntityViews", "Chat" }) - log.FindIndex(r => r == $"bundle:{plugin}").Should().BeInRange(0, firstFiles - 1, - $"{plugin}'s module must be downloaded before any package content is fetched"); - // …and the pass's ONE proposal is written (or finds the set already naming them), so a - // restart loads them. - var verdict = pass.ModuleSetVerdict ?? ""; - (verdict.StartsWith(InstanceAutoRegistrationService.ModuleSetProposed, StringComparison.Ordinal) - || verdict.StartsWith(InstanceAutoRegistrationService.ModuleSetUnchanged, StringComparison.Ordinal)) - .Should().BeTrue($"the pass's own proposal is written or already current, but its verdict was '{verdict}'"); - var proposed = ModuleSetStore.Read(LandingRoot).Proposed; - proposed.Should().NotBeNull("a module set naming what landed exists"); - proposed!.Generations.Keys.Should().Contain(new[] { AiModule, ViewsModule, ChatModule }); - pass.ModulesLanded.Order(StringComparer.Ordinal).Should().Equal(new[] { AiModule, ViewsModule, ChatModule }.Order(StringComparer.Ordinal)); - pass.ToString().Should().Contain("MODULES LANDED ahead of content"); - } -} - -/// -/// 🚨 The module phase proposes its set BEFORE phase 2 writes any install record, so the selection's -/// own declared floors must bind that proposal: a fresh package requiring AI@^2 must not let a -/// set loading AI 1.0.0 be written just because its record does not exist yet. -/// -public class ModulePhaseProposalHonoursTheSelectionsFloorsTest(ITestOutputHelper output) : StoreBootstrapTestBase(output) -{ - private static PackageManifest Fresh(string id, string? module, params string[] requires) => new() - { - Id = id, - Source = SourceName, - Kind = PackageKind.NodeRepo, - Module = module, - Requires = [.. requires], - }; - - [Fact(Timeout = 180_000)] - public async Task ASelectedPackagesUnmetFloor_RefusesTheSet_AndAMetOneDoesNot() - { - await FirstPass(TestContext.Current.CancellationToken); - var landing = Mesh.ServiceProvider.GetRequiredService(); - - var refused = await ModuleDependencyFloor - .ProposeChecked(Mesh, landing, [Fresh("AI", AiModule), Fresh("Chat", ChatModule, "AI@^2.0.0")]) - .Select(_ => (Exception?)null) - .Catch((Exception ex) => Observable.Return(ex)) - .LastAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - refused.Should().NotBeNull("Chat requires AI@^2 and the landed AI is 1.0.0 — that set must not be written"); - - var accepted = await ModuleDependencyFloor - .ProposeChecked(Mesh, landing, [Fresh("AI", AiModule), Fresh("Chat", ChatModule, "AI@^1.0.0")]) - .Select(_ => (Exception?)null) - .Catch((Exception ex) => Observable.Return(ex)) - .LastOrDefaultAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - accepted.Should().BeNull("a met floor proposes (or finds nothing changed) without a fault"); - } -} - -/// -/// 🚨 End to end: the selection's floors bind the pass's one proposal. Chat requires -/// AI@^2 while the registry's AI bundle is 1.0.0 — every module still lands, but NO module set is -/// proposed (by phase 1 or by any phase-2 install), so a restart cannot load the incompatible pair. -/// -public class AnUnmetSelectionFloorProposesNoSetTest(ITestOutputHelper output) : StoreBootstrapTestBase(output) -{ - /// - protected override string ChatRequiresAi => "AI@^2.0.0"; - - /// - protected override MeshBuilder Arrange(MeshBuilder builder) => - // The modules' types "loaded", so phase 2 installs packages (and would propose per package). - builder.AddMeshNodes(new MeshNode("Skill") { Name = "Skill", NodeType = MeshNode.NodeTypePath, IsSatelliteType = false }); - - [Fact(Timeout = 180_000)] - public async Task ChatRequiringAi2_AgainstALandedAi1_ProposesNoSet() - { - var pass = await FirstPass(TestContext.Current.CancellationToken); - Output.WriteLine($"pass: {pass}"); - pass.Delivered.Should().Contain("AI", "phase 2 installed AI — the install that used to propose on its own"); - ModuleActivationSidecar.Read(LandingRoot).Entries.Select(e => e.Name).Should().Contain(new[] { AiModule, ChatModule }); - pass.ModuleSetVerdict.Should().StartWith(InstanceAutoRegistrationService.ModuleSetRefused, - "Chat's floor AI@^2 is not met by the landed AI 1.0.0, so this pass may not write the set"); - } -} - -/// -/// 🚨 A module the wave could not land, REQUIRED by one that did, withholds the set: the floor check -/// skips a dependency with no landed entry, so without this the AI-less Chat set would be published. -/// -public class ARequiredModuleThatDidNotLandProposesNoSetTest(ITestOutputHelper output) : StoreBootstrapTestBase(output) -{ - /// - protected override bool ServesAiBytes => false; - - [Fact(Timeout = 180_000)] - public async Task AiNotLanded_ChatLanded_ProposesNoSet() - { - var pass = await FirstPass(TestContext.Current.CancellationToken); - Output.WriteLine($"pass: {pass}"); - var landed = ModuleActivationSidecar.Read(LandingRoot).Entries.Select(e => e.Name).ToList(); - landed.Should().Contain(ChatModule); - landed.Should().NotContain(AiModule, "the registry served no AI bytes"); - // THIS lane's verdict — the reconciler's own boot wave close proposes independently - // (records only), so the store is not this lane's evidence. - pass.ModuleSetVerdict.Should().StartWith(InstanceAutoRegistrationService.ModuleSetWithheld, - "Chat requires AI, whose module did not land — a Chat-only set must not be proposed"); - } -} - -/// -/// 🚨 A candidate the node install HOLDS (its partition is kept current by another writer — a -/// _GitSync entry; MeshWeaver#4588) adopts nothing, so the module phase lands no module for it -/// either: the ownership decision is the same one for both phases. -/// -public class ModulePhaseLandsNothingForAHeldCandidateTest(ITestOutputHelper output) : StoreBootstrapTestBase(output) -{ - /// - protected override IReadOnlyList BootSet => [$"{SourceName}/Store"]; - - /// - protected override MeshBuilder Arrange(MeshBuilder builder) => - builder.AddGitHubSyncTypes().ConfigureServices(services => services.AddGitHubSyncServices()); - - [Fact(Timeout = 300_000)] - public async Task AHeldCarrier_LandsNoModule_AndIsReportedHeld() - { - var ct = TestContext.Current.CancellationToken; - // Booted with a set that carries no module (BootSet); EntityViews' partition is made - // somebody else's before it is ever selected. - await FirstPass(ct); - await Mesh.ServiceProvider.GetRequiredService() - .CreateNode(new MeshNode(GitHubSyncService.ConfigId, "EntityViews") - { - Name = "GitHub Sync", - NodeType = GitHubSyncService.ConfigNodeType, - State = MeshNodeState.Active, - Content = new GitHubSyncConfig { RepositoryUrl = "https://github.com/acme/example", Branch = "main" }, - }) - .Should().Within(TestTimeouts.Convergence) - .Emit("connecting the partition to a repository is the precondition of the hold", cancellationToken: ct); - await Observable.Interval(TestTimeouts.Quick / 20).StartWith(0L) - .SelectMany(_ => PartitionContentOwnership.Observe(Mesh, "EntityViews")) - .Where(v => !v.InstallerOwnsTheContent) - .FirstAsync().Timeout(TestTimeouts.CrossSilo).Await(ct); - - Options.InstallByDefault = [$"{SourceName}/Store", $"{SourceName}/EntityViews"]; - var pass = await Mesh.ServiceProvider.GetRequiredService() - .RunDefaultInstall().FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - Output.WriteLine($"pass: {pass}"); - - pass.Held.Select(h => h.Package).Should().Contain("EntityViews"); - Requests.Should().NotContain("bundle:EntityViews", "a held candidate's module bytes are never fetched"); - ModuleActivationSidecar.Read(LandingRoot).Entries.Select(e => e.Name).Should().NotContain(ViewsModule); - } -} - -/// -/// The boot AFTER that restart: the landed MeshWeaver.AI module now registers Skill -/// (stood in for by a code-registered NodeType), so one pass installs the whole store — Store -/// first, everything rooted on Store/Plugin after it — with the order derived from the declared -/// requires, not maintained by hand. -/// -public class StoreBootstrapCompletesAfterTheRestartTest(ITestOutputHelper output) : StoreBootstrapTestBase(output) -{ - /// - protected override MeshBuilder Arrange(MeshBuilder builder) => - builder.AddMeshNodes(new MeshNode("Skill") - { - Name = "Skill", - NodeType = MeshNode.NodeTypePath, - IsSatelliteType = false, - }); - - [Fact(Timeout = 180_000)] - public async Task OnceTheModulesLoad_OnePassInstallsTheStoreThenEverythingRootedOnIt() - { - var pass = await FirstPass(TestContext.Current.CancellationToken); - Output.WriteLine($"pass: {pass}"); - - pass.Failures.Should().BeEmpty(); - pass.Delivered.Order(StringComparer.Ordinal).Should().Equal(new[] { "Store", "AI", "EntityViews", "Chat" }.Order(StringComparer.Ordinal)); - var order = pass.Packages.ToList(); - order.IndexOf("Store").Should().BeLessThan(order.IndexOf("AI")); - order.IndexOf("Store").Should().BeLessThan(order.IndexOf("EntityViews")); - order.IndexOf("AI").Should().BeLessThan(order.IndexOf("Chat")); - } -} - -/// -/// 🚨 The DEFAULT-APPS lane (defaultApp: true) on an instance that DECLARES its package set: -/// the registry's other preInstalled packages are still dropped (policy -/// declared-package-set-is-exact), the default app is not — it is the one named exception. -/// -public class ADefaultAppSurvivesAnExplicitSetTest(ITestOutputHelper output) : StoreBootstrapTestBase(output) -{ - /// - protected override IReadOnlyList DefaultApps => ["Games"]; - - /// Names ONLY Chat, so EntityViews - a preInstalled package nobody named - is the probe - /// for "the lane accidentally enabled the whole baseline". - protected override IReadOnlyList BootSet => [$"{SourceName}/Chat"]; - - /// - protected override MeshBuilder Arrange(MeshBuilder builder) => - builder.AddMeshNodes(new MeshNode("Skill") - { - Name = "Skill", - NodeType = MeshNode.NodeTypePath, - IsSatelliteType = false, - }); - - [Fact(Timeout = 180_000)] - public async Task TheDefaultApp_IsSelectedBesideTheDeclaredSet_AndNoOtherPreInstalledPackageIs() - { - var pass = await FirstPass(TestContext.Current.CancellationToken); - Output.WriteLine($"pass: {pass}"); - - pass.Packages.Order(StringComparer.Ordinal).Should().Equal( - new[] { "Store", "AI", "Chat", "Games" }.Order(StringComparer.Ordinal), - "the declared set, what it requires, and the one package declaring defaultApp"); - pass.Packages.Should().NotContain("EntityViews", "the rest of the baseline stays dropped"); - } - - /// - /// A default app is RECONCILED, not seeded once: after the first pass the ledger lists it as - /// seeded, its install record is removed straight from storage (no uninstall marker), and the - /// next pass puts it back. Without the reconciled exemption the ledger filter would drop it. - /// - [Fact(Timeout = 240_000)] - public async Task ASeededDefaultApp_WhosePartitionIsMissing_IsRestoredByTheNextPass() - { - var ct = TestContext.Current.CancellationToken; - var installer = Mesh.ServiceProvider.GetRequiredService(); - var first = await FirstPass(ct); - first.Packages.Should().Contain("Games"); - first.Delivered.Should().Contain("Games", "the first pass seeds it, so the ledger now lists it"); - - await Mesh.ServiceProvider.GetRequiredService() - .Delete($"{PackageInstaller.InstalledPartition}/Games") - .Take(1).Timeout(TestTimeouts.Convergence).Await(ct); - - var second = await installer.RunDefaultInstall() - .FirstAsync().Timeout(TimeSpan.FromSeconds(180)).Await(ct); - Output.WriteLine($"pass 2: {second}"); - second.Packages.Should().Contain("Games", - "a default app re-asserts every boot - the ledger's 'seeded' must not drop it"); - } -} - -/// -/// The switch: an explicit PluginCatalog:InstallPreInstalledPackages=false — "this instance -/// manages its own content" — suppresses the default apps too, and nothing else does. -/// -public class ADefaultAppIsSuppressedByTheExplicitOffSwitchTest(ITestOutputHelper output) : StoreBootstrapTestBase(output) -{ - /// - protected override IReadOnlyList DefaultApps => ["Games"]; - - /// - protected override bool? ConfiguredPreInstalled => false; - - [Fact(Timeout = 180_000)] - public async Task WithTheBaselineSwitchedOff_NoDefaultAppIsSelected() - { - var pass = await FirstPass(TestContext.Current.CancellationToken); - Output.WriteLine($"pass: {pass}"); - - pass.Packages.Should().NotContain("Games"); - pass.Packages.Order(StringComparer.Ordinal).Should().Equal( - new[] { "Store", "AI", "EntityViews", "Chat" }.Order(StringComparer.Ordinal), - "the declared set is still installed — only the platform's own lanes are off"); - } -} - -/// The default-apps decision, pure. -public class DefaultAppsSelectionTest -{ - [Theory] - [InlineData(null, true)] - [InlineData(true, true)] - [InlineData(false, false)] - public void DefaultApps_AreOn_UnlessTheBaselineSwitchIsExplicitlyFalse(bool? configured, bool expected) => - InstanceAutoRegistrationService.DefaultAppsApply(configured).Should().Be(expected); - - [Fact] - public void AnExplicitSet_DropsTheBaseline_ButKeepsTheDefaultApps() - { - IReadOnlyList named = ["Plugins/EntityViews", "Plugins/Chat"]; - InstanceAutoRegistrationService.BaselineApplies(null, named).Should().BeFalse(); - InstanceAutoRegistrationService.DefaultAppsApply( - new PluginCatalogOptions { InstallByDefault = [.. named] }.EffectiveInstallPreInstalledPackages).Should().BeTrue(); - } - - [Theory] - [InlineData(null, true)] - [InlineData("true", true)] - [InlineData("false", false)] - public void BoundFromConfiguration_AnExplicitSet_FollowsTheKeyOnly(string? key, bool expectedDefaultApps) - { - var entries = new Dictionary - { - ["PluginCatalog:InstallByDefault:0"] = "Plugins/EntityViews", - }; - if (key is not null) - entries["PluginCatalog:InstallPreInstalledPackages"] = key; - var options = new Microsoft.Extensions.Configuration.ConfigurationBuilder() - .AddInMemoryCollection(entries).Build() - .GetSection(PluginCatalogOptions.SectionName).Get()!; - - InstanceAutoRegistrationService.DefaultAppsApply(options.EffectiveInstallPreInstalledPackages) - .Should().Be(expectedDefaultApps); - } -} - -/// The selection rule, pure. -public class PreInstalledBaselineSelectionTest -{ - private static IReadOnlyList Parse(params string[] patterns) => patterns; - - [Fact] - public void AnExplicitSet_DropsTheBaseline_UnlessConfiguredOtherwise() - { - var named = Parse("Plugins/EntityViews", "Plugins/Chat"); - InstanceAutoRegistrationService.DeclaresExplicitSet(named).Should().BeTrue(); - InstanceAutoRegistrationService.BaselineApplies(null, named).Should().BeFalse( - "an instance that names its packages gets those and their requires"); - InstanceAutoRegistrationService.BaselineApplies(true, named).Should().BeTrue( - "an explicit true keeps the baseline beside the set"); - InstanceAutoRegistrationService.BaselineApplies(false, named).Should().BeFalse(); - } - - [Fact] - public void NoPatterns_OrAWholeSource_KeepsTheBaseline() - { - InstanceAutoRegistrationService.BaselineApplies(null, Parse()).Should().BeTrue(); - InstanceAutoRegistrationService.BaselineApplies(null, Parse("Plugins/*")).Should().BeTrue( - "the chart default names a whole source, never a set"); - InstanceAutoRegistrationService.BaselineApplies(null, Parse("Plugins/Essentials", "Education/*")) - .Should().BeTrue("one whole-source pattern means the deployment did not name its set"); - InstanceAutoRegistrationService.BaselineApplies(false, Parse()).Should().BeFalse(); - } - - /// - /// 🚨 The decision as a DEPLOYED portal reaches it: options BOUND from configuration, the way - /// MemexConfiguration builds them. The first fix read a nullable backing field behind the - /// bool; ConfigurationBinder writes every property's current value back through its setter, - /// so the field was always "true" and a fresh control instance naming five packages still selected - /// 48. Unset must stay unset through binding. - /// - [Theory] - [InlineData(null, false)] - [InlineData("true", true)] - [InlineData("false", false)] - public void BoundFromConfiguration_AnExplicitSet_FollowsTheKeyOnly(string? key, bool expectedBaseline) - { - var entries = new Dictionary - { - ["PluginCatalog:InstallByDefault:0"] = "Plugins/EntityViews", - ["PluginCatalog:InstallByDefault:1"] = "Plugins/Chat", - }; - if (key is not null) - entries["PluginCatalog:InstallPreInstalledPackages"] = key; - var options = new Microsoft.Extensions.Configuration.ConfigurationBuilder() - .AddInMemoryCollection(entries).Build() - .GetSection(PluginCatalogOptions.SectionName).Get()!; - - InstanceAutoRegistrationService.BaselineApplies(options.EffectiveInstallPreInstalledPackages, options.InstallByDefault) - .Should().Be(expectedBaseline); - } - - [Fact] - public void BoundFromConfiguration_NoPatterns_KeepsTheBaseline() - { - var options = new Microsoft.Extensions.Configuration.ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { ["PluginCatalog:HomeUrl"] = "https://x.example" }) - .Build().GetSection(PluginCatalogOptions.SectionName).Get()!; - InstanceAutoRegistrationService.BaselineApplies(options.EffectiveInstallPreInstalledPackages, options.InstallByDefault) - .Should().BeTrue(); - new PluginCatalogOptions { InstallPreInstalledPackages = false }.EffectiveInstallPreInstalledPackages - .Should().Be(false, "code that sets the bool to false still suppresses"); - } - - [Fact] - public void AMalformedEntry_IsNotAnExplicitSet_SoTheBaselineStays() - { - var mixed = Parse("Plugins/Store", "Education/"); - InstanceAutoRegistrationService.DeclaresExplicitSet(mixed).Should().BeFalse( - "one entry that is not a Source/Package means the operator did not state a set"); - InstanceAutoRegistrationService.BaselineApplies(null, mixed).Should().BeTrue(); - InstanceAutoRegistrationService.BaselineApplies(null, Parse("Plugins/Store", " ")) - .Should().BeFalse("a blank entry is not an entry"); - } -} diff --git a/test/Memex.Portal.Shared.Test/SyncTokenScopeTest.cs b/test/Memex.Portal.Shared.Test/SyncTokenScopeTest.cs deleted file mode 100644 index 392274f2e7..0000000000 --- a/test/Memex.Portal.Shared.Test/SyncTokenScopeTest.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Memex.Portal.Shared.Api; -using MeshWeaver.Mesh.Security; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins how a token-exchange request is intersected with the instance's sync licence — the pure -/// decision behind POST /api/instances/token's scope. -/// -/// Written because the first implementation got the whole-source case wrong: it MATCHED -/// Plugins/* against the licence instead of EXPANDING it, so an instance licensed per-package -/// that asked for "everything I hold in this source" got an empty scope and a 403. -/// -public class SyncTokenScopeTest -{ - private static readonly DateTimeOffset Now = new(2026, 8, 19, 12, 0, 0, TimeSpan.Zero); - - private static AuthenticatedInstance Caller(bool revoked = false, params PluginGrantEntry[] entries) => - new(new MeshWeaverInstance { InstanceId = "manufacturing-ci", KeyHash = "hash" }, - new PluginGrant { InstanceId = "manufacturing-ci", Entries = entries, IsRevoked = revoked }); - - private static PluginGrantEntry Entry(string source, string package, DateTimeOffset? expires = null) => - new() { Source = source, PackageId = package, ExpiresAt = expires }; - - private static List Scope(AuthenticatedInstance caller, params string[] requested) => - InstanceTokenEndpoints.EffectiveScope(caller, requested.Length == 0 ? null : requested, Now).ToList(); - - [Fact] - public void NoRequest_YieldsEverythingLicensed() - { - var caller = Caller(false, Entry("Plugins", "Publish"), Entry("Plugins", "Store")); - Assert.Equal(["Plugins/Publish", "Plugins/Store"], Scope(caller)); - } - - [Fact] - public void AnExactRequestIsHonoured() - { - var caller = Caller(false, Entry("Plugins", "Publish"), Entry("Plugins", "Store")); - Assert.Equal(["Plugins/Publish"], Scope(caller, "Plugins/Publish")); - } - - [Fact] - public void AWholeSourceRequest_EXPANDS_ToTheLicensedPackages() - { - // THE regression this test exists for: matching instead of expanding returned nothing, and - // the endpoint answered 403 to a caller asking a perfectly reasonable question. - var caller = Caller(false, Entry("Plugins", "Publish"), Entry("Plugins", "Store")); - Assert.Equal(["Plugins/Publish", "Plugins/Store"], Scope(caller, "Plugins/*")); - } - - [Fact] - public void AWholeSourceRequest_ExpandsOnlyWithinThatSource() - { - var caller = Caller(false, Entry("Plugins", "Publish"), Entry("Education", "DataModeling")); - Assert.Equal(["Plugins/Publish"], Scope(caller, "Plugins/*")); - } - - [Fact] - public void AWholeSourceLicenceStaysAWholeSourceScope() - { - var caller = Caller(false, Entry("Plugins", PluginGrantEntry.AllPackages)); - Assert.Equal(["Plugins/*"], Scope(caller, "Plugins/*")); - // And an exact request under a whole-source licence narrows to that package. - Assert.Equal(["Plugins/Publish"], Scope(caller, "Plugins/Publish")); - } - - [Fact] - public void AnUnlicensedRequestIsDropped_NotRefused() - { - // A token can only narrow, so an over-broad request is a stale caller rather than an attack. - var caller = Caller(false, Entry("Plugins", "Publish")); - Assert.Equal(["Plugins/Publish"], Scope(caller, "Plugins/Publish", "Education/DataModeling")); - } - - [Fact] - public void RequestingOnlyUnlicensedThings_YieldsNothing_SoTheEndpointCanSayWhy() - { - var caller = Caller(false, Entry("Plugins", "Publish")); - Assert.Empty(Scope(caller, "Education/DataModeling")); - } - - [Fact] - public void AnExpiredEntryIsNotOffered() - { - // Minting against an ended licence would hand back a token that fails on every call. - var caller = Caller(false, Entry("Plugins", "Publish", Now.AddDays(-1)), Entry("Plugins", "Store")); - Assert.Equal(["Plugins/Store"], Scope(caller)); - Assert.Empty(Scope(caller, "Plugins/Publish")); - } - - [Fact] - public void AnExpiredEntryIsNotOfferedByAWildcardEither() - { - var caller = Caller(false, Entry("Plugins", "Publish", Now.AddDays(-1))); - Assert.Empty(Scope(caller, "Plugins/*")); - } - - [Fact] - public void ARevokedGrantOffersNothing() - { - var caller = Caller(true, Entry("Plugins", "Publish")); - Assert.Empty(Scope(caller)); - Assert.Empty(Scope(caller, "Plugins/Publish")); - Assert.Empty(Scope(caller, "Plugins/*")); - } - - [Fact] - public void MalformedRequestEntriesAreIgnored_NeverThrown() - { - var caller = Caller(false, Entry("Plugins", "Publish")); - Assert.Equal(["Plugins/Publish"], Scope(caller, "", " ", "/", "Plugins/Publish")); - } - - [Fact] - public void DuplicateRequestsCollapse() - { - var caller = Caller(false, Entry("Plugins", "Publish")); - Assert.Equal(["Plugins/Publish"], Scope(caller, "Plugins/Publish", "Plugins/Publish", "Plugins/*")); - } - - [Fact] - public void SourceMatchingIsCaseInsensitive_AsItIsInAGrant() - { - var caller = Caller(false, Entry("Plugins", "Publish")); - Assert.Equal(["Plugins/Publish"], Scope(caller, "plugins/Publish")); - Assert.Equal(["Plugins/Publish"], Scope(caller, "PLUGINS/*")); - } -} diff --git a/test/Memex.Portal.Shared.Test/TierInUseDeletionGuardTest.cs b/test/Memex.Portal.Shared.Test/TierInUseDeletionGuardTest.cs deleted file mode 100644 index 23971cfdab..0000000000 --- a/test/Memex.Portal.Shared.Test/TierInUseDeletionGuardTest.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using MeshWeaver.Reactive.Assertions; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A plan TIER node cannot be deleted while a registered instance stands on it (#5894). -/// -/// The ladder is data: Admin/Tiers/{id} is what makes a plan id known. On the public -/// registry Admin/Tiers/sme was deleted under a dedicated client instance storing sme; -/// its plan became unknown, decided at the baseline, and it was refused 18 packages as "free". The -/// guard refuses that delete and names the instances; an unused tier, and a tier node carrying a -/// RETIRED id (resolved to its successor everywhere), stay deletable. -/// -public class TierInUseDeletionGuardTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string TierNodeType = "Store/Tier"; - private const string Owner = "tier-owner"; - - /// The tier node's content as the registry reads it (rank, all-access) — the Store's - /// TierContent shape, test-local because this fixture does not run the Store. - public record TierNode - { - public int Rank { get; init; } - public bool AllAccess { get; init; } - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .AddMeshNodes(new MeshNode(TierNodeType) - { - Name = "Tier", - IsSatelliteType = false, - HubConfiguration = config => config - .AddMeshDataSource(source => source.WithContentType()), - }) - .ConfigureHub(config => config.WithType(nameof(TierNode))); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - private static string TierPath(string id) => $"{PlanTierLadder.Namespace}/{id}"; - - private Task SeedTier(string id, int rank, bool allAccess, CancellationToken ct) => - Access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode(new MeshNode(id, PlanTierLadder.Namespace) - { - Name = id, - NodeType = TierNodeType, - State = MeshNodeState.Active, - Content = new TierNode { Rank = rank, AllAccess = allAccess }, - })) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - private Task Register(string instanceId, string tier, CancellationToken ct) => - new MeshWeaverInstanceService( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService>(), - new ConfigurationBuilder().Build()) - .Register(Owner, "Owner", "owner@test.com", instanceId, instanceId, tier: tier) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - /// The delete's fault, or null when it went through. Run as System: the guard does not - /// exempt System, and System is what removes the RLS question from the measurement. - private Task TryDelete(string path, CancellationToken ct) => - Access.RunAsSystem(() => NodeFactory.DeleteNode(path)) - .Select(_ => (Exception?)null) - .Catch((Exception ex) => Observable.Return(ex)) - .DefaultIfEmpty(null) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - private Task Read(string path, CancellationToken ct) => - Access.RunAsSystem(() => Mesh.GetMeshNode(path, TimeSpan.FromSeconds(10)).Take(1)) - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - [Fact(Timeout = 300_000)] - public async Task ATierAnInstanceStandsOn_CannotBeDeleted_AndAnUnusedOneCan() - { - var ct = TestContext.Current.CancellationToken; - await SeedTier("free", 0, false, ct); - await SeedTier("pro", 20, false, ct); - await SeedTier("personal", 10, false, ct); - await Register("pro-client", "pro", ct); - - var refused = await TryDelete(TierPath("pro"), ct); - refused.Should().NotBeNull("an instance stands on 'pro'; deleting its tier node would make its plan unknown"); - refused?.Message.Should().Contain("pro-client", "the refusal names the instance that has to be moved first"); - (await Read(TierPath("pro"), ct)).Should().NotBeNull("the refused delete must leave the tier node in place"); - - // The control: nothing stands on `personal`, so the guard must not refuse everything. - (await TryDelete(TierPath("personal"), ct)).Should().BeNull("no instance stands on 'personal'"); - } - - /// What STORAGE holds at — the authoritative absence check, which - /// activates no per-node hub at an address that has no node. - private Task Stored(string path, CancellationToken ct) => - Mesh.ServiceProvider.GetRequiredService() - .Read(path, Mesh.JsonSerializerOptions) - .DefaultIfEmpty(null) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - /// The move's response, issued as System for the same reason runs - /// as System: the guard does not exempt it, and it takes RLS out of the measurement. - private Task Move(string source, string target, CancellationToken ct) => - ObserveNodeOperation(new MoveNodeRequest(source, target), o => o.WithAccessContext(new AccessContext - { - ObjectId = WellKnownUsers.System, - Name = WellKnownUsers.System, - })) - .Select(d => d.Message) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - - /// - /// 🚨 A MOVE removes the tier from where the ladder reads it exactly as a delete does, so it is - /// refused for the same reason. Before the move ran the source's delete pre-flight, this move - /// SUCCEEDED and the in-use tier left the ladder unguarded. The second move is the negative - /// control: an unused tier moves freely, so the refusal is the guard's and not a broken move. - /// - [Fact(Timeout = 300_000)] - public async Task MovingATierAnInstanceStandsOn_IsRefused_AndMovingAnUnusedOneIsNot() - { - var ct = TestContext.Current.CancellationToken; - await SeedTier("free", 0, false, ct); - await SeedTier("pro", 20, false, ct); - await SeedTier("personal", 10, false, ct); - await Register("pro-client", "pro", ct); - const string archive = "Admin/TierArchive"; - - var refused = await Move(TierPath("pro"), $"{archive}/pro", ct); - Output.WriteLine($"move pro: success={refused.Success} reason={refused.RejectionReason} error={refused.Error}"); - refused.Success.Should().BeFalse("moving the tier out of the ladder makes the instance's plan unknown, exactly like deleting it"); - refused.RejectionReason.Should().Be(NodeMoveRejectionReason.ValidationFailed); - refused.Error.Should().Contain("pro-client", "the refusal carries the guard's own reason, naming the instance"); - (await Read(TierPath("pro"), ct)).Should().NotBeNull("the refused move must leave the tier where it was"); - (await Stored($"{archive}/pro", ct)).Should().BeNull("the refusal comes before the copy creates anything"); - - var moved = await Move(TierPath("personal"), $"{archive}/personal", ct); - Output.WriteLine($"move personal: success={moved.Success} reason={moved.RejectionReason} error={moved.Error}"); - moved.Success.Should().BeTrue(moved.Error ?? "nothing stands on 'personal', so the guard has no objection"); - (await Read($"{archive}/personal", ct)).Should().NotBeNull("a legitimate move lands at the target"); - (await Stored(TierPath("personal"), ct)).Should().BeNull("and leaves nothing at the source"); - } - - [Fact(Timeout = 300_000)] - public async Task TheBaselineTier_IsInUseByAnInstanceWithNoPaidPlan() - { - var ct = TestContext.Current.CancellationToken; - await SeedTier("free", 0, false, ct); - await Register("free-client", "free", ct); - - var refused = await TryDelete(TierPath("free"), ct); - refused.Should().NotBeNull("an instance stands on the baseline plan"); - refused?.Message.Should().Contain("free-client"); - } - - [Fact(Timeout = 300_000)] - public async Task ATierNodeCarryingTheRetiredSmeId_StaysDeletable_WhileItsSuccessorIsGuarded() - { - var ct = TestContext.Current.CancellationToken; - await SeedTier("dedicated", 25, true, ct); - await SeedTier("sme", 25, true, ct); - // Registered on `sme`: the record stores the successor, the guard counts it on `dedicated`. - await Register("dedicated-client", "sme", ct); - - (await TryDelete(TierPath("sme"), ct)).Should().BeNull( - "'sme' is retired — it resolves to 'dedicated', so nothing stands on its own node"); - var refused = await TryDelete(TierPath("dedicated"), ct); - refused.Should().NotBeNull("the instance registered on 'sme' stands on its successor 'dedicated'"); - refused?.Message.Should().Contain("dedicated-client"); - } -} diff --git a/test/Memex.Portal.Shared.Test/TieredPgoIsOffInEveryCompilingHostTest.cs b/test/Memex.Portal.Shared.Test/TieredPgoIsOffInEveryCompilingHostTest.cs deleted file mode 100644 index 3376b1f665..0000000000 --- a/test/Memex.Portal.Shared.Test/TieredPgoIsOffInEveryCompilingHostTest.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.IO; -using System.Text.Json; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #890: a process that compiles in-mesh C# must run with Tiered PGO OFF. -/// -/// On .NET 10 linux-x64 the Tier-1 JIT, in roughly one test host in three of this suite's -/// workload, inlines Roslyn's SourceMemberContainerTypeSymbol.ContainingType -/// (_containingSymbol as NamedTypeSymbol) into NamedTypeSymbol.AsNestedTypeDefinitionImpl -/// without the isinst. A top-level type's namespace container then reads as its containing type, -/// and every later Emit in that process throws in get_ContainingTypeDefinition — which is -/// how InstallNeverInheritsRepoCompileVerdictTest, ModulesUpdateIndependentlyOfThePlatformTest -/// and ModuleLinkVersionTest went red on unrelated pull requests. Measured in CI's own shape: the -/// listing has no isinst in every poisoned host and has it in every clean one, and with Tiered PGO -/// off no host poisoned (Doc/Architecture/NodeTypeCompilation, "The defect, in one listing"). -/// -/// The defect itself is per-process and architecture-specific, so it cannot be reproduced on -/// demand in one test. What CAN be pinned deterministically is that the switch that removes it reaches -/// the host: the runtime reads System.Runtime.TieredPGO from this host's runtimeconfig, and the -/// build writes it there from <TieredPGO>false</TieredPGO> in the Directory.Build.props -/// files. Negative control: building this project with -p:TieredPGO=true makes both assertions -/// fail. -/// -public class TieredPgoIsOffInEveryCompilingHostTest -{ - private const string Switch = "System.Runtime.TieredPGO"; - - [Fact] - public void TheRuntimeIsHandedTieredPgoOff() - { - var value = AppContext.GetData(Switch); - Assert.True(value is not null, - $"{Switch} is not set for this host, so the runtime's default (Tiered PGO ON) applies and the " - + "#890 miscompile can poison every Emit in the process. Set false."); - Assert.Equal("false", value!.ToString(), ignoreCase: true); - } - - [Fact] - public void TheRuntimeConfigOnDiskCarriesIt() - { - var name = typeof(TieredPgoIsOffInEveryCompilingHostTest).Assembly.GetName().Name; - var path = Path.Combine(AppContext.BaseDirectory, $"{name}.runtimeconfig.json"); - Assert.True(File.Exists(path), $"no runtimeconfig at {path}"); - using var doc = JsonDocument.Parse(File.ReadAllText(path)); - Assert.True( - doc.RootElement.GetProperty("runtimeOptions").TryGetProperty("configProperties", out var props) - && props.TryGetProperty(Switch, out var pgo) - && pgo.ValueKind == JsonValueKind.False, - $"{path} does not carry \"{Switch}\": false — the switch never reaches a host launched as " - + "`dotnet .dll`, which is how CI's shards launch it."); - } -} diff --git a/test/Memex.Portal.Shared.Test/TokenMintedOnOneReplicaValidatesOnAnotherTest.cs b/test/Memex.Portal.Shared.Test/TokenMintedOnOneReplicaValidatesOnAnotherTest.cs deleted file mode 100644 index ec210ee243..0000000000 --- a/test/Memex.Portal.Shared.Test/TokenMintedOnOneReplicaValidatesOnAnotherTest.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Reactive.Linq; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// A bearer token minted by one replica is accepted by a replica that never saw it minted. -/// -/// Why this exists as a test and not as an argument. #5074 reported an MCP session that -/// alternated between working and answering 401 "token expired", and reasoned from outside the -/// cluster that the token must live in the issuing process's memory. It no longer does — -/// ApiTokenService reads the index row and the token row STRAIGHT off the shared storage -/// adapter, and CreateToken confirms both are committed before the raw token leaves the -/// server. But "it no longer does" is a claim about code, and the two shapes it replaced -/// (an in-memory dictionary, then a read that rode the mesh's cross-silo per-node-hub path) were -/// both invisible to every single-instance test in the suite. A claim that only a second replica -/// could falsify needs a second replica, or it is not being tested at all. -/// -/// Two ApiTokenService instances over one storage adapter is the same relationship two -/// pods have to one Postgres: independent service state, one authoritative store. The companion -/// case for authorization codes is OAuthCodeStoreTest's two-store exchange. -/// -/// SHOULD-FAIL-IF validation ever reads instance-local state again: the mint happens on -/// one instance and every assertion is made on the other. -/// is the control in the other -/// direction — a validator hardwired to Valid would pass the first test and fail this one, and it -/// also pins the distinction the 401/503 split rests on: an unknown token is -/// , never . -/// -public class TokenMintedOnOneReplicaValidatesOnAnotherTest(ITestOutputHelper output) - : MonolithMeshTestBase(output) -{ - private const string UserId = "replica-split-user"; - private const string UserEmail = "replica-split@example.com"; - - /// - /// One "replica": its own service instance, the SHARED store. Nothing is passed between the two - /// instances except the raw token, exactly as a client passes it. - /// - private ApiTokenService Replica() => - new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService(), - Mesh.ServiceProvider.GetRequiredService>()); - - [Fact] - public async Task AFreshTokenIsValidOnTheReplicaThatDidNotMintIt() - { - var minting = Replica(); - var validating = Replica(); - - var created = await minting - .CreateToken(UserId, "Replica Split", UserEmail, "MintedOnA") - .Should().Emit(); - - var verdict = await validating.Validate(created.RawToken).Should().Emit(); - - verdict.Status.Should().Be( - TokenValidationStatus.Valid, - "the token's rows are in the shared store before the raw token is handed out, so the " - + "replica that receives the next request resolves it without having minted it — " - + "reason given: {0}", - verdict.Reason ?? "(none)"); - verdict.Token!.UserId.Should().Be(UserId); - } - - [Fact] - public async Task AnUnknownTokenIsADefinitiveNegativeOnEitherReplica() - { - var verdict = await Replica() - .Validate("mw_this-token-was-never-minted-anywhere") - .Should().Emit(); - - verdict.Status.Should().Be( - TokenValidationStatus.Invalid, - "an absent row is a verdict, not a failure to reach one — Unavailable is reserved for a " - + "store that could not be read, and answering 401 for that is what makes a client " - + "discard a credential that was never wrong"); - } -} diff --git a/test/Memex.Portal.Shared.Test/TokenProvisionedInstanceIsKeyedTest.cs b/test/Memex.Portal.Shared.Test/TokenProvisionedInstanceIsKeyedTest.cs deleted file mode 100644 index 953a42d4c9..0000000000 --- a/test/Memex.Portal.Shared.Test/TokenProvisionedInstanceIsKeyedTest.cs +++ /dev/null @@ -1,67 +0,0 @@ -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #5245: an installation provisioned with a registry TOKEN (no registration key) was classified -/// as the OPEN lane, so Settings ▸ Instance registration offered it a consent form and read -/// "Awaiting consent — not registered" forever — auto-registration skips a token-configured -/// installation ("the explicit token wins"), so no credential is ever stored for the page to find. -/// The consent step belongs to the open lane only; an operator-configured token is operator -/// provisioning just as a registration key is. Every case goes through the production -/// InstanceConsentService.Target(options) — registry selection, legacy-token attribution and -/// the keyed rule together — never a copy of its wiring. -/// -public class TokenProvisionedInstanceIsKeyedTest -{ - private const string RegistryUrl = "https://memex.meshweaver.cloud"; - - private static bool Keyed(PluginCatalogOptions options) - { - var target = InstanceConsentService.Target(options); - Assert.NotNull(target); - return target.Value.Keyed; - } - - [Fact] - public void ALegacyRegistryToken_IsOperatorProvisioning() - { - // The client-estate shape: PluginCatalog__RegistryUrl + PluginCatalog__RegistryToken from - // the client's Key Vault, an instance id, and no BootstrapKey. - var options = new PluginCatalogOptions - { - RegistryUrl = RegistryUrl, - RegistryToken = "mwi_operator-issued", - InstanceId = "globex-memex", - }; - Assert.True(Keyed(options), "a configured registry token is operator provisioning — no consent step applies"); - } - - [Fact] - public void ANamedRegistrysOwnToken_IsOperatorProvisioning() - { - var options = new PluginCatalogOptions { InstanceId = "globex-memex" }; - options.Registries.Add(new PluginRegistryReference { Url = RegistryUrl, Token = "mwi_operator-issued" }); - Assert.True(Keyed(options)); - } - - [Fact] - public void ARegistrationKey_IsOperatorProvisioning() - { - var options = new PluginCatalogOptions - { - RegistryUrl = RegistryUrl, InstanceId = "acme", BootstrapKey = "mwb_bootstrap", - }; - Assert.True(Keyed(options)); - } - - [Fact] - public void NeitherKeyNorToken_IsTheOpenLane() - { - // The control that keeps the consent form where it belongs: an instance id alone. - var options = new PluginCatalogOptions { RegistryUrl = RegistryUrl, InstanceId = "acme" }; - Assert.False(Keyed(options), - "an installation with neither a key nor a token registers through the consent-gated open lane"); - } -} diff --git a/test/Memex.Portal.Shared.Test/TokenRevocationIsIssuedOffTheRouterTest.cs b/test/Memex.Portal.Shared.Test/TokenRevocationIsIssuedOffTheRouterTest.cs deleted file mode 100644 index 7c2df2cbc1..0000000000 --- a/test/Memex.Portal.Shared.Test/TokenRevocationIsIssuedOffTheRouterTest.cs +++ /dev/null @@ -1,344 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 MeshWeaver#6026: wrote the revocation TWICE — -/// once through GetMeshNodeStream(path).Update, then again as a raw whole-node -/// SaveMeshNodeRequest posted from the hub the service was handed. In production that hub is -/// the ROOT MESH HUB, so the router became the SENDER of a node write (ROUTER_TRAFFIC ORIGIN), -/// and the second write's outcome — including a refusal — had nowhere to go. The revoke also folded -/// every fault into false, so a REFUSED revocation was indistinguishable from "nothing to do" -/// and a rotation that ignores the value reported success while the old credential stayed live. -/// -/// The service is constructed on Mesh DELIBERATELY: the router as the caller IS the -/// production shape (the singleton is built from the root container). -/// -/// SHOULD-FAIL-IF the second write comes back: -/// records a sender report. If the swallowing .Catch comes back: -/// sees -/// OnNext(false) instead of a fault. -/// is the positive control: without it "no report" and "the capture was never wired" read the same. -/// -public class TokenRevocationIsIssuedOffTheRouterTest : MonolithMeshTestBase -{ - private const string OwnerId = "revoke-router-owner"; - - /// The control's probe: a message with no meaning beyond being WORK the router posts. - private record RevokeRouterProbe; - - private readonly RouterTrafficCapture _capture = new(); - - /// Producer → test: the client hub completes this when the probe reaches its handler. - private readonly AsyncSubject _probeArrived = new(); - - public TokenRevocationIsIssuedOffTheRouterTest(ITestOutputHelper output) : base(output) - { - // After the base constructor's ClearProviders(), so it survives beside the xUnit sink. - Services.AddLogging(l => l.Services.AddSingleton(_capture)); - } - - /// - /// 🚨 ConfigureMeshBase, NOT base.ConfigureMesh: the latter grants Public the - /// Admin role at root, under which every caller holds All everywhere and no refusal can be - /// observed. Here only the harness admin holds a grant. - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder).ConfigureHub(c => c.WithTypes(typeof(RevokeRouterProbe))); - - /// - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) - => base.ConfigureClient(configuration) - .WithTypes(typeof(RevokeRouterProbe)) - .WithHandler((_, delivery) => - { - _probeArrived.OnNext(Unit.Default); - _probeArrived.OnCompleted(); - return delivery.Processed(); - }); - - private ApiTokenService GetService() => - new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService(), - Mesh.ServiceProvider.GetRequiredService>()); - - /// - /// 🚨 THE MEASUREMENT, and the durability proof that makes deleting the second write safe: the - /// revocation is read back from the STORAGE ADAPTER — no stream mirror, no cache — so a revoke - /// that only reached the in-memory node would fail here. - /// - [Fact(Timeout = 120_000)] - public async Task RevokingFromTheRootMeshHub_NeverMakesTheRouterASender() - { - var ct = TestContext.Current.CancellationToken; - var service = GetService(); - var created = await service.CreateToken(OwnerId, "Revoke Owner", "revoke-owner@example.com", "Router") - .Should().Emit("the token must exist, or the revoke measures nothing", ct); - var reportsBefore = Reports().Length; - - var revoked = await service.RevokeToken(created.Node.Path) - .Should().Within(TestTimeouts.Convergence) - .Emit("the revoke must complete — an exchange that never happened emits no traffic", ct); - revoked.Should().BeTrue(); - - var persisted = await Mesh.ServiceProvider.GetRequiredService() - .Read(created.Node.Path, Mesh.JsonSerializerOptions) - .Should().Within(TestTimeouts.Convergence) - .Match(n => n?.ContentAs(Mesh.JsonSerializerOptions)?.IsRevoked == true, - "the revocation must be DURABLE through the one update alone — that is the " - + "property the deleted SaveMeshNodeRequest claimed to supply", ct); - persisted.Should().NotBeNull(); - - var validation = await service.Validate(created.RawToken) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: ct); - validation.Status.Should().Be(TokenValidationStatus.Invalid, $"a revoked token must stop authenticating ({validation.Reason})"); - - DumpReports(); - Reports().Skip(reportsBefore).Where(r => r.Role.Contains("sender", StringComparison.Ordinal)) - .Should().BeEmpty( - "the revocation must reach the token's owning hub through the mesh's own write " - + "path, never as a post stamped `sender: mesh/{id}` (MeshWeaver#6026)"); - } - - /// - /// 🚨 THE UNAUTHORISED PATH. A caller with no grant on the token's partition revokes it: the mesh - /// must refuse, the refusal must reach the caller as a FAULT, and the token must still be valid - /// afterwards. The identity is checked first so the test cannot pass by asserting against the - /// wrong user. - /// - [Fact(Timeout = 120_000)] - public async Task ARevokeByACallerWithoutUpdate_IsRefused_AndTheTokenStillAuthenticates() - { - var ct = TestContext.Current.CancellationToken; - var service = GetService(); - var created = await service.CreateToken(OwnerId, "Revoke Owner", "revoke-owner@example.com", "Refused") - .Should().Emit(cancellationToken: ct); - - var access = Mesh.ServiceProvider.GetRequiredService(); - var stranger = new AccessContext { ObjectId = "revoke-stranger", Name = "Revoke Stranger" }; - var strangerPerms = await Mesh.GetEffectivePermissions(created.Node.Path, stranger.ObjectId) - .Should().Emit(cancellationToken: ct); - strangerPerms.HasFlag(Permission.Update).Should().BeFalse( - "the control must be a caller who genuinely lacks Update on the token, or a refusal " - + "cannot be expected"); - - Notification outcome; - try - { - access.SetContext(stranger); - access.SetHostIdentity(stranger); - outcome = await service.RevokeToken(created.Node.Path).Materialize() - .Should().Within(TestTimeouts.Convergence) - .Emit("the revoke must answer — silence would be its own defect", ct); - } - finally - { - access.SetContext(TestUsers.Admin); - access.SetHostIdentity(TestUsers.Admin); - } - - Output.WriteLine($"stranger revoke outcome: {outcome.Kind} {outcome.Exception?.Message}"); - outcome.Kind.Should().Be(NotificationKind.OnError, - "a REFUSED revocation is a statement about a token that still authenticates — folding it " - + "into `false` is how a rotation reported success with the old credential live"); - (outcome.Exception?.Message ?? "(no exception)").Should().Contain("Access denied", - "the fault must be the mesh's permission refusal, not some unrelated failure"); - - var persisted = await Mesh.ServiceProvider.GetRequiredService() - .Read(created.Node.Path, Mesh.JsonSerializerOptions) - .Should().Emit(cancellationToken: ct); - (persisted?.ContentAs(Mesh.JsonSerializerOptions)?.IsRevoked).Should().Be((bool?)false, - "the refused caller must not have revoked the token"); - - var validation = await service.Validate(created.RawToken) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: ct); - validation.Status.Should().Be(TokenValidationStatus.Valid, $"a refused revoke leaves the owner's token working ({validation.Reason})"); - } - - /// - /// The other half of the refusal test, so the pair cannot both be satisfied by "always fault": - /// a path that holds no node is not a refusal — nothing there authenticates — and answers - /// false, as does for the same case. - /// - [Fact(Timeout = 120_000)] - public async Task RevokingAPathThatHoldsNoToken_AnswersFalse_NotAFault() - { - var outcome = await GetService().RevokeToken($"{OwnerId}/ApiToken/never-minted").Materialize() - .Should().Within(TestTimeouts.Convergence) - .Emit(cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine($"absent revoke outcome: {outcome.Kind} {outcome.Exception?.Message}"); - outcome.Kind.Should().Be(NotificationKind.OnNext); - outcome.Value.Should().BeFalse("nothing was there, so nothing was revoked"); - } - - /// - /// The absent-path shape that matters in practice: a token that WAS there and has been deleted, - /// under a partition whose other token is alive (so the partition and its hubs are live, not a - /// routing miss on an unknown prefix). Must answer false, not fault. - /// - [Fact(Timeout = 120_000)] - public async Task RevokingADeletedTokenUnderALivePartition_AnswersFalse_NotAFault() - { - var ct = TestContext.Current.CancellationToken; - var service = GetService(); - await service.CreateToken(OwnerId, "Revoke Owner", "revoke-owner@example.com", "Survivor") - .Should().Emit("a live sibling keeps the partition live", ct); - var doomed = await service.CreateToken(OwnerId, "Revoke Owner", "revoke-owner@example.com", "Doomed") - .Should().Emit(cancellationToken: ct); - (await service.DeleteToken(doomed.Node.Path).Should().Emit(cancellationToken: ct)) - .Should().BeTrue("the token must really have been removed first"); - - var outcome = await service.RevokeToken(doomed.Node.Path).Materialize() - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: ct); - - Output.WriteLine($"deleted-token revoke outcome: {outcome.Kind} {outcome.Exception?.Message}"); - outcome.Kind.Should().Be(NotificationKind.OnNext); - outcome.Value.Should().BeFalse("the token is gone, so nothing was revoked and nothing authenticates"); - } - - /// The dependents' own shape (MeshWeaver.Plugins RevokeToken_NonexistentPath_ReturnsFalse): - /// a path under the global ApiToken namespace that never held a token. - [Fact(Timeout = 120_000)] - public async Task RevokingANonexistentGlobalTokenPath_AnswersFalse_NotAFault() - { - var outcome = await GetService().RevokeToken("ApiToken/nonexistent").Materialize() - .Should().Within(TestTimeouts.Convergence) - .Emit(cancellationToken: TestContext.Current.CancellationToken); - - Output.WriteLine($"global-absent revoke outcome: {outcome.Kind} {outcome.Exception?.Message}"); - outcome.Kind.Should().Be(NotificationKind.OnNext); - outcome.Value.Should().BeFalse(); - } - - /// - /// A node at the path whose content is NOT an must fault the revoke and be - /// left as it was — never read leniently into a default token and overwritten, and never - /// reported as revoked. - /// - [Fact(Timeout = 120_000)] - public async Task RevokingANodeThatIsNotAToken_Faults_AndLeavesItUntouched() - { - var ct = TestContext.Current.CancellationToken; - var path = $"{OwnerId}/ApiToken/not-a-token"; - await NodeFactory.CreateNode(new MeshNode("not-a-token", $"{OwnerId}/ApiToken") - { - Name = "Not A Token", - NodeType = ApiTokenNodeType.NodeType, - Content = new ApiTokenIndex { TokenHash = "unrelated", TokenPath = "elsewhere" }, - }) - .Should().Emit("the decoy must exist, or the revoke would take the absent-path branch", ct); - - var outcome = await GetService().RevokeToken(path).Materialize() - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: ct); - - Output.WriteLine($"non-token revoke outcome: {outcome.Kind} {outcome.Exception?.Message}"); - outcome.Kind.Should().Be(NotificationKind.OnError, - "content that is not a token cannot be revoked, and saying it was would be a lie"); - - var persisted = await Mesh.ServiceProvider.GetRequiredService() - .Read(path, Mesh.JsonSerializerOptions) - .Should().Emit(cancellationToken: ct); - (persisted?.ContentAs(Mesh.JsonSerializerOptions)?.TokenHash).Should().Be("unrelated", - "the node must not have been overwritten with a default-constructed token"); - } - - /// - /// 🚨 THE POSITIVE CONTROL: make the router a sender on purpose, so "no report" above means the - /// seam held rather than the capture never being wired into this mesh. - /// - [Fact(Timeout = 120_000)] - public async Task TheCapture_RecordsAViolation_WhenTheRouterGenuinelyPostsWork() - { - var client = GetClient(); - - Mesh.Post(new RevokeRouterProbe(), o => o.WithTarget(client.Address)); - - await _probeArrived.Should().Within(TestTimeouts.Convergence) - .Emit("the probe must reach the client hub, or this control proves nothing", - cancellationToken: TestContext.Current.CancellationToken); - - DumpReports(); - Reports().Should().Contain(r => r.Role == "sender" && r.Sender == Mesh.Address.ToString(), - "the capture must be able to record a genuine router-sent post"); - } - - private RouterTrafficRecord[] Reports() => _capture.Records; - - private void DumpReports() - { - foreach (var record in _capture.Records) - Output.WriteLine($"ROUTER_TRAFFIC captured: {record}"); - } - - private sealed record RouterTrafficRecord(string MessageType, string Role, string Sender, string Target); - - /// Reads the detector's own ERROR (both the ORIGIN and the delivery form) out of the - /// logging pipeline as structured state. - private sealed class RouterTrafficCapture : ILoggerProvider - { - private readonly ConcurrentQueue _records = new(); - - internal RouterTrafficRecord[] Records => _records.ToArray(); - - public ILogger CreateLogger(string categoryName) => new CapturingLogger(_records); - - public void Dispose() { } - - private sealed class CapturingLogger(ConcurrentQueue sink) : ILogger - { - private sealed class NullScope : IDisposable - { - internal static readonly NullScope Instance = new(); - public void Dispose() { } - } - - public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; - - public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - if (logLevel < LogLevel.Error || state is not IReadOnlyList> values) - return; - if (!formatter(state, exception).StartsWith("ROUTER_TRAFFIC", StringComparison.Ordinal)) - return; - - sink.Enqueue(new RouterTrafficRecord( - Value(values, "MessageType"), - Value(values, "Role"), - Value(values, "Sender"), - Value(values, "Target"))); - } - - private static string Value(IReadOnlyList> values, string key) - { - foreach (var pair in values) - if (pair.Key == key) - return pair.Value?.ToString() ?? "(null)"; - return "(absent)"; - } - } - } -} diff --git a/test/Memex.Portal.Shared.Test/TokenValidationHotPathTest.cs b/test/Memex.Portal.Shared.Test/TokenValidationHotPathTest.cs deleted file mode 100644 index fe69d5d35d..0000000000 --- a/test/Memex.Portal.Shared.Test/TokenValidationHotPathTest.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚦 The token hot path (Doc/Architecture/TokenValidationHotPath): authenticating a request must -/// not depend on a hub hop being answered. On memex, 2026-09-30 05:23Z, a validation forwarded to -/// ApiToken/342abeed8e6d on a replica Ready for 40 minutes was never handled; every MCP call -/// with that token answered 503 for five minutes. Measured here on a real mesh: the SHARED verdict -/// over the authoritative store validates a real token, the middleware's entry point reaches the -/// same verdict and remembers it, and an unknown token is a DEFINITIVE "not found" — the answer -/// the readiness canary depends on. -/// -public class TokenValidationHotPathTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string TokenUserId = "hotpath-user"; - - /// A real token validates straight from the store, and the middleware remembers it. - [Fact(Timeout = 120_000)] - public async Task RealToken_ValidatesFromTheStore_AndIsCached() - { - var rawToken = await MintToken(); - var storage = Mesh.ServiceProvider.GetRequiredService(); - - var direct = await ApiTokenVerdict.Decide(rawToken, p => storage.Read(p, Mesh.JsonSerializerOptions), Mesh.JsonSerializerOptions) - .Should().Within(TestTimeouts.Convergence) - .Emit("the direct verdict always emits exactly one answer", cancellationToken: TestContext.Current.CancellationToken); - Assert.True(direct.Success, $"a real token must validate from the store (error: {direct.Error}, unavailable: {direct.IsUnavailable})"); - Assert.Equal(TokenUserId, direct.UserId); - - var cache = new ValidatedTokenCache(); - var viaMiddleware = await UserContextMiddleware.ValidateToken(rawToken, Mesh, cache) - .Should().Within(TestTimeouts.Convergence) - .Emit("the middleware's entry point must answer", cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(viaMiddleware); - Assert.True(viaMiddleware.Success); - Assert.NotNull(cache.TryGet(ValidateTokenRequest.HashToken(rawToken), DateTimeOffset.UtcNow)); - } - - /// - /// A token that does not exist is a DEFINITIVE "not found" from the store — never UNAVAILABLE. - /// The readiness canary reads exactly this answer as "the path works"; if absence surfaced as - /// UNAVAILABLE, every healthy replica would report not-ready. - /// - [Fact(Timeout = 120_000)] - public async Task UnknownToken_IsADefinitiveNotFound_NotUnavailable() - { - var storage = Mesh.ServiceProvider.GetRequiredService(); - var verdict = await ApiTokenVerdict.Decide( - ValidateTokenRequest.TokenPrefix + "readiness-canary-" + Guid.NewGuid().ToString("N"), - p => storage.Read(p, Mesh.JsonSerializerOptions), Mesh.JsonSerializerOptions) - .Should().Within(TestTimeouts.Convergence) - .Emit("the direct verdict always emits exactly one answer", cancellationToken: TestContext.Current.CancellationToken); - - Assert.False(verdict.Success); - Assert.False(verdict.IsUnavailable, $"absence must be definitive, not a fault (error: {verdict.Error})"); - Assert.Equal("Token not found", verdict.Error); - } - - private async Task MintToken() - { - var rawToken = $"mw_{Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant()}"; - var hash = ValidateTokenRequest.HashToken(rawToken); - var hashPrefix = hash[..12]; - await NodeFactory.CreateNode(new MeshNode(hashPrefix, $"User/{TokenUserId}/_Api") - { - Name = "Hot Path Token", - NodeType = ApiTokenNodeType.NodeType, - MainNode = $"User/{TokenUserId}", - Content = new ApiToken - { - UserId = TokenUserId, - UserName = TokenUserId, - UserEmail = "hotpath@meshweaver.io", - TokenHash = hash, - Label = "Hot Path Token", - CreatedAt = DateTimeOffset.UtcNow, - }, - }) - .Should().Emit("the token record must exist", cancellationToken: TestContext.Current.CancellationToken); - await NodeFactory.CreateNode(new MeshNode(hashPrefix, ApiTokenNodeType.NodeType) - { - Name = "Hot Path Token Index", - NodeType = ApiTokenNodeType.NodeType, - Content = new ApiTokenIndex { TokenHash = hash, TokenPath = $"User/{TokenUserId}/_Api/{hashPrefix}" }, - }) - .Should().Emit("the index at ApiToken/{hashPrefix} must exist", cancellationToken: TestContext.Current.CancellationToken); - return rawToken; - } -} - -/// -/// The verdict and the cache, pure: every outcome of over a -/// fake read, and the cache's three rules (successes only, TTL, bound). -/// -public class TokenVerdictAndCacheTest -{ - private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); - private const string Raw = "mw_0123456789abcdef0123456789abcdef"; - private static readonly string Hash = ValidateTokenRequest.HashToken(Raw); - - private static Func> Store(ApiToken? token) - => path => Observable.Return(path switch - { - _ when path == ApiTokenVerdict.IndexPath(Hash) => new MeshNode(Hash[..12], "ApiToken") - { - Content = new ApiTokenIndex { TokenHash = Hash, TokenPath = "User/u/_Api/t" }, - }, - "User/u/_Api/t" when token is not null => new MeshNode("t", "User/u/_Api") { Content = token }, - _ => null, - }); - - private static ApiToken Token(Func? change = null) - { - var token = new ApiToken { TokenHash = Hash, UserId = "u", UserName = "U", UserEmail = "u@x.io", CreatedAt = DateTimeOffset.UtcNow }; - return change is null ? token : change(token); - } - - private static async Task Decide(Func> read) - => await ApiTokenVerdict.Decide(Raw, read, Options) - .Should().Within(TestTimeouts.Convergence) - .Emit("the verdict always emits exactly one answer", cancellationToken: TestContext.Current.CancellationToken); - - /// A live token: success with the owner's identity. - [Fact] - public async Task LiveToken_Succeeds() => Assert.Equal("u", (await Decide(Store(Token()))).UserId); - - /// Each definitive negative stays definitive (never UNAVAILABLE). - [Fact] - public async Task DefinitiveNegatives_AreFails() - { - foreach (var (read, expected) in new (Func>, string)[] - { - (_ => Observable.Return(null), "Token not found"), - (Store(Token(t => t with { IsRevoked = true })), "Token revoked"), - (Store(Token(t => t with { ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1) })), "Token expired"), - (Store(Token(t => t with { TokenHash = "other" })), "Invalid token"), - }) - { - var verdict = await Decide(read); - Assert.False(verdict.Success); - Assert.False(verdict.IsUnavailable, expected); - Assert.Equal(expected, verdict.Error); - } - } - - /// A read that FAULTS reached no verdict — UNAVAILABLE, never "invalid". - [Fact] - public async Task FaultedRead_IsUnavailable() - => Assert.True((await Decide(_ => Observable.Throw(new TimeoutException("store")))).IsUnavailable); - - /// A read that NEVER answers becomes UNAVAILABLE at the bound — never a hang. - [Fact] - public async Task UnansweredRead_IsUnavailable_AtTheBound() - => Assert.True((await Decide(_ => Observable.Never())).IsUnavailable); - - /// The cache remembers a success, and only until its TTL. - [Fact] - public void Cache_RemembersSuccesses_UntilTheTtl() - { - var cache = new ValidatedTokenCache(); - var now = DateTimeOffset.UtcNow; - cache.Put("h", ValidateTokenResponse.Ok("u", "U", "u@x.io"), now); - Assert.NotNull(cache.TryGet("h", now + TimeSpan.FromSeconds(1))); - Assert.Null(cache.TryGet("h", now + ValidatedTokenCache.Ttl + TimeSpan.FromSeconds(1))); - } - - /// A negative or unavailable verdict is never remembered. - [Fact] - public void Cache_NeverRemembersFailures() - { - var cache = new ValidatedTokenCache(); - var now = DateTimeOffset.UtcNow; - cache.Put("a", ValidateTokenResponse.Fail("Token not found"), now); - cache.Put("b", ValidateTokenResponse.Unavailable("store"), now); - Assert.Equal(0, cache.Count); - } - - /// The cache stays within its capacity. - [Fact] - public void Cache_IsBounded() - { - var cache = new ValidatedTokenCache(); - var now = DateTimeOffset.UtcNow; - for (var i = 0; i < ValidatedTokenCache.Capacity + 50; i++) - cache.Put($"h{i}", ValidateTokenResponse.Ok("u", "U", "u@x.io"), now + TimeSpan.FromMilliseconds(i)); - Assert.True(cache.Count <= ValidatedTokenCache.Capacity); - Assert.NotNull(cache.TryGet($"h{ValidatedTokenCache.Capacity + 49}", now)); // the newest entry survives the bound - } -} diff --git a/test/Memex.Portal.Shared.Test/TokenValidationIsIssuedOffTheRouterTest.cs b/test/Memex.Portal.Shared.Test/TokenValidationIsIssuedOffTheRouterTest.cs deleted file mode 100644 index df21f61573..0000000000 --- a/test/Memex.Portal.Shared.Test/TokenValidationIsIssuedOffTheRouterTest.cs +++ /dev/null @@ -1,254 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Security.Cryptography; -using System.Threading.Tasks; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 The second call site of -/// #1140, and the one -/// this repo owns: token validation on the HTTP request path. -/// -/// UserContextMiddleware resolves its hub as -/// RequestServices.GetService<PortalApplication>()?.Hub ?? RequestServices.GetRequiredService<IMessageHub>(). -/// The first is the portal hub and is fine. The FALLBACK resolves the ROOT MESH HUB in the root -/// container — which is what a portal running without the Blazor shell -/// (Features:Gui:Blazor=false) gets, and what any request outside a circuit scope gets. A -/// ValidateTokenRequest posted there reaches the ApiToken node's hub stamped -/// Sender = mesh/{id} and its response is addressed straight back at the router. -/// ValidateTokenRequest/Response is named among the observed types in #1140's own evidence -/// from memex. -/// -/// Why ReadIssuingHub and not NodeOperationIssuingHub. This is a bounded -/// READ with an HTTP request waiting on it. portal/reads-{meshId} registers no handlers, so -/// its block only ever dispatches replies to reads issued on it; the node-CRUD execution hub would -/// put this reply behind every write in flight — #2901's ~10.3 s-then-503 shape. Both seams are the -/// identity function for any hub that is not the router, so the portal-hub path is unchanged. -/// -/// The positive control is not optional — see -/// . Without it, "no -/// ROUTER_TRAFFIC" and "the capture was never wired into this mesh" are the same reading. -/// -public class TokenValidationIsIssuedOffTheRouterTest : MonolithMeshTestBase -{ - private const string TokenUserId = "TokenRouterUser"; - - /// The control's probe: a message with no meaning beyond being WORK the router posts. - private record RouterOriginProbe; - - private readonly RouterTrafficCapture _capture = new(); - - /// Producer → test: the client hub completes this when the probe reaches its handler. - private readonly AsyncSubject _probeArrived = new(); - - public TokenValidationIsIssuedOffTheRouterTest(ITestOutputHelper output) : base(output) - { - // Registered AFTER the base constructor's ClearProviders(), so it survives alongside the - // xUnit sink. The detector's whole contract is the ERROR it emits; reading that record is - // the only way to assert on it without re-implementing the decision here. - Services.AddLogging(l => l.Services.AddSingleton(_capture)); - } - - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .ConfigureHub(c => c - .WithTypes(typeof(RouterOriginProbe)) - .WithType(typeof(ValidateTokenRequest), nameof(ValidateTokenRequest)) - .WithType(typeof(ValidateTokenResponse), nameof(ValidateTokenResponse))); - - /// - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) - => base.ConfigureClient(configuration) - .WithTypes(typeof(RouterOriginProbe)) - .WithHandler((_, delivery) => - { - _probeArrived.OnNext(Unit.Default); - _probeArrived.OnCompleted(); - return delivery.Processed(); - }); - - /// - /// 🚨 THE MEASUREMENT. The middleware's own entry point, handed the ROOT MESH HUB — exactly what - /// its GetRequiredService<IMessageHub>() fallback produces — must leave the router - /// off BOTH ends of the validation exchange. - /// - [Fact(Timeout = 120_000)] - public async Task TokenValidationIssuedFromTheRootMeshHub_NeverPutsTheRouterOnEitherEnd() - { - var rawToken = $"mw_{Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant()}"; - var hash = ValidateTokenRequest.HashToken(rawToken); - var hashPrefix = hash[..12]; - - // A real token node, created through the seam that is already off-router so nothing before - // the subject under test can contribute a record. - await NodeFactory.CreateNode(new MeshNode(hashPrefix, $"User/{TokenUserId}/_Api") - { - Name = "Router Origin Token", - NodeType = ApiTokenNodeType.NodeType, - MainNode = $"User/{TokenUserId}", - Content = new ApiToken - { - UserId = TokenUserId, - UserName = TokenUserId, - UserEmail = "token-router@meshweaver.io", - TokenHash = hash, - Label = "Router Origin Token", - CreatedAt = DateTimeOffset.UtcNow, - }, - }) - .Should().Emit("the token must exist, or the validation never reaches a receiving hub " - + "and the assertion below would measure nothing", cancellationToken: TestContext.Current.CancellationToken); - - // The routing index the middleware's target address names — `ApiToken/{hashPrefix}`. In - // production ApiTokenService writes it beside the token; nothing writes it automatically, so - // the fixture does. Without it the validation faults with a DeliveryFailure and the - // assertions below would pass having observed no exchange at all (they did, on the first - // run of this test — which is why the Success assertion above is not decoration). - await NodeFactory.CreateNode(new MeshNode(hashPrefix, ApiTokenNodeType.NodeType) - { - Name = "Router Origin Token Index", - NodeType = ApiTokenNodeType.NodeType, - Content = new ApiTokenIndex - { - TokenHash = hash, - TokenPath = $"User/{TokenUserId}/_Api/{hashPrefix}", - }, - }) - .Should().Emit("the validation routes to the INDEX at ApiToken/{hashPrefix} first", cancellationToken: TestContext.Current.CancellationToken); - - // 🚨 On `Mesh` DELIBERATELY: the ROUTER as the caller IS the subject, and it is the shape - // the middleware's own fallback produces. - var response = await UserContextMiddleware.ValidateTokenViaHub(rawToken, Mesh) - .Should().Within(TestTimeouts.Convergence) - .Emit("the validation must actually complete — an exchange that never happened emits no " - + "traffic at all", cancellationToken: TestContext.Current.CancellationToken); - - response.Should().NotBeNull(); - response!.Success.Should().BeTrue( - $"the token is real — a failed validation takes a different, shorter path and would " - + $"leave the assertions below measuring nothing (error: {response.Error ?? "(none)"}, " - + $"unavailable: {response.IsUnavailable})"); - response.UserId.Should().Be(TokenUserId); - - DumpReports(); - Reports().Where(r => r.Role.Contains("sender", StringComparison.Ordinal)).Should().BeEmpty( - "the ValidateTokenRequest must reach the ApiToken node's hub from the off-router read " - + "seam (MeshExtensions.ReadIssuingHub), never stamped `sender: mesh/{id}` — which is " - + "one of the message types #1140's evidence from memex names"); - Reports().Where(r => r.Role.Contains("target", StringComparison.Ordinal)).Should().BeEmpty( - "and the ValidateTokenResponse must not be addressed back at the router, which follows " - + "from the request's sender"); - } - - /// - /// 🚨 THE POSITIVE CONTROL. The measurement reads "no records" as "the seam held", which is only - /// valid if this fixture's capture CAN record one. So make the router an end on purpose. - /// - /// A plain Post at a client hub: the same "sender" role, with no token or node CRUD - /// involved, so a failure here cannot be confused with a failure of the validation path. The - /// arrival subject is the barrier rather than a sleep — the detector runs synchronously at the - /// top of DeliverMessage, strictly before the delivery reaches the handler that completes - /// it. - /// - [Fact(Timeout = 120_000)] - public async Task TheCapture_RecordsAViolation_WhenTheRouterGenuinelyPostsWork() - { - var client = GetClient(); - - Mesh.Post(new RouterOriginProbe(), o => o.WithTarget(client.Address)); - - await _probeArrived.Should().Within(TestTimeouts.Convergence) - .Emit("the probe must actually reach the client hub, or nothing was delivered and this " - + "control proves nothing", cancellationToken: TestContext.Current.CancellationToken); - - DumpReports(); - // Asserted on ROLE and ENDS, never on the message type: the client hub is reached over a - // serializing route, so the detector reads `RawJson` — precisely the type #1140's production - // lines carry. - var report = Reports().Should().ContainSingle( - "the capture must be able to record a genuine violation, or the measurement's green is " - + "an instrument that cannot fail rather than a seam that holds").Subject; - report.Role.Should().Be("sender"); - report.Sender.Should().Be(Mesh.Address.ToString()); - report.Target.Should().Be(client.Address.ToString()); - } - - private RouterTrafficRecord[] Reports() => _capture.Records; - - private void DumpReports() - { - foreach (var record in _capture.Records) - Output.WriteLine($"ROUTER_TRAFFIC captured: {record}"); - } - - private sealed record RouterTrafficRecord(string MessageType, string Role, string Sender, string Target); - - /// - /// Reads the detector's own ERROR out of the logging pipeline — structured state, not the - /// formatted string, so the assertions pin the VALUES the detector chose rather than the prose - /// around them. - /// - private sealed class RouterTrafficCapture : ILoggerProvider - { - private readonly ConcurrentQueue _records = new(); - - internal RouterTrafficRecord[] Records => _records.ToArray(); - - public ILogger CreateLogger(string categoryName) => new CapturingLogger(_records); - - public void Dispose() { } - - private sealed class CapturingLogger(ConcurrentQueue sink) : ILogger - { - private sealed class NullScope : IDisposable - { - internal static readonly NullScope Instance = new(); - public void Dispose() { } - } - - public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; - - public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - if (logLevel < LogLevel.Error || state is not IReadOnlyList> values) - return; - if (!formatter(state, exception).StartsWith("ROUTER_TRAFFIC:", StringComparison.Ordinal)) - return; - - sink.Enqueue(new RouterTrafficRecord( - Value(values, "MessageType"), - Value(values, "Role"), - Value(values, "Sender"), - Value(values, "Target"))); - } - - private static string Value(IReadOnlyList> values, string key) - { - foreach (var pair in values) - if (pair.Key == key) - return pair.Value?.ToString() ?? "(null)"; - return "(absent)"; - } - } - } -} diff --git a/test/Memex.Portal.Shared.Test/TokenValidationNegativeStagesTest.cs b/test/Memex.Portal.Shared.Test/TokenValidationNegativeStagesTest.cs deleted file mode 100644 index 45facf9c99..0000000000 --- a/test/Memex.Portal.Shared.Test/TokenValidationNegativeStagesTest.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System; -using System.Linq; -using System.Security.Cryptography; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The stage a rejected bearer token is logged under tells the truth about the ROW. -/// -/// Why the log line is the subject. A 401 from the bearer path is a definitive -/// verdict, and the investigation of #5074 rested its whole inference on the stage the validator -/// logs: index-not-found / token-not-found were read as "the row was deleted", which -/// in the OAuth path can only be the one-credential-per-client supersede. That reading is sound -/// ONLY if -not-found is emitted exclusively when the store answered that no row exists. -/// It was not: a row that IS there but whose content cannot be read as a token record took the -/// same branch and printed "no row at {Path}" — sending the reader after a delete that -/// never happened. The two facts now carry two stages, -not-found and -unreadable. -/// -/// What does NOT change. Both are definitive negatives — the store was read, and a row -/// carrying no token record authenticates nobody — so the verdict stays -/// in both cases; -/// remains reserved for a store that could not be read at all. The assertions pin that too. -/// -/// SHOULD-FAIL-IF the two negatives fold back into one line: -/// goes red on -/// the pre-change code (it printed index-not-found for that row), while -/// is the control in the other -/// direction — an implementation that stamped every negative -unreadable would pass the -/// first and fail this one, so no constant satisfies the pair. -/// -public class TokenValidationNegativeStagesTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private CapturingLogger Log { get; } = new(); - - private IStorageAdapter Storage => Mesh.ServiceProvider.GetRequiredService(); - - private ApiTokenService Service() => new( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Storage, - Log); - - /// A raw token nobody minted, in the wire shape the validator accepts. - private static string UnmintedRawToken() => - "mw_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - - [Fact] - public async Task ARowThatExistsButCarriesNoTokenRecord_IsLoggedUnreadable_NotNotFound() - { - var rawToken = UnmintedRawToken(); - var prefix = ApiTokenService.HashToken(rawToken)[..12]; - var indexPath = $"ApiToken/{prefix}"; - - // The index row EXISTS at the address the validator reads first, but nothing in it is a - // token record: neither an ApiTokenIndex nor a legacy ApiToken, typed or as JSON. - var written = await Storage - .Write(new MeshNode(prefix, "ApiToken") - { - Name = "not a token record", - NodeType = "ApiToken", - State = MeshNodeState.Active, - Content = "not a token record", - }, Mesh.JsonSerializerOptions) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - written.Should().NotBeNull("the row must be in the store before validation reads it"); - - var verdict = await Service().Validate(rawToken) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - - verdict.Status.Should().Be( - TokenValidationStatus.Invalid, - "the store WAS read and the row carries no token record — a definitive negative, never Unavailable"); - - var warnings = Log.Lines(LogLevel.Warning); - var forThisToken = warnings.Where(l => l.Contains(prefix)).ToArray(); - forThisToken.Should().ContainSingle( - l => l.Contains("index-unreadable"), - "a row that exists but cannot be typed is its own fact, logged under its own stage"); - forThisToken.Single(l => l.Contains("index-unreadable")).Should() - .Contain($"row at {indexPath} exists") - .And.Contain("was NOT deleted", - "the line must say the opposite of what -not-found says, because the opposite is what happened") - .And.Contain("String", - "naming the content's runtime type is what makes the next occurrence attributable"); - forThisToken.Should().NotContain( - l => l.Contains("-not-found") || l.Contains("no row at"), - "'no row' is the claim an investigation reads as 'the row was deleted', and here the row is there"); - } - - [Fact] - public async Task AnAbsentRow_IsLoggedNotFound_NeverUnreadable() - { - var rawToken = UnmintedRawToken(); - var prefix = ApiTokenService.HashToken(rawToken)[..12]; - - var verdict = await Service().Validate(rawToken) - .Should().Within(TestTimeouts.Convergence).Emit(cancellationToken: TestContext.Current.CancellationToken); - - verdict.Status.Should().Be( - TokenValidationStatus.Invalid, - "an absent row is a verdict, not a failure to reach one"); - - var forThisToken = Log.Lines(LogLevel.Warning).Where(l => l.Contains(prefix)).ToArray(); - forThisToken.Should().ContainSingle( - l => l.Contains("index-not-found") && l.Contains($"no row at ApiToken/{prefix}"), - "an absent row keeps the stage every investigation already knows how to read"); - forThisToken.Should().NotContain( - l => l.Contains("-unreadable"), - "the unreadable stage is reserved for a row that exists — stamping it on absence would " - + "make the split meaningless"); - } -} diff --git a/test/Memex.Portal.Shared.Test/TokenValidationReadinessTest.cs b/test/Memex.Portal.Shared.Test/TokenValidationReadinessTest.cs deleted file mode 100644 index d9e8534eff..0000000000 --- a/test/Memex.Portal.Shared.Test/TokenValidationReadinessTest.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚦 Readiness = this replica can authenticate an API token (maintainer, 2026-09-30: "we must show -/// as ready only when this stuff can be served"). The thresholds, pure: not ready until measured, -/// still ready through fewer than -/// consecutive failures (one slow read must never take a replica — let alone the fleet — out of -/// rotation), not ready from there on, ready again on the first good sample. -/// -public class TokenValidationReadinessTest -{ - private static TokenValidationReadiness.Sample Ok() => new(DateTimeOffset.UtcNow, Ok: true, NoStore: false, "ok", 12); - private static TokenValidationReadiness.Sample Failed() => new(DateTimeOffset.UtcNow, Ok: false, NoStore: false, "TimeoutException", 10_000); - - /// An unmeasured path is not a working one. - [Fact] - public void NotMeasuredYet_IsNotReady() - => Assert.Equal(HealthStatus.Unhealthy, TokenValidationReadiness.Classify(null, 0).Status); - - /// A good sample is ready. - [Fact] - public void GoodSample_IsReady() - => Assert.Equal(HealthStatus.Healthy, TokenValidationReadiness.Classify(Ok(), 0).Status); - - /// - /// Below the threshold the replica stays in rotation — Degraded, which /ready answers 200 — - /// so a transient never flaps a replica out. - /// - [Fact] - public void FewerFailuresThanTheThreshold_StayReady() - { - for (var failures = 1; failures < TokenValidationReadiness.FailuresToUnready; failures++) - Assert.Equal(HealthStatus.Degraded, TokenValidationReadiness.Classify(Failed(), failures).Status); - } - - /// A sustained failure takes the replica out of rotation. - [Fact] - public void SustainedFailure_IsNotReady() - => Assert.Equal(HealthStatus.Unhealthy, - TokenValidationReadiness.Classify(Failed(), TokenValidationReadiness.FailuresToUnready).Status); - - /// A host with no mesh store has nothing to measure and is not held back by it. - [Fact] - public void NoStore_IsReady() - => Assert.Equal(HealthStatus.Healthy, - TokenValidationReadiness.Classify(new(DateTimeOffset.UtcNow, Ok: true, NoStore: true, "no store", 0), 0).Status); -} diff --git a/test/Memex.Portal.Shared.Test/UnanchoredQueryCensusTest.cs b/test/Memex.Portal.Shared.Test/UnanchoredQueryCensusTest.cs deleted file mode 100644 index efe361d2c2..0000000000 --- a/test/Memex.Portal.Shared.Test/UnanchoredQueryCensusTest.cs +++ /dev/null @@ -1,222 +0,0 @@ -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Authentication; -using Memex.Portal.Shared.Email; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The census of every nodeType: query core issues at RUNTIME that names no partition in its -/// text, fed to the decision the Postgres planner takes before running it (#3202, Plugins #1231): -/// IsSufficientlySpecified(parsed) || ResolvesByRoutingHint(parsed), the latter against the -/// REAL of a running mesh. Everything else is refused at runtime -/// with an UnanchoredQueryException — a fault nothing compiles differently for and no test -/// used to catch, which is how the sign-in path broke for every user with both repositories green. -/// -/// The finding (2026-09-03, before this change). Of the query literals under -/// src/ and memex/ with nodeType: and no path:/namespace:/ -/// partitions:all on the same line, the ones the planner REFUSED — every other one was either -/// anchored on an adjacent line or pinned by a routing rule — are listed in -/// : the sign-in role fold, the home page's root and -/// "shared with me" legs, the sitemap, every NodeType-catalog enumeration (compile sweep, pre-warm, -/// prebuilt adoption, create menu, MCP catalog, cell surfaces), the UI-contribution catalog, the -/// outbound-mail watch, the event-subscription runner, the GitHub webhook's config scan, the -/// instance registry's id lookups, the What's New type lane, the Code usage prior, the stranded- -/// instance probe, the root subject picker and the plugin-catalog watcher. Each is now either -/// ANCHORED (the sign-in fold — three pinned homes) or DECLARED mesh-wide through -/// with the reason at the call site. -/// -/// How this can fail. The corpus is asserted non-empty and every entry must parse to -/// a query naming a node type (a matcher that matched nothing cannot pass); the known-refused -/// shapes are asserted REFUSED and the known-anchored ones ANCHORED before the corpus is judged (a -/// classifier that answered "served" to everything cannot pass); a parse or configuration fault -/// propagates — nothing here is caught; and -/// mutates the decision's input and asserts the verdict moves. -/// -public class UnanchoredQueryCensusTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private MeshConfiguration MeshConfig => Mesh.ServiceProvider.GetRequiredService(); - - /// - /// Every runtime query core issues whose TEXT names no partition, as the code issues it today — - /// built from the public builders where the site exposes one, otherwise a literal copy of the - /// site's shape with representative substitutions and the site named. A site edited to a new - /// shape without updating its row here is exactly the drift the census exists to notice. - /// - private static IEnumerable<(string Site, string Query)> Corpus() - { - // ── sign-in / identity ────────────────────────────────────────────────────────────── - foreach (var leg in OnboardingMiddleware.RoleQueries("rbuergi")) - yield return ("OnboardingMiddleware.LoadUserRoles", leg); - yield return ("OnboardingMiddleware.FindUserByEmail", OnboardingMiddleware.UserByEmailQuery("a@b.c")); - yield return ("UserIdentityCache.DirectoryQuery", UserIdentityCache.DirectoryQuery.Query!); - yield return ("DevAuthController legacy User fallback", "nodeType:User"); - yield return ("GroupInviteExtensions / SpaceInviteService", "nodeType:User content.email:a@b.c"); - foreach (var q in AccessSubjectQueries.ForScope(null)) - yield return ("AccessSubjectQueries.ForScope(root)", q); - foreach (var q in AccessSubjectQueries.ForScope("acme/doc")) - yield return ("AccessSubjectQueries.ForScope(acme/doc)", q); - foreach (var q in SecurityQueries.AllShapes) - yield return ("SecurityQueries", q); - - // ── portal host (memex/Memex.Portal.Shared) ──────────────────────────────────────── - yield return ("OutboundEmailSender.WatchQuery", OutboundEmailSender.WatchQuery); - foreach (var q in WhatsNewSettingsTab.ListingQueries) - yield return ("WhatsNewSettingsTab.ListingQueries", q); - foreach (var type in new[] { "Store/Plugin", "Store/Catalog", "Space" }) - yield return ("SeoEndpoints sitemap candidates (#4080: root push-down, uncapped)", SeoEndpoints.RootCandidateQuery(type)); - yield return ("MeshWeaverInstanceService id lookup", MeshWideQuery.Declare("nodeType:MeshWeaverInstance id:inst-1")); - - // ── platform (src/) ──────────────────────────────────────────────────────────────── - yield return ("NodeType catalog enumerations (pre-warm, sweep, recompile, prebuilt, cells, MCP)", MeshWideQuery.OfType("NodeType")); - yield return ("NodeTypeLayoutAreas compile sweep", MeshWideQuery.Declare("nodeType:NodeType select:path,id,name,nodeType,content")); - yield return ("CompletionUsageIndex", MeshWideQuery.Declare("nodeType:Code limit:2000")); - yield return ("UiContributionCatalog", MeshWideQuery.OfType("UiContribution")); - yield return ("CreateLayoutArea namespace picker", MeshWideQuery.OfType("Space")); - // The Create menu at the ROOT path has no namespace to scope to, so its NodeType leg is - // the declared catalog fan-out (CreatableTypesProvider.BuildQueries). Every other leg it - // issues carries a concrete namespace and is anchored by construction. - yield return ("CreatableTypesProvider root create menu", - MeshWideQuery.Declare("nodeType:NodeType context:create")); - yield return ("EventSubscriptionRunner", MeshWideQuery.OfType("Invitation") + " content.email:a@b.c"); - yield return ("GitHubWebhookProcessor / ModuleDiscovery / InstanceComboReader", MeshWideQuery.OfType("GitHubSync")); - yield return ("NodeTypeInstanceProbe", MeshWideQuery.OfType("Acme/Story")); - yield return ("PluginUpdateWatcher", MeshWideQuery.OfType("PluginCatalog")); - yield return ("InstanceGrantAdminSettingsTab / InstancePlanService", MeshWideQuery.Declare("nodeType:MeshWeaverInstance id:inst-1")); - // The home's two mesh-wide reads, moved OFF the render path (2026-10-04): they run once per - // logon in RefreshSpacePathsLogonAction, and the home anchors on the profile's manifests. - yield return ("RefreshSpacePathsLogonAction shared grants (was the home's shared-with-me)", MeshWideQuery.Declare("nodeType:AccessAssignment content.accessObject:rbuergi")); - yield return ("RefreshSpacePathsLogonAction space roots (was the home's root leg)", "namespace: is:main is:content nodeType:Space select:path,id,namespace,nodeType partitions:all"); - yield return ("UserActivityLayoutAreas home subtree scope", "is:main is:content -nodeType:User sort:Name-asc partitions:all"); - } - - /// - /// 🚨 THE FINDING — the shapes the planner refused before this change, one per call site, with - /// representative substitutions. Kept as the positive control: a classifier that cannot see a - /// refusal in THESE cannot be trusted to see one in the corpus. - /// - private static readonly (string Site, string Query)[] RefusedBeforeThisChange = - [ - ("OnboardingMiddleware.LoadUserRoles (the 503 — #3202)", "nodeType:AccessAssignment content.accessObject:\"rbuergi\" scope:subtree limit:all"), - ("UserActivityLayoutAreas.ObserveSharedTargets (home: shared with me)", "nodeType:AccessAssignment content.accessObject:rbuergi"), - ("UserActivityLayoutAreas.FirstLevelUnion root leg (home: spaces)", "namespace: is:main is:content nodeType:Space sort:Name-asc"), - ("UserActivityLayoutAreas.CatalogQuery subtree scope (home: everything)", "is:main is:content -nodeType:User sort:Name-asc"), - ("SeoEndpoints sitemap candidates", "nodeType:Space is:main limit:500"), - ("MeshWeaverInstanceService.AdoptKeyHash / IsIdAvailable", "nodeType:MeshWeaverInstance id:inst-1"), - ("InstancePlanService / InstanceGrantAdminSettingsTab", "nodeType:MeshWeaverInstance"), - ("InstanceGrantAdminSettingsTab keys", "nodeType:RegistrationKey"), - ("OutboundEmailSender.WatchQuery", "nodeType:Email content.direction:Outbound -content.status:Sending -content.status:Sent -content.status:Failed"), - ("WhatsNewSettingsTab type lane", "nodeType:WhatsNew"), - ("DynamicTypePreWarmer / NodeTypeRecompileExtensions / ShippedPrebuiltBundles / CellSurfaceAssemblyProvider / CreatableTypesProvider / MeshOperations.Catalog", "nodeType:NodeType"), - ("NodeTypeLayoutAreas compile sweep", "nodeType:NodeType select:path,id,name,nodeType,content"), - ("CompletionUsageIndex", "nodeType:Code limit:2000"), - ("UiContributionCatalog", "nodeType:UiContribution"), - ("CreateLayoutArea namespace picker", "nodeType:Space"), - ("EventSubscriptionRunner (a trigger type with no routing rule)", "nodeType:Acme/Order content.status:Open"), - ("GitHubWebhookProcessor / ModuleDiscoveryService / InstanceComboReader", "nodeType:GitHubSync"), - ("InstanceComboReader discovery records", "nodeType:ModuleDiscovery"), - ("NodeTypeInstanceProbe", "nodeType:Acme/Story"), - ("PluginUpdateWatcher", "nodeType:PluginCatalog"), - ("AccessSubjectQueries.Groups(root)", "nodeType:Group"), - ]; - - /// - /// Shapes that were NOT refused although their text names no partition — the ones a routing - /// rule pins. Listed so the census records WHY they are absent from the finding, and so the - /// rule's disappearance would show up here. - /// - private static readonly (string Site, string Query, string Partition)[] PinnedByRule = - [ - ("OnboardingMiddleware.FindUserByEmail", "nodeType:User content.email:a@b.c limit:1", "Auth"), - ("UserIdentityCache.DirectoryQuery", "nodeType:User", "Auth"), - ("EventSubscriptionRunner (Invitation trigger)", "nodeType:Invitation", "Admin"), - ("EventSubscriptionRunner (EventSubscription trigger)", "nodeType:EventSubscription", "Admin"), - ]; - - [Fact] - public void ThePlannerRefusedEveryShapeInTheFinding_AndPinsTheRuledOnes() - { - var configuration = MeshConfig; - RefusedBeforeThisChange.Should().NotBeEmpty(); - foreach (var (site, query) in RefusedBeforeThisChange) - QueryRouteClassifier.VerdictOf(query, configuration).Should().Be(PlannerVerdict.Refused, - $"{site} issued '{query}', which names no partition and is pinned by no rule"); - - PinnedByRule.Should().NotBeEmpty(); - foreach (var (site, query, partition) in PinnedByRule) - { - QueryRouteClassifier.VerdictOf(query, configuration).Should().Be(PlannerVerdict.PinnedByRoutingRule, - $"{site} issues '{query}', which a registered routing rule pins"); - configuration.ResolveRoutingHints(new QueryParser().Parse(query)).Partition.Should().Be(partition); - } - } - - /// - /// The census proper: after this change no runtime query core issues is refused. The refused - /// set is printed whatever its size — an empty one is the assertion, never a silent pass. - /// - [Fact] - public void NoRuntimeQueryCoreIssuesIsRefused() - { - var configuration = MeshConfig; - var corpus = Corpus().ToArray(); - corpus.Should().NotBeEmpty("a census over nothing proves nothing"); - - var parser = new QueryParser(); - foreach (var (site, query) in corpus) - { - var parsed = parser.Parse(query); - (parsed.ExtractNodeType() is not null || parsed.IsMain == true || !string.IsNullOrEmpty(parsed.Path)) - .Should().BeTrue( - $"{site}: '{query}' must parse to a query the planner can classify — a row the parser " - + "reads as empty is a row the census is not measuring"); - } - - var verdicts = corpus - .Select(x => (x.Site, x.Query, Verdict: QueryRouteClassifier.VerdictOf(x.Query, configuration))) - .ToArray(); - var refused = verdicts.Where(v => v.Verdict == PlannerVerdict.Refused).ToArray(); - - Output.WriteLine($"census: {verdicts.Length} queries, {refused.Length} refused"); - foreach (var (site, query, verdict) in verdicts) - Output.WriteLine($" [{verdict}] {site}: {query}"); - - refused.Should().BeEmpty( - "every runtime query core issues must be anchored, pinned by a routing rule, or DECLARED " - + "mesh-wide through MeshWideQuery with the reason at the call site — the storage layer " - + "refuses anything else at runtime, and that refusal is invisible to the compiler (#3202). " - + "Refused: " + string.Join("; ", refused.Select(r => $"{r.Site}: '{r.Query}'"))); - - // The declared fan-outs are the ones whose cost is knowingly paid — record how many. - verdicts.Count(v => v.Verdict == PlannerVerdict.DeclaredFanOut).Should().BePositive( - "core has genuine catalogs (NodeType, UiContribution, Space roots) that ARE mesh-wide; a census " - + "that finds none of them declared is reading the wrong corpus"); - } - - /// - /// 🚨 The mutation: the decision's second input is the routing rules. Take them away and the - /// rule-pinned rows must flip to REFUSED, so a corpus that passes only because of a rule cannot - /// pass under a planner without it. If this stayed green, the harness would be measuring the - /// classifier's optimism rather than the planner's decision. - /// - [Fact] - public void TheHarnessGoesRedWhenTheRulesAreTakenAway() - { - var withRules = Corpus().Select(x => QueryRouteClassifier.VerdictOf(x.Query, MeshConfig)).ToArray(); - var withoutRules = Corpus().Select(x => QueryRouteClassifier.VerdictOf(x.Query, configuration: null)).ToArray(); - - withRules.Should().NotContain(PlannerVerdict.Refused); - withoutRules.Should().Contain(PlannerVerdict.Refused, - "the corpus carries rule-pinned reads (nodeType:User), so removing the rules must produce a refusal"); - withoutRules.Count(v => v == PlannerVerdict.Refused).Should().Be( - withRules.Count(v => v == PlannerVerdict.PinnedByRoutingRule), - "exactly the rule-pinned rows flip; anchored and declared rows do not depend on rules"); - } -} diff --git a/test/Memex.Portal.Shared.Test/UndeclaredCatalogFormatIsDetectedTest.cs b/test/Memex.Portal.Shared.Test/UndeclaredCatalogFormatIsDetectedTest.cs deleted file mode 100644 index 74910499b9..0000000000 --- a/test/Memex.Portal.Shared.Test/UndeclaredCatalogFormatIsDetectedTest.cs +++ /dev/null @@ -1,90 +0,0 @@ -#pragma warning disable CS1591 -using System; -using System.IO; -using MeshWeaver.PluginCatalog; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// #3384 made the shipped node-repo format the default for every reader of a catalog node — and -/// turned every undeclared package.json catalog into an empty page (measured on the Plugins -/// pin move to 3.0.0-ci.7917: two migrated PluginCatalog tests rendered zero cards). A declared -/// format still wins; an undeclared one on a local checkout is read off the layout. -/// -public class UndeclaredCatalogFormatIsDetectedTest : IDisposable -{ - private readonly string root = Path.Combine(Path.GetTempPath(), "mw-catfmt-" + Guid.NewGuid().ToString("N")); - - private string Repo(string name) { var p = Path.Combine(root, name); Directory.CreateDirectory(p); return p; } - private static void Touch(string repo, string relative) - { - var p = Path.Combine(repo, relative); - Directory.CreateDirectory(Path.GetDirectoryName(p)!); - File.WriteAllText(p, "{}"); - } - - [Fact] - public void AnUndeclaredPackageJsonCatalog_IsReadAsPackageJson() - { - var repo = Repo("pkg"); - Touch(repo, "catalog/pack-a/package.json"); - Touch(repo, "catalog/pack-b/package.json"); - Assert.False(PackageSources.IsNodeRepoFormatOrDetected(null, repo, "catalog"), - "two package.json manifests under the subdir and no index.json anywhere is the manifest shape — the two Plugins tests that went dark on 7917"); - Assert.False(PackageSources.IsNodeRepoFormatOrDetected("", repo, "catalog")); - } - - [Fact] - public void AnUndeclaredNodeRepoCheckout_StaysNodeRepo() - { - var repo = Repo("nr"); - Touch(repo, "Edu/index.json"); - Touch(repo, "Store/index.json"); - Assert.True(PackageSources.IsNodeRepoFormatOrDetected(null, repo, null), "Space roots at the repo root — the #3384 case keeps working"); - Assert.True(PackageSources.IsNodeRepoFormatOrDetected(null, repo, "plugins"), "a subdir that does not exist changes nothing"); - } - - [Fact] - public void BothShapesAtOnce_FallBackToTheShippedDefault() - { - var repo = Repo("both"); - Touch(repo, "Edu/index.json"); - Touch(repo, "catalog/pack-a/package.json"); - Assert.True(PackageSources.IsNodeRepoFormatOrDetected(null, repo, "catalog"), "ambiguity is not evidence for the manifest shape"); - } - - [Fact] - public void ADeclaredFormat_WinsOverTheLayout() - { - var repo = Repo("declared"); - Touch(repo, "catalog/pack-a/package.json"); - Assert.True(PackageSources.IsNodeRepoFormatOrDetected("node-repo", repo, "catalog"), "declared node-repo over a manifest layout is honoured — the operator said so"); - var nr = Repo("declared-nr"); - Touch(nr, "Edu/index.json"); - Assert.False(PackageSources.IsNodeRepoFormatOrDetected("package-json", nr, null), "declared package-json over a node-repo layout is honoured too"); - } - - [Fact] - public void AUrlOrAnAbsentCheckout_KeepsTheShippedDefault() - { - Assert.True(PackageSources.IsNodeRepoFormatOrDetected(null, "https://github.com/Systemorph/MeshWeaver.Plugins", "catalog"), "nothing to inspect before a fetch"); - Assert.True(PackageSources.IsNodeRepoFormatOrDetected(null, Path.Combine(root, "does-not-exist"), "catalog"), "an absent directory is not evidence"); - var empty = Repo("empty"); - Directory.CreateDirectory(Path.Combine(empty, "catalog")); - Assert.True(PackageSources.IsNodeRepoFormatOrDetected(null, empty, "catalog"), "an existing but EMPTY checkout is not evidence for the manifest shape either — it must not silently pick package.json"); - Assert.True(PackageSources.IsNodeRepoFormatOrDetected(null, null, null)); - Assert.True(PackageSources.IsNodeRepoFormatOrDetected(null, "", null)); - Assert.True(PackageSources.IsNodeRepoFormatOrDetected(null, " ", null), "whitespace is absent, not a path to scan"); - var pkg = Repo("rooted"); - Touch(pkg, "catalog/pack-a/package.json"); - Assert.True(PackageSources.IsNodeRepoFormatOrDetected(null, pkg, Path.Combine(pkg, "catalog")), - "a ROOTED subdir would make Path.Combine drop the repo and scan elsewhere — refused into the default"); - Assert.True(PackageSources.IsNodeRepoFormatOrDetected(null, "\0not-a-path\0", "catalog"), "a malformed path fails closed to the default, never throws into a render"); - } - - public void Dispose() - { - try { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } catch (IOException) { } - } -} diff --git a/test/Memex.Portal.Shared.Test/UndeliveredModuleIsRecordedTest.cs b/test/Memex.Portal.Shared.Test/UndeliveredModuleIsRecordedTest.cs deleted file mode 100644 index e13485c72d..0000000000 --- a/test/Memex.Portal.Shared.Test/UndeliveredModuleIsRecordedTest.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A store-installed module the registry does NOT OFFER must be recorded as not delivered — -/// never left reading as installed and up to date (Systemorph/MeshWeaver.Plugins#1584). -/// -/// Both lanes of 's reconcile iterate the packages the -/// REGISTRY SERVES and intersect them with this installation's install records. A package -/// installed here that the registry does not serve is therefore not "up to date" and not "failed" -/// — it is absent from both loops, and absence produced no log line, no ledger entry and no card -/// state anywhere. -/// -/// Measured on memex 2026-09-10: the registry answered its feed with 45 manifests and -/// Mail was not among them — the package declares tier: personal while the instance's -/// catalog grant covers the baseline plan, so the registry's own maintenance task answered "the -/// registry does not offer a package 'Mail' to this instance". The CONTENT kept arriving through -/// the instance's own git source and the install record advanced to 1.5.0 an hour before the -/// measurement, while MeshWeaver.Mail.MicrosoftGraph went on loading an assembly written -/// eleven days earlier and every dashboard read "installed, up to date". An Executive Assistant -/// thread asked for the Teams tools that shipped in 1.5 and had none. -/// -/// This fixture pins the mechanism against the REAL reconciler, the REAL -/// HTTP path and a registry that answers exactly as memex's -/// did — a full, successful feed that simply does not list the package: -/// -/// The undelivered package is named on the ledger node the reconciler owns, carrying the -/// module whose bytes are not arriving and the content identity the record claims. -/// A package the registry DOES offer is not named — being served is what delivery is. -/// A CONTENT-ONLY installed package the registry does not offer is not named either: it -/// declares no module, so there are no module bytes to deliver and calling it undelivered would -/// be an alarm nobody can act on. -/// The consumer-wide verdict () reads -/// the same answer off the ledger, with as its -/// denominator — so an empty result can be read as "nothing undelivered" instead of "nothing -/// known". -/// -/// -public class UndeliveredModuleIsRecordedTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string RegistryUrl = "http://registry.delivery.test"; - private const string RegistryName = "Delivery Test Registry"; - private const string Token = "mwi_undelivered_module_test"; - - /// Installed here, declares a module, and the registry SERVES it — delivered. - private const string ServedPackageId = "DeliveredPkg"; - - /// Installed here, declares a module, and the registry does NOT serve it. The - /// Mail shape: content moves, module bytes do not. - private const string DeclinedPackageId = "DeclinedPkg"; - private const string DeclinedModule = "MeshWeaver.Delivery.Declined"; - private const string DeclinedModuleVersion = "mv-declined-1f5"; - - /// Installed here, NOT served, and declares no module — nothing to deliver. - private const string ContentOnlyPackageId = "ContentOnlyPkg"; - - private readonly FakeRegistry registry = new(); - - private System.Text.Json.JsonSerializerOptions Json => Mesh.JsonSerializerOptions; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - // The registry, as RegistryPackageSource reaches it: through the named HttpClient the - // catalog wires. Nothing between the reconciler and the wire is replaced. - .ConfigureServices(s => s.AddSingleton(new FakeRegistryClientFactory(registry))); - - [Fact] - public async Task AFullFeedThatOmitsAnInstalledModulePackage_RecordsItAsNotDelivered() - { - await SeedInstallRecord(ServedPackageId, module: "MeshWeaver.Delivery.Served", - moduleVersion: "mv-served-9e4"); - await SeedInstallRecord(DeclinedPackageId, module: DeclinedModule, - moduleVersion: DeclinedModuleVersion); - await SeedInstallRecord(ContentOnlyPackageId, module: null, moduleVersion: "mv-content-3b2"); - - // 🚨 A SUCCESSFUL, COMPLETE feed that simply does not list the other two packages — the - // entitlement decline as a consumer sees it. Nothing here is an error: the registry - // answered in full, which is precisely what makes the absence evidence rather than noise. - registry.Serve(HttpStatusCode.OK, [ServedManifest()]); - - var reconciler = Mesh.ServiceProvider.GetRequiredService(); - var reference = new PluginRegistryReference { Name = RegistryName, Url = RegistryUrl, Token = Token }; - await reconciler.ReconcileRegistry(reference, _ => TimeSpan.Zero).Timeout(TestTimeouts.Convergence).Await(); - - // ── The reconcile completed, and it recorded WHAT IT DOES NOT DELIVER ─────────────────── - var entries = await LedgerEntries() - .Where(es => es.Any(e => e.Url == RegistryUrl && e.LastReconciledAt is not null)) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(); - var entry = entries.Single(e => e.Url == RegistryUrl); - Assert.Equal(RegistryReconcileEntry.ViaBoot, entry.LastReconciledVia); - - // 🚨 null would mean "not determined". The feed read succeeded, so the question IS answered. - Assert.NotNull(entry.UndeliveredModules); - var undelivered = Assert.Single(entry.UndeliveredModules!); - Assert.Equal(DeclinedPackageId, undelivered.PackageId); - // The module whose bytes are not arriving, and the identity the record claims is installed - // — the pair that makes "installed 1.5, running an eleven-day-old assembly" legible. - Assert.Equal(DeclinedModule, undelivered.Module); - Assert.Equal(DeclinedModuleVersion, undelivered.InstalledModuleVersion); - - // A package the registry serves is delivered; a content-only package has nothing to deliver. - Assert.DoesNotContain(entry.UndeliveredModules!, u => u.PackageId == ServedPackageId); - Assert.DoesNotContain(entry.UndeliveredModules!, u => u.PackageId == ContentOnlyPackageId); - - // ── The consumer-wide verdict reads the same answer, with its denominator ─────────────── - var ledger = new RegistryReconcileLedger { Registries = [.. entries] }; - Assert.True(ModuleDelivery.AnyRegistryDetermined(ledger)); - Assert.Equal( - [DeclinedPackageId], - ModuleDelivery.NotDeliveredByAnyRegistry(ledger).Select(u => u.PackageId)); - } - - private async Task SeedInstallRecord(string id, string? module, string moduleVersion) - { - var manifest = new PackageManifest - { - Id = id, - Name = $"{id} display name", - Version = "1.5.0", - ModuleVersion = moduleVersion, - Module = module, - TargetPartition = id, - AutoUpdate = false, - }; - var record = MeshNode.FromPath($"{PackageInstaller.InstalledPartition}/{id}") with - { - NodeType = PackageInstaller.PackageNodeType, - Name = manifest.Name, - State = MeshNodeState.Active, - Content = manifest, - }; - var access = Mesh.ServiceProvider.GetRequiredService(); - await access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode(record)) - .Timeout(TestTimeouts.Convergence).Await(); - } - - /// The one package the registry offers — at the SAME module content identity the - /// install record carries, so the content lane has nothing to say and the only thing this test - /// observes is the delivery verdict. - private static PackageManifest ServedManifest() => new() - { - Id = ServedPackageId, - Name = $"{ServedPackageId} display name", - Version = "1.5.0", - ModuleVersion = "mv-served-9e4", - Module = "MeshWeaver.Delivery.Served", - TargetPartition = ServedPackageId, - }; - - // The ledger, read LIVE through a children query (a query never storms on a node that does not - // exist yet, and it re-emits on every write), flattened to its registry entries. - private IObservable> LedgerEntries() => - Mesh.GetWorkspace() - .GetQuery("ledger|delivery", - $"path:{PackageInstaller.InstalledPartition} scope:children nodeType:{RegistryUpdateReconciler.LedgerNodeType}") - .Select(ns => (IReadOnlyList)(ns ?? []) - .Select(n => n.ContentAs(Json)) - .Where(l => l is not null) - .SelectMany(l => l!.Registries) - .ToList()); - - /// A registry that answers every request with the status and list payload the test - /// set. The undelivered package is absent from that payload — that IS the decline. - private sealed class FakeRegistry : HttpMessageHandler - { - private volatile HttpStatusCode status = HttpStatusCode.ServiceUnavailable; - private volatile string body = ""; - private int attempts; - - public int Attempts => Volatile.Read(ref attempts); - - public void Serve(HttpStatusCode code, IReadOnlyList packages) - { - // Body before status: a reader that sees the new status sees the body it belongs to. - body = PluginRegistryPayloads.List(packages); - status = code; - } - - // HttpMessageHandler's contract is Task-shaped; nothing here bridges an observable. - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - Interlocked.Increment(ref attempts); - var current = status; - var content = current == HttpStatusCode.OK ? body : "{\"error\":\"unavailable\"}"; - return Task.FromResult(new HttpResponseMessage(current) - { - Content = new StringContent(content, Encoding.UTF8, "application/json"), - }); - } - } - - private sealed class FakeRegistryClientFactory(HttpMessageHandler handler) : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); - } -} diff --git a/test/Memex.Portal.Shared.Test/UngrantedGetIsNotFoundTest.cs b/test/Memex.Portal.Shared.Test/UngrantedGetIsNotFoundTest.cs deleted file mode 100644 index e03564bca8..0000000000 --- a/test/Memex.Portal.Shared.Test/UngrantedGetIsNotFoundTest.cs +++ /dev/null @@ -1,151 +0,0 @@ -#pragma warning disable CS1591 - -using System.Reactive.Linq; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 get decides access for the CALLER, whichever thread its read finally runs on -/// (MeshWeaver#6570, found under #6564). -/// -/// What production showed. On a deployed portal an MCP get returned -/// AccessAssignment, Code and NodeType nodes under a sample subtree whose -/// _Access holds no grant the caller matches — whoami at the same paths answered -/// permissions: [] and search hid them. Every leaking path carried an interior -/// Data segment (…/samples/Graph/Data/Type/_Access/…), and Data is a -/// unified-reference keyword, so first tried the path as a -/// unified read (GetDataRequest to the address before the keyword), and only on that read's -/// error fell back to the node read. The node read ran INSIDE the continuation of the unified -/// response, and FetchNode captured "the caller" from the ambient AccessContext AT -/// THAT MOMENT — the thread delivering the response, not the request. Whatever identity that thread -/// carried (a hub principal, which the stream cache's read gate deliberately passes through) was -/// what the gate evaluated: no error, no log line, the node served. -/// -/// How this reproduces it without a cluster. The observable is BUILT under the -/// caller's identity and SUBSCRIBED under a hub principal — the shape of "the continuation runs on -/// a different identity than the call". Before the fix the denied caller received the -/// AccessAssignment; after it, the answer is the one an absent path gets. -/// -/// Controls. The holder of the grant, under the same build/subscribe split, still -/// reads the node (so the denial is the gate's, not a read that cannot succeed); a path WITHOUT a -/// unified keyword stays denied. -/// -public class UngrantedGetIsNotFoundTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Granted = "GetGateGranted"; - private const string Hidden = "GetGateHidden"; - private const string ViewerUser = "getgate-viewer"; - private const string HolderUser = "getgate-holder"; - - /// The subtree the viewer holds nothing on; Data is a unified-reference keyword. - private const string TypeScope = Hidden + "/samples/Data/Type"; - private const string AssignmentPath = TypeScope + "/_Access/" + HolderUser + "_Access"; - private const string AbsentPath = TypeScope + "/_Access/nobody_Access"; - - /// The same subtree shape without a unified keyword. - private const string PlainScope = Hidden + "/Plain"; - private const string PlainAssignmentPath = PlainScope + "/_Access/" + HolderUser + "_Access"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - // 🚨 ConfigureMeshBase, not base.ConfigureMesh: the latter grants Public the Admin role in every - // default partition, under which the viewer would read everything and the denial would be vacuous. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddMeshNodes( - new MeshNode(Granted) { Name = "Granted", NodeType = "Markdown" }, - new MeshNode(Hidden) { Name = "Hidden", NodeType = "Markdown" }, - new MeshNode("Type", Hidden + "/samples/Data") { Name = "Type", NodeType = "Markdown" }, - new MeshNode("Plain", Hidden) { Name = "Plain", NodeType = "Markdown" }, - AssignmentNodeFactory.UserRole(ViewerUser, "Viewer", Granted), - AssignmentNodeFactory.UserRole(HolderUser, "Viewer", TypeScope), - AssignmentNodeFactory.UserRole(HolderUser, "Viewer", PlainScope)); - - private IMessageHub SessionHub() => SessionHubFactory.Resolve( - Mesh, "mcp", "getgate", - Mesh.ServiceProvider.GetRequiredService().CreateLogger(nameof(UngrantedGetIsNotFoundTest))); - - /// - /// Builds the get as , then subscribes it while the ambient - /// identity is a hub principal — the continuation-on-another-identity shape. - /// - private Task GetBuiltAsSubscribedAsHub(string userId, string path) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - access.SetContext(new AccessContext { ObjectId = userId, Name = userId }); - var read = new MeshOperations(SessionHub()).Get(path); - using (access.ImpersonateAsHub(Mesh)) - return read.Timeout(Budget).Await(TestContext.Current.CancellationToken); - } - - [Fact] - public async Task AnUngrantedCaller_GetsTheAbsentAnswer_ForAnAccessAssignment_UnderAUnifiedKeywordPath() - { - var denied = await GetBuiltAsSubscribedAsHub(ViewerUser, AssignmentPath); - var absent = await GetBuiltAsSubscribedAsHub(ViewerUser, AbsentPath); - - denied.Should().NotContain("AccessAssignment", - "the viewer holds no Read on the subtree — who holds which grant must not be disclosed"); - denied.Should().Be(absent, - "a refused read answers exactly what an absent node answers, so a denial discloses nothing"); - } - - [Fact] - public async Task TheGrantHolder_StillReadsIt_UnderTheSameSplit() - { - var answer = await GetBuiltAsSubscribedAsHub(HolderUser, AssignmentPath); - - answer.Should().Contain("\"nodeType\":\"AccessAssignment\"", - "the holder's own grant covers the subtree — if this fails, the denial above proves nothing"); - } - - [Fact] - public async Task APathWithoutAUnifiedKeyword_IsDeniedToo() - { - var answer = await GetBuiltAsSubscribedAsHub(ViewerUser, PlainAssignmentPath); - - answer.Should().StartWith("Not found", - "a refused read of a plain path answers like an absent node"); - } - - /// A unified read that SUCCEEDS for the holder — the direct-success branch, which posts - /// its GetDataRequest at subscribe time and never reaches the node-read fallback. - private const string PlainUnifiedPath = PlainScope + "/data/"; - - /// The MCP area route of the same node. - private const string PlainAreaPath = PlainScope + "/area/"; - - [Fact] - public async Task AUnifiedRead_IsDecidedForTheCaller_NotTheSubscriber() - { - var denied = await GetBuiltAsSubscribedAsHub(ViewerUser, PlainUnifiedPath); - var held = await GetBuiltAsSubscribedAsHub(HolderUser, PlainUnifiedPath); - - held.Should().NotStartWith("Error", - "the holder reads the node's data — and the read must be decided for HER, not for the hub that subscribed"); - denied.Should().StartWith("Error", - "the viewer holds no Read on the node, so its unified read is refused"); - denied.Should().Contain(ViewerUser, "the refusal is decided for the caller, never for the subscribing hub"); - } - - [Fact] - public async Task TheAreaRoute_IsRenderedForTheCaller_NotTheSubscriber() - { - var denied = await GetBuiltAsSubscribedAsHub(ViewerUser, PlainAreaPath); - var held = await GetBuiltAsSubscribedAsHub(HolderUser, PlainAreaPath); - - held.Should().Contain("\"areas\"", "the holder's render is the settled control tree — the control for the denial below"); - denied.Should().NotContain("\"areas\"", - "the viewer holds no Read on the node, so nothing of its rendered area reaches her"); - } -} diff --git a/test/Memex.Portal.Shared.Test/UnhandledRequestIsNamedTest.cs b/test/Memex.Portal.Shared.Test/UnhandledRequestIsNamedTest.cs deleted file mode 100644 index 135a9b8698..0000000000 --- a/test/Memex.Portal.Shared.Test/UnhandledRequestIsNamedTest.cs +++ /dev/null @@ -1,190 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// MeshWeaver#6790 — a portal request failed with an unhandled that -/// had no frames of its own, and the only line it left (ExceptionHandlerMiddleware's -/// "An unhandled exception has occurred while executing the request.") named neither the -/// method nor the route, so the endpoint that produced it could not be identified. -/// -/// Two halves, each over a REAL ASP.NET host (TestServer) and, for the verb surface, the REAL -/// mesh — no doubles: -/// -/// The verb surface answers its deadline. MeshApiEndpoints.RunString — the shared -/// body behind every /api/mesh/* verb — let a verb's expired .Timeout(…) escape as a -/// faulted , i.e. exactly the unhandled 500 of the incident. It now answers 504 with -/// a body naming the route. Its control: a fault that is NOT a deadline still reaches the exception -/// handler — the fix turns one designed outcome into a response, it does not swallow faults. -/// The unhandled-exception line names the request. -/// writes that line ONCE, -/// with the exception, the method and the route TEMPLATE — never the concrete path. Its control: the -/// framework's plain UseExceptionHandler on the same host writes the pathless line this issue -/// could not decide from. -/// -/// -public class UnhandledRequestIsNamedTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string ErrorPath = "/error"; - private const string MiddlewareCategory = "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware"; - - [Fact] - public async Task A_verb_whose_bounded_wait_expires_answers_504_naming_the_route() - { - var logs = new CapturingLoggerProvider(); - await using var app = await StartHost(logs, useRequestNaming: true, a => - a.MapPost("/api/mesh/test-deadline", (HttpContext http, CancellationToken ct) => - // The shape of the incident: an Rx deadline with no fallback, whose TimeoutException - // is created, never thrown — so it carries no frames. - MeshApiEndpoints.RunString(http, Mesh, ct, - _ => Observable.Never().Timeout(TimeSpan.FromMilliseconds(100))))); - - var response = await Post(app, "/api/mesh/test-deadline"); - var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - response.StatusCode.Should().Be(HttpStatusCode.GatewayTimeout, - "a verb's expired deadline is a designed outcome, answered like /render-area's — not an " - + $"unhandled 500. Body: {body}"); - using var doc = JsonDocument.Parse(body); - doc.RootElement.GetProperty("kind").GetString().Should().Be(MeshApiEndpoints.TimeoutKind); - doc.RootElement.GetProperty("error").GetString().Should().Contain("POST /api/mesh/test-deadline"); - - logs.Records.Should().ContainSingle(r => - r.Level == LogLevel.Warning - && r.Exception is TimeoutException - && r.Message.Contains("POST /api/mesh/test-deadline"), - "the expiry is logged once, at Warning, naming the method and route"); - logs.Records.Should().NotContain(r => r.Level >= LogLevel.Error, - "nothing reached the exception handler"); - } - - [Fact] - public async Task A_verb_fault_that_is_not_a_deadline_still_reaches_the_exception_handler() - { - var logs = new CapturingLoggerProvider(); - await using var app = await StartHost(logs, useRequestNaming: true, a => - a.MapPost("/api/mesh/test-defect", (HttpContext http, CancellationToken ct) => - MeshApiEndpoints.RunString(http, Mesh, ct, - _ => Observable.Throw(new InvalidOperationException("a real defect"))))); - - var response = await Post(app, "/api/mesh/test-defect"); - - response.StatusCode.Should().Be(HttpStatusCode.InternalServerError, - "only the deadline is a designed answer; any other fault is a defect and must stay one"); - logs.Records.Should().ContainSingle(r => r.Level == LogLevel.Error && r.Exception is InvalidOperationException); - } - - [Fact] - public async Task The_unhandled_exception_line_names_method_and_route_template_once() - { - var logs = new CapturingLoggerProvider(); - await using var app = await StartHost(logs, useRequestNaming: true, MapThrowingRoute); - - var response = await Post(app, "/api/test/items/private-node-name"); - - response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - var errors = logs.Records.Where(r => r.Level >= LogLevel.Error).ToList(); - errors.Should().ContainSingle("the exception is logged ONCE — the middleware's own pathless " - + "line is replaced, not duplicated. Got: " + string.Join(" | ", errors.Select(e => $"{e.Category}: {e.Message}"))); - var line = errors[0]; - line.Category.Should().Be(RequestNamingExceptionHandling.LogCategory); - line.Exception.Should().BeOfType(); - line.Message.Should().Contain("POST /api/test/items/{id}"); - line.Message.Should().NotContain("private-node-name", - "the route TEMPLATE is logged, never the concrete path — it carries user data"); - } - - /// - /// The control: the framework's plain handler on the SAME host and route writes the line #6790 - /// had — the exception and no request. If this ever names the route, the test above proves - /// nothing about the extension. - /// - [Fact] - public async Task Control_the_plain_exception_handler_line_does_not_name_the_request() - { - var logs = new CapturingLoggerProvider(); - await using var app = await StartHost(logs, useRequestNaming: false, MapThrowingRoute); - - var response = await Post(app, "/api/test/items/private-node-name"); - - response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - var line = logs.Records.Should().ContainSingle(r => r.Level >= LogLevel.Error).Subject; - line.Category.Should().Be(MiddlewareCategory); - line.Message.Should().NotContain("/api/test/items"); - } - - private static void MapThrowingRoute(WebApplication app) => - app.MapPost("/api/test/items/{id}", (string id) => - Task.FromException(new TimeoutException())); - - private async Task StartHost( - CapturingLoggerProvider logs, bool useRequestNaming, Action map) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.Logging.SetMinimumLevel(LogLevel.Information); - builder.Logging.AddProvider(logs); - builder.Services.AddSingleton(Mesh); - - var app = builder.Build(); - if (useRequestNaming) - app.UseRequestNamingExceptionHandler(ErrorPath, createScopeForErrors: true); - else - app.UseExceptionHandler(ErrorPath, createScopeForErrors: true); - app.Map(ErrorPath, () => Results.Problem(statusCode: StatusCodes.Status500InternalServerError)); - map(app); - await app.StartAsync(TestContext.Current.CancellationToken); - return app; - } - - private static Task Post(WebApplication app, string route) => - app.GetTestClient().PostAsync(route, new StringContent(""), TestContext.Current.CancellationToken); - - private sealed record CapturedLog(LogLevel Level, string Category, string Message, Exception? Exception); - - /// Keeps every record the host's logger factory routes to it. Instance-owned — its - /// lifetime is one test's host — never static state. - private sealed class CapturingLoggerProvider : ILoggerProvider - { - private readonly ConcurrentQueue records = new(); - - public IReadOnlyList Records => records.ToArray(); - - public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, records); - - public void Dispose() { } - - private sealed class CapturingLogger(string category, ConcurrentQueue sink) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; - - public void Log(LogLevel logLevel, EventId eventId, TState state, - Exception? exception, Func formatter) - => sink.Enqueue(new CapturedLog(logLevel, category, formatter(state, exception), exception)); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/UnreadablePolicyRecordIsNotClobberedTest.cs b/test/Memex.Portal.Shared.Test/UnreadablePolicyRecordIsNotClobberedTest.cs deleted file mode 100644 index d7e92b35a2..0000000000 --- a/test/Memex.Portal.Shared.Test/UnreadablePolicyRecordIsNotClobberedTest.cs +++ /dev/null @@ -1,300 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Reactive; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A FAILED READ MUST NOT BECOME A WRITE — #3542, proposal 3, the half #3607 did not close. -/// -/// #3607 made an ABSENT policy field read as rather -/// than as "enabled". That fixed what an absent field MEANS; it did not stop the record from losing -/// the field. What loses it is a bookkeeping write built on -/// UpdatePolicyNodeType.ParseContent, which answers new UpdatePolicyContent() for BOTH -/// "there is no content" and "the content is present and this build cannot read it" — and the write -/// then PERSISTS that empty record over the admin's policy, the latest available tag, every combo -/// verdict and any live availability hold. -/// -/// Measured before the fix, on origin/main at 5453be493. Seeded -/// {"policy":"None","latestAvailableTag":"3.0.0-ci.8009","comboVerifications":"corrupt"} — -/// one field this build cannot deserialize, which is why the record reaches the write lambda as an -/// untyped JsonElement ("stayed an untyped JsonElement", logged by the stream cache). One -/// RecordAvailable-shaped write then completed SILENTLY and left -/// {"requireCiGreen":true,"latestAvailableTag":"3.0.0-ci.9999","comboVerifications":[]}: no -/// policy at all — the production state #3542 opens on, from a single bookkeeping write. -/// -/// The fix is the framework's own typed write, not a bespoke guard: -/// Update<UpdatePolicyContent>((node, cur) => …) hands the lambda null only -/// when the content is ABSENT and faults with a MeshNodeStreamException when it is present -/// and unreadable — the write does not happen. Every bookkeeping caller already wraps its write in a -/// .Catch that logs and carries on (#1020: a bookkeeping write may never gate the roll), so -/// an unreadable record is refused loudly and left intact. -/// -/// 🚨 Moving lastCheckedAt to a separate node — the other repair #3542 proposes — would -/// NOT have closed this. The three sibling writes (RecordAvailable, RecordHold, -/// RecordVerification) have the same shape on the same record, so the defect is -/// read-failure-becomes-a-default, not the co-location. Hence all four are pinned here. -/// -/// 🚨 Every arm has a positive control on a READABLE record. Without them, "the write was -/// refused" would be satisfied by a write that never works at all. -/// -public class UnreadablePolicyRecordIsNotClobberedTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// - /// A real record carrying an explicit policy and a real tag, written by a build whose content - /// type DIFFERED — , in which comboVerifications is a - /// string where this build declares an ImmutableList<ComboVerification>. The record - /// as a whole is therefore unreadable as UpdatePolicyContent. - /// - /// 🚨 The discriminator names a type the mesh CAN materialise, and that is deliberate - /// (#3625). Written without a resolvable $type, this record does not merely fail to - /// read as UpdatePolicyContent — it degrades to an untyped JsonElement at the - /// stream cache, which is a DIFFERENT defect: content nothing can read, and the exact condition - /// the check-untyped-content.sh shard gate exists to report. This fixture would then red - /// its own shard, on a true positive. Naming a registered foreign type instead makes the cache - /// type it happily while ContentAs<UpdatePolicyContent> still answers null, - /// because ObjectAsExtensions.As<T> recovers a foreign runtime type ONLY when the - /// short name matches. It is also the closer model of production: a same-named record from - /// another collectible assembly, or a genuinely different type, is how this arises in a running - /// mesh. See Doc/Architecture/ReadingCiSignals. - /// - private const string UnreadableRecord = - """{"$type":"ForeignPolicyRecord","policy":"None","requireCiGreen":true,"latestAvailableTag":"3.0.0-ci.8009","comboVerifications":"written-by-a-shape-this-build-cannot-read"}"""; - - /// - /// The content type the unreadable record was written by — a stand-in for another build's - /// version of the same record. Registered on the test mesh so the framework can materialise it; - /// its SHORT NAME differs from UpdatePolicyContent, which is what keeps - /// As<UpdatePolicyContent> answering null instead of silently adopting it. - /// - public sealed record ForeignPolicyRecord - { - public string? Policy { get; init; } - public bool RequireCiGreen { get; init; } - public string? LatestAvailableTag { get; init; } - public string? ComboVerifications { get; init; } - } - - private const string ReadableRecord = - """{"$type":"UpdatePolicyContent","policy":"None","requireCiGreen":true,"latestAvailableTag":"3.0.0-ci.8009"}"""; - - private const string Candidate = "3.0.0-ci.9999"; - - /// 🚨 , never a literal: a hand-written 30 s is - /// both a guess about machine speed AND the framework's own write bound (#2819). - private static TimeSpan Budget => TestTimeouts.Convergence; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - var configured = base.ConfigureMesh(builder).AddUpdatePolicyType(); - // The foreign writer's type, so the seeded record materialises rather than degrading — see - // UnreadableRecord for why that distinction is load-bearing (#3625). - configured.ConfigureHub(config => config.WithType(nameof(ForeignPolicyRecord))); - return configured; - } - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - public static IEnumerable EveryBookkeepingWrite() => - [ - ["RecordAvailable"], ["RecordCheck"], ["RecordHold"], ["RecordVerification"], - ]; - - /// - /// THE REGRESSION. Each of the four writes on Admin/UpdatePolicy, against a record this - /// build cannot read: the write must be REFUSED, and the stored bytes must still carry the - /// admin's policy and the tag that was there. - /// - [Theory(Timeout = 180000)] - [MemberData(nameof(EveryBookkeepingWrite))] - public async Task AnUnreadableRecordIsRefused_NotOverwritten(string write) - { - await Seed(UnreadableRecord, TestContext.Current.CancellationToken); - - var fault = await RunWrite(write); - - Output.WriteLine($"{write} -> {fault?.GetType().Name ?? "COMPLETED"}: {fault?.Message}"); - fault.Should().NotBeNull( - $"{write} read a record it could not parse, so it must refuse rather than write a " - + "default-valued record over it"); - - // 🚨 A single read is sound HERE and only here: the refusal happens INSIDE the update - // lambda, which has therefore already run against this hub's mirror, and no patch was - // posted — so there is no later emission to wait for. The positive control below, where a - // real write does have to propagate, waits on the condition instead. - var after = await ReadRawJson(); - Output.WriteLine($"after {write}: {after}"); - after.Should().Contain("\"policy\"", - "the admin's explicit choice must survive a bookkeeping write that could not read it — " - + "losing this field is how memex-cloud rolled itself onto a withdrawn line (#3542)"); - after.Should().Contain("3.0.0-ci.8009", - "nothing on the record may be replaced by a default the write invented"); - } - - /// - /// THE POSITIVE CONTROL, one per write. On a READABLE record the very same call must land AND - /// leave the policy alone — otherwise the assertion above would pass for a write that is simply - /// broken. - /// - [Theory(Timeout = 180000)] - [MemberData(nameof(EveryBookkeepingWrite))] - public async Task AReadableRecordIsWritten_AndKeepsItsPolicy(string write) - { - await Seed(ReadableRecord, TestContext.Current.CancellationToken); - - var fault = await RunWrite(write); - - Output.WriteLine($"{write} -> {fault?.GetType().Name ?? "COMPLETED"}: {fault?.Message}"); - fault.Should().BeNull($"{write} must still land on a record it CAN read"); - - // 🚨 Waits for the write's OWN field on the live stream — never a one-shot read, which is - // free to answer with the pre-write snapshot and assert something it never observed - // (measured while writing this: all three poller arms "failed" that way while the write had - // in fact landed). - var content = await WaitForContent(c => Landed(write, c)); - - content.DeclaredPolicy.Should().Be(UpdatePolicyKind.None, - "a bookkeeping write touches only its own fields"); - } - - private static bool Landed(string write, UpdatePolicyContent c) => write switch - { - "RecordAvailable" => c.LatestAvailableTag == Candidate, - "RecordCheck" => c.LastCheckedAt is not null, - "RecordHold" => c.HeldTag == Candidate, - "RecordVerification" => c.ComboVerifications.Count == 1, - _ => throw new ArgumentOutOfRangeException(nameof(write), write, "unknown write"), - }; - - /// Runs one of the four PRODUCTION writes and returns the fault it produced, or null - /// when it completed. Nothing here re-implements a write shape: RecordVerification is the - /// public API, and the other three are the poller's own protected virtual members reached - /// through a derived class. - private Task RunWrite(string write) - { - var poller = new ExposedPoller(Mesh); - var run = write switch - { - "RecordAvailable" => poller.Available(Candidate), - "RecordCheck" => poller.Check(), - "RecordHold" => poller.Hold(Candidate), - "RecordVerification" => UpdatePolicyNodeType.RecordVerification( - Mesh, - new ComboVerification - { - CandidateTag = Candidate, - Verdict = ComboVerdictKind.Green, - VerifiedAt = DateTimeOffset.UtcNow, - }), - _ => throw new ArgumentOutOfRangeException(nameof(write), write, "unknown write"), - }; - return run - .Select(_ => (Exception?)null) - .Catch((Exception ex) => Observable.Return(ex)) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - } - - /// The poller with its three bookkeeping writes exposed. Nothing else is overridden — - /// the writes under test are the production ones, byte for byte. - private sealed class ExposedPoller(IMessageHub hub) - : SelfUpdateHostedService(hub, new NoTags(), new NoPatch(), new SelfUpdateOptions()) - { - public IObservable Available(string tag) => RecordAvailable(tag); - - public IObservable Check() => - RecordCheck(SelfUpdateTrigger.SafetyNet, SelfUpdateVerdict.DetectOnly(Candidate)); - - public IObservable Hold(string tag) => - RecordHold(tag, UpdatabilityVerdict.Unavailable("a package cannot survive this release")); - } - - private sealed class NoTags : IAcrTagLister - { - public Task> ListTagsAsync(string repository, CancellationToken ct) => - Task.FromResult>([]); - } - - private sealed class NoPatch : IDeploymentUpdater - { - public bool CanPatch => false; - - public Task LastRolledAtAsync(CancellationToken ct) => - Task.FromResult(null); - - public Task PatchToVersionAsync(string versionTag, CancellationToken ct) => Task.CompletedTask; - } - - /// The stored bytes, not a parse of them: "the record still carries its policy" is a - /// fact about what is ON the node, and reading it through the parser that fails closed to - /// None would answer None for a record that has no policy at all. - private Task ReadRawJson() => - RawContent() - .Select(c => c switch - { - null => "", - JsonElement je => je.GetRawText(), - _ => JsonSerializer.Serialize(c, Mesh.JsonSerializerOptions), - }) - .Catch((Exception ex) => Observable.Return($"")) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - private Task WaitForContent(Func settled) => - RawContent() - .Select(c => UpdatePolicyNodeType.ParseContent(c, Mesh.JsonSerializerOptions)) - .Where(settled) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - private IObservable RawContent() => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(n => n is not null) - .Select(n => (object?)n.Content) - .Subscribe(observer); - }); - - private Task Seed(string json, CancellationToken cancellationToken) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = JsonDocument.Parse(json).RootElement.Clone(), - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(cancellationToken); - } -} diff --git a/test/Memex.Portal.Shared.Test/UpdateAdvisoriesOnTheTabTest.cs b/test/Memex.Portal.Shared.Test/UpdateAdvisoriesOnTheTabTest.cs deleted file mode 100644 index 5e867bb679..0000000000 --- a/test/Memex.Portal.Shared.Test/UpdateAdvisoriesOnTheTabTest.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using Memex.Portal.Shared.SelfUpdate; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Hosting.SelfUpdate; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 What the release gate reported WITHOUT holding is a state on the Updates tab (#3651). -/// -/// A roll that recompiles a course at boot, or that carries a module whose loadability on -/// the target could not be measured, is a roll an operator must be able to SEE on the surface they -/// already look at — not a fact that lives only in a pod log. The poller records the verdict's -/// advisories on Admin/UpdatePolicy beside the (cleared or set) hold, and the tab renders -/// them for the tag they describe and for no other. -/// -public class UpdateAdvisoriesOnTheTabTest -{ - private const string Tag = "3.0.0-ci.8100"; - - [Fact] - public void Advisories_RenderForTheTagTheyDescribe_UnderTheAvailableLine() - { - var content = new UpdatePolicyContent - { - LatestAvailableTag = Tag, - CheckedAt = DateTimeOffset.UtcNow, - AdvisoriesTag = Tag, - Advisories = "would recompile at boot on 3.0.0-ci.8100: Education, Crm; " - + "Views: whether its landed module MeshWeaver.Views loads could not be determined", - }; - - var markdown = UpdatePolicySettingsTab.StatusMarkdown(content, EchoLocalizer); - - Assert.Contains($"ui.updateLatestAvailable[{Tag}]", markdown, StringComparison.Ordinal); - Assert.Contains($"ui.updateAdvisories[{Tag}]", markdown, StringComparison.Ordinal); - Assert.Contains("would recompile at boot", markdown, StringComparison.Ordinal); - Assert.Contains("MeshWeaver.Views", markdown, StringComparison.Ordinal); - // Not held: an advisory is a cost the roll accepts, and must never read as a hold. - Assert.DoesNotContain("ui.updateHeld[", markdown, StringComparison.Ordinal); - } - - /// Advisories recorded about a PREVIOUS candidate say nothing about this one — a - /// stale scare is the same defect as a stale hold. - [Fact] - public void Advisories_AboutAnotherTag_AreNotRendered() - { - var content = new UpdatePolicyContent - { - LatestAvailableTag = Tag, - CheckedAt = DateTimeOffset.UtcNow, - AdvisoriesTag = "3.0.0-ci.8055", - Advisories = "would recompile at boot on 3.0.0-ci.8055: Education", - }; - - var markdown = UpdatePolicySettingsTab.StatusMarkdown(content, EchoLocalizer); - - Assert.DoesNotContain("ui.updateAdvisories", markdown, StringComparison.Ordinal); - Assert.DoesNotContain("Education", markdown, StringComparison.Ordinal); - Assert.False(content.HasAdvisoriesFor(Tag)); - } - - /// A held tag renders its hold AND its advisories — the two answer different - /// questions (why it is not moving; what it would cost when it does) and neither hides the other. - [Fact] - public void AHeldTag_RendersBothTheHoldAndTheAdvisories() - { - var content = new UpdatePolicyContent - { - // 🚨 Policy is STATED, not defaulted (#3812). This case is "a live hold and advisories - // render together", and a hold only reads as live while a poller is running to clear - // it — Policy's default is None, which is the frozen-record case - // FrozenHoldIsHistoryTest pins. Leaving it implicit made the two indistinguishable. - Policy = UpdatePolicyKind.Continuous, - LatestAvailableTag = Tag, - CheckedAt = DateTimeOffset.UtcNow, - HeldTag = Tag, - HeldReason = "Views: its landed module MeshWeaver.Views cannot load on 3.0.0-ci.8100", - HeldAt = DateTimeOffset.UtcNow, - AdvisoriesTag = Tag, - Advisories = "would recompile at boot on 3.0.0-ci.8100: Education", - }; - - var markdown = UpdatePolicySettingsTab.StatusMarkdown(content, EchoLocalizer); - - Assert.Contains($"ui.updateHeld[{Tag}]", markdown, StringComparison.Ordinal); - Assert.Contains("cannot load", markdown, StringComparison.Ordinal); - Assert.Contains($"ui.updateAdvisories[{Tag}]", markdown, StringComparison.Ordinal); - Assert.Contains("Education", markdown, StringComparison.Ordinal); - } - - private static string EchoLocalizer(string key, object?[] args) => - $"{key}[{string.Join(',', args)}]"; -} diff --git a/test/Memex.Portal.Shared.Test/UpdateBindsToTheInstallOriginTest.cs b/test/Memex.Portal.Shared.Test/UpdateBindsToTheInstallOriginTest.cs deleted file mode 100644 index 9ffec6587a..0000000000 --- a/test/Memex.Portal.Shared.Test/UpdateBindsToTheInstallOriginTest.cs +++ /dev/null @@ -1,332 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 An unattended update is bound to the repository the install came from, never to the -/// package id alone (MeshWeaver#6526). -/// -/// The update watcher hands a watched repository's listing to -/// , which matched install records by id. A second watched -/// repository listing the same id could therefore update a package it never installed. Now the -/// record carries , and a record that predates it is decided -/// adopt-or-refuse: exactly one watched repository listing the package is adopted, two or more are -/// refused by name on the platform bell. -/// -/// The records use the reminder policy, so every arm is observed through the reminder marker -/// () and the platform bell, and nothing is -/// fetched. The marker is the positive signal that a pass TOOK a candidate. -/// -public class UpdateBindsToTheInstallOriginTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Honest = "github.com/acme/plugins"; - private const string Impostor = "github.com/someone-else/plugins"; - private const string Installed = "mv-installed-6526"; - private const string Next = "mv-next-6526"; - private const string Later = "mv-later-6526"; - - private System.Text.Json.JsonSerializerOptions Json => Mesh.JsonSerializerOptions; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddPluginCatalog() - // The running platform is pinned so a candidate declaring FloorFixture.Above is HELD — - // the pinned arm below needs a candidate the floor gate would act on. - .ConfigureServices(services => services.AddSingleton(FloorFixture.Pin)); - - [Fact] - public void TheOriginIsCanonical_WhicheverSpellingOfTheRepositoryBuiltTheSource() - { - InstallOrigin.ForRepo("https://github.com/Acme/Plugins.git/", null).Should().Be(Honest); - InstallOrigin.ForRepo("https://github.com/acme/plugins", "/catalog/").Should().Be(Honest + "#catalog"); - InstallOrigin.ForRepo("", "x").Should().BeNull(); - InstallOrigin.ForRegistry("https://Memex.Example/").Should().Be("registry:https://memex.example"); - - var none = new OriginCensus(ImmutableSortedSet.Empty, ImmutableSortedSet.Empty); - InstallOrigin.Decide(Honest, Honest, null).Should().Be(OriginVerdict.Bound); - InstallOrigin.Decide(Honest, Impostor, null).Should().Be(OriginVerdict.Foreign); - InstallOrigin.Decide(null, Honest, none).Should().Be(OriginVerdict.Adopt); - InstallOrigin.Decide(null, Honest, none with { Listing = [Impostor] }).Should().Be(OriginVerdict.Ambiguous); - InstallOrigin.Decide(null, Honest, none with { Unread = [Impostor] }).Should().Be(OriginVerdict.Undetermined); - InstallOrigin.Decide(null, Honest, null).Should().Be(OriginVerdict.Undetermined); - } - - [Fact] - public async Task ALegacyRecordAdoptsTheOnlyListingRepository_AndAnImpostorIsIgnoredAfterwards() - { - const string id = "OriginAdoptPkg"; - await Seed(id); - - // ── Adopt: the honest repository is the only watched one listing the package ───────────── - var censusReads = 0; - await Reconcile(id, Next, Bind(Honest, _ => - { - Interlocked.Increment(ref censusReads); - return Census(); - })); - (await AwaitRecord(id, r => r.NotifiedModuleVersion == Next)).Origin.Should().Be(Honest, - "the record names no origin and only one watched repository lists it, so it adopts that one"); - censusReads.Should().Be(1, "the census is read for a record with no origin"); - - // ── An impostor listing the same id is ignored once the origin is recorded ────────────── - await Reconcile(id, Later, Bind(Impostor, _ => throw new InvalidOperationException( - "a record that names its origin must never need the census"))); - var afterImpostor = await Record(id); - afterImpostor.Origin.Should().Be(Honest); - afterImpostor.NotifiedModuleVersion.Should().Be(Next, - "the impostor's candidate must not even be reminded about — it is not an update of this install"); - (await Reminders(id)).Should().ContainSingle("only the honest candidate raised a bell"); - - // ── Control: the same candidate from the honest repository IS taken ───────────────────── - // Without this the arm above could pass because the pass itself was broken. - await Reconcile(id, Later, Bind(Honest, _ => throw new InvalidOperationException("not needed"))); - await AwaitRecord(id, r => r.NotifiedModuleVersion == Later); - } - - [Fact] - public async Task ALegacyRecordListedByTwoRepositories_IsRefusedByName_AndNothingIsAdopted() - { - const string id = "OriginAmbiguousPkg"; - await Seed(id); - - await Reconcile(id, Next, Bind(Honest, _ => Census(listing: [Impostor]))); - - var refusals = await PlatformBell(id) - .Where(ns => ns.Count > 0) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - var refusal = refusals.Should().ContainSingle().Subject.ContentAs(Json)!; - refusal.Title.Should().StartWith("Update refused"); - refusal.Message.Should().Contain(id).And.Contain(Honest).And.Contain(Impostor, - "the refusal names the package and EVERY candidate repository"); - (await Record(id)).Origin.Should().BeNull("an ambiguous record adopts nothing"); - - // An incomplete census neither adopts nor refuses — and the update is not taken either. - const string other = "OriginUnreadPkg"; - await Seed(other); - await Reconcile(other, Next, Bind(Honest, _ => Census(unread: [Impostor]))); - var unread = await Record(other); - unread.Origin.Should().BeNull(); - unread.NotifiedModuleVersion.Should().BeNull(); - } - - [Fact] - public async Task APinnedRecord_IsNotHeldByAForeignCandidate() - { - // A pinned record never reached the origin gate, and the floor gate ran before the policy - // switch, so a FOREIGN candidate above the floor stamped a hold (and a blocking ticket) on a - // package the administrator pinned (review on MeshWeaver#6541). - const string id = "OriginPinnedPkg"; - await Seed(id, origin: Honest, policy: PackageUpdatePolicy.None); - await Reconcile(id, Next, Bind(Impostor, _ => throw new InvalidOperationException( - "a pinned record must never need the census")), minMeshVersion: FloorFixture.Above); - var pinned = await Record(id); - pinned.HeldUpdate.Should().BeNull("nothing moves for a pinned package — no hold from any repository"); - pinned.HeldUpdateDispatch.Should().BeNull(); - pinned.Origin.Should().Be(Honest); - - // Control: the same floor-held candidate on a NOTIFY record from its own repository IS held, - // so the arm above cannot pass because the floor gate never fired. - const string control = "OriginPinnedControlPkg"; - await Seed(control, origin: Honest); - await Reconcile(control, Next, Bind(Honest, _ => throw new InvalidOperationException("not needed")), - minMeshVersion: FloorFixture.Above); - (await AwaitRecord(control, r => r.HeldUpdate is not null)).HeldUpdate.Should().Contain(FloorFixture.Above); - } - - [Fact] - public async Task APinnedRecordWithoutModuleVersion_IsNotHeldByAnUnboundPass() - { - // A legacy record (installed before its source emitted a manifest.lock) is taken as a - // change once the listing carries an identity (#6797) — but a PINNED one must stay - // untouched on the UNBOUND registry path too: no floor hold, no ticket (review on #6797). - const string id = "LegacyPinnedPkg"; - await Seed(id, origin: Honest, policy: PackageUpdatePolicy.None, withModuleVersion: false); - await Reconcile(id, Next, binding: null, minMeshVersion: FloorFixture.Above); - var pinned = await Record(id); - pinned.HeldUpdate.Should().BeNull("nothing moves for a pinned package, whatever side lacks an identity"); - pinned.HeldUpdateDispatch.Should().BeNull(); - - // Control: the same legacy record on NOTIFY reaches the floor gate on the unbound pass and is - // held — so the arm above cannot pass because the candidate never got past the identity gate. - const string control = "LegacyPinnedControlPkg"; - await Seed(control, origin: Honest, withModuleVersion: false); - await Reconcile(control, Next, binding: null, minMeshVersion: FloorFixture.Above); - (await AwaitRecord(control, r => r.HeldUpdate is not null)).HeldUpdate.Should().Contain(FloorFixture.Above); - } - - [Fact] - public async Task AnAdoptionLostToAConcurrentInstall_TakesNothing() - { - // The census sees a record with NO origin; before the adoption writes, a concurrent install - // stamps the impostor's origin. The adoption must decide on the record it WROTE, which now - // names another repository — not on its pre-write snapshot (review on MeshWeaver#6541). - const string id = "OriginRacePkg"; - await Seed(id); - await Reconcile(id, Next, Bind(Honest, _ => Observable.Defer(() => - Mesh.ServiceProvider.GetRequiredService() - .RunAsSystem(() => Mesh.GetMeshNodeStream(RecordPath(id)) - .Update(current => current with { Origin = Impostor })) - .Take(1) - .SelectMany(_ => Census())))); - var record = await Record(id); - record.Origin.Should().Be(Impostor, "the concurrent install's origin wins; adoption fills an EMPTY origin only"); - record.NotifiedModuleVersion.Should().BeNull( - "the candidate is bound to a repository the record no longer names, so it is not taken"); - (await Reminders(id, expectAny: false)).Should().BeEmpty(); - } - - [Fact] - public async Task NegativeControl_AnUnboundPassStillTakesTheImpostorsCandidate() - { - // The defect this fixture pins, reproduced: with no binding the reconciler matches by id - // alone, so a record installed from the honest repository is reminded about the impostor's - // build. If this ever stops raising, the arms above prove nothing. - const string id = "OriginUnboundPkg"; - await Seed(id, origin: Honest); - await Reconcile(id, Next, binding: null); - (await AwaitRecord(id, r => r.NotifiedModuleVersion == Next)).Origin.Should().Be(Honest); - } - - private static InstallOriginBinding Bind(string origin, Func> census) - => new(origin, census); - - private static IObservable Census( - ImmutableSortedSet? listing = null, ImmutableSortedSet? unread = null) - => Observable.Return(new OriginCensus( - listing ?? ImmutableSortedSet.Empty, unread ?? ImmutableSortedSet.Empty)); - - private async Task Reconcile(string id, string candidate, InstallOriginBinding? binding, - string? minMeshVersion = null) - { - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - await PackageUpdateReconciler - .ReconcileInstalled(Mesh, new NoFetchSource(), "HEAD", - [new PackageManifest { Id = id, Name = id, Version = "1.0.1", ModuleVersion = candidate, TargetPartition = id, MinMeshVersion = minMeshVersion }], - "Built from test", logger, binding) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - } - - private async Task Seed(string id, string? origin = null, - PackageUpdatePolicy policy = PackageUpdatePolicy.Notify, bool withModuleVersion = true) - { - var manifest = new PackageManifest - { - Id = id, - Name = id, - Version = "1.0.0", - ModuleVersion = withModuleVersion ? Installed : null, - TargetPartition = id, - Origin = origin, - AutoUpdate = false, - UpdatePolicy = policy, - UpdatePolicySetAt = DateTimeOffset.UtcNow, - }; - var record = MeshNode.FromPath(RecordPath(id)) with - { - NodeType = PackageInstaller.PackageNodeType, - Name = id, - State = MeshNodeState.Active, - Content = manifest, - }; - await Mesh.ServiceProvider.GetRequiredService() - .RunAsSystem(() => NodeFactory.CreateOrUpdateNode(record)) - .Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - } - - private static string RecordPath(string id) => $"{PackageInstaller.InstalledPartition}/{id}"; - - private Task Record(string id) => AwaitRecord(id, _ => true); - - private async Task AwaitRecord(string id, Func predicate) => - await Mesh.GetMeshNodeStream(RecordPath(id)) - .Select(n => n?.ContentAs(Json)) - .Where(r => r is not null && predicate(r)) - .Select(r => r!) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(TestContext.Current.CancellationToken); - - /// The record's "Update available" rows. When a reminder is EXPECTED, wait for the bell - /// to show one: the query's initial snapshot can precede the index seeing the reminder, and an - /// empty first emission would read as "none" (review on MeshWeaver#6541). - private async Task> Reminders(string id, bool expectAny = true) => - (await PlatformBell(id).Where(ns => !expectAny || ns.Count > 0).FirstAsync().Timeout(TestTimeouts.Convergence) - .Await(TestContext.Current.CancellationToken)) - .Where(n => n.ContentAs(Json)?.Title?.StartsWith("Update available", StringComparison.Ordinal) == true) - .ToList(); - - /// This record's rows on the PLATFORM bell (the addressee of every package-update - /// notification), narrowed to the ones it is the click target of. - private IObservable> PlatformBell(string id) => - Mesh.GetWorkspace() - .GetQuery($"notif|pkg|origin|{id}", - $"path:{NotificationService.DeliveryNamespace(NotificationService.PlatformAddressee)} " - + "scope:children nodeType:Notification") - .Select(ns => (IReadOnlyList)(ns ?? []) - .Where(n => string.Equals( - n.ContentAs(Json)?.TargetNodePath, RecordPath(id), StringComparison.Ordinal)) - .ToList()); - - /// The reminder path fetches nothing; a fetch here is a branch the pass had no business taking. - private sealed class NoFetchSource : IPackageSource - { - public IObservable> ListPackages(string gitRef) => - Observable.Throw>(new InvalidOperationException("must not list")); - - public IObservable> FetchPackageFiles(PackageManifest package, string gitRef) => - Observable.Throw>(new InvalidOperationException("must not fetch")); - } -} - -/// -/// An install records the origin of the SOURCE it ran through — never an origin its manifest -/// authors (MeshWeaver#6526). -/// -public class AnInstallRecordsItsOriginTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Honest = "github.com/acme/plugins"; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddPluginCatalog() - .ConfigureServices(services => services - .AddSingleton(new PluginCatalogOptions { InstallPreInstalledPackages = false })); - - [Fact(Timeout = 300_000)] - public async Task AManualInstall_RecordsTheSourcesOrigin_OverAnAuthoredOne() - { - var ct = TestContext.Current.CancellationToken; - var logger = Mesh.ServiceProvider.GetRequiredService() - .CreateLogger(); - await Mesh.ServiceProvider.GetRequiredService().Completed - .FirstAsync().Timeout(180.Seconds()).Await(ct); - - // The catalog click's path: the funnel, through a source built for the honest repository, - // with a manifest that AUTHORS a different origin. - await CatalogLayoutAreas - .InstallOrUpdate(Mesh, - InstallOrigin.Bind(new ReassertFixture.Source(ReassertFixture.Files()), Honest), "HEAD", - ReassertFixture.Candidate() with { Origin = "github.com/someone-else/plugins" }, logger) - .Should().Within(180.Seconds()).Emit("the install must land", cancellationToken: ct); - - var record = await ReassertFixture.ReadRecord(Mesh); - record.Should().NotBeNull("CONTROL — the install wrote its record"); - record!.Origin.Should().Be(Honest, - "the record names the source the install ran through, never what the manifest authored"); - } -} diff --git a/test/Memex.Portal.Shared.Test/UpdateKeepsTheSyncClaimTest.cs b/test/Memex.Portal.Shared.Test/UpdateKeepsTheSyncClaimTest.cs deleted file mode 100644 index 76c74a12ad..0000000000 --- a/test/Memex.Portal.Shared.Test/UpdateKeepsTheSyncClaimTest.cs +++ /dev/null @@ -1,132 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Reactive.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Data; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// MeshWeaver.Plugins#2803: a runtime triage item, created with the -/// claim so a Hosting GitSync import keeps it, was -/// PRUNED as "absent from the repo". Its version history shows the write that released it: v3, -/// the triage agent recording its verdict with the full-entity update tool (no -/// changedBy — the Update verb's write). The agent sent the node without -/// syncBehavior; the typed deserialisation filled in the default (Include) and the -/// full-entity write stored it, so the claim was gone and the importer's prune guard — which -/// spares only non-Include nodes — had nothing to spare. Pinned here against a REAL mesh: a -/// full-entity update that does not NAME the claim keeps the live one, and one that names it -/// (including Include, the "resume sync" decision) writes it as given. -/// -public class UpdateKeepsTheSyncClaimTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private static readonly TimeSpan Budget = TestTimeouts.Convergence; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddUpdatePolicyType(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - [Fact] - public async Task Update_ThatOmitsSyncBehavior_KeepsTheLiveClaim() - { - var ct = TestContext.Current.CancellationToken; - var before = await Seed(ct); - before.SyncBehavior.Should().Be(SyncBehavior.ExcludeThisOnly, "the seed is a claimed runtime record"); - - var nodeJson = JsonSerializer.SerializeToNode(before, Mesh.JsonSerializerOptions)!.AsObject(); - nodeJson.Remove("syncBehavior"); - nodeJson["content"]!.AsObject()["policy"] = JsonSerializer.SerializeToNode(UpdatePolicyKind.None, Mesh.JsonSerializerOptions); - - var result = await Run(new MeshOperations(Mesh).Update(new JsonArray(nodeJson).ToJsonString()), ct); - Output.WriteLine($"Update tool returned: {result}"); - result.Should().StartWith("Updated:"); - - var after = await ReadNode(ct, n => UpdatePolicyNodeType.Parse(n, Mesh.JsonSerializerOptions).Policy - == UpdatePolicyKind.None); - after.SyncBehavior.Should().Be(SyncBehavior.ExcludeThisOnly, - "a caller that did not name the claim has not released it — releasing it lets the next " - + "static-repo import prune a runtime record (Plugins#2803)"); - } - - [Fact] - public async Task Update_ThatNamesInclude_ReleasesTheClaim() - { - var ct = TestContext.Current.CancellationToken; - var before = await Seed(ct); - - var nodeJson = JsonSerializer.SerializeToNode(before, Mesh.JsonSerializerOptions)!.AsObject(); - nodeJson["syncBehavior"] = JsonSerializer.SerializeToNode(SyncBehavior.Include, Mesh.JsonSerializerOptions); - nodeJson["content"]!.AsObject()["policy"] = JsonSerializer.SerializeToNode(UpdatePolicyKind.None, Mesh.JsonSerializerOptions); - - var result = await Run(new MeshOperations(Mesh).Update(new JsonArray(nodeJson).ToJsonString()), ct); - Output.WriteLine($"Update tool returned: {result}"); - result.Should().StartWith("Updated:"); - - var after = await ReadNode(ct, n => UpdatePolicyNodeType.Parse(n, Mesh.JsonSerializerOptions).Policy - == UpdatePolicyKind.None); - after.SyncBehavior.Should().Be(SyncBehavior.Include, "an explicitly stated claim is written as given"); - } - - [Fact] - public void NamesSyncBehavior_ReadsTheKeyInAnyCasing() - { - MeshOperations.NamesSyncBehavior(JsonNode.Parse("""{"id":"x"}""")!.AsObject()).Should().BeFalse(); - MeshOperations.NamesSyncBehavior(JsonNode.Parse("""{"syncBehavior":"Include"}""")!.AsObject()).Should().BeTrue(); - MeshOperations.NamesSyncBehavior(JsonNode.Parse("""{"SyncBehavior":0}""")!.AsObject()).Should().BeTrue(); - MeshOperations.NamesSyncBehavior(null).Should().BeFalse(); - } - - // ── helpers ── - - private static Task Run(IObservable op, CancellationToken ct) => - op.FirstAsync().Timeout(Budget).Await(ct); - - private async Task Seed(CancellationToken ct) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - Content = new UpdatePolicyContent { Policy = UpdatePolicyKind.Stable }, - SyncBehavior = SyncBehavior.ExcludeThisOnly, - }; - await Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(ct); - return await ReadNode(ct); - } - - private Task ReadNode(CancellationToken ct, Func? until = null) => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null && (until is null || until(node))) - .Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(ct); -} diff --git a/test/Memex.Portal.Shared.Test/UpdatePolicyMcpPatchTest.cs b/test/Memex.Portal.Shared.Test/UpdatePolicyMcpPatchTest.cs deleted file mode 100644 index 1ac0b8228c..0000000000 --- a/test/Memex.Portal.Shared.Test/UpdatePolicyMcpPatchTest.cs +++ /dev/null @@ -1,302 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.SelfUpdate; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Data; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.SelfUpdate; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// #2469: an MCP patch to Admin/UpdatePolicy reported success -/// ("Patched: Admin/UpdatePolicy", no version-transition suffix) while the durable node -/// stayed on its old — the operator's auto-update -/// kill-switch never took effect. Reproduces the exact operator sequence against a REAL mesh: -/// seed the node at (the platform default until -/// 2026-09-08, and still the enum's own default value, so it is OMITTED from JSON under this hub's -/// DefaultIgnoreCondition = WhenWritingDefault), then run the exact MCP patch call -/// an operator would issue to freeze auto-update, while the self-update poller concurrently -/// writes its own bookkeeping fields (checkedAt / latestAvailableTag) on the SAME -/// node — precisely what -/// does continuously in production. -/// -public class UpdatePolicyMcpPatchTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private static readonly TimeSpan Budget = TimeSpan.FromSeconds(30); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddUpdatePolicyType(); - - private AccessService Access => Mesh.ServiceProvider.GetRequiredService(); - - /// - /// The plain case: no concurrent writer at all. An admin patches - /// Admin/UpdatePolicy's policy field away from its (platform- and enum-) - /// default. The tool's own reply must be trustworthy: if it says "Patched", the field must - /// actually have changed. - /// - [Fact(Timeout = 60000)] - public async Task Patch_SetsPolicyAwayFromDefault_ActuallyLands() - { - await Seed(); - - var result = await new MeshOperations(Mesh) - .Patch(UpdatePolicyNodeType.NodePath, """{"content":{"policy":"None"}}""") - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - - Output.WriteLine($"Patch tool returned: {result}"); - result.Should().NotContain("Error:", "a real, applicable field change must not be refused"); - - var content = await ReadContentOnce(); - content.Policy.Should().Be(UpdatePolicyKind.None, - "the tool reported success, so the write must actually have landed"); - } - - /// - /// The tool call owns the writer identity, while its node read and validation answer later - /// on reactive callbacks. The final write must retain that original identity even when the - /// observable is subscribed after the caller's ambient scope has ended. - /// - [Fact(Timeout = 60000)] - public async Task Patch_CarriesWriterAcrossItsReadAndWrite() - { - await Seed(); - - IObservable patch; - using (Access.ImpersonateAsSystem()) - patch = new MeshOperations(Mesh) - .Patch(UpdatePolicyNodeType.NodePath, """{"content":{"policy":"None"}}"""); - - var result = await patch.FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - result.Should().StartWith("Patched:"); - - var written = await Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions).Policy - == UpdatePolicyKind.None) - .Subscribe(observer); - }) - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - written.LastModifiedBy.Should().Be(WellKnownUsers.System, - "the identity at the patch call must reach the eventual node update"); - } - - /// - /// The production shape: the self-update poller keeps ticking checkedAt / - /// latestAvailableTag on the SAME node (as System) while the operator's patch is in - /// flight — exactly the contention the issue names ("two fields, different writers, different - /// lifetimes, contending on one row"). - /// - [Fact(Timeout = 60000)] - public async Task Patch_SetsPolicyAwayFromDefault_ConcurrentWithPollerBookkeeping_ActuallyLands() - { - await Seed(); - - // Fire a handful of poller-shaped bookkeeping writes (as System) racing the operator's - // patch — mirrors SelfUpdateHostedService.RecordAvailable's write shape exactly. - var pollerTicks = Task.Run(async () => - { - for (var i = 0; i < 10; i++) - { - await RecordAvailable($"3.0.0-ci.{i}"); - await Task.Delay(10, TestContext.Current.CancellationToken); - } - }, TestContext.Current.CancellationToken); - - var result = await new MeshOperations(Mesh) - .Patch(UpdatePolicyNodeType.NodePath, """{"content":{"policy":"None"}}""") - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - - await pollerTicks; - - Output.WriteLine($"Patch tool returned: {result}"); - result.Should().NotContain("Error:", "a real, applicable field change must not be refused"); - - var content = await ReadContentOnce(); - content.Policy.Should().Be(UpdatePolicyKind.None, - "the operator's write must survive concurrent poller bookkeeping on unrelated fields"); - } - - /// - /// A companion invariant check for #2469: Patch's success string must be an honest - /// signal — if it does not say "Error:", the caller's OWN field must actually have been - /// COMMITTED. Runs the operator's patch against a heavy REAL-concurrency write storm on the - /// exact same leaf (policy, seeded away from its enum default so base values are - /// actually carried and a genuine Conflict can fire). - /// 🚨 "Committed", not "still holds now" (#6635): a storm writer queued behind the - /// operator's patch legitimately overwrites policy right after it lands, so the live - /// value at the instant the tool answers is the wrong instrument — it is exactly the read-back - /// that reported landed writes as lost. The node's history as the mesh hub saw it is recorded - /// from BEFORE the patch, and a "Patched:" must be backed by a version that carried the - /// caller's value. - /// - [Fact(Timeout = 90000)] - public async Task Patch_SuccessString_IsHonest_UnderConcurrentContentionOnTheSameField() - { - // Seeded away from the enum default (Continuous) so "policy" is NOT omitted from JSON — - // ExtractBaseValues carries a real base for it, so a concurrent write to the SAME leaf - // produces a genuine Conflict refusal instead of the no-base "apply blindly" fallback. - await Seed(UpdatePolicyKind.Stable); - - // A heavy concurrent storm on the SAME leaf ("policy", toggled between two OTHER values) - // — real concurrency (Task.WhenAll, not sequential awaits) so it genuinely backs up the - // owning hub's single-threaded action block. Started slightly ahead of, and kept running - // through, the operator's own patch call so that call's PatchDataRequest is likely queued - // behind a real backlog when the owner finally answers it. - using var stormCts = new CancellationTokenSource(); - var storm = Task.WhenAll(Enumerable.Range(0, StormSize).Select(i => Task.Run(async () => - { - try - { - await ToggleStormWriter(i % 2 == 0 ? UpdatePolicyKind.Continuous : UpdatePolicyKind.Stable); - } - catch (OperationCanceledException) { /* storm cancelled after the patch settles */ } - catch (Exception ex) - { - // Concurrent conflicts are EXPECTED and self-heal via the framework's own - // re-enqueue; only log so a genuine infra fault is still visible. - Output.WriteLine($"[storm] writer {i} faulted: {ex.GetType().Name}: {ex.Message}"); - } - }, stormCts.Token))); - - await Task.Delay(50, TestContext.Current.CancellationToken); // let the storm get ahead - - // Every policy value the mesh hub's view of the node carried, from before the patch on. - var history = new ReplaySubject(); - using var historySub = Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Subscribe(observer); - }) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions).Policy) - .Subscribe(history); - - var result = await new MeshOperations(Mesh) - .Patch(UpdatePolicyNodeType.NodePath, """{"content":{"policy":"None"}}""") - .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); - - Output.WriteLine($"Patch tool returned: {result}"); - - if (!result.Contains("Error:", StringComparison.Ordinal)) - { - await history.Where(policy => policy == UpdatePolicyKind.None).Take(1) - .Should().Within(Budget).Emit( - "the tool did not report an error, so a committed version must have carried the " - + $"caller's OWN value — the tool said '{result}'", - cancellationToken: TestContext.Current.CancellationToken); - } - - await stormCts.CancelAsync(); - await storm; - } - - // ── helpers ── - - /// Concurrent writers contending on the SAME leaf in the storm test — large enough - /// to genuinely back up the owning hub's single-threaded action block past the 2s - /// owner-response bound. - private const int StormSize = 250; - - private Task Seed(UpdatePolicyKind policy = UpdatePolicyKind.Continuous) - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var node = new MeshNode(UpdatePolicyNodeType.NodeId, UpdatePolicyNodeType.AdminPartition) - { - NodeType = UpdatePolicyNodeType.NodeType, - Name = "Update Policy", - State = MeshNodeState.Active, - // The default overload seeds at the enum's own default (Continuous = 0; the platform - // default is Stable since 2026-09-08) — deliberately, so it is omitted from JSON under - // DefaultIgnoreCondition = WhenWritingDefault, exactly like the real - // Admin/UpdatePolicy node on a fresh install. - Content = new UpdatePolicyContent { Policy = policy }, - }; - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return meshService.CreateNode(node).Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - } - - /// One storm writer: flips policy to — the SAME leaf - /// the operator's patch targets, so a genuine base/live conflict is possible on either side. - private Task ToggleStormWriter(UpdatePolicyKind value) - { - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - // The TYPED write, as every production writer on this node is (#3542). - .Update((node, cur) => node with - { - Content = (cur ?? new UpdatePolicyContent()) with { Policy = value }, - }) - .Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - } - - /// Same write shape as SelfUpdateHostedService.RecordAvailable — touches only - /// the poller's bookkeeping fields, preserves Policy. - private Task RecordAvailable(string tag) - { - return Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - // 🚨 Mirrors SelfUpdateHostedService.RecordAvailable EXACTLY, which since - // #3542 is the TYPED write — a mirror that drifts back to - // `ParseContent` + the untyped overload stops testing the production shape. - .Update((node, cur) => node with - { - Content = (cur ?? new UpdatePolicyContent()) with - { - LatestAvailableTag = tag, - CheckedAt = DateTimeOffset.UtcNow, - }, - }) - .Subscribe(observer); - }) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - } - - /// A single authoritative read off the shared stream handle — the same primitive a - /// follow-up MCP get uses. - private Task ReadContentOnce() => - Observable.Create(observer => - { - using (Access.ImpersonateAsSystem()) - return Mesh.GetWorkspace().GetMeshNodeStream(UpdatePolicyNodeType.NodePath) - .Where(node => node is not null) - .Subscribe(observer); - }) - .Select(node => UpdatePolicyNodeType.Parse(node, Mesh.JsonSerializerOptions)) - .FirstAsync() - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); -} diff --git a/test/Memex.Portal.Shared.Test/UploadIsDecidedWhereItIsWrittenTest.cs b/test/Memex.Portal.Shared.Test/UploadIsDecidedWhereItIsWrittenTest.cs deleted file mode 100644 index f49a626907..0000000000 --- a/test/Memex.Portal.Shared.Test/UploadIsDecidedWhereItIsWrittenTest.cs +++ /dev/null @@ -1,320 +0,0 @@ -#pragma warning disable CS1591 - -using System.IO; -using System.Reactive.Linq; -using System.Threading; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.ContentCollections; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 #6585 — an upload is authorized WHERE ITS BYTES ARE COMMITTED. The bytes travel to the -/// node that owns the collection (), and that node's inbound gate -/// decides Update for the caller on the delivery that carries them. A caller-side pre-flight is only -/// an early answer. -/// -/// Before: asked Update on the CALLING hub, then wrote the -/// bytes through the calling hub's content service — so a grant revoked after that check did not -/// stop the write, and the write path itself carried no authorization at all. -/// -/// The revocation is forced deterministically through the framework's evaluator seam -/// (), narrowed to one identity: -/// every check asked on a hub OTHER than the owner (the pre-flight) grants and, in the same breath, -/// revokes Update at the owner. So the pre-flight passes and the owner then sees only Read — the -/// time-of-check/time-of-use window, with no timing involved. Every other identity keeps the real -/// evaluator. -/// -public class UploadIsDecidedWhereItIsWrittenTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string UploadType = "UploadOwnerGateProbe"; - private const string Target = "UploadOwnerGateTarget"; - private const string RevokedUser = "uploadowner-revoked"; - private const string ReaderUser = "uploadowner-reader"; - private const string EditorUser = "uploadowner-editor"; - - /// 1 once the revocable user's pre-flight has been answered: the owner then grants Read only. - private int revoked; - - private readonly string _contentRoot = Path.Combine( - AppContext.BaseDirectory, "Files", "UploadOwnerGate", Guid.NewGuid().ToString("N")); - - /// A collection registered on the MESH hub and exposed to children — the store a node - /// hub's content service falls back to for a name the node does not declare itself. - private const string HostCollection = "hostshared"; - - private readonly string _hostRoot = Path.Combine( - AppContext.BaseDirectory, "Files", "UploadOwnerGateHost", Guid.NewGuid().ToString("N")); - - private static TimeSpan Budget => TestTimeouts.Convergence; - - // 🚨 ConfigureMeshBase, not base.ConfigureMesh: the latter grants Public the Admin role in every - // default partition, under which every caller could write and the refusals would be vacuous. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - Directory.CreateDirectory(_contentRoot); - Directory.CreateDirectory(_hostRoot); - return ConfigureMeshBase(builder) - .ConfigureHub(hub => hub.AddContentCollection(_ => new ContentCollectionConfig - { - Name = HostCollection, - SourceType = "FileSystem", - BasePath = _hostRoot, - IsEditable = true, - ExposeInChildren = true, - })) - .AddMeshNodes( - new MeshNode(UploadType) - { - Name = "Upload Owner Gate Probe", - HubConfiguration = config => config - .AddContentCollection(_ => new ContentCollectionConfig - { - Name = ContentCollectionsExtensions.DefaultCollectionName, - SourceType = "FileSystem", - BasePath = _contentRoot, - IsEditable = true, - ExposeInChildren = true, - }), - }, - new MeshNode(Target) { Name = "Upload Owner Gate Target", NodeType = UploadType }, - AssignmentNodeFactory.UserRole(ReaderUser, "Viewer", Target), - AssignmentNodeFactory.UserRole(EditorUser, "Editor", Target)) - // The mesh hub's delegate is what session hubs copy (SessionHubFactory); the node hubs - // carry their own. Both WRAP the evaluator RLS installed — applied after it, and - // WithPermissionEvaluator is a Set — so only the one identity's answer changes. - .ConfigureHub(Wrap) - .ConfigureDefaultNodeHub(Wrap); - } - - private MessageHubConfiguration Wrap(MessageHubConfiguration config) - { - var configured = config.Get() - ?? throw new InvalidOperationException( - "Row-level security installed no evaluator on this hub, so the rig would decide every " - + "check rather than the one identity's — the test would pass for a reason it is not about."); - return config.WithPermissionEvaluator((hub, path, userId) => Evaluate(configured, hub, path, userId)); - } - - private IObservable Evaluate( - EffectivePermissionsDelegate configured, IMessageHub hub, string nodePath, string userId) - { - if (userId != RevokedUser) - return configured(hub, nodePath, userId); - if (!string.Equals(hub.Address.Path, Target, StringComparison.OrdinalIgnoreCase)) - { - // The caller-side check: it passes, and the grant is revoked the moment it has. - Interlocked.Exchange(ref revoked, 1); - return Observable.Return(Permission.All); - } - return Observable.Return(Volatile.Read(ref revoked) == 1 ? Permission.Read : Permission.All); - } - - private IMessageHub SessionHub() => SessionHubFactory.Resolve( - Mesh, "mcp", "uploadownergate", - Mesh.ServiceProvider.GetRequiredService().CreateLogger(nameof(UploadIsDecidedWhereItIsWrittenTest))); - - private Task UploadAs(string userId, string path, byte[] bytes) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - access.SetContext(new AccessContext { ObjectId = userId, Name = userId }); - var upload = new MeshOperations(SessionHub()).Upload(path, bytes); - using (access.ImpersonateAsHub(Mesh)) - return upload.Timeout(Budget).Await(TestContext.Current.CancellationToken); - } - - private string Stored(string fileName) => Path.Combine(_contentRoot, fileName); - - [Fact] - public async Task AGrantRevokedAfterThePreflight_IsRefusedByTheOwner_AndNothingIsStored() - { - var answer = await UploadAs(RevokedUser, $"{Target}/content/revoked.txt", - System.Text.Encoding.UTF8.GetBytes("after the revocation")); - Output.WriteLine($"revoked: {answer}"); - - Volatile.Read(ref revoked).Should().Be(1, "the pre-flight ran and passed — this is the window under test"); - answer.Should().StartWith("Error:", "the owner decided Update on the delivery that carried the bytes") - .And.Contain("Nothing was written"); - File.Exists(Stored("revoked.txt")).Should().BeFalse( - "the bytes are committed only behind the owner's gate, so a revoked grant stores nothing"); - } - - [Fact] - public async Task AReader_WritingStraightToTheOwner_IsRefused_AndNothingIsStored() - { - // No pre-flight on this path at all — the owner's gate is the only thing between a caller - // and the store, which is the property #6585 asks for. - var reader = new AccessContext { ObjectId = ReaderUser, Name = ReaderUser }; - var failure = await Record.ExceptionAsync(() => SessionHub() - .SaveContentFile(Target) - .To(ContentCollectionsExtensions.DefaultCollectionName, "direct.txt") - .WithAccessContext(reader) - .Post(System.Text.Encoding.UTF8.GetBytes("from a reader")) - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken)); - Output.WriteLine($"reader direct: {failure}"); - - failure.Should().BeOfType("the owner's gate refuses the delivery"); - ((DeliveryFailureException)failure!).Failure.ErrorType.Should().Be(ErrorType.Unauthorized); - File.Exists(Stored("direct.txt")).Should().BeFalse("a refused write stores nothing"); - } - - [Fact] - public async Task AnEditor_WritingStraightToTheOwner_IsStored() - { - var editor = new AccessContext { ObjectId = EditorUser, Name = EditorUser }; - var answer = await SessionHub() - .SaveContentFile(Target) - .To(ContentCollectionsExtensions.DefaultCollectionName, "nested/editor.txt") - .WithAccessContext(editor) - .Post(System.Text.Encoding.UTF8.GetBytes("from an editor")) - .Timeout(Budget) - .Await(TestContext.Current.CancellationToken); - - answer.Success.Should().BeTrue($"the editor holds Update on the node — if this fails the refusals above prove nothing ({answer.Error})"); - (await File.ReadAllTextAsync(Path.Combine(_contentRoot, "nested", "editor.txt"), TestContext.Current.CancellationToken)) - .Should().Be("from an editor"); - } - - [Fact] - public async Task AnUploadLargerThanOneDelivery_TravelsStaged_IsStored_AndLeavesNoStagedBlob() - { - // Over the per-delivery budget, so the bytes go through the collection's staging folder - // and the delivery carries only the handle — the owner still writes the file. - var bytes = new byte[ContentDeliveryBudget.BudgetBytes + 64 * 1024]; - new Random(6585).NextBytes(bytes); - - var answer = await UploadAs(EditorUser, $"{Target}/content/large.bin", bytes); - Output.WriteLine($"large: {answer}"); - - answer.Should().Contain("\"Uploaded\""); - (await File.ReadAllBytesAsync(Stored("large.bin"), TestContext.Current.CancellationToken)) - .Should().Equal(bytes); - var staging = Path.Combine(_contentRoot, ContentStaging.Folder); - (Directory.Exists(staging) ? Directory.GetFiles(staging) : []) - .Should().BeEmpty("the staged blob is reclaimed when the write answers"); - } - - [Fact] - public async Task AnEditorOfOneNode_CannotWriteIntoACollectionItInheritsFromItsHost() - { - // The node does not declare HostCollection; its content service resolves the name off the - // mesh hub, whose store is shared by every node it hosts. Update on the node is no authority - // over that store, so the owner-side write refuses it and nothing lands in the host's root. - var answer = await UploadAs(EditorUser, $"{Target}/{HostCollection}/planted.txt", - System.Text.Encoding.UTF8.GetBytes("into the host's store")); - Output.WriteLine($"inherited: {answer}"); - - answer.Should().StartWith("Error:").And.Contain("not declared by this node"); - File.Exists(Path.Combine(_hostRoot, "planted.txt")).Should().BeFalse( - "a collection the node only inherits is not the node's to write into"); - File.Exists(Path.Combine(_hostRoot, Target, "planted.txt")).Should().BeFalse(); - } - - [Fact] - public async Task AnOversizedUpload_IntoAnInheritedCollection_StagesNothingInTheHostStore() - { - // Over budget, so the bytes would travel staged — and staging writes on the CALLING hub, - // where no owner gate sees it. The inherited collection must be refused before staging, or - // the host's staging folder holds an editor-of-one-node's blob until the age sweep (and for - // good when the post is cancelled after staging). - var bytes = new byte[ContentDeliveryBudget.BudgetBytes + 64 * 1024]; - new Random(65930).NextBytes(bytes); - - var answer = await UploadAs(EditorUser, $"{Target}/{HostCollection}/planted-large.bin", bytes); - Output.WriteLine($"inherited, staged: {answer}"); - - answer.Should().StartWith("Error:").And.Contain("not declared by this node"); - Directory.EnumerateFileSystemEntries(_hostRoot, "*", SearchOption.AllDirectories) - .Should().BeEmpty("nothing — neither the file nor a staged blob — lands in the host's store"); - } - - [Fact] - public async Task AStagedUpload_NeverReclaimsABlobAnotherTransferOfTheSameBytesStaged() - { - // Deterministic form of two concurrent uploads of identical bytes: the OTHER transfer's blob - // is already in staging, fresh, under the bytes' content hash — exactly where it sits while - // that transfer's owner is still probing or reading it. Content-addressed staging would adopt - // that blob and reclaim it when THIS upload answers, deleting it under the other's read. - var bytes = new byte[ContentDeliveryBudget.BudgetBytes + 64 * 1024]; - new Random(65931).NextBytes(bytes); - var staging = Path.Combine(_contentRoot, ContentStaging.Folder); - Directory.CreateDirectory(staging); - var othersBlob = Path.Combine(staging, ContentStaging.ComputeHandle(bytes)); - await File.WriteAllBytesAsync(othersBlob, bytes, TestContext.Current.CancellationToken); - - var answer = await UploadAs(EditorUser, $"{Target}/content/twin.bin", bytes); - Output.WriteLine($"twin: {answer}"); - - answer.Should().Contain("\"Uploaded\""); - (await File.ReadAllBytesAsync(Stored("twin.bin"), TestContext.Current.CancellationToken)).Should().Equal(bytes); - File.Exists(othersBlob).Should().BeTrue("a transfer reclaims only the blob it staged itself"); - Directory.GetFiles(staging).Should().Equal([othersBlob], "and its own blob is reclaimed when the write answers"); - } - - [Fact] - public async Task AStagedUpload_SweepsAStaleBlobADeadProducerLeft() - { - // A blob abandoned by an upload that never terminated (an HTTP cancellation after staging): - // older than StaleAfter, so the next staged upload into the collection reclaims it first. - var staging = Path.Combine(_contentRoot, ContentStaging.Folder); - Directory.CreateDirectory(staging); - var abandoned = Path.Combine(staging, new string('a', 64)); - await File.WriteAllBytesAsync(abandoned, [1, 2, 3], TestContext.Current.CancellationToken); - File.SetLastWriteTimeUtc(abandoned, DateTime.UtcNow - ContentStaging.StaleAfter - TimeSpan.FromHours(1)); - - var bytes = new byte[ContentDeliveryBudget.BudgetBytes + 64 * 1024]; - new Random(6593).NextBytes(bytes); - var answer = await UploadAs(EditorUser, $"{Target}/content/after-abandoned.bin", bytes); - Output.WriteLine($"sweep: {answer}"); - - answer.Should().Contain("\"Uploaded\""); - File.Exists(abandoned).Should().BeFalse("a staged upload reclaims a dead producer's residue before staging"); - Directory.GetFiles(staging).Should().BeEmpty("and its own blob is reclaimed when the write answers"); - } - - [Fact] - public async Task AStagedBlob_IsNeitherListedNorServed_ToAReaderOfTheNode() - { - // A staged blob is bytes the owner has not admitted yet — an oversized upload's, before its - // owner decides Update, or one an abandoned upload left until the age sweep. A reader of the - // node must reach it through neither the listing nor the content route (#6585 review). - var bytes = System.Text.Encoding.UTF8.GetBytes("staged, not admitted"); - var staging = Path.Combine(_contentRoot, ContentStaging.Folder); - Directory.CreateDirectory(staging); - var handle = ContentStaging.ComputeHandle(bytes); - await File.WriteAllBytesAsync(Path.Combine(staging, handle), bytes, TestContext.Current.CancellationToken); - await File.WriteAllTextAsync(Path.Combine(_contentRoot, "visible.txt"), "content", TestContext.Current.CancellationToken); - - var reader = new AccessContext { ObjectId = ReaderUser, Name = ReaderUser }; - var access = Mesh.ServiceProvider.GetRequiredService(); - access.SetContext(reader); - var operations = new MeshOperations(SessionHub()); - var root = operations.ContentList($"{Target}/content"); - var folder = operations.ContentList($"{Target}/content/{ContentStaging.Folder}"); - var resolved = ContentFileResolver.Resolve(SessionHub(), $"{Target}/content/{ContentStaging.BlobPath(handle)}", reader); - string rootAnswer, folderAnswer; - ContentFileResolutionResult resolution; - using (access.ImpersonateAsHub(Mesh)) - { - rootAnswer = await root.Timeout(Budget).Await(TestContext.Current.CancellationToken); - folderAnswer = await folder.Timeout(Budget).Await(TestContext.Current.CancellationToken); - resolution = await resolved.Timeout(Budget).Await(TestContext.Current.CancellationToken); - } - Output.WriteLine($"root: {rootAnswer}\nfolder: {folderAnswer}\nresolve: {resolution}"); - - rootAnswer.Should().Contain("visible.txt", "the reader may list the collection — else the absences below prove nothing") - .And.NotContain(ContentStaging.Folder, "the staging folder is framework state, never listed"); - folderAnswer.Should().StartWith("Error:").And.NotContain(handle, "the staging folder lists like an absent one"); - resolution.Resolution.Should().BeNull("a staged blob is never served as content"); - } -} diff --git a/test/Memex.Portal.Shared.Test/UploadRequiresUpdateTest.cs b/test/Memex.Portal.Shared.Test/UploadRequiresUpdateTest.cs deleted file mode 100644 index 20a32cac0e..0000000000 --- a/test/Memex.Portal.Shared.Test/UploadRequiresUpdateTest.cs +++ /dev/null @@ -1,125 +0,0 @@ -#pragma warning disable CS1591 - -using System.IO; -using System.Reactive.Linq; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.ContentCollections; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 An upload is a WRITE and is authorized as one: -/// (the MCP upload tool and POST /api/mesh/upload) requires Update on the node that -/// owns the collection, for the caller captured at its entry. -/// -/// Before the fix the only gate on the way was the collection-config lookup, which the -/// owner serves to any READER; the save itself runs on the calling hub's content service straight -/// into the collection's storage, where no owner gate sees it. So a caller holding Read alone wrote -/// files into a node's editable content collection. -/// -/// Each upload is BUILT as the caller and SUBSCRIBED under a hub principal — the -/// continuation-on-another-identity shape of MeshWeaver#6570 — so the decision is proven to be the -/// caller's, not the thread's. The editor is the negative control: without it a refusal proves -/// nothing. -/// -public class UploadRequiresUpdateTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string UploadType = "UploadGateProbe"; - private const string Target = "UploadGateTarget"; - private const string ReaderUser = "uploadgate-reader"; - private const string EditorUser = "uploadgate-editor"; - private const string StrangerUser = "uploadgate-stranger"; - private const string AbsentRoot = "UploadGateNoSuchRoot"; - - private readonly string _contentRoot = Path.Combine( - AppContext.BaseDirectory, "Files", "UploadGate", Guid.NewGuid().ToString("N")); - - private static TimeSpan Budget => TestTimeouts.Convergence; - - // 🚨 ConfigureMeshBase, not base.ConfigureMesh: the latter grants Public the Admin role in every - // default partition, under which every caller could write and the refusal would be vacuous. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - Directory.CreateDirectory(_contentRoot); - return ConfigureMeshBase(builder) - .AddMeshNodes( - new MeshNode(UploadType) - { - Name = "Upload Gate Probe", - HubConfiguration = config => config - .AddContentCollection(_ => new ContentCollectionConfig - { - Name = ContentCollectionsExtensions.DefaultCollectionName, - SourceType = "FileSystem", - BasePath = _contentRoot, - IsEditable = true, - ExposeInChildren = true, - }), - }, - new MeshNode(Target) { Name = "Upload Gate Target", NodeType = UploadType }, - AssignmentNodeFactory.UserRole(ReaderUser, "Viewer", Target), - AssignmentNodeFactory.UserRole(EditorUser, "Editor", Target)); - } - - private IMessageHub SessionHub() => SessionHubFactory.Resolve( - Mesh, "mcp", "uploadgate", - Mesh.ServiceProvider.GetRequiredService().CreateLogger(nameof(UploadRequiresUpdateTest))); - - private Task UploadBuiltAsSubscribedAsHub(string userId, string path, string text) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - access.SetContext(new AccessContext { ObjectId = userId, Name = userId }); - var upload = new MeshOperations(SessionHub()).Upload(path, System.Text.Encoding.UTF8.GetBytes(text)); - using (access.ImpersonateAsHub(Mesh)) - return upload.Timeout(Budget).Await(TestContext.Current.CancellationToken); - } - - private string Stored(string fileName) => Path.Combine(_contentRoot, fileName); - - [Fact] - public async Task AReader_IsRefused_AndNothingIsStored() - { - var answer = await UploadBuiltAsSubscribedAsHub(ReaderUser, $"{Target}/content/reader.txt", "from a reader"); - Output.WriteLine($"reader: {answer}"); - - answer.Should().StartWith("Error:", "Read alone does not entitle a caller to write into the node's content") - .And.Contain("lacks Update permission", "a reader already knows the node exists, so the missing permission is named"); - File.Exists(Stored("reader.txt")).Should().BeFalse("a refused upload writes nothing"); - } - - [Fact] - public async Task AnEditor_Uploads_AndTheFileIsStored() - { - var answer = await UploadBuiltAsSubscribedAsHub(EditorUser, $"{Target}/content/editor.txt", "from an editor"); - Output.WriteLine($"editor: {answer}"); - - answer.Should().Contain("\"Uploaded\"", - "the editor holds Update on the node — if this fails, the refusal above proves nothing"); - (await File.ReadAllTextAsync(Stored("editor.txt"), TestContext.Current.CancellationToken)) - .Should().Be("from an editor"); - } - - [Fact] - public async Task ACallerWhoCannotRead_GetsTheAbsentAnswer_AndNothingIsStored() - { - const string deniedPath = Target + "/content/stranger.txt"; - const string absentPath = AbsentRoot + "/content/stranger.txt"; - var denied = await UploadBuiltAsSubscribedAsHub(StrangerUser, deniedPath, "from a stranger"); - var absent = await UploadBuiltAsSubscribedAsHub(StrangerUser, absentPath, "from a stranger"); - Output.WriteLine($"denied: {denied}"); - Output.WriteLine($"absent: {absent}"); - - denied.Replace(deniedPath, "").Should().Be(absent.Replace(absentPath, ""), - "a caller who may not read the node must not learn from the refusal that it exists"); - File.Exists(Stored("stranger.txt")).Should().BeFalse("a refused upload writes nothing"); - } -} diff --git a/test/Memex.Portal.Shared.Test/UserDirectoryOpensAtStartTest.cs b/test/Memex.Portal.Shared.Test/UserDirectoryOpensAtStartTest.cs deleted file mode 100644 index 951873b0e9..0000000000 --- a/test/Memex.Portal.Shared.Test/UserDirectoryOpensAtStartTest.cs +++ /dev/null @@ -1,333 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text.Json; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.Reactive.Assertions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Hosting.Internal; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚦 The first signed-in request on a fresh process meets a HYDRATED user directory -/// (MeshWeaver#6850). -/// -/// The directory () is a DI singleton, so it was built by the -/// first request that resolved it, and that request then asked the index it had just created. The -/// answer was "the mesh user index has not received its first snapshot yet" on every pod. The -/// control instance logged it once per pod on each of 8 pod generations in 24 hours. -/// now opens the directory when the application starts and -/// holds readiness until the first snapshot lands. -/// -/// The directory provider HOLDS its snapshot until the test releases it. That makes "the -/// snapshot has not arrived" a state the test controls, not a race it has to win. -/// -public class UserDirectoryOpensAtStartTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string UserId = "start-probe-user"; - private const string Email = "start-probe@example.com"; - - private readonly HeldDirectoryProvider provider = new(UserId, Email); - - /// How many more times resolving the directory THROWS — the open-failure leg. - private int failingOpens; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => base.ConfigureMesh(builder) - .ConfigureServices(services => services - .AddSingleton(provider) - // Registered as the portal host registers it (MeshWeaver.Plugins, - // BlazorHostingExtensions): a plain singleton, built on first resolve. The factory only - // adds the switch that makes a resolve throw; the DI container does not cache a - // factory's exception, so the next resolve builds the real instance. - .AddSingleton(sp => - System.Threading.Interlocked.Decrement(ref failingOpens) >= 0 - ? throw new InvalidOperationException("injected: the directory could not be built") - : ActivatorUtilities.CreateInstance(sp))); - - /// - /// The host's view of the readiness service: the mesh hub plus the REAL generic-host lifetime, - /// so ApplicationStarted has production semantics — registered callbacks run when - /// fires, and not before - /// (pattern: ModuleGcOffReadinessPathTest). - /// - private (UserDirectoryReadiness Readiness, ApplicationLifetime Lifetime, ServiceProvider Host) StartableReadiness() - { - var lifetime = new ApplicationLifetime(NullLogger.Instance); - var host = new ServiceCollection() - .AddSingleton(Mesh) - .AddSingleton(lifetime) - .BuildServiceProvider(); - var readiness = new UserDirectoryReadiness( - host, - Mesh.ServiceProvider.GetRequiredService().CreateLogger()); - return (readiness, lifetime, host); - } - - private static Task Probe(UserDirectoryReadiness readiness, CancellationToken ct) - => readiness.CheckHealthAsync(new HealthCheckContext(), ct); - - [Fact(Timeout = 240_000)] - public async Task ReadinessOpensTheDirectoryAtStart_AndTheFirstLookupIsHydrated() - { - var ct = TestContext.Current.CancellationToken; - var (readiness, lifetime, host) = StartableReadiness(); - await using var _ = host; - - await readiness.StartAsync(ct); - - // ── StartAsync runs BEFORE the host has started, so it only registers: the directory is - // not open, and a probe in this window does not open it either. (cache == null is what - // "not open yet" reports — deterministic, unlike counting reads on the query pool.) - var beforeStart = await Probe(readiness, ct); - beforeStart.Status.Should().Be(HealthStatus.Unhealthy); - beforeStart.Description.Should().Contain("not open yet", - "the directory must open on ApplicationStarted, never in StartAsync or on a probe before it"); - provider.DirectoryReads.Should().Be(0); - - lifetime.NotifyStarted(); - - // ── The directory read is OPEN before any request has asked it. This is the fix: with the - // old shape nothing opened it until the first lookup. - await Observable.Interval(TestTimeouts.Quick / 100).StartWith(0L) - .Select(_ => provider.DirectoryReads) - .Where(n => n >= 1) - .Should().Within(TestTimeouts.Convergence) - .Emit("ApplicationStarted must open the user directory read before any request asks it", ct); - - // ── Not ready while the first snapshot is held. - (await Probe(readiness, ct)).Status - .Should().Be(HealthStatus.Unhealthy, "a replica whose directory has no first snapshot cannot identify anyone"); - - provider.Release(); - - // ── Ready once it lands, and only then would the Service route a request here. - // (Reading is a property read, not a lookup — so "the first lookup" below stays the first.) - var cache = Mesh.ServiceProvider.GetRequiredService(); - await Observable.Interval(TestTimeouts.Quick / 100).StartWith(0L) - .Select(_ => cache.Reading) - .Where(r => r.EverHydrated) - .Should().Within(TestTimeouts.Convergence) - .Emit("the released snapshot must reach the directory", ct); - (await Probe(readiness, ct)).Status - .Should().Be(HealthStatus.Healthy, "the first snapshot turns readiness Healthy"); - - // ── THE PROPERTY: the FIRST lookup this process makes is a verdict, not Unavailable. - var first = cache.Lookup(Email); - first.IsUnavailable.Should().BeFalse( - $"the first lookup on a ready replica must meet a hydrated index, got: {first.UnavailableReason}"); - first.Node!.Id.Should().Be(UserId); - provider.DirectoryReads.Should().Be(1, "the lookup asked the read readiness opened — not a second one"); - - await readiness.StopAsync(ct); - } - - /// - /// An open that THROWS at ApplicationStarted is retried by the next probe — never left to keep - /// the replica Unhealthy for the life of the process. Without the retry the second probe - /// still reports "could not be opened" and no directory read is ever issued. - /// - [Fact(Timeout = 240_000)] - public async Task AnOpenThatThrows_IsRetriedByTheNextProbe_AndTheFailureClears() - { - var ct = TestContext.Current.CancellationToken; - var (readiness, lifetime, host) = StartableReadiness(); - await using var _ = host; - System.Threading.Volatile.Write(ref failingOpens, 1); - - await readiness.StartAsync(ct); - lifetime.NotifyStarted(); // the open runs — and throws - - var failed = await Probe(readiness, ct); - // This probe already retried (and the retry succeeded), so it must NOT report the stale - // failure: the directory is open and waiting for its first snapshot. - failed.Description.Should().NotContain("could not be opened", - "a probe after ApplicationStarted must retry the failed open and clear its failure on success"); - failed.Status.Should().Be(HealthStatus.Unhealthy, "the directory is open but has no first snapshot yet"); - - await Observable.Interval(TestTimeouts.Quick / 100).StartWith(0L) - .Select(_ => provider.DirectoryReads) - .Where(n => n >= 1) - .Should().Within(TestTimeouts.Convergence) - .Emit("the retried open must issue the directory read", ct); - - provider.Release(); - var cache = Mesh.ServiceProvider.GetRequiredService(); - await Observable.Interval(TestTimeouts.Quick / 100).StartWith(0L) - .Select(_ => cache.Reading) - .Where(r => r.EverHydrated) - .Should().Within(TestTimeouts.Convergence) - .Emit("the released snapshot must reach the directory the retry opened", ct); - (await Probe(readiness, ct)).Status.Should().Be(HealthStatus.Healthy); - - await readiness.StopAsync(ct); - } - - /// - /// An INCOMPLETE first snapshot (a provider dropped part of its read) must not hydrate the - /// directory or release readiness: users may be missing, and a miss would be a false - /// "no such user". It is recorded as the read's failure, and the next probe opens a fresh read - /// whose complete snapshot does hydrate it. Without the rejection the floor hydrates the index, - /// no failure is ever recorded, and the wait below for one times out. - /// - [Fact(Timeout = 240_000)] - public async Task AnIncompleteFirstSnapshot_DoesNotHydrate_AndTheNextProbeReopens() - { - var ct = TestContext.Current.CancellationToken; - var (readiness, lifetime, host) = StartableReadiness(); - await using var _ = host; - provider.IncompleteReads = 1; - provider.Release(); - - await readiness.StartAsync(ct); - lifetime.NotifyStarted(); - - var cache = Mesh.ServiceProvider.GetRequiredService(); - var rejected = await Observable.Interval(TestTimeouts.Quick / 100).StartWith(0L) - .Select(_ => cache.Reading) - .Where(r => r.Failure is not null) - .FirstAsync() - .Timeout(TestTimeouts.Convergence) - .Await(ct); - rejected.Failure.Should().Contain("INCOMPLETE"); - rejected.EverHydrated.Should().BeFalse("a floor must never count as the first snapshot"); - rejected.Hydrated.Should().BeFalse(); - - // A probe re-opens the terminated read; the replacement's snapshot is complete. Probe on every - // tick, as the kubelet does: the failed chain publishes its Failure BEFORE it releases its - // opening gate, so a probe landing in between is a no-op and only a LATER probe re-opens. - // A probe's own verdict is not asserted: the provider is already released, so the - // replacement may hydrate before it samples, and the `rejected` assertions above already - // prove the incomplete snapshot did not hydrate. - await Observable.Interval(TestTimeouts.Quick / 100).StartWith(0L) - .Select(tick => - { - Probe(readiness, ct); // synchronous: CheckHealthAsync returns a completed task - return cache.Reading; - }) - .Where(r => r.EverHydrated) - .Should().Within(TestTimeouts.Convergence) - .Emit("the probe must re-open the read and its complete snapshot must hydrate the directory", ct); - provider.DirectoryReads.Should().Be(2, "one rejected read, one replacement opened by the probe"); - (await Probe(readiness, ct)).Status.Should().Be(HealthStatus.Healthy); - cache.Lookup(Email).Node!.Id.Should().Be(UserId); - - await readiness.StopAsync(ct); - } - - /// - /// NEGATIVE CONTROL — the shape before the fix: no readiness, so the first request builds the - /// directory and asks it in the same breath. That first lookup is Unavailable. The snapshot is - /// held, so this is deterministic and not a lost race. - /// - [Fact] - public void WithoutReadiness_TheFirstLookupBuildsTheDirectoryAndMeetsItCold() - { - provider.DirectoryReads.Should().Be(0); - - var cache = Mesh.ServiceProvider.GetRequiredService(); - var first = cache.Lookup(Email); - - first.IsUnavailable.Should().BeTrue("the request that opens the directory always meets it before its first snapshot"); - first.UnavailableReason.Should().Contain("first snapshot"); - provider.Release(); - } - - [Fact] - public void Classify_IsUnreadyUntilTheFirstSnapshot_AndNeverAfter() - { - UserDirectoryReadiness.Classify(null, null, null).Status.Should().Be(HealthStatus.Unhealthy); - UserDirectoryReadiness.Classify(new(false, false, null, 0), null, null).Status.Should().Be(HealthStatus.Unhealthy); - UserDirectoryReadiness.Classify(new(false, false, "boom", 0), null, null).Status.Should().Be(HealthStatus.Unhealthy); - UserDirectoryReadiness.Classify(null, null, "threw").Status.Should().Be(HealthStatus.Unhealthy); - UserDirectoryReadiness.Classify(new(true, true, null, 3), null, null).Status.Should().Be(HealthStatus.Healthy); - // Once it has answered, a rebuild or a terminated read is printed but holds no traffic. - UserDirectoryReadiness.Classify(new(false, true, null, 3), null, null).Status.Should().Be(HealthStatus.Degraded); - UserDirectoryReadiness.Classify(new(false, true, "boom", 3), null, null).Status.Should().Be(HealthStatus.Degraded); - // A read that terminated after hydrating publishes its failure before Hydrated drops. - UserDirectoryReadiness.Classify(new(true, true, "boom", 3), null, null).Status.Should().Be(HealthStatus.Degraded, - "a recorded failure must never print Healthy"); - // A concurrent probe's losing retry can publish an opening failure after the winner opened - // the directory; once a reading exists that failure is stale. - UserDirectoryReadiness.Classify(new(true, true, null, 3), null, "threw").Status.Should().Be(HealthStatus.Healthy, - "an opening failure must not outlive an open directory"); - UserDirectoryReadiness.Classify(new(false, false, null, 0), null, "threw").Description.Should().Contain("first snapshot"); - // A host with no directory has nothing to measure. - UserDirectoryReadiness.Classify(null, "no directory", null).Status.Should().Be(HealthStatus.Healthy); - } - - /// - /// Answers the directory read (nodeType:User, exactly) with one user, but only once the - /// test releases it; every other query gets a prompt empty Initial so the rest of the mesh is - /// untouched. - /// - private sealed class HeldDirectoryProvider(string userId, string email) : IMeshQueryProvider - { - private readonly AsyncSubject _release = new(); - private int _reads; - private int _incompleteReads; - - /// How many directory reads (from the next one on) answer an INCOMPLETE snapshot. - public int IncompleteReads { set => System.Threading.Volatile.Write(ref _incompleteReads, value); } - - public string Name => nameof(HeldDirectoryProvider); - public int DirectoryReads => System.Threading.Volatile.Read(ref _reads); - public bool Matches(IReadOnlyList queryNamespaces) => true; - - public void Release() - { - _release.OnNext(Unit.Default); - _release.OnCompleted(); - } - - public IObservable> Query(MeshQueryRequest request, JsonSerializerOptions options) - { - if (!request.EffectiveQueries.Any(q => string.Equals(q.Trim(), "nodeType:User", StringComparison.OrdinalIgnoreCase))) - return Observable.Return(Frame([])); - System.Threading.Interlocked.Increment(ref _reads); - if (System.Threading.Interlocked.Decrement(ref _incompleteReads) >= 0) - // A provider that dropped part of its read: the frame carries NO user, which is - // exactly the lie a floor would tell if it were applied. - return _release.Select(_ => Frame([]) with { SnapshotIncomplete = true }) - .Concat(Observable.Never>()); - var node = new MeshNode(userId) - { - Name = userId, NodeType = "User", State = MeshNodeState.Active, - Content = new MeshWeaver.Mesh.Security.User { Email = email, FullName = userId }, - }; - return _release.Select(_ => Frame([(T)(object)node])).Concat(Observable.Never>()); - } - - private static QueryResultChange Frame(T[] items) => new() - { - ChangeType = QueryChangeType.Initial, - Items = items, - Timestamp = DateTimeOffset.UtcNow, - }; - - public IObservable> Query(MeshQueryRequest request, JsonSerializerOptions options) - => Observable.Return((IReadOnlyCollection)Array.Empty()); - - public IObservable> Autocomplete( - string basePath, string prefix, JsonSerializerOptions options, - AutocompleteMode mode = AutocompleteMode.RelevanceFirst, int limit = 10, - string? contextPath = null, string? context = null) - => Observable.Return((IReadOnlyCollection)Array.Empty()); - - public IObservable Select(string path, string property, JsonSerializerOptions options) - => Observable.Return(default); - } -} diff --git a/test/Memex.Portal.Shared.Test/UserIndexReopenDoesNotServeADeadSnapshotTest.cs b/test/Memex.Portal.Shared.Test/UserIndexReopenDoesNotServeADeadSnapshotTest.cs deleted file mode 100644 index 044821d2d4..0000000000 --- a/test/Memex.Portal.Shared.Test/UserIndexReopenDoesNotServeADeadSnapshotTest.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text.Json; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.AspNetCore.Portal; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Reactive.Assertions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A user directory that is being REBUILT answers "I cannot say", never a stale hit and never -/// a definitive miss. Found by review on core #5048. -/// -/// Why this exists. Core #5048 made the query fan-in's stall a TERMINAL, which turned -/// UserIdentityCache's once-only directory subscription from an eternal wait into an eternal -/// FAILURE — so that subscription is now re-openable: the dead chain is dropped and the next -/// Lookup opens a replacement (no timer, no poller). That re-open has a window, and the -/// window has exactly two ways to lie, both of them about IDENTITY, which is the most expensive -/// thing in the portal to be wrong about: -/// -/// Keep the dead chain's rows and -/// answers Found off them — it checks the HIT before it checks either flag, by design — -/// so a user deleted or renamed since the terminal is still served. Retaining the unavailable -/// state does not close this: the hit outranks that too. -/// Clear the failure BEFORE lowering the hydrated flag and a reader sees -/// "healthy and authoritative" over the dead index, so a MISS is a definitive Unknown — -/// "no such user", the false actionable verdict #974/#637 exist to prevent and the exact input -/// that drives onboarding and provisioning. -/// -/// -/// The cure is Apply's own Initial/Reset order, reused verbatim: down first, clear, -/// then un-fail. This test drives the three states through a REAL mesh — a real -/// MeshService, the real fan-in, the real cache — with a provider that answers the directory -/// query, then faults on the test's signal, then withholds its replacement snapshot until released. -/// Nothing is mocked; the provider is the extension point every storage backend plugs into, and -/// "faults after its first snapshot" is the shape Classify's own doc calls out. -/// -public class UserIndexReopenDoesNotServeADeadSnapshotTest(ITestOutputHelper output) - : MonolithMeshTestBase(output) -{ - private const string UserId = "reopen-probe-user"; - private const string Email = "reopen-probe@example.com"; - - private readonly DirectoryProvider provider = new(UserId, Email); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => base.ConfigureMesh(builder) - .ConfigureServices(services => services.AddSingleton(provider)); - - [Fact(Timeout = 240_000)] - public async Task AReopenedIndexAnswersUnavailable_NeverAStaleHitAndNeverAFalseUnknown() - { - var ct = TestContext.Current.CancellationToken; - var cache = new UserIdentityCache( - Mesh.ServiceProvider.GetRequiredService(), - Mesh, - Mesh.ServiceProvider.GetRequiredService() - .CreateLogger()); - - // ── 1. THE PRECONDITION. Without a Found here the rest measures nothing: an index that never - // filled would answer Unavailable for a reason that has nothing to do with the re-open. - var hydrated = await Poll(cache, l => l.Node is not null, ct); - hydrated.Node!.Id.Should().Be(UserId); - - // ── 2. Fault the live chain AFTER its first snapshot — the case Classify documents as "both - // set", where the index's contents are now arbitrarily stale. - provider.FaultTheOpenChain(); - - // ── 3. THE PROPERTY. Every reading from here until the replacement snapshot lands must be - // UNAVAILABLE. Not Found (that is the dead chain's row) and not Unknown (that is - // "no such user" about a user who exists). The replacement chain is HELD, so this is a - // stable state rather than a race: the poll cannot pass by getting lucky on timing. - var readings = new List(); - var degraded = await Poll(cache, l => l.IsUnavailable, ct, readings); - degraded.IsUnavailable.Should().BeTrue(); - readings.Should().NotContain(l => l.Node is null && !l.IsUnavailable, - "a definitive Unknown over a directory that is mid-rebuild says 'no such user' about a " - + "user who exists — the false actionable verdict, and the input that drives onboarding"); - - // Two more readings, taken while the replacement is still held, so the assertion is about a - // STATE and not about the one instant the poll happened to sample. - cache.Lookup(Email).IsUnavailable.Should().BeTrue(); - cache.Lookup(Email).IsUnavailable.Should().BeTrue(); - cache.Lookup(Email).Node.Should().BeNull( - "the dead chain's row must be gone, not merely flagged — Classify answers Found on a hit " - + "before it consults either flag"); - - // ── 4. THE CONTROL on the repair itself: released, the replacement chain fills the index and - // the very same lookup answers again. Without this the fix could "pass" by permanently - // blacking the directory out, which is the opposite failure. - provider.ReleaseTheReplacement(); - var repaired = await Poll(cache, l => l.Node is not null, ct); - repaired.Node!.Id.Should().Be(UserId); - provider.Subscriptions.Should().BeGreaterThanOrEqualTo(2, - "the next Lookup after a terminal must OPEN A FRESH chain — nothing re-subscribes on its " - + $"own, and the provider saw {provider.Subscriptions} subscription(s)"); - } - - /// - /// Re-asks until it satisfies . - /// The sanctioned shape for a request/response source (there is no stream to filter — a lookup is - /// a synchronous question), and every reading is recorded when is - /// given so an assertion can be made about the whole window rather than one sample. - /// - private static async Task Poll( - UserIdentityCache cache, Func until, - CancellationToken ct, List? readings = null) - => await Observable.Interval(TestTimeouts.Quick / 100) - .StartWith(0L) - .Select(_ => - { - var reading = cache.Lookup(Email); - readings?.Add(reading); - return reading; - }) - .Where(until) - .Should().Within(TestTimeouts.Convergence) - .Emit("the cache has to reach the state under test", ct); - - /// - /// Answers the nodeType:User directory query, then faults its OPEN chain on demand, then - /// withholds the replacement chain's snapshot until released — so the re-open window is a state - /// the test holds open rather than an instant it has to catch. - /// - /// Queries other than the directory read are answered with a prompt EMPTY Initial (never - /// Observable.Empty, which would make this the "completed without an Initial" case and - /// change what is under test), so the rest of the mesh is untouched. - /// - private sealed class DirectoryProvider(string userId, string email) : IMeshQueryProvider - { - public string Name => nameof(DirectoryProvider); - - public bool Matches(IReadOnlyList queryNamespaces) => true; - - /// Test probe: how many times the directory read has been subscribed. A re-open is - /// the second one. - public int Subscriptions; - - // Completed to fault the chain that is currently open (arm 1), and to release the snapshot the - // chain opened after it is holding back (arm 2). AsyncSubjects the TEST completes — the - // sanctioned producer→test signal; nothing here waits on a gate. - private readonly AsyncSubject _fault = new(); - private readonly AsyncSubject _release = new(); - - public void FaultTheOpenChain() - { - _fault.OnNext(Unit.Default); - _fault.OnCompleted(); - } - - public void ReleaseTheReplacement() - { - _release.OnNext(Unit.Default); - _release.OnCompleted(); - } - - public IObservable> Query( - MeshQueryRequest request, JsonSerializerOptions options) - { - if (!IsDirectoryRead(request)) - return Observable.Return(Frame([])); - - var n = System.Threading.Interlocked.Increment(ref Subscriptions); - var node = new MeshNode(userId) - { - Name = userId, NodeType = "User", State = MeshNodeState.Active, - // 🚨 The REAL User content type. A loose dictionary is not convertible by - // TryGetEmail's ContentAs, so the node indexes under no email at all and the - // precondition below fails with "not convertible" in the log — which is exactly what - // it is there for, but it measures nothing about the re-open. - Content = new MeshWeaver.Mesh.Security.User { Email = email, FullName = userId }, - }; - var snapshot = Frame([(T)(object)node]); - - return n == 1 - // The FIRST chain answers, then faults on the test's signal — a fault AFTER the - // first snapshot, which is the state Classify calls "both set". - ? Observable.Return(snapshot) - .Concat(_fault.SelectMany(_ => - Observable.Throw>( - new InvalidOperationException("directory read faulted (test))")))) - // Every LATER chain is the re-open: it withholds its snapshot until released, so the - // window the test asserts about is stable. - : _release.Select(_ => snapshot).Concat(Observable.Never>()); - } - - private static QueryResultChange Frame(T[] items) => new() - { - ChangeType = QueryChangeType.Initial, - Items = items, - Timestamp = DateTimeOffset.UtcNow, - }; - - private static bool IsDirectoryRead(MeshQueryRequest request) - => request.EffectiveQueries.Any(q => - q.Contains("nodeType:User", StringComparison.OrdinalIgnoreCase)); - - public IObservable> Query( - MeshQueryRequest request, JsonSerializerOptions options) - => Observable.Return((IReadOnlyCollection)Array.Empty()); - - public IObservable> Autocomplete( - string basePath, string prefix, JsonSerializerOptions options, - AutocompleteMode mode = AutocompleteMode.RelevanceFirst, int limit = 10, - string? contextPath = null, string? context = null) - => Observable.Return((IReadOnlyCollection)Array.Empty()); - - public IObservable Select(string path, string property, JsonSerializerOptions options) - => Observable.Return(default); - } -} diff --git a/test/Memex.Portal.Shared.Test/VersionEndpointTest.cs b/test/Memex.Portal.Shared.Test/VersionEndpointTest.cs deleted file mode 100644 index ac67ea03c0..0000000000 --- a/test/Memex.Portal.Shared.Test/VersionEndpointTest.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Reflection; -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.ServiceDefaults; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Xunit; - -// The class and its namespace share the name "ServiceDefaults", so the bare name binds to the -// namespace here. Alias it so the endpoint's own constants stay readable. -using Defaults = Memex.Portal.ServiceDefaults.ServiceDefaults; - -namespace Memex.Portal.Shared.Test; - -/// -/// The /api/version contract (issue #956, second half): a machine-readable build identity -/// that answers WITHOUT a session, so a deployment can be checked from outside the portal. -/// -/// Anonymity is the entire point — an endpoint that quietly required a login would satisfy -/// nothing — so it is proved the only way that means anything: the pipeline under test carries a -/// deny-by-default authorization fallback, and a control endpoint in the SAME pipeline is refused. -/// Without that control the 200 would be vacuous (any route answers when nothing is guarding). -/// -/// The other half of the contract is what the response must NOT contain. It is exactly two -/// fields; no environment, cluster, namespace, configuration or user data may ever ride along, so -/// the property set is asserted exhaustively rather than by presence. -/// -public class VersionEndpointTest -{ - /// A route with no AllowAnonymous — the proof the fallback policy is real. - private const string GuardedRoute = "/guarded"; - - private const string TestScheme = "NoSession"; - - /// - /// The pipeline under test: the REAL composition - /// (so this pins the endpoint as it actually ships, not a hand-mapped copy), behind the real - /// authentication + authorization middleware with a fallback policy that denies everything not - /// explicitly marked anonymous — i.e. the shape of a portal that requires a login by default. - /// - private static WebApplication BuildApp() - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.AddDefaultHealthChecks(); - - // A scheme that never authenticates anyone: every caller here is anonymous, and a challenge - // answers 401 (the handler's default) instead of throwing for want of a registered scheme. - builder.Services.AddAuthentication(TestScheme) - .AddScheme(TestScheme, _ => { }); - - // Deny by default. AllowAnonymous on the version endpoint is what must beat this. - builder.Services.AddAuthorizationBuilder() - .SetFallbackPolicy(new AuthorizationPolicyBuilder() - .RequireAssertion(_ => false) - .Build()); - - var app = builder.Build(); - app.UseAuthentication(); - app.UseAuthorization(); - app.MapDefaultEndpoints(); - app.MapGet(GuardedRoute, () => "secret"); - return app; - } - - /// Authenticates nobody — the "no session" the endpoint must answer under. - private sealed class NoSessionHandler( - IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) - : AuthenticationHandler(options, logger, encoder) - { - protected override Task HandleAuthenticateAsync() => - Task.FromResult(AuthenticateResult.NoResult()); - } - - private static async Task<(HttpResponseMessage Response, string Body)> GetAsync(string route) - { - var app = BuildApp(); - await using (app) - { - await app.StartAsync(TestContext.Current.CancellationToken); - using var client = app.GetTestClient(); - var response = await client.GetAsync(route, TestContext.Current.CancellationToken); - var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - return (response, body); - } - } - - /// - /// Non-vacuity guard: the pipeline really does refuse an unauthenticated caller. Should this - /// route ever start answering instead, the anonymity assertion below would be proving nothing — - /// every route answers when nothing is guarding. - /// - [Fact] - public async Task A_guarded_route_in_the_same_pipeline_refuses_an_anonymous_caller() - { - var (response, _) = await GetAsync(GuardedRoute); - - response.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden); - } - - /// The point of the endpoint: it answers with no session at all, and both fields carry a value. - [Fact] - public async Task Version_endpoint_answers_anonymously_with_version_and_commit() - { - var (response, body) = await GetAsync(Defaults.VersionRoute); - - response.StatusCode.Should().Be(HttpStatusCode.OK, - "verifying a deployment from outside must not require a login"); - - var payload = JsonSerializer.Deserialize(body, JsonSerializerOptions.Web)!; - payload.Version.Should().NotBeNullOrWhiteSpace(); - payload.Commit.Should().NotBeNullOrWhiteSpace( - "the git SHA is stamped into every assembly by the AddCommitHashMetadata target"); - } - - /// - /// The disclosure contract, asserted exhaustively: the body has these two properties and no - /// others. A future field added carelessly here would be published to anonymous callers. - /// - [Fact] - public async Task Version_endpoint_discloses_nothing_beyond_version_and_commit() - { - var (_, body) = await GetAsync(Defaults.VersionRoute); - - using var document = JsonDocument.Parse(body); - var properties = document.RootElement.EnumerateObject() - .Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray(); - - properties.Should().Equal("commit", "version"); - } - - /// - /// The projection itself, off an assembly THIS build stamped: both values come from assembly - /// metadata — no poller, no node, no configuration key involved. - /// - [Fact] - public void ReadBuildIdentity_reads_the_stamps_off_a_build_assembly() - { - var identity = Defaults.ReadBuildIdentity(typeof(BuildIdentity).Assembly); - - identity.Version.Should().NotBeNullOrWhiteSpace(); - identity.Commit.Should().NotBeNullOrWhiteSpace( - "AddCommitHashMetadata stamps every assembly built from this repo"); - } - - /// - /// The fallback that makes the endpoint answer correctly under a host that is not part of this - /// build — and this test run IS that case, which is what gives the assertion teeth. - /// - /// test/Directory.Build.props deliberately does NOT import the root props, so - /// AddCommitHashMetadata never runs for test projects and the entry assembly here carries - /// no CommitHash. Reading the entry assembly blindly would report the runner's 1.0.0 - /// and an empty commit; the fallback reports the real build instead. Production is the other - /// branch — memex/Directory.Build.props imports the root, so the portal executable IS - /// stamped and wins, which is what keeps this endpoint and the About tab in agreement. - /// - [Fact] - public void Build_falls_back_to_a_stamped_assembly_when_the_host_is_not_part_of_this_build() - { - var entry = Assembly.GetEntryAssembly(); - entry.Should().NotBeNull(); - - // Non-vacuity guard: the host really is unstamped, so the fallback really is exercised. - Defaults.ReadBuildIdentity(entry!).Commit.Should().BeEmpty(); - - Defaults.Build.Commit.Should().NotBeNullOrWhiteSpace(); - Defaults.Build.Version.Should().NotBeNullOrWhiteSpace(); - } - - /// - /// A build with no source-control information reports an EMPTY commit rather than throwing or - /// inventing one — the same "not recorded" case the About tab renders as a note. - /// - [Fact] - public void ReadBuildIdentity_reports_an_empty_commit_when_the_build_carries_no_sha() - { - // The framework's own assembly is built outside this repo, so it carries no CommitHash. - var identity = Defaults.ReadBuildIdentity(typeof(object).Assembly); - - identity.Commit.Should().BeEmpty(); - identity.Version.Should().NotBeNullOrWhiteSpace(); - } -} diff --git a/test/Memex.Portal.Shared.Test/VersionSelectTest.cs b/test/Memex.Portal.Shared.Test/VersionSelectTest.cs deleted file mode 100644 index fe8a27a8c5..0000000000 --- a/test/Memex.Portal.Shared.Test/VersionSelectTest.cs +++ /dev/null @@ -1,730 +0,0 @@ -using System.Collections.Immutable; -using System.Text.Json; -using System.Text.Json.Serialization; -using Memex.Portal.Shared.SelfUpdate; -using Xunit; -using MeshWeaver.Hosting.SelfUpdate; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the self-update DECISION logic: which registry tag each policy rolls to, and when a target is -/// "newer" than the running version. This is the safety core of the feature (a wrong pick auto-rolls -/// the whole platform), so the truth table is fixed here. Also covers the policy-node content parse. -/// -public class VersionSelectTest -{ - // 3.1.0-ci.55 is the newest overall (run 55 is the latest publication); the only CLEAN release - // is 3.0.0. 🚨 The build number was 5 here until #3542: a 3.1.0 LABEL over an EARLIER run than - // 3.0.0-ci.51, which is the slip shape itself, and the assertion below then pinned the defect as - // the contract. Real tags cannot look like that — the number is the CI run number and only ever - // increases — so the data now says what the pipeline actually produces. - private static readonly string[] Tags = - ["3.0.0", "3.0.0-ci.40", "3.0.0-ci.51", "3.1.0-ci.55", "garbage", "latest", "main"]; - - [Fact] - public void Continuous_PicksNewestTag_IncludingBuildNumbered() - => Assert.Equal("3.1.0-ci.55", VersionSelect.PickTarget(Tags, UpdatePolicyKind.Continuous)); - - [Fact] - public void Stable_PicksNewestCleanRelease_IgnoringBuildNumbered() - => Assert.Equal("3.0.0", VersionSelect.PickTarget(Tags, UpdatePolicyKind.Stable)); - - [Fact] - public void None_PicksNothing() - => Assert.Null(VersionSelect.PickTarget(Tags, UpdatePolicyKind.None)); - - [Fact] - public void PickTarget_NoParseableTags_ReturnsNull() - => Assert.Null(VersionSelect.PickTarget(["latest", "main", "garbage"], UpdatePolicyKind.Continuous)); - - [Fact] - public void Continuous_IgnoresPerArchRidTags_PrefersManifestList() - // The multi-arch CD pushes the manifest list (3.0.0-ci.43) alongside per-RID image tags whose - // suffix parses as an extra pre-release identifier that sorts ABOVE it. The poller must roll to the - // manifest list, never the x64-only image (which would be wrong-arch on an arm64 node). - => Assert.Equal("3.0.0-ci.43", VersionSelect.PickTarget( - ["3.0.0-ci.43", "3.0.0-ci.43-linux-x64", "3.0.0-ci.43-linux-arm64", "main", "main-linux-x64"], - UpdatePolicyKind.Continuous)); - - [Fact] - public void Continuous_IgnoresBareGitShaTags_EvenAllDigitOnes() - // The CD pushes a 7-char git-sha tag per version alongside the ci.N tag. An ALL-DIGIT sha like - // "6943991" NuGet-parses as version 6943991.0.0 and outranks every real 3.x release — the prod bug - // that froze every portal on ci.122 (whose sha, 6943991, is all digits) and reverted rolls to a - // newer ci.N. Only a real MAJOR.MINOR.PATCH tag may be picked; git-shas (digit- or letter-) are out. - => Assert.Equal("3.0.0-ci.133", VersionSelect.PickTarget( - ["3.0.0-ci.122", "6943991", "3.0.0-ci.133", "4779b4e", "main"], - UpdatePolicyKind.Continuous)); - - // CI-green gate: a `-edge.N` build is UNVERIFIED. Default (RequireCiGreen=true) must skip it even - // though it is the newest; opting into "any" (RequireCiGreen=false) rolls to it. - [Fact] - public void GreenOnly_Default_ExcludesEdgeBuilds_EvenWhenNewest() - => Assert.Equal("3.0.0-ci.51", - VersionSelect.PickTarget(["3.0.0-ci.51", "3.1.0-edge.7"], UpdatePolicyKind.Continuous)); - - [Fact] - public void Any_RequireCiGreenFalse_IncludesEdgeBuilds() - // 🚨 edge.70, not edge.7: `edge-images.yml` rewrites the `ci` label to `edge` and keeps the same - // run number, so an edge build that is genuinely newer carries a HIGHER one. Pinning - // "3.1.0-edge.7 beats 3.0.0-ci.51" would be pinning the #3542 defect under another label. - => Assert.Equal("3.1.0-edge.70", - VersionSelect.PickTarget(["3.0.0-ci.51", "3.1.0-edge.70"], UpdatePolicyKind.Continuous, requireCiGreen: false)); - - [Fact] - public void UpdatePolicyContent_DefaultsToRequireCiGreen() - => Assert.True(new UpdatePolicyContent().RequireCiGreen, "green-only must be the safe default"); - - // ───────── 2026-09-08: clean releases by default; continuous builds by PATTERN only ───────── - // - // Maintainer: "by default we will not upgrade as long as no version without -ci... is labelled. - // ==> we want to have a clean label 3.0.1 to upgrade. if we want to get the -ci... we have to - // specify the pattern 3.0.1-ci* or something. at the moment it is 3.0.0-ci* as we have not - // released anything else." - - /// The registry after 3.0.1 is tagged and the next line has started: the clean - /// release, its own ci builds, and the next line's first ci build. Run numbers are MONOTONIC - /// across lines, as the pipeline produces them (#3542) — `3.0.1-ci.8112` was published after - /// `3.0.0-ci.8059`, whatever the labels say. - private static readonly string[] LineRegistry = - [ - "3.0.0", "3.0.0-ci.8059", "3.0.1-ci.8112", "3.0.1-ci.8130", "3.0.1", "3.0.2-ci.8140", - "3-latest", "3.0-latest", "3.0.1-latest", - ]; - - /// 🚨 The seeded default is Stable — clean releases only — and it is the ONE default. - [Fact] - public void TheDefaultPolicy_IsCleanReleasesOnly() - { - Assert.Equal(UpdatePolicyKind.Stable, new SelfUpdateOptions().DefaultPolicy); - Assert.Null(new SelfUpdateOptions().DefaultPattern); - } - - /// Clean-only ignores 3.0.1-ci.12 and picks 3.0.1 — an install that has - /// never opted into a pattern sees the release and nothing before it. - [Fact] - public void CleanOnly_IgnoresTheCiBuilds_AndPicksTheRelease() - { - var selection = VersionSelect.SelectCandidates(LineRegistry, "3.0.0", UpdatePolicyKind.Stable); - Assert.Equal(["3.0.1"], selection.Candidates); - } - - /// 🚨 The rule itself: Continuous WITHOUT a pattern decides exactly as - /// Stable — no pre-release is eligible on its own — and says so. - [Fact] - public void ContinuousWithoutAPattern_IsStable_AndSaysSo() - { - var channel = VersionSelect.ResolveChannel(UpdatePolicyKind.Continuous, null); - Assert.Equal(UpdatePolicyKind.Stable, channel.Policy); - Assert.Null(channel.Pattern); - Assert.NotNull(channel.Advisory); - Assert.Contains(UpdatePolicyNodeType.NodePath, channel.Advisory, StringComparison.Ordinal); - Assert.Contains("pattern", channel.Advisory, StringComparison.Ordinal); - Assert.Contains("\"3.*\"", channel.Advisory, StringComparison.Ordinal); - - // A blank pattern is no pattern — " " must not be a third policy. - Assert.Equal(UpdatePolicyKind.Stable, VersionSelect.ResolveChannel(UpdatePolicyKind.Continuous, " ").Policy); - - var selection = VersionSelect.SelectCandidates( - LineRegistry, "3.0.0", UpdatePolicyKind.Continuous, pattern: null); - Assert.Equal(["3.0.1"], selection.Candidates); - - // The other two policies carry no advisory: they mean what they say. - Assert.Null(VersionSelect.ResolveChannel(UpdatePolicyKind.Stable, null).Advisory); - Assert.Null(VersionSelect.ResolveChannel(UpdatePolicyKind.None, null).Advisory); - Assert.Null(VersionSelect.ResolveChannel(UpdatePolicyKind.Continuous, "3.0.1-ci*").Advisory); - Assert.Equal(UpdatePolicyKind.None, VersionSelect.ResolveChannel(UpdatePolicyKind.None, "3.0.1-ci*").Policy); - } - - /// Pattern 3.0.1-ci* picks the highest ci of 3.0.1 — by run number, so - /// ci.8130 over ci.8112 — and NOT 3.0.2-ci.8140, and not the clean - /// 3.0.1 either: the glob names the line's continuous builds and nothing else. - [Fact] - public void APattern_AdmitsExactlyTheLineItNames() - { - var selection = VersionSelect.SelectCandidates( - LineRegistry, "3.0.0-ci.8059", UpdatePolicyKind.Continuous, pattern: "3.0.1-ci*"); - Assert.Equal(["3.0.1-ci.8130", "3.0.1-ci.8112"], selection.Candidates); - Assert.Equal("3.0.1-ci.8130", - VersionSelect.PickTarget(LineRegistry, UpdatePolicyKind.Continuous, pattern: "3.0.1-ci*")); - } - - /// 🚨 The fleet's own setting today: 3.0.0-ci* never picks 3.0.1 (nor - /// 3.0.1-ci.*). Following one line's continuous builds ENDS by the pattern matching - /// nothing new — which is why the docs say to change it the day 3.0.1 is tagged. - [Fact] - public void TheCurrentFleetPattern_NeverCrossesToTheNextRelease() - { - Assert.Equal(["3.0.0-ci.8059"], - VersionSelect.PickTargets(LineRegistry, UpdatePolicyKind.Continuous, pattern: "3.0.0-ci*")); - - var settled = VersionSelect.SelectCandidates( - LineRegistry, "3.0.0-ci.8059", UpdatePolicyKind.Continuous, pattern: "3.0.0-ci*"); - Assert.Empty(settled.Candidates); - Assert.False(settled.IsRecovery); - } - - /// Ordering under a pattern is still the lineage: ci.7845 < ci.8059 numerically, - /// a retired rc label never outranks a later run, and the clean release ranks above its - /// own pre-releases wherever both are admitted (a pattern wide enough to admit both). - [Fact] - public void UnderAPattern_TheOrderIsStillTheLineage() - { - string[] registry = ["3.0.0-ci.7845", "3.0.0-rc9.ci.7824", "3.0.0-ci.8059", "3.0.0"]; - - Assert.Equal(["3.0.0-ci.8059", "3.0.0-ci.7845"], - VersionSelect.PickTargets(registry, UpdatePolicyKind.Continuous, pattern: "3.0.0-ci*")); - // `3.0.0*` admits the rc tag and the release too: run number first, promotion last. - Assert.Equal(["3.0.0-ci.8059", "3.0.0-ci.7845", "3.0.0-rc9.ci.7824", "3.0.0"], - VersionSelect.PickTargets(registry, UpdatePolicyKind.Continuous, pattern: "3.0.0*")); - Assert.True(VersionSelect.IsNewer("3.0.0-ci.8059", "3.0.0-ci.7845")); - Assert.True(VersionSelect.IsNewer("3.0.0", "3.0.0-ci.8059")); - } - - /// A pattern narrows Stable too — 3.0.* keeps an install on one line's - /// clean releases while the next line's release exists. - [Fact] - public void APattern_NarrowsStable_ToOneLine() - { - string[] registry = ["3.0.1", "3.1.0", "3.1.0-ci.9000", "3.0.2"]; - Assert.Equal(["3.0.2", "3.0.1"], - VersionSelect.PickTargets(registry, UpdatePolicyKind.Stable, pattern: "3.0.*")); - } - - /// 🚨 The moving image pointers (3-latest, 3.0-latest, 3.0.1-latest) - /// are POINTERS, not versions: never a candidate, under any policy or pattern — even a pattern - /// that would match them textually. - [Fact] - public void MovingPointers_AreNeverCandidates() - { - string[] registry = ["3-latest", "3.0-latest", "3.0.1-latest", "latest", "3.0.1"]; - Assert.Equal(["3.0.1"], VersionSelect.PickTargets(registry, UpdatePolicyKind.Stable)); - Assert.Equal(["3.0.1"], VersionSelect.PickTargets(registry, UpdatePolicyKind.Continuous)); - Assert.Empty(VersionSelect.PickTargets(registry, UpdatePolicyKind.Continuous, pattern: "*-latest")); - Assert.Empty(VersionSelect.PickTargets(registry, UpdatePolicyKind.Continuous, pattern: "3-latest")); - } - - /// The glob itself: * any run, ? one character, everything else literal, - /// whole-tag, case-insensitive; a blank pattern admits nothing (the CALLER decides what "no - /// pattern" means, never the matcher). - [Theory] - [InlineData("3.0.1-ci*", "3.0.1-ci.12", true)] - [InlineData("3.0.1-ci*", "3.0.1-CI.12", true)] - [InlineData("3.0.1-ci*", "3.0.1", false)] - [InlineData("3.0.1-ci*", "3.0.10-ci.1", false)] - [InlineData("3.0.?-ci*", "3.0.7-ci.1", true)] - [InlineData("3.0.?-ci*", "3.0.10-ci.1", false)] - [InlineData("3.0.*", "3.0.1", true)] - [InlineData("3.0.*", "3.1.0", false)] - [InlineData("", "3.0.1", false)] - [InlineData(null, "3.0.1", false)] - [InlineData(" ", "3.0.1", false)] - public void UpdateChannelPattern_IsAWholeTagGlob(string? pattern, string tag, bool expected) - => Assert.Equal(expected, UpdateChannelPattern.Matches(pattern, tag)); - - [Fact] - public void UpdateChannelPattern_EscapesEverythingButTheWildcards() - { - Assert.Equal(@"^3\.0\.1-ci.*$", UpdateChannelPattern.ToRegex("3.0.1-ci*")); - Assert.Null(UpdateChannelPattern.Normalize(" ")); - Assert.Equal("3.0.1-ci*", UpdateChannelPattern.Normalize(" 3.0.1-ci* ")); - } - - /// The pattern round-trips on the record as pattern, and its absence stays - /// absent (the serializer drops nulls, and null is the one "no pattern"). - [Fact] - public void UpdatePolicyContent_PatternRoundTrips_AsPattern() - { - var element = JsonSerializer.SerializeToElement( - new UpdatePolicyContent { Policy = UpdatePolicyKind.Continuous, Pattern = "3.0.0-ci*" }, Web); - Assert.Equal("3.0.0-ci*", element.GetProperty("pattern").GetString()); - var parsed = UpdatePolicyNodeType.ParseContent(element, Web); - Assert.Equal("3.0.0-ci*", parsed.Pattern); - Assert.Equal(UpdatePolicyKind.Continuous, parsed.Policy); - - // These Web options write nulls out (the hub's own options drop them); either way the - // read is "no pattern", which is the one fact the record must not lose. - var bare = JsonSerializer.SerializeToElement( - new UpdatePolicyContent { Policy = UpdatePolicyKind.Stable }, Web); - Assert.True(!bare.TryGetProperty("pattern", out var p) || p.ValueKind == JsonValueKind.Null); - Assert.Null(UpdatePolicyNodeType.ParseContent(bare, Web).Pattern); - } - - [Theory] - [InlineData("3.1.0-ci.55", "3.0.0-ci.51", true)] // later run on a higher base wins - [InlineData("3.0.0-ci.51", "3.0.0-ci.40", true)] // monotonic ci number - [InlineData("3.0.0", "3.0.0-ci.51", true)] // release beats its prerelease - [InlineData("3.0.0-ci.40", "3.0.0-ci.51", false)] // older ci number - [InlineData("3.0.0", "3.0.0", false)] // equal - [InlineData("3.0.0", "unknown", false)] // unparseable current → never update - public void IsNewer_TruthTable(string target, string current, bool expected) - => Assert.Equal(expected, VersionSelect.IsNewer(target, current)); - - [Theory] - // The running InformationalVersion carries +build. metadata; SemVer ignores it in comparison. - [InlineData("3.0.0-ci.52", "3.0.0-ci.51+build.638123456789", true)] - [InlineData("3.0.0-ci.51", "3.0.0-ci.51+build.638123456789", false)] - public void IsNewer_IgnoresBuildMetadataOnCurrent(string target, string current, bool expected) - => Assert.Equal(expected, VersionSelect.IsNewer(target, current)); - - [Fact] - public void ParseContent_TypedContent_RoundTrips() - => Assert.Equal(UpdatePolicyKind.Stable, - UpdatePolicyNodeType.ParseContent( - new UpdatePolicyContent { Policy = UpdatePolicyKind.Stable }, Web).Policy); - - [Fact] - public void ParseContent_Null_FailsClosedToNone() - // 🚨 REVERSED by #3542, deliberately. This used to assert Continuous — i.e. content that is - // absent or unreadable enabled unattended rolls. That is how memex-cloud rolled onto a - // withdrawn 3.1.0-ci line "on a policy record that lost its own policy". A read that - // produced nothing must not be the most permissive answer. - => Assert.Equal(UpdatePolicyKind.None, UpdatePolicyNodeType.ParseContent(null, Web).Policy); - - [Fact] - public void ParseContent_JsonElement_DeserializesEnumByName() - { - var element = JsonSerializer.SerializeToElement( - new UpdatePolicyContent { Policy = UpdatePolicyKind.None }, Web); - Assert.Equal(UpdatePolicyKind.None, UpdatePolicyNodeType.ParseContent(element, Web).Policy); - } - - private static readonly JsonSerializerOptions Web = - new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; - - // ───────── PickTargets: the ordered candidate list (the deadlock fix) ───────── - - /// - /// The newest eligible tag is simply the first candidate — so the two selectors can never - /// disagree about what "best" means. - /// - [Fact] - public void PickTargets_FirstCandidate_IsExactlyPickTarget() - { - string[] tags = ["3.0.0-rc7.ci.4928", "3.0.0-rc7.ci.4900", "3.0.0-rc6", "main", "6943991"]; - - var best = VersionSelect.PickTarget(tags, UpdatePolicyKind.Continuous); - var all = VersionSelect.PickTargets(tags, UpdatePolicyKind.Continuous); - - Assert.Equal(best, all.First()); - } - - /// - /// 🚨 The deadlock this exists to break. memex sat on 3.0.0-rc6 held against - /// 3.0.0-rc7.ci.4928 because no sealed bake existed for that image — while three separate - /// bakes published three OTHER identities. Newest-only selection can never escape that: each - /// new build produces another unbaked tag. Walking the ordered list lets the caller take the - /// newest release that is actually sealed. - /// - [Fact] - public void PickTargets_IsOrderedNewestFirst_SoTheCallerCanSkipAnUnbakedHead() - { - string[] tags = ["3.0.0-rc7.ci.4900", "3.0.0-rc7.ci.4928", "3.0.0-rc6", "3.0.0-rc7.ci.4910"]; - - var candidates = VersionSelect.PickTargets(tags, UpdatePolicyKind.Continuous); - - Assert.Equal(["3.0.0-rc7.ci.4928", "3.0.0-rc7.ci.4910", "3.0.0-rc7.ci.4900", "3.0.0-rc6"], candidates); - - // The caller's move: head is unbaked, so it rolls to the next one down rather than freezing. - var sealedTags = new HashSet { "3.0.0-rc7.ci.4910", "3.0.0-rc6" }; - Assert.Equal("3.0.0-rc7.ci.4910", candidates.First(sealedTags.Contains)); - } - - /// Every exclusion PickTarget applies applies here too — same filter, one place. - [Fact] - public void PickTargets_ExcludesRidTags_ShaTags_And_Edge() - { - string[] tags = - [ - "3.0.0-rc7.ci.4928", "3.0.0-rc7.ci.4928-linux-x64", "3.0.0-rc7.ci.4928-linux-arm64", - "main", "6943991", "3.0.0-edge.51", - ]; - - var candidates = VersionSelect.PickTargets(tags, UpdatePolicyKind.Continuous); - - Assert.Equal(["3.0.0-rc7.ci.4928"], candidates); - } - - [Fact] - public void PickTargets_Stable_TakesOnlyCleanReleases_And_None_TakesNothing() - { - string[] tags = ["3.1.0", "3.0.0-rc7.ci.4928", "3.0.0"]; - - Assert.Equal(["3.1.0", "3.0.0"], VersionSelect.PickTargets(tags, UpdatePolicyKind.Stable)); - Assert.Empty(VersionSelect.PickTargets(tags, UpdatePolicyKind.None)); - } - - // ───────── #3542: the key is the sealed-publication LINEAGE, not the version string ───────── - - /// - /// 🚨 The incident, in one assertion. On 2026-09-05 Directory.Build.props briefly - /// read 3.1.0, so runs 7832–7841 published as 3.1.0-ci.* and were withdrawn. Ordered - /// by SemVer those outrank every later, SEALED 3.0.0-ci.79xx set forever — and on - /// 2026-09-07 memex-cloud rolled itself onto 3.1.0-ci.7841, three days behind, onto a line - /// nobody had published for two days. - /// - /// Nothing in the release process can prevent that: 7841 passed promote, bake, seal and - /// register. Every gate it has says yes, and none of them asks whether this core is NEWER than - /// what the fleet already runs. Only the ordering can, and only once it stops reading a - /// hand-maintained label as if it were the publication order. - /// - [Fact] - public void AMislabelledLine_NeverOutranksALaterSealedSet() - { - // The registry as it stood on 2026-09-07, minus the noise. - string[] tags = - [ - "3.0.0-rc9.ci.7693", "3.1.0-ci.7832", "3.1.0-ci.7841", - "3.0.0-ci.7955", "3.0.0-ci.7962", "3.0.0-ci.7977", - ]; - - Assert.Equal("3.0.0-ci.7977", VersionSelect.PickTarget(tags, UpdatePolicyKind.Continuous)); - Assert.Equal( - [ - "3.0.0-ci.7977", "3.0.0-ci.7962", "3.0.0-ci.7955", - "3.1.0-ci.7841", "3.1.0-ci.7832", "3.0.0-rc9.ci.7693", - ], - VersionSelect.PickTargets(tags, UpdatePolicyKind.Continuous)); - } - - /// - /// 🚨 The other half of the same defect: once ON the mislabelled tag, nothing is ever newer, so - /// the install can never leave under its own power — an operator had to move both AKS portals by - /// hand. Lineage answers it (7977 was published after 7841, whatever the labels say), and the - /// reverse must stay false or the two would trade places on every check. - /// - [Fact] - public void AnInstallOnTheMislabelledTag_SeesTheLaterSealedSetAsNewer() - { - Assert.True(VersionSelect.IsNewer("3.0.0-ci.7977", "3.1.0-ci.7841")); - Assert.False(VersionSelect.IsNewer("3.1.0-ci.7841", "3.0.0-ci.7977")); - } - - /// - /// 🚨 The trap one layer down, and the reason "just stop bumping the line" is not the fix: SemVer - /// §11.4 compares pre-release identifiers as TEXT, so rc9 sorts above ci and the - /// retired rc images still in ACR outrank every clean-line build. With the 3.1.0 tags removed the - /// selector simply picked the next mislabelled tag — a 2026-09-04 build — and would have rolled - /// the portal onto it within the hour. - /// - [Fact] - public void ARetiredPrereleaseLabel_DoesNotOutrankALaterRun() - { - Assert.Equal( - "3.0.0-ci.7977", - VersionSelect.PickTarget(["3.0.0-rc9.ci.7824", "3.0.0-ci.7977"], UpdatePolicyKind.Continuous)); - Assert.False(VersionSelect.IsNewer("3.0.0-rc9.ci.7824", "3.0.0-ci.7977")); - Assert.True(VersionSelect.IsNewer("3.0.0-ci.7977", "3.0.0-rc9.ci.7824")); - } - - /// - /// 🚨 The regression the lineage key must NOT introduce. An official release is a PROMOTION and - /// carries no run number of its own, so a Stable install running a continuous build has only the - /// version string to compare against — and it must still be able to reach the clean release it is - /// waiting for. That is why IsNewer falls back to SemVer whenever either side is a - /// promotion, and why the Stable ordering is untouched. - /// - /// 🚨 IsNewer is the ORDER, not the POLICY: it says the release is newer, and - /// is what stops a Continuous - /// install acting on that. Both answers are wanted; see OnTheContinuousLine. - /// - [Fact] - public void AnOfficialRelease_IsStillReachableFromAContinuousBuild() - { - Assert.True(VersionSelect.IsNewer("3.1.0", "3.0.0-ci.7977")); - Assert.True(VersionSelect.IsNewer("3.0.0", "3.0.0-ci.7977")); - Assert.Equal( - ["3.1.0", "3.0.0"], - VersionSelect.PickTargets(["3.0.0", "3.1.0", "3.0.0-ci.7977"], UpdatePolicyKind.Stable)); - - // Stable is where a release is actually TAKEN, and it still is. - var stable = VersionSelect.SelectCandidates( - ["3.0.0", "3.0.0-ci.7977"], "3.0.0-ci.7977", UpdatePolicyKind.Stable); - Assert.Equal(["3.0.0"], stable.Candidates); - } - - /// - /// 🚨 "Continuous follows the ci line" — maintainer, 2026-09-07, and the reason it needs a - /// test rather than a comment. The day v3.0.0 is tagged, SemVer ranks the clean release - /// above every 3.0.0-ci.<n>. A Continuous install would take it and then sit there - /// while later sealed ci builds pile up underneath — the release outranks all of them — which is - /// the #3542 freeze again, wearing the release's clothes. - /// - /// Three cases, and the middle one is the whole point: it is the state right after the tag, - /// where nothing on the ci line is newer yet and the release is sitting there looking newer. - /// - [Fact] - public void ContinuousFollowsTheCiLine_AndDoesNotJumpToTheRelease() - { - string[] registry = ["3.0.0", "3.0.0-ci.7977", "3.0.0-ci.7989"]; - - // 1. The ordering alone already prefers the line: the release is in the trailing band. - Assert.Equal("3.0.0-ci.7989", VersionSelect.PickTarget(registry, UpdatePolicyKind.Continuous)); - - // 2. 🚨 Nothing newer ON the line ⇒ nothing to take. The release must NOT be the answer, and - // it is exactly here that SemVer would have said it was. (2026-09-08: "on the line" now - // needs the pattern that admits the line — see case 5.) - var settled = VersionSelect.SelectCandidates( - registry, "3.0.0-ci.7989", UpdatePolicyKind.Continuous, pattern: "3.0.0-ci*"); - Assert.Empty(settled.Candidates); - Assert.False(settled.IsRecovery); - - // 3. A newer ci build ⇒ take it, and the release is not even a fallback the gate walk could - // drop through to. - var rolling = VersionSelect.SelectCandidates( - registry, "3.0.0-ci.7977", UpdatePolicyKind.Continuous, pattern: "3.0.0-ci*"); - Assert.Equal(["3.0.0-ci.7989"], rolling.Candidates); - - // 4. An install NOT on the line is not held off it: it rejoins at the next line's ci builds - // — provided its pattern admits them. - var offTheLine = VersionSelect.SelectCandidates( - ["3.0.0", "3.1.0-ci.8100"], "3.0.0", UpdatePolicyKind.Continuous, pattern: "*-ci*"); - Assert.Equal(["3.1.0-ci.8100"], offTheLine.Candidates); - - // 5. 🚨 2026-09-08: the SAME record with NO pattern is Stable — and Stable DOES take the - // release above its pre-releases. The ci-line rule protects an install that opted into - // the line; one that never wrote a pattern is waiting for exactly this clean label. - var noPattern = VersionSelect.SelectCandidates( - registry, "3.0.0-ci.7989", UpdatePolicyKind.Continuous); - Assert.Equal(["3.0.0"], noPattern.Candidates); - } - - /// - /// The same-line case, where this selector and the module platform floor MUST agree: two builds of - /// one line compare by build number, numerically. The floors now name a 3.0.0-ci.<n> - /// (MeshWeaver.Plugins#1447), so that is the whole of the overlap — and it is the one case where - /// "newer than" and "satisfies the floor of" cannot diverge. - /// - [Fact] - public void SameLineBuildsCompareNumerically_TheOneCaseTheFloorAlsoAsks() - { - Assert.True(VersionSelect.IsNewer("3.0.0-ci.7989", "3.0.0-ci.7977")); - Assert.False(VersionSelect.IsNewer("3.0.0-ci.7977", "3.0.0-ci.7989")); - Assert.False(VersionSelect.IsNewer("3.0.0-ci.7989", "3.0.0-ci.7989")); - } - - /// The run number is read out of all four shapes the pipeline publishes — both - /// separators (Directory.Build.props requires it), the retired rc line, the edge channel — - /// and is absent exactly for a promotion tag. - [Theory] - [InlineData("3.0.0-ci.7977", 7977L)] - [InlineData("3.0.0-rc9.ci.7824", 7824L)] - [InlineData("3.0.0-edge.7977", 7977L)] - [InlineData("3.0.0-ci.7977+build.638", 7977L)] - [InlineData("3.0.0", null)] - [InlineData("3.0.0-rc6", null)] - [InlineData("main", null)] - public void BuildOrdinal_ReadsThePublishingRunNumber(string version, long? expected) - => Assert.Equal(expected, VersionSelect.BuildOrdinal(version)); - - // ───────── #3543: "I am current" is not the same as "my tag no longer exists" ───────── - - /// - /// 🚨 The three states, and why the third one exists. A check that asks a boolean about - /// something it had to READ must not fold a failed read into a real negative: an unparseable - /// running version, or a listing carrying no platform tags at all, means the question was never - /// answered — and answering it "withdrawn" would strand-recover the whole fleet off one bad ACR - /// response. - /// - [Fact] - public void CheckInstalledTag_SeparatesCurrent_FromWithdrawn_FromCouldNotTell() - { - string[] registry = ["3.0.0-ci.7955", "3.0.0-ci.7977", "main", "6943991"]; - - Assert.Equal( - VersionSelect.InstalledTagResolution.Resolved, - VersionSelect.CheckInstalledTag(registry, "3.0.0-ci.7977").Resolution); - // The running InformationalVersion carries +build.; the registry tag does not. - Assert.Equal( - VersionSelect.InstalledTagResolution.Resolved, - VersionSelect.CheckInstalledTag(registry, "3.0.0-ci.7977+build.638123456789").Resolution); - Assert.Equal( - VersionSelect.InstalledTagResolution.Withdrawn, - VersionSelect.CheckInstalledTag(registry, "3.1.0-ci.7841").Resolution); - Assert.Equal( - VersionSelect.InstalledTagResolution.Indeterminate, - VersionSelect.CheckInstalledTag(registry, "unknown").Resolution); - Assert.Equal( - VersionSelect.InstalledTagResolution.Indeterminate, - VersionSelect.CheckInstalledTag(["main", "6943991"], "3.0.0-ci.7977").Resolution); - Assert.Equal( - VersionSelect.InstalledTagResolution.Indeterminate, - VersionSelect.CheckInstalledTag([], "3.0.0-ci.7977").Resolution); - } - - /// - /// 🚨 The strand, and the way out. The 3.1.0 tags were untagged from ACR on 2026-09-07, so - /// the installed tag stopped resolving and the Deployment named an image no new pod could start - /// from. The check still reported "500 tag(s) listed, none newer than the installed - /// 3.1.0-ci.7841" — the same sentence a perfectly current install prints — and by construction - /// nothing would ever be newer than a tag that already outranks everything left. - /// - /// So when nothing is newer AND the installed tag is gone, the eligible list becomes a - /// RECOVERY set: the best AVAILABLE release, even though it is older, because an image that - /// exists beats one that does not. - /// - /// 🚨 The numbers here are deliberately NOT the 2026-09-07 ones. Ranking by lineage already - /// rescues that exact incident — 3.0.0-ci.7977 was published after 3.1.0-ci.7841, so - /// it now reads as newer and the install rolls forward normally. The strand survives the ordering - /// fix in the case the ordering cannot reach: the withdrawn tag was the NEWEST publication, which - /// is the state between an untagging and the next CD run. - /// - [Fact] - public void AWithdrawnInstalledTag_TurnsTheEligibleListIntoARecoverySet() - { - string[] registry = ["3.0.0-ci.7000", "3.0.0-ci.7100"]; - - var selection = VersionSelect.SelectCandidates( - registry, "3.1.0-ci.7841", UpdatePolicyKind.Continuous, pattern: "*-ci*"); - - Assert.True(selection.IsRecovery, - "nothing is newer than a withdrawn tag, so 'nothing newer' cannot be the answer"); - Assert.Equal(["3.0.0-ci.7100", "3.0.0-ci.7000"], selection.Candidates); - Assert.Equal(VersionSelect.InstalledTagResolution.Withdrawn, selection.Installed.Resolution); - Assert.Equal(2, selection.Listed); - } - - /// - /// 🚨 The two fixes meet here, and the ORDER matters: the 2026-09-07 registry is no longer a - /// strand at all, because 3.0.0-ci.7977 was PUBLISHED after the withdrawn - /// 3.1.0-ci.7841 and lineage says so. The install rolls forward normally, and the recovery - /// branch — which is allowed to roll BACKWARDS — is never reached. Pinning that keeps a future - /// change from turning an ordinary update into a downgrade. - /// - [Fact] - public void TheRealIncident_IsAnOrdinaryRollForward_NotARecovery() - { - var selection = VersionSelect.SelectCandidates( - ["3.0.0-ci.7955", "3.0.0-ci.7962", "3.0.0-ci.7977"], - "3.1.0-ci.7841", - UpdatePolicyKind.Continuous, - pattern: "*-ci*"); - - Assert.False(selection.IsRecovery); - Assert.Equal("3.0.0-ci.7977", selection.Candidates[0]); - Assert.Equal(VersionSelect.InstalledTagResolution.Withdrawn, selection.Installed.Resolution); - } - - /// A current install recovers nothing and rolls nothing — the recovery path must be - /// reachable ONLY from a proven withdrawal, or every check would roll backwards. - [Fact] - public void ACurrentInstall_SelectsNothingAndIsNotRecovering() - { - var selection = VersionSelect.SelectCandidates( - ["3.0.0-ci.7955", "3.0.0-ci.7977"], "3.0.0-ci.7977", UpdatePolicyKind.Continuous); - - Assert.Empty(selection.Candidates); - Assert.False(selection.IsRecovery); - Assert.Equal(VersionSelect.InstalledTagResolution.Resolved, selection.Installed.Resolution); - } - - /// 🚨 A listing that could not answer must NOT recover: "the registry read returned - /// nothing" and "your tag was withdrawn" have the same shape and opposite meanings, and acting on - /// the second when it was the first rolls the fleet backwards off one bad response. - [Fact] - public void AnUnreadableListing_RecoversNothing() - { - var selection = VersionSelect.SelectCandidates([], "3.1.0-ci.7841", UpdatePolicyKind.Continuous); - - Assert.Empty(selection.Candidates); - Assert.False(selection.IsRecovery); - Assert.Equal( - VersionSelect.InstalledTagResolution.Indeterminate, selection.Installed.Resolution); - } - - /// A newer release is still an ordinary update — the recovery branch is only reached - /// once the newer set is empty, so a withdrawn tag with something newer above it rolls forward - /// normally and says nothing about a strand. - [Fact] - public void SomethingNewer_IsAnOrdinaryUpdate_EvenWhenTheInstalledTagIsGone() - { - var selection = VersionSelect.SelectCandidates( - ["3.0.0-ci.7977"], "3.1.0-ci.7000", UpdatePolicyKind.Continuous, pattern: "*-ci*"); - - Assert.Equal(["3.0.0-ci.7977"], selection.Candidates); - Assert.False(selection.IsRecovery); - } - - // ───────── the SemVer notation (policy `platform-semver-versioning`) ───────── - // - // Doc/Architecture/PlatformVersioning: continuous builds move from `3.0.0-ci.` to the plain - // SemVer `3..`, the patch being the same monotonic CD run number. The registry on - // cut-over day holds BOTH notations, the withdrawn 3.1.0-ci slip, the moving pointers, per-RID - // images and an unverified edge build of the new notation. - - private static readonly ImmutableArray CutOverRegistry = - [ - "3.0.0-ci.9998", "3.0.0-ci.9999", "3.1.0-ci.7841", "3.1.10000", "3.1.10001", - "3.1.10001-linux-x64", "3.1.10002-edge.10002", "3-latest", "3.1-latest", "3.0.0-latest", "main", - ]; - - /// 🚨 A Continuous install on the old notation crosses to the new one under a - /// MAJOR pattern, newest run first — and never onto the slip, the edge build, a pointer or a - /// per-RID image. - [Fact] - public void AMajorPattern_CrossesFromTheOldNotationToTheNew() - { - var selection = VersionSelect.SelectCandidates( - CutOverRegistry, "3.0.0-ci.9999", UpdatePolicyKind.Continuous, pattern: "3.*"); - - Assert.Equal(["3.1.10001", "3.1.10000"], selection.Candidates); - Assert.False(selection.IsRecovery); - - var onTheNewNotation = VersionSelect.SelectCandidates( - CutOverRegistry, "3.1.10000", UpdatePolicyKind.Continuous, pattern: "3.*"); - Assert.Equal(["3.1.10001"], onTheNewNotation.Candidates); - Assert.Equal(VersionSelect.InstalledTagResolution.Resolved, onTheNewNotation.Installed.Resolution); - } - - /// - /// 🚨 NEGATIVE CONTROL: the pattern every fleet record carries today, 3.0.0-ci*, does NOT - /// admit the new notation — which is why the records must be widened to 3.* BEFORE core - /// mints its first 3.1.<run>, or every Continuous install freezes on its last - /// 3.0.0-ci set with nothing to say so but "no newer release". - /// - [Fact] - public void TheOldFleetPattern_NeverSelectsTheNewNotation() - { - var selection = VersionSelect.SelectCandidates( - CutOverRegistry, "3.0.0-ci.9999", UpdatePolicyKind.Continuous, pattern: "3.0.0-ci*"); - - Assert.Empty(selection.Candidates); - Assert.False(selection.IsRecovery); - } - - /// Widening to 3.* is safe BEFORE the first new-notation build exists: the - /// withdrawn slip 3.1.0-ci.7841 matches the glob but is ranked by its run number, so it is - /// never newer than the running set. - [Fact] - public void WideningThePattern_BeforeTheCutOver_ChangesNothing() - { - string[] beforeTheFlip = ["3.0.0-ci.9998", "3.0.0-ci.9999", "3.1.0-ci.7841", "3-latest", "3.0.0-latest"]; - - Assert.Empty(VersionSelect.SelectCandidates( - beforeTheFlip, "3.0.0-ci.9999", UpdatePolicyKind.Continuous, pattern: "3.*").Candidates); - Assert.Equal(["3.0.0-ci.9999"], VersionSelect.SelectCandidates( - beforeTheFlip, "3.0.0-ci.9998", UpdatePolicyKind.Continuous, pattern: "3.*").Candidates); - } - - /// 🚨 A new-notation build carries no pre-release label, yet it is a CONTINUOUS - /// publication — Stable must not start following main the day the notation flips. - [Fact] - public void Stable_NeverSelectsANewNotationBuild() - { - Assert.Null(VersionSelect.PickTarget(CutOverRegistry, UpdatePolicyKind.Stable)); - Assert.Equal("3.0.0", - VersionSelect.PickTarget([.. CutOverRegistry, "3.0.0"], UpdatePolicyKind.Stable)); - } - - /// The unverified edge build of the new notation is excluded by the green-only gate - /// and eligible only when an install opts out of it. - [Fact] - public void TheNewNotationsEdgeBuild_IsGatedLikeTheOld() - { - Assert.Equal("3.1.10001", - VersionSelect.PickTarget(CutOverRegistry, UpdatePolicyKind.Continuous, pattern: "3.*")); - Assert.Equal("3.1.10002-edge.10002", - VersionSelect.PickTarget(CutOverRegistry, UpdatePolicyKind.Continuous, requireCiGreen: false, pattern: "3.*")); - } -} diff --git a/test/Memex.Portal.Shared.Test/ViewerZoneTimestampTest.cs b/test/Memex.Portal.Shared.Test/ViewerZoneTimestampTest.cs deleted file mode 100644 index 3c080bb199..0000000000 --- a/test/Memex.Portal.Shared.Test/ViewerZoneTimestampTest.cs +++ /dev/null @@ -1,98 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Text.Json; -using Memex.Portal.Shared.Settings; -using Memex.Portal.Shared.SelfUpdate; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Instants shown to a viewer must be rendered in the VIEWER's zone. -/// -/// These are stored as UTC and were formatted as UTC, so the displayed value was simply the -/// wrong time for anyone not on UTC — and, because the fields are date-only, wrong by a whole DAY -/// either side of midnight rather than by hours (#2302). On Blazor Server the ambient culture is -/// the container's and identical for every simultaneous viewer, so this cannot be left to it. -/// -/// The probe is 23:30Z: in Zurich (UTC+2 in August) that is the NEXT day, which is the -/// failure a UTC-formatted date-only field hides. -/// -public class ViewerZoneTimestampTest -{ - private static string Echo(string key, object?[] args) => key + ":" + string.Join(",", args); - - [Fact] - public void UpdatePolicy_checked_at_renders_in_the_viewer_zone_not_utc() - { - var content = new UpdatePolicyContent - { - LatestAvailableTag = "3.0.0-rc8.ci.9999", - CheckedAt = new DateTimeOffset(2026, 8, 28, 23, 30, 0, TimeSpan.Zero), - }; - - var utc = UpdatePolicySettingsTab.StatusMarkdown(content, Echo); - var zurich = UpdatePolicySettingsTab.StatusMarkdown(content, Echo, "Europe/Zurich"); - - Assert.Contains("2026-08-28 23:30", utc, StringComparison.Ordinal); - // +02:00 in August — the same instant, the NEXT day for the viewer. - Assert.Contains("2026-08-29 01:30", zurich, StringComparison.Ordinal); - Assert.NotEqual(utc, zurich); - } - - /// A null zone must stay UTC — the documented fallback, and what every existing caller - /// and test relies on. - [Fact] - public void A_null_zone_still_renders_utc() - { - var content = new UpdatePolicyContent - { - LatestAvailableTag = "t", - CheckedAt = new DateTimeOffset(2026, 8, 28, 23, 30, 0, TimeSpan.Zero), - }; - Assert.Equal( - UpdatePolicySettingsTab.StatusMarkdown(content, Echo), - UpdatePolicySettingsTab.StatusMarkdown(content, Echo, null)); - } - - /// - /// 🚨 A converted timestamp must not sit next to a label that still says "UTC". Caught in - /// review: converting the value while `ui.updateCheckedAt` remained "_(checked {0} UTC)_" - /// renders a LOCAL wall-clock time asserting it is UTC — strictly worse than the unconverted - /// version, which was at least honest. The three keys dropped the suffix; this stops it - /// coming back. - /// - /// The time-zone SETTING description still mentions UTC on purpose ("stored times stay - /// in UTC, only the display converts"), which is why this pins the three label keys by name - /// rather than banning the word. - /// - [Theory] - [InlineData("ui.updateCheckedAt")] - [InlineData("ui.updateHeldAt")] - [InlineData("ui.updateVerifiedAtLine")] - public void Timestamp_labels_do_not_claim_UTC(string key) - { - foreach (var lang in new[] { "en", "de" }) - { - var path = Path.Combine( - RepoRoot(), "src", "MeshWeaver.Messaging.Hub", "Localization", $"strings.{lang}.json"); - using var doc = JsonDocument.Parse(File.ReadAllText(path)); - Assert.True(doc.RootElement.TryGetProperty(key, out var v), $"{key} missing from {lang}"); - Assert.False( - v.GetString()!.Contains("UTC", StringComparison.Ordinal), - $"strings.{lang}.json[{key}] still claims UTC while the value is rendered in the " - + "viewer's zone — the label would assert a timezone the number is not in."); - } - } - - private static string RepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeshWeaver.slnx"))) - dir = dir.Parent; - Assert.NotNull(dir); - return dir!.FullName; - } -} diff --git a/test/Memex.Portal.Shared.Test/VirtualUserMiddlewareAuthContextTest.cs b/test/Memex.Portal.Shared.Test/VirtualUserMiddlewareAuthContextTest.cs deleted file mode 100644 index 1509bde16b..0000000000 --- a/test/Memex.Portal.Shared.Test/VirtualUserMiddlewareAuthContextTest.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Security.Claims; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// Pins the auth-context guards in : VUser -/// provisioning must only fire for genuinely-anonymous requests. Logged-in -/// users (either via HttpContext.User.Identity.IsAuthenticated == true -/// or via a real-user AccessContext already set by -/// UserContextMiddleware) skip the whole block. -/// -/// Why these tests matter: the prod 2026-05-24 thread-page crash -/// (No handler found for message type CreateNodeRequest in -/// portal/anonymous) was caused by a legitimately-authed user whose -/// HttpContext.User happened to be unauthenticated for that one -/// pipeline pass (Bearer-token resolution happened later inside -/// UserContextMiddleware). VirtualUserMiddleware then provisioned a guest -/// VUser node and the CreateNodeRequest blew up. The fix is two-layered: -/// (1) skip VUser when AccessService.Context already carries a real user, -/// (2) target the mesh hub (not the portal hub) for the create-node post. -/// These tests pin (1); the integration test in MeshWeaver.Hosting.Blazor.Test -/// pins (2). -/// -public class VirtualUserMiddlewareAuthContextTest -{ - [Fact] - public async Task AuthenticatedUserViaHttpContext_SkipsVUserBlock_AndCallsNext() - { - var nextCalled = false; - RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; - - var middleware = new VirtualUserMiddleware(next, NullLogger.Instance); - - var context = new DefaultHttpContext - { - Request = { Path = "/Systemorph/_Thread/foo" }, - User = new ClaimsPrincipal(new ClaimsIdentity("TestAuth")) - }; - - // No RequestServices — if VirtualUserMiddleware tried to resolve - // PortalApplication it would NRE. The IsAuthenticated guard must - // short-circuit before that point. - await middleware.InvokeAsync(context); - - nextCalled.Should().BeTrue( - "an authenticated HttpContext.User must skip the VUser block entirely " + - "and pass through to the next middleware"); - } - - [Fact] - public async Task UnauthenticatedHttpContext_EntersVUserBlock_ThrowsOnMissingPortalApplication() - { - RequestDelegate next = _ => Task.CompletedTask; - - var middleware = new VirtualUserMiddleware(next, NullLogger.Instance); - - var context = new DefaultHttpContext - { - Request = { Path = "/Systemorph/_Thread/foo" } - // No User — IsAuthenticated == false; falls into VUser provisioning. - }; - - // With no PortalApplication registered, the VUser path NREs / DI-throws. - // Asserting the throw proves we DID enter the block (vs. the guard above - // short-circuiting). The integration test in MeshWeaver.Hosting.Blazor.Test - // covers the happy path with a real DI scope. - Func act = () => middleware.InvokeAsync(context); - await act.Should().ThrowAsync( - "unauthenticated requests must enter the VUser provisioning block"); - } -} diff --git a/test/Memex.Portal.Shared.Test/VirtualUserMiddlewareExclusionTest.cs b/test/Memex.Portal.Shared.Test/VirtualUserMiddlewareExclusionTest.cs deleted file mode 100644 index 566d349388..0000000000 --- a/test/Memex.Portal.Shared.Test/VirtualUserMiddlewareExclusionTest.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using System.Threading.Tasks; -using Memex.Portal.Shared.Authentication; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -public class VirtualUserMiddlewareExclusionTest -{ - [Theory] - [InlineData("/_framework/blazor.web.js")] - [InlineData("/_content/MeshWeaver.Blazor/css/app.css")] - [InlineData("/_blazor/negotiate")] - [InlineData("/static/images/logo.png")] - [InlineData("/favicon.ico")] - [InlineData("/mcp")] - // The anonymous build-identity endpoint: polled by machines that keep no cookie, so the VUser - // flow could only churn — and it must stay answerable when the mesh is unhealthy. - [InlineData("/api/version")] - // Browser-sent Content-Security-Policy violation reports: no identity, possibly no cookie. - [InlineData("/api/csp-report")] - public async Task ExcludedPrefixes_SkipVirtualUserAssignment(string path) - { - var nextCalled = false; - RequestDelegate next = _ => - { - nextCalled = true; - return Task.CompletedTask; - }; - - var middleware = new VirtualUserMiddleware(next, NullLogger.Instance); - - var context = new DefaultHttpContext(); - context.Request.Path = path; - - await middleware.InvokeAsync(context); - - nextCalled.Should().BeTrue(because: $"'{path}' should be excluded and pass through to next"); - } - - [Theory] - [InlineData("/ACME/Overview")] - [InlineData("/User/Alice")] - [InlineData("/")] - // /api/version is excluded EXACTLY, so a route that merely starts with the same text is not. - // A prefix match here would silently exempt future routes nobody meant to exempt. - [InlineData("/api/versioning")] - [InlineData("/api/version/history")] - public async Task NonExcludedPaths_AttemptVirtualUserAssignment(string path) - { - RequestDelegate next = _ => Task.CompletedTask; - - var middleware = new VirtualUserMiddleware(next, NullLogger.Instance); - - var context = new DefaultHttpContext(); - context.Request.Path = path; - - // Non-excluded paths will attempt to resolve PortalApplication from DI. - // Since RequestServices isn't set up, this throws. - var act = () => middleware.InvokeAsync(context); - await act.Should().ThrowAsync( - because: "non-excluded paths should attempt virtual user assignment via PortalApplication"); - } - - [Theory] - [InlineData("/_FRAMEWORK/blazor.web.js")] - [InlineData("/STATIC/image.png")] - [InlineData("/FAVICON.ICO")] - public async Task ExcludedPrefixes_AreCaseInsensitive(string path) - { - var nextCalled = false; - RequestDelegate next = _ => - { - nextCalled = true; - return Task.CompletedTask; - }; - - var middleware = new VirtualUserMiddleware(next, NullLogger.Instance); - - var context = new DefaultHttpContext(); - context.Request.Path = path; - - await middleware.InvokeAsync(context); - - nextCalled.Should().BeTrue(because: "exclusion should be case-insensitive"); - } - - [Theory] - [InlineData("/mcp")] - [InlineData("/mcp/tools")] - [InlineData("/api/mcp")] - [InlineData("/api/mcp/tools")] - public async Task McpPath_StillExcluded(string path) - { - var nextCalled = false; - RequestDelegate next = _ => - { - nextCalled = true; - return Task.CompletedTask; - }; - - var middleware = new VirtualUserMiddleware(next, NullLogger.Instance); - - var context = new DefaultHttpContext(); - context.Request.Path = path; - - await middleware.InvokeAsync(context); - - nextCalled.Should().BeTrue(because: "MCP paths should remain excluded after refactor"); - } -} diff --git a/test/Memex.Portal.Shared.Test/WebhookBodyFailureStatusTest.cs b/test/Memex.Portal.Shared.Test/WebhookBodyFailureStatusTest.cs deleted file mode 100644 index 738e6f6c9f..0000000000 --- a/test/Memex.Portal.Shared.Test/WebhookBodyFailureStatusTest.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using Memex.Portal.Shared.Social; -using MeshWeaver.GitSync; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 What the ENDPOINTS answer when the body read fails — which is the property -/// deliberately cannot reach (#4860). -/// -/// Those tests pin the reader: a client that vanishes mid-body must surface an exception -/// rather than the null that means over-the-cap. Necessary, and not sufficient — an endpoint -/// regressing to an unhandled 500, or answering a fixed status, would leave every one of them -/// green. Caught in review on this PR, which is exactly the shape of a suite that cannot fail for -/// the thing it was written for. -/// -/// Two statuses, and they must not collapse into one. -/// is raised by Kestrel for BOTH "Unexpected end of request -/// content" (400) and a MaxRequestBodySize breach (413). Answering a fixed 400 would report -/// a server-limit breach as a bad request — and these endpoints already answer 413 for their OWN -/// cap, so the same oversized delivery would report two different statuses depending only on which -/// limit noticed it first. -/// -public class WebhookBodyFailureStatusTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - [Theory] - [InlineData(StatusCodes.Status400BadRequest)] // the truncated delivery this issue was filed on - [InlineData(StatusCodes.Status413PayloadTooLarge)] // Kestrel's own body-size refusal - public async Task TheGitHubEndpoint_AnswersTheBodyReadsOwnStatus(int raised) - { - await using var app = await StartHost(a => a.MapGitHubWebhook(), raised); - var response = await Post(app, "/webhooks/github"); - - Assert.Equal((HttpStatusCode)raised, response.StatusCode); - } - - [Theory] - [InlineData(StatusCodes.Status400BadRequest)] - [InlineData(StatusCodes.Status413PayloadTooLarge)] - public async Task TheWebhookInbox_AnswersTheBodyReadsOwnStatus(int raised) - { - // The OTHER call site. The incident only ever named the GitHub endpoint, but both read - // through the same BoundedBody, so a fix proved on one says nothing about the other. - await using var app = await StartHost(a => a.MapWebhookInbox(), raised); - var response = await Post(app, "/api/hooks/anything"); - - Assert.Equal((HttpStatusCode)raised, response.StatusCode); - } - - /// - /// A real host over the REAL mesh (no doubles), with one piece of middleware that swaps the - /// request body for a stream failing the way Kestrel does. That is what makes the endpoint's own - /// catch run — reaching it from outside would need a real socket to be severed mid-request. - /// - private async Task StartHost(Action map, int raised) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - // Without a secret the GitHub endpoint answers 503 BEFORE the body is read, and the - // test would pass having exercised nothing. - ["GitHub:Webhook:Secret"] = "test-secret", - }); - builder.Services.AddSingleton(Mesh); - builder.Services.AddSingleton(Mesh.ServiceProvider.GetRequiredService()); - builder.Services.AddSingleton(); - - var app = builder.Build(); - app.Use(async (context, next) => - { - context.Request.Body = new FailingBody(raised); - await next(context); - }); - map(app); - await app.StartAsync(TestContext.Current.CancellationToken); - return app; - } - - private static Task Post(WebApplication app, string route) => - app.GetTestClient().PostAsync( - route, new StringContent(""), TestContext.Current.CancellationToken); - - /// Fails on the first read with the status Kestrel would carry. - private sealed class FailingBody(int statusCode) : Stream - { - public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) - => throw new BadHttpRequestException("Unexpected end of request content.", statusCode); - - public override int Read(byte[] buffer, int offset, int count) - => throw new BadHttpRequestException("Unexpected end of request content.", statusCode); - - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => throw new NotSupportedException(); - public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } - public override void Flush() { } - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - } -} diff --git a/test/Memex.Portal.Shared.Test/WebhookInboxAnswersOnCommitTest.cs b/test/Memex.Portal.Shared.Test/WebhookInboxAnswersOnCommitTest.cs deleted file mode 100644 index ce7b90d0f5..0000000000 --- a/test/Memex.Portal.Shared.Test/WebhookInboxAnswersOnCommitTest.cs +++ /dev/null @@ -1,263 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Reactive; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A webhook delivery is answered as soon as it is DURABLY STORED, not once the create's -/// reply has made its way back (MeshWeaver#6039). -/// -/// GitHub recorded a delivery as "no response within 10 s" although the portal had logged -/// Node created at Hosting/PlatformBuilds/_Inbox/… 200 ms after it was sent. The endpoint -/// answered on the CreateNodeResponse. That reply is posted after the post-creation -/// handlers and leaves through the node-operation hub's own action block, so anything holding -/// either one held the HTTP answer for a row that was already committed. -/// -/// The repro holds the create's reply after the commit, deterministically: a post-creation -/// handler for WebhookEvent that does not complete until the test releases it in a -/// finally. The sender's own limit is the bound, GitHub's 10 s, set as the client's -/// timeout. The create's verdict deadline is 30 s, so an endpoint that waits for the reply cannot -/// answer inside it. -/// -/// Negative control. With WebhookInbox's store going back to -/// mesh.CreateNode(node).Take(1), -/// goes red: the client gives up after 10 s with the handler still held, which is the incident. -/// -public class WebhookInboxAnswersOnCommitTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Target = "Hosting/HeldInbox"; - private const string Route = "/api/hooks/" + Target; - - /// GitHub's delivery timeout: a sender that waits longer records no response. - private static readonly TimeSpan SenderTimeout = TimeSpan.FromSeconds(10); - - private readonly HeldWebhookHandler handler = new(); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => base.ConfigureMesh(builder) - .AddWebhookInbox() - .ConfigureServices(services => services.AddSingleton(handler)); - - private async Task StartHost(CancellationToken ct) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [$"{WebhookInbox.TargetsConfigSection}:0"] = Target, - }); - builder.Services.AddSingleton(Mesh); - var app = builder.Build(); - app.MapWebhookInbox(); - await app.StartAsync(ct); - return app; - } - - private async Task SeedTarget(CancellationToken ct) - { - var access = Mesh.ServiceProvider.GetRequiredService(); - // RunAsSystem, never Observable.Using(ImpersonateAsSystem, …) (#1790). - await access.RunAsSystem( - () => Mesh.ServiceProvider.GetRequiredService().CreateOrUpdateNode( - new MeshNode(Target) - { - Name = Target, - NodeType = "Markdown", - Content = new MarkdownContent { Content = "# Inbox target\n" }, - })) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - } - - private static HttpClient SenderClient(WebApplication app) - { - var client = app.GetTestClient(); - client.Timeout = SenderTimeout; - return client; - } - - /// - /// THE CHANGE: the row is committed and its reply is held. The sender still gets its 200 - /// inside its own limit, while the handler is provably still running. - /// - [Fact(Timeout = 300_000)] - public async Task ADeliveryWhoseReplyIsHeld_IsAnsweredOnTheCommit() - { - var ct = TestContext.Current.CancellationToken; - await SeedTarget(ct); - await using var app = await StartHost(ct); - using var client = SenderClient(app); - - handler.Holding = true; - try - { - using var response = await client.PostAsync(Route, - new StringContent("{\"action\":\"edited\"}", Encoding.UTF8, "application/json"), ct); - - response.StatusCode.Should().Be(HttpStatusCode.OK, - "the delivery was durably stored, which is all the sender is owed"); - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); - json.RootElement.GetProperty("status").GetString().Should().Be("accepted"); - handler.StillHeld.Should().BeTrue( - "the answer must not have waited for the create's reply: the handler that holds the " - + "reply had not been released when the sender was answered"); - await handler.EnteredSignal.Should().Within(TestTimeouts.Quick).Emit( - "the post-creation handler runs only after the commit, so it being entered proves " - + "the row was written"); - } - finally - { - handler.Release(); - } - } - - /// - /// THE CONTROL: nothing is held, and the answer is the same 200. A route that answered without - /// storing anything would never enter the handler, which the case above asserts. - /// - [Fact(Timeout = 300_000)] - public async Task ADeliveryWithNothingHeld_IsAccepted() - { - var ct = TestContext.Current.CancellationToken; - await SeedTarget(ct); - await using var app = await StartHost(ct); - using var client = SenderClient(app); - - handler.Holding = false; - using var response = await client.PostAsync(Route, - new StringContent("{\"action\":\"opened\"}", Encoding.UTF8, "application/json"), ct); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - await handler.EnteredSignal.Should().Within(TestTimeouts.Quick).Emit( - "the delivery was stored, so its post-creation step ran"); - } - - /// - /// THE LIFETIME: once the commit has answered, nothing waits for the reply any more. The - /// create's reply is held, so the only thing that can end the reply subscription is the answer - /// itself. Without that, every delivery whose reply is never routed back would leave one - /// pending callback on the issuing hub for as long as the process lives. - /// - /// Negative control. With AnswerOnCommit keeping the reply subscription - /// after the commit's answer, the disposal signal never fires while the handler is held, and - /// this case goes red. - /// - [Fact(Timeout = 300_000)] - public async Task AnAnswerOnTheCommit_DropsTheReplySubscription() - { - var ct = TestContext.Current.CancellationToken; - await SeedTarget(ct); - var mesh = Mesh.ServiceProvider.GetRequiredService(); - var feed = Mesh.ServiceProvider.GetRequiredService(); - var access = Mesh.ServiceProvider.GetRequiredService(); - var id = Guid.NewGuid().ToString("N"); - var node = new MeshNode(id, $"{Target}/{WebhookInbox.InboxContainer}") - { - Name = $"Webhook {id}", - NodeType = WebhookInbox.NodeType, - MainNode = Target, - Content = new WebhookEvent - { - ReceivedAt = DateTimeOffset.UtcNow, - ContentType = "application/json", - Body = "{\"action\":\"closed\"}", - }, - }; - - var replyDropped = new AsyncSubject(); - handler.Holding = true; - try - { - var path = await access.RunAsSystem(() => - { - var create = mesh.CreateNode(node); - var tracked = Observable.Create(o => - { - var inner = create.Subscribe(o); - return Disposable.Create(() => - { - inner.Dispose(); - replyDropped.OnNext(Unit.Default); - replyDropped.OnCompleted(); - }); - }); - return tracked.AnswerOnCommit(feed, node.Path); - }) - .Timeout(TestTimeouts.Convergence).Await(ct); - - path.Should().Be(node.Path, "the answer names the committed path on either signal"); - handler.StillHeld.Should().BeTrue("the reply is held, so the commit gave the answer"); - await replyDropped.Should().Within(TestTimeouts.Quick).Emit( - "an answer on the commit ends the caller's wait for the reply; a subscription kept " - + "past it is a callback that stays registered until a reply that may never come"); - } - finally - { - handler.Release(); - } - } - - /// - /// A post-creation handler for WebhookEvent that, while , does not - /// complete until : the reply of the create is held after its commit. The - /// release is an the test completes, never a blocked thread. - /// - private sealed class HeldWebhookHandler : INodePostCreationHandler - { - private readonly AsyncSubject release = new(); - private readonly AsyncSubject enteredSignal = new(); - private int holding; - - public string NodeType => WebhookInbox.NodeType; - - public bool Holding - { - get => Volatile.Read(ref holding) == 1; - set => Interlocked.Exchange(ref holding, value ? 1 : 0); - } - - /// Completes when a WebhookEvent's post-creation step was entered. - public IObservable EnteredSignal => enteredSignal.AsObservable(); - - public bool StillHeld => !release.IsCompleted; - - public void Release() - { - release.OnNext(Unit.Default); - release.OnCompleted(); - } - - public IObservable Handle(MeshNode createdNode, string? createdBy) - { - enteredSignal.OnNext(Unit.Default); - enteredSignal.OnCompleted(); - return Holding ? release.AsObservable() : Observable.Return(Unit.Default); - } - } -} diff --git a/test/Memex.Portal.Shared.Test/WebhookInboxStalledReadTest.cs b/test/Memex.Portal.Shared.Test/WebhookInboxStalledReadTest.cs deleted file mode 100644 index d95c933175..0000000000 --- a/test/Memex.Portal.Shared.Test/WebhookInboxStalledReadTest.cs +++ /dev/null @@ -1,244 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Reactive.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Memex.Portal.Shared.Api; -using MeshWeaver.Fixture; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using MeshWeaver.PluginCatalog; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// 🚨 A webhook delivery whose target-existence read STALLS answers 503 + Retry-After — -/// never an unhandled 500 (MeshWeaver#6125). -/// -/// WebhookInbox.Deliver confirms the allowlisted target exists with a -/// path:{target} query before storing. When a query provider never delivers its Initial, -/// the fan-in terminates that read with (policy -/// query-fanin-stall-terminal) — retryable by its own statement. The route let it escape, so -/// ASP.NET answered a bare 500 and logged an unhandled exception of -/// WebhookInboxEndpoints.Deliver (measured on memex 2026-10-04 19:25:46Z and 2026-10-05 -/// 07:44:45Z, query path:Hosting/PlatformBuilds). The sibling plugin-bundle routes already -/// answer this 503 (#5345); this pins the inbox to the same convention. -/// -/// Same harness as PluginBundleStalledReadTest: a test -/// — the extension point every backend plugs into, not a mock of a core service — that claims every -/// query, answers each with an empty Initial, and leaves ONLY the target-existence read unanswered -/// (or faults it) while armed. The stall fires at the fan-in's own production rung. -/// -/// Negative control. With the endpoint's UnavailableOnAStalledRead line removed, -/// goes red: the exception escapes and the -/// TestServer rethrows it / answers 500. -/// -public class WebhookInboxStalledReadTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Target = "Hosting/StalledInbox"; - private const string Route = "/api/hooks/" + Target; - private const string DefectMessage = "target read defect (test)"; - - private readonly StallingTargetProvider provider = new($"path:{Target}"); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) => base.ConfigureMesh(builder) - .AddWebhookInbox() - .ConfigureServices(services => services.AddSingleton(provider)); - - private async Task StartHost(CancellationToken ct) - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - builder.Logging.ClearProviders(); - builder.Configuration.AddInMemoryCollection(new Dictionary - { - [$"{WebhookInbox.TargetsConfigSection}:0"] = Target, - }); - builder.Services.AddSingleton(Mesh); - var app = builder.Build(); - app.MapWebhookInbox(); - await app.StartAsync(ct); - return app; - } - - private static Task Post(WebApplication app, CancellationToken ct) => - app.GetTestClient().PostAsync(Route, - new StringContent("{\"action\":\"edited\"}", Encoding.UTF8, "application/json"), ct); - - /// - /// THE CHANGE: the existence read stalls, and the wire says "ask again later" with the shared - /// retry schedule. Unfixed, the exception escapes the handler. - /// - [Fact(Timeout = 300_000)] - public async Task AStalledTargetRead_Answers503WithRetryAfter() - { - var ct = TestContext.Current.CancellationToken; - await using var app = await StartHost(ct); - - provider.Stalling = true; - using var response = await Post(app, ct); - - response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable, - "the target read produced no answer — an availability failure that says it is " - + "retryable — so the wire must say 'ask again later', not report an unhandled 500"); - response.Headers.RetryAfter.Should().NotBeNull("a 503 without Retry-After gives the sender no schedule"); - response.Headers.RetryAfter!.Delta.Should().Be( - TimeSpan.FromSeconds(InstanceRegistryAuthenticator.RetryAfterSeconds), - "the inbox uses the same retry convention as the plugin-bundle routes"); - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); - json.RootElement.GetProperty("error").GetString().Should().Be( - WebhookInboxEndpoints.StalledReadError, - "the body must name what THIS route could not read, not the registry's catalogue"); - provider.StalledReads.Should().BeGreaterThan(0, - "the stall must actually have been the target-existence read — otherwise this test " - + "measured something else"); - } - - /// - /// THE CONTROL: the same host, the provider answering, the target present — 200 accepted. A route - /// that answered 503 unconditionally would pass the case above and fail this one. - /// - [Fact(Timeout = 300_000)] - public async Task AnAnsweredTargetRead_StillAccepts() - { - var ct = TestContext.Current.CancellationToken; - var access = Mesh.ServiceProvider.GetRequiredService(); - // RunAsSystem, never Observable.Using(ImpersonateAsSystem, …) (#1790): Using opens the - // AsyncLocal scope on the subscribing thread and disposes it wherever the create's reply - // lands, which can leave this test's thread latched as System. - await access.RunAsSystem( - () => Mesh.ServiceProvider.GetRequiredService().CreateOrUpdateNode( - new MeshNode(Target) - { - Name = Target, - NodeType = "Markdown", - Content = new MarkdownContent { Content = "# Inbox target\n" }, - })) - .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); - await using var app = await StartHost(ct); - - provider.Stalling = false; - using var response = await Post(app, ct); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); - json.RootElement.GetProperty("status").GetString().Should().Be("accepted"); - } - - /// - /// THE OTHER SIDE of the classification: an ordinary DEFECT on the same read still escapes, so a - /// real defect stays visible and only an availability fault becomes "retry later". - /// - [Fact(Timeout = 300_000)] - public async Task AnOrdinaryFault_OnTheTargetRead_IsNotConvertedTo503() - { - var ct = TestContext.Current.CancellationToken; - await using var app = await StartHost(ct); - - provider.Faulting = true; - HttpStatusCode? status = null; - Exception? escaped = null; - try - { - using var response = await Post(app, ct); - status = response.StatusCode; - } - catch (Exception ex) - { - // TestServer rethrows an unhandled pipeline exception to the client — that IS the escape. - escaped = ex; - } - - (escaped is not null || status == HttpStatusCode.InternalServerError).Should().BeTrue( - $"a defect must still escape as one (status {status?.ToString() ?? "none"}, " - + $"exception {escaped?.GetType().Name ?? "none"})"); - status.Should().NotBe(HttpStatusCode.ServiceUnavailable, - "only an availability fault may be answered 'retry later'"); - if (escaped is not null) - escaped.ToString().Should().Contain(DefectMessage, - "the exception that escaped must be the target read's own defect, not something else"); - } - - /// - /// Claims every query and answers each with an empty Initial — except the target-existence query - /// while armed, which it leaves unanswered (: no Initial, no completion, no - /// error — the shape the fan-in's stall terminal exists for) or faults with an ordinary defect - /// (). - /// - private sealed class StallingTargetProvider(string targetQuery) : IMeshQueryProvider - { - private int stalling; - private int faulting; - private int stalledReads; - - public string Name => nameof(StallingTargetProvider); - - public bool Stalling - { - get => Volatile.Read(ref stalling) == 1; - set => Interlocked.Exchange(ref stalling, value ? 1 : 0); - } - - public bool Faulting - { - get => Volatile.Read(ref faulting) == 1; - set => Interlocked.Exchange(ref faulting, value ? 1 : 0); - } - - /// How many target reads were left unanswered. - public int StalledReads => Volatile.Read(ref stalledReads); - - public bool Matches(IReadOnlyList queryNamespaces) => true; - - public IObservable> Query( - MeshQueryRequest request, JsonSerializerOptions options) => - Observable.Defer(() => - { - var isTargetRead = request.EffectiveQueries.Any(q => - string.Equals(q.Trim(), targetQuery, StringComparison.Ordinal)); - if (isTargetRead && Faulting) - return Observable.Throw>( - new InvalidOperationException(DefectMessage)); - if (!isTargetRead || !Stalling) - return Observable.Return(new QueryResultChange - { - ChangeType = QueryChangeType.Initial, - Items = Array.Empty(), - Timestamp = DateTimeOffset.UtcNow, - }); - Interlocked.Increment(ref stalledReads); - return Observable.Never>(); - }); - - public IObservable> Query( - MeshQueryRequest request, JsonSerializerOptions options) - => Observable.Return((IReadOnlyCollection)Array.Empty()); - - public IObservable> Autocomplete( - string basePath, string prefix, JsonSerializerOptions options, - AutocompleteMode mode = AutocompleteMode.RelevanceFirst, int limit = 10, - string? contextPath = null, string? context = null) - => Observable.Return((IReadOnlyCollection)Array.Empty()); - - public IObservable Select(string path, string property, JsonSerializerOptions options) - => Observable.Return(default); - } -} diff --git a/test/Memex.Portal.Shared.Test/WhatsNewSatelliteEntryTest.cs b/test/Memex.Portal.Shared.Test/WhatsNewSatelliteEntryTest.cs deleted file mode 100644 index d24b6bc0c4..0000000000 --- a/test/Memex.Portal.Shared.Test/WhatsNewSatelliteEntryTest.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using MeshWeaver.Fixture; - -namespace Memex.Portal.Shared.Test; - -/// -/// A release note authored OUTSIDE this repository must reach the one What's New feed (#2539). -/// -/// The feed used to be a single path:Doc/WhatsNew scope:children listing, and that -/// path exists only here — so a satellite repo (Plugins, Education, Reinsurance, SocialMedia, -/// Memex) had no route to file an entry from its own PR, and a user-noticeable fix landing there -/// was simply missing from the changelog. The failure is silent and it COMPOUNDS: every extraction -/// moves more user-visible behaviour out of core, so the feed drifts toward being a platform-only -/// changelog while reading as a complete one. -/// -/// The contract a satellite writes against is exactly what this test performs: a node -/// carrying nodeType: WhatsNew in its front matter, living anywhere in its own tree. -/// -public class WhatsNewSatelliteEntryTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - [Fact] - public async Task An_entry_outside_Doc_reaches_the_feed() - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var ns = $"SatelliteRepo{Guid.NewGuid():N}"[..20]; - - await meshService.CreateNode(new MeshNode("2026-08-28-a-satellite-fix", ns) - { - NodeType = WhatsNewSettingsTab.EntryNodeType, - Name = "A fix that shipped from a satellite repo", - State = MeshNodeState.Active, - }).Should().Within(30.Seconds()).Emit(); - - var listed = await Mesh - .GetQuery("whatsnew:test-listing", WhatsNewSettingsTab.ListingQueries) - .Where(nodes => nodes.Any(n => - string.Equals(n.NodeType, WhatsNewSettingsTab.EntryNodeType, StringComparison.Ordinal))) - .FirstAsync() - .Timeout(30.Seconds()) - .Await(); - - listed.Should().Contain( - n => n.Name == "A fix that shipped from a satellite repo", - "an entry declaring nodeType:WhatsNew must reach the feed from any namespace — " - + "otherwise a satellite repo can only skip the entry, and the changelog silently " - + "becomes platform-only as more code leaves core"); - } - - /// - /// The platform's own 612 entries live under Doc/WhatsNew and carry no explicit node - /// type. Their lane must survive: this is a UNION, deliberately, not a migration. - /// - [Fact] - public void The_platform_lane_is_still_declared() - { - WhatsNewSettingsTab.ListingQueries.Should().Contain( - $"path:{WhatsNewSettingsTab.WhatsNewNamespace} scope:children", - "the 612 existing entries carry no nodeType — dropping this lane would empty the feed"); - // The type lane is mesh-wide BY DESIGN (a satellite files entries in its own tree), so it - // DECLARES the fan-out — fan-out is opt-in since Plugins #1231, and an undeclared - // path-less read is refused at runtime (#3202). - WhatsNewSettingsTab.ListingQueries.Should().Contain( - MeshWideQuery.OfType(WhatsNewSettingsTab.EntryNodeType), - "the satellite lane is what #2539 adds, and it says out loud that it spans every partition"); - } -} diff --git a/test/Memex.Portal.Shared.Test/WhatsNewTabIsATemplateTest.cs b/test/Memex.Portal.Shared.Test/WhatsNewTabIsATemplateTest.cs deleted file mode 100644 index 339c6d9a6c..0000000000 --- a/test/Memex.Portal.Shared.Test/WhatsNewTabIsATemplateTest.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Memex.Portal.Shared.Settings; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Graph; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Client; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// The What's New tab, end to end through a node hub, is a TEMPLATE (Doc/GUI/DataBinding → -/// "Templates first, data later"): its feed is a whose text is a -/// POINTER into /data/whatsNewList, and the synced listing's rendering reaches that slot — -/// including an entry created while the tab is open. -/// -/// What it was: a deferred view, GetQuery(…).Select(nodes => Controls.Markdown(Render(nodes))), -/// that rendered nothing but a loading line until the listing answered, then rebuilt the control on -/// every emission. The "renders before its data" half is pinned for the helper itself in -/// MeshWeaver.Layout.Test/FedControlsAreTemplatesTest with a feed held silent; this test pins -/// that the tab is wired to it. -/// -/// Negative control, run before this file was committed: against the pre-conversion tab the -/// first wait fails — no frame carries a pointer-bound markdown. -/// -public class WhatsNewTabIsATemplateTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder).ConfigureDefaultNodeHub(config => config.AddPlatformSettingsTabAreas()); - - /// - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) - => base.ConfigureClient(configuration).AddLayoutClient(); - - [Fact] - public async Task TheFeedIsABoundMarkdown_AndFollowsTheListing() - { - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var ns = $"WhatsNewTpl{Guid.NewGuid():N}"[..20]; - await meshService.CreateNode(new MeshNode("Host", ns) - { - Name = "Host", NodeType = "Markdown", State = MeshNodeState.Active, - }).Should().Within(TestTimeouts.Convergence).Emit(); - - var stream = GetClient().GetWorkspace().GetRemoteStream( - new Address($"{ns}/Host"), new LayoutAreaReference(PlatformSettingsTabAreas.WhatsNewArea)); - - var feed = await stream.Select(change => change.Value) - .Select(frame => FindBoundMarkdown(GetClient().JsonSerializerOptions, frame)) - .Should().Within(TestTimeouts.Convergence).Match(m => m is not null, - "the tab declares its feed as a markdown control bound by pointer"); - ((JsonPointerReference)feed!.Markdown).Pointer.Should().Contain("whatsNewList"); - - var text = stream.DataBind(new JsonPointerReference(LayoutAreaReference.GetDataPointer("whatsNewList"))); - - await meshService.CreateNode(new MeshNode("2026-10-01-a-template-entry", ns) - { - NodeType = WhatsNewSettingsTab.EntryNodeType, - Name = "An entry created while the tab is open", - State = MeshNodeState.Active, - }).Should().Within(TestTimeouts.Convergence).Emit(); - - await text.Should().Within(TestTimeouts.Convergence).Match( - t => t.Contains("An entry created while the tab is open", StringComparison.Ordinal), - "the listing's rendering reaches the SAME bound slot, live"); - } - - private static MarkdownControl? FindBoundMarkdown(JsonSerializerOptions options, JsonElement frame) - { - if (!frame.TryGetProperty("areas", out var areas)) - return null; - foreach (var area in areas.EnumerateObject()) - if (area.Value.TryGetProperty("$type", out var t) && t.GetString() == nameof(MarkdownControl) - && area.Value.Deserialize(options) is MarkdownControl - { Markdown: JsonPointerReference } markdown) - return markdown; - return null; - } -} diff --git a/test/Memex.Portal.Shared.Test/WhoAmIAnswersAccessPerNodeTest.cs b/test/Memex.Portal.Shared.Test/WhoAmIAnswersAccessPerNodeTest.cs deleted file mode 100644 index 86918dea7d..0000000000 --- a/test/Memex.Portal.Shared.Test/WhoAmIAnswersAccessPerNodeTest.cs +++ /dev/null @@ -1,185 +0,0 @@ -#pragma warning disable CS1591 - -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.AI; // MeshOperations — its namespace is a frozen binary contract (#2370) -using MeshWeaver.Fixture; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Messaging; -using Memex.Portal.Shared.Api; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace Memex.Portal.Shared.Test; - -/// -/// MeshWeaver#5189: whoami answered who the caller is and never what they may do, so "am I -/// an administrator here?" could only be answered by opening an admin surface and reading its -/// refusal — which is indistinguishable from a stale session. Access is per node, so the answer is -/// asked AT an address () and computed by the evaluator the -/// gates themselves consult. -/// -/// Each case below discriminates: the same call answers differently for a Viewer, an Editor, -/// a platform admin and an anonymous caller, at a node inside and outside their grant — so a -/// WhoAmI that returned a constant, the caller's roles anywhere, or "admin ⇒ everything" fails -/// at least one of them. -/// -public class WhoAmIAnswersAccessPerNodeTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private const string Partition = "WhoAmIProbe"; - private const string DocPath = Partition + "/Doc"; - private const string Elsewhere = "WhoAmIElsewhere"; - - private const string ViewerUser = "whoami-viewer"; - private const string EditorUser = "whoami-editor"; - private const string PlatformAdminUser = "whoami-platform-admin"; - - private static TimeSpan Budget => TestTimeouts.Convergence; - - // 🚨 ConfigureMeshBase, not base.ConfigureMesh: the latter grants Public the Admin role in every - // default partition, under which every caller would hold everything and each case would pass - // vacuously. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => ConfigureMeshBase(builder) - .AddMeshNodes( - new MeshNode(Partition) { Name = "WhoAmI Probe", NodeType = "Markdown" }, - new MeshNode("Doc", Partition) { Name = "Doc", NodeType = "Markdown" }, - new MeshNode(Elsewhere) { Name = "Elsewhere", NodeType = "Markdown" }, - AssignmentNodeFactory.UserRole(ViewerUser, "Viewer", Partition), - AssignmentNodeFactory.UserRole(EditorUser, "Editor", Partition), - // The canonical platform-admin shape: Admin on the Admin partition, nothing else. - AssignmentNodeFactory.UserRole(PlatformAdminUser, "Admin", "Admin")); - - private IMessageHub SessionHub() => SessionHubFactory.Resolve( - Mesh, - "mcp", - "whoami", - Mesh.ServiceProvider.GetRequiredService().CreateLogger(nameof(WhoAmIAnswersAccessPerNodeTest))); - - // A caller with no identity is stamped as Anonymous — what UserContextMiddleware leaves on an - // unauthenticated request; clearing the context would fall back to the harness's circuit user. - private void ActAs(string? userId) => Mesh.ServiceProvider.GetRequiredService() - .SetContext(new AccessContext { ObjectId = userId ?? WellKnownUsers.Anonymous, Name = userId ?? "" }); - - private async Task WhoAmI(string? userId, string path) - { - ActAs(userId); - var json = await new MeshOperations(SessionHub()).WhoAmI(path) - .Timeout(Budget).Await(TestContext.Current.CancellationToken); - Assert.False(json.StartsWith("Error", StringComparison.Ordinal), json); - return JsonDocument.Parse(json).RootElement.Clone(); - } - - private static string[] Permissions(JsonElement answer) => - [.. answer.GetProperty("permissions").EnumerateArray().Select(p => p.GetString()!)]; - - [Fact] - public async Task AViewer_ReadsButCannotUpdate_InsideTheirGrant() - { - var answer = await WhoAmI(ViewerUser, DocPath); - - Assert.Equal(ViewerUser, answer.GetProperty("userId").GetString()); - Assert.Equal(DocPath, answer.GetProperty("path").GetString()); - Assert.Contains("Read", Permissions(answer)); - Assert.DoesNotContain("Update", Permissions(answer)); - Assert.False(answer.GetProperty("isGlobalAdmin").GetBoolean()); - } - - [Fact] - public async Task AnEditor_AtTheSameNode_IsToldTheyMayUpdate() - { - var answer = await WhoAmI(EditorUser, DocPath); - - Assert.Contains("Update", Permissions(answer)); - Assert.False(answer.GetProperty("isGlobalAdmin").GetBoolean()); - } - - /// The same Viewer, one partition over: the answer is per node, not per person. - [Fact] - public async Task TheViewer_OutsideTheirGrant_IsToldTheyMayNotRead() - => Assert.DoesNotContain("Read", Permissions(await WhoAmI(ViewerUser, Elsewhere))); - - /// - /// The issue's own case: a platform admin learns they ARE one — and, at a content node, that it - /// confers no read there (a global admin is not a data superuser, AccessControl.md). - /// - [Fact] - public async Task APlatformAdmin_IsToldSo_AndThatItOpensNoContent() - { - var atContent = await WhoAmI(PlatformAdminUser, DocPath); - - Assert.True(atContent.GetProperty("isGlobalAdmin").GetBoolean()); - Assert.DoesNotContain("Read", Permissions(atContent)); - - var atAdmin = await WhoAmI(PlatformAdminUser, "Admin"); - Assert.Contains("Update", Permissions(atAdmin)); - } - - [Fact] - public async Task ACallerWithNoIdentity_IsAnsweredAsAnonymous_WithNoIdentityEchoed() - { - var answer = await WhoAmI(null, DocPath); - - Assert.Equal(JsonValueKind.Null, answer.GetProperty("userId").ValueKind); - Assert.False(answer.GetProperty("isGlobalAdmin").GetBoolean()); - Assert.DoesNotContain("Update", Permissions(answer)); - } - - /// - /// The REST dispatch: only a body naming a node asks for the access answer. No body, `{}` and a - /// blank path — what portal-next's SSR, the React-Native app and the e2e send today — must - /// stay the identity-only answer, or every existing caller changes shape. - /// - [Theory] - [InlineData(null, false)] - [InlineData("", false)] - [InlineData(" ", false)] - [InlineData(" " + DocPath + " ", true)] - public void TheRestBody_AsksForAccessOnlyWhenItNamesANode(string? path, bool asks) - { - var body = path is null ? null : new MeshApiEndpoints.WhoAmIBody(path); - Assert.Equal(asks, MeshApiEndpoints.AsksForAccess(body, out var resolved)); - if (asks) - Assert.Equal(DocPath, resolved); - Assert.False(MeshApiEndpoints.AsksForAccess(null, out _), "an omitted body is the identity-only answer"); - } - - /// - /// And the real mapped route binds that body OPTIONALLY: an SSR caller posting no body at all - /// must not be refused with 400 by the binder before the handler runs. - /// - [Fact] - public async Task TheWhoAmIRoute_BindsItsBodyOptionally() - { - var builder = Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder(); - Microsoft.AspNetCore.TestHost.WebHostBuilderExtensions.UseTestServer(builder.WebHost); - // Parameter binding must see IMessageHub as a SERVICE; never resolved — nothing is invoked. - builder.Services.AddSingleton(_ => null!); - var app = builder.Build(); - await using (app) - { - Memex.Portal.Shared.Api.MeshApiEndpoints.MapMeshApi(app); - var endpoint = ((Microsoft.AspNetCore.Routing.IEndpointRouteBuilder)app).DataSources - .SelectMany(source => source.Endpoints) - .OfType() - .Single(e => e.RoutePattern.RawText == "/api/mesh/whoami"); - var accepts = endpoint.Metadata.GetMetadata(); - Assert.NotNull(accepts); - Assert.Equal(typeof(MeshApiEndpoints.WhoAmIBody), accepts!.RequestType); - Assert.True(accepts.IsOptional, "a missing body must reach the identity-only answer, not a 400"); - } - } - - [Fact] - public async Task NoAddress_IsRefused_BecauseAccessIsPerNode() - { - ActAs(ViewerUser); - var json = await new MeshOperations(SessionHub()).WhoAmI(" ") - .Timeout(Budget).Await(TestContext.Current.CancellationToken); - Assert.StartsWith("Error: path is required", json); - } -} diff --git a/test/MeshWeaver.Documentation.Test/HubReachableAsyncGuard.cs b/test/MeshWeaver.Documentation.Test/HubReachableAsyncGuard.cs index b0502b79fa..103688e3ba 100644 --- a/test/MeshWeaver.Documentation.Test/HubReachableAsyncGuard.cs +++ b/test/MeshWeaver.Documentation.Test/HubReachableAsyncGuard.cs @@ -65,8 +65,10 @@ public class HubReachableAsyncGuard(ITestOutputHelper output) /// private static readonly string[] ScannedRoots = ["src", "memex", "tools", "samples", "clients"]; - /// The seeded inventory's size for . - private const int AwaitedReadTotalBudget = 2; + /// The seeded inventory's size for . Zero: its + /// last two sites (DevAuthController, UserRoleResolver) left with Memex.Portal.Shared for + /// MeshWeaver.Plugins (core slimming), so every new await of a mesh read here is NEW. + private const int AwaitedReadTotalBudget = 0; /// The seeded inventory's size for . private const int SeamTotalBudget = 5; @@ -333,13 +335,6 @@ public void BothScannersFindTheInventoryTheyRatchet() { var root = SourceScan.FindRepoRoot(); - Assert.True(ScanAwaitedReads(root).Count > 0, - "The awaited-read scanner found NO site anywhere under " - + string.Join(", ", ScannedRoots) + ". Either every site was migrated — in which case " - + "empty " + AwaitedReadAllowFile + ", drop " + nameof(AwaitedReadTotalBudget) - + " to 0 and delete this half of the assertion — or the scanner is broken, which would " - + "make the ratchet pass on no evidence."); - Assert.True(ScanContractSeams(root).Count > 0, "The mesh-contract seam scanner found NO Task-returning interface member under " + MeshContractProject + ", while " + SeamAllowFile + " lists some. That is a broken " diff --git a/test/MeshWeaver.Documentation.Test/IdentityReadsRunAsSystemGuard.cs b/test/MeshWeaver.Documentation.Test/IdentityReadsRunAsSystemGuard.cs deleted file mode 100644 index f92dd9b595..0000000000 --- a/test/MeshWeaver.Documentation.Test/IdentityReadsRunAsSystemGuard.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using Xunit; - -namespace MeshWeaver.Documentation.Test; - -/// -/// A read that ESTABLISHES an identity must not be gated on that identity. -/// -/// workspace.GetQuery re-applies per-user RLS at the consumer -/// (SyncedQueryDataSourceExtensions.WrapWithPerUserRls), and that filter resolves -/// permissions through PermissionEvaluator — which reads AccessAssignment nodes. So a -/// pre-identity read left unwrapped re-enters the permission fold from the request path and asks -/// the identity being built whether it may learn what it is. Seeing System, the wrap short-circuits -/// to the raw upstream: "no per-user filter, no service resolution, no recursion". -/// -/// 🚨 Why a guard and not a review rule. The filter can only ever REMOVE grants a -/// viewer legitimately holds, and every failure it causes is SILENT and plausible: a shorter role -/// list looks like fewer roles, a filtered User node looks like "no such account" (and gets -/// a signed-in user bounced to /onboarding to make a second one), a filtered invitation looks like -/// one that was never issued. Nothing throws and nothing is logged, so the only moment this is -/// cheap to catch is the moment it is written. -/// -/// The scope is deliberately narrow — these are the reads that run BEFORE a viewer has a mesh -/// identity. It is NOT "every auth query must be System": ApiTokenService.GetTokensForUser -/// answers "show me MY tokens" for an established viewer, and per-user filtering there is the -/// feature, not the bug. -/// -public class IdentityReadsRunAsSystemGuard -{ - /// - /// Files whose reads are pre-identity. A null method set means EVERY GetQuery in the - /// file must be a System read — true of the onboarding middleware, whose whole job is resolving - /// a viewer before one exists. Naming methods instead restricts the rule to those. - /// - private static readonly (string File, string[]? Methods)[] PreIdentityReads = - [ - ("memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs", null), - ("memex/Memex.Portal.Shared/Authentication/InvitationService.cs", ["FindPendingInvitation"]), - ]; - - [Fact] - public void Pre_identity_reads_run_as_system() - { - var root = SourceScan.FindRepoRoot(); - var violations = new List(); - var checkedCalls = 0; - - foreach (var (relative, methods) in PreIdentityReads) - { - var path = Path.Combine(root, relative); - Assert.True(File.Exists(path), $"guarded file no longer exists — update this guard: {relative}"); - - // Masked, so a doc comment mentioning GetQuery (this file's subject matter is full of - // them) cannot be mistaken for a call. - var masked = SourceScan.MaskCommentsAndStrings(File.ReadAllText(path)); - - // Member starts at 4-space indentation — enough to bracket a method in these files. - var memberStarts = Regex - .Matches(masked, @"\n (?:public|internal|private|protected)[^\n]*", RegexOptions.None) - .Select(m => (m.Index, Text: m.Value)) - .ToList(); - - foreach (Match call in Regex.Matches(masked, @"\bGetQuery\s*\(")) - { - var owner = memberStarts.LastOrDefault(m => m.Index < call.Index); - var ownerName = MethodName(owner.Text) ?? "(unknown)"; - if (methods is not null && !methods.Contains(ownerName, StringComparer.Ordinal)) - continue; - - checkedCalls++; - var body = masked[owner.Index..call.Index]; - if (!body.Contains("RunAsSystem", StringComparison.Ordinal)) - violations.Add( - $"{relative}: {ownerName} reads via GetQuery without RunAsSystem " - + $"(line {masked.Take(call.Index).Count(c => c == '\n') + 1})"); - } - } - - // Anti-vacuity: a refactor that renames GetQuery or reshapes these files must fail here - // rather than silently check nothing and stay green. - Assert.True( - checkedCalls >= 3, - $"expected at least 3 pre-identity GetQuery calls to check, found {checkedCalls} — " - + "the guard is no longer looking at anything it was written to protect"); - - Assert.True( - violations.Count == 0, - "A read that establishes an identity ran under that identity. Wrap it in " - + "AccessService.RunAsSystem(() => …) — never Observable.Using(() => " - + "ImpersonateAsSystem(), …), whose store and restore land on different threads and " - + "leave the subscriber latched.\n " + string.Join("\n ", violations)); - } - - private static string? MethodName(string? declaration) - { - if (declaration is null) return null; - var m = Regex.Match(declaration, @"(\w+)\s*\("); - return m.Success ? m.Groups[1].Value : null; - } -} diff --git a/test/MeshWeaver.Documentation.Test/ImpersonationScopeSiteRatchetGuard.cs b/test/MeshWeaver.Documentation.Test/ImpersonationScopeSiteRatchetGuard.cs index dc5fc2425c..6789cf47f2 100644 --- a/test/MeshWeaver.Documentation.Test/ImpersonationScopeSiteRatchetGuard.cs +++ b/test/MeshWeaver.Documentation.Test/ImpersonationScopeSiteRatchetGuard.cs @@ -92,8 +92,12 @@ public class ImpersonationScopeSiteRatchetGuard(ITestOutputHelper output) /// lowering it by one both read "64 → 63", and taking either side alone would have left the /// budget one above the file and put the free slot back. The seed is EXACT against the file /// again (62). + /// + /// 45, not 61: sixteen allowances in ten files left with Memex.Portal.Shared when it moved + /// to MeshWeaver.Plugins (core slimming). The sites moved with their code; they were not + /// converted. /// - private const int TotalBudget = 61; + private const int TotalBudget = 45; /// Production roots. test/ and samples/ are deliberately out of scope — /// the leak there costs test isolation, not a user's permissions, and listing 21 more entries diff --git a/test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard.cs b/test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard.cs index 88ffef70a7..f312c01f32 100644 --- a/test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard.cs +++ b/test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard.cs @@ -43,8 +43,9 @@ public class LayoutAreaDataBakeRatchetGuard(ITestOutputHelper output) private const string AllowFileName = "LayoutAreaDataBakeSites.allow"; - /// The seeded inventory's size. Lower it in the same change that lowers a line. - private const int TotalBudget = 19; + /// The seeded inventory's size. Lower it in the same change that lowers a line. + /// (18: NotificationsSettingsTab left with Memex.Portal.Shared for MeshWeaver.Plugins.) + private const int TotalBudget = 18; /// Reads that make a unit wait on data. internal static readonly Regex LoadPattern = new( diff --git a/test/MeshWeaver.Graph.Test/InlineSvgEmissionBackplateGuard.cs b/test/MeshWeaver.Graph.Test/InlineSvgEmissionBackplateGuard.cs index 362802c675..05ca1a8e39 100644 --- a/test/MeshWeaver.Graph.Test/InlineSvgEmissionBackplateGuard.cs +++ b/test/MeshWeaver.Graph.Test/InlineSvgEmissionBackplateGuard.cs @@ -149,7 +149,9 @@ public void TheScanFindsEveryKnownClassifier_SoAPassIsNotVacuous() "src/MeshWeaver.Graph/IconBackplate.cs", "src/MeshWeaver.Domain/Icon.cs", "src/MeshWeaver.Hosting/Persistence/Parsers/MarkdownFileParser.cs", - "memex/Memex.Portal.Shared/Seo/SeoResolver.cs", + // memex/Memex.Portal.Shared/Seo/SeoResolver.cs left with Memex.Portal.Shared for + // MeshWeaver.Plugins (core slimming); its half of this guard went with it + // (Memex.Portal.Shared.Test.InlineSvgEmissionBackplateGuard). ]; var missing = expected.Where(e => !files.Contains(e)).ToImmutableArray();