From 3a81716d8982adef74914d4699eabe62cfe754a7 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:29:27 -0400 Subject: [PATCH 01/70] Continue the Warden v3 moderation rewrite on its own branch --- backend/build.gradle | 4 + .../config/db/ArtifactManifestStore.java | 8 + .../config/db/MongoArtifactManifestStore.java | 40 ++ .../net/modtale/config/db/MongoConfig.java | 13 +- .../config/db/ReferencedArtifactManifest.java | 31 + .../config/db/SecurityEvidenceConverters.java | 78 +++ .../properties/AppReviewRepairProperties.java | 32 + .../properties/AppWardenProperties.java | 4 +- .../config/security/SecurityConfig.java | 9 +- .../admin/ArtifactInspectionController.java | 167 +++++ .../admin/FindingReviewController.java | 41 ++ .../admin/ModerationQueuePageController.java | 30 + .../PriorFindingReasoningController.java | 19 + .../admin/ProjectManagementController.java | 31 +- .../admin/ReviewCancellationController.java | 30 + .../admin/ReviewOriginController.java | 38 + .../admin/ReviewRepairController.java | 29 + .../ReviewStateDiagnosticController.java | 35 + .../controller/auth/AuthController.java | 19 +- .../controller/project/TeamController.java | 15 +- .../user/OrganizationController.java | 12 +- .../net/modtale/mapper/ProjectMapper.java | 12 +- .../model/dto/admin/AdminProjectDTO.java | 3 +- .../admin/AdminProjectVersionSummaryDTO.java | 3 +- .../admin/AdminVerificationQueueScanDTO.java | 1 + .../project/ProjectInvitationResponse.java | 4 + .../ResolveProjectTransferRequest.java | 5 + .../user/OrganizationInvitationResponse.java | 3 + .../net/modtale/model/project/Project.java | 26 +- .../modtale/model/project/ProjectVersion.java | 41 ++ .../model/project/RemoteReviewBinding.java | 29 + .../model/project/RemoteReviewOrigin.java | 8 + .../net/modtale/model/project/ScanResult.java | 59 ++ .../model/project/SecurityManifest.java | 44 ++ .../java/net/modtale/model/user/User.java | 12 + .../ProjectAdminOperationsService.java | 4 +- .../project/ProjectAdminQueryService.java | 23 +- .../admin/project/ProjectMetadataRepair.java | 53 ++ .../project/ProjectModerationService.java | 29 +- .../admin/review/ModerationQueueCursor.java | 39 ++ .../review/ModerationQueuePageReader.java | 96 +++ .../review/ProjectReviewAdminService.java | 22 +- .../review/ProjectReviewDecisionService.java | 16 +- .../review/ProjectReviewPersistence.java | 238 +++++++ .../review/ProjectReviewQueryService.java | 39 -- .../review/ProjectReviewQueueService.java | 59 -- .../admin/review/ProjectReviewSnapshot.java | 33 + .../ProjectReviewTransitionService.java | 44 +- .../admin/review/RawReviewSnapshotReader.java | 93 +++ .../review/ReviewCancellationAccess.java | 77 +++ .../ReviewCancellationConfiguration.java | 30 + .../review/ReviewCancellationReconciler.java | 115 +++ .../admin/review/ReviewIsolationExecutor.java | 165 +++++ .../admin/review/ReviewOriginCursor.java | 17 + .../admin/review/ReviewOriginInventory.java | 75 ++ .../ReviewOrphanCancellationExecutor.java | 62 ++ .../ReviewOrphanCancellationJournal.java | 246 +++++++ .../review/ReviewOrphanTargetResolver.java | 53 ++ .../admin/review/ReviewRepairAccess.java | 91 +++ .../review/ReviewRepairConfiguration.java | 31 + .../service/admin/review/ReviewRepairIo.java | 73 ++ .../admin/review/ReviewRepairJournal.java | 109 +++ .../review/ReviewRepairOperationReader.java | 47 ++ .../admin/review/ReviewRepairPreparation.java | 55 ++ .../admin/review/ReviewRepairWorkflow.java | 121 ++++ .../admin/review/ReviewSnapshotArchive.java | 118 ++++ .../review/ReviewStateDiagnosticCursor.java | 23 + .../review/ReviewStateDiagnosticReader.java | 132 ++++ .../review/VersionReviewPersistence.java | 100 +++ .../admin/review/VersionReviewSnapshot.java | 42 ++ .../auth/AuthenticationMutationService.java | 22 - .../service/auth/AuthenticationService.java | 6 - .../service/auth/MfaEnrollmentService.java | 69 ++ .../service/auth/TwoFactorService.java | 1 + .../communication/NotificationService.java | 68 +- .../project/lifecycle/LifecycleService.java | 4 + .../ProjectArtifactDeletionService.java | 22 +- .../lifecycle/ProjectDeletionService.java | 39 +- .../ProjectDraftWorkflowService.java | 21 +- .../lifecycle/ProjectPublicationService.java | 86 ++- .../ScheduledReleaseExecutionService.java | 137 ++-- .../project/media/ProjectMediaService.java | 67 +- .../project/metadata/MetadataService.java | 12 +- .../project/team/ProjectInvitationPolicy.java | 29 + .../project/team/TeamMembershipService.java | 95 +-- .../project/team/TeamNotificationService.java | 6 +- .../service/project/team/TeamRoleService.java | 35 +- .../service/project/team/TeamService.java | 16 +- .../project/team/TeamTransferService.java | 49 +- .../VersionCreationCommandHandler.java | 23 +- .../VersionMutationOrchestrationService.java | 19 +- .../project/version/VersionService.java | 15 +- .../version/VersionUpdateCommandHandler.java | 37 +- .../issue/FindingDecisionValidity.java | 98 +++ .../security/issue/FindingReviewHistory.java | 66 ++ .../security/issue/FindingReviewService.java | 147 ++++ .../security/issue/IssueEvidenceIdentity.java | 59 ++ .../issue/PriorFindingReasoningService.java | 74 ++ .../issue/SecurityIssueAnalysisService.java | 11 +- .../issue/SecurityIssueApprovalService.java | 40 +- .../issue/SecurityIssueBaselineService.java | 9 +- .../issue/SecurityIssueEvaluationService.java | 55 +- .../scan/ArtifactClearancePolicy.java | 33 + .../security/scan/ArtifactReviewContext.java | 50 ++ .../security/scan/ArtifactReviewLineage.java | 128 ++++ .../scan/ArtifactReviewReuseService.java | 54 ++ .../security/scan/RemoteReviewBootstrap.java | 56 ++ .../security/scan/RemoteReviewClient.java | 173 +++++ .../security/scan/RemoteReviewDiscovery.java | 83 +++ .../scan/RemoteReviewPersistence.java | 141 ++++ .../security/scan/RemoteReviewPollStore.java | 137 ++++ .../security/scan/RemoteReviewScheduler.java | 106 +++ .../scan/RemoteReviewSchedulerConfig.java | 21 + .../security/scan/RemoteReviewStep.java | 58 ++ .../security/scan/ScanCompletionService.java | 108 ++- .../security/scan/ScanExecutionService.java | 31 +- .../security/scan/ScanPersistenceService.java | 167 ++++- .../security/scan/ScanRecoveryService.java | 15 +- .../security/scan/ScanRequestService.java | 15 +- .../security/scan/ScanRoutingService.java | 56 +- .../service/security/scan/ScanService.java | 5 + .../security/scan/WardenClientService.java | 66 +- .../service/social/FavoritePersistence.java | 63 ++ .../service/social/ProjectSocialService.java | 64 +- .../modtale/service/social/SocialService.java | 11 +- .../service/storage/DownloadService.java | 6 +- .../storage/ModpackArchiveService.java | 19 +- .../service/storage/ProjectMediaKeys.java | 24 + .../service/storage/StorageService.java | 20 + .../AccountPreferencesPersistence.java | 46 ++ .../service/user/account/AccountService.java | 49 +- .../account/OAuthAvatarHealingService.java | 29 +- .../user/account/UserAvatarPersistence.java | 32 + .../OrganizationApplicationService.java | 12 +- .../OrganizationInvitationPolicy.java | 36 + .../OrganizationInvitePersistence.java | 49 ++ .../OrganizationInviteService.java | 63 +- .../organization/OrganizationService.java | 8 +- .../src/main/resources/application.properties | 10 +- .../SecurityConfigLauncherOAuthTest.java | 29 + .../security/SecurityFilterChainTest.java | 41 ++ .../ArtifactInspectionAuthorizationTest.java | 50 ++ .../ArtifactInspectionControllerTest.java | 163 +++++ .../admin/FindingReviewAuthorizationTest.java | 61 ++ .../ModerationQueuePageControllerTest.java | 83 +++ .../PriorFindingReasoningControllerTest.java | 48 ++ .../ProjectManagementControllerTest.java | 4 +- .../ReviewCancellationControllerTest.java | 97 +++ .../admin/ReviewOriginControllerTest.java | 69 ++ .../admin/ReviewRepairControllerTest.java | 80 +++ .../ReviewStateDiagnosticControllerTest.java | 71 ++ .../controller/auth/AuthControllerTest.java | 11 +- .../project/TeamControllerTest.java | 4 +- .../net/modtale/mapper/ProjectMapperTest.java | 2 + .../model/project/RemoteReviewOriginTest.java | 19 + .../ReviewIsolationProvenanceTest.java | 19 + .../model/project/SecurityManifestTest.java | 41 ++ .../project/ProjectModerationServiceTest.java | 74 ++ .../ArtifactReviewLineageIntegrationTest.java | 285 ++++++++ ...ndingReviewPersistenceIntegrationTest.java | 654 ++++++++++++++++++ .../review/ModerationQueueCursorTest.java | 30 + .../review/ModerationQueuePageReaderTest.java | 91 +++ .../review/ProjectMetadataRepairTest.java | 24 + .../ProjectReviewDecisionServiceTest.java | 16 +- .../review/ProjectReviewQueryServiceTest.java | 70 -- .../review/ProjectReviewQueueServiceTest.java | 70 -- .../review/ProjectReviewSnapshotTest.java | 21 + .../review/RawReviewSnapshotReaderTest.java | 66 ++ .../review/ReviewCancellationAccessTest.java | 104 +++ .../ReviewCancellationConfigurationTest.java | 36 + .../review/ReviewIsolationExecutorTest.java | 131 ++++ .../review/ReviewOriginInventoryTest.java | 95 +++ .../ReviewRepairAccessIntegrationTest.java | 126 ++++ .../admin/review/ReviewRepairAccessTest.java | 53 ++ .../admin/review/ReviewRepairClosureTest.java | 118 ++++ .../review/ReviewRepairConfigurationTest.java | 36 + .../admin/review/ReviewRepairIoStallTest.java | 132 ++++ .../admin/review/ReviewRepairIoTest.java | 34 + .../admin/review/ReviewRepairJournalTest.java | 81 +++ .../ReviewRepairOperationReaderTest.java | 49 ++ .../review/ReviewRepairPreparationTest.java | 74 ++ .../ReviewRepairTransactionContractTest.java | 67 ++ .../ReviewRepairTransactionStallTest.java | 85 +++ .../review/ReviewRepairWorkflowTest.java | 139 ++++ .../review/ReviewSnapshotArchiveTest.java | 104 +++ .../ReviewStateDiagnosticCursorTest.java | 28 + .../ReviewStateDiagnosticReaderTest.java | 136 ++++ ...rsionReviewPersistenceIntegrationTest.java | 496 +++++++++++++ .../review/VersionReviewSnapshotTest.java | 18 + .../AuthenticationMutationServiceTest.java | 22 - .../auth/AuthenticationServiceTest.java | 7 +- .../auth/MfaEnrollmentIntegrationTest.java | 81 +++ .../service/auth/TwoFactorServiceTest.java | 23 + .../NotificationServiceTest.java | 2 +- .../lifecycle/LifecycleServiceTest.java | 73 +- .../lifecycle/ProjectDeletionServiceTest.java | 64 +- .../ProjectPublicationSecurityTest.java | 49 ++ .../media/ProjectMediaServiceTest.java | 63 +- .../project/metadata/MetadataServiceTest.java | 23 +- .../service/project/team/TeamServiceTest.java | 86 ++- .../version/VersionPublishingServiceTest.java | 177 +++-- .../project/version/VersionServiceTest.java | 96 ++- .../issue/FindingDecisionValidityTest.java | 129 ++++ .../issue/IssueEvidenceIdentityTest.java | 152 ++++ .../PriorFindingReasoningServiceTest.java | 92 +++ .../SecurityIssueApprovalServiceTest.java | 38 + .../SecurityIssueBaselineServiceTest.java | 4 +- .../SecurityIssueEvaluationServiceTest.java | 8 +- .../scan/ArtifactManifestReferencesTest.java | 62 ++ .../scan/ArtifactReviewLineageTest.java | 82 +++ .../scan/ArtifactReviewReuseServiceTest.java | 102 +++ .../security/scan/RemoteReviewClientTest.java | 140 ++++ .../scan/RemoteReviewDiscoveryTest.java | 71 ++ .../RemoteReviewIsolationIntegrationTest.java | 67 ++ .../scan/RemoteReviewSchedulerTest.java | 70 ++ .../scan/RemoteReviewStepIntegrationTest.java | 452 ++++++++++++ .../ReviewCancellationApiIntegrationTest.java | 103 +++ .../ReviewCancellationReconcilerTest.java | 141 ++++ .../ReviewOrphanCancellationExecutorTest.java | 112 +++ .../ReviewOrphanCancellationJournalTest.java | 173 +++++ .../scan/ReviewOrphanTargetResolverTest.java | 74 ++ .../security/scan/ScanEvidenceFixtures.java | 28 + .../scan/ScanEvidencePersistenceTest.java | 52 ++ .../scan/ScanPersistenceServiceTest.java | 90 +++ .../scan/ScanRecoveryServiceTest.java | 27 +- .../ScanRequestIdentityExecutionTest.java | 89 +++ .../security/scan/ScanRequestServiceTest.java | 32 +- .../security/scan/ScanRoutingServiceTest.java | 49 +- .../security/scan/ScanServiceTest.java | 15 +- .../WardenInspectionWindowClientTest.java | 33 + .../FavoritePersistenceIntegrationTest.java | 82 +++ .../social/ProjectSocialServiceTest.java | 74 ++ .../service/social/SocialServiceTest.java | 6 +- .../storage/BoundedStorageDownloadTest.java | 29 + .../storage/CurseForgeModpackFlowTest.java | 7 +- .../service/storage/DownloadServiceTest.java | 15 +- .../storage/ModpackArchiveServiceTest.java | 42 +- .../storage/ProjectMediaOwnershipTest.java | 69 ++ ...PreferencesPersistenceIntegrationTest.java | 114 +++ .../user/account/AccountServiceTest.java | 15 +- .../UserAvatarPersistenceIntegrationTest.java | 90 +++ ...ationInvitePersistenceIntegrationTest.java | 100 +++ frontend/src/modules/admin/api/adminClient.ts | 25 +- .../src/modules/admin/api/findingReviews.ts | 36 + .../src/modules/admin/api/moderationQueue.ts | 40 ++ .../modules/admin/api/reviewCancellation.ts | 117 ++++ .../modules/admin/api/reviewDiagnostics.ts | 57 ++ .../src/modules/admin/api/reviewOrigins.ts | 44 ++ .../src/modules/admin/api/reviewRepair.ts | 102 +++ .../admin/components/ProjectManagement.tsx | 20 +- .../components/ReviewCancellationPanel.tsx | 119 ++++ .../admin/components/ReviewOrigins.tsx | 66 ++ .../admin/components/ReviewRepairHistory.tsx | 57 ++ .../admin/components/ReviewRepairPanel.tsx | 97 +++ .../components/ReviewStateDiagnostics.tsx | 78 +++ .../admin/components/VerificationQueue.tsx | 40 +- .../modules/admin/hooks/useModerationQueue.ts | 54 ++ frontend/src/modules/admin/utils/access.ts | 2 +- .../admin/utils/projectMetadataRepair.ts | 11 + .../src/modules/admin/views/AdminPanel.tsx | 81 ++- .../modules/admin/views/ArtifactChanges.tsx | 81 +++ .../modules/admin/views/FindingDecisions.tsx | 79 +++ .../admin/views/PriorFindingReasoning.tsx | 57 ++ frontend/src/modules/admin/views/Review.tsx | 207 +++++- .../modules/admin/views/SourceInspector.tsx | 340 +++++---- .../organization/api/organizationClient.ts | 2 +- .../src/modules/organization/tabs/Members.tsx | 3 +- .../src/modules/project/api/projectClient.ts | 4 +- .../modules/project/hooks/useProjectEditor.ts | 5 +- frontend/src/modules/user/api/userClient.ts | 2 +- .../modules/user/utils/notificationActions.ts | 17 +- frontend/src/types.ts | 20 + frontend/src/utils/api.ts | 3 +- .../modules/admin/api/adminClient.test.ts | 45 +- .../modules/admin/api/findingReviews.test.ts | 18 + .../modules/admin/api/moderationQueue.test.ts | 24 + .../admin/api/reviewCancellation.test.ts | 78 +++ .../admin/api/reviewDiagnostics.test.ts | 19 + .../modules/admin/api/reviewOrigins.test.ts | 19 + .../modules/admin/api/reviewRepair.test.ts | 98 +++ .../components/ProjectManagement.test.tsx | 46 ++ .../ReviewCancellationPanel.test.tsx | 83 +++ .../admin/components/ReviewOrigins.test.tsx | 36 + .../components/ReviewRepairHistory.test.tsx | 64 ++ .../components/ReviewRepairPanel.test.tsx | 53 ++ .../ReviewStateDiagnostics.test.tsx | 52 ++ .../components/VerificationQueue.test.tsx | 58 ++ .../admin/hooks/useModerationQueue.test.tsx | 51 ++ .../admin/views/AdminPanel.queue.test.tsx | 41 ++ .../admin/views/ArtifactChanges.test.tsx | 82 +++ .../admin/views/FindingDecisions.test.tsx | 96 +++ .../views/PriorFindingReasoning.test.tsx | 46 ++ .../tests/modules/admin/views/Review.test.tsx | 219 ++++++ .../admin/views/SourceInspector.test.tsx | 156 +++++ .../api/organizationClient.test.ts | 6 + .../modules/project/api/projectClient.test.ts | 5 + .../project/hooks/useProjectEditor.test.tsx | 14 + .../tests/modules/user/api/userClient.test.ts | 7 + .../modules/user/notificationActions.test.ts | 25 +- frontend/tests/setup.ts | 7 + frontend/tests/utils/api.test.ts | 9 + 301 files changed, 17231 insertions(+), 1409 deletions(-) create mode 100644 backend/src/main/java/net/modtale/config/db/ArtifactManifestStore.java create mode 100644 backend/src/main/java/net/modtale/config/db/MongoArtifactManifestStore.java create mode 100644 backend/src/main/java/net/modtale/config/db/ReferencedArtifactManifest.java create mode 100644 backend/src/main/java/net/modtale/config/db/SecurityEvidenceConverters.java create mode 100644 backend/src/main/java/net/modtale/config/properties/AppReviewRepairProperties.java create mode 100644 backend/src/main/java/net/modtale/controller/admin/ArtifactInspectionController.java create mode 100644 backend/src/main/java/net/modtale/controller/admin/FindingReviewController.java create mode 100644 backend/src/main/java/net/modtale/controller/admin/ModerationQueuePageController.java create mode 100644 backend/src/main/java/net/modtale/controller/admin/PriorFindingReasoningController.java create mode 100644 backend/src/main/java/net/modtale/controller/admin/ReviewCancellationController.java create mode 100644 backend/src/main/java/net/modtale/controller/admin/ReviewOriginController.java create mode 100644 backend/src/main/java/net/modtale/controller/admin/ReviewRepairController.java create mode 100644 backend/src/main/java/net/modtale/controller/admin/ReviewStateDiagnosticController.java create mode 100644 backend/src/main/java/net/modtale/model/dto/request/project/ProjectInvitationResponse.java create mode 100644 backend/src/main/java/net/modtale/model/dto/request/user/OrganizationInvitationResponse.java create mode 100644 backend/src/main/java/net/modtale/model/project/RemoteReviewBinding.java create mode 100644 backend/src/main/java/net/modtale/model/project/RemoteReviewOrigin.java create mode 100644 backend/src/main/java/net/modtale/model/project/SecurityManifest.java create mode 100644 backend/src/main/java/net/modtale/service/admin/project/ProjectMetadataRepair.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ModerationQueueCursor.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ModerationQueuePageReader.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ProjectReviewPersistence.java delete mode 100644 backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueueService.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ProjectReviewSnapshot.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/RawReviewSnapshotReader.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationAccess.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationConfiguration.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationReconciler.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewIsolationExecutor.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewOriginCursor.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewOriginInventory.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanCancellationExecutor.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanCancellationJournal.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanTargetResolver.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewRepairAccess.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewRepairConfiguration.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewRepairIo.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewRepairJournal.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewRepairOperationReader.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewRepairPreparation.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewRepairWorkflow.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewSnapshotArchive.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursor.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticReader.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/VersionReviewPersistence.java create mode 100644 backend/src/main/java/net/modtale/service/admin/review/VersionReviewSnapshot.java create mode 100644 backend/src/main/java/net/modtale/service/auth/MfaEnrollmentService.java create mode 100644 backend/src/main/java/net/modtale/service/project/team/ProjectInvitationPolicy.java create mode 100644 backend/src/main/java/net/modtale/service/security/issue/FindingDecisionValidity.java create mode 100644 backend/src/main/java/net/modtale/service/security/issue/FindingReviewHistory.java create mode 100644 backend/src/main/java/net/modtale/service/security/issue/FindingReviewService.java create mode 100644 backend/src/main/java/net/modtale/service/security/issue/IssueEvidenceIdentity.java create mode 100644 backend/src/main/java/net/modtale/service/security/issue/PriorFindingReasoningService.java create mode 100644 backend/src/main/java/net/modtale/service/security/scan/ArtifactClearancePolicy.java create mode 100644 backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewContext.java create mode 100644 backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewLineage.java create mode 100644 backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewReuseService.java create mode 100644 backend/src/main/java/net/modtale/service/security/scan/RemoteReviewBootstrap.java create mode 100644 backend/src/main/java/net/modtale/service/security/scan/RemoteReviewClient.java create mode 100644 backend/src/main/java/net/modtale/service/security/scan/RemoteReviewDiscovery.java create mode 100644 backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPersistence.java create mode 100644 backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPollStore.java create mode 100644 backend/src/main/java/net/modtale/service/security/scan/RemoteReviewScheduler.java create mode 100644 backend/src/main/java/net/modtale/service/security/scan/RemoteReviewSchedulerConfig.java create mode 100644 backend/src/main/java/net/modtale/service/security/scan/RemoteReviewStep.java create mode 100644 backend/src/main/java/net/modtale/service/social/FavoritePersistence.java create mode 100644 backend/src/main/java/net/modtale/service/storage/ProjectMediaKeys.java create mode 100644 backend/src/main/java/net/modtale/service/user/account/AccountPreferencesPersistence.java create mode 100644 backend/src/main/java/net/modtale/service/user/account/UserAvatarPersistence.java create mode 100644 backend/src/main/java/net/modtale/service/user/organization/OrganizationInvitationPolicy.java create mode 100644 backend/src/main/java/net/modtale/service/user/organization/OrganizationInvitePersistence.java create mode 100644 backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionAuthorizationTest.java create mode 100644 backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionControllerTest.java create mode 100644 backend/src/test/java/net/modtale/controller/admin/FindingReviewAuthorizationTest.java create mode 100644 backend/src/test/java/net/modtale/controller/admin/ModerationQueuePageControllerTest.java create mode 100644 backend/src/test/java/net/modtale/controller/admin/PriorFindingReasoningControllerTest.java create mode 100644 backend/src/test/java/net/modtale/controller/admin/ReviewCancellationControllerTest.java create mode 100644 backend/src/test/java/net/modtale/controller/admin/ReviewOriginControllerTest.java create mode 100644 backend/src/test/java/net/modtale/controller/admin/ReviewRepairControllerTest.java create mode 100644 backend/src/test/java/net/modtale/controller/admin/ReviewStateDiagnosticControllerTest.java create mode 100644 backend/src/test/java/net/modtale/model/project/RemoteReviewOriginTest.java create mode 100644 backend/src/test/java/net/modtale/model/project/ReviewIsolationProvenanceTest.java create mode 100644 backend/src/test/java/net/modtale/model/project/SecurityManifestTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/project/ProjectModerationServiceTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ArtifactReviewLineageIntegrationTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/FindingReviewPersistenceIntegrationTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ModerationQueueCursorTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ModerationQueuePageReaderTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ProjectMetadataRepairTest.java delete mode 100644 backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueryServiceTest.java delete mode 100644 backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueueServiceTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ProjectReviewSnapshotTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/RawReviewSnapshotReaderTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationAccessTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationConfigurationTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewIsolationExecutorTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewOriginInventoryTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessIntegrationTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewRepairClosureTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewRepairConfigurationTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoStallTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewRepairJournalTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewRepairOperationReaderTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewRepairPreparationTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionContractTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionStallTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewRepairWorkflowTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewSnapshotArchiveTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursorTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticReaderTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/VersionReviewPersistenceIntegrationTest.java create mode 100644 backend/src/test/java/net/modtale/service/admin/review/VersionReviewSnapshotTest.java create mode 100644 backend/src/test/java/net/modtale/service/auth/MfaEnrollmentIntegrationTest.java create mode 100644 backend/src/test/java/net/modtale/service/auth/TwoFactorServiceTest.java create mode 100644 backend/src/test/java/net/modtale/service/project/lifecycle/ProjectPublicationSecurityTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/issue/FindingDecisionValidityTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/issue/IssueEvidenceIdentityTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/issue/PriorFindingReasoningServiceTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/ArtifactManifestReferencesTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewLineageTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewReuseServiceTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/RemoteReviewClientTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/RemoteReviewDiscoveryTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/RemoteReviewIsolationIntegrationTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/RemoteReviewSchedulerTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/RemoteReviewStepIntegrationTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationApiIntegrationTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationReconcilerTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationExecutorTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationJournalTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanTargetResolverTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/ScanEvidenceFixtures.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/ScanEvidencePersistenceTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/ScanPersistenceServiceTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/ScanRequestIdentityExecutionTest.java create mode 100644 backend/src/test/java/net/modtale/service/security/scan/WardenInspectionWindowClientTest.java create mode 100644 backend/src/test/java/net/modtale/service/social/FavoritePersistenceIntegrationTest.java create mode 100644 backend/src/test/java/net/modtale/service/social/ProjectSocialServiceTest.java create mode 100644 backend/src/test/java/net/modtale/service/storage/BoundedStorageDownloadTest.java create mode 100644 backend/src/test/java/net/modtale/service/storage/ProjectMediaOwnershipTest.java create mode 100644 backend/src/test/java/net/modtale/service/user/account/AccountPreferencesPersistenceIntegrationTest.java create mode 100644 backend/src/test/java/net/modtale/service/user/account/UserAvatarPersistenceIntegrationTest.java create mode 100644 backend/src/test/java/net/modtale/service/user/organization/OrganizationInvitePersistenceIntegrationTest.java create mode 100644 frontend/src/modules/admin/api/findingReviews.ts create mode 100644 frontend/src/modules/admin/api/moderationQueue.ts create mode 100644 frontend/src/modules/admin/api/reviewCancellation.ts create mode 100644 frontend/src/modules/admin/api/reviewDiagnostics.ts create mode 100644 frontend/src/modules/admin/api/reviewOrigins.ts create mode 100644 frontend/src/modules/admin/api/reviewRepair.ts create mode 100644 frontend/src/modules/admin/components/ReviewCancellationPanel.tsx create mode 100644 frontend/src/modules/admin/components/ReviewOrigins.tsx create mode 100644 frontend/src/modules/admin/components/ReviewRepairHistory.tsx create mode 100644 frontend/src/modules/admin/components/ReviewRepairPanel.tsx create mode 100644 frontend/src/modules/admin/components/ReviewStateDiagnostics.tsx create mode 100644 frontend/src/modules/admin/hooks/useModerationQueue.ts create mode 100644 frontend/src/modules/admin/utils/projectMetadataRepair.ts create mode 100644 frontend/src/modules/admin/views/ArtifactChanges.tsx create mode 100644 frontend/src/modules/admin/views/FindingDecisions.tsx create mode 100644 frontend/src/modules/admin/views/PriorFindingReasoning.tsx create mode 100644 frontend/tests/modules/admin/api/findingReviews.test.ts create mode 100644 frontend/tests/modules/admin/api/moderationQueue.test.ts create mode 100644 frontend/tests/modules/admin/api/reviewCancellation.test.ts create mode 100644 frontend/tests/modules/admin/api/reviewDiagnostics.test.ts create mode 100644 frontend/tests/modules/admin/api/reviewOrigins.test.ts create mode 100644 frontend/tests/modules/admin/api/reviewRepair.test.ts create mode 100644 frontend/tests/modules/admin/components/ProjectManagement.test.tsx create mode 100644 frontend/tests/modules/admin/components/ReviewCancellationPanel.test.tsx create mode 100644 frontend/tests/modules/admin/components/ReviewOrigins.test.tsx create mode 100644 frontend/tests/modules/admin/components/ReviewRepairHistory.test.tsx create mode 100644 frontend/tests/modules/admin/components/ReviewRepairPanel.test.tsx create mode 100644 frontend/tests/modules/admin/components/ReviewStateDiagnostics.test.tsx create mode 100644 frontend/tests/modules/admin/components/VerificationQueue.test.tsx create mode 100644 frontend/tests/modules/admin/hooks/useModerationQueue.test.tsx create mode 100644 frontend/tests/modules/admin/views/AdminPanel.queue.test.tsx create mode 100644 frontend/tests/modules/admin/views/ArtifactChanges.test.tsx create mode 100644 frontend/tests/modules/admin/views/FindingDecisions.test.tsx create mode 100644 frontend/tests/modules/admin/views/PriorFindingReasoning.test.tsx create mode 100644 frontend/tests/modules/admin/views/Review.test.tsx create mode 100644 frontend/tests/modules/admin/views/SourceInspector.test.tsx diff --git a/backend/build.gradle b/backend/build.gradle index c567a51d1..e57edc20a 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -55,6 +55,10 @@ dependencies { } tasks.named('test') { + // Explicit database checks must exercise the current database, not cached results. + if (System.getenv('WARDEN_REVIEW_DB_TEST') == 'true' || System.getenv('WARDEN_REPAIR_TX_DB_TEST') == 'true') { + outputs.upToDateWhen { false } + } useJUnitPlatform() } diff --git a/backend/src/main/java/net/modtale/config/db/ArtifactManifestStore.java b/backend/src/main/java/net/modtale/config/db/ArtifactManifestStore.java new file mode 100644 index 000000000..febf31505 --- /dev/null +++ b/backend/src/main/java/net/modtale/config/db/ArtifactManifestStore.java @@ -0,0 +1,8 @@ +package net.modtale.config.db; + +import java.util.Map; + +public interface ArtifactManifestStore { + String put(Map entries); + Map get(String identity); +} diff --git a/backend/src/main/java/net/modtale/config/db/MongoArtifactManifestStore.java b/backend/src/main/java/net/modtale/config/db/MongoArtifactManifestStore.java new file mode 100644 index 000000000..201f94a1c --- /dev/null +++ b/backend/src/main/java/net/modtale/config/db/MongoArtifactManifestStore.java @@ -0,0 +1,40 @@ +package net.modtale.config.db; + +import com.mongodb.MongoWriteException; +import net.modtale.model.project.SecurityManifest; +import org.bson.Document; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mapping.MappingException; +import java.util.*; + +/** Immutable, content-addressed records; raw driver access avoids recursive mapping conversions. */ +public final class MongoArtifactManifestStore implements ArtifactManifestStore { + public static final String COLLECTION = "artifact_manifests"; + private final MongoDatabaseFactory database; + public MongoArtifactManifestStore(MongoDatabaseFactory database) { this.database = database; } + @Override public String put(Map entries) { + if (!SecurityManifest.valid(entries, false)) throw new MappingException("Invalid artifact manifest"); + var snapshot = Collections.unmodifiableMap(new TreeMap<>(entries)); + String identity = SecurityManifest.identity(snapshot); + var values = new ArrayList(); + snapshot.forEach((path, hash) -> values.add(new Document("path", path).append("sha256", hash))); + try { + database.getMongoDatabase().getCollection(COLLECTION).insertOne(new Document("_id", identity) + .append("entries", values).append("createdAt", new Date())); + } catch (MongoWriteException duplicate) { + if (duplicate.getError().getCode() != 11000) throw duplicate; + get(identity); // An existing record must really contain the same immutable manifest. + } + return identity; + } + @Override public Map get(String identity) { + if (!SecurityManifest.digest(identity)) throw new MappingException("Invalid artifact manifest reference"); + Document document = database.getMongoDatabase().getCollection(COLLECTION).find(new Document("_id", identity)).first(); + if (document == null) throw new MappingException("Artifact manifest is unavailable"); + var evidence = new SecurityEvidenceConverters.Read().convert(new Document("entryHashes", document.get("entries"))); + var entries = evidence.entryHashes(); + if (!SecurityManifest.valid(entries, false) || !identity.equals(SecurityManifest.identity(entries))) + throw new MappingException("Artifact manifest integrity check failed"); + return Collections.unmodifiableMap(new TreeMap<>(entries)); + } +} diff --git a/backend/src/main/java/net/modtale/config/db/MongoConfig.java b/backend/src/main/java/net/modtale/config/db/MongoConfig.java index a839e36e7..b4750a80e 100644 --- a/backend/src/main/java/net/modtale/config/db/MongoConfig.java +++ b/backend/src/main/java/net/modtale/config/db/MongoConfig.java @@ -12,8 +12,19 @@ public class MongoConfig { @Bean + public ArtifactManifestStore artifactManifestStore(org.springframework.data.mongodb.MongoDatabaseFactory database) { + return new MongoArtifactManifestStore(database); + } + + @Bean + public MongoCustomConversions mongoCustomConversions(ArtifactManifestStore store) { + return new MongoCustomConversions(List.of(new StringToOAuthProviderConverter(), + new SecurityEvidenceConverters.Write(store), new SecurityEvidenceConverters.Read(store))); + } + public MongoCustomConversions mongoCustomConversions() { - return new MongoCustomConversions(List.of(new StringToOAuthProviderConverter())); + return new MongoCustomConversions(List.of(new StringToOAuthProviderConverter(), + new SecurityEvidenceConverters.Write(), new SecurityEvidenceConverters.Read())); } @ReadingConverter diff --git a/backend/src/main/java/net/modtale/config/db/ReferencedArtifactManifest.java b/backend/src/main/java/net/modtale/config/db/ReferencedArtifactManifest.java new file mode 100644 index 000000000..ca43784ab --- /dev/null +++ b/backend/src/main/java/net/modtale/config/db/ReferencedArtifactManifest.java @@ -0,0 +1,31 @@ +package net.modtale.config.db; + +import net.modtale.model.project.SecurityManifest; +import java.util.*; + +/** Existing Map-based consumers still inspect real verified entries before granting clearance. */ +final class ReferencedArtifactManifest extends AbstractMap { + final ArtifactManifestStore store; + final String identity; + private volatile Map loaded; + ReferencedArtifactManifest(ArtifactManifestStore store, String identity) { + if (store == null || !SecurityManifest.digest(identity)) throw new IllegalArgumentException("Invalid artifact manifest reference"); + this.store = store; this.identity = identity; + } + private Map load() { + var snapshot = loaded; + if (snapshot != null) return snapshot; + synchronized (this) { + if (loaded == null) { + try { + var entries = store.get(identity); + if (!SecurityManifest.valid(entries, false) || !identity.equals(SecurityManifest.identity(entries))) + throw new IllegalStateException("Artifact manifest integrity check failed"); + loaded = Collections.unmodifiableMap(new TreeMap<>(entries)); + } catch (RuntimeException unavailable) { throw new SecurityManifest.Unavailable(unavailable); } + } + return loaded; + } + } + @Override public Set> entrySet() { return load().entrySet(); } +} diff --git a/backend/src/main/java/net/modtale/config/db/SecurityEvidenceConverters.java b/backend/src/main/java/net/modtale/config/db/SecurityEvidenceConverters.java new file mode 100644 index 000000000..72377eba9 --- /dev/null +++ b/backend/src/main/java/net/modtale/config/db/SecurityEvidenceConverters.java @@ -0,0 +1,78 @@ +package net.modtale.config.db; + +import java.util.*; +import net.modtale.model.project.ScanResult.SecurityEvidence; +import net.modtale.model.project.SecurityManifest; +import org.bson.Document; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.mapping.MappingException; + +public final class SecurityEvidenceConverters { + private SecurityEvidenceConverters() {} + + @WritingConverter + public static final class Write implements Converter { + private final ArtifactManifestStore store; + public Write() { this(null); } + public Write(ArtifactManifestStore store) { this.store = store; } + @Override public Document convert(SecurityEvidence evidence) { + if (store != null && evidence.entryHashes() instanceof ReferencedArtifactManifest reference && reference.store == store) + return header(evidence).append("manifestRef", reference.identity); + if (!SecurityManifest.valid(evidence.entryHashes(), true)) throw new MappingException("Invalid or oversized security manifest"); + if (store != null && evidence.entryHashes() != null && !evidence.entryHashes().isEmpty()) + return header(evidence).append("manifestRef", store.put(evidence.entryHashes())); + var entries = new ArrayList(); + if (evidence.entryHashes() != null) evidence.entryHashes().forEach((path, hash) -> + entries.add(new Document("path", path).append("sha256", hash))); + return header(evidence).append("entryHashes", entries); + } + private Document header(SecurityEvidence evidence) { + return new Document("policyVersion", evidence.policyVersion()) + .append("artifactSha256", evidence.artifactSha256()) + .append("contentSha256", evidence.contentSha256()) + .append("complete", evidence.complete()) + .append("clearanceGranted", evidence.clearanceGranted()) + .append("reviewState", evidence.reviewState()); + } + } + + @ReadingConverter + public static final class Read implements Converter { + private final ArtifactManifestStore store; + public Read() { this(null); } + public Read(ArtifactManifestStore store) { this.store = store; } + @Override public SecurityEvidence convert(Document source) { + if (source.containsKey("manifestRef")) { + if (source.containsKey("entryHashes") || store == null || !(source.get("manifestRef") instanceof String identity) + || !SecurityManifest.digest(identity)) throw new MappingException("Invalid artifact manifest reference"); + return evidence(source, new ReferencedArtifactManifest(store, identity)); + } + var entries = new LinkedHashMap(); + Object stored = source.get("entryHashes"); + if (stored instanceof List list) { + if (list.size() > 20_000) throw new MappingException("Security manifest exceeds entry limit"); + for (Object value : list) { + if (!(value instanceof Document entry) || !(entry.get("path") instanceof String path) + || !(entry.get("sha256") instanceof String hash) || entries.putIfAbsent(path, hash) != null) + throw new MappingException("Invalid security manifest entry"); + } + } else if (stored instanceof Map map) { + if (map.size() > 20_000) throw new MappingException("Security manifest exceeds entry limit"); + for (var entry : map.entrySet()) { + if (!(entry.getKey() instanceof String path) || !(entry.getValue() instanceof String hash)) + throw new MappingException("Invalid legacy security manifest entry"); + entries.put(path, hash); + } + } else if (stored != null) throw new MappingException("Invalid security manifest"); + if (!SecurityManifest.valid(entries, true)) throw new MappingException("Invalid or oversized security manifest"); + return evidence(source, entries); + } + private SecurityEvidence evidence(Document source, Map entries) { + return new SecurityEvidence(source.getString("policyVersion"), source.getString("artifactSha256"), + source.getString("contentSha256"), Boolean.TRUE.equals(source.get("complete")), + Boolean.TRUE.equals(source.get("clearanceGranted")), source.getString("reviewState"), entries); + } + } +} diff --git a/backend/src/main/java/net/modtale/config/properties/AppReviewRepairProperties.java b/backend/src/main/java/net/modtale/config/properties/AppReviewRepairProperties.java new file mode 100644 index 000000000..68039f39b --- /dev/null +++ b/backend/src/main/java/net/modtale/config/properties/AppReviewRepairProperties.java @@ -0,0 +1,32 @@ +package net.modtale.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import java.util.*; + +@ConfigurationProperties(prefix="app.warden.repair") +public record AppReviewRepairProperties(@DefaultValue("false") boolean enabled,@DefaultValue("") String activeKey, + Map signingKeys,@DefaultValue("1") int concurrency,@DefaultValue("300000") long preparationLifetimeMillis) { + public AppReviewRepairProperties { + signingKeys=signingKeys==null?Map.of():Map.copyOf(signingKeys); + if(enabled) { + if(concurrency<1 || concurrency>2 || preparationLifetimeMillis<1000 || preparationLifetimeMillis>900000 + || activeKey==null || !signingKeys.containsKey(activeKey) || signingKeys.isEmpty() || signingKeys.size()>8)throw invalid(); + decode(signingKeys); + } + } + public Map decodedKeys(){if(!enabled)throw invalid();return decode(signingKeys);} + private static Map decode(Map keys) { + var decoded=new HashMap(); + keys.forEach((id,value)->{ + try { + if(id==null || !id.matches("[A-Za-z0-9_-]{1,64}") || value==null || value.length()>172)throw invalid(); + byte[] key=Base64.getDecoder().decode(value); + if(key.length<32 || key.length>128 || !Base64.getEncoder().encodeToString(key).equals(value))throw invalid(); + decoded.put(id,key); + } catch(IllegalArgumentException invalid){throw invalid();} + });return Map.copyOf(decoded); + } + private static IllegalArgumentException invalid(){return new IllegalArgumentException("Invalid review repair settings");} + @Override public String toString(){return "ReviewRepairSettings[enabled="+enabled+"]";} +} diff --git a/backend/src/main/java/net/modtale/config/properties/AppWardenProperties.java b/backend/src/main/java/net/modtale/config/properties/AppWardenProperties.java index 1068854be..fca64a432 100644 --- a/backend/src/main/java/net/modtale/config/properties/AppWardenProperties.java +++ b/backend/src/main/java/net/modtale/config/properties/AppWardenProperties.java @@ -8,7 +8,7 @@ public record AppWardenProperties( @DefaultValue("http://localhost:8081") String url, @DefaultValue("") String apiKey, @DefaultValue("true") boolean enabled, - @DefaultValue("3") int maxAttempts, - @DefaultValue("75") long requestTimeoutSeconds + @DefaultValue("1") int maxAttempts, + @DefaultValue("600") long requestTimeoutSeconds ) { } diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index 91080cd6c..6ecdb2d11 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -339,6 +339,8 @@ public SecurityFilterChain securityFilterChain( .requestMatchers( "/api/v1/user/analytics", "/api/v1/user/api-keys/**", + "/api/v1/auth/mfa/setup", + "/api/v1/auth/mfa/verify", "/api/v1/admin/**" ).access((authentication, context) -> { boolean isApiKeyUser = authentication.get().getAuthorities().stream() @@ -353,8 +355,6 @@ public SecurityFilterChain securityFilterChain( "/api/v1/user/settings/**", "/api/v1/user/repos/**", "/api/v1/projects/*/favorite", - "/api/v1/auth/mfa/setup", - "/api/v1/auth/mfa/verify", "/api/v1/auth/resend-verification", "/api/v1/auth/change-password", "/api/v1/auth/credentials" @@ -431,11 +431,6 @@ public AuthenticationSuccessHandler oauthSuccessHandler() { } User user = accountService.getPublicProfile(login); - if (user != null && user.isMfaEnabled() && (user.getMfaSecret() == null || user.getMfaSecret().isBlank())) { - logger.warn("OAuth User {} has MFA enabled but missing secret. Auto-disabling MFA to prevent lockout.", user.getId()); - user.setMfaEnabled(false); - user = accountService.saveUser(user); - } boolean isLinking = Boolean.TRUE.equals(oauthUser.getAttribute("is_linking")); LauncherOAuthRequest launcherOAuthRequest = consumeLauncherOAuthRequest(request); if (launcherOAuthRequest != null && !isLinking) { diff --git a/backend/src/main/java/net/modtale/controller/admin/ArtifactInspectionController.java b/backend/src/main/java/net/modtale/controller/admin/ArtifactInspectionController.java new file mode 100644 index 000000000..3c56c7e4f --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/ArtifactInspectionController.java @@ -0,0 +1,167 @@ +package net.modtale.controller.admin; + +import net.modtale.model.project.*; +import net.modtale.service.admin.review.ProjectReviewSnapshot; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.WardenClientService; +import net.modtale.service.storage.StorageService; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +@RestController +@RequestMapping("/api/v1/admin/projects/{id}/versions/{version}") +@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") +public class ArtifactInspectionController { + private final ProjectService projects; + private final StorageService storage; + private final WardenClientService inspector; + public ArtifactInspectionController(ProjectService projects, StorageService storage, WardenClientService inspector) { + this.projects=projects;this.storage=storage;this.inspector=inspector; + } + @GetMapping("/structure") + public ResponseEntity> structure(@PathVariable String id,@PathVariable String version, @RequestHeader(value="If-Match", required=false) String expected) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(inspect(id,version,null,expected).paths()); + } + @GetMapping(value="/file",produces=MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity file(@PathVariable String id,@PathVariable String version,@RequestParam String path, @RequestHeader(value="If-Match", required=false) String expected) { + var response=inspect(id,version,path,expected); + String prefix="JVM_BYTECODE".equals(response.format()) ? "// JVM bytecode of the uploaded class. LINE entries refer to original source lines.\n" : ""; + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff").body(prefix+response.content()); + } + public record FileWindow(String identity, String content, String format, int start, int end, int totalCharacters, + int firstLine, boolean lineMatched, boolean representationComplete, List gaps) {} + @GetMapping("/file-window") + public ResponseEntity window(@PathVariable String id, @PathVariable String version, @RequestParam String path, + @RequestParam(defaultValue="0") int offset, @RequestParam(defaultValue="32000") int characters, + @RequestParam(defaultValue="0") int sourceLine, @RequestParam(required=false) String identity, + @RequestHeader(value="If-Match",required=false) String expected) { + Project project = projects.getRawProjectById(id); + if (project == null || project.getVersions() == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + ProjectReviewSnapshot.requireCurrent(project, expected); + String snapshot = ProjectReviewSnapshot.token(project); + if (path == null || path.isBlank() || path.length() > 8192 || offset < 0 || offset > 4_000_000 + || characters < 1 || characters > 32000 || sourceLine < 0 || sourceLine > 4_000_001 + || sourceLine > 0 && offset != 0 || offset > 0 && identity == null || identity != null && !identity.matches("[0-9a-f]{64}")) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid inspection window"); + var selected = requireVersion(project, version); + byte[] bytes = verifiedBytes(selected); + var response = inspector.inspectWindow(bytes, path, offset, characters, sourceLine); + requireUnchanged(id, snapshot); + if (!validWindow(response, selected.getHash(), path, offset, characters, sourceLine)) + throw new ResponseStatusException(HttpStatus.CONFLICT, "A consistent inspection window is unavailable"); + var fields = new ArrayList<>(List.of(response.artifactSha256(), response.path(), response.entrySha256(), response.policyVersion(), + response.representationSha256(), response.format(), Integer.toString(response.totalCharacters()), Boolean.toString(response.representationComplete()))); + fields.addAll(response.gaps()); + StringBuilder binding = new StringBuilder(); fields.forEach(field -> binding.append(field.length()).append(':').append(field)); + String actual = digest(binding.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + if (identity != null && !identity.equals(actual)) throw new ResponseStatusException(HttpStatus.CONFLICT, "Inspection changed; reopen the file"); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options", "nosniff") + .body(new FileWindow(actual, response.content(), response.format(), response.start(), response.end(), response.totalCharacters(), + response.firstLine(), response.lineMatched(), response.representationComplete(), response.gaps())); + } + private static boolean validWindow(WardenClientService.InspectionWindow w, String hash, String path, int offset, int characters, int sourceLine) { + return w != null && Objects.equals(hash,w.artifactSha256()) && path.equals(w.path()) + && w.entrySha256() != null && w.entrySha256().matches("[0-9a-f]{64}") && w.representationSha256() != null && w.representationSha256().matches("[0-9a-f]{64}") + && w.policyVersion() != null && !w.policyVersion().isBlank() && w.policyVersion().length() <= 256 && w.format() != null + && Set.of("JVM_BYTECODE","TEXT_RESOURCE","JSON_RESOURCE","VALIDATED_RASTER","OPAQUE_RESOURCE","UNREPRESENTED").contains(w.format()) + && w.start() >= 0 && (sourceLine != 0 || offset == w.start()) && w.end() >= w.start() && w.end() <= w.totalCharacters() && w.totalCharacters() <= 4_000_000 + && (w.start() == w.totalCharacters() || w.end() > w.start()) && w.firstLine() >= 1 && w.firstLine() <= w.start() + 1 + && w.content() != null && w.content().length() == w.end()-w.start() && w.content().length() <= characters + && w.gaps() != null && w.gaps().size() <= 8 && w.gaps().stream().allMatch(gap -> gap != null && gap.length() <= 512) + && (!w.representationComplete() || w.gaps().isEmpty() && !Set.of("OPAQUE_RESOURCE","UNREPRESENTED").contains(w.format())); + } + private static String digest(byte[] bytes) { + try { return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); } + catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + private byte[] verifiedBytes(ProjectVersion version) { + if (version.getFileUrl() == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + byte[] bytes = storage.download(version.getFileUrl()); + if (!Objects.equals(digest(bytes),version.getHash())) throw new ResponseStatusException(HttpStatus.CONFLICT,"Stored artifact hash mismatch"); + return bytes; + } + public record FileChange(String path, String change) {} + public record ArtifactChanges(String reviewToken, String baselineVersion, boolean contextComparable, boolean contextChanged, + int added, int modified, int removed, int unchanged, List files) {} + + @GetMapping("/changes") + public ResponseEntity changes(@PathVariable String id, @PathVariable String version, @RequestHeader(value="If-Match", required=false) String expected) { + Project project=projects.getRawProjectById(id); + if(project==null || project.getVersions()==null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + ProjectReviewSnapshot.requireCurrent(project, expected); + String snapshot = ProjectReviewSnapshot.token(project); + ProjectVersion current = requireVersion(project, version); + ProjectVersion baseline=project.getVersions().stream().filter(Objects::nonNull).filter(v -> v.getReviewStatus()==ProjectVersion.ReviewStatus.APPROVED + && !Objects.equals(v.getId(),current.getId())) + .max(Comparator.comparingLong(ProjectVersion::getSecurityApprovedAt)).orElse(null); + if(baseline==null) return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .body(new ArtifactChanges(snapshot,null,false,false,0,0,0,0,List.of())); + requireVersion(project, baseline.getVersionNumber()); + var before=inspect(baseline,null); + var after=inspect(current,null); + requireUnchanged(id, snapshot); + if(!validManifest(before.entryHashes()) || !validManifest(after.entryHashes()) || before.policyVersion()==null + || !before.policyVersion().equals(after.policyVersion())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"A consistent artifact comparison is unavailable"); + String priorContext=baseline.getApprovedSecurityContextSha256(); + String currentContext=net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(current); + boolean comparable=priorContext!=null && currentContext!=null + && priorContext.equals(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(baseline)); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(compare(snapshot,baseline.getVersionNumber(), + comparable, comparable && !priorContext.equals(currentContext),before.entryHashes(),after.entryHashes())); + } + private static boolean validManifest(Map entries) { + return net.modtale.model.project.SecurityManifest.valid(entries, false); + } + static ArtifactChanges compare(String snapshot, String baseline, boolean comparable, boolean contextChanged, + Map before, Map after) { + var paths=new TreeSet(); paths.addAll(before.keySet()); paths.addAll(after.keySet()); + var changes=new ArrayList(); int added=0,modified=0,removed=0,unchanged=0; + for(String path:paths) { + String change; + if(!before.containsKey(path)) {change="ADDED";added++;} + else if(!after.containsKey(path)) {change="REMOVED";removed++;} + else if(!Objects.equals(before.get(path),after.get(path))) {change="MODIFIED";modified++;} + else {change="UNCHANGED";unchanged++;} + changes.add(new FileChange(path,change)); + } + return new ArtifactChanges(snapshot,baseline,comparable,contextChanged,added,modified,removed,unchanged,List.copyOf(changes)); + } + private WardenClientService.InspectionResponse inspect(String id,String number,String path,String expected) { + Project project=projects.getRawProjectById(id); + if(project==null || project.getVersions()==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND); + ProjectReviewSnapshot.requireCurrent(project, expected); + String snapshot = ProjectReviewSnapshot.token(project); + var result = inspect(requireVersion(project, number), path); + requireUnchanged(id, snapshot); + return result; + } + private ProjectVersion requireVersion(Project project, String number) { + var matches = project.getVersions().stream().filter(Objects::nonNull) + .filter(v -> Objects.equals(v.getVersionNumber(), number)).toList(); + if (matches.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + if (matches.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "Version identity is ambiguous"); + return matches.getFirst(); + } + private void requireUnchanged(String id, String snapshot) { + var current = projects.getRawProjectById(id); + if (current == null || current.getVersions() == null) throw ProjectReviewSnapshot.conflict(); + ProjectReviewSnapshot.requireCurrent(current, snapshot); + } + private WardenClientService.InspectionResponse inspect(ProjectVersion version, String path) { + if(version.getFileUrl()==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND); + byte[] bytes=storage.download(version.getFileUrl()); + try { + String actual=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); + if(!actual.equals(version.getHash())) throw new ResponseStatusException(HttpStatus.CONFLICT,"Stored artifact hash mismatch"); + } catch(java.security.NoSuchAlgorithmException impossible) {throw new IllegalStateException(impossible);} + var response=inspector.inspectFile(bytes,"artifact.zip",path); + if(response==null || !Objects.equals(version.getHash(),response.artifactSha256())) { + throw new ResponseStatusException(HttpStatus.CONFLICT,"Artifact contents no longer match this version"); + } + return response; + } +} diff --git a/backend/src/main/java/net/modtale/controller/admin/FindingReviewController.java b/backend/src/main/java/net/modtale/controller/admin/FindingReviewController.java new file mode 100644 index 000000000..7a3675177 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/FindingReviewController.java @@ -0,0 +1,41 @@ +package net.modtale.controller.admin; + +import net.modtale.service.security.issue.FindingReviewService; +import net.modtale.service.user.account.AccountService; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/projects/{projectId}/versions/{versionId}/finding-decisions") +public class FindingReviewController { + private final FindingReviewService reviews; + private final AccountService accounts; + public FindingReviewController(FindingReviewService reviews, AccountService accounts) { + this.reviews = reviews; this.accounts = accounts; + } + @GetMapping + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") + public ResponseEntity history(@PathVariable String projectId, + @PathVariable String versionId, @RequestHeader("If-Match") String token, + @RequestParam(defaultValue = "0") int offset) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(reviews.history(projectId, versionId, token, offset)); + } + @PostMapping + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)") + public ResponseEntity record(@PathVariable String projectId, + @PathVariable String versionId, @RequestHeader("If-Match") String token, + @RequestBody FindingReviewService.Request request) { + var actor = accounts.requireCurrentUser("recording finding decisions"); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(reviews.record(projectId, versionId, token, actor.getId(), request)); + } + public record Revocation(String rationale) {} + @PostMapping("/{decisionId}/revoke") + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)") + public ResponseEntity revoke(@PathVariable String projectId, + @PathVariable String versionId, @PathVariable String decisionId, + @RequestHeader("If-Match") String token, @RequestBody Revocation request) { + var actor = accounts.requireCurrentUser("revoking finding decisions"); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(reviews.revoke(projectId, versionId, token, actor.getId(), decisionId, request.rationale())); + } +} diff --git a/backend/src/main/java/net/modtale/controller/admin/ModerationQueuePageController.java b/backend/src/main/java/net/modtale/controller/admin/ModerationQueuePageController.java new file mode 100644 index 000000000..d3e4d2cfe --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/ModerationQueuePageController.java @@ -0,0 +1,30 @@ +package net.modtale.controller.admin; + +import java.util.List; +import net.modtale.model.dto.admin.AdminVerificationQueueItemDTO; +import net.modtale.service.admin.review.ModerationQueueCursor; +import net.modtale.service.admin.review.ModerationQueuePageReader; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/v1/admin/verification/queue") +public class ModerationQueuePageController { + public record Page(List items,String nextCursor,int unavailableItems,String order,ModerationQueuePageReader.Filter filter) {} + private final ModerationQueuePageReader reader; + public ModerationQueuePageController(ModerationQueuePageReader reader) {this.reader=reader;} + @GetMapping("/page") + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") + public ResponseEntity read(@RequestParam(required=false) String cursor,@RequestParam(defaultValue="25") int limit,@RequestParam(defaultValue="ALL") ModerationQueuePageReader.Filter filter) { + ModerationQueuePageReader.Cursor position; + try { + if(limit<1 || limit>50)throw new IllegalArgumentException(); + position=ModerationQueueCursor.decode(cursor); + if(filter==null || position!=null && position.filter()!=filter)throw new IllegalArgumentException(); + } catch(IllegalArgumentException invalid) {throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid queue pagination");} + var page=reader.page(position,limit,filter); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(new Page(page.items(),ModerationQueueCursor.encode(page.next()),page.unavailableItems(),"PROJECT_VERSION",filter)); + } +} diff --git a/backend/src/main/java/net/modtale/controller/admin/PriorFindingReasoningController.java b/backend/src/main/java/net/modtale/controller/admin/PriorFindingReasoningController.java new file mode 100644 index 000000000..6a00c8165 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/PriorFindingReasoningController.java @@ -0,0 +1,19 @@ +package net.modtale.controller.admin; + +import net.modtale.service.security.issue.PriorFindingReasoningService; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/projects/{projectId}/versions/{versionId}/prior-finding-reasoning") +public class PriorFindingReasoningController { + private final PriorFindingReasoningService reasoning; + public PriorFindingReasoningController(PriorFindingReasoningService reasoning) { this.reasoning=reasoning; } + @GetMapping + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") + public ResponseEntity read(@PathVariable String projectId,@PathVariable String versionId, + @RequestParam String sourceVersionId,@RequestParam int issueIndex,@RequestHeader("If-Match") String token) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(reasoning.read(projectId,versionId,sourceVersionId,issueIndex,token)); + } +} diff --git a/backend/src/main/java/net/modtale/controller/admin/ProjectManagementController.java b/backend/src/main/java/net/modtale/controller/admin/ProjectManagementController.java index 5426e1897..e7a098d7b 100644 --- a/backend/src/main/java/net/modtale/controller/admin/ProjectManagementController.java +++ b/backend/src/main/java/net/modtale/controller/admin/ProjectManagementController.java @@ -4,7 +4,9 @@ import java.util.List; import net.modtale.model.dto.admin.AdminProjectDTO; import net.modtale.model.dto.admin.AdminProjectReviewDTO; -import net.modtale.model.dto.admin.AdminVerificationQueueItemDTO; +import org.springframework.http.ProblemDetail; +import org.springframework.http.HttpStatus; +import org.springframework.http.CacheControl; import net.modtale.model.dto.project.ProjectSummaryDTO; import net.modtale.model.dto.request.admin.RejectReasonRequest; import net.modtale.model.project.Project; @@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -45,8 +48,10 @@ public ProjectManagementController( @GetMapping("/verification/queue") @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") - public ResponseEntity> getVerificationQueue() { - return ResponseEntity.ok(projectReviewAdminService.getVerificationQueue()); + public ResponseEntity getVerificationQueue() { + return ResponseEntity.status(HttpStatus.GONE).cacheControl(CacheControl.noStore()) + .header("Link", "; rel=\"successor-version\"") + .body(ProblemDetail.forStatusAndDetail(HttpStatus.GONE, "Use the paginated moderation queue endpoint. Refresh the application to load the current review interface.")); } @GetMapping("/projects/{id}/review-details") @@ -63,41 +68,41 @@ public ResponseEntity getProjectById(@PathVariable String id) { @PutMapping("/projects/{id}/raw") @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_RAW_EDIT', authentication)") - public ResponseEntity updateRawProject(@PathVariable String id, @RequestBody Project updatedProject) { + public ResponseEntity updateRawProject(@PathVariable String id, @RequestBody java.util.Map metadata, @RequestHeader("If-Match") String token) { User currentUser = accountService.requireCurrentUser("editing raw project data"); - projectAdminOperationsService.updateRawProject(currentUser.getId(), id, updatedProject); + projectAdminOperationsService.updateRawProject(currentUser.getId(), id, metadata, token); return ResponseEntity.ok().build(); } @PostMapping("/projects/{id}/publish") @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)") - public ResponseEntity publishProject(@PathVariable String id) { + public ResponseEntity publishProject(@PathVariable String id, @RequestHeader("If-Match") String reviewToken, @RequestParam(required = false) String versionId) { User currentUser = accountService.requireCurrentUser("publishing projects"); - projectReviewAdminService.publishProject(currentUser, id); + projectReviewAdminService.publishProject(currentUser, id, reviewToken, versionId); return ResponseEntity.ok().build(); } @PostMapping("/projects/{id}/versions/{versionId}/approve") @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)") - public ResponseEntity approveVersion(@PathVariable String id, @PathVariable String versionId) { + public ResponseEntity approveVersion(@PathVariable String id, @PathVariable String versionId, @RequestHeader("If-Match") String reviewToken) { User currentUser = accountService.requireCurrentUser("approving project versions"); - projectReviewAdminService.approveVersion(currentUser, id, versionId); + projectReviewAdminService.approveVersion(currentUser, id, versionId, reviewToken); return ResponseEntity.ok().build(); } @PostMapping("/projects/{id}/versions/{versionId}/reject") @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)") - public ResponseEntity rejectVersion(@PathVariable String id, @PathVariable String versionId, @Valid @RequestBody RejectReasonRequest requestPayload) { + public ResponseEntity rejectVersion(@PathVariable String id, @PathVariable String versionId, @Valid @RequestBody RejectReasonRequest requestPayload, @RequestHeader("If-Match") String reviewToken) { User currentUser = accountService.requireCurrentUser("rejecting project versions"); - projectReviewAdminService.rejectVersion(currentUser, id, versionId, requestPayload.getReason()); + projectReviewAdminService.rejectVersion(currentUser, id, versionId, requestPayload.getReason(), reviewToken); return ResponseEntity.ok().build(); } @PostMapping("/projects/{id}/reject") @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)") - public ResponseEntity rejectProject(@PathVariable String id, @Valid @RequestBody RejectReasonRequest requestPayload) { + public ResponseEntity rejectProject(@PathVariable String id, @Valid @RequestBody RejectReasonRequest requestPayload, @RequestHeader("If-Match") String reviewToken) { User currentUser = accountService.requireCurrentUser("rejecting projects"); - projectReviewAdminService.rejectProject(currentUser, id, requestPayload.getReason()); + projectReviewAdminService.rejectProject(currentUser, id, requestPayload.getReason(), reviewToken); return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/net/modtale/controller/admin/ReviewCancellationController.java b/backend/src/main/java/net/modtale/controller/admin/ReviewCancellationController.java new file mode 100644 index 000000000..d35d6081b --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/ReviewCancellationController.java @@ -0,0 +1,30 @@ +package net.modtale.controller.admin; + +import net.modtale.service.admin.review.*; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/verification/cancellations") +@ConditionalOnProperty(name="app.warden.repair.cancellation.enabled",havingValue="true") +@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication) and @apiSecurity.hasAdminPermission('PROJECT_VERSION_RESCAN', authentication)") +public class ReviewCancellationController { + public record Prepare(String isolationId) {} + private final ReviewCancellationAccess access; + public ReviewCancellationController(ReviewCancellationAccess access){this.access=access;} + @GetMapping("/capabilities") public ResponseEntity capabilities(){return response(access.capability());} + @GetMapping("/targets/{id}") public ResponseEntity preview(@PathVariable String id){return response(access.preview(id));} + @GetMapping("/operations/{id}") public ResponseEntity recover(@PathVariable String id){return response(access.recover(id));} + @PostMapping("/prepare") public ResponseEntity prepare(@RequestBody Prepare request){return response(access.prepare(request==null?null:request.isolationId()));} + @PostMapping("/execute") public ResponseEntity execute(@RequestBody ReviewOrphanCancellationJournal.Prepared request){return response(access.execute(request));} + @PostMapping("/receipt") public ResponseEntity receipt(@RequestBody ReviewOrphanCancellationJournal.Prepared request){return response(access.receipt(request));} + @PostMapping("/checks") public ResponseEntity check(@RequestBody ReviewCancellationAccess.Check request){return response(access.check(request));} + @PostMapping("/checks/receipt") public ResponseEntity checkReceipt(@RequestBody ReviewCancellationAccess.Check request){return response(access.checkReceipt(request));} + private static ResponseEntity response(T value){return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(value);} + @ExceptionHandler(SecurityException.class) ResponseEntity forbidden(){return ResponseEntity.status(HttpStatus.FORBIDDEN).cacheControl(CacheControl.noStore()).build();} + @ExceptionHandler({IllegalArgumentException.class,org.springframework.http.converter.HttpMessageNotReadableException.class}) ResponseEntity invalid(){return ResponseEntity.badRequest().cacheControl(CacheControl.noStore()).build();} + @ExceptionHandler(IllegalStateException.class) ResponseEntity conflict(){return ResponseEntity.status(HttpStatus.CONFLICT).cacheControl(CacheControl.noStore()).build();} + @ExceptionHandler(com.mongodb.MongoException.class) ResponseEntity unavailable(){return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).cacheControl(CacheControl.noStore()).build();} +} diff --git a/backend/src/main/java/net/modtale/controller/admin/ReviewOriginController.java b/backend/src/main/java/net/modtale/controller/admin/ReviewOriginController.java new file mode 100644 index 000000000..23d94eb62 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/ReviewOriginController.java @@ -0,0 +1,38 @@ +package net.modtale.controller.admin; + +import java.util.List; +import net.modtale.service.admin.review.*; +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.types.ObjectId; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/v1/admin/verification/origins") +public class ReviewOriginController { + public record Position(String projectIdType,String projectId,int versionIndex) {} + public record Item(Position position,String versionId,boolean ambiguousVersion,String requestId,String jobId,ReviewOriginInventory.OriginState originState) {} + public record Page(List items,String nextCursor,int examinedSlots,String scope) {} + private final ReviewOriginInventory inventory; + public ReviewOriginController(ReviewOriginInventory inventory){this.inventory=inventory;} + @GetMapping("/page") + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") + public ResponseEntity read(@RequestParam(required=false) String cursor,@RequestParam(defaultValue="25") int limit) { + RemoteReviewDiscovery.Cursor position; + try { + if(limit<1 || limit>64)throw new IllegalArgumentException(); + position=ReviewOriginCursor.decode(cursor); + }catch(IllegalArgumentException invalid){throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid origin pagination");} + ReviewOriginInventory.Page page; + try {page=inventory.page(position,limit);} + catch(RuntimeException unavailable){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Review origin inventory unavailable");} + var items=page.items().stream().map(item->{ + boolean oid=item.projectId() instanceof ObjectId; + return new Item(new Position(oid?"OBJECT_ID":"STRING",oid?((ObjectId)item.projectId()).toHexString():(String)item.projectId(),item.versionIndex()), + item.versionId(),item.ambiguousVersion(),item.requestId(),item.jobId(),item.originState()); + }).toList(); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(new Page(items,ReviewOriginCursor.encode(page.next()),page.examined(),"RETAINED_REVIEW_ORIGINS")); + } +} diff --git a/backend/src/main/java/net/modtale/controller/admin/ReviewRepairController.java b/backend/src/main/java/net/modtale/controller/admin/ReviewRepairController.java new file mode 100644 index 000000000..486e64783 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/ReviewRepairController.java @@ -0,0 +1,29 @@ +package net.modtale.controller.admin; + +import net.modtale.service.admin.review.*; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/verification/repairs") +@ConditionalOnProperty(name="app.warden.repair.enabled",havingValue="true") +@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication) and @apiSecurity.hasAdminPermission('PROJECT_VERSION_RESCAN', authentication)") +public class ReviewRepairController { + private final ReviewRepairAccess access; + public ReviewRepairController(ReviewRepairAccess access){this.access=access;} + @GetMapping("/capabilities") public ResponseEntity capabilities(){return response(access.capability());} + @GetMapping("/operations") public ResponseEntity operations(@RequestParam(required=false) String cursor,@RequestParam(defaultValue="25") int limit){return response(access.operations(cursor,limit));} + @GetMapping("/operations/{id}") public ResponseEntity recover(@PathVariable String id){return response(access.recover(id));} + @PostMapping("/inspect") public ResponseEntity inspect(@RequestBody ReviewRepairAccess.Inspect request){return response(access.inspect(request));} + @PostMapping("/prepare") public ResponseEntity prepare(@RequestBody ReviewRepairAccess.Prepare request){return response(access.prepare(request));} + @PostMapping("/execute") public ResponseEntity execute(@RequestBody ReviewRepairPreparation.Prepared request){return response(access.execute(request));} + @PostMapping("/close-expired") public ResponseEntity closeExpired(@RequestBody ReviewRepairPreparation.Prepared request){return response(access.closeExpired(request));} + @PostMapping("/receipt") public ResponseEntity receipt(@RequestBody ReviewRepairPreparation.Prepared request){return response(access.receipt(request));} + private static ResponseEntity response(T value){return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(value);} + @ExceptionHandler(SecurityException.class) ResponseEntity forbidden(){return ResponseEntity.status(HttpStatus.FORBIDDEN).cacheControl(CacheControl.noStore()).build();} + @ExceptionHandler({IllegalArgumentException.class,org.springframework.http.converter.HttpMessageNotReadableException.class}) ResponseEntity invalid(){return ResponseEntity.badRequest().cacheControl(CacheControl.noStore()).build();} + @ExceptionHandler(IllegalStateException.class) ResponseEntity conflict(){return ResponseEntity.status(HttpStatus.CONFLICT).cacheControl(CacheControl.noStore()).build();} + @ExceptionHandler(com.mongodb.MongoException.class) ResponseEntity unavailable(){return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).cacheControl(CacheControl.noStore()).build();} +} diff --git a/backend/src/main/java/net/modtale/controller/admin/ReviewStateDiagnosticController.java b/backend/src/main/java/net/modtale/controller/admin/ReviewStateDiagnosticController.java new file mode 100644 index 000000000..94c08458c --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/admin/ReviewStateDiagnosticController.java @@ -0,0 +1,35 @@ +package net.modtale.controller.admin; + +import java.util.List; +import net.modtale.service.admin.review.*; +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.types.ObjectId; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/v1/admin/verification/diagnostics") +public class ReviewStateDiagnosticController { + public record Position(String projectIdType,String projectId,int versionIndex) {} + public record Item(Position position,String versionId,List reasons) {} + public record Page(List items,String nextCursor,int examinedSlots,String scope) {} + private final ReviewStateDiagnosticReader reader; + public ReviewStateDiagnosticController(ReviewStateDiagnosticReader reader){this.reader=reader;} + @GetMapping("/page") + @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)") + public ResponseEntity read(@RequestParam(required=false) String cursor,@RequestParam(defaultValue="25") int limit) { + RemoteReviewDiscovery.Cursor position; + try { + if(limit<1 || limit>64)throw new IllegalArgumentException(); + position=ReviewStateDiagnosticCursor.decode(cursor); + } catch(IllegalArgumentException invalid) {throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid diagnostic pagination");} + var page=reader.page(position,limit); + var items=page.items().stream().map(item->{ + Object id=item.projectId();boolean objectId=id instanceof ObjectId; + return new Item(new Position(objectId?"OBJECT_ID":"STRING",objectId?((ObjectId)id).toHexString():(String)id,item.versionIndex()),item.versionId(),item.reasons()); + }).toList(); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(new Page(items,ReviewStateDiagnosticCursor.encode(page.next()),page.examined(),"PENDING_SCAN_STRUCTURE")); + } +} diff --git a/backend/src/main/java/net/modtale/controller/auth/AuthController.java b/backend/src/main/java/net/modtale/controller/auth/AuthController.java index 98bc574a0..fe2d3b1c6 100644 --- a/backend/src/main/java/net/modtale/controller/auth/AuthController.java +++ b/backend/src/main/java/net/modtale/controller/auth/AuthController.java @@ -54,6 +54,7 @@ public class AuthController { private final AuthenticationMutationService authenticationMutationService; private final AccountService accountService; private final TwoFactorService twoFactorService; + private final net.modtale.service.auth.MfaEnrollmentService mfaEnrollmentService; private final LauncherAuthService launcherAuthService; private final SecurityContextRepository securityContextRepository; @@ -63,9 +64,11 @@ public AuthController( AccountService accountService, TwoFactorService twoFactorService, LauncherAuthService launcherAuthService, - SecurityContextRepository securityContextRepository + SecurityContextRepository securityContextRepository, + net.modtale.service.auth.MfaEnrollmentService mfaEnrollmentService ) { this.authenticationService = authenticationService; + this.mfaEnrollmentService = mfaEnrollmentService; this.authenticationMutationService = authenticationMutationService; this.accountService = accountService; this.twoFactorService = twoFactorService; @@ -150,29 +153,25 @@ public ResponseEntity removePassword() { return ResponseEntity.ok().build(); } - @GetMapping("/mfa/setup") - @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + @PostMapping("/mfa/setup") + @PreAuthorize("!@apiSecurity.isApiKey(authentication) && @apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") public ResponseEntity setupMfa() { User user = accountService.requireCurrentUser("setting up two-factor authentication"); if (user.isMfaEnabled()) { throw new InvalidAuthenticationRequestException("Two-factor authentication is already enabled for this account."); } - String secret = twoFactorService.generateNewSecret(); - authenticationMutationService.setTempMfaSecret(user.getId(), secret); + String secret = mfaEnrollmentService.begin(user.getId()); String qrCode = twoFactorService.generateQrCodeImageUri(secret, user.getUsername()); return ResponseEntity.ok(new MfaSetupResponse(secret, qrCode)); } @PostMapping("/mfa/verify") - @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + @PreAuthorize("!@apiSecurity.isApiKey(authentication) && @apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") public ResponseEntity verifyMfaSetup(@Valid @RequestBody VerifyMfaRequest requestPayload) { User user = accountService.requireCurrentUser("verifying two-factor authentication setup"); - if (!twoFactorService.isOtpValid(user.getMfaSecret(), requestPayload.getCode())) { - throw new InvalidAuthenticationRequestException("That verification code was not accepted, so two-factor authentication was not enabled."); - } - authenticationMutationService.enableMfa(user.getId()); + mfaEnrollmentService.verify(user.getId(), requestPayload.getCode()); return ResponseEntity.ok(new MessageResponse("MFA enabled successfully")); } diff --git a/backend/src/main/java/net/modtale/controller/project/TeamController.java b/backend/src/main/java/net/modtale/controller/project/TeamController.java index 046aa79b1..0f353c1d2 100644 --- a/backend/src/main/java/net/modtale/controller/project/TeamController.java +++ b/backend/src/main/java/net/modtale/controller/project/TeamController.java @@ -19,6 +19,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -91,9 +92,9 @@ public ResponseEntity inviteContributor( @DeleteMapping("/invites/{userId}") @PreAuthorize("@apiSecurity.hasProjectPerm(#projectId, 'PROJECT_TEAM_INVITE', authentication)") - public ResponseEntity cancelInvite(@PathVariable String projectId, @PathVariable String userId) { + public ResponseEntity cancelInvite(@PathVariable String projectId, @PathVariable String userId, @RequestParam String requestId) { User user = accountService.requireCurrentUser("canceling a project invite"); - teamService.cancelInvite(projectId, userId, user); + teamService.cancelInvite(projectId, userId, requestId, user); return ResponseEntity.ok().build(); } @@ -117,16 +118,16 @@ public ResponseEntity removeContributor(@PathVariable String projectId, @P } @PostMapping("/invite/accept") - public ResponseEntity acceptInvite(@PathVariable String projectId) { + public ResponseEntity acceptInvite(@PathVariable String projectId, @Valid @RequestBody net.modtale.model.dto.request.project.ProjectInvitationResponse response) { User user = accountService.requireCurrentUser("accepting a project invite"); - teamService.acceptInvite(projectId, user.getId()); + teamService.acceptInvite(projectId, user.getId(), response.requestId()); return ResponseEntity.ok().build(); } @PostMapping("/invite/decline") - public ResponseEntity declineInvite(@PathVariable String projectId) { + public ResponseEntity declineInvite(@PathVariable String projectId, @Valid @RequestBody net.modtale.model.dto.request.project.ProjectInvitationResponse response) { User user = accountService.requireCurrentUser("declining a project invite"); - teamService.declineInvite(projectId, user.getId()); + teamService.declineInvite(projectId, user.getId(), response.requestId()); return ResponseEntity.ok().build(); } @@ -147,7 +148,7 @@ public ResponseEntity resolveTransfer( @Valid @RequestBody ResolveProjectTransferRequest requestPayload ) { User user = accountService.requireCurrentUser("responding to a project transfer"); - teamService.resolveTransfer(projectId, requestPayload.getAccept(), user); + teamService.resolveTransfer(projectId, requestPayload.getAccept(), requestPayload.getRequestId(), user); return ResponseEntity.ok().build(); } } diff --git a/backend/src/main/java/net/modtale/controller/user/OrganizationController.java b/backend/src/main/java/net/modtale/controller/user/OrganizationController.java index f6c7b73f8..ff7fe2763 100644 --- a/backend/src/main/java/net/modtale/controller/user/OrganizationController.java +++ b/backend/src/main/java/net/modtale/controller/user/OrganizationController.java @@ -182,25 +182,25 @@ public ResponseEntity uploadOrgBanner(@PathVariable String @PostMapping("/orgs/{orgId}/invite/accept") @PreAuthorize("@apiSecurity.hasPersonalPerm('ORG_INVITE_ACCEPT', authentication)") - public ResponseEntity acceptOrgInvite(@PathVariable String orgId) { + public ResponseEntity acceptOrgInvite(@PathVariable String orgId, @Valid @RequestBody net.modtale.model.dto.request.user.OrganizationInvitationResponse response) { User user = accountService.requireCurrentUser("accepting an organization invite"); - organizationApplicationService.acceptOrganizationInvite(orgId, user); + organizationApplicationService.acceptOrganizationInvite(orgId, response.requestId(), user); return ResponseEntity.ok().build(); } @PostMapping("/orgs/{orgId}/invite/decline") @PreAuthorize("@apiSecurity.hasPersonalPerm('ORG_INVITE_DECLINE', authentication)") - public ResponseEntity declineOrgInvite(@PathVariable String orgId) { + public ResponseEntity declineOrgInvite(@PathVariable String orgId, @Valid @RequestBody net.modtale.model.dto.request.user.OrganizationInvitationResponse response) { User user = accountService.requireCurrentUser("declining an organization invite"); - organizationApplicationService.declineOrganizationInvite(orgId, user); + organizationApplicationService.declineOrganizationInvite(orgId, response.requestId(), user); return ResponseEntity.ok().build(); } @DeleteMapping("/orgs/{orgId}/invites/{userId}") @PreAuthorize("@apiSecurity.hasOrgPerm(#orgId, 'ORG_MEMBER_INVITE', authentication)") - public ResponseEntity cancelOrgInvite(@PathVariable String orgId, @PathVariable String userId) { + public ResponseEntity cancelOrgInvite(@PathVariable String orgId, @PathVariable String userId, @org.springframework.web.bind.annotation.RequestParam String requestId) { User user = accountService.requireCurrentUser("canceling an organization invite"); - organizationApplicationService.cancelOrganizationInvite(orgId, userId, user); + organizationApplicationService.cancelOrganizationInvite(orgId, userId, requestId, user); return ResponseEntity.ok().build(); } } diff --git a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java index 26d7eb781..5591d7f02 100644 --- a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java +++ b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java @@ -196,7 +196,8 @@ public static AdminProjectDTO toAdminDTO(Project project) { project.getProjectRoles(), project.getTeamMembers(), project.getTeamInvites(), - toAdminVersionSummaryDTOs(project.getVersions()) + toAdminVersionSummaryDTOs(project.getVersions()), + net.modtale.service.admin.review.ProjectReviewSnapshot.token(project) ); } @@ -367,7 +368,8 @@ public static AdminProjectVersionSummaryDTO toAdminVersionSummaryDTO(ProjectVers version.getChannel(), version.getReviewStatus(), version.getRejectionReason(), - version.getScanResult() + version.getScanResult(), + net.modtale.service.admin.review.VersionReviewSnapshot.token(version) ); } @@ -388,6 +390,11 @@ public static AdminVerificationQueueItemDTO toVerificationQueueItemDTO(Project p .findFirst() .orElseGet(() -> project.getVersions().stream().filter(java.util.Objects::nonNull).findFirst().orElse(null)); + return toVerificationQueueItemDTO(project, pendingVersion); + } + + public static AdminVerificationQueueItemDTO toVerificationQueueItemDTO(Project project, ProjectVersion pendingVersion) { + if (project == null) return null; return new AdminVerificationQueueItemDTO( project.getId(), project.getTitle(), @@ -417,6 +424,7 @@ private static AdminVerificationQueueScanDTO toVerificationQueueScanDTO(ScanResu return new AdminVerificationQueueScanDTO( scanResult.getStatus(), scanResult.getVerdict(), + scanResult.getScanState(), scanResult.getRiskScore(), scanResult.getKnownIssueCount(), scanResult.getNewIssueCount(), diff --git a/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectDTO.java b/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectDTO.java index aea8cd35a..88afed283 100644 --- a/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectDTO.java +++ b/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectDTO.java @@ -44,5 +44,6 @@ public record AdminProjectDTO( List projectRoles, List teamMembers, List teamInvites, - List versions + List versions, + String reviewToken ) {} diff --git a/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectVersionSummaryDTO.java b/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectVersionSummaryDTO.java index 0a0f60779..6374806f2 100644 --- a/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectVersionSummaryDTO.java +++ b/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectVersionSummaryDTO.java @@ -15,5 +15,6 @@ public record AdminProjectVersionSummaryDTO( ProjectVersion.Channel channel, ProjectVersion.ReviewStatus reviewStatus, String rejectionReason, - ScanResult scanResult + ScanResult scanResult, + String reviewToken ) {} diff --git a/backend/src/main/java/net/modtale/model/dto/admin/AdminVerificationQueueScanDTO.java b/backend/src/main/java/net/modtale/model/dto/admin/AdminVerificationQueueScanDTO.java index fa1ff4ab9..1b08802e1 100644 --- a/backend/src/main/java/net/modtale/model/dto/admin/AdminVerificationQueueScanDTO.java +++ b/backend/src/main/java/net/modtale/model/dto/admin/AdminVerificationQueueScanDTO.java @@ -7,6 +7,7 @@ public record AdminVerificationQueueScanDTO( ScanStatus status, String verdict, + String scanState, int riskScore, int knownIssueCount, int newIssueCount, diff --git a/backend/src/main/java/net/modtale/model/dto/request/project/ProjectInvitationResponse.java b/backend/src/main/java/net/modtale/model/dto/request/project/ProjectInvitationResponse.java new file mode 100644 index 000000000..ad00b00ce --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/project/ProjectInvitationResponse.java @@ -0,0 +1,4 @@ +package net.modtale.model.dto.request.project; + +public record ProjectInvitationResponse( + @jakarta.validation.constraints.NotBlank @jakarta.validation.constraints.Size(max=64) String requestId) {} diff --git a/backend/src/main/java/net/modtale/model/dto/request/project/ResolveProjectTransferRequest.java b/backend/src/main/java/net/modtale/model/dto/request/project/ResolveProjectTransferRequest.java index 485d62ef8..1275547ae 100644 --- a/backend/src/main/java/net/modtale/model/dto/request/project/ResolveProjectTransferRequest.java +++ b/backend/src/main/java/net/modtale/model/dto/request/project/ResolveProjectTransferRequest.java @@ -6,6 +6,11 @@ public class ResolveProjectTransferRequest { @NotNull(message = "A transfer response is required.") private Boolean accept; + @jakarta.validation.constraints.NotBlank(message = "The transfer request identity is required.") + @jakarta.validation.constraints.Size(max = 64) + private String requestId; + public String getRequestId() { return requestId; } + public void setRequestId(String value) { requestId = value; } public Boolean getAccept() { return accept; } public void setAccept(Boolean accept) { this.accept = accept; } diff --git a/backend/src/main/java/net/modtale/model/dto/request/user/OrganizationInvitationResponse.java b/backend/src/main/java/net/modtale/model/dto/request/user/OrganizationInvitationResponse.java new file mode 100644 index 000000000..324e4c701 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/user/OrganizationInvitationResponse.java @@ -0,0 +1,3 @@ +package net.modtale.model.dto.request.user; +public record OrganizationInvitationResponse( + @jakarta.validation.constraints.NotBlank @jakarta.validation.constraints.Size(max=64) String requestId) {} diff --git a/backend/src/main/java/net/modtale/model/project/Project.java b/backend/src/main/java/net/modtale/model/project/Project.java index eb338514e..b5511bb0c 100644 --- a/backend/src/main/java/net/modtale/model/project/Project.java +++ b/backend/src/main/java/net/modtale/model/project/Project.java @@ -97,6 +97,18 @@ private static Set copyPermissions(Set requestPermissions; + public String getRequestId() { return requestId; } + public void setRequestId(String value) { requestId = value; } + public String getRequestOwnerId() { return requestOwnerId; } + public void setRequestOwnerId(String value) { requestOwnerId = value; } + public long getRequestExpiresAt() { return requestExpiresAt; } + public void setRequestExpiresAt(long value) { requestExpiresAt = value; } + public Set getRequestPermissions() { return requestPermissions; } + public void setRequestPermissions(Set value) { requestPermissions = value; } @Transient private String username; @Transient private String avatarUrl; @@ -193,6 +205,9 @@ public ProjectMember(String userId, String roleId) { private List teamInvites = new ArrayList<>(); private String pendingTransferTo; + private String pendingTransferRequestId; + private String pendingTransferOwnerId; + private long pendingTransferExpiresAt; private List galleryImages = new ArrayList<>(); private Map galleryImageCaptions = new HashMap<>(); @@ -298,7 +313,16 @@ public Project() {} public void setTeamInvites(List teamInvites) { this.teamInvites = teamInvites; } public String getPendingTransferTo() { return pendingTransferTo; } - public void setPendingTransferTo(String pendingTransferTo) { this.pendingTransferTo = pendingTransferTo; } + public void setPendingTransferTo(String pendingTransferTo) { + this.pendingTransferTo = pendingTransferTo; + if (pendingTransferTo == null) { pendingTransferRequestId = null; pendingTransferOwnerId = null; pendingTransferExpiresAt = 0; } + } + public String getPendingTransferRequestId() { return pendingTransferRequestId; } + public void setPendingTransferRequestId(String id) { pendingTransferRequestId = id; } + public String getPendingTransferOwnerId() { return pendingTransferOwnerId; } + public void setPendingTransferOwnerId(String id) { pendingTransferOwnerId = id; } + public long getPendingTransferExpiresAt() { return pendingTransferExpiresAt; } + public void setPendingTransferExpiresAt(long time) { pendingTransferExpiresAt = time; } public List getGalleryImages() { return galleryImages; } public void setGalleryImages(List galleryImages) { this.galleryImages = galleryImages; } public Map getGalleryImageCaptions() { return galleryImageCaptions; } diff --git a/backend/src/main/java/net/modtale/model/project/ProjectVersion.java b/backend/src/main/java/net/modtale/model/project/ProjectVersion.java index 8fe7cb1e9..0cb64f715 100644 --- a/backend/src/main/java/net/modtale/model/project/ProjectVersion.java +++ b/backend/src/main/java/net/modtale/model/project/ProjectVersion.java @@ -3,6 +3,22 @@ import java.util.List; public class ProjectVersion { + private ReviewIsolation reviewIsolation; + @com.fasterxml.jackson.annotation.JsonIgnore + public ReviewIsolation getReviewIsolation() { return reviewIsolation; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setReviewIsolation(ReviewIsolation value) { reviewIsolation = value; } + public record ReviewIsolation(String operationId, String actorId, String beforeSha256, java.util.Date isolatedAt) { + public ReviewIsolation { + if (operationId == null || !operationId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || actorId == null || actorId.isBlank() || actorId.length() > 256 + || beforeSha256 == null || !beforeSha256.matches("[0-9a-f]{64}") || isolatedAt == null) + throw new IllegalArgumentException("Invalid review isolation provenance"); + isolatedAt = new java.util.Date(isolatedAt.getTime()); + } + @Override public java.util.Date isolatedAt() { return new java.util.Date(isolatedAt.getTime()); } + } + private List modpackConfigs; public List getModpackConfigs() { return modpackConfigs; } public void setModpackConfigs(List value) { modpackConfigs = value; } @@ -22,8 +38,29 @@ public class ProjectVersion { private List incompatibleProjectIds; private Channel channel; + private ScanResult.SecurityEvidence approvedSecurityEvidence; + private long securityApprovedAt; + private String approvedSecurityContextSha256; + public String getApprovedSecurityContextSha256() { return approvedSecurityContextSha256; } + public void setApprovedSecurityContextSha256(String value) { approvedSecurityContextSha256 = value; } + public ScanResult.SecurityEvidence getApprovedSecurityEvidence() { return approvedSecurityEvidence; } + public void setApprovedSecurityEvidence(ScanResult.SecurityEvidence value) { approvedSecurityEvidence = value; } + public long getSecurityApprovedAt() { return securityApprovedAt; } + public void setSecurityApprovedAt(long value) { securityApprovedAt = value; } private ScanResult scanResult; private List approvedIssueBaselines; + private String securityApprovalProjectId; + public String getSecurityApprovalProjectId() { return securityApprovalProjectId; } + public void setSecurityApprovalProjectId(String value) { securityApprovalProjectId = value; } + private java.util.Map approvedReviewOrigins; + public java.util.Map getApprovedReviewOrigins() { return approvedReviewOrigins; } + public void setApprovedReviewOrigins(java.util.Map value) { approvedReviewOrigins = value; } + private String approvedFindingReviewHead; + public String getApprovedFindingReviewHead() { return approvedFindingReviewHead; } + public void setApprovedFindingReviewHead(String value) { approvedFindingReviewHead = value; } + private String findingReviewHead; + public String getFindingReviewHead() { return findingReviewHead; } + public void setFindingReviewHead(String value) { findingReviewHead = value; } private ReviewStatus reviewStatus = ReviewStatus.PENDING; private String rejectionReason; @@ -36,6 +73,7 @@ public enum ReviewStatus { PENDING, SCHEDULED, APPROVED, REJECTED } public static class ApprovedIssueBaseline { private String fingerprint; private String looseFingerprint; + private String evidenceIdentity; private String severity; private int scoreImpact; private int confidence; @@ -59,6 +97,9 @@ public ApprovedIssueBaseline( this.approvedAt = approvedAt; } + public String getEvidenceIdentity() { return evidenceIdentity; } + public void setEvidenceIdentity(String value) { evidenceIdentity = value; } + public String getFingerprint() { return fingerprint; } public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; } diff --git a/backend/src/main/java/net/modtale/model/project/RemoteReviewBinding.java b/backend/src/main/java/net/modtale/model/project/RemoteReviewBinding.java new file mode 100644 index 000000000..4c3274f08 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/project/RemoteReviewBinding.java @@ -0,0 +1,29 @@ +package net.modtale.model.project; + +public record RemoteReviewBinding(String projectId, String versionId, String requestId, int attempt, + String filePath, String artifactSha256, String contextSha256, String policyVersion, + String reviewConfigSha256, String jobId, boolean manualRescan, RemoteReviewOrigin origin) { + public RemoteReviewBinding(String projectId,String versionId,String requestId,int attempt,String filePath,String artifactSha256, + String contextSha256,String policyVersion,String reviewConfigSha256,String jobId) { + this(projectId,versionId,requestId,attempt,filePath,artifactSha256,contextSha256,policyVersion,reviewConfigSha256,jobId,false); + } + // Legacy records remain readable, but their missing origin cannot authorize remote requests. + public RemoteReviewBinding(String projectId,String versionId,String requestId,int attempt,String filePath,String artifactSha256, + String contextSha256,String policyVersion,String reviewConfigSha256,String jobId,boolean manualRescan) { + this(projectId,versionId,requestId,attempt,filePath,artifactSha256,contextSha256,policyVersion,reviewConfigSha256,jobId,manualRescan,null); + } + public RemoteReviewBinding { + if (!text(projectId,128) || !text(versionId,128) || !uuid(requestId) || attempt < 1 + || !text(filePath,4096) || !digest(artifactSha256) || !digest(contextSha256) + || policyVersion == null || !policyVersion.matches("warden-3\\.0\\.0:[0-9a-f]{64}") + || !digest(reviewConfigSha256) || jobId != null && !uuid(jobId)) + throw new IllegalArgumentException("Invalid remote review binding"); + } + public RemoteReviewBinding withJobId(String value) { + if (!uuid(value) || jobId != null && !jobId.equals(value)) throw new IllegalArgumentException("Remote job identity cannot change"); + return new RemoteReviewBinding(projectId,versionId,requestId,attempt,filePath,artifactSha256,contextSha256,policyVersion,reviewConfigSha256,value,manualRescan,origin); + } + private static boolean text(String value,int max) { return value != null && !value.isBlank() && value.length() <= max && value.chars().noneMatch(Character::isISOControl); } + private static boolean digest(String value) { return value != null && value.matches("[0-9a-f]{64}"); } + private static boolean uuid(String value) { return value != null && value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); } +} diff --git a/backend/src/main/java/net/modtale/model/project/RemoteReviewOrigin.java b/backend/src/main/java/net/modtale/model/project/RemoteReviewOrigin.java new file mode 100644 index 000000000..97517cac1 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/project/RemoteReviewOrigin.java @@ -0,0 +1,8 @@ +package net.modtale.model.project; + +public record RemoteReviewOrigin(String deploymentId,String callerScope) { + public RemoteReviewOrigin { + if(deploymentId==null || !deploymentId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || callerScope==null || !callerScope.matches("[0-9a-f]{64}"))throw new IllegalArgumentException("Invalid remote review origin"); + } +} diff --git a/backend/src/main/java/net/modtale/model/project/ScanResult.java b/backend/src/main/java/net/modtale/model/project/ScanResult.java index 617c47ff7..c4f4500b9 100644 --- a/backend/src/main/java/net/modtale/model/project/ScanResult.java +++ b/backend/src/main/java/net/modtale/model/project/ScanResult.java @@ -6,6 +6,56 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class ScanResult { + private boolean manualRescan; + @com.fasterxml.jackson.annotation.JsonIgnore + public boolean isManualRescan() { return manualRescan; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setManualRescan(boolean value) { manualRescan = value; } + private RemoteReviewStatus remoteStatus; + public record RemoteReviewStatus(String jobId,String state,boolean artifactRetained,long createdAt,long expiresAt,String workState) {} + @com.fasterxml.jackson.annotation.JsonIgnore + public RemoteReviewStatus getRemoteStatus() { return remoteStatus; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setRemoteStatus(RemoteReviewStatus value) { remoteStatus = value; } + private RemoteReviewPoll remotePoll; + public record RemoteReviewPoll(String token, java.util.Date leaseUntil, java.util.Date nextPollAt) {} + @com.fasterxml.jackson.annotation.JsonIgnore + public RemoteReviewPoll getRemotePoll() { return remotePoll; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setRemotePoll(RemoteReviewPoll value) { remotePoll = value; } + private RemoteReviewBinding remoteReview; + @com.fasterxml.jackson.annotation.JsonIgnore + public RemoteReviewBinding getRemoteReview() { return remoteReview; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setRemoteReview(RemoteReviewBinding value) { remoteReview = value; } + private SecurityEvidence securityEvidence; + private boolean artifactVerified; + private String reviewedContextSha256; + @com.fasterxml.jackson.annotation.JsonIgnore + public String getReviewedContextSha256() { return reviewedContextSha256; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setReviewedContextSha256(String value) { reviewedContextSha256 = value; } + private java.util.Map reusedReviewOrigins; + @com.fasterxml.jackson.annotation.JsonIgnore + public java.util.Map getReusedReviewOrigins() { return reusedReviewOrigins; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setReusedReviewOrigins(java.util.Map value) { reusedReviewOrigins = value; } + private long reusedReviewApprovedAt; + public long getReusedReviewApprovedAt() { return reusedReviewApprovedAt; } + public void setReusedReviewApprovedAt(long value) { reusedReviewApprovedAt = value; } + private String reusedReviewVersion; + public SecurityEvidence getSecurityEvidence() { return securityEvidence; } + public void setSecurityEvidence(SecurityEvidence value) { securityEvidence = value; } + @com.fasterxml.jackson.annotation.JsonIgnore + public boolean isArtifactVerified() { return artifactVerified; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setArtifactVerified(boolean value) { artifactVerified = value; } + public String getReusedReviewVersion() { return reusedReviewVersion; } + public void setReusedReviewVersion(String value) { reusedReviewVersion = value; if (value == null) reusedReviewOrigins = null; } + @JsonIgnoreProperties(ignoreUnknown = true) + public record SecurityEvidence(String policyVersion, String artifactSha256, String contentSha256, + boolean complete, boolean clearanceGranted, String reviewState, java.util.Map entryHashes) {} + private ScanStatus status; private String verdict; private String riskLevel; @@ -14,6 +64,11 @@ public class ScanResult { private int riskScore; private int confidenceScore; private int scanAttempt; + private String scanRequestId; + @com.fasterxml.jackson.annotation.JsonIgnore + public String getScanRequestId() { return scanRequestId; } + @com.fasterxml.jackson.annotation.JsonIgnore + public void setScanRequestId(String value) { scanRequestId = value; } private long scanTimestamp; private long holdUntilTimestamp; @@ -204,6 +259,7 @@ public static class ScanIssue { private boolean resolved; private String fingerprint; private boolean knownIssue; + private boolean historicalFileEvidenceIdentical; private boolean escalated; private String baselineVersion; private int baselineScoreImpact; @@ -259,6 +315,9 @@ public ScanIssue() {} public String getFingerprint() { return fingerprint; } public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; } + public boolean isHistoricalFileEvidenceIdentical() { return historicalFileEvidenceIdentical; } + public void setHistoricalFileEvidenceIdentical(boolean value) { historicalFileEvidenceIdentical = value; } + public boolean isKnownIssue() { return knownIssue; } public void setKnownIssue(boolean knownIssue) { this.knownIssue = knownIssue; } diff --git a/backend/src/main/java/net/modtale/model/project/SecurityManifest.java b/backend/src/main/java/net/modtale/model/project/SecurityManifest.java new file mode 100644 index 000000000..7a2743770 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/project/SecurityManifest.java @@ -0,0 +1,44 @@ +package net.modtale.model.project; + +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.util.*; +import java.util.regex.Pattern; + +/** Shared bounds and identity rules for artifact entry manifests. */ +public final class SecurityManifest { + private static final Pattern SHA256 = Pattern.compile("[0-9a-f]{64}"); + private SecurityManifest() {} + public static final class Unavailable extends IllegalStateException { + public Unavailable(Throwable cause) { super("Artifact manifest is unavailable or invalid", cause); } + } + public static boolean valid(Map entries, boolean allowEmpty) { + try { return validLoaded(entries, allowEmpty); } + catch (Unavailable unavailable) { return false; } + } + private static boolean validLoaded(Map entries, boolean allowEmpty) { + if (entries == null || entries.isEmpty()) return allowEmpty; + if (entries.size() > 20_000) return false; + int characters = 0; + for (var entry : entries.entrySet()) { + String path = entry.getKey(); + if (path == null || path.isBlank() || path.length() > 8192 || !digest(entry.getValue())) return false; + characters += path.length(); + if (characters > 1_000_000) return false; + } + return true; + } + public static boolean digest(String value) { return value != null && SHA256.matcher(value).matches(); } + public static String identity(Map entries) { + if (!valid(entries, false)) throw new IllegalArgumentException("Invalid artifact manifest"); + try { + MessageDigest hash = MessageDigest.getInstance("SHA-256"); + for (var entry : new TreeMap<>(entries).entrySet()) { + hash.update((entry.getKey().length() + ":").getBytes(StandardCharsets.UTF_8)); + hash.update(entry.getKey().getBytes(StandardCharsets.UTF_8)); + hash.update((":" + entry.getValue() + "\n").getBytes(StandardCharsets.UTF_8)); + } + return HexFormat.of().formatHex(hash.digest()); + } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } +} diff --git a/backend/src/main/java/net/modtale/model/user/User.java b/backend/src/main/java/net/modtale/model/user/User.java index 4e59007d9..3b22a8f31 100644 --- a/backend/src/main/java/net/modtale/model/user/User.java +++ b/backend/src/main/java/net/modtale/model/user/User.java @@ -143,6 +143,18 @@ public static class OrganizationMember implements Serializable { private String userId; private String role; private String roleId; + private String requestId; + private long requestExpiresAt; + private Set requestPermissions; + private Set requestOwnerIds; + public String getRequestId() { return requestId; } + public void setRequestId(String value) { requestId = value; } + public long getRequestExpiresAt() { return requestExpiresAt; } + public void setRequestExpiresAt(long value) { requestExpiresAt = value; } + public Set getRequestPermissions() { return requestPermissions; } + public void setRequestPermissions(Set value) { requestPermissions = value; } + public Set getRequestOwnerIds() { return requestOwnerIds; } + public void setRequestOwnerIds(Set value) { requestOwnerIds = value; } public OrganizationMember() {} public OrganizationMember(String userId, String roleId) { diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminOperationsService.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminOperationsService.java index 9c3d3dcc7..98f6ce29b 100644 --- a/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminOperationsService.java +++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminOperationsService.java @@ -25,8 +25,8 @@ public AdminProjectDTO getProjectById(String id) { return projectAdminQueryService.getProjectById(id); } - public void updateRawProject(String adminId, String id, Project updatedProject) { - projectAdminQueryService.updateRawProject(adminId, id, updatedProject); + public void updateRawProject(String adminId, String id, java.util.Map metadata, String token) { + projectAdminQueryService.updateRawProject(adminId, id, metadata, token); } public void deleteProject(net.modtale.model.user.User adminUser, String id, String reason) { diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminQueryService.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminQueryService.java index dd60ad2d7..c90c62f38 100644 --- a/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminQueryService.java +++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminQueryService.java @@ -8,7 +8,8 @@ import net.modtale.model.project.Project; import net.modtale.model.project.ProjectSort; import net.modtale.model.project.ProjectViewCategory; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.admin.audit.AdminAuditLogger; import net.modtale.service.project.query.ProjectService; import net.modtale.service.project.query.SearchService; @@ -18,18 +19,18 @@ @Service public class ProjectAdminQueryService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final SearchService searchService; private final AdminAuditLogger adminAuditLogger; public ProjectAdminQueryService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, SearchService searchService, AdminAuditLogger adminAuditLogger ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.searchService = searchService; this.adminAuditLogger = adminAuditLogger; @@ -43,13 +44,13 @@ public AdminProjectDTO getProjectById(String id) { return ProjectMapper.toAdminDTO(project); } - public void updateRawProject(String adminId, String id, Project updatedProject) { - Project existing = requireProject(id); - projectService.evictProjectCache(existing); - updatedProject.setId(existing.getId()); - projectRepository.save(updatedProject); - projectService.evictProjectCache(updatedProject); - adminAuditLogger.logAction(adminId, "RAW_UPDATE_PROJECT", existing.getId(), "PROJECT", "Updated via Raw JSON"); + public void updateRawProject(String adminId, String id, java.util.Map metadata, String token) { + ProjectMetadataRepair.validate(metadata); + var snapshot = reviewPersistence.capture(id, token); + if (!reviewPersistence.applyMetadataRepair(snapshot, metadata)) throw ProjectReviewSnapshot.conflict(); + projectService.evictProjectCache(snapshot.project()); + adminAuditLogger.logAction(adminId, "RAW_UPDATE_PROJECT", id, "PROJECT", + "Repaired metadata fields: " + String.join(", ", new java.util.TreeSet<>(metadata.keySet()))); } public List searchProjects(String query, boolean deleted) { diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectMetadataRepair.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectMetadataRepair.java new file mode 100644 index 000000000..0b0b82b6f --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectMetadataRepair.java @@ -0,0 +1,53 @@ +package net.modtale.service.admin.project; + +import java.util.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public final class ProjectMetadataRepair { + private ProjectMetadataRepair() {} + private static final Set STRINGS = Set.of("title", "about", "description", "imageUrl", "bannerUrl", + "repositoryUrl", "license", "hmWikiSlug"); + private static final Set FLAGS = Set.of("customLicenseOpenSource", "allowModpacks", "allowComments", + "hmWikiEnabled", "galleryCarouselEnabled"); + private static final Set LISTS = Set.of("tags", "galleryImages"); + private static final Set MAPS = Set.of("links", "galleryImageCaptions"); + public static void validate(Map metadata) { + if (metadata == null || metadata.isEmpty() || metadata.size() > 17) throw invalid(); + long characters = 0; + for (var entry : metadata.entrySet()) { + String field = entry.getKey(); Object value = entry.getValue(); + if (field == null) throw invalid(); + if (STRINGS.contains(field)) { + if (value != null && !(value instanceof String)) throw invalid(); + if (field.equals("title") && (!(value instanceof String text) || text.isBlank())) throw invalid(); + characters += textSize(value, field.equals("description") ? 100_000 : 4_096); + } else if (FLAGS.contains(field)) { + if (!(value instanceof Boolean)) throw invalid(); + } else if (LISTS.contains(field)) { + if (!(value instanceof List values) || values.size() > 1000) throw invalid(); + for (Object item : values) { + if (!(item instanceof String)) throw invalid(); + characters += textSize(item, 4096); + } + } else if (MAPS.contains(field)) { + if (!(value instanceof Map values) || values.size() > 1000) throw invalid(); + for (var pair : values.entrySet()) { + if (!(pair.getKey() instanceof String) || !(pair.getValue() instanceof String)) throw invalid(); + characters += textSize(pair.getKey(), 4096) + textSize(pair.getValue(), 4096); + } + } else throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Only editable project metadata is accepted. Use the dedicated workflow for versions, access, and review decisions."); + if (characters > 200_000) throw invalid(); + } + } + private static int textSize(Object value, int maximum) { + if (value == null) return 0; + int size = ((String) value).length(); + if (size > maximum) throw invalid(); + return size; + } + private static ResponseStatusException invalid() { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, "Project metadata has invalid fields, types, or size."); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java index 204fe5de0..29af16ffb 100644 --- a/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java +++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java @@ -5,7 +5,8 @@ import net.modtale.exception.ResourceNotFoundException; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectStatus; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.admin.audit.AdminAuditLogger; import net.modtale.service.analytics.ScoringService; import net.modtale.service.communication.NotificationService; @@ -19,7 +20,7 @@ @Service public class ProjectModerationService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final ProjectRetentionService projectRetentionService; private final ProjectDeletionService projectDeletionService; @@ -30,7 +31,7 @@ public class ProjectModerationService { private final AdminAuditLogger adminAuditLogger; public ProjectModerationService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ProjectRetentionService projectRetentionService, ProjectDeletionService projectDeletionService, @@ -40,7 +41,7 @@ public ProjectModerationService( ProjectVersionAccessService projectVersionAccessService, AdminAuditLogger adminAuditLogger ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectRetentionService = projectRetentionService; this.projectDeletionService = projectDeletionService; @@ -85,10 +86,12 @@ public void restoreProject(net.modtale.model.user.User adminUser, String id, Pro public void unlistProject(net.modtale.model.user.User adminUser, String id, String reason) { Project targetProject = requireProject(id); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(targetProject)); + targetProject = snapshot.project(); targetProject.setStatus(ProjectStatus.UNLISTED); targetProject.setExpiresAt(null); scoringService.markProjectRankingDirty(targetProject); - projectRepository.save(targetProject); + if (!reviewPersistence.unlist(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(targetProject); notificationService.sendNotifcation( @@ -103,18 +106,14 @@ public void unlistProject(net.modtale.model.user.User adminUser, String id, Stri public void deleteProjectVersion(net.modtale.model.user.User adminUser, String id, String versionId) { Project project = requireProject(id); - projectVersionAccessService.requireById(project, versionId, + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); + var removed = projectVersionAccessService.requireById(project, versionId, () -> new ResourceNotFoundException("Version not found.")); - project.getVersions().removeIf(version -> { - if (!version.getId().equals(versionId)) { - return false; - } - projectDeletionService.deleteVersionFile(version); - return true; - }); - - projectRepository.save(project); + project.getVersions().removeIf(version -> version.getId().equals(versionId)); + if (!reviewPersistence.applyVersionList(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); + projectDeletionService.deleteVersionFile(removed); adminAuditLogger.logAction(adminUser.getId(), "DELETE_VERSION", id, "VERSION", "VerID: " + versionId); } diff --git a/backend/src/main/java/net/modtale/service/admin/review/ModerationQueueCursor.java b/backend/src/main/java/net/modtale/service/admin/review/ModerationQueueCursor.java new file mode 100644 index 000000000..01910a5e4 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ModerationQueueCursor.java @@ -0,0 +1,39 @@ +package net.modtale.service.admin.review; + +import org.bson.types.ObjectId; +import java.nio.ByteBuffer; +import java.nio.charset.*; +import java.util.Base64; + +public final class ModerationQueueCursor { + private ModerationQueueCursor() {} + public static String encode(ModerationQueuePageReader.Cursor cursor) { + if(cursor==null)return null; + Object id=cursor.projectId();String type=id instanceof ObjectId?"o":"s"; + String value=id instanceof ObjectId objectId?objectId.toHexString():(String)id; + if(!StandardCharsets.UTF_8.newEncoder().canEncode(value))throw new IllegalArgumentException("Invalid queue cursor"); + String encoded=Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + return (cursor.filter()==ModerationQueuePageReader.Filter.ALL?"1.":"2."+cursor.filter().name()+".")+type+"."+cursor.versionIndex()+"."+encoded; + } + public static ModerationQueuePageReader.Cursor decode(String token) { + if(token==null)return null; + if(token.length()>800 || !token.matches("(1|2\\.(SECURITY|OPERATIONS))\\.[os]\\.(0|[1-9][0-9]{0,7})\\.[A-Za-z0-9_-]{1,684}"))throw new IllegalArgumentException("Invalid queue cursor"); + try { + boolean filtered=token.startsWith("2."); + var parts=token.split("\\."); + var filter=filtered?ModerationQueuePageReader.Filter.valueOf(parts[1]):ModerationQueuePageReader.Filter.ALL; + var fields=filtered?new String[]{parts[0],parts[2],parts[3],parts[4]}:parts; + byte[] bytes=Base64.getUrlDecoder().decode(fields[3]); + String value=StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)).toString(); + Object id=value; + if(fields[1].equals("o")) { + if(!value.matches("[0-9a-f]{24}"))throw new IllegalArgumentException("Invalid queue cursor"); + id=new ObjectId(value); + } + var cursor=new ModerationQueuePageReader.Cursor(id,Long.parseLong(fields[2]),filter); + if(!encode(cursor).equals(token))throw new IllegalArgumentException("Invalid queue cursor"); + return cursor; + } catch(CharacterCodingException | IllegalArgumentException invalid) {throw new IllegalArgumentException("Invalid queue cursor");} + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ModerationQueuePageReader.java b/backend/src/main/java/net/modtale/service/admin/review/ModerationQueuePageReader.java new file mode 100644 index 000000000..98557e2b4 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ModerationQueuePageReader.java @@ -0,0 +1,96 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import net.modtale.model.dto.admin.*; +import net.modtale.model.project.*; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; +import java.util.*; +import java.util.concurrent.TimeUnit; + +@Service +public final class ModerationQueuePageReader { + public enum Filter { ALL, SECURITY, OPERATIONS } + public record Cursor(Object projectId,long versionIndex,Filter filter) { + public Cursor(Object projectId,long versionIndex) {this(projectId,versionIndex,Filter.ALL);} + public Cursor { + if (!(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.codePointCount(0,s.length())<=128) + || versionIndex<0 || versionIndex>16*1024*1024 || filter==null) throw new IllegalArgumentException("Invalid queue cursor"); + } + } + public record Page(List items,Cursor next,int unavailableItems) { + public Page { items=List.copyOf(items); } + } + private final MongoCollection projects; + public ModerationQueuePageReader(MongoTemplate mongo) { + projects=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Page page(Cursor cursor,int limit) {return page(cursor,limit,Filter.ALL);} + public Page page(Cursor cursor,int limit,Filter filter) { + if(filter==null || cursor!=null && cursor.filter()!=filter)throw new IllegalArgumentException("Queue filter changed"); + if(limit<1 || limit>50)throw new IllegalArgumentException("Invalid queue page size"); + var stages=new ArrayList(); + var match=new Document("status",new Document("$in",List.of("PENDING","PUBLISHED"))) + .append("_id",new Document("$type",List.of("string","objectId"))); + var idBound=new Document("$cond",List.of(new Document("$eq",List.of(new Document("$type","$_id"),"string")), + new Document("$and",List.of(new Document("$gt",List.of(new Document("$strLenCP","$_id"),0)),new Document("$lte",List.of(new Document("$strLenCP","$_id"),128)))),true)); + match.append("$expr",cursor==null?idBound:new Document("$and",List.of(idBound,new Document("$gte",List.of("$_id",literal(cursor.projectId())))))); + stages.add(new Document("$match",match));stages.add(new Document("$sort",new Document("_id",1))); + var versions=new Document("$cond",List.of(new Document("$isArray","$versions"),"$versions",List.of())); + var pendingOrScanning=new Document("$filter",new Document("input",versions).append("as","v").append("cond",new Document("$or",List.of( + new Document("$eq",List.of("$$v.reviewStatus","PENDING")),new Document("$eq",List.of("$$v.scanResult.status","SCANNING")))))); + var projectOnly=new Document("$and",List.of(new Document("$eq",List.of("$status","PENDING")),new Document("$eq",List.of(new Document("$size",pendingOrScanning),0)))); + stages.add(new Document("$set",new Document("queueProjectOnly",projectOnly) + .append("queueVersion",new Document("$cond",List.of(projectOnly,Collections.singletonList(null),versions))))); + stages.add(new Document("$unwind",new Document("path","$queueVersion").append("includeArrayIndex","queueIndex").append("preserveNullAndEmptyArrays",true))); + stages.add(new Document("$match",new Document("$or",List.of(new Document("queueProjectOnly",true), + new Document("queueVersion.reviewStatus","PENDING").append("queueVersion.scanResult.status",new Document("$ne","SCANNING")))))); + if(cursor!=null)stages.add(new Document("$match",new Document("$expr",new Document("$or",List.of( + new Document("$gt",List.of("$_id",literal(cursor.projectId()))),new Document("$gt",List.of("$queueIndex",cursor.versionIndex()))))))); + if(filter==Filter.OPERATIONS)stages.add(new Document("$match",new Document("queueVersion.scanResult.status","FAILED"))); + if(filter==Filter.SECURITY)stages.add(new Document("$match",new Document("$or",List.of( + new Document("queueVersion.scanResult.status",new Document("$in",List.of("INFECTED","FLAGGED","SUSPICIOUS"))), + new Document("queueVersion.scanResult.verdict","BLOCK"), + new Document("queueVersion.scanResult.newIssueCount",new Document("$gt",0)), + new Document("queueVersion.scanResult.escalatedIssueCount",new Document("$gt",0)))))); + stages.add(new Document("$limit",limit+1)); + var projection=new Document("_id",1).append("queueIndex",1).append("queueProjectOnly",1) + .append("title",text("$title",256)).append("description",text("$description",1024)).append("author",text("$author",128)) + .append("imageUrl",text("$imageUrl",2048)).append("classification",text("$classification",32)).append("status",1).append("updatedAt",text("$updatedAt",64)) + .append("versionId",text("$queueVersion._id",129)).append("versionNumber",text("$queueVersion.versionNumber",128)) + .append("changelog",text("$queueVersion.changelog",1024)).append("scanStatus",text("$queueVersion.scanResult.status",32)) + .append("verdict",text("$queueVersion.scanResult.verdict",32)).append("scanState",text("$queueVersion.scanResult.scanState",64)); + for(String field:List.of("riskScore","knownIssueCount","newIssueCount","escalatedIssueCount"))projection.append(field,new Document("$convert",new Document("input","$queueVersion.scanResult."+field).append("to","int").append("onError",0).append("onNull",0))); + projection.append("idCount",new Document("$size",new Document("$filter",new Document("input",versions).append("as","v") + .append("cond",new Document("$eq",List.of("$$v._id","$queueVersion._id")))))); + stages.add(new Document("$project",projection)); + var rows=new ArrayList(); + try(var iterator=projects.aggregate(stages).collation(Collation.builder().locale("simple").build()).allowDiskUse(false) + .maxTime(5,TimeUnit.SECONDS).batchSize(limit+1).iterator()) {while(iterator.hasNext())rows.add(iterator.next());} + boolean more=rows.size()>limit;if(more)rows.removeLast(); + var items=new ArrayList();int unavailable=0; + for(var row:rows) { + Object id=row.get("_id");String projectId=id instanceof ObjectId oid?oid.toHexString():(String)id; + boolean only=Boolean.TRUE.equals(row.get("queueProjectOnly"));String versionId=row.getString("versionId"); + if(!validId(projectId) || !only && (!validId(versionId) || row.getInteger("idCount",0)!=1)) {unavailable++;continue;} + var status=enumValue(ScanStatus.class,row.getString("scanStatus")); + if(row.getString("scanStatus")!=null && status==null) {unavailable++;continue;} + var scan=row.getString("scanStatus")==null?null:new AdminVerificationQueueScanDTO(status,row.getString("verdict"),row.getString("scanState"), + row.getInteger("riskScore",0),row.getInteger("knownIssueCount",0),row.getInteger("newIssueCount",0),row.getInteger("escalatedIssueCount",0)); + items.add(new AdminVerificationQueueItemDTO(projectId,row.getString("title"),row.getString("description"),row.getString("author"),row.getString("imageUrl"), + enumValue(ProjectClassification.class,row.getString("classification")),enumValue(ProjectStatus.class,row.getString("status")),row.getString("updatedAt"), + only?null:new AdminVerificationQueueVersionDTO(versionId,row.getString("versionNumber"),row.getString("changelog"),ProjectVersion.ReviewStatus.PENDING,scan))); + } + Cursor next=null; + if(more) {var last=rows.getLast();next=new Cursor(last.get("_id"),((Number)last.get("queueIndex")).longValue(),filter);} + return new Page(items,next,unavailable); + } + private static boolean validId(String s) {return s!=null && !s.isBlank() && s.codePointCount(0,s.length())<=128 && s.chars().noneMatch(Character::isISOControl);} + private static > T enumValue(Class type,String value) {try{return value==null?null:Enum.valueOf(type,value);}catch(IllegalArgumentException invalid){return null;}} + private static Document text(String field,int limit) {return new Document("$cond",Arrays.asList(new Document("$eq",List.of(new Document("$type",field),"string")),new Document("$substrCP",List.of(field,0,limit)),null));} + private static Document literal(Object value) {return new Document("$literal",value);} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewAdminService.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewAdminService.java index b30471024..d1ad2fd51 100644 --- a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewAdminService.java +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewAdminService.java @@ -1,8 +1,6 @@ package net.modtale.service.admin.review; -import java.util.List; import net.modtale.model.dto.admin.AdminProjectReviewDTO; -import net.modtale.model.dto.admin.AdminVerificationQueueItemDTO; import net.modtale.model.user.User; import org.springframework.stereotype.Service; @@ -20,27 +18,23 @@ public ProjectReviewAdminService( this.projectReviewDecisionService = projectReviewDecisionService; } - public List getVerificationQueue() { - return projectReviewQueryService.getVerificationQueue(); - } - public AdminProjectReviewDTO getProjectReviewDetails(String id) { return projectReviewQueryService.getProjectReviewDetails(id); } - public void publishProject(User adminUser, String id) { - projectReviewDecisionService.publishProject(adminUser, id); + public void publishProject(User adminUser, String id, String reviewToken, String versionId) { + projectReviewDecisionService.publishProject(adminUser, id, reviewToken, versionId); } - public void approveVersion(User adminUser, String id, String versionId) { - projectReviewDecisionService.approveVersion(adminUser, id, versionId); + public void approveVersion(User adminUser, String id, String versionId, String reviewToken) { + projectReviewDecisionService.approveVersion(adminUser, id, versionId, reviewToken); } - public void rejectVersion(User adminUser, String id, String versionId, String reason) { - projectReviewDecisionService.rejectVersion(adminUser, id, versionId, reason); + public void rejectVersion(User adminUser, String id, String versionId, String reason, String reviewToken) { + projectReviewDecisionService.rejectVersion(adminUser, id, versionId, reason, reviewToken); } - public void rejectProject(User adminUser, String id, String reason) { - projectReviewDecisionService.rejectProject(adminUser, id, reason); + public void rejectProject(User adminUser, String id, String reason, String reviewToken) { + projectReviewDecisionService.rejectProject(adminUser, id, reason, reviewToken); } } diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewDecisionService.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewDecisionService.java index 79b08044b..f72f51160 100644 --- a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewDecisionService.java +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewDecisionService.java @@ -17,26 +17,26 @@ public ProjectReviewDecisionService( this.projectReviewEffectService = projectReviewEffectService; } - public void publishProject(User adminUser, String id) { - projectReviewTransitionService.publishProject(adminUser, id); + public void publishProject(User adminUser, String id, String reviewToken, String versionId) { + projectReviewTransitionService.publishProject(adminUser, id, reviewToken, versionId); projectReviewEffectService.onProjectPublished(adminUser, id); } - public void approveVersion(User adminUser, String id, String versionId) { + public void approveVersion(User adminUser, String id, String versionId, String reviewToken) { ProjectReviewTransitionService.VersionReviewDecision decision = - projectReviewTransitionService.approveVersion(id, versionId); + projectReviewTransitionService.approveVersion(id, versionId, reviewToken); projectReviewEffectService.onVersionApproved(adminUser, id, versionId, decision); } - public void rejectVersion(User adminUser, String id, String versionId, String reason) { + public void rejectVersion(User adminUser, String id, String versionId, String reason, String reviewToken) { ProjectReviewTransitionService.VersionReviewDecision decision = - projectReviewTransitionService.rejectVersion(id, versionId, reason); + projectReviewTransitionService.rejectVersion(id, versionId, reason, reviewToken); projectReviewEffectService.onVersionRejected(adminUser, id, versionId, decision); } - public void rejectProject(User adminUser, String id, String reason) { + public void rejectProject(User adminUser, String id, String reason, String reviewToken) { ProjectReviewTransitionService.ProjectRejectionDecision decision = - projectReviewTransitionService.rejectProject(id, reason); + projectReviewTransitionService.rejectProject(id, reason, reviewToken); projectReviewEffectService.onProjectRejected(adminUser, id, decision); } } diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewPersistence.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewPersistence.java new file mode 100644 index 000000000..7626c6d99 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewPersistence.java @@ -0,0 +1,238 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectVersion; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.data.mongodb.core.convert.UpdateMapper; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Service; +import java.util.*; + +@Service +public class ProjectReviewPersistence { + private final MongoTemplate mongo; + public ProjectReviewPersistence(MongoTemplate mongo) { this.mongo = mongo; } + public record Snapshot(Document raw, Project project) {} + public Snapshot capture(String id, String token) { + var entity = mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var query = new QueryMapper(mongo.getConverter()).getMappedObject(new Document("_id", id), entity); + var raw = mongo.getCollection(mongo.getCollectionName(Project.class)).find(query).first(); + if (raw == null) throw ProjectReviewSnapshot.conflict(); + var project = mongo.getConverter().read(Project.class, raw); + ProjectReviewSnapshot.requireCurrent(project, token); + return new Snapshot(raw, project); + } + public boolean apply(Snapshot snapshot, String approvedVersionId) { + Project project = snapshot.project(); + var originGuard = new org.springframework.data.mongodb.core.query.Query(); + var update = new Update().set("status", project.getStatus()).set("expiresAt", project.getExpiresAt()) + .set("updatedAt", project.getUpdatedAt()).set("createdAt", project.getCreatedAt()) + .set("approvedBy", project.getApprovedBy()).set("imageUrl", project.getImageUrl()) + .set("rankingDirty", true); + if (approvedVersionId != null) { + var versions = project.getVersions(); + int index = -1; + for (int i = 0; i < versions.size(); i++) if (approvedVersionId.equals(versions.get(i).getId())) { + if (index >= 0) throw ProjectReviewSnapshot.conflict(); + index = i; + } + if (index < 0) throw ProjectReviewSnapshot.conflict(); + ProjectVersion version = versions.get(index); + var original = mongo.getConverter().read(ProjectVersion.class, snapshot.raw().getList("versions", Document.class).get(index)); + if (!approvedVersionId.equals(original.getId())) throw ProjectReviewSnapshot.conflict(); + if (version.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED) + net.modtale.service.security.issue.FindingReviewHistory.requireManualApproval(mongo, project.getId(), original); + version.setApprovedFindingReviewHead(version.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED + ? original.getFindingReviewHead() : null); + var scan = original.getScanResult(); + if (version.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED && scan != null && scan.getReusedReviewVersion() != null + && (!net.modtale.service.security.scan.ArtifactClearancePolicy.complete(scan) + || !net.modtale.service.security.scan.ArtifactReviewLineage.bind(mongo, project.getId(), scan, originGuard))) return false; + String path = "versions." + index + "."; + update.set(path + "reviewStatus", version.getReviewStatus()).set(path + "rejectionReason", version.getRejectionReason()) + .set(path + "scheduledPublishDate", version.getScheduledPublishDate()).set(path + "scanResult", version.getScanResult()) + .set(path + "securityApprovalProjectId", version.getSecurityApprovalProjectId()) + .set(path + "approvedReviewOrigins", version.getApprovedReviewOrigins()) + .set(path + "approvedFindingReviewHead", version.getApprovedFindingReviewHead()) + .set(path + "approvedSecurityEvidence", version.getApprovedSecurityEvidence()) + .set(path + "approvedSecurityContextSha256", version.getApprovedSecurityContextSha256()) + .set(path + "securityApprovedAt", version.getSecurityApprovedAt()) + .set(path + "approvedIssueBaselines", version.getApprovedIssueBaselines()); + } + return applyUpdate(snapshot, update, originGuard.getQueryObject().get("$expr")); + } + public boolean submitDraft(Snapshot snapshot) { + if (snapshot.project().getStatus() != net.modtale.model.project.ProjectStatus.PENDING + || !"DRAFT".equals(snapshot.raw().getString("status"))) throw ProjectReviewSnapshot.conflict(); + var update = new Update().set("status", net.modtale.model.project.ProjectStatus.PENDING) + .set("expiresAt", null).set("updatedAt", java.time.LocalDateTime.now().toString()); + var originals = snapshot.raw().getList("versions", Document.class, List.of()); + var versions = snapshot.project().getVersions(); + if (originals.size() != versions.size()) throw ProjectReviewSnapshot.conflict(); + for (int i = 0; i < versions.size(); i++) { + var original = mongo.getConverter().read(ProjectVersion.class, originals.get(i)); + var version = versions.get(i); + if (!Objects.equals(original.getId(), version.getId())) throw ProjectReviewSnapshot.conflict(); + String path = "versions." + i + "."; + if (original.getScanResult() == null && version.getScanResult() != null) { + update.set(path + "scanResult", version.getScanResult()).set(path + "reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set(path + "scheduledPublishDate", null); + } else if (original.getReviewStatus() == null || original.getReviewStatus() == ProjectVersion.ReviewStatus.REJECTED) { + update.set(path + "reviewStatus", ProjectVersion.ReviewStatus.PENDING); + } + } + return applyUpdate(snapshot, update); + } + public boolean applyTeam(Snapshot snapshot) { return applyTeam(snapshot, null); } + public boolean resolveContributorInvite(Snapshot snapshot, String userId, String requestId, boolean accepting) { + var original = mongo.getConverter().read(Project.class, snapshot.raw()); + var invite = net.modtale.service.project.team.ProjectInvitationPolicy.require(original, userId, requestId, accepting); + return applyTeam(snapshot, accepting ? new Document("$gt", List.of(invite.getRequestExpiresAt(), new Document("$toLong", "$$NOW"))) : null); + } + public boolean resolveTransfer(Snapshot snapshot, String requestId) { + if (requestId == null || !requestId.equals(snapshot.raw().getString("pendingTransferRequestId")) + || snapshot.raw().get("pendingTransferOwnerId") == null + || !Objects.equals(snapshot.raw().get("pendingTransferOwnerId"), snapshot.raw().get("authorId"))) + throw ProjectReviewSnapshot.conflict(); + return applyTeam(snapshot, new Document("$gt", List.of("$pendingTransferExpiresAt", new Document("$toLong", "$$NOW")))); + } + private boolean applyTeam(Snapshot snapshot, Object guard) { + var encoded = new Document(); mongo.getConverter().write(snapshot.project(), encoded); + var update = new Update(); + for (String field : List.of("authorId", "author", "pendingTransferTo", "pendingTransferRequestId", "pendingTransferOwnerId", "pendingTransferExpiresAt", "teamMembers", "teamInvites", "projectRoles")) + update.set(field, encoded.get(field)); + update.set("updatedAt", java.time.LocalDateTime.now().toString()); + return applyUpdate(snapshot, update, guard); + } + public boolean cacheModpackArchive(String projectId, String projectToken, String versionId, String versionToken, String fileUrl) { + if (fileUrl == null || fileUrl.isBlank() || versionId == null || versionId.isBlank()) return false; + var snapshot = capture(projectId, projectToken); + if (snapshot.project().getClassification() != net.modtale.model.project.ProjectClassification.MODPACK) + throw ProjectReviewSnapshot.conflict(); + var versions = snapshot.project().getVersions(); + int index = -1; + for (int i = 0; i < versions.size(); i++) { + if (!Objects.equals(versionId, versions.get(i).getId())) continue; + if (index != -1 || !Objects.equals(versionToken, VersionReviewSnapshot.token(versions.get(i)))) + throw ProjectReviewSnapshot.conflict(); + index = i; + } + if (index == -1) throw ProjectReviewSnapshot.conflict(); + return applyUpdate(snapshot, new Update().set("versions." + index + ".fileUrl", fileUrl)); + } + public boolean applyComments(Snapshot snapshot) { + return applyUpdate(snapshot, new Update().set("comments", snapshot.project().getComments())); + } + public boolean unlist(Snapshot snapshot) { + if (!"PUBLISHED".equals(snapshot.raw().getString("status")) + && !"UNLISTED".equals(snapshot.raw().getString("status"))) + throw ProjectReviewSnapshot.conflict(); + return applyUpdate(snapshot, new Update().set("status", net.modtale.model.project.ProjectStatus.UNLISTED) + .set("expiresAt", null).set("rankingDirty", true) + .set("updatedAt", java.time.LocalDateTime.now().toString())); + } + public boolean applyDeletionState(Snapshot snapshot, boolean scrub) { + var project = snapshot.project(); + var update = new Update().set("status", project.getStatus()).set("deletedAt", project.getDeletedAt()) + .set("updatedAt", java.time.LocalDateTime.now().toString()).set("rankingDirty", true); + if (scrub) { + var encoded = new Document(); mongo.getConverter().write(project, encoded); + for (String field : List.of("title", "description", "about", "slug", "imageUrl", "bannerUrl", "galleryImages", + "galleryImageCaptions", "teamMembers", "teamInvites", "projectRoles", "comments", "tags")) + update.set(field, encoded.get(field)); + } + return applyUpdate(snapshot, update); + } + public boolean deleteProject(Snapshot snapshot) { + var query = new Document("_id", snapshot.raw().get("_id")).append("$expr", + new Document("$eq", List.of("$$ROOT", new Document("$literal", snapshot.raw())))); + return mongo.getCollection(mongo.getCollectionName(Project.class)).deleteOne(query).getDeletedCount() == 1; + } + public boolean applyPresentation(Snapshot snapshot, boolean media) { + var fields = media ? List.of("imageUrl", "bannerUrl", "galleryImages", "galleryImageCaptions") + : List.of("classification", "tags", "title", "description", "about", "categories", "slug", "license", + "customLicenseOpenSource", "repositoryUrl", "types", "allowModpacks", "allowComments", + "hmWikiEnabled", "hmWikiSlug", "galleryCarouselEnabled", "links", "imageUrl"); + var encoded = new Document(); mongo.getConverter().write(snapshot.project(), encoded); + var update = new Update(); + for (var field : fields) update.set(field, encoded.get(field)); + update.set("updatedAt", java.time.LocalDateTime.now().toString()).set("rankingDirty", true); + return applyUpdate(snapshot, update); + } + public boolean applyVersionList(Snapshot snapshot) { + var original = new HashMap(); + for (var raw : snapshot.raw().getList("versions", Document.class, List.of())) { + var version = mongo.getConverter().read(ProjectVersion.class, raw); + if (version.getId() == null || original.put(version.getId(), raw) != null) throw ProjectReviewSnapshot.conflict(); + } + var seen = new HashSet(); + var updated = new ArrayList(); + for (var version : snapshot.project().getVersions()) { + if (version.getId() == null || !seen.add(version.getId())) throw ProjectReviewSnapshot.conflict(); + var prior = original.get(version.getId()); + if (prior == null) { + if (version.getReviewStatus() != ProjectVersion.ReviewStatus.PENDING) throw ProjectReviewSnapshot.conflict(); + var added = new Document(); mongo.getConverter().write(version, added); updated.add(added); + } else { + // Retained versions keep every stored field, including evidence unknown to this binary. + var retained = new Document(prior); + var before = mongo.getConverter().read(ProjectVersion.class, prior); + if (!Objects.equals(before.getGameVersions(), version.getGameVersions())) { + retained.put("gameVersions", version.getGameVersions()); + retained.put("reviewStatus", "PENDING"); retained.put("scheduledPublishDate", null); + retained.put("scanResult", mongo.getConverter().convertToMongoType(version.getScanResult())); + for (String field : List.of("approvedSecurityEvidence", "approvedSecurityContextSha256", "approvedReviewOrigins", + "securityApprovalProjectId", "approvedFindingReviewHead", "approvedIssueBaselines")) retained.put(field, null); + retained.put("securityApprovedAt", 0L); + } + updated.add(retained); + } + } + return applyUpdate(snapshot, new Update().set("versions", updated) + .set("classification", snapshot.project().getClassification()) + .set("childProjectIds", snapshot.project().getChildProjectIds()) + .set("updatedAt", java.time.LocalDateTime.now().toString())); + } + public boolean applyVersionEdit(Snapshot snapshot, String versionId, boolean contextChanged, boolean childIdsChanged) { + var versions = snapshot.project().getVersions(); + int index = -1; + for (int i = 0; i < versions.size(); i++) if (versionId.equals(versions.get(i).getId())) { + if (index >= 0) throw ProjectReviewSnapshot.conflict(); + index = i; + } + if (index < 0) throw ProjectReviewSnapshot.conflict(); + var version = versions.get(index); + String path = "versions." + index + "."; + var update = new Update().set(path + "gameVersions", version.getGameVersions()) + .set(path + "dependencies", version.getDependencies()).set(path + "incompatibleProjectIds", version.getIncompatibleProjectIds()) + .set(path + "changelog", version.getChangelog()).set(path + "channel", version.getChannel()) + .set(path + "fileUrl", version.getFileUrl()); + if (childIdsChanged) update.set("childProjectIds", snapshot.project().getChildProjectIds()); + if (contextChanged) update.set(path + "reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set(path + "scheduledPublishDate", null).set(path + "scanResult", version.getScanResult()) + .set(path + "approvedSecurityEvidence", null).set(path + "approvedSecurityContextSha256", null) + .set(path + "securityApprovedAt", 0).set(path + "approvedReviewOrigins", null) + .set(path + "securityApprovalProjectId", null).set(path + "approvedFindingReviewHead", null) + .set(path + "approvedIssueBaselines", null); + update.set("updatedAt", java.time.LocalDateTime.now().toString()); + return applyUpdate(snapshot, update); + } + public boolean applyMetadataRepair(Snapshot snapshot, Map metadata) { + net.modtale.service.admin.project.ProjectMetadataRepair.validate(metadata); + var update = new Update(); + metadata.forEach(update::set); + update.set("updatedAt", java.time.LocalDateTime.now().toString()).set("rankingDirty", true); + return applyUpdate(snapshot, update); + } + private boolean applyUpdate(Snapshot snapshot, Update update) { return applyUpdate(snapshot, update, null); } + private boolean applyUpdate(Snapshot snapshot, Update update, Object originGuard) { + var entity = mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var mapped = new UpdateMapper(mongo.getConverter()).getMappedObject(update.getUpdateObject(), entity); + Object expression = new Document("$eq", List.of("$$ROOT", new Document("$literal", snapshot.raw()))); + if (originGuard != null) expression = new Document("$and", List.of(expression, originGuard)); + var query = new Document("_id", snapshot.raw().get("_id")).append("$expr", expression); + return mongo.getCollection(mongo.getCollectionName(Project.class)).updateOne(query, mapped).getModifiedCount() > 0; + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueryService.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueryService.java index 63d685f1e..3df64acd4 100644 --- a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueryService.java +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueryService.java @@ -1,16 +1,10 @@ package net.modtale.service.admin.review; -import java.util.Comparator; -import java.util.List; -import java.util.Locale; -import java.util.Objects; import net.modtale.exception.ResourceNotFoundException; import net.modtale.mapper.ProjectMapper; import net.modtale.model.dto.admin.AdminAuthorStatsDTO; import net.modtale.model.dto.admin.AdminProjectReviewDTO; -import net.modtale.model.dto.admin.AdminVerificationQueueItemDTO; import net.modtale.model.project.Project; -import net.modtale.model.project.ScanStatus; import net.modtale.model.user.User; import net.modtale.repository.user.UserRepository; import net.modtale.service.project.query.ProjectListingQueryService; @@ -23,51 +17,18 @@ public class ProjectReviewQueryService { private final UserRepository userRepository; private final ProjectService projectService; - private final ProjectReviewQueueService projectReviewQueueService; private final ProjectListingQueryService projectListingQueryService; public ProjectReviewQueryService( UserRepository userRepository, ProjectService projectService, - ProjectReviewQueueService projectReviewQueueService, ProjectListingQueryService projectListingQueryService ) { this.userRepository = userRepository; this.projectService = projectService; - this.projectReviewQueueService = projectReviewQueueService; this.projectListingQueryService = projectListingQueryService; } - public List getVerificationQueue() { - return projectReviewQueueService.getVerificationQueue().stream() - .map(ProjectMapper::toVerificationQueueItemDTO) - .filter(Objects::nonNull) - .sorted(Comparator - .comparingInt(ProjectReviewQueryService::queuePriority).reversed() - .thenComparing(item -> item.updatedAt() == null ? "" : item.updatedAt())) - .toList(); - } - - private static int queuePriority(AdminVerificationQueueItemDTO item) { - if (item.pendingVersion() == null || item.pendingVersion().scan() == null) return 2_000; - var scan = item.pendingVersion().scan(); - String verdict = scan.verdict() == null ? "" : scan.verdict().toUpperCase(Locale.ROOT); - int riskScore = scan.riskScore(); - - if ("BLOCK".equals(verdict) || scan.status() == ScanStatus.INFECTED) { - return 8_000 + riskScore + scan.newIssueCount() * 10 + scan.escalatedIssueCount() * 15; - } - if (scan.newIssueCount() > 0 || scan.escalatedIssueCount() > 0) { - return 6_000 + riskScore + scan.newIssueCount() * 6 + scan.escalatedIssueCount() * 10; - } - if ("REVIEW".equals(verdict) - || scan.status() == ScanStatus.SUSPICIOUS - || scan.status() == ScanStatus.FLAGGED) { - return 4_000 + riskScore; - } - return 2_000 + riskScore; - } - public AdminProjectReviewDTO getProjectReviewDetails(String id) { Project project = requireProject(id); User author = userRepository.findById(project.getAuthorId()).orElse(null); diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueueService.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueueService.java deleted file mode 100644 index 4792251bb..000000000 --- a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewQueueService.java +++ /dev/null @@ -1,59 +0,0 @@ -package net.modtale.service.admin.review; - -import java.util.List; -import net.modtale.model.project.Project; -import net.modtale.model.project.ProjectStatus; -import net.modtale.model.project.ProjectVersion; -import net.modtale.model.project.ScanStatus; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.stereotype.Service; - -@Service -public class ProjectReviewQueueService { - - private final MongoTemplate mongoTemplate; - - public ProjectReviewQueueService(MongoTemplate mongoTemplate) { - this.mongoTemplate = mongoTemplate; - } - - public List getVerificationQueue() { - Criteria reviewCandidate = new Criteria().orOperator( - Criteria.where("status").is(ProjectStatus.PENDING), - new Criteria().andOperator( - Criteria.where("status").is(ProjectStatus.PUBLISHED), - Criteria.where("versions").elemMatch( - Criteria.where("reviewStatus").is(ProjectVersion.ReviewStatus.PENDING) - ) - ) - ); - Criteria noVersionIsScanning = Criteria.where("versions").not().elemMatch( - Criteria.where("scanResult.status").is(ScanStatus.SCANNING) - ); - Query query = new Query(new Criteria().andOperator(reviewCandidate, noVersionIsScanning)); - - query.fields() - .include("id") - .include("title") - .include("description") - .include("author") - .include("imageUrl") - .include("classification") - .include("status") - .include("updatedAt") - .include("versions.id") - .include("versions.versionNumber") - .include("versions.changelog") - .include("versions.reviewStatus") - .include("versions.scanResult.status") - .include("versions.scanResult.verdict") - .include("versions.scanResult.riskScore") - .include("versions.scanResult.knownIssueCount") - .include("versions.scanResult.newIssueCount") - .include("versions.scanResult.escalatedIssueCount"); - - return mongoTemplate.find(query, Project.class); - } -} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewSnapshot.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewSnapshot.java new file mode 100644 index 000000000..226c22a21 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewSnapshot.java @@ -0,0 +1,33 @@ +package net.modtale.service.admin.review; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import net.modtale.model.project.Project; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.security.MessageDigest; +import java.util.*; + +public final class ProjectReviewSnapshot { + private static final ObjectMapper MAPPER = new ObjectMapper().findAndRegisterModules() + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private ProjectReviewSnapshot() {} + public static String token(Project project) { + try { + @SuppressWarnings("unchecked") Map fields = MAPPER.convertValue(project, Map.class); + for (String operational : List.of("downloadCount", "favoriteCount", "downloads7d", "downloads30d", "downloads90d", + "trendScore", "relevanceScore", "popularScore", "trendingRank", "popularRank", "relevanceRank", + "rankingDirty", "lastTrendingNotification", "comments", "canEdit", "owner")) fields.remove(operational); + fields.put("versions", project.getVersions() == null ? List.of() + : project.getVersions().stream().map(VersionReviewSnapshot::token).toList()); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(MAPPER.writeValueAsBytes(fields))); + } catch (Exception invalid) { throw new IllegalStateException("Could not bind the project review", invalid); } + } + public static void requireCurrent(Project project, String expected) { + if (expected == null || !expected.equals(token(project))) throw conflict(); + } + public static ResponseStatusException conflict() { + return new ResponseStatusException(HttpStatus.CONFLICT, + "This project changed after the review was opened. Refresh its evidence before deciding."); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewTransitionService.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewTransitionService.java index d48ab0538..202c1c014 100644 --- a/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewTransitionService.java +++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectReviewTransitionService.java @@ -6,7 +6,6 @@ import net.modtale.model.project.ProjectStatus; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; import net.modtale.service.analytics.ScoringService; import net.modtale.service.project.access.ProjectVersionAccessService; import net.modtale.service.project.lifecycle.LifecycleService; @@ -17,7 +16,8 @@ @Service public class ProjectReviewTransitionService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence projectReviewPersistence; + private final VersionReviewPersistence reviewPersistence; private final ProjectService projectService; private final LifecycleService lifecycleService; private final ScoringService scoringService; @@ -25,14 +25,16 @@ public class ProjectReviewTransitionService { private final ProjectVersionAccessService projectVersionAccessService; public ProjectReviewTransitionService( - ProjectRepository projectRepository, ProjectService projectService, LifecycleService lifecycleService, ScoringService scoringService, SecurityIssueAnalysisService securityIssueAnalysisService, - ProjectVersionAccessService projectVersionAccessService + ProjectVersionAccessService projectVersionAccessService, + VersionReviewPersistence reviewPersistence, + ProjectReviewPersistence projectReviewPersistence ) { - this.projectRepository = projectRepository; + this.projectReviewPersistence = projectReviewPersistence; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.lifecycleService = lifecycleService; this.scoringService = scoringService; @@ -40,41 +42,53 @@ public ProjectReviewTransitionService( this.projectVersionAccessService = projectVersionAccessService; } - public void publishProject(User adminUser, String id) { - lifecycleService.publishProject(id, adminUser); + public void publishProject(User adminUser, String id, String reviewToken, String versionId) { + lifecycleService.publishProject(id, adminUser, reviewToken, versionId); } - public VersionReviewDecision approveVersion(String id, String versionId) { + public VersionReviewDecision approveVersion(String id, String versionId, String reviewToken) { Project project = requireProject(id); ProjectVersion version = projectVersionAccessService.requireById(project, versionId, () -> new ResourceNotFoundException("Version not found.")); + VersionReviewSnapshot.requireCurrent(version,reviewToken); + var snapshot=reviewPersistence.capture(id,versionId,reviewToken); version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); version.setRejectionReason(null); version.setScheduledPublishDate(null); - securityIssueAnalysisService.pruneApprovedScanResults(project); + version.setSecurityApprovalProjectId(project.getId()); + securityIssueAnalysisService.markIssuesAcceptedForApprovedVersion(version); project.setUpdatedAt(LocalDateTime.now().toString()); - projectRepository.save(project); + if(!reviewPersistence.apply(snapshot,version)) throw VersionReviewPersistence.conflict(); projectService.evictProjectCache(project); return new VersionReviewDecision(project, version, null); } - public VersionReviewDecision rejectVersion(String id, String versionId, String reason) { + public VersionReviewDecision rejectVersion(String id, String versionId, String reason, String reviewToken) { Project project = requireProject(id); ProjectVersion version = projectVersionAccessService.requireById(project, versionId, () -> new ResourceNotFoundException("Version not found.")); + VersionReviewSnapshot.requireCurrent(version,reviewToken); + var snapshot=reviewPersistence.capture(id,versionId,reviewToken); version.setReviewStatus(ProjectVersion.ReviewStatus.REJECTED); version.setRejectionReason(reason); + version.setApprovedSecurityEvidence(null); + version.setApprovedSecurityContextSha256(null); + version.setSecurityApprovedAt(0); + version.setApprovedIssueBaselines(null); version.setScheduledPublishDate(null); - projectRepository.save(project); + if(!reviewPersistence.apply(snapshot,version)) throw VersionReviewPersistence.conflict(); projectService.evictProjectCache(project); return new VersionReviewDecision(project, version, reason); } - public ProjectRejectionDecision rejectProject(String id, String reason) { - Project project = requireProject(id); + public ProjectRejectionDecision rejectProject(String id, String reason, String reviewToken) { + var snapshot = projectReviewPersistence.capture(id, reviewToken); + Project project = snapshot.project(); + if (project.getStatus() != ProjectStatus.PENDING) throw ProjectReviewSnapshot.conflict(); project.setStatus(ProjectStatus.DRAFT); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + project.setUpdatedAt(LocalDateTime.now().toString()); + if (!projectReviewPersistence.apply(snapshot, null)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); return new ProjectRejectionDecision(project, reason); } diff --git a/backend/src/main/java/net/modtale/service/admin/review/RawReviewSnapshotReader.java b/backend/src/main/java/net/modtale/service/admin/review/RawReviewSnapshotReader.java new file mode 100644 index 000000000..656828915 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/RawReviewSnapshotReader.java @@ -0,0 +1,93 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import org.bson.*; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Exact stored bytes for a unique version. Capture is evidence, not permission to repair. */ +public final class RawReviewSnapshotReader { + public record Captured(Object projectId,int versionIndex,String versionId,byte[] versionBytes,String sha256) { + public Captured {versionBytes=versionBytes.clone();} + @Override public byte[] versionBytes(){return versionBytes.clone();} + @Override public String toString(){return "CapturedReviewSnapshot["+sha256+"]";} + public ReviewSnapshotArchive.Snapshot forArchive(String id,String actor,ReviewSnapshotArchive.Action action,long createdAt,long expiresAt) { + return new ReviewSnapshotArchive.Snapshot(id,projectId,versionIndex,actor,action,createdAt,expiresAt,versionBytes); + } + } + private final MongoCollection projects; + public RawReviewSnapshotReader(MongoTemplate mongo) { + projects=mongo.getCollection("projects").withDocumentClass(RawBsonDocument.class).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Captured capture(Object projectId,int versionIndex,String versionId) {return capture(null,projectId,versionIndex,versionId);} + public Captured capture(com.mongodb.client.ClientSession session,Object projectId,int versionIndex,String versionId) { + if(!(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.length()<=128 + && java.nio.charset.StandardCharsets.UTF_8.newEncoder().canEncode(s)) || versionIndex<0 || versionIndex>16*1024*1024 + || versionId==null || versionId.isBlank() || versionId.length()>128 || versionId.chars().anyMatch(Character::isISOControl) + || !java.nio.charset.StandardCharsets.UTF_8.newEncoder().canEncode(versionId))throw new IllegalArgumentException("Invalid review snapshot position"); + var query=new Document("_id",projectId); + var timed=ReviewRepairIo.collection(projects,session); + var root=(session==null?timed.find(query):timed.find(session,query)).collation(Collation.builder().locale("simple").build()).maxTime(5,TimeUnit.SECONDS).first(); + if(root==null)throw conflict(); + var buffer=root.getByteBuffer().asNIO();byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes); + if(bytes.length>ReviewSnapshotArchive.MAX_BYTES)throw conflict(); + long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(5);byte[] selected=null;int matches=0,arrays=0,rootIds=0; + try(var reader=new BsonBinaryReader(ByteBuffer.wrap(bytes))) { + reader.readStartDocument(); + while(reader.readBsonType()!=BsonType.END_OF_DOCUMENT) { + checkDeadline(deadline);String field=reader.readName(); + if(field.equals("_id")) { + rootIds++;Object found=reader.getCurrentBsonType()==BsonType.OBJECT_ID?reader.readObjectId():reader.getCurrentBsonType()==BsonType.STRING?reader.readString():null; + if(!projectId.equals(found))throw conflict(); + } else if(field.equals("versions")) { + if(++arrays!=1 || reader.getCurrentBsonType()!=BsonType.ARRAY)throw conflict(); + reader.readStartArray();int index=0; + while(reader.readBsonType()!=BsonType.END_OF_DOCUMENT) { + checkDeadline(deadline);int start=reader.getBsonInput().getPosition();boolean document=reader.getCurrentBsonType()==BsonType.DOCUMENT; + reader.skipValue();int end=reader.getBsonInput().getPosition(); + if(document) { + String id=versionId(bytes,start,end,deadline); + if(versionId.equals(id))matches++; + if(index==versionIndex) { + if(!versionId.equals(id))throw conflict(); + selected=Arrays.copyOfRange(bytes,start,end); + } + } else if(index==versionIndex)throw conflict(); + index++; + } + reader.readEndArray(); + } else reader.skipValue(); + } + reader.readEndDocument(); + } catch(RuntimeException malformed) {throw conflict();} + if(rootIds!=1 || arrays!=1 || matches!=1 || selected==null)throw conflict(); + try {return new Captured(projectId,versionIndex,versionId,selected,HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(selected)));} + catch(java.security.NoSuchAlgorithmException unavailable){throw new IllegalStateException(unavailable);} + } + public boolean isCurrent(Captured snapshot) { + try {var current=capture(snapshot.projectId(),snapshot.versionIndex(),snapshot.versionId());return Arrays.equals(snapshot.versionBytes(),current.versionBytes());} + catch(IllegalStateException changed){return false;} + } + private static String versionId(byte[] bytes,int start,int end,long deadline) { + String id=null;int count=0; + try(var reader=new BsonBinaryReader(ByteBuffer.wrap(bytes,start,end-start).slice())) { + reader.readStartDocument(); + while(reader.readBsonType()!=BsonType.END_OF_DOCUMENT) { + checkDeadline(deadline);String field=reader.readName(); + if(field.equals("_id")) {count++;if(reader.getCurrentBsonType()==BsonType.STRING)id=reader.readString();else reader.skipValue();} + else reader.skipValue(); + } + reader.readEndDocument(); + } + // Duplicate identity fields are ambiguous even if they happen to contain the same value. + if(count>1)throw conflict();return id; + } + private static void checkDeadline(long deadline){if(System.nanoTime()>deadline)throw conflict();} + private static IllegalStateException conflict(){return new IllegalStateException("Review snapshot changed, is ambiguous, or is unavailable");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationAccess.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationAccess.java new file mode 100644 index 000000000..d9665851f --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationAccess.java @@ -0,0 +1,77 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.user.AdminPermission; +import net.modtale.model.user.User; +import net.modtale.service.user.account.AccountService; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.function.BooleanSupplier; + +/** Account-bound operations; all work shares the existing repair admission and I/O budget. */ +public final class ReviewCancellationAccess { + public record Check(String id, ReviewOrphanCancellationJournal.Prepared original) {} + private final AccountService accounts; + private final ReviewRepairWorkflow workflow; + private final ReviewOrphanCancellationJournal journal; + private final ReviewOrphanCancellationExecutor executor; + private final ReviewCancellationReconciler reconciler; + + public ReviewCancellationAccess(AccountService accounts, ReviewRepairWorkflow workflow, + ReviewOrphanCancellationJournal journal, ReviewOrphanCancellationExecutor executor, + ReviewCancellationReconciler reconciler) { + this.accounts=accounts;this.workflow=workflow;this.journal=journal;this.executor=executor;this.reconciler=reconciler; + } + public record Capability(String action) {} + public Capability capability() { + var authority=authority(); + return workflow.call(allowed->new Capability("CANCEL_ORIGINAL_REVIEW"),authority.allowed()); + } + public ReviewOrphanCancellationJournal.Preview preview(String isolationId) { + var authority=authority();uuid(isolationId); + return workflow.call(allowed->journal.preview(isolationId,authority.actor(),allowed),authority.allowed()); + } + public ReviewOrphanCancellationJournal.Prepared prepare(String isolationId) { + var authority=authority();uuid(isolationId); + return workflow.call(allowed->journal.prepare(isolationId,authority.actor(),allowed),authority.allowed()); + } + public ReviewOrphanCancellationJournal.Receipt recover(String isolationId) { + var authority=authority();uuid(isolationId); + return workflow.call(allowed->journal.recover(isolationId,authority.actor(),allowed),authority.allowed()); + } + public ReviewOrphanCancellationExecutor.Execution execute(ReviewOrphanCancellationJournal.Prepared original) { + var authority=authority();validate(original); + return workflow.call(allowed->executor.execute(original,authority.actor(),allowed),authority.allowed()); + } + public ReviewOrphanCancellationJournal.Receipt receipt(ReviewOrphanCancellationJournal.Prepared original) { + var authority=authority();validate(original); + return workflow.call(allowed->journal.receipt(original,authority.actor(),allowed),authority.allowed()); + } + public ReviewCancellationReconciler.Receipt check(Check request) { + var authority=authority();validate(request); + return workflow.call(allowed->reconciler.check(request.id(),request.original(),authority.actor(),allowed),authority.allowed()); + } + public ReviewCancellationReconciler.Receipt checkReceipt(Check request) { + var authority=authority();validate(request); + return workflow.call(allowed->reconciler.receipt(request.id(),request.original(),authority.actor(),allowed),authority.allowed()); + } + private record Authority(String actor,BooleanSupplier allowed) {} + private Authority authority() { + User user=current();if(!allowed(user))throw new SecurityException("Review cancellation is not permitted"); + String actor=user.getId(); + return new Authority(actor,()->{User refreshed=current();return allowed(refreshed)&&actor.equals(refreshed.getId());}); + } + private User current(){return accounts.getCurrentUser(SecurityContextHolder.getContext().getAuthentication());} + private static boolean allowed(User user) { + return user!=null && !user.isDeleted() && user.getId()!=null && !user.getId().isBlank() + && AdminPermission.hasPermission(user,AdminPermission.PROJECT_REVIEW_READ) + && AdminPermission.hasPermission(user,AdminPermission.PROJECT_VERSION_RESCAN); + } + private static void validate(Check request){if(request==null)throw invalid();uuid(request.id());validate(request.original());} + private static void validate(ReviewOrphanCancellationJournal.Prepared original) { + if(original==null)throw invalid();uuid(original.isolationId()); + if(original.targetSha256()==null || !original.targetSha256().matches("[0-9a-f]{64}") + || original.createdAt()<=0 || original.expiresAt()<=original.createdAt() + || original.expiresAt()-original.createdAt()>120000)throw invalid(); + } + private static void uuid(String id){if(id==null || !id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))throw invalid();} + private static IllegalArgumentException invalid(){return new IllegalArgumentException("Invalid review cancellation request");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationConfiguration.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationConfiguration.java new file mode 100644 index 000000000..de14b48cf --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationConfiguration.java @@ -0,0 +1,30 @@ +package net.modtale.service.admin.review; + +import net.modtale.config.properties.AppReviewRepairProperties; +import net.modtale.service.security.scan.RemoteReviewClient; +import net.modtale.service.user.account.AccountService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.*; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnProperty(name="app.warden.repair.cancellation.enabled",havingValue="true") +public class ReviewCancellationConfiguration { + @Bean ReviewOrphanTargetResolver reviewOrphanTargetResolver(MongoTemplate mongo,ReviewSnapshotArchive archive,ReviewIsolationExecutor isolation) { + return new ReviewOrphanTargetResolver(mongo,archive,isolation); + } + @Bean ReviewOrphanCancellationJournal reviewOrphanCancellationJournal(MongoTemplate mongo,ReviewOrphanTargetResolver resolver,ReviewSnapshotArchive archive) { + return new ReviewOrphanCancellationJournal(mongo,resolver,archive,Clock.systemUTC(),60000); + } + @Bean ReviewOrphanCancellationExecutor reviewOrphanCancellationExecutor(ReviewOrphanCancellationJournal journal,RemoteReviewClient client,AppReviewRepairProperties properties) { + return new ReviewOrphanCancellationExecutor(journal,client,properties.concurrency(),Duration.ofSeconds(30)); + } + @Bean ReviewCancellationReconciler reviewCancellationReconciler(MongoTemplate mongo,ReviewOrphanCancellationJournal journal,RemoteReviewClient client,AppReviewRepairProperties properties) { + return new ReviewCancellationReconciler(mongo,journal,client,properties.concurrency(),Duration.ofSeconds(30)); + } + @Bean ReviewCancellationAccess reviewCancellationAccess(AccountService accounts,ReviewRepairWorkflow workflow,ReviewOrphanCancellationJournal journal, + ReviewOrphanCancellationExecutor executor,ReviewCancellationReconciler reconciler) { + return new ReviewCancellationAccess(accounts,workflow,journal,executor,reconciler); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationReconciler.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationReconciler.java new file mode 100644 index 000000000..e13ee607c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewCancellationReconciler.java @@ -0,0 +1,115 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import net.modtale.service.security.scan.RemoteReviewClient; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.*; + +/** Separate status observations. No cancellation, replacement or overwrite of the original outcome. */ +public final class ReviewCancellationReconciler implements AutoCloseable { + public static final String COLLECTION="review_cancellation_observations"; + public record Receipt(String id,ReviewOrphanCancellationJournal.Prepared original,String state, + ReviewOrphanCancellationJournal.Observation observation,Long receivedAt) {} + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private final MongoCollection observations; + private final ReviewOrphanCancellationJournal journal; + private final RemoteReviewClient client; + private final Semaphore slots; + private final long budgetNanos; + private final AtomicBoolean closed=new AtomicBoolean(); + public ReviewCancellationReconciler(MongoTemplate mongo,ReviewOrphanCancellationJournal journal,RemoteReviewClient client,int concurrency,Duration budget) { + if(concurrency<1 || concurrency>2 || budget==null || budget.compareTo(Duration.ofSeconds(1))<0 || budget.compareTo(Duration.ofSeconds(30))>0)throw new IllegalArgumentException("Invalid reconciliation capacity"); + this.journal=Objects.requireNonNull(journal);this.client=Objects.requireNonNull(client);slots=new Semaphore(concurrency);budgetNanos=budget.toNanos(); + observations=mongo.getCollection(COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY) + .withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(5,TimeUnit.SECONDS)).withTimeout(5000,TimeUnit.MILLISECONDS); + } + public Receipt check(String id,ReviewOrphanCancellationJournal.Prepared original,String actor,BooleanSupplier permitted) { + permission(permitted);if(!uuid(id))throw new IllegalArgumentException("Invalid observation identity"); + if(closed.get() || !slots.tryAcquire())throw new IllegalStateException("Reconciliation unavailable"); + long started=System.nanoTime();LongSupplier remaining=()->budgetNanos-(System.nanoTime()-started); + BooleanSupplier allowed=()->{if(closed.get() || Thread.currentThread().isInterrupted() || remaining.getAsLong()<=0)throw new IllegalStateException("Reconciliation stopped");return permitted.getAsBoolean();}; + try(var io=ReviewRepairIo.open(remaining)) { + var target=journal.reconciliationTarget(original,actor,allowed);var expected=intent(original,actor);var existing=read(id); + if(existing!=null)return decoded(existing,id,original,expected,target,allowed); + var reserved=new Document("_id",id).append("intent",expected).append("token",UUID.randomUUID().toString()).append("state","RESERVED"); + permission(allowed); + try {ReviewRepairIo.collection(observations).insertOne(reserved);} + catch(MongoException uncertain) { + var stored=read(id);if(stored==null)throw unavailable(); + if(!Arrays.equals(bytes(stored),bytes(reserved)))return decoded(stored,id,original,expected,target,allowed); + } + permission(allowed);ReviewOrphanCancellationJournal.Observation observation; + try { + var status=client.status(target.binding(),()->{ + if(!target.equals(journal.reconciliationTarget(original,actor,allowed)))throw unavailable(); + permission(allowed); + var reading=ReviewRepairIo.collection(observations).findOneAndUpdate(exact(reserved),List.of(new Document("$set",new Document("state","READING").append("startedAt","$$NOW"))), + new FindOneAndUpdateOptions().collation(BINARY).returnDocument(ReturnDocument.AFTER)); + if(reading==null)return false;decoded(reading,id,original,expected,target,allowed);return allowed.getAsBoolean(); + },remaining); + observation=new ReviewOrphanCancellationJournal.Observation("REMOTE_STATUS",status,null); + }catch(RemoteReviewClient.Unavailable failure) { + int code=failure.status();String kind=code==404?"NOT_FOUND":code==409?"CONTEXT_CONFLICT":code==401||code==403||code==429?"UNAVAILABLE":"UNKNOWN"; + observation=new ReviewOrphanCancellationJournal.Observation(kind,null,code); + }catch(RemoteReviewClient.Superseded stopped){observation=new ReviewOrphanCancellationJournal.Observation("UNKNOWN",null,null);} + try(var cleanup=ReviewRepairIo.cleanup()) { + journal.reconciliationTarget(original,actor,permitted);ReviewOrphanCancellationJournal.validateObservation(observation,target); + var stored=read(id);decoded(stored,id,original,expected,target,permitted); + if("READING".equals(stored.get("state")) && reserved.get("token").equals(stored.get("token"))) { + permission(permitted); + var fields=new Document("state","UNKNOWN".equals(observation.kind())?"UNKNOWN":"OBSERVED") + .append("observation",literal(ReviewOrphanCancellationJournal.observationDocument(observation))).append("receivedAt","$$NOW"); + try {ReviewRepairIo.collection(observations).updateOne(exact(stored),List.of(new Document("$set",fields)),new UpdateOptions().collation(BINARY));} + catch(MongoException uncertain){ /* Read-back preserves uncertainty; no second GET. */ } + } + return receipt(id,original,actor,permitted); + } + }finally{slots.release();} + } + /** Reads only the retained local observation, including after the original cancellation expiry. */ + public Receipt receipt(String id,ReviewOrphanCancellationJournal.Prepared original,String actor,BooleanSupplier permitted) { + permission(permitted);if(!uuid(id))throw new IllegalArgumentException("Invalid observation identity"); + var target=journal.reconciliationTarget(original,actor,permitted);return decoded(read(id),id,original,intent(original,actor),target,permitted); + } + private static Receipt decoded(Document stored,String id,ReviewOrphanCancellationJournal.Prepared original,Document intent, + ReviewOrphanTargetResolver.Target target,BooleanSupplier permitted) { + try { + if(stored==null || !id.equals(stored.get("_id")) || !(stored.get("token") instanceof String token) || !uuid(token) + || !(stored.get("intent") instanceof Document expected) || !Arrays.equals(bytes(expected),bytes(intent)))throw unavailable(); + String state=stored.getString("state");ReviewOrphanCancellationJournal.Observation observation=null;Long received=null; + if("RESERVED".equals(state)) {if(stored.size()!=4)throw unavailable();} + else { + if(!(stored.get("startedAt") instanceof Date start))throw unavailable(); + if("READING".equals(state)) {if(stored.size()!=5)throw unavailable();} + else { + if(!Set.of("OBSERVED","UNKNOWN").contains(state) || stored.size()!=7 || !(stored.get("receivedAt") instanceof Date end) || end.before(start))throw unavailable(); + observation=ReviewOrphanCancellationJournal.observation(stored.get("observation",Document.class));ReviewOrphanCancellationJournal.validateObservation(observation,target); + if("UNKNOWN".equals(state)!= "UNKNOWN".equals(observation.kind()))throw unavailable();received=end.getTime(); + } + } + permission(permitted);return new Receipt(id,original,state,observation,received); + }catch(SecurityException denied){throw denied;} + catch(RuntimeException invalid){throw unavailable();} + } + private Document read(String id){return ReviewRepairIo.collection(observations).find(new Document("_id",id)).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();} + private static Document intent(ReviewOrphanCancellationJournal.Prepared original,String actor){return new Document("actor",actor).append("isolationId",original.isolationId()) + .append("targetSha256",original.targetSha256()).append("createdAt",original.createdAt()).append("expiresAt",original.expiresAt());} + private static Document exact(Document stored){return new Document("_id",stored.get("_id")).append("$expr",new Document("$and",List.of( + new Document("$eq",List.of("$$ROOT",literal(stored))),new Document("$eq",List.of(new Document("$type","$intent.createdAt"),"long")), + new Document("$eq",List.of(new Document("$type","$intent.expiresAt"),"long")))));} + private static byte[] bytes(Document doc){var buffer=new RawBsonDocument(doc,new DocumentCodec()).getByteBuffer().asNIO();byte[] result=new byte[buffer.remaining()];buffer.get(result);return result;} + private static Document literal(Object value){return new Document("$literal",value);} + private static boolean uuid(String value){return value!=null && value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Reconciliation is not permitted");} + private static IllegalStateException unavailable(){return new IllegalStateException("Reconciliation observation is unavailable or inconsistent");} + @Override public void close(){closed.set(true);} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewIsolationExecutor.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewIsolationExecutor.java new file mode 100644 index 000000000..6c545370d --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewIsolationExecutor.java @@ -0,0 +1,165 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import com.mongodb.client.model.*; +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ArtifactReviewContext; +import org.bson.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** Isolates broken local review state; never approves or starts/cancels a remote review. */ +public final class ReviewIsolationExecutor { + public record Result(String state,String afterSha256) {} + private final MongoTemplate mongo; + private final ReviewSnapshotArchive archive; + private final RawReviewSnapshotReader reader; + private final ReviewRepairJournal journal; + private final ReviewRepairOperationReader operationReader; + private final MongoCollection projects,operations; + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private static final TransactionOptions OPTIONS=TransactionOptions.builder().readConcern(ReadConcern.SNAPSHOT) + .readPreference(ReadPreference.primary()).writeConcern(WriteConcern.MAJORITY.withJournal(true)).maxCommitTime(5000L,TimeUnit.MILLISECONDS).build(); + public ReviewIsolationExecutor(MongoTemplate mongo,ReviewSnapshotArchive archive,RawReviewSnapshotReader reader,ReviewRepairJournal journal) { + this.mongo=mongo;this.archive=archive;this.reader=reader;this.journal=journal;operationReader=new ReviewRepairOperationReader(mongo); + projects=mongo.getCollection("projects");operations=mongo.getCollection(ReviewRepairJournal.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public void initializeDiscovery(){operationReader.initialize();} + public ReviewRepairOperationReader.Page operations(String actor,String cursor,int limit){return operationReader.page(actor,cursor,limit);} + public record Recovered(ReviewRepairPreparation.Prepared prepared,Receipt receipt) {} + public Recovered recover(String id,String actor) { + var source=archive.load(id); + if(source.action()!=ReviewSnapshotArchive.Action.ISOLATE_REVIEW || !source.actorId().equals(actor))throw new SecurityException("Repair recovery is not permitted"); + var prepared=new ReviewRepairPreparation.Prepared(source.id(),sourceDigest(source),source.createdAt(),source.expiresAt()); + return new Recovered(prepared,receipt(source,prepared)); + } + public Receipt closeExpired(ReviewRepairPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);var source=verifiedSource(prepared,actor); + var hello=ReviewRepairIo.database(mongo.getDb()).runCommand(new Document("hello",1),ReadPreference.primary()); + if(!(hello.get("setName") instanceof String) && !"isdbgrid".equals(hello.get("msg")))throw new IllegalStateException("Review isolation requires transaction support"); + try {journal.closeExpired(prepared,actor,permitted);} + catch(MongoException unknown) { + // An acknowledgement loss does not justify another write. Only read back the receipt. + try(var cleanup=ReviewRepairIo.cleanup()){return receipt(source,prepared);} + } + return receipt(source,prepared); + } + public boolean eligible(RawReviewSnapshotReader.Captured captured) { + var source=captured.forArchive("00000000-0000-0000-0000-000000000000","inspection",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,1,2); + var original=new RawBsonDocument(source.versionBytes()).decode(new org.bson.codecs.DocumentCodec()); + return fields(original,source)!=null; + } + public record Receipt(Result outcome,Object projectId,int versionIndex,String versionId,String beforeSha256) {} + public Receipt receipt(ReviewRepairPreparation.Prepared prepared,String actor) { + return receipt(verifiedSource(prepared,actor),prepared); + } + private Receipt receipt(ReviewSnapshotArchive.Snapshot source,ReviewRepairPreparation.Prepared prepared) { + String versionId=new RawBsonDocument(source.versionBytes()).getString("_id").getValue(); + return new Receipt(outcome(prepared,source.actorId()),source.projectId(),source.versionIndex(),versionId,prepared.sha256()); + } + private ReviewSnapshotArchive.Snapshot verifiedSource(ReviewRepairPreparation.Prepared prepared,String actor) { + var source=archive.load(prepared.id()); + if(source.action()!=ReviewSnapshotArchive.Action.ISOLATE_REVIEW || !source.actorId().equals(actor))throw new SecurityException("Repair execution is not permitted"); + if(!sourceDigest(source).equals(prepared.sha256()) || source.createdAt()!=prepared.createdAt() || source.expiresAt()!=prepared.expiresAt())throw new IllegalStateException("Repair intent changed"); + return source; + } + private static String sourceDigest(ReviewSnapshotArchive.Snapshot source) { + try {return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(source.versionBytes()));} + catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);} + } + public Result execute(ReviewRepairPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);var source=verifiedSource(prepared,actor); + var original=new RawBsonDocument(source.versionBytes()).decode(new org.bson.codecs.DocumentCodec()); + Document fields=fields(original,source);if(fields==null)return new Result("INELIGIBLE",null); + var hello=ReviewRepairIo.database(mongo.getDb()).runCommand(new Document("hello",1),ReadPreference.primary()); + if(!(hello.get("setName") instanceof String) && !"isdbgrid".equals(hello.get("msg")))throw new IllegalStateException("Review isolation requires transaction support"); + permission(permitted); + var claim=journal.claim(prepared,actor,ReviewSnapshotArchive.Action.ISOLATE_REVIEW,permitted); + if(claim==null)return outcome(prepared,actor); + boolean commitAttempted=false; + try(var session=mongo.getMongoDatabaseFactory().getSession(ReviewRepairIo.sessionOptions())) { + try { + session.startTransaction(ReviewRepairIo.transactionOptions(OPTIONS));permission(permitted); + var op=ReviewRepairIo.collection(operations,session).find(session,new Document("_id",claim.id()).append("token",claim.token()).append("state","EXECUTING")).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first(); + if(op==null)throw new IllegalStateException("Repair claim changed"); + var current=reader.capture(session,source.projectId(),source.versionIndex(),original.getString("_id")); + String result;String after=null; + if(!Arrays.equals(source.versionBytes(),current.versionBytes()))result="NOT_APPLIED"; + else { + String prefix="versions."+source.versionIndex()+".";var set=new Document();fields.forEach((key,value)->set.put(prefix+key,value)); + var conditions=new ArrayList();conditions.add(new Document("$gte",List.of("$$NOW",new Date(source.createdAt()))));conditions.add(new Document("$lt",List.of("$$NOW",new Date(source.expiresAt())))); + var scan=original.get("scanResult",Document.class);var poll=scan.get("remotePoll"); + if(poll instanceof Document p && p.get("leaseUntil") instanceof Date lease)conditions.add(new Document("$gte",List.of("$$NOW",lease))); + permission(permitted); + long changed=ReviewRepairIo.collection(projects,session).updateOne(session,new Document("_id",source.projectId()).append("$expr",new Document("$and",conditions)), + new Document("$set",set).append("$currentDate",new Document(prefix+"reviewIsolation.isolatedAt",true)),new UpdateOptions().collation(BINARY)).getModifiedCount(); + if(changed==1) {after=reader.capture(session,source.projectId(),source.versionIndex(),original.getString("_id")).sha256();result="APPLIED";} + else result="NOT_APPLIED"; + } + var outcome=new Document("state",result).append("afterSha256",after); + if(ReviewRepairIo.collection(operations,session).updateOne(session,op,List.of(new Document("$set",outcome.append("finishedAt","$$NOW"))),new UpdateOptions().collation(BINARY)).getModifiedCount()!=1)throw new IllegalStateException("Repair claim changed"); + permission(permitted);commitAttempted=true;session.commitTransaction();return new Result(result,after); + } catch(RuntimeException failure) { + if(!commitAttempted)try{session.abortTransaction();}catch(RuntimeException ignored){} + throw failure; + } + } catch(RuntimeException uncertain) { + try(var cleanup=ReviewRepairIo.cleanup()) { + // Conservative bookkeeping for an already-owned operation remains necessary after permission loss. + if(uncertain instanceof SecurityException) {try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){}throw uncertain;} + try {var observed=outcome(prepared,actor);if(!observed.state().equals("UNKNOWN"))return observed;}catch(RuntimeException ignored){} + try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){} + return new Result("UNKNOWN",null); + } + } + } + private Result outcome(ReviewRepairPreparation.Prepared prepared,String actor) { + var record=ReviewRepairIo.collection(operations).find(new Document("_id",prepared.id())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first(); + if(record==null || !actor.equals(record.get("actor")) || !prepared.sha256().equals(record.get("sha256")) + || !Long.valueOf(prepared.createdAt()).equals(record.get("createdAt")) || !Long.valueOf(prepared.expiresAt()).equals(record.get("expiresAt")) + || !"ISOLATE_REVIEW".equals(record.get("action")))return new Result("UNKNOWN",null); + if(!(record.get("finishedAt") instanceof Date))return new Result("UNKNOWN",null); + if("NOT_APPLIED".equals(record.get("state")) && record.get("afterSha256")==null)return new Result("NOT_APPLIED",null); + if("APPLIED".equals(record.get("state")) && record.get("afterSha256") instanceof String sha && sha.matches("[0-9a-f]{64}"))return new Result("APPLIED",sha); + return new Result("UNKNOWN",null); + } + private Document fields(Document original,ReviewSnapshotArchive.Snapshot source) { + try { + var encoded=new RawBsonDocument(original,new org.bson.codecs.DocumentCodec()).getByteBuffer().asNIO();byte[] bytes=new byte[encoded.remaining()];encoded.get(bytes); + if(!Arrays.equals(bytes,source.versionBytes()) || !(original.get("_id") instanceof String) || !"PENDING".equals(original.get("reviewStatus")) + || !(original.get("scanResult") instanceof Document scan) || !"SCANNING".equals(scan.get("status")))return null; + if(original.containsKey("reviewIsolation") && !(original.get("reviewIsolation") instanceof Document))return null; + var changed=new Document(scan);changed.put("remotePoll",null);changed.put("remoteStatus",null); + boolean broken=scan.containsKey("remotePoll") && invalidPoll(scan.get("remotePoll")); + String state=scan.getString("scanState");broken|=!Set.of("QUEUED","SCANNING","REMOTE_REVIEW").contains(Objects.toString(state,"")); + RemoteReviewBinding binding=null; + if(scan.get("remoteReview")!=null) { + try{binding=mongo.getConverter().read(RemoteReviewBinding.class,(Document)scan.get("remoteReview"));} + catch(RuntimeException invalid){broken=true;changed.put("remoteReview",null);} + } else if("REMOTE_REVIEW".equals(state))broken=true; + changed.put("status","FAILED");changed.put("scanState","REMOTE_ISOLATED");changed.put("verdict","BLOCK".equals(scan.get("verdict"))?"BLOCK":"REVIEW"); + for(String field:List.of("securityEvidence","reviewedContextSha256","reusedReviewVersion","reusedReviewOrigins"))changed.put(field,null); + changed.put("artifactVerified",false);changed.put("holdUntilTimestamp",0L);changed.put("reusedReviewApprovedAt",0L); + var patch=new Document("scanResult",changed).append("scheduledPublishDate",null).append("securityApprovedAt",0L); + for(String field:List.of("securityApprovalProjectId","approvedSecurityEvidence","approvedSecurityContextSha256","approvedReviewOrigins","approvedFindingReviewHead"))patch.put(field,null); + var candidate=new Document(original);candidate.putAll(patch);var typed=mongo.getConverter().read(ProjectVersion.class,candidate); + if(binding!=null)broken|=!source.projectId().toString().equals(binding.projectId()) || !typed.getId().equals(binding.versionId()) + || !Objects.equals(typed.getHash(),binding.artifactSha256()) || !Objects.equals(typed.getFileUrl(),binding.filePath()) + || !Objects.equals(ArtifactReviewContext.automaticallyReviewableFingerprint(typed),binding.contextSha256()) + || !Objects.equals(typed.getScanResult().getScanRequestId(),binding.requestId()) || typed.getScanResult().getScanAttempt()!=binding.attempt() + || typed.getScanResult().isManualRescan()!=binding.manualRescan() || !"REMOTE_REVIEW".equals(state); + if(!broken)return null; + patch.put("reviewIsolation.operationId",source.id());patch.put("reviewIsolation.actorId",source.actorId()); + patch.put("reviewIsolation.beforeSha256",HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(source.versionBytes()))); + return patch; + } catch(Exception invalid){return null;} + } + private static boolean invalidPoll(Object value) { + return !(value instanceof Document p) || p.size()!=3 || !(p.get("leaseUntil") instanceof Date) || !(p.get("nextPollAt") instanceof Date) + || !p.containsKey("token") || p.get("token")!=null && (!(p.get("token") instanceof String token) || !token.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")); + } + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Review isolation is not permitted");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewOriginCursor.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewOriginCursor.java new file mode 100644 index 000000000..9baf09d4c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewOriginCursor.java @@ -0,0 +1,17 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.RemoteReviewDiscovery; + +/** Origin inventory positions cannot be reused as scan-diagnostic positions. */ +public final class ReviewOriginCursor { + private ReviewOriginCursor() {} + public static String encode(RemoteReviewDiscovery.Cursor cursor) { + String encoded=ReviewStateDiagnosticCursor.encode(cursor); + return encoded==null?null:"o1."+encoded.substring(3); + } + public static RemoteReviewDiscovery.Cursor decode(String token) { + if(token==null)return null; + if(token.length()>810 || !token.startsWith("o1."))throw new IllegalArgumentException("Invalid origin cursor"); + return ReviewStateDiagnosticCursor.decode("d1."+token.substring(3)); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewOriginInventory.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewOriginInventory.java new file mode 100644 index 000000000..c871417d0 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewOriginInventory.java @@ -0,0 +1,75 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import net.modtale.model.project.Project; +import net.modtale.model.project.RemoteReviewOrigin; +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Read-only inventory of retained origin fields, not proof of a job's origin or permission to reconcile it. */ +@org.springframework.stereotype.Service +public final class ReviewOriginInventory { + public enum OriginState { MISSING, INVALID, RECORDED, UNREADABLE_BINDING } + public record Item(Object projectId,int versionIndex,String versionId,boolean ambiguousVersion, + String requestId,String jobId,OriginState originState) {} + public record Page(List items,RemoteReviewDiscovery.Cursor next,int examined) { + public Page {items=List.copyOf(items);} + } + private final MongoCollection projects; + public ReviewOriginInventory(MongoTemplate mongo) { + projects=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Page page(RemoteReviewDiscovery.Cursor cursor,int limit) { + if(limit<1 || limit>64)throw new IllegalArgumentException("Invalid origin inventory page size"); + var terms=new ArrayList(); + terms.add(new Document("$cond",List.of(eq(type("$_id"),"string"),new Document("$and",List.of( + new Document("$gt",List.of(new Document("$strLenCP","$_id"),0)),new Document("$lte",List.of(new Document("$strLenCP","$_id"),128)))),true))); + if(cursor!=null)terms.add(new Document(cursor.afterProject()?"$gt":"$gte",List.of("$_id",literal(cursor.projectId())))); + var match=new Document("_id",new Document("$type",List.of("string","objectId"))) + .append("versions",new Document("$elemMatch",new Document("scanResult.remoteReview",new Document("$exists",true).append("$ne",null)))) + .append("$expr",new Document("$and",terms)); + Object offset=cursor==null?literal(0):new Document("$cond",List.of(eq("$_id",literal(cursor.projectId())),cursor.offset(),0)); + var versions=new Document("$cond",List.of(new Document("$isArray","$versions"),"$versions",List.of())); + String binding="$$v.scanResult.remoteReview",origin=binding+".origin"; + var view=new Document("bindingType",type(binding)).append("versionId",text("$$v._id",128)) + .append("idCount",new Document("$size",new Document("$filter",new Document("input",versions).append("as","sibling").append("cond",eq("$$sibling._id","$$v._id"))))) + .append("requestId",text(binding+".requestId",36)).append("jobId",text(binding+".jobId",36)) + .append("originType",type(origin)).append("deployment",text(origin+".deploymentId",36)).append("caller",text(origin+".callerScope",64)); + var projection=new Document("_id",1).append("offset",offset).append("total",new Document("$size",versions)) + .append("items",new Document("$map",new Document("input",new Document("$slice",List.of(versions,offset,limit))).append("as","v").append("in",view))); + var pipeline=List.of(new Document("$match",match),new Document("$sort",new Document("_id",1)),new Document("$limit",1),new Document("$project",projection)); + Document root; + try(var rows=projects.withTimeout(5000,TimeUnit.MILLISECONDS).aggregate(pipeline).collation(Collation.builder().locale("simple").build()) + .allowDiskUse(false).maxTime(5,TimeUnit.SECONDS).batchSize(1).iterator()) {root=rows.hasNext()?rows.next():null;} + if(root==null)return new Page(List.of(),null,0); + var items=new ArrayList();var rows=root.getList("items",Document.class);int index=root.getInteger("offset"); + for(var row:rows) { + String bindingType=row.getString("bindingType"); + if(!Set.of("missing","null").contains(bindingType)) { + String version=row.getString("versionId");boolean valid=version!=null && !version.isBlank() && version.length()<=128 && version.chars().noneMatch(Character::isISOControl); + boolean object="object".equals(bindingType); + items.add(new Item(root.get("_id"),index,valid?version:null,!valid || row.getInteger("idCount",0)!=1, + object?uuid(row.getString("requestId")):null,object?uuid(row.getString("jobId")):null,object?originState(row):OriginState.UNREADABLE_BINDING)); + } + index++; + } + var next=index2 || budget==null || budget.compareTo(Duration.ofSeconds(1))<0 || budget.compareTo(Duration.ofSeconds(30))>0)throw new IllegalArgumentException("Invalid cancellation capacity"); + this.journal=Objects.requireNonNull(journal);this.client=Objects.requireNonNull(client);slots=new Semaphore(concurrency);budgetNanos=budget.toNanos(); + } + public Execution execute(ReviewOrphanCancellationJournal.Prepared prepared,String actor,BooleanSupplier permitted) { + if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Cancellation is not permitted"); + if(closed.get())return new Execution("SHUTDOWN",null); + if(!slots.tryAcquire())return new Execution("BUSY",null); + long started=System.nanoTime();var deadline=new AtomicLong(started+budgetNanos); + LongSupplier remaining=()->Math.min(budgetNanos-(System.nanoTime()-started),deadline.get()-System.nanoTime()); + BooleanSupplier allowed=()->{ + if(closed.get() || Thread.currentThread().isInterrupted() || remaining.getAsLong()<=0)throw new IllegalStateException("Cancellation dispatch stopped"); + return permitted.getAsBoolean(); + }; + ReviewOrphanCancellationJournal.Claim claim=null; + try(var io=ReviewRepairIo.open(remaining)) { + claim=journal.claim(prepared,actor,allowed); + if(claim==null)return result(journal.receipt(prepared,actor,allowed)); + var own=claim;ReviewOrphanCancellationJournal.Observation observation; + try { + var status=client.cancel(own.target().binding(),()->{ + long gateStarted=System.nanoTime();long millis=journal.beginDispatch(own,actor,allowed); + if(millis<=0)return false; + deadline.accumulateAndGet(gateStarted+TimeUnit.MILLISECONDS.toNanos(millis),Math::min); + return allowed.getAsBoolean(); + },remaining); + observation=new ReviewOrphanCancellationJournal.Observation("REMOTE_STATUS",status,null); + }catch(RemoteReviewClient.Unavailable failure) { + int code=failure.status();String kind=code==404?"NOT_FOUND":code==409?"CONTEXT_CONFLICT":code==401||code==403||code==429?"UNAVAILABLE":"UNKNOWN"; + observation=new ReviewOrphanCancellationJournal.Observation(kind,null,code); + }catch(RemoteReviewClient.Superseded stopped) {observation=new ReviewOrphanCancellationJournal.Observation("UNKNOWN",null,null);} + // Only conservative observation persistence/read-back may use the independent cleanup budget. + try(var cleanup=ReviewRepairIo.cleanup()){return result(journal.record(own,actor,observation,permitted));} + }catch(SecurityException denied){throw denied;} + catch(RuntimeException unknown) { + try(var cleanup=ReviewRepairIo.cleanup()){return result(journal.receipt(prepared,actor,permitted));} + catch(SecurityException denied){throw denied;} + catch(RuntimeException unavailable){return new Execution("UNKNOWN",null);} + }finally{slots.release();} + } + private static Execution result(ReviewOrphanCancellationJournal.Receipt receipt){return new Execution(receipt.state(),receipt);} + @Override public void close(){closed.set(true);} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanCancellationJournal.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanCancellationJournal.java new file mode 100644 index 000000000..c5e1a7736 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanCancellationJournal.java @@ -0,0 +1,246 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import net.modtale.service.security.scan.RemoteReviewClient; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.*; +import org.bson.codecs.DocumentCodec; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** One immutable cancellation intent per isolation, with no remote dispatch or automatic claim recovery. */ +public final class ReviewOrphanCancellationJournal { + public static final String COLLECTION="review_orphan_cancellations"; + public record Prepared(String isolationId,String targetSha256,long createdAt,long expiresAt) {} + public record Claim(Prepared prepared,String token,ReviewOrphanTargetResolver.Target target) {} + public record Observation(String kind,RemoteReviewClient.Status status,Integer httpStatus) { + public Observation { + if(!Set.of("REMOTE_STATUS","NOT_FOUND","CONTEXT_CONFLICT","UNAVAILABLE","UNKNOWN").contains(Objects.toString(kind,"")) + || "REMOTE_STATUS".equals(kind)!=(status!=null) || status!=null && httpStatus!=null + || "NOT_FOUND".equals(kind) && !Integer.valueOf(404).equals(httpStatus) + || "CONTEXT_CONFLICT".equals(kind) && !Integer.valueOf(409).equals(httpStatus) + || "UNAVAILABLE".equals(kind) && !Set.of(401,403,429).contains(httpStatus==null?0:httpStatus) + || httpStatus!=null && (httpStatus<100 || httpStatus>599))throw new IllegalArgumentException("Invalid cancellation observation"); + } + } + public record Receipt(Prepared prepared,String state,Observation observation,Long observedAt) {} + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private final MongoCollection operations; + private final ReviewOrphanTargetResolver resolver; + private final ReviewSnapshotArchive archive; + private final Clock clock; + private final long lifetimeMillis; + public ReviewOrphanCancellationJournal(MongoTemplate mongo,ReviewOrphanTargetResolver resolver,ReviewSnapshotArchive archive,Clock clock,long lifetimeMillis) { + if(lifetimeMillis<1000 || lifetimeMillis>120000)throw new IllegalArgumentException("Invalid cancellation lifetime"); + this.resolver=Objects.requireNonNull(resolver);this.archive=Objects.requireNonNull(archive);this.clock=Objects.requireNonNull(clock);this.lifetimeMillis=lifetimeMillis; + operations=mongo.getCollection(COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY) + .withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(5,TimeUnit.SECONDS)).withTimeout(5000,TimeUnit.MILLISECONDS); + } + public record Preview(String isolationId,String projectIdType,String projectId,int versionIndex,String versionId, + String requestId,String jobId,String beforeSha256,String targetSha256,String artifactSha256) {} + /** Original archived target only; does not prepare an operation or contact the remote service. */ + public Preview preview(String isolationId,String actor,BooleanSupplier permitted) { + permission(permitted);if(!uuid(isolationId))throw new IllegalArgumentException("Invalid cancellation identity"); + var original=resolver.resolve(isolationId,actor);permission(permitted); + boolean objectId=original.projectId() instanceof org.bson.types.ObjectId; + var binding=original.binding(); + var result=new Preview(original.isolationId(),objectId?"OBJECT_ID":"STRING", + objectId?((org.bson.types.ObjectId)original.projectId()).toHexString():(String)original.projectId(), + original.versionIndex(),binding.versionId(),binding.requestId(),binding.jobId(),original.beforeSha256(), + digest(bytes(target(original))),binding.artifactSha256()); + permission(permitted);return result; + } + public Prepared prepare(String isolationId,String actor,BooleanSupplier permitted) { + permission(permitted);var target=resolver.resolve(isolationId,actor);permission(permitted); + var stored=read(isolationId); + var retained=archive.find(archiveId(isolationId));permission(permitted); + if(retained==null) { + if(stored!=null)throw unavailable(); + long now=clock.millis();var candidate=new ReviewSnapshotArchive.Snapshot(archiveId(isolationId),target.projectId(),target.versionIndex(),actor, + ReviewSnapshotArchive.Action.CANCEL_REMOTE_REVIEW,now,Math.addExact(now,lifetimeMillis),bytes(target(target))); + permission(permitted); + try {retained=archive.retain(candidate);} + catch(RuntimeException uncertain) {retained=archive.find(candidate.id());if(retained==null)throw uncertain;} + } + permission(permitted);validateArchive(retained,actor,target); + if(stored==null) { + var intent=intent(actor,target,retained.createdAt(),retained.expiresAt()); + var candidate=new Document("_id",isolationId).append("intent",intent).append("state","PREPARED"); + permission(permitted); + try {ReviewRepairIo.collection(operations).insertOne(candidate);} + catch(MongoException uncertain) { /* Read back the original immutable intent; never replace it. */ } + stored=read(isolationId); + } + permission(permitted);var prepared=authenticate(stored,actor,target); + if(clock.millis()=prepared.expiresAt())throw unavailable(); + permission(permitted);return prepared; + } + public Claim claim(Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);Objects.requireNonNull(prepared); + var target=resolver.resolve(prepared.isolationId(),actor);permission(permitted);var stored=read(prepared.isolationId()); + if(!prepared.equals(authenticate(stored,actor,target)))throw unavailable(); + if(!"PREPARED".equals(stored.get("state")))return null; + String token=UUID.randomUUID().toString(); + var predicate=new Document("$and",List.of(new Document("$eq",List.of("$$ROOT",literal(stored))), + new Document("$eq",List.of(new Document("$type","$intent.createdAt"),"long")), + new Document("$eq",List.of(new Document("$type","$intent.expiresAt"),"long")), + new Document("$eq",List.of(new Document("$type","$intent.target.versionIndex"),"int")), + new Document("$eq",List.of(new Document("$type","$intent.target.binding.attempt"),"int")), + new Document("$gte",List.of("$$NOW",new Date(prepared.createdAt()))),new Document("$lt",List.of("$$NOW",new Date(prepared.expiresAt()))))); + var query=new Document("_id",prepared.isolationId()).append("$expr",predicate); + permission(permitted); + try { + var armed=ReviewRepairIo.collection(operations).findOneAndUpdate(query,List.of(new Document("$set",new Document("state","EXECUTING").append("token",literal(token)).append("startedAt","$$NOW"))), + new FindOneAndUpdateOptions().collation(BINARY).returnDocument(ReturnDocument.AFTER)); + permission(permitted); + return owned(armed,stored,token)?new Claim(prepared,token,target):null; + }catch(MongoException uncertain) { + permission(permitted);var armed=read(prepared.isolationId());permission(permitted); + if(owned(armed,stored,token))return new Claim(prepared,token,target); + throw unavailable(); + } + } + /** Returns the database-time budget once, immediately before the HTTP subscription. */ + public long beginDispatch(Claim claim,String actor,BooleanSupplier permitted) { + permission(permitted);var stored=ownedRecord(claim,actor);permission(permitted); + if(!"EXECUTING".equals(stored.get("state")))return 0; + var query=exact(stored).append("$expr",new Document("$and",List.of(exactExpression(stored), + new Document("$gte",List.of("$$NOW",new Date(claim.prepared().createdAt()))),new Document("$lt",List.of("$$NOW",new Date(claim.prepared().expiresAt())))))); + permission(permitted); + // A lost acknowledgement is deliberately not recovered into dispatch authority here. + var dispatched=ReviewRepairIo.collection(operations).findOneAndUpdate(query,List.of(new Document("$set",new Document("state","DISPATCHING").append("dispatchAt","$$NOW"))), + new FindOneAndUpdateOptions().collation(BINARY).returnDocument(ReturnDocument.AFTER)); + permission(permitted);if(dispatched==null)return 0; + if(!claim.prepared().equals(authenticate(dispatched,actor,claim.target())))throw unavailable(); + return claim.prepared().expiresAt()-dispatched.getDate("dispatchAt").getTime(); + } + public Receipt record(Claim claim,String actor,Observation observation,BooleanSupplier permitted) { + permission(permitted);var stored=ownedRecord(claim,actor);validateObservation(observation,claim.target());permission(permitted); + if(!"DISPATCHING".equals(stored.get("state")))return receipt(claim.prepared(),actor,permitted); + var fields=new Document("state","UNKNOWN".equals(observation.kind())?"UNKNOWN":"OBSERVED") + .append("observation",literal(observationDocument(observation))).append("finishedAt","$$NOW"); + permission(permitted); + try {ReviewRepairIo.collection(operations).updateOne(exact(stored),List.of(new Document("$set",fields)),new UpdateOptions().collation(BINARY));} + catch(MongoException uncertain) { /* Receipt read-back decides whether the observation was persisted. */ } + return receipt(claim.prepared(),actor,permitted); + } + public Receipt receipt(Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);Objects.requireNonNull(prepared); + var recovered=recover(prepared.isolationId(),actor,permitted); + if(!prepared.equals(recovered.prepared()))throw unavailable(); + permission(permitted);return recovered; + } + /** Read-only recovery of the original signed intent, including after expiry. */ + public Receipt recover(String isolationId,String actor,BooleanSupplier permitted) { + permission(permitted);if(!uuid(isolationId))throw new IllegalArgumentException("Invalid cancellation identity"); + var target=resolver.resolve(isolationId,actor);permission(permitted);var stored=read(isolationId); + var prepared=authenticate(stored,actor,target); + String state=stored.getString("state");Observation observation=stored.containsKey("observation")?observation(stored.get("observation",Document.class)):null; + permission(permitted);return new Receipt(prepared,state,observation,stored.containsKey("finishedAt")?stored.getDate("finishedAt").getTime():null); + } + /** Original target for observation only; this never returns or restores an execution token. */ + public ReviewOrphanTargetResolver.Target reconciliationTarget(Prepared prepared,String actor,BooleanSupplier permitted) { + permission(permitted);var target=resolver.resolve(prepared.isolationId(),actor);permission(permitted);var stored=read(prepared.isolationId()); + if(!prepared.equals(authenticate(stored,actor,target)) || "PREPARED".equals(stored.get("state")))throw unavailable(); + permission(permitted);return target; + } + private Document ownedRecord(Claim claim,String actor) { + if(claim==null || !uuid(claim.token()))throw unavailable(); + var target=resolver.resolve(claim.prepared().isolationId(),actor);var stored=read(claim.prepared().isolationId()); + if(!target.equals(claim.target()) || !claim.prepared().equals(authenticate(stored,actor,target)) || !claim.token().equals(stored.get("token")))throw unavailable(); + return stored; + } + private static Document exact(Document stored){return new Document("_id",stored.get("_id")).append("$expr",exactExpression(stored));} + private static Document exactExpression(Document stored) { + return new Document("$and",List.of(new Document("$eq",List.of("$$ROOT",literal(stored))), + new Document("$eq",List.of(new Document("$type","$intent.createdAt"),"long")),new Document("$eq",List.of(new Document("$type","$intent.expiresAt"),"long")), + new Document("$eq",List.of(new Document("$type","$intent.target.versionIndex"),"int")),new Document("$eq",List.of(new Document("$type","$intent.target.binding.attempt"),"int")))); + } + static Document observationDocument(Observation observation) { + var status=observation.status();Document encoded=status==null?null:new Document("jobId",status.jobId()).append("state",status.state()).append("artifactRetained",status.artifactRetained()) + .append("createdAt",status.createdAt()).append("expiresAt",status.expiresAt()).append("workState",status.workState()); + return new Document("kind",observation.kind()).append("status",encoded).append("httpStatus",observation.httpStatus()); + } + static Observation observation(Document doc) { + if(doc==null || doc.size()!=3)throw unavailable();var status=doc.get("status",Document.class);RemoteReviewClient.Status parsed=null; + if(status!=null) { + if(status.size()!=6 || !(status.get("artifactRetained") instanceof Boolean) || !(status.get("createdAt") instanceof Long) || !(status.get("expiresAt") instanceof Long))throw unavailable(); + parsed=new RemoteReviewClient.Status(status.getString("jobId"),status.getString("state"),status.getBoolean("artifactRetained"),status.getLong("createdAt"),status.getLong("expiresAt"),status.getString("workState")); + } + var result=new Observation(doc.getString("kind"),parsed,doc.getInteger("httpStatus"));if(!Arrays.equals(bytes(doc),bytes(observationDocument(result))))throw unavailable();return result; + } + static void validateObservation(Observation observation,ReviewOrphanTargetResolver.Target target) { + Objects.requireNonNull(observation);var status=observation.status();if(status==null)return; + if(!target.binding().jobId().equals(status.jobId()) || !Set.of("QUEUED","RUNNING","COMPLETED","CANCELLED","EXPIRED","HELD","UPLOADING","AWAITING_UPLOAD").contains(Objects.toString(status.state(),"")) + || status.createdAt()<=0 || status.expiresAt()<=status.createdAt() || status.workState()!=null && status.workState().length()>128 + || Set.of("QUEUED","RUNNING","COMPLETED","HELD").contains(status.state()) && !status.artifactRetained())throw unavailable(); + } + private Prepared authenticate(Document stored,String actor,ReviewOrphanTargetResolver.Target target) { + try { + if(stored==null || !target.isolationId().equals(stored.get("_id")))throw unavailable(); + var intent=stored.get("intent",Document.class); + if(intent==null || !(intent.get("createdAt") instanceof Long created) || !(intent.get("expiresAt") instanceof Long expires) + || created<1 || expires<=created || expires-created>120000)throw unavailable(); + var retained=archive.load(archiveId(target.isolationId()));validateArchive(retained,actor,target); + if(created!=retained.createdAt() || expires!=retained.expiresAt() + || !Arrays.equals(bytes(intent),bytes(intent(actor,target,created,expires))))throw unavailable(); + String state=stored.getString("state"); + if("PREPARED".equals(state)) {if(stored.size()!=3)throw unavailable();} + else { + if(!Set.of("EXECUTING","DISPATCHING","OBSERVED","UNKNOWN").contains(state) || !(stored.get("token") instanceof String token) || !uuid(token) + || !(stored.get("startedAt") instanceof Date started) || started.getTime()=expires)throw unavailable(); + if("EXECUTING".equals(state)) {if(stored.size()!=5)throw unavailable();} + else { + if(!(stored.get("dispatchAt") instanceof Date dispatched) || dispatched.before(started) || dispatched.getTime()>=expires)throw unavailable(); + if("DISPATCHING".equals(state)) {if(stored.size()!=6)throw unavailable();} + else { + if(stored.size()!=8 || !(stored.get("finishedAt") instanceof Date finished) || finished.before(dispatched))throw unavailable(); + var observation=observation(stored.get("observation",Document.class));validateObservation(observation,target); + if("UNKNOWN".equals(state)!= "UNKNOWN".equals(observation.kind()))throw unavailable(); + } + } + } + return new Prepared(target.isolationId(),digest(bytes(target(target))),created,expires); + }catch(RuntimeException invalid){throw unavailable();} + } + private static String archiveId(String isolationId) { + if(!uuid(isolationId))throw unavailable(); + byte[] hash=HexFormat.of().parseHex(digest(("warden-orphan-cancellation-intent-v1\0"+isolationId).getBytes(java.nio.charset.StandardCharsets.UTF_8))); + hash[6]=(byte)((hash[6]&15)|128);hash[8]=(byte)((hash[8]&63)|128); + var buffer=java.nio.ByteBuffer.wrap(hash);return new UUID(buffer.getLong(),buffer.getLong()).toString(); + } + private static void validateArchive(ReviewSnapshotArchive.Snapshot retained,String actor,ReviewOrphanTargetResolver.Target target) { + if(retained==null || !archiveId(target.isolationId()).equals(retained.id()) || !actor.equals(retained.actorId()) + || retained.action()!=ReviewSnapshotArchive.Action.CANCEL_REMOTE_REVIEW || !target.projectId().equals(retained.projectId()) + || target.versionIndex()!=retained.versionIndex() || retained.expiresAt()-retained.createdAt()>120000 + || !Arrays.equals(retained.versionBytes(),bytes(target(target))))throw unavailable(); + } + private static boolean owned(Document armed,Document before,String token) { + if(armed==null || armed.size()!=5 || !(armed.get("startedAt") instanceof Date))return false; + var expected=new Document(before).append("state","EXECUTING").append("token",token).append("startedAt",armed.get("startedAt")); + return Arrays.equals(bytes(armed),bytes(expected)); + } + private Document read(String id){return ReviewRepairIo.collection(operations).find(new Document("_id",id)).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();} + private static Document intent(String actor,ReviewOrphanTargetResolver.Target target,long created,long expires) { + return new Document("action","CANCEL_REMOTE_REVIEW").append("actor",actor).append("target",target(target)).append("createdAt",created).append("expiresAt",expires); + } + private static Document target(ReviewOrphanTargetResolver.Target target) { + var b=target.binding();if(b==null || b.origin()==null || b.jobId()==null)throw unavailable(); + var binding=new Document("projectId",b.projectId()).append("versionId",b.versionId()).append("requestId",b.requestId()).append("attempt",b.attempt()) + .append("filePath",b.filePath()).append("artifactSha256",b.artifactSha256()).append("contextSha256",b.contextSha256()).append("policyVersion",b.policyVersion()) + .append("reviewConfigSha256",b.reviewConfigSha256()).append("jobId",b.jobId()).append("manualRescan",b.manualRescan()) + .append("origin",new Document("deploymentId",b.origin().deploymentId()).append("callerScope",b.origin().callerScope())); + return new Document("isolationId",target.isolationId()).append("projectId",target.projectId()).append("versionIndex",target.versionIndex()) + .append("beforeSha256",target.beforeSha256()).append("binding",binding); + } + private static byte[] bytes(Document document){var buffer=new RawBsonDocument(document,new DocumentCodec()).getByteBuffer().asNIO();byte[] result=new byte[buffer.remaining()];buffer.get(result);return result;} + private static String digest(byte[] bytes){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes));}catch(Exception invalid){throw unavailable();}} + private static Document literal(Object value){return new Document("$literal",value);} + private static boolean uuid(String value){return value!=null && value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static void permission(BooleanSupplier permitted){if(permitted==null || !permitted.getAsBoolean())throw new SecurityException("Cancellation is not permitted");} + private static IllegalStateException unavailable(){return new IllegalStateException("Cancellation intent is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanTargetResolver.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanTargetResolver.java new file mode 100644 index 000000000..1fba7ac01 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewOrphanTargetResolver.java @@ -0,0 +1,53 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ArtifactReviewContext; +import org.bson.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; + +/** Original target evidence only; resolving a target never sends or authorizes a remote cancellation. */ +public final class ReviewOrphanTargetResolver { + public record Target(String isolationId,Object projectId,int versionIndex,String beforeSha256,RemoteReviewBinding binding) {} + private final ReviewSnapshotArchive archive; + private final ReviewIsolationExecutor isolation; + private final MongoTemplate mongo; + public ReviewOrphanTargetResolver(MongoTemplate mongo,ReviewSnapshotArchive archive,ReviewIsolationExecutor isolation) { + this.mongo=Objects.requireNonNull(mongo);this.archive=Objects.requireNonNull(archive);this.isolation=Objects.requireNonNull(isolation); + } + public Target resolve(String isolationId,String actor) { + var recovered=isolation.recover(isolationId,actor); + if(!"APPLIED".equals(recovered.receipt().outcome().state()))throw unavailable(); + var source=archive.load(isolationId); + try { + if(!source.id().equals(recovered.prepared().id()) || !source.actorId().equals(actor) || source.action()!=ReviewSnapshotArchive.Action.ISOLATE_REVIEW + || source.createdAt()!=recovered.prepared().createdAt() || source.expiresAt()!=recovered.prepared().expiresAt() + || !source.projectId().equals(recovered.receipt().projectId()) || source.versionIndex()!=recovered.receipt().versionIndex() + || !digest(source.versionBytes()).equals(recovered.prepared().sha256()))throw unavailable(); + var original=new RawBsonDocument(source.versionBytes()).decode(new org.bson.codecs.DocumentCodec()); + var rawBinding=original.get("scanResult",Document.class).get("remoteReview",Document.class); + if(rawBinding==null || !(rawBinding.get("manualRescan") instanceof Boolean))throw unavailable(); + var binding=mongo.getConverter().read(RemoteReviewBinding.class,rawBinding); + var scan=original.get("scanResult",Document.class); + if(!"PENDING".equals(original.get("reviewStatus")) || !"SCANNING".equals(scan.get("status")) + || !"REMOTE_REVIEW".equals(scan.get("scanState")) || binding.jobId()==null || binding.origin()==null + || !source.projectId().toString().equals(binding.projectId()) || !binding.versionId().equals(original.get("_id")) + || !binding.requestId().equals(scan.get("scanRequestId")) || !(scan.get("manualRescan") instanceof Boolean manual) || manual!=binding.manualRescan() + || !(scan.get("scanAttempt") instanceof Integer || scan.get("scanAttempt") instanceof Long) + || ((Number)scan.get("scanAttempt")).longValue()!=binding.attempt() + || !binding.filePath().equals(original.get("fileUrl")) || !binding.artifactSha256().equals(original.get("hash")))throw unavailable(); + // Poll and result fields may be malformed: only the original artifact context is decoded. + var context=new Document(); + for(String field:List.of("_id","fileUrl","hash","gameVersions","dependencies","manifestId","manifestVersion","overrideFileUrl","modpackConfigs")) + if(original.containsKey(field))context.put(field,original.get(field)); + var version=mongo.getConverter().read(ProjectVersion.class,context); + if(!binding.contextSha256().equals(ArtifactReviewContext.automaticallyReviewableFingerprint(version)))throw unavailable(); + return new Target(isolationId,source.projectId(),source.versionIndex(),recovered.prepared().sha256(),binding); + } catch(RuntimeException invalid) {throw unavailable();} + } + private static String digest(byte[] value) { + try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value));} + catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);} + } + private static IllegalStateException unavailable(){return new IllegalStateException("Original remote review target is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairAccess.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairAccess.java new file mode 100644 index 000000000..c4d477d01 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairAccess.java @@ -0,0 +1,91 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.user.AdminPermission; +import net.modtale.model.user.User; +import net.modtale.service.user.account.AccountService; +import org.bson.types.ObjectId; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.function.BooleanSupplier; + +/** Account-bound boundary for the disabled-by-default moderator repair workflow. */ +public final class ReviewRepairAccess { + public record Position(String projectIdType,String projectId,int versionIndex) {} + public record Inspect(Position position,String versionId) {} + public record Preview(Position position,String versionId,String sha256,boolean eligible,String effect) {} + public record Prepare(String id,Position position,String versionId,String expectedSha256) {} + private final AccountService accounts; + private final ReviewRepairWorkflow workflow; + private final RawReviewSnapshotReader reader; + private final ReviewIsolationExecutor isolation; + public ReviewRepairAccess(AccountService accounts,ReviewRepairWorkflow workflow,RawReviewSnapshotReader reader,ReviewIsolationExecutor isolation) { + this.accounts=accounts;this.workflow=workflow;this.reader=reader;this.isolation=isolation; + } + public record Capability(String action) {} + public Capability capability() {var authority=authority();return workflow.read(()->new Capability("ISOLATE_LOCAL_REVIEW"),authority.allowed());} + public Preview inspect(Inspect request) { + var authority=authority();if(request==null)throw invalid();Object project=project(request.position()); + return workflow.read(()->{ + var captured=reader.capture(project,request.position().versionIndex(),request.versionId()); + return new Preview(request.position(),captured.versionId(),captured.sha256(),isolation.eligible(captured),"ISOLATE_LOCAL_REVIEW"); + },authority.allowed()); + } + public ReviewRepairPreparation.Prepared prepare(Prepare request) { + var authority=authority();if(request==null)throw invalid();Object project=project(request.position()); + var input=new ReviewRepairPreparation.Request(request.id(),project,request.position().versionIndex(),request.versionId(),request.expectedSha256(),authority.actor(),ReviewSnapshotArchive.Action.ISOLATE_REVIEW); + return workflow.prepare(input,authority.allowed()); + } + public ReviewIsolationExecutor.Result execute(ReviewRepairPreparation.Prepared prepared) { + var authority=authority();validate(prepared);return workflow.isolate(prepared,authority.actor(),authority.allowed()); + } + public Receipt closeExpired(ReviewRepairPreparation.Prepared prepared) { + var authority=authority();validate(prepared);return receiptView(workflow.closeExpired(prepared,authority.actor(),authority.allowed())); + } + public record Receipt(Position position,String versionId,String beforeSha256,String state,String afterSha256) {} + public Receipt receipt(ReviewRepairPreparation.Prepared prepared) { + var authority=authority();validate(prepared);return workflow.read(()->{ + var receipt=isolation.receipt(prepared,authority.actor()); + return receiptView(receipt); + },authority.allowed()); + } + public ReviewRepairOperationReader.Page operations(String cursor,int limit) { + var authority=authority();return workflow.read(()->isolation.operations(authority.actor(),cursor,limit),authority.allowed()); + } + public record Recovered(ReviewRepairPreparation.Prepared prepared,Receipt receipt) {} + public Recovered recover(String id) { + var authority=authority(); + if(id==null || !id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))throw invalid(); + return workflow.read(()->{var recovered=isolation.recover(id,authority.actor());return new Recovered(recovered.prepared(),receiptView(recovered.receipt()));},authority.allowed()); + } + private static Receipt receiptView(ReviewIsolationExecutor.Receipt receipt) { + boolean objectId=receipt.projectId() instanceof ObjectId; + var position=new Position(objectId?"OBJECT_ID":"STRING",objectId?((ObjectId)receipt.projectId()).toHexString():(String)receipt.projectId(),receipt.versionIndex()); + return new Receipt(position,receipt.versionId(),receipt.beforeSha256(),receipt.outcome().state(),receipt.outcome().afterSha256()); + } + private record Authority(String actor,BooleanSupplier allowed) {} + private Authority authority() { + User user=current();if(!allowed(user))throw new SecurityException("Review repair is not permitted"); + String actor=user.getId(); + return new Authority(actor,()->{User refreshed=current();return allowed(refreshed)&&actor.equals(refreshed.getId());}); + } + private User current(){return accounts.getCurrentUser(SecurityContextHolder.getContext().getAuthentication());} + private static boolean allowed(User user) { + return user!=null && !user.isDeleted() && user.getId()!=null && !user.getId().isBlank() + && AdminPermission.hasPermission(user,AdminPermission.PROJECT_REVIEW_READ) + && AdminPermission.hasPermission(user,AdminPermission.PROJECT_VERSION_RESCAN); + } + private static Object project(Position position) { + if(position==null || position.projectId()==null || position.versionIndex()<0 || position.versionIndex()>16*1024*1024)throw invalid(); + if("OBJECT_ID".equals(position.projectIdType())) { + if(!position.projectId().matches("[0-9a-f]{24}"))throw invalid();return new ObjectId(position.projectId()); + } + if(!"STRING".equals(position.projectIdType()) || position.projectId().isEmpty() || position.projectId().length()>128 + || !java.nio.charset.StandardCharsets.UTF_8.newEncoder().canEncode(position.projectId()))throw invalid(); + return position.projectId(); + } + private static void validate(ReviewRepairPreparation.Prepared prepared) { + if(prepared==null || prepared.id()==null || !prepared.id().matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || prepared.sha256()==null || !prepared.sha256().matches("[0-9a-f]{64}") || prepared.createdAt()<=0 || prepared.expiresAt()<=prepared.createdAt() + || prepared.expiresAt()-prepared.createdAt()>900000)throw invalid(); + } + private static IllegalArgumentException invalid(){return new IllegalArgumentException("Invalid review repair request");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairConfiguration.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairConfiguration.java new file mode 100644 index 000000000..11b02d794 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairConfiguration.java @@ -0,0 +1,31 @@ +package net.modtale.service.admin.review; + +import net.modtale.config.properties.AppReviewRepairProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Clock; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnProperty(name="app.warden.repair.enabled",havingValue="true") +@EnableConfigurationProperties(AppReviewRepairProperties.class) +public class ReviewRepairConfiguration { + @Bean ReviewSnapshotArchive reviewSnapshotArchive(MongoTemplate mongo,AppReviewRepairProperties properties) { + return new ReviewSnapshotArchive(mongo,properties.activeKey(),properties.decodedKeys()); + } + @Bean ReviewRepairJournal reviewRepairJournal(MongoTemplate mongo,ReviewSnapshotArchive archive){return new ReviewRepairJournal(mongo,archive);} + @Bean(initMethod="initializeDiscovery") ReviewIsolationExecutor reviewIsolationExecutor(MongoTemplate mongo,ReviewSnapshotArchive archive,RawReviewSnapshotReader reader,ReviewRepairJournal journal) { + return new ReviewIsolationExecutor(mongo,archive,reader,journal); + } + @Bean ReviewRepairWorkflow reviewRepairWorkflow(ReviewRepairPreparation preparation,ReviewIsolationExecutor isolation,AppReviewRepairProperties properties) { + return new ReviewRepairWorkflow(preparation,isolation,properties.concurrency()); + } + @Bean ReviewRepairAccess reviewRepairAccess(net.modtale.service.user.account.AccountService accounts,ReviewRepairWorkflow workflow,RawReviewSnapshotReader reader,ReviewIsolationExecutor isolation) { + return new ReviewRepairAccess(accounts,workflow,reader,isolation); + } + @Bean RawReviewSnapshotReader rawReviewSnapshotReader(MongoTemplate mongo){return new RawReviewSnapshotReader(mongo);} + @Bean ReviewRepairPreparation reviewRepairPreparation(ReviewSnapshotArchive archive,RawReviewSnapshotReader reader,AppReviewRepairProperties properties) { + return new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),properties.concurrency(),properties.preparationLifetimeMillis()); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairIo.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairIo.java new file mode 100644 index 000000000..c70b2ba58 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairIo.java @@ -0,0 +1,73 @@ +package net.modtale.service.admin.review; + +import com.mongodb.ClientSessionOptions; +import com.mongodb.TransactionOptions; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; + +/** Synchronous workflow scope; never inherited by another thread or reused after return. */ +final class ReviewRepairIo implements AutoCloseable { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private final ReviewRepairIo previous; + private final LongSupplier remainingNanos; + private boolean closed; + + private ReviewRepairIo(LongSupplier remainingNanos) { + this.remainingNanos = remainingNanos; + previous = CURRENT.get(); + CURRENT.set(this); + } + + static ReviewRepairIo open(LongSupplier remainingNanos) { return new ReviewRepairIo(remainingNanos); } + + // Only conservative receipt reconciliation/bookkeeping may enter this independent cleanup budget. + static ReviewRepairIo cleanup() { + long started = System.nanoTime(); + return open(() -> TimeUnit.SECONDS.toNanos(5) - (System.nanoTime() - started)); + } + + private static long timeoutMillis() { + long nanos = CURRENT.get().remainingNanos.getAsLong(); + if (nanos <= 0) throw new IllegalStateException("Review repair I/O deadline exceeded"); + // Never round a positive remainder down to zero: the driver interprets zero as unlimited. + return Math.min(5000, Math.max(1, TimeUnit.NANOSECONDS.toMillis(nanos))); + } + + static MongoCollection collection(MongoCollection collection) { + return CURRENT.get() == null ? collection : collection.withTimeout(timeoutMillis(), TimeUnit.MILLISECONDS); + } + + static MongoCollection collection(MongoCollection collection, com.mongodb.client.ClientSession session) { + if (session != null && session.hasActiveTransaction()) { + // The driver forbids changing timeoutMS inside a transaction. Its transaction budget applies. + if (CURRENT.get() != null) timeoutMillis(); + return collection; + } + return collection(collection); + } + + static MongoDatabase database(MongoDatabase database) { + return CURRENT.get() == null ? database : database.withTimeout(timeoutMillis(), TimeUnit.MILLISECONDS); + } + + static ClientSessionOptions sessionOptions() { + var builder = ClientSessionOptions.builder().causallyConsistent(false); + if (CURRENT.get() != null) builder.defaultTimeout(timeoutMillis(), TimeUnit.MILLISECONDS); + return builder.build(); + } + + static TransactionOptions transactionOptions(TransactionOptions base) { + if (CURRENT.get() == null) return base; + return TransactionOptions.builder().readConcern(base.getReadConcern()).readPreference(base.getReadPreference()) + .writeConcern(base.getWriteConcern()).timeout(timeoutMillis(), TimeUnit.MILLISECONDS).build(); + } + + @Override public void close() { + if (closed) return; + if (CURRENT.get() != this) throw new IllegalStateException("Invalid repair I/O scope"); + closed = true; + if (previous == null) CURRENT.remove(); else CURRENT.set(previous); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairJournal.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairJournal.java new file mode 100644 index 000000000..a3e3797c8 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairJournal.java @@ -0,0 +1,109 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +/** One-shot execution claims. An uncertain claim is never automatically reclaimed. */ +public final class ReviewRepairJournal { + public static final String COLLECTION="review_repair_operations"; + public record Claim(String id,String token) { + public Claim {if(!uuid(id)||!uuid(token))throw new IllegalArgumentException("Invalid repair claim");} + } + private final MongoCollection operations; + private final ReviewSnapshotArchive archive; + private static final Collation BINARY=Collation.builder().locale("simple").build(); + public ReviewRepairJournal(MongoTemplate mongo,ReviewSnapshotArchive archive) { + this.archive=Objects.requireNonNull(archive);operations=mongo.getCollection(COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY) + .withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(10,TimeUnit.SECONDS)); + } + public Claim claim(ReviewRepairPreparation.Prepared prepared,String actor,ReviewSnapshotArchive.Action action,BooleanSupplier permitted) { + requirePermission(permitted);Objects.requireNonNull(prepared); + var snapshot=archive.load(prepared.id()); + if(!snapshot.actorId().equals(actor)||snapshot.action()!=action||snapshot.createdAt()!=prepared.createdAt()||snapshot.expiresAt()!=prepared.expiresAt() + ||!digest(snapshot.versionBytes()).equals(prepared.sha256()))throw unavailable(); + var record=new Document("_id",prepared.id()).append("token",UUID.randomUUID().toString()).append("sha256",prepared.sha256()) + .append("actor",actor).append("action",action.name()).append("createdAt",prepared.createdAt()).append("expiresAt",prepared.expiresAt()).append("state","RESERVED"); + requirePermission(permitted); + try {ReviewRepairIo.collection(operations).insertOne(record);} + catch(MongoException uncertain) { + var existing=read(prepared.id()); + if(existing==null)throw unavailable(); + if(!record.equals(existing))return null; + } + requirePermission(permitted); + var query=new Document(record).append("$expr",new Document("$and",List.of(new Document("$gte",List.of("$$NOW",new Date(prepared.createdAt()))), + new Document("$lt",List.of("$$NOW",new Date(prepared.expiresAt())))))); + try { + var armed=ReviewRepairIo.collection(operations).findOneAndUpdate(query,List.of(new Document("$set",new Document("state","EXECUTING").append("startedAt","$$NOW"))), + new FindOneAndUpdateOptions().collation(BINARY).returnDocument(ReturnDocument.AFTER)); + requirePermission(permitted); + return armed!=null && executing(armed,record)?new Claim(prepared.id(),record.getString("token")):null; + } catch(MongoException uncertain) { + requirePermission(permitted);var armed=read(prepared.id());requirePermission(permitted); + if(armed!=null && executing(armed,record))return new Claim(prepared.id(),record.getString("token")); + throw unavailable(); + } + } + public boolean markUnknown(Claim claim,BooleanSupplier permitted) { + requirePermission(permitted);var stored=read(claim.id());if(stored==null || !claim.token().equals(stored.get("token")))return false; + if("UNKNOWN".equals(stored.get("state")))return true; + if(!"EXECUTING".equals(stored.get("state")) || stored.size()!=9 || !(stored.get("startedAt") instanceof Date))return false; + requirePermission(permitted); + try { + return ReviewRepairIo.collection(operations).updateOne(stored,List.of(new Document("$set",new Document("state","UNKNOWN").append("uncertainAt","$$NOW"))),new UpdateOptions().collation(BINARY)).getModifiedCount()==1; + } catch(MongoException uncertain) { + var current=read(claim.id());return current!=null && claim.token().equals(current.get("token")) && "UNKNOWN".equals(current.get("state")); + } + } + /** Closes an expired claim; never grants a new execution token or changes a version. */ + public boolean closeExpired(ReviewRepairPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + requirePermission(permitted); + var source=archive.load(prepared.id()); + if(!source.actorId().equals(actor) || source.action()!=ReviewSnapshotArchive.Action.ISOLATE_REVIEW + || source.createdAt()!=prepared.createdAt() || source.expiresAt()!=prepared.expiresAt() + || !digest(source.versionBytes()).equals(prepared.sha256()))throw unavailable(); + var stored=read(prepared.id()); + if(!closable(stored,prepared,actor))return false; + var conditions=List.of(new Document("$gte",List.of("$$NOW",new Date(prepared.expiresAt()))), + new Document("$eq",List.of(new Document("$size",new Document("$objectToArray","$$ROOT")),stored.size())), + new Document("$eq",List.of(new Document("$type","$createdAt"),"long")), + new Document("$eq",List.of(new Document("$type","$expiresAt"),"long"))); + var query=new Document(stored).append("$expr",new Document("$and",conditions)); + var resolution=new Document("kind","EXPIRED_CLAIM_CLOSED").append("actorId",new Document("$literal",actor)) + .append("previousState",stored.getString("state")).append("closedAt","$$NOW"); + requirePermission(permitted); + // A competing isolation must update this same journal document in its version transaction. + // Either its terminal receipt wins, or this write prevents that transaction from committing. + return ReviewRepairIo.collection(operations).updateOne(query,List.of(new Document("$set",new Document("state","NOT_APPLIED") + .append("afterSha256",null).append("finishedAt","$$NOW").append("resolution",resolution))),new UpdateOptions().collation(BINARY)).getModifiedCount()==1; + } + private static boolean closable(Document stored,ReviewRepairPreparation.Prepared prepared,String actor) { + if(stored==null || !(stored.get("token") instanceof String token) || !uuid(token) + || !actor.equals(stored.get("actor")) || !"ISOLATE_REVIEW".equals(stored.get("action")) + || !prepared.sha256().equals(stored.get("sha256")) || !Long.valueOf(prepared.createdAt()).equals(stored.get("createdAt")) + || !Long.valueOf(prepared.expiresAt()).equals(stored.get("expiresAt")))return false; + return switch(Objects.toString(stored.get("state"),"")) { + case "RESERVED" -> stored.size()==8; + case "EXECUTING" -> stored.size()==9 && stored.get("startedAt") instanceof Date; + case "UNKNOWN" -> stored.size()==10 && stored.get("startedAt") instanceof Date && stored.get("uncertainAt") instanceof Date; + default -> false; + }; + } + private static boolean executing(Document stored,Document reserved) { + var expected=new Document(reserved);expected.put("state","EXECUTING"); + if(stored.size()!=9 || !(stored.get("startedAt") instanceof Date))return false; + var copy=new Document(stored);copy.remove("startedAt");return expected.equals(copy); + } + private Document read(String id){return ReviewRepairIo.collection(operations).find(new Document("_id",id)).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();} + private static String digest(byte[] bytes){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}catch(Exception invalid){throw unavailable();}} + private static boolean uuid(String id){return id!=null && id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static void requirePermission(BooleanSupplier permitted){if(permitted==null||!permitted.getAsBoolean())throw new SecurityException("Repair execution is not permitted");} + private static IllegalStateException unavailable(){return new IllegalStateException("Repair execution claim is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairOperationReader.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairOperationReader.java new file mode 100644 index 000000000..e0c94fd91 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairOperationReader.java @@ -0,0 +1,47 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.*; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Actor-scoped discovery only; a recorded state never substitutes for an authenticated receipt. */ +public final class ReviewRepairOperationReader { + public record Item(String id,String recordedState) {} + public record Page(List items,String nextCursor,String order) {} + private static final String UUID="[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + public static final String INDEX="review_repair_actor_action_id"; + private static final Collation BINARY=Collation.builder().locale("simple").build(); + private static final List STATES=List.of("RESERVED","EXECUTING","UNKNOWN","APPLIED","NOT_APPLIED"); + private final MongoCollection operations; + public ReviewRepairOperationReader(MongoTemplate mongo) { + operations=mongo.getCollection(ReviewRepairJournal.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public void initialize() { + operations.withWriteConcern(WriteConcern.MAJORITY.withJournal(true)).withTimeout(5000,TimeUnit.MILLISECONDS) + .createIndex(new Document("actor",1).append("action",1).append("_id",1),new IndexOptions().name(INDEX).collation(BINARY)); + } + public Page page(String actor,String cursor,int limit) { + if(actor==null || actor.isBlank() || actor.length()>256 || limit<1 || limit>25)throw new IllegalArgumentException("Invalid operation page"); + String after=null; + if(cursor!=null) {if(!cursor.matches("r1\\."+UUID))throw new IllegalArgumentException("Invalid operation cursor");after=cursor.substring(3);} + var id=new Document("$type","string").append("$regex","^"+UUID+"$");if(after!=null)id.append("$gt",after); + var scalar=new Document("$and",List.of(new Document("$eq",List.of(new Document("$type","$actor"),"string")),new Document("$eq",List.of(new Document("$type","$action"),"string")))); + var query=new Document("actor",actor).append("action","ISOLATE_REVIEW").append("_id",id).append("$expr",scalar); + var state=new Document("$cond",List.of(new Document("$in",List.of("$state",STATES)),"$state","UNKNOWN")); + var pipeline=List.of(new Document("$match",query),new Document("$sort",new Document("_id",1)),new Document("$limit",limit+1), + new Document("$project",new Document("_id",1).append("recordedState",state))); + var records=ReviewRepairIo.collection(operations).aggregate(pipeline).hintString(INDEX).collation(BINARY).allowDiskUse(false) + .maxTime(5,TimeUnit.SECONDS).batchSize(limit+1).into(new ArrayList<>()); + boolean more=records.size()>limit;var items=new ArrayList(); + for(var record:records.subList(0,Math.min(records.size(),limit))) { + String operationId=record.getString("_id"),recordedState=record.getString("recordedState"); + if(operationId==null || !operationId.matches(UUID) || !STATES.contains(recordedState))throw new IllegalStateException("Invalid operation record"); + items.add(new Item(operationId,recordedState)); + } + return new Page(List.copyOf(items),more?"r1."+items.getLast().id():null,"OPERATION_ID"); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairPreparation.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairPreparation.java new file mode 100644 index 000000000..316040a67 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairPreparation.java @@ -0,0 +1,55 @@ +package net.modtale.service.admin.review; + +import org.bson.types.ObjectId; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.Semaphore; +import java.util.function.BooleanSupplier; + +/** Prepares immutable evidence for a proposed action; never mutates the project or starts work. */ +public final class ReviewRepairPreparation { + public record Request(String id,Object projectId,int versionIndex,String versionId,String expectedSha256,String actorId,ReviewSnapshotArchive.Action action) { + public Request { + if(id==null || !id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || !(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.length()<=128) + || versionIndex<0 || versionIndex>16*1024*1024 || versionId==null || versionId.isBlank() || versionId.length()>128 + || versionId.chars().anyMatch(Character::isISOControl) || expectedSha256==null || !expectedSha256.matches("[0-9a-f]{64}") + || actorId==null || actorId.isBlank() || actorId.length()>256 || action==null)throw new IllegalArgumentException("Invalid repair preparation"); + } + @Override public String toString(){return "ReviewRepairPreparation["+id+"]";} + } + public record Prepared(String id,String sha256,long createdAt,long expiresAt) {} + private final ReviewSnapshotArchive archive; + private final RawReviewSnapshotReader reader; + private final Clock clock; + private final long lifetimeMillis; + private final Semaphore slots; + public ReviewRepairPreparation(ReviewSnapshotArchive archive,RawReviewSnapshotReader reader,Clock clock,int concurrency,long lifetimeMillis) { + if(concurrency<1 || concurrency>2 || lifetimeMillis<1000 || lifetimeMillis>900000)throw new IllegalArgumentException("Invalid repair preparation limits"); + this.archive=Objects.requireNonNull(archive);this.reader=Objects.requireNonNull(reader);this.clock=Objects.requireNonNull(clock);this.lifetimeMillis=lifetimeMillis;slots=new Semaphore(concurrency); + } + public Prepared prepare(Request request,BooleanSupplier permitted) { + Objects.requireNonNull(request);Objects.requireNonNull(permitted);requirePermission(permitted); + if(!slots.tryAcquire())throw new IllegalStateException("Repair preparation is busy"); + try { + var stored=archive.find(request.id());requirePermission(permitted); + if(stored!=null && (!stored.projectId().equals(request.projectId()) || stored.versionIndex()!=request.versionIndex() + || !stored.actorId().equals(request.actorId()) || stored.action()!=request.action()))throw conflict(); + if(stored!=null)requireLive(stored); + var captured=reader.capture(request.projectId(),request.versionIndex(),request.versionId());requirePermission(permitted); + if(!captured.sha256().equals(request.expectedSha256()) || stored!=null && !Arrays.equals(stored.versionBytes(),captured.versionBytes()))throw conflict(); + if(stored==null) { + long now=clock.millis();stored=archive.retain(captured.forArchive(request.id(),request.actorId(),request.action(),now,Math.addExact(now,lifetimeMillis))); + } + requirePermission(permitted);requireLive(stored); + if(!reader.isCurrent(captured))throw conflict(); + requirePermission(permitted);requireLive(stored); + return new Prepared(stored.id(),captured.sha256(),stored.createdAt(),stored.expiresAt()); + } finally {slots.release();} + } + private void requireLive(ReviewSnapshotArchive.Snapshot stored) { + long now=clock.millis();if(now=stored.expiresAt())throw conflict(); + } + private static void requirePermission(BooleanSupplier permitted){if(!permitted.getAsBoolean())throw new SecurityException("Repair preparation is not permitted");} + private static IllegalStateException conflict(){return new IllegalStateException("Repair preparation changed or expired");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairWorkflow.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairWorkflow.java new file mode 100644 index 000000000..6ad60ab4c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewRepairWorkflow.java @@ -0,0 +1,121 @@ +package net.modtale.service.admin.review; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +import java.util.function.LongSupplier; + +/** Shared, fail-fast admission for preparation and isolation; owns no background workers. */ +public final class ReviewRepairWorkflow implements AutoCloseable { + public record Status(String state, int active, int overdue) { + public Status(String state, int active) { this(state, active, 0); } + } + private final ReviewRepairPreparation preparation; + private final ReviewIsolationExecutor isolation; + private final int concurrency; + private final long durationNanos; + private final LongSupplier ticker; + private boolean accepting = true; + private int active; + private final java.util.Set admissions = new java.util.HashSet<>(); + + public ReviewRepairWorkflow(ReviewRepairPreparation preparation, ReviewIsolationExecutor isolation, int concurrency) { + this(preparation, isolation, concurrency, 30000, System::nanoTime); + } + + ReviewRepairWorkflow(ReviewRepairPreparation preparation, ReviewIsolationExecutor isolation, int concurrency, + long durationMillis, LongSupplier ticker) { + if (concurrency < 1 || concurrency > 2 || durationMillis < 1000 || durationMillis > 120000) + throw new IllegalArgumentException("Invalid repair workflow limits"); + this.preparation = Objects.requireNonNull(preparation); + this.isolation = Objects.requireNonNull(isolation); + this.concurrency = concurrency; + this.durationNanos = TimeUnit.MILLISECONDS.toNanos(durationMillis); + this.ticker = Objects.requireNonNull(ticker); + } + + public ReviewRepairPreparation.Prepared prepare(ReviewRepairPreparation.Request request, BooleanSupplier permitted) { + Objects.requireNonNull(request); + try (var admission = admit(permitted); var io = ReviewRepairIo.open(admission::remainingNanos)) { + return preparation.prepare(request, admission::permitted); + } + } + + public ReviewIsolationExecutor.Result isolate(ReviewRepairPreparation.Prepared prepared, String actor, BooleanSupplier permitted) { + Objects.requireNonNull(prepared); + Objects.requireNonNull(actor); + try (var admission = admit(permitted); var io = ReviewRepairIo.open(admission::remainingNanos)) { + return isolation.execute(prepared, actor, admission::permitted); + } + } + + public ReviewIsolationExecutor.Receipt closeExpired(ReviewRepairPreparation.Prepared prepared,String actor,BooleanSupplier permitted) { + Objects.requireNonNull(prepared);Objects.requireNonNull(actor); + try(var admission=admit(permitted);var io=ReviewRepairIo.open(admission::remainingNanos)) { + return isolation.closeExpired(prepared,actor,admission::permitted); + } + } + + T read(java.util.function.Supplier operation, BooleanSupplier permitted) { + return call(allowed -> operation.get(), permitted); + } + + T call(java.util.function.Function operation, BooleanSupplier permitted) { + try (var admission = admit(permitted); var io = ReviewRepairIo.open(admission::remainingNanos)) { + if (!admission.permitted()) throw new SecurityException("Review repair is not permitted"); + T result = operation.apply(admission::permitted); + if (!admission.permitted()) throw new SecurityException("Review repair is not permitted"); + return result; + } + } + + private Admission admit(BooleanSupplier permitted) { + Objects.requireNonNull(permitted); + if (!permitted.getAsBoolean()) throw new SecurityException("Review repair is not permitted"); + synchronized (this) { + if (!accepting || Thread.currentThread().isInterrupted()) throw new IllegalStateException("Review repair is stopped"); + if (active >= concurrency) throw new IllegalStateException("Review repair is busy"); + var admission = new Admission(permitted, ticker.getAsLong()); + active++; + admissions.add(admission); + return admission; + } + } + + public synchronized Status status() { + long now = ticker.getAsLong(); + int overdue = (int) admissions.stream().filter(admission -> now - admission.started >= durationNanos).count(); + return new Status(accepting ? "OPEN" : active == 0 ? "CLOSED" : "DRAINING", active, overdue); + } + + // In-flight calls retain their slots until they actually return. A commit already entered may complete. + @Override public synchronized void close() { accepting = false; } + + private final class Admission implements AutoCloseable { + private final BooleanSupplier permission; + private final long started; + private boolean released; + Admission(BooleanSupplier permission, long started) { this.permission = permission; this.started = started; } + boolean permitted() { + checkpoint(); + boolean allowed = permission.getAsBoolean(); + checkpoint(); + return allowed; + } + long remainingNanos() { + checkpoint(); + return durationNanos - (ticker.getAsLong() - started); + } + private void checkpoint() { + synchronized (ReviewRepairWorkflow.this) { + if (released || !accepting || Thread.currentThread().isInterrupted()) throw new IllegalStateException("Review repair is stopped"); + if (ticker.getAsLong() - started >= durationNanos) throw new IllegalStateException("Review repair deadline exceeded"); + } + } + @Override public void close() { + synchronized (ReviewRepairWorkflow.this) { + if (!released) { released = true; active--; admissions.remove(this); } + } + } + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewSnapshotArchive.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewSnapshotArchive.java new file mode 100644 index 000000000..010b18765 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewSnapshotArchive.java @@ -0,0 +1,118 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import org.bson.Document; +import org.bson.types.Binary; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Immutable evidence storage only. A retained snapshot never authorizes a version mutation. */ +public final class ReviewSnapshotArchive { + public static final String METADATA="review_snapshot_archives", CHUNKS="review_snapshot_chunks"; + public static final int MAX_BYTES=16*1024*1024, CHUNK_BYTES=256*1024; + public enum Action { ISOLATE_REVIEW, REPLACE_REVIEW, CANCEL_REMOTE_REVIEW } + public record Snapshot(String id,Object projectId,int versionIndex,String actorId,Action action,long createdAt,long expiresAt,byte[] versionBytes) { + public Snapshot { + if(!uuid(id) || !(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.length()<=128 + && StandardCharsets.UTF_8.newEncoder().canEncode(s)) || versionIndex<0 || versionIndex>16*1024*1024 + || actorId==null || actorId.isBlank() || actorId.length()>256 || !StandardCharsets.UTF_8.newEncoder().canEncode(actorId) + || action==null || createdAt<=0 || expiresAt<=createdAt || expiresAt-createdAt>900000 + || versionBytes==null || versionBytes.length<5 || versionBytes.length>MAX_BYTES)throw new IllegalArgumentException("Invalid review snapshot"); + versionBytes=versionBytes.clone(); + } + @Override public byte[] versionBytes(){return versionBytes.clone();} + @Override public String toString(){return "ReviewSnapshot["+id+"]";} + } + private final MongoCollection metadata,chunks; + private final Map keys; + private final String activeKey; + public ReviewSnapshotArchive(MongoTemplate mongo,String activeKey,Map signingKeys) { + if(activeKey==null || signingKeys==null || signingKeys.isEmpty() || signingKeys.size()>8)throw new IllegalArgumentException("Snapshot signing keys are required"); + var copy=new HashMap(); + signingKeys.forEach((id,key)->{if(id==null || !id.matches("[A-Za-z0-9_-]{1,64}") || key==null || key.length<32 || key.length>128)throw new IllegalArgumentException("Invalid snapshot signing key");copy.put(id,key.clone());}); + if(!copy.containsKey(activeKey))throw new IllegalArgumentException("Active snapshot signing key is unavailable"); + this.activeKey=activeKey;keys=Map.copyOf(copy); + metadata=collection(mongo,METADATA);chunks=collection(mongo,CHUNKS); + } + private static MongoCollection collection(MongoTemplate mongo,String name) { + return mongo.getCollection(name).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY) + .withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(10,TimeUnit.SECONDS)); + } + public Snapshot retain(Snapshot snapshot) { + Objects.requireNonNull(snapshot); + var existing=read(metadata,snapshot.id()); + if(existing!=null) {var restored=load(snapshot.id());if(!same(snapshot,restored))throw unavailable();return restored;} + byte[] bytes=snapshot.versionBytes();int count=(bytes.length+CHUNK_BYTES-1)/CHUNK_BYTES; + for(int i=0;iMAX_BYTES || count!=(length+CHUNK_BYTES-1)/CHUNK_BYTES)throw unavailable(); + var bytes=new ByteArrayOutputStream(length); + for(int i=0;i collection,Document record) { + try {ReviewRepairIo.collection(collection).insertOne(record);} + catch(MongoException uncertain) { + // An acknowledged exact read can recover an inserted record, including lost write acknowledgements. + var restored=read(collection,record.getString("_id"));if(!record.equals(restored))throw unavailable(); + } + } + private static Document read(MongoCollection collection,String id) { + return ReviewRepairIo.collection(collection).find(new Document("_id",id)).collation(Collation.builder().locale("simple").build()).maxTime(5,TimeUnit.SECONDS).first(); + } + private static String tag(Document record,byte[] key) { + try { + var bytes=new ByteArrayOutputStream();var out=new DataOutputStream(bytes);out.writeUTF("modtale-review-snapshot-v1"); + out.writeUTF(record.getString("_id"));out.writeInt(record.getInteger("schema"));out.writeUTF(record.getString("keyId")); + Object id=record.get("projectId");out.writeBoolean(id instanceof ObjectId); + if(id instanceof ObjectId objectId)out.write(objectId.toByteArray());else out.writeUTF((String)id); + out.writeInt(record.getInteger("versionIndex"));out.writeUTF(record.getString("actorId"));out.writeUTF(record.getString("action")); + out.writeLong(record.getLong("createdAt"));out.writeLong(record.getLong("expiresAt"));out.writeInt(record.getInteger("length"));out.writeInt(record.getInteger("chunks"));out.writeUTF(record.getString("sha256")); + var mac=Mac.getInstance("HmacSHA256");mac.init(new SecretKeySpec(key,"HmacSHA256"));return HexFormat.of().formatHex(mac.doFinal(bytes.toByteArray())); + } catch(Exception invalid) {throw unavailable();} + } + private static String digest(byte[] bytes) { + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}catch(Exception invalid){throw unavailable();} + } + private static boolean same(Snapshot a,Snapshot b){return a.id().equals(b.id()) && a.projectId().equals(b.projectId()) && a.versionIndex()==b.versionIndex() + && a.actorId().equals(b.actorId()) && a.action()==b.action() && a.createdAt()==b.createdAt() && a.expiresAt()==b.expiresAt() && Arrays.equals(a.versionBytes(),b.versionBytes());} + private static boolean uuid(String id){return id!=null && id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static IllegalStateException unavailable(){return new IllegalStateException("Review snapshot is unavailable or inconsistent");} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursor.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursor.java new file mode 100644 index 000000000..b94812b01 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursor.java @@ -0,0 +1,23 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.RemoteReviewDiscovery; + +/** A scoped read position, not permission to modify the referenced record. */ +public final class ReviewStateDiagnosticCursor { + private ReviewStateDiagnosticCursor() {} + public static String encode(RemoteReviewDiscovery.Cursor cursor) { + if(cursor==null)return null; + return "d1."+(cursor.afterProject()?"a.":"v.")+ModerationQueueCursor.encode( + new ModerationQueuePageReader.Cursor(cursor.projectId(),cursor.offset())); + } + public static RemoteReviewDiscovery.Cursor decode(String token) { + if(token==null)return null; + if(token.length()>810 || !(token.startsWith("d1.a.1.") || token.startsWith("d1.v.1.")))throw new IllegalArgumentException("Invalid diagnostic cursor"); + try { + var position=ModerationQueueCursor.decode(token.substring(5)); + var cursor=new RemoteReviewDiscovery.Cursor(position.projectId(),Math.toIntExact(position.versionIndex()),token.charAt(3)=='a'); + if(!encode(cursor).equals(token))throw new IllegalArgumentException(); + return cursor; + } catch(IllegalArgumentException | ArithmeticException invalid) {throw new IllegalArgumentException("Invalid diagnostic cursor");} + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticReader.java b/backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticReader.java new file mode 100644 index 000000000..9d0e0c7c3 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/ReviewStateDiagnosticReader.java @@ -0,0 +1,132 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import net.modtale.model.project.Project; +import net.modtale.model.project.RemoteReviewBinding; +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Read-only structural diagnostics. Positions identify stored rows, never approval or retry authority. */ +@Service +public final class ReviewStateDiagnosticReader { + public enum Reason { INVALID_PROJECT_ID, INVALID_VERSION_ID, DUPLICATE_VERSION_ID, INVALID_REQUEST, + INVALID_ARTIFACT, INVALID_MANUAL_MODE, INVALID_SCAN_STATE, MISSING_BINDING, INVALID_BINDING, + BINDING_MISMATCH, INVALID_POLL, POLL_WITHOUT_REMOTE_STATE } + public record Item(Object projectId,int versionIndex,String versionId,List reasons) { + public Item { reasons=List.copyOf(reasons); } + } + public record Page(List items,RemoteReviewDiscovery.Cursor next,int examined) { + public Page { items=List.copyOf(items); } + } + private final MongoCollection projects; + public ReviewStateDiagnosticReader(MongoTemplate mongo) { + projects=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Page page(RemoteReviewDiscovery.Cursor cursor,int limit) { + if(limit<1 || limit>64)throw new IllegalArgumentException("Invalid diagnostic page size"); + var scanning=new Document("reviewStatus","PENDING").append("scanResult.status","SCANNING"); + var terms=new ArrayList();terms.add(new Document("$cond",List.of(eq(type("$_id"),"string"), + new Document("$and",List.of(new Document("$gt",List.of(new Document("$strLenCP","$_id"),0)),new Document("$lte",List.of(new Document("$strLenCP","$_id"),128)))),true))); + if(cursor!=null)terms.add(new Document(cursor.afterProject()?"$gt":"$gte",List.of("$_id",literal(cursor.projectId())))); + var match=new Document("_id",new Document("$type",List.of("string","objectId"))).append("versions",new Document("$elemMatch",scanning)) + .append("$expr",new Document("$and",terms)); + Object offset=cursor==null?literal(0):new Document("$cond",List.of(eq("$_id",literal(cursor.projectId())),cursor.offset(),0)); + var versions=new Document("$cond",List.of(new Document("$isArray","$versions"),"$versions",List.of())); + var view=new Document("scanning",new Document("$and",List.of(eq("$$v.reviewStatus","PENDING"),eq("$$v.scanResult.status","SCANNING")))) + .append("versionId",text("$$v._id",128)).append("filePath",text("$$v.fileUrl",4096)).append("hash",text("$$v.hash",64)) + .append("idCount",new Document("$size",new Document("$filter",new Document("input",versions).append("as","sibling") + .append("cond",eq("$$sibling._id","$$v._id"))))); + String scan="$$v.scanResult"; + view.append("state",text(scan+".scanState",64)).append("request",text(scan+".scanRequestId",36)).append("attempt",integer(scan+".scanAttempt")) + .append("manualType",type(scan+".manualRescan")).append("manual",bool(scan+".manualRescan")) + .append("bindingType",type(scan+".remoteReview")).append("pollType",type(scan+".remotePoll")); + String binding=scan+".remoteReview"; + for(String key:List.of("projectId","versionId","requestId","filePath","artifactSha256","contextSha256","policyVersion","reviewConfigSha256","jobId")) + view.append("binding_"+key,text(binding+"."+key,key.equals("filePath")?4096:128)); + view.append("binding_attempt",integer(binding+".attempt")).append("binding_manualType",type(binding+".manualRescan")) + .append("binding_manual",bool(binding+".manualRescan")).append("binding_jobType",type(binding+".jobId")); + view.append("originType",type(binding+".origin")).append("originDeployment",text(binding+".origin.deploymentId",36)).append("originCaller",text(binding+".origin.callerScope",64)); + String poll=scan+".remotePoll"; + view.append("pollSize",new Document("$cond",List.of(eq(type(poll),"object"),new Document("$size",new Document("$objectToArray",poll)),0))) + .append("pollToken",text(poll+".token",36)).append("pollTokenType",type(poll+".token")) + .append("pollLeaseType",type(poll+".leaseUntil")).append("pollNextType",type(poll+".nextPollAt")); + var projection=new Document("_id",1).append("offset",offset).append("total",new Document("$size",versions)) + .append("items",new Document("$map",new Document("input",new Document("$slice",List.of(versions,offset,limit))).append("as","v").append("in",view))); + var pipeline=List.of(new Document("$match",match),new Document("$sort",new Document("_id",1)),new Document("$limit",1),new Document("$project",projection)); + Document root; + try(var rows=projects.aggregate(pipeline).collation(Collation.builder().locale("simple").build()).allowDiskUse(false).maxTime(5,TimeUnit.SECONDS).batchSize(1).iterator()) { + root=rows.hasNext()?rows.next():null; + } + if(root==null)return new Page(List.of(),null,0); + Object id=root.get("_id");String project=id instanceof ObjectId oid?oid.toHexString():(String)id; + var rows=root.getList("items",Document.class);var items=new ArrayList();int index=root.getInteger("offset"); + for(var row:rows) { + if(Boolean.TRUE.equals(row.get("scanning"))) { + var reasons=classify(project,row); + if(!reasons.isEmpty())items.add(new Item(id,index,validText(row.getString("versionId"),128)?row.getString("versionId"):null,reasons)); + } + index++; + } + var next=index classify(String project,Document row) { + var reasons=new ArrayList(); + if(!validText(project,128))reasons.add(Reason.INVALID_PROJECT_ID); + if(!validText(row.getString("versionId"),128))reasons.add(Reason.INVALID_VERSION_ID); + if(row.getInteger("idCount",0)>1)reasons.add(Reason.DUPLICATE_VERSION_ID); + if(!uuid(row.getString("request")) || row.getInteger("attempt",0)<1)reasons.add(Reason.INVALID_REQUEST); + if(!validText(row.getString("filePath"),4096) || !digest(row.getString("hash")))reasons.add(Reason.INVALID_ARTIFACT); + if(!manualType(row.getString("manualType")))reasons.add(Reason.INVALID_MANUAL_MODE); + String state=row.getString("state");boolean remote="REMOTE_REVIEW".equals(state); + if(!Set.of("QUEUED","SCANNING","REMOTE_REVIEW").contains(Objects.toString(state,"")))reasons.add(Reason.INVALID_SCAN_STATE); + String bindingType=row.getString("bindingType"); + if(Set.of("missing","null").contains(bindingType)) {if(remote)reasons.add(Reason.MISSING_BINDING);} + else { + RemoteReviewBinding binding=null; + try { + if(!"object".equals(bindingType) || !"bool".equals(row.getString("binding_manualType")) + || !Set.of("missing","null","string").contains(row.getString("binding_jobType")) + || "string".equals(row.getString("binding_jobType")) && !uuid(row.getString("binding_jobId")))throw new IllegalArgumentException(); + net.modtale.model.project.RemoteReviewOrigin origin=null; + if(!Set.of("missing","null").contains(row.getString("originType"))) { + if(!"object".equals(row.getString("originType")))throw new IllegalArgumentException(); + origin=new net.modtale.model.project.RemoteReviewOrigin(row.getString("originDeployment"),row.getString("originCaller")); + } + binding=new RemoteReviewBinding(row.getString("binding_projectId"),row.getString("binding_versionId"),row.getString("binding_requestId"),row.getInteger("binding_attempt",0), + row.getString("binding_filePath"),row.getString("binding_artifactSha256"),row.getString("binding_contextSha256"),row.getString("binding_policyVersion"), + row.getString("binding_reviewConfigSha256"),row.getString("binding_jobId"),Boolean.TRUE.equals(row.get("binding_manual")),origin); + } catch(IllegalArgumentException invalid) {reasons.add(Reason.INVALID_BINDING);} + if(binding!=null && (!remote || !project.equals(binding.projectId()) || !Objects.equals(row.getString("versionId"),binding.versionId()) + || !Objects.equals(row.getString("request"),binding.requestId()) || row.getInteger("attempt",0)!=binding.attempt() + || !Objects.equals(row.getString("filePath"),binding.filePath()) || !Objects.equals(row.getString("hash"),binding.artifactSha256()) + || Boolean.TRUE.equals(row.get("manual"))!=binding.manualRescan()))reasons.add(Reason.BINDING_MISMATCH); + } + String pollType=row.getString("pollType"); + if(!"missing".equals(pollType)) { + if(!remote)reasons.add(Reason.POLL_WITHOUT_REMOTE_STATE); + if(!"object".equals(pollType) || row.getInteger("pollSize",0)!=3 || !"date".equals(row.getString("pollLeaseType")) + || !"date".equals(row.getString("pollNextType")) || !("null".equals(row.getString("pollTokenType")) + || "string".equals(row.getString("pollTokenType")) && uuid(row.getString("pollToken"))))reasons.add(Reason.INVALID_POLL); + } + return reasons; + } + private static boolean manualType(String type){return "missing".equals(type) || "bool".equals(type);} + private static boolean validText(String s,int max){return s!=null && !s.isBlank() && s.length()<=max && s.chars().noneMatch(Character::isISOControl);} + private static boolean uuid(String s){return s!=null && s.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");} + private static boolean digest(String s){return s!=null && s.matches("[0-9a-f]{64}");} + private static Document type(String field){return new Document("$type",field);} + private static Document eq(Object a,Object b){return new Document("$eq",List.of(a,b));} + private static Document literal(Object v){return new Document("$literal",v);} + private static Document text(String field,int max){return new Document("$cond",Arrays.asList(eq(type(field),"string"),new Document("$substrCP",List.of(field,0,max+1)),null));} + private static Document bool(String field){return new Document("$cond",List.of(eq(type(field),"bool"),field,false));} + private static Document integer(String field){return new Document("$cond",List.of(new Document("$in",List.of(type(field),List.of("int","long"))), + new Document("$convert",new Document("input",field).append("to","int").append("onError",0).append("onNull",0)),0));} +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/VersionReviewPersistence.java b/backend/src/main/java/net/modtale/service/admin/review/VersionReviewPersistence.java new file mode 100644 index 000000000..b23c0f665 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/VersionReviewPersistence.java @@ -0,0 +1,100 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.model.Filters; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectVersion; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.data.mongodb.core.convert.UpdateMapper; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.*; + +@Service +public class VersionReviewPersistence { + private final MongoTemplate mongo; + public VersionReviewPersistence(MongoTemplate mongo) {this.mongo=mongo;} + public record Snapshot(Object projectId, Document version) {} + public Snapshot capture(String projectId, String versionId, String expectedToken) { + return capture(projectId, versionId, expectedToken, false); + } + public Snapshot captureForRescan(String projectId, String versionId, String expectedToken) { + return capture(projectId, versionId, expectedToken, true); + } + private Snapshot capture(String projectId, String versionId, String expectedToken, boolean rescan) { + var entity=mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var filter=new QueryMapper(mongo.getConverter()).getMappedObject(new Document("_id",projectId),entity); + Document project=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(com.mongodb.ReadPreference.primary()) + .withReadConcern(com.mongodb.ReadConcern.MAJORITY).find(filter) + .collation(com.mongodb.client.model.Collation.builder().locale("simple").build()).maxTime(5,java.util.concurrent.TimeUnit.SECONDS).first(); + if(versionId==null || project==null || !(project.get("versions") instanceof List versions)) throw conflict(); + var matches=versions.stream().filter(item->item instanceof Document stored && versionId.equals(stored.get("_id"))).toList(); + if(matches.size()!=1)throw conflict(); + var stored=(Document)matches.getFirst(); + ProjectVersion version; + try {version=mongo.getConverter().read(ProjectVersion.class,stored);}catch(RuntimeException invalid){throw conflict();} + if (rescan) { + if (expectedToken == null || !expectedToken.equals(VersionReviewSnapshot.rescanToken(version))) throw conflict(); + } else VersionReviewSnapshot.requireCurrent(version,expectedToken); + return new Snapshot(project.get("_id"),stored); + } + public boolean apply(Snapshot snapshot, ProjectVersion reviewed) { + var originGuard = new org.springframework.data.mongodb.core.query.Query(); + var originalVersion = mongo.getConverter().read(ProjectVersion.class, snapshot.version()); + if (reviewed.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED) + net.modtale.service.security.issue.FindingReviewHistory.requireManualApproval(mongo, snapshot.projectId().toString(), originalVersion); + reviewed.setApprovedFindingReviewHead(reviewed.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED + ? originalVersion.getFindingReviewHead() : null); + var original = originalVersion.getScanResult(); + if (reviewed.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED && original != null && original.getReusedReviewVersion() != null + && (!net.modtale.service.security.scan.ArtifactClearancePolicy.complete(original) + || !net.modtale.service.security.scan.ArtifactReviewLineage.bind(mongo, snapshot.projectId().toString(), original, originGuard))) return false; + var update=new Update().set("versions.$.reviewStatus",reviewed.getReviewStatus()) + .set("versions.$.rejectionReason",reviewed.getRejectionReason()) + .set("versions.$.scheduledPublishDate",reviewed.getScheduledPublishDate()) + .set("versions.$.scanResult",reviewed.getScanResult()) + .set("versions.$.securityApprovalProjectId", reviewed.getSecurityApprovalProjectId()) + .set("versions.$.approvedReviewOrigins",reviewed.getApprovedReviewOrigins()) + .set("versions.$.approvedFindingReviewHead", reviewed.getApprovedFindingReviewHead()) + .set("versions.$.approvedSecurityEvidence",reviewed.getApprovedSecurityEvidence()) + .set("versions.$.approvedSecurityContextSha256",reviewed.getApprovedSecurityContextSha256()) + .set("versions.$.securityApprovedAt",reviewed.getSecurityApprovedAt()) + .set("versions.$.approvedIssueBaselines",reviewed.getApprovedIssueBaselines()) + .set("updatedAt",LocalDateTime.now().toString()); + return applyUpdate(snapshot, update, originGuard.getQueryObject()); + } + public boolean queueRescan(Snapshot snapshot, net.modtale.model.project.ScanResult queued) { + return applyUpdate(snapshot, new Update() + .set("versions.$.scanResult", queued) + .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set("versions.$.scheduledPublishDate", null) + .set("updatedAt", LocalDateTime.now().toString())); + } + public boolean appendFindingReview(Snapshot snapshot, String decisionId) { + return applyUpdate(snapshot, new Update().set("versions.$.findingReviewHead", decisionId) + .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set("versions.$.scheduledPublishDate", null) + .set("updatedAt", LocalDateTime.now().toString())); + } + private boolean applyUpdate(Snapshot snapshot, Update update) { return applyUpdate(snapshot, update, new Document()); } + private boolean applyUpdate(Snapshot snapshot, Update update, Document originGuard) { + var entity=mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var mapped=new UpdateMapper(mongo.getConverter()).getMappedObject(update.getUpdateObject(),entity); + if(!(snapshot.version().get("_id") instanceof String versionId))return false; + var unique=new Document("$eq",List.of(new Document("$size",new Document("$filter", + new Document("input",new Document("$cond",List.of(new Document("$isArray","$versions"),"$versions",List.of()))) + .append("as","v").append("cond",new Document("$eq",List.of("$$v._id",new Document("$literal",versionId)))))),1)); + var filter=Filters.and(Filters.eq("_id",snapshot.projectId()),new Document("versions",new Document("$eq",snapshot.version())),new Document("$expr",unique)); + if (!originGuard.isEmpty()) filter = Filters.and(filter, originGuard); + return mongo.getCollection(mongo.getCollectionName(Project.class)).withWriteConcern(com.mongodb.WriteConcern.MAJORITY.withJournal(true) + .withWTimeout(10,java.util.concurrent.TimeUnit.SECONDS)).updateOne(filter,mapped, + new com.mongodb.client.model.UpdateOptions().collation(com.mongodb.client.model.Collation.builder().locale("simple").build())).getModifiedCount()>0; + } + public static ResponseStatusException conflict() { + return new ResponseStatusException(HttpStatus.CONFLICT,"This version changed while the decision was being applied. Refresh its evidence before deciding."); + } +} diff --git a/backend/src/main/java/net/modtale/service/admin/review/VersionReviewSnapshot.java b/backend/src/main/java/net/modtale/service/admin/review/VersionReviewSnapshot.java new file mode 100644 index 000000000..fca4b419c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/admin/review/VersionReviewSnapshot.java @@ -0,0 +1,42 @@ +package net.modtale.service.admin.review; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import net.modtale.model.project.ProjectVersion; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.security.MessageDigest; +import java.util.*; + +public final class VersionReviewSnapshot { + private static final ObjectMapper MAPPER=new ObjectMapper().enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private static final ObjectMapper RESCAN_MAPPER = MAPPER.copy().addMixIn( + net.modtale.model.project.ScanResult.SecurityEvidence.class, RescanEvidence.class); + private abstract static class RescanEvidence { + @com.fasterxml.jackson.annotation.JsonIgnore abstract Map entryHashes(); + } + private VersionReviewSnapshot() {} + public static String token(ProjectVersion version) { + return token(version, MAPPER); + } + // Rescan requests need not load the old manifest; approval still uses the full evidence snapshot. + public static String rescanToken(ProjectVersion version) { + return token(version, RESCAN_MAPPER); + } + private static String token(ProjectVersion version, ObjectMapper mapper) { + if(version==null) throw new IllegalArgumentException("Missing review version"); + try { + @SuppressWarnings("unchecked") Map fields=mapper.convertValue(version,Map.class); + fields.remove("downloadCount"); + var scan=version.getScanResult(); + fields.put("verifiedArtifact",scan!=null && scan.isArtifactVerified()); + fields.put("reusedOrigins",scan==null ? null : scan.getReusedReviewOrigins()); + fields.put("reviewedContext",scan==null ? null : scan.getReviewedContextSha256()); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(MAPPER.writeValueAsBytes(fields))); + } catch(Exception invalid) {throw new IllegalStateException("Could not bind the review snapshot",invalid);} + } + public static void requireCurrent(ProjectVersion version,String expected) { + if(expected==null || !expected.equals(token(version))) + throw new ResponseStatusException(HttpStatus.CONFLICT,"This version changed after the review was opened. Refresh its evidence before deciding."); + } +} diff --git a/backend/src/main/java/net/modtale/service/auth/AuthenticationMutationService.java b/backend/src/main/java/net/modtale/service/auth/AuthenticationMutationService.java index cf7ff3b8e..9e1a45967 100644 --- a/backend/src/main/java/net/modtale/service/auth/AuthenticationMutationService.java +++ b/backend/src/main/java/net/modtale/service/auth/AuthenticationMutationService.java @@ -44,28 +44,6 @@ public AuthenticationMutationService( this.reservedAccountGuardService = reservedAccountGuardService; } - public void setTempMfaSecret(String userId, String secret) { - User user = requireUser(userId); - user.setMfaSecret(secret); - userRepository.save(user); - } - - public void enableMfa(String userId) { - User user = requireUser(userId); - if (user.getMfaSecret() == null || user.getMfaSecret().isBlank()) { - throw new InvalidAuthenticationRequestException("Cannot enable two-factor authentication because no secret has been set."); - } - user.setMfaEnabled(true); - userRepository.save(user); - } - - public void disableMfa(String userId) { - User user = requireUser(userId); - user.setMfaEnabled(false); - user.setMfaSecret(null); - userRepository.save(user); - } - public void addCredentials(String userId, String email, String password) { User user = requireUser(userId); reservedAccountGuardService.rejectReservedEmailInProduction(email); diff --git a/backend/src/main/java/net/modtale/service/auth/AuthenticationService.java b/backend/src/main/java/net/modtale/service/auth/AuthenticationService.java index 49dd131f6..9e746edd3 100644 --- a/backend/src/main/java/net/modtale/service/auth/AuthenticationService.java +++ b/backend/src/main/java/net/modtale/service/auth/AuthenticationService.java @@ -125,12 +125,6 @@ public User authenticate(String login, String password) { throw new UnauthorizedException("We couldn't sign you in with that username and password. Double-check both fields and try again."); } - if (user.isMfaEnabled() && (user.getMfaSecret() == null || user.getMfaSecret().isBlank())) { - logger.warn("User {} has MFA enabled but missing secret. Auto-disabling MFA to prevent lockout.", user.getId()); - user.setMfaEnabled(false); - user = userRepository.save(user); - } - return user; } diff --git a/backend/src/main/java/net/modtale/service/auth/MfaEnrollmentService.java b/backend/src/main/java/net/modtale/service/auth/MfaEnrollmentService.java new file mode 100644 index 000000000..218664619 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/auth/MfaEnrollmentService.java @@ -0,0 +1,69 @@ +package net.modtale.service.auth; + +import java.util.ArrayList; +import java.util.List; +import net.modtale.exception.InvalidAuthenticationRequestException; +import net.modtale.exception.ResourceNotFoundException; +import net.modtale.model.user.User; +import net.modtale.util.MongoIdUtils; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class MfaEnrollmentService { + private final MongoTemplate mongo; + private final TwoFactorService factors; + + public MfaEnrollmentService(MongoTemplate mongo, TwoFactorService factors) { + this.mongo = mongo; + this.factors = factors; + } + + public String begin(String userId) { + Document current = capture(userId); + requireDisabled(current); + String secret = factors.generateNewSecret(); + if (secret == null || secret.isBlank()) throw new IllegalStateException("Could not create an authentication factor."); + update(current, new Document("mfaSecret", secret)); + return secret; + } + + public void verify(String userId, String code) { + Document current = capture(userId); + requireDisabled(current); + String secret = current.getString("mfaSecret"); + if (secret == null || secret.isBlank() || !factors.isOtpValid(secret, code)) { + throw new InvalidAuthenticationRequestException("That verification code was not accepted, so two-factor authentication was not enabled."); + } + update(current, new Document("mfaEnabled", true)); + } + + private Document capture(String id) { + if (id == null || id.isBlank()) throw new ResourceNotFoundException("User not found."); + var matches = mongo.getCollection(mongo.getCollectionName(User.class)) + .find(new Document("_id", new Document("$in", MongoIdUtils.expandIds(List.of(id))))) + .limit(2).into(new ArrayList<>()); + if (matches.size() != 1 || matches.getFirst().get("deletedAt") != null) + throw new ResourceNotFoundException("User not found."); + return matches.getFirst(); + } + + private void requireDisabled(Document current) { + Object enabled = current.get("mfaEnabled"); + if (enabled != null && !Boolean.FALSE.equals(enabled)) { + throw new InvalidAuthenticationRequestException("Two-factor authentication is already enabled or requires account recovery."); + } + } + + private void update(Document current, Document fields) { + var result = mongo.getCollection(mongo.getCollectionName(User.class)).updateOne( + new Document("_id", current.get("_id")).append("$expr", + new Document("$eq", List.of("$$ROOT", new Document("$literal", current)))), + new Document("$set", fields)); + if (result.getMatchedCount() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, + "Account changed during two-factor setup. Refresh and retry."); + } +} diff --git a/backend/src/main/java/net/modtale/service/auth/TwoFactorService.java b/backend/src/main/java/net/modtale/service/auth/TwoFactorService.java index ecacd814b..34dc753c0 100644 --- a/backend/src/main/java/net/modtale/service/auth/TwoFactorService.java +++ b/backend/src/main/java/net/modtale/service/auth/TwoFactorService.java @@ -46,6 +46,7 @@ public String generateQrCodeImageUri(String secret, String username) { } public boolean isOtpValid(String secret, String code) { + if (secret == null || secret.isBlank() || code == null || !code.matches("[0-9]{6}")) return false; TimeProvider timeProvider = new SystemTimeProvider(); CodeGenerator codeGenerator = new DefaultCodeGenerator(); DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); diff --git a/backend/src/main/java/net/modtale/service/communication/NotificationService.java b/backend/src/main/java/net/modtale/service/communication/NotificationService.java index cb70f058b..90da693a4 100644 --- a/backend/src/main/java/net/modtale/service/communication/NotificationService.java +++ b/backend/src/main/java/net/modtale/service/communication/NotificationService.java @@ -103,7 +103,7 @@ public void cleanupExpiredRequests() { if (!expired.isEmpty()) { logger.info("Cleaning up {} expired actionable notifications.", expired.size()); for (Notification n : expired) { - voidAction(n); + voidAction(n, true); notificationRepository.delete(n); } } @@ -117,34 +117,66 @@ public void sendNotifcation(List userIds, String title, String message, notificationDeliveryService.sendNotifcation(userIds, title, message, link, iconUrl); } - private void voidAction(Notification n) { + @Scheduled(fixedDelay = 60000) + public void cleanupExpiredTransfers() { + mongoTemplate.updateMulti(new Query(Criteria.where("pendingTransferExpiresAt").gt(0).lte(System.currentTimeMillis())), + clearTransfer(), Project.class); + } + + @Scheduled(fixedDelay = 60000) + public void cleanupExpiredContributorInvites() { + var expired = new Document("requestExpiresAt", new Document("$gt", 0).append("$lte", System.currentTimeMillis())); + mongoTemplate.updateMulti(new Query(Criteria.where("teamInvites").elemMatch( + Criteria.where("requestExpiresAt").gt(0).lte(System.currentTimeMillis()))), + new Update().pull("teamInvites", expired), Project.class); + } + + @Scheduled(fixedDelay = 60000) + public void cleanupExpiredOrganizationInvites() { + long now = System.currentTimeMillis(); + var expired = new Document("requestExpiresAt", new Document("$gt", 0).append("$lte", now)); + mongoTemplate.updateMulti(new Query(Criteria.where("accountType").is(User.AccountType.ORGANIZATION) + .and("pendingOrgInvites").elemMatch(Criteria.where("requestExpiresAt").gt(0).lte(now))), + new Update().pull("pendingOrgInvites", expired), User.class); + } + + private Update clearTransfer() { + return new Update().unset("pendingTransferTo").unset("pendingTransferRequestId") + .unset("pendingTransferOwnerId").unset("pendingTransferExpiresAt"); + } + + private void voidAction(Notification n) { voidAction(n, false); } + private void voidAction(Notification n, boolean expired) { try { if (n.getType() == NotificationType.TRANSFER_REQUEST) { String projectId = n.getMetadata().get("projectId"); - if (projectId != null) { - mongoTemplate.updateFirst( - new Query(Criteria.where("_id").is(projectId).and("pendingTransferTo").exists(true)), - new Update().unset("pendingTransferTo"), + String requestId = n.getMetadata().get("requestId"); + String targetUserId = n.getMetadata().get("targetUserId"); + if (projectId != null && requestId != null && !requestId.isBlank() && targetUserId != null + && (expired || targetUserId.equals(n.getUserId()))) { + var match = Criteria.where("_id").is(projectId).and("pendingTransferTo").is(targetUserId) + .and("pendingTransferRequestId").is(requestId); + if (expired) match.and("pendingTransferExpiresAt").gt(0).lte(System.currentTimeMillis()); + mongoTemplate.updateFirst(new Query(match), clearTransfer(), Project.class ); } } else if (n.getType() == NotificationType.ORG_INVITE) { String orgId = n.getMetadata().get("orgId"); - if (orgId != null) { - mongoTemplate.updateFirst( - new Query(Criteria.where("_id").is(orgId)), - new Update().pull("pendingOrgInvites", new Document("userId", n.getUserId())), - User.class - ); + String requestId = n.getMetadata().get("requestId"); + if (orgId != null && requestId != null && !requestId.isBlank()) { + var invite = new Document("userId", n.getUserId()).append("requestId", requestId); + if (expired) invite.append("requestExpiresAt", new Document("$gt", 0).append("$lte", System.currentTimeMillis())); + mongoTemplate.updateFirst(new Query(Criteria.where("_id").is(orgId)), new Update().pull("pendingOrgInvites", invite), User.class); } } else if (n.getType() == NotificationType.CONTRIBUTOR_INVITE) { String projectId = n.getMetadata().get("projectId"); - if (projectId != null) { - mongoTemplate.updateFirst( - new Query(Criteria.where("_id").is(projectId)), - new Update().pull("teamInvites", new Document("userId", n.getUserId())), - Project.class - ); + String requestId = n.getMetadata().get("requestId"); + if (projectId != null && requestId != null && !requestId.isBlank()) { + var invite = new Document("userId", n.getUserId()).append("requestId", requestId); + if (expired) invite.append("requestExpiresAt", new Document("$gt", 0).append("$lte", System.currentTimeMillis())); + mongoTemplate.updateFirst(new Query(Criteria.where("_id").is(projectId)), + new Update().pull("teamInvites", invite), Project.class); } } } catch (Exception e) { diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/LifecycleService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/LifecycleService.java index b509a32ca..f0cac1bde 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/LifecycleService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/LifecycleService.java @@ -52,6 +52,10 @@ public void privateProject(String id, User user) { projectPublicationService.privateProject(id, user); } + public void publishProject(String id, User user, String reviewToken, String versionId) { + projectPublicationService.publishProject(id, user, reviewToken, versionId); + } + public void publishProject(String id, User user) { projectPublicationService.publishProject(id, user); } diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectArtifactDeletionService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectArtifactDeletionService.java index 0b1243da7..45b04fe4a 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectArtifactDeletionService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectArtifactDeletionService.java @@ -35,14 +35,20 @@ public void deleteStoredFile(String fileUrl) { } } + public void deleteProjectMediaFile(Project project, String location) { + if (project == null) return; + var remaining = new java.util.ArrayList(); + remaining.add(project.getImageUrl()); remaining.add(project.getBannerUrl()); + if (project.getGalleryImages() != null) remaining.addAll(project.getGalleryImages()); + storageService.deleteOwnedProjectMedia(project.getId(), location, remaining); + } + public void deleteProjectMedia(Project project) { - deleteStoredFile(project.getImageUrl()); - deleteStoredFile(project.getBannerUrl()); - if (project.getGalleryImages() != null) { - project.getGalleryImages().forEach(this::deleteStoredFile); - project.getGalleryImages().clear(); - } - project.setImageUrl(null); - project.setBannerUrl(null); + var removed = new java.util.ArrayList(); + removed.add(project.getImageUrl()); removed.add(project.getBannerUrl()); + if (project.getGalleryImages() != null) removed.addAll(project.getGalleryImages()); + project.setImageUrl(null); project.setBannerUrl(null); + project.setGalleryImages(new java.util.ArrayList<>()); + for (String location : removed) deleteProjectMediaFile(project, location); } } diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java index 23196ffb1..824893344 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDeletionService.java @@ -8,6 +8,8 @@ import net.modtale.model.project.ProjectStatus; import net.modtale.model.project.ProjectVersion; import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.analytics.ScoringService; import net.modtale.service.analytics.TrackingService; import net.modtale.service.project.query.ProjectService; @@ -20,6 +22,7 @@ @Service public class ProjectDeletionService { + private final ProjectReviewPersistence reviewPersistence; private final ProjectRepository projectRepository; private final ProjectService projectService; private final TrackingService trackingService; @@ -33,9 +36,11 @@ public ProjectDeletionService( TrackingService trackingService, ScoringService scoringService, ProjectArtifactDeletionService projectArtifactDeletionService, - MongoTemplate mongoTemplate + MongoTemplate mongoTemplate, + ProjectReviewPersistence reviewPersistence ) { this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.trackingService = trackingService; this.scoringService = scoringService; @@ -44,11 +49,13 @@ public ProjectDeletionService( } public void softDelete(Project project) { + var snapshot = reviewPersistence.capture(project.getId(), ProjectReviewSnapshot.token(project)); + project = snapshot.project(); ProjectStatus oldStatus = project.getStatus(); project.setStatus(ProjectStatus.DELETED); project.setDeletedAt(LocalDateTime.now()); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + if (!reviewPersistence.applyDeletionState(snapshot, false)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); if (oldStatus == ProjectStatus.PUBLISHED || oldStatus == ProjectStatus.UNLISTED || oldStatus == ProjectStatus.ARCHIVED) { trackingService.logDeletedProject(project.getId()); @@ -56,21 +63,29 @@ public void softDelete(Project project) { } public void restore(Project project, ProjectStatus targetStatus) { + var snapshot = reviewPersistence.capture(project.getId(), ProjectReviewSnapshot.token(project)); + project = snapshot.project(); project.setStatus(targetStatus); project.setDeletedAt(null); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + if (!reviewPersistence.applyDeletionState(snapshot, false)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } public void hardDelete(Project project) { + var snapshot = reviewPersistence.capture(project.getId(), ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (!projectRepository.findByDependency(project.getId()).isEmpty()) { + var removedMedia = mediaSnapshot(project); scrubProjectForDependencyResolution(project); - projectRepository.save(project); + if (!reviewPersistence.applyDeletionState(snapshot, true)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); + projectArtifactDeletionService.deleteProjectMedia(removedMedia); return; } + if (!reviewPersistence.deleteProject(snapshot)) throw ProjectReviewSnapshot.conflict(); + projectService.evictProjectCache(project); trackingService.deleteProjectAnalytics(project.getId()); Set dependencyIds = new HashSet<>(); if (project.getVersions() != null) { @@ -93,8 +108,6 @@ public void hardDelete(Project project) { mongoTemplate.updateMulti(new Query(Criteria.where("likedModIds").is(project.getId())), new Update().pull("likedModIds", project.getId()), net.modtale.model.user.User.class); scoringService.markProjectRankingDirty(project.getId()); - projectRepository.delete(project); - projectService.evictProjectCache(project); dependencyIds.forEach(this::cleanupOrphanedDependency); } @@ -106,16 +119,28 @@ public void deleteVersionFile(String fileUrl) { projectArtifactDeletionService.deleteVersionFile(fileUrl); } + public void deleteProjectMediaFile(Project project, String location) { + projectArtifactDeletionService.deleteProjectMediaFile(project, location); + } + public void deleteStoredFile(String fileUrl) { projectArtifactDeletionService.deleteStoredFile(fileUrl); } + private Project mediaSnapshot(Project project) { + var media = new Project(); media.setId(project.getId()); + media.setImageUrl(project.getImageUrl()); media.setBannerUrl(project.getBannerUrl()); + media.setGalleryImages(project.getGalleryImages() == null ? new ArrayList<>() : new ArrayList<>(project.getGalleryImages())); + return media; + } + private void scrubProjectForDependencyResolution(Project project) { project.setTitle("Deleted Project"); project.setDescription("This project has been deleted."); project.setAbout("This project was deleted by the author but is retained for dependency resolution."); project.setSlug(null); - projectArtifactDeletionService.deleteProjectMedia(project); + project.setImageUrl(null); project.setBannerUrl(null); + project.setGalleryImages(new ArrayList<>()); project.setGalleryImageCaptions(new java.util.HashMap<>()); project.setTeamMembers(new ArrayList<>()); project.setTeamInvites(new ArrayList<>()); project.setProjectRoles(new ArrayList<>()); diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDraftWorkflowService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDraftWorkflowService.java index 7b992da2d..c6f2aa6f4 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDraftWorkflowService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectDraftWorkflowService.java @@ -15,6 +15,8 @@ import net.modtale.model.user.ApiKey; import net.modtale.model.user.User; import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.repository.user.UserRepository; import net.modtale.service.communication.WebhookService; import net.modtale.service.project.access.ProjectAccessService; @@ -28,6 +30,7 @@ @Service public class ProjectDraftWorkflowService { + private final ProjectReviewPersistence reviewPersistence; private final ProjectRepository projectRepository; private final ProjectService projectService; private final ValidationService validationService; @@ -49,9 +52,11 @@ public ProjectDraftWorkflowService( ProjectAccessService projectAccessService, ProjectMutationGuard projectMutationGuard, VersionMutationOrchestrationService versionMutationOrchestrationService, - AppLimitProperties limitProperties + AppLimitProperties limitProperties, + ProjectReviewPersistence reviewPersistence ) { this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.validationService = validationService; this.webhookService = webhookService; @@ -148,7 +153,7 @@ public Project createDraft(String title, String description, ProjectClassificati project.setTeamMembers(new ArrayList<>()); project.setTeamInvites(new ArrayList<>()); - return projectRepository.save(project); + return projectRepository.insert(project); } public void submitProject(String id, User user) { @@ -156,6 +161,10 @@ public void submitProject(String id, User user) { "You do not have permission to submit this project."); requireVerifiedEmail(user, "submit a project for review"); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); + if (project.getStatus() != ProjectStatus.DRAFT) + throw new InvalidProjectRequestException("Only draft projects can be submitted for review."); if (project.getVersions().isEmpty() && project.getClassification() != ProjectClassification.MODPACK) { throw new InvalidProjectRequestException("Add at least one version before submitting this project."); @@ -180,7 +189,7 @@ public void submitProject(String id, User user) { project.setExpiresAt(null); List scansQueuedForSubmission = new ArrayList<>(); if (project.getVersions() != null) { - project.getVersions().forEach(version -> { + for (var version : project.getVersions()) { if (version.getReviewStatus() == null || version.getReviewStatus() == ProjectVersion.ReviewStatus.REJECTED) { version.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); @@ -188,12 +197,12 @@ public void submitProject(String id, User user) { if (versionMutationOrchestrationService.queueSubmissionScanIfNeeded(project, version)) { scansQueuedForSubmission.add(version); } - }); + } } - projectRepository.save(project); + if (!reviewPersistence.submitDraft(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); - scansQueuedForSubmission.forEach(version -> versionMutationOrchestrationService.enqueueSubmissionScan(project, version)); + for (var version : scansQueuedForSubmission) versionMutationOrchestrationService.enqueueSubmissionScan(project, version); if (project.getVersions() == null || project.getVersions().stream().noneMatch(version -> version.getScanResult() != null && version.getScanResult().getStatus() == ScanStatus.SCANNING)) { diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectPublicationService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectPublicationService.java index ac7d48857..1b1178f88 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectPublicationService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ProjectPublicationService.java @@ -1,13 +1,14 @@ package net.modtale.service.project.lifecycle; import java.time.LocalDateTime; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.exception.InvalidProjectRequestException; import net.modtale.exception.ProjectOperationForbiddenException; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectStatus; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; import net.modtale.service.analytics.ScoringService; import net.modtale.service.analytics.TrackingService; import net.modtale.service.communication.ProjectNotificationService; @@ -21,7 +22,7 @@ @Service public class ProjectPublicationService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final ProjectNotificationService projectNotificationService; private final WebhookService webhookService; @@ -32,7 +33,6 @@ public class ProjectPublicationService { private final SecurityIssueAnalysisService securityIssueAnalysisService; public ProjectPublicationService( - ProjectRepository projectRepository, ProjectService projectService, ProjectNotificationService projectNotificationService, WebhookService webhookService, @@ -40,9 +40,10 @@ public ProjectPublicationService( ScoringService scoringService, AccessControlService accessControlService, ProjectAccessService projectAccessService, - SecurityIssueAnalysisService securityIssueAnalysisService + SecurityIssueAnalysisService securityIssueAnalysisService, + ProjectReviewPersistence reviewPersistence ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectNotificationService = projectNotificationService; this.webhookService = webhookService; @@ -56,19 +57,24 @@ public ProjectPublicationService( public void revertProjectToDraft(String id, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_STATUS_REVERT", "You do not have permission to revert this project."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getStatus() != ProjectStatus.PENDING) { throw new InvalidProjectRequestException( "Only projects that are pending review can be reverted to draft."); } project.setStatus(ProjectStatus.DRAFT); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + project.setUpdatedAt(LocalDateTime.now().toString()); + if (!reviewPersistence.apply(snapshot, null)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } public void archiveProject(String id, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_STATUS_ARCHIVE", "You do not have permission to archive this project."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getStatus() != ProjectStatus.PUBLISHED && project.getStatus() != ProjectStatus.UNLISTED && project.getStatus() != ProjectStatus.PRIVATE) { @@ -77,38 +83,55 @@ public void archiveProject(String id, User user) { project.setStatus(ProjectStatus.ARCHIVED); project.setExpiresAt(null); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + project.setUpdatedAt(LocalDateTime.now().toString()); + if (!reviewPersistence.apply(snapshot, null)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } public void unlistProject(String id, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_STATUS_UNLIST", "You do not have permission to unlist this project."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getStatus() != ProjectStatus.PUBLISHED && project.getStatus() != ProjectStatus.ARCHIVED) { throw new InvalidProjectRequestException("Only published or archived projects can be unlisted."); } project.setStatus(ProjectStatus.UNLISTED); project.setExpiresAt(null); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + project.setUpdatedAt(LocalDateTime.now().toString()); + if (!reviewPersistence.apply(snapshot, null)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } public void privateProject(String id, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_STATUS_UNLIST", "You do not have permission to make this project private."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getStatus() == ProjectStatus.PENDING || project.getStatus() == ProjectStatus.DELETED) { throw new InvalidProjectRequestException("Pending or deleted projects cannot be made private."); } project.setStatus(ProjectStatus.PRIVATE); project.setExpiresAt(null); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + project.setUpdatedAt(LocalDateTime.now().toString()); + if (!reviewPersistence.apply(snapshot, null)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } public void publishProject(String id, User user) { - Project project = projectAccessService.requireProject(id); + publishProject(id, user, null, null); + } + + public void publishProject(String id, User user, String reviewToken, String reviewedVersionId) { + var snapshot = reviewToken == null ? null : reviewPersistence.capture(id, reviewToken); + boolean reviewed = snapshot != null; + if (snapshot == null) { + Project current = projectAccessService.requireProject(id); + snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(current)); + } + Project project = snapshot.project(); boolean canApproveReviews = accessControlService.canApproveProjectReviews(user); boolean isRestoration = project.getStatus() == ProjectStatus.ARCHIVED || project.getStatus() == ProjectStatus.UNLISTED @@ -122,20 +145,37 @@ public void publishProject(String id, User user) { } else if (!canApproveReviews) { throw new ProjectOperationForbiddenException("Only administrators with review approval permission can publish a new project."); } + if (!isRestoration && !reviewed) { + throw ProjectReviewSnapshot.conflict(); + } + if (reviewed && project.getStatus() != ProjectStatus.PENDING) { + throw new InvalidProjectRequestException("Only pending projects can be approved through review."); + } + ProjectVersion selected = null; + if (reviewed && reviewedVersionId != null && project.getVersions() != null) { + var matches = project.getVersions().stream().filter(version -> reviewedVersionId.equals(version.getId())).toList(); + if (matches.size() != 1) throw ProjectReviewSnapshot.conflict(); + selected = matches.getFirst(); + if (selected.getReviewStatus() != ProjectVersion.ReviewStatus.PENDING + && selected.getReviewStatus() != ProjectVersion.ReviewStatus.SCHEDULED + && selected.getReviewStatus() != ProjectVersion.ReviewStatus.APPROVED) { + throw new InvalidProjectRequestException("This version is not available for approval."); + } + } + if (reviewed && selected == null && project.getVersions() != null && !project.getVersions().isEmpty()) { + throw new InvalidProjectRequestException("Select the inspected version before publishing this project."); + } project.setStatus(ProjectStatus.PUBLISHED); project.setExpiresAt(null); project.setUpdatedAt(LocalDateTime.now().toString()); scoringService.markProjectRankingDirty(project); - if (project.getVersions() != null) { - project.getVersions().forEach(version -> { - if (version.getReviewStatus() == ProjectVersion.ReviewStatus.PENDING - || version.getReviewStatus() == ProjectVersion.ReviewStatus.SCHEDULED) { - version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); - version.setScheduledPublishDate(null); - } - }); - securityIssueAnalysisService.pruneApprovedScanResults(project); + if (selected != null && selected.getReviewStatus() != ProjectVersion.ReviewStatus.APPROVED) { + selected.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + selected.setScheduledPublishDate(null); + selected.setRejectionReason(null); + selected.setSecurityApprovalProjectId(project.getId()); + securityIssueAnalysisService.markIssuesAcceptedForApprovedVersion(selected); } if (isNew) { @@ -148,7 +188,8 @@ public void publishProject(String id, User user) { project.setImageUrl("https://modtale.net/assets/favicon.svg"); } - Project saved = projectRepository.save(project); + if (!reviewPersistence.apply(snapshot, reviewedVersionId)) throw ProjectReviewSnapshot.conflict(); + Project saved = project; projectService.evictProjectCache(saved); if (isNew) { @@ -162,10 +203,13 @@ public void publishProject(String id, User user) { public void updateProjectStatus(String id, ProjectStatus status, User user, String permissionRequired) { Project project = projectAccessService.requireProjectPermission(id, user, permissionRequired, "You do not have permission to update this project."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); project.setStatus(status); project.setExpiresAt(null); scoringService.markProjectRankingDirty(project); - projectRepository.save(project); + project.setUpdatedAt(LocalDateTime.now().toString()); + if (!reviewPersistence.apply(snapshot, null)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } } diff --git a/backend/src/main/java/net/modtale/service/project/lifecycle/ScheduledReleaseExecutionService.java b/backend/src/main/java/net/modtale/service/project/lifecycle/ScheduledReleaseExecutionService.java index 5baf5ff82..482fb1eaa 100644 --- a/backend/src/main/java/net/modtale/service/project/lifecycle/ScheduledReleaseExecutionService.java +++ b/backend/src/main/java/net/modtale/service/project/lifecycle/ScheduledReleaseExecutionService.java @@ -1,72 +1,107 @@ package net.modtale.service.project.lifecycle; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import net.modtale.model.project.Project; -import net.modtale.model.project.ProjectVersion; -import net.modtale.repository.project.ProjectRepository; +import java.util.*; +import net.modtale.model.project.*; import net.modtale.service.communication.ProjectNotificationService; import net.modtale.service.project.query.ProjectService; import net.modtale.service.security.issue.SecurityIssueAnalysisService; +import net.modtale.service.security.scan.ArtifactClearancePolicy; +import net.modtale.service.security.scan.ArtifactReviewContext; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.*; import org.springframework.stereotype.Service; @Service public class ScheduledReleaseExecutionService { - - private final ProjectRepository projectRepository; + private final net.modtale.service.security.scan.WardenClientService warden; + private final MongoTemplate mongo; private final ProjectService projectService; - private final ProjectNotificationService projectNotificationService; - private final SecurityIssueAnalysisService securityIssueAnalysisService; + private final ProjectNotificationService notifications; + private final SecurityIssueAnalysisService issueAnalysis; - public ScheduledReleaseExecutionService( - ProjectRepository projectRepository, - ProjectService projectService, - ProjectNotificationService projectNotificationService, - SecurityIssueAnalysisService securityIssueAnalysisService - ) { - this.projectRepository = projectRepository; - this.projectService = projectService; - this.projectNotificationService = projectNotificationService; - this.securityIssueAnalysisService = securityIssueAnalysisService; + public ScheduledReleaseExecutionService(MongoTemplate mongo, ProjectService projectService, + ProjectNotificationService notifications, SecurityIssueAnalysisService issueAnalysis, + net.modtale.service.security.scan.WardenClientService warden) { + this.warden = warden; + this.mongo = mongo; this.projectService = projectService; + this.notifications = notifications; this.issueAnalysis = issueAnalysis; } - public List publishDueVersions(Project project, LocalDateTime publishTime) { - List releasedVersions = new ArrayList<>(); - if (project.getVersions() == null) { - return releasedVersions; - } - + List released = new ArrayList<>(); + if (project.getVersions() == null) return released; + if (project.getVersions().stream().noneMatch(version -> due(version, publishTime))) return released; + String currentPolicy = warden.currentPolicyVersion(); + if (currentPolicy == null) return released; for (ProjectVersion version : project.getVersions()) { - if (!isDueForRelease(version, publishTime)) { + if (!due(version, publishTime)) continue; + ScanResult scan = version.getScanResult(); + Criteria versionMatch = Criteria.where("_id").is(version.getId()) + .and("reviewStatus").is(ProjectVersion.ReviewStatus.SCHEDULED) + .and("scheduledPublishDate").is(version.getScheduledPublishDate()) + .and("hash").is(version.getHash()) + .and("scanResult.scanAttempt").is(scan == null ? null : scan.getScanAttempt()); + ArtifactReviewContext.bindSnapshot(versionMatch, version); + long now = System.currentTimeMillis(); + boolean valid = ArtifactClearancePolicy.boundToVersion(version) + && currentPolicy.equals(scan.getSecurityEvidence().policyVersion()) + && scan.getScanTimestamp() > 0 + && scan.getScanTimestamp() <= now + && now - scan.getScanTimestamp() < java.time.Duration.ofDays(30).toMillis(); + Update update = new Update().set("versions.$.scheduledPublishDate", null) + .set("updatedAt", publishTime.toString()); + Query originGuard = new Query(); + valid = valid && net.modtale.service.security.scan.ArtifactReviewLineage.bind(mongo, project.getId(), scan, originGuard); + if (valid) { + versionMatch.and("scanResult.securityEvidence.artifactSha256").is(scan.getSecurityEvidence().artifactSha256()) + .and("scanResult.securityEvidence.contentSha256").is(scan.getSecurityEvidence().contentSha256()) + .and("scanResult.reviewedContextSha256").is(scan.getReviewedContextSha256()) + .and("scanResult.securityEvidence.policyVersion").is(scan.getSecurityEvidence().policyVersion()) + .and("scanResult.scanTimestamp").is(scan.getScanTimestamp()) + .and("scanResult.verdict").is(scan.getVerdict()) + .and("scanResult.status").is(scan.getStatus()); + version.setSecurityApprovalProjectId(project.getId()); + issueAnalysis.markIssuesAcceptedForApprovedVersion(version); + update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.APPROVED) + .set("versions.$.securityApprovalProjectId", version.getSecurityApprovalProjectId()) + .set("versions.$.approvedReviewOrigins", version.getApprovedReviewOrigins()) + .set("versions.$.approvedFindingReviewHead", null) + .set("versions.$.approvedSecurityEvidence", version.getApprovedSecurityEvidence()) + .set("versions.$.approvedSecurityContextSha256", version.getApprovedSecurityContextSha256()) + .set("versions.$.securityApprovedAt", version.getSecurityApprovedAt()) + .set("versions.$.approvedIssueBaselines", version.getApprovedIssueBaselines()) + .set("versions.$.scanResult", null); + } else { + update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING); + if (scan != null && scan.getReusedReviewVersion() != null) { + net.modtale.service.security.scan.ArtifactReviewLineage.invalidate(scan); + update.set("versions.$.scanResult", scan); + } + } + Query query = new Query(Criteria.where("_id").is(project.getId()).and("versions").elemMatch(versionMatch)); + if (originGuard.getQueryObject().containsKey("$expr")) query.addCriteria(Criteria.where("$expr").is(originGuard.getQueryObject().get("$expr"))); + if (mongo.updateFirst(query, update, Project.class).getModifiedCount() == 0) { + if (valid && scan.getReusedReviewVersion() != null) { + net.modtale.service.security.scan.ArtifactReviewLineage.invalidate(scan); + var hold = new Update().set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set("versions.$.scanResult", scan).set("versions.$.scheduledPublishDate", null); + var targetOnly = new Query(Criteria.where("_id").is(project.getId()).and("versions").elemMatch(versionMatch)); + if (mongo.updateFirst(targetOnly, hold, Project.class).getModifiedCount() > 0) projectService.evictProjectCache(project); + } continue; } - - version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); - version.setScheduledPublishDate(null); - releasedVersions.add(version.getVersionNumber()); - } - - if (releasedVersions.isEmpty()) { - return releasedVersions; + projectService.evictProjectCache(project); + if (valid) { + released.add(version.getVersionNumber()); + notifications.notifyUpdates(project, version.getVersionNumber()); + notifications.notifyDependents(project, version.getVersionNumber()); + } } - - project.setUpdatedAt(publishTime.toString()); - securityIssueAnalysisService.pruneApprovedScanResults(project); - projectRepository.save(project); - projectService.evictProjectCache(project); - releasedVersions.forEach(versionNumber -> { - projectNotificationService.notifyUpdates(project, versionNumber); - projectNotificationService.notifyDependents(project, versionNumber); - }); - return releasedVersions; + return released; } - - private boolean isDueForRelease(ProjectVersion version, LocalDateTime publishTime) { - if (version.getReviewStatus() != ProjectVersion.ReviewStatus.SCHEDULED - || version.getScheduledPublishDate() == null) { - return false; - } - return !LocalDateTime.parse(version.getScheduledPublishDate()).isAfter(publishTime); + private boolean due(ProjectVersion version, LocalDateTime time) { + if (version == null || version.getReviewStatus() != ProjectVersion.ReviewStatus.SCHEDULED || version.getScheduledPublishDate() == null) return false; + try { return !LocalDateTime.parse(version.getScheduledPublishDate()).isAfter(time); } + catch (java.time.format.DateTimeParseException ignored) { return true; } } } diff --git a/backend/src/main/java/net/modtale/service/project/media/ProjectMediaService.java b/backend/src/main/java/net/modtale/service/project/media/ProjectMediaService.java index 1318187e7..ca266d93a 100644 --- a/backend/src/main/java/net/modtale/service/project/media/ProjectMediaService.java +++ b/backend/src/main/java/net/modtale/service/project/media/ProjectMediaService.java @@ -14,7 +14,8 @@ import net.modtale.exception.StorageUploadException; import net.modtale.model.project.Project; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.media.MediaUploadService; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.access.ProjectMutationGuard; @@ -27,7 +28,7 @@ @Service public class ProjectMediaService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final ProjectAccessService projectAccessService; private final ProjectMutationGuard projectMutationGuard; @@ -39,7 +40,7 @@ public class ProjectMediaService { private static final Pattern YOUTUBE_VIDEO_ID_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{11}$"); public ProjectMediaService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ProjectAccessService projectAccessService, ProjectMutationGuard projectMutationGuard, @@ -48,7 +49,7 @@ public ProjectMediaService( FileValidationService fileValidationService, AppLimitProperties limitProperties ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectAccessService = projectAccessService; this.projectMutationGuard = projectMutationGuard; @@ -63,18 +64,15 @@ public void updateProjectImage(String id, MultipartFile file, User user, boolean Project project = projectAccessService.requireProjectPermission(id, user, permission, "You do not have permission to update this project's image."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); try { String currentUrl = isBanner ? project.getBannerUrl() : project.getImageUrl(); String publicUrl = mediaUploadService.uploadPublicUrl( file, - "images", - isBanner ? fileValidationService::validateBanner : fileValidationService::validateIcon, - () -> { - if (currentUrl != null && !currentUrl.contains("default.png") && !currentUrl.contains("placeholder") && !currentUrl.contains("favicon")) { - projectDeletionService.deleteStoredFile(currentUrl); - } - } + net.modtale.service.storage.ProjectMediaKeys.prefix(project.getId(), "images"), + isBanner ? fileValidationService::validateBanner : fileValidationService::validateIcon ); if (isBanner) { @@ -83,8 +81,9 @@ public void updateProjectImage(String id, MultipartFile file, User user, boolean project.setImageUrl(publicUrl); } - projectRepository.save(project); - projectService.evictProjectCache(project); + saveAndEvict(snapshot); + if (currentUrl != null && !currentUrl.equals(publicUrl) && !currentUrl.contains("default.png") + && !currentUrl.contains("placeholder") && !currentUrl.contains("favicon")) projectDeletionService.deleteProjectMediaFile(project, currentUrl); } catch (StorageUploadException ex) { throw new ProjectMediaOperationException(ex.getMessage(), ex); } @@ -94,11 +93,13 @@ public Project addGalleryImage(String id, MultipartFile file, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_GALLERY_ADD", "You do not have permission to upload gallery images for this project."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); ensureGalleryCapacity(project); try { - galleryItems(project).add(mediaUploadService.uploadPublicUrl(file, "gallery", fileValidationService::validateGalleryImage)); - return saveAndEvict(project); + galleryItems(project).add(mediaUploadService.uploadPublicUrl(file, net.modtale.service.storage.ProjectMediaKeys.prefix(project.getId(), "gallery"), fileValidationService::validateGalleryImage)); + return saveAndEvict(snapshot); } catch (StorageUploadException ex) { throw new ProjectMediaOperationException(ex.getMessage(), ex); } @@ -108,6 +109,8 @@ public Project addGalleryVideo(String id, String videoUrl, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_GALLERY_ADD", "You do not have permission to add gallery videos for this project."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); ensureGalleryCapacity(project); String normalizedVideoUrl = normalizeYouTubeUrl(videoUrl); @@ -117,29 +120,33 @@ public Project addGalleryVideo(String id, String videoUrl, User user) { } galleryItems.add(normalizedVideoUrl); - return saveAndEvict(project); + return saveAndEvict(snapshot); } public Project removeGalleryImage(String id, String imageUrl, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_GALLERY_REMOVE", "You do not have permission to remove gallery images from this project."); projectMutationGuard.ensureEditable(project); - galleryItems(project).remove(imageUrl); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); + if (!galleryItems(project).remove(imageUrl)) + throw new InvalidProjectRequestException("That gallery image does not exist on this project."); if (project.getGalleryImageCaptions() != null && project.getGalleryImageCaptions().containsKey(imageUrl)) { Map captions = new HashMap<>(project.getGalleryImageCaptions()); captions.remove(imageUrl); project.setGalleryImageCaptions(captions); } - if (!isYouTubeUrl(imageUrl)) { - projectDeletionService.deleteStoredFile(imageUrl); - } - return saveAndEvict(project); + var saved = saveAndEvict(snapshot); + if (!isYouTubeUrl(imageUrl)) projectDeletionService.deleteProjectMediaFile(project, imageUrl); + return saved; } public Project updateGalleryImageCaption(String id, String imageUrl, String caption, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_GALLERY_ADD", "You do not have permission to edit gallery image captions for this project."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getGalleryImages() == null || !project.getGalleryImages().contains(imageUrl)) { throw new InvalidProjectRequestException("That gallery image does not exist on this project."); @@ -161,16 +168,15 @@ public Project updateGalleryImageCaption(String id, String imageUrl, String capt } project.setGalleryImageCaptions(captions); - Project saved = projectRepository.save(project); - Project cacheTarget = saved != null ? saved : project; - projectService.evictProjectCache(cacheTarget); - return cacheTarget; + return saveAndEvict(snapshot); } public Project reorderGallery(String id, List imageUrls, User user) { Project project = projectAccessService.requireProjectPermission(id, user, "PROJECT_GALLERY_ADD", "You do not have permission to reorder this project gallery."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); List currentItems = new ArrayList<>(galleryItems(project)); List requestedItems = imageUrls == null ? List.of() : new ArrayList<>(imageUrls); @@ -181,14 +187,13 @@ public Project reorderGallery(String id, List imageUrls, User user) { } project.setGalleryImages(requestedItems); - return saveAndEvict(project); + return saveAndEvict(snapshot); } - private Project saveAndEvict(Project project) { - Project saved = projectRepository.save(project); - Project cacheTarget = saved != null ? saved : project; - projectService.evictProjectCache(cacheTarget); - return cacheTarget; + private Project saveAndEvict(ProjectReviewPersistence.Snapshot snapshot) { + if (!reviewPersistence.applyPresentation(snapshot, true)) throw ProjectReviewSnapshot.conflict(); + projectService.evictProjectCache(snapshot.project()); + return snapshot.project(); } private void ensureGalleryCapacity(Project project) { diff --git a/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java b/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java index b134f68a3..7f962d725 100644 --- a/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java +++ b/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java @@ -8,6 +8,8 @@ import net.modtale.model.project.ProjectLicenseSupport; import net.modtale.model.user.User; import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.access.ProjectMutationGuard; import net.modtale.service.project.query.ProjectService; @@ -25,6 +27,7 @@ public class MetadataService { ); private static final Pattern GALLERY_CAROUSEL_MARKER_PATTERN = Pattern.compile("\\{\\{\\s*gallery-carousel\\s*\\}\\}", Pattern.CASE_INSENSITIVE); + private final ProjectReviewPersistence reviewPersistence; private final ProjectRepository projectRepository; private final ProjectService projectService; private final ValidationService validationService; @@ -34,6 +37,7 @@ public class MetadataService { public MetadataService( ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ValidationService validationService, ProjectAccessService projectAccessService, @@ -41,6 +45,7 @@ public MetadataService( SanitizationService sanitizer ) { this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.validationService = validationService; this.projectAccessService = projectAccessService; @@ -52,6 +57,8 @@ public void updateMetadata(String id, Project updated, User user) { Project existing = projectAccessService.requireProjectPermission(id, user, "PROJECT_EDIT_METADATA", "You do not have permission to edit this project's metadata."); projectMutationGuard.ensureEditable(existing); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(existing)); + existing = snapshot.project(); if (updated.getClassification() != null && updated.getClassification() != existing.getClassification()) { if (!MUTABLE_CLASSIFICATIONS.contains(existing.getClassification())) { @@ -104,9 +111,10 @@ else if (!newSlug.equals(existing.getSlug())) { existing.setHmWikiSlug(updated.getHmWikiSlug() != null ? updated.getHmWikiSlug().trim() : null); existing.setGalleryCarouselEnabled(updated.isGalleryCarouselEnabled()); if (updated.getLinks() != null) existing.setLinks(updated.getLinks()); - if (updated.getImageUrl() != null) existing.setImageUrl(updated.getImageUrl()); + if (updated.getImageUrl() != null && !java.util.Objects.equals(updated.getImageUrl(), existing.getImageUrl())) + throw new InvalidProjectRequestException("Use the project image upload endpoint to change its image."); - projectRepository.save(existing); + if (!reviewPersistence.applyPresentation(snapshot, false)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(existing); } diff --git a/backend/src/main/java/net/modtale/service/project/team/ProjectInvitationPolicy.java b/backend/src/main/java/net/modtale/service/project/team/ProjectInvitationPolicy.java new file mode 100644 index 000000000..a338b5ca8 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/project/team/ProjectInvitationPolicy.java @@ -0,0 +1,29 @@ +package net.modtale.service.project.team; + +import java.util.*; +import net.modtale.exception.InvalidProjectRequestException; +import net.modtale.model.project.Project; + +public final class ProjectInvitationPolicy { + private ProjectInvitationPolicy() {} + public static Project.ProjectMember require(Project project, String userId, String requestId, boolean accepting) { + var matches = project.getTeamInvites() == null ? List.of() : project.getTeamInvites().stream() + .filter(invite -> Objects.equals(userId, invite.getUserId())).toList(); + if (matches.size() != 1 || requestId == null || requestId.isBlank()) throw invalid(); + var invite = matches.getFirst(); + boolean legacyCancellation = !accepting && invite.getRequestId() == null && requestId.equals("legacy"); + if (!legacyCancellation && !requestId.equals(invite.getRequestId())) throw invalid(); + if (accepting) { + if (invite.getRequestOwnerId() == null || !invite.getRequestOwnerId().equals(project.getAuthorId()) + || invite.getRequestExpiresAt() <= System.currentTimeMillis() || invite.getRequestPermissions() == null) throw invalid(); + var roles = project.getProjectRoles() == null ? List.of() : project.getProjectRoles().stream() + .filter(role -> Objects.equals(invite.getRoleId(), role.getId())).toList(); + if (roles.size() != 1 || !invite.getRequestPermissions().equals( + roles.getFirst().getPermissions() == null ? Set.of() : roles.getFirst().getPermissions())) throw invalid(); + } + return invite; + } + private static InvalidProjectRequestException invalid() { + return new InvalidProjectRequestException("This invitation is no longer current. Ask the project owner to send a new invitation."); + } +} diff --git a/backend/src/main/java/net/modtale/service/project/team/TeamMembershipService.java b/backend/src/main/java/net/modtale/service/project/team/TeamMembershipService.java index a928f2670..3dc3079c2 100644 --- a/backend/src/main/java/net/modtale/service/project/team/TeamMembershipService.java +++ b/backend/src/main/java/net/modtale/service/project/team/TeamMembershipService.java @@ -9,7 +9,8 @@ import net.modtale.model.project.Project; import net.modtale.model.user.ApiKey; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.repository.user.UserRepository; import net.modtale.service.auth.ApiKeyService; import net.modtale.service.project.access.ProjectAccessService; @@ -21,7 +22,7 @@ @Service public class TeamMembershipService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final UserRepository userRepository; private final ProjectService projectService; private final ProjectAccessService projectAccessService; @@ -31,7 +32,7 @@ public class TeamMembershipService { private final AccessControlService accessControlService; public TeamMembershipService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, UserRepository userRepository, ProjectService projectService, ProjectAccessService projectAccessService, @@ -40,7 +41,7 @@ public TeamMembershipService( ApiKeyService apiKeyService, AccessControlService accessControlService ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.userRepository = userRepository; this.projectService = projectService; this.projectAccessService = projectAccessService; @@ -54,7 +55,9 @@ public void inviteContributor(String id, String targetUserId, String roleId, Use Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_TEAM_INVITE", "You do not have permission to invite contributors to this project."); projectMutationGuard.ensureEditable(project); - requireRole(project, roleId); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); + var role = requireRole(project, roleId); User invitee = userRepository.findById(targetUserId) .orElseThrow(() -> new ResourceNotFoundException("We couldn't find the contributor you tried to invite.")); @@ -71,24 +74,33 @@ public void inviteContributor(String id, String targetUserId, String roleId, Use throw new InvalidProjectRequestException("That contributor already has a pending invite."); } - project.getTeamInvites().add(new Project.ProjectMember(invitee.getId(), roleId)); - saveProject(project); + var invitation = new Project.ProjectMember(invitee.getId(), roleId); + invitation.setRequestId(java.util.UUID.randomUUID().toString()); + invitation.setRequestOwnerId(project.getAuthorId()); + invitation.setRequestExpiresAt(System.currentTimeMillis() + java.time.Duration.ofDays(7).toMillis()); + invitation.setRequestPermissions(role.getPermissions() == null || role.getPermissions().isEmpty() + ? EnumSet.noneOf(ApiKey.ApiPermission.class) : EnumSet.copyOf(role.getPermissions())); + project.getTeamInvites().add(invitation); + saveProject(snapshot); teamNotificationService.sendContributorInvite(project, invitee); } - public void cancelInvite(String id, String targetUserId, User requester) { + public void cancelInvite(String id, String targetUserId, String requestId, User requester) { Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_TEAM_INVITE", "You do not have permission to manage contributor invites for this project."); - if (project.getTeamInvites() != null) { - project.getTeamInvites().removeIf(member -> member.getUserId().equals(targetUserId)); - saveProject(project); - } + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); + var invite = ProjectInvitationPolicy.require(project, targetUserId, requestId, false); + project.getTeamInvites().remove(invite); + saveInvitation(snapshot, targetUserId, requestId, false); } public void updateContributorRole(String id, String targetUserId, String roleId, User requester) { Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_MEMBER_EDIT_ROLE", "You do not have permission to update contributor roles for this project."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); Project.ProjectRole role = project.getProjectRoles().stream() .filter(existingRole -> existingRole.getId().equals(roleId)) @@ -100,7 +112,7 @@ public void updateContributorRole(String id, String targetUserId, String roleId, .findFirst() .orElseThrow(() -> new ResourceNotFoundException("We couldn't find that contributor on the project team.")); member.setRoleId(roleId); - saveProject(project); + saveProject(snapshot); apiKeyService.syncUserProjectPermissions(targetUserId, id, role.getPermissions()); teamNotificationService.sendContributorRoleUpdated(project, targetUserId, role); } @@ -110,45 +122,35 @@ public void removeContributor(String id, String targetUserId, User requester) { Project project = projectService.getRawProjectById(id); if (project != null && (accessControlService.hasProjectPermission(project, requester, "PROJECT_TEAM_REMOVE") || requester.getId().equals(targetUserId))) { projectMutationGuard.ensureEditable(project); - if (project.getTeamMembers() != null && project.getTeamMembers().removeIf(member -> member.getUserId().equals(targetUserId))) { - apiKeyService.syncUserProjectPermissions(targetUserId, id, EnumSet.noneOf(ApiKey.ApiPermission.class)); - } - saveProject(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); + boolean removed = project.getTeamMembers() != null && project.getTeamMembers().removeIf(member -> member.getUserId().equals(targetUserId)); + saveProject(snapshot); + if (removed) apiKeyService.syncUserProjectPermissions(targetUserId, id, EnumSet.noneOf(ApiKey.ApiPermission.class)); return; } throw new ProjectOperationForbiddenException("You do not have permission to remove that contributor."); } - public void acceptInvite(String id, String userId) { + public void acceptInvite(String id, String userId, String requestId) { User user = userRepository.findById(userId) .orElseThrow(() -> new ResourceNotFoundException("We couldn't find that user.")); Project project = projectService.getRawProjectById(id); if (project == null) { throw new ResourceNotFoundException("We couldn't find the project for that invite."); } + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); - if (project.getTeamMembers() != null - && project.getTeamMembers().stream().anyMatch(member -> userId.equals(member.getUserId()))) { - return; - } - - Project.ProjectMember invite = project.getTeamInvites() != null - ? project.getTeamInvites().stream() - .filter(member -> userId.equals(member.getUserId())) - .findFirst() - .orElse(null) - : null; - if (invite == null) { - throw new InvalidProjectRequestException("We couldn't find a pending project invite for your account."); - } - + projectMutationGuard.ensureEditable(project); + var invite = ProjectInvitationPolicy.require(project, userId, requestId, true); Project.ProjectRole role = requireRole(project, invite.getRoleId()); project.getTeamInvites().remove(invite); if (project.getTeamMembers() == null) { project.setTeamMembers(new ArrayList<>()); } - project.getTeamMembers().add(invite); - saveProject(project); + project.getTeamMembers().add(new Project.ProjectMember(invite.getUserId(), invite.getRoleId())); + saveInvitation(snapshot, userId, requestId, true); apiKeyService.syncUserProjectPermissions(userId, id, role.getPermissions() != null ? role.getPermissions() : EnumSet.noneOf(ApiKey.ApiPermission.class)); @@ -157,17 +159,24 @@ public void acceptInvite(String id, String userId) { teamNotificationService.sendInviteAccepted(project, owner, user); } - public void declineInvite(String id, String userId) { + public void declineInvite(String id, String userId, String requestId) { Project project = projectService.getRawProjectById(id); - if (project != null && project.getTeamInvites() != null) { - project.getTeamInvites().removeIf(member -> member.getUserId().equals(userId)); - saveProject(project); - } + if (project == null) throw new ResourceNotFoundException("Project not found."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); + var invite = ProjectInvitationPolicy.require(project, userId, requestId, false); + project.getTeamInvites().remove(invite); + saveInvitation(snapshot, userId, requestId, false); + } + + private void saveInvitation(ProjectReviewPersistence.Snapshot snapshot, String userId, String requestId, boolean accepting) { + if (!reviewPersistence.resolveContributorInvite(snapshot, userId, requestId, accepting)) throw ProjectReviewSnapshot.conflict(); + projectService.evictProjectCache(snapshot.project()); } - private void saveProject(Project project) { - projectRepository.save(project); - projectService.evictProjectCache(project); + private void saveProject(ProjectReviewPersistence.Snapshot snapshot) { + if (!reviewPersistence.applyTeam(snapshot)) throw ProjectReviewSnapshot.conflict(); + projectService.evictProjectCache(snapshot.project()); } private Project.ProjectRole requireRole(Project project, String roleId) { diff --git a/backend/src/main/java/net/modtale/service/project/team/TeamNotificationService.java b/backend/src/main/java/net/modtale/service/project/team/TeamNotificationService.java index 001a3bb06..d71d9e556 100644 --- a/backend/src/main/java/net/modtale/service/project/team/TeamNotificationService.java +++ b/backend/src/main/java/net/modtale/service/project/team/TeamNotificationService.java @@ -29,7 +29,8 @@ public void sendContributorInvite(Project project, User invitee) { URI.create("/dashboard/projects"), project.getImageUrl(), NotificationType.CONTRIBUTOR_INVITE, - Map.of("projectId", project.getId(), "action", "CONTRIBUTOR_INVITE") + Map.of("projectId", project.getId(), "action", "CONTRIBUTOR_INVITE", "requestId", + project.getTeamInvites().stream().filter(invite -> invitee.getId().equals(invite.getUserId())).findFirst().orElseThrow().getRequestId()) ); } @@ -66,7 +67,8 @@ public void sendTransferRequest(Project project, User targetUser, User author) { URI.create("/dashboard/projects"), project.getImageUrl(), NotificationType.TRANSFER_REQUEST, - Map.of("projectId", project.getId(), "action", "TRANSFER_REQUEST") + Map.of("projectId", project.getId(), "action", "TRANSFER_REQUEST", + "requestId", project.getPendingTransferRequestId(), "targetUserId", targetUser.getId()) ); } diff --git a/backend/src/main/java/net/modtale/service/project/team/TeamRoleService.java b/backend/src/main/java/net/modtale/service/project/team/TeamRoleService.java index 35bc08079..4cb1acb10 100644 --- a/backend/src/main/java/net/modtale/service/project/team/TeamRoleService.java +++ b/backend/src/main/java/net/modtale/service/project/team/TeamRoleService.java @@ -7,7 +7,8 @@ import net.modtale.model.project.Project; import net.modtale.model.user.ApiKey; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.auth.ApiKeyService; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.query.ProjectService; @@ -16,18 +17,18 @@ @Service public class TeamRoleService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final ProjectAccessService projectAccessService; private final ApiKeyService apiKeyService; public TeamRoleService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ProjectAccessService projectAccessService, ApiKeyService apiKeyService ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectAccessService = projectAccessService; this.apiKeyService = apiKeyService; @@ -36,6 +37,8 @@ public TeamRoleService( public Project createProjectRole(String id, String name, String color, Set perms, User requester) { Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_MEMBER_EDIT_ROLE", "You do not have permission to manage project roles."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getProjectRoles() == null) { project.setProjectRoles(new ArrayList<>()); } @@ -44,13 +47,15 @@ public Project createProjectRole(String id, String name, String color, Set perms, User requester) { Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_MEMBER_EDIT_ROLE", "You do not have permission to manage project roles."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); Project.ProjectRole role = project.getProjectRoles().stream() .filter(existingRole -> existingRole.getId().equals(roleId)) @@ -64,19 +69,19 @@ public Project updateProjectRole(String id, String roleId, String name, String c } if (perms != null) { role.setPermissions(perms); - if (project.getTeamMembers() != null) { - project.getTeamMembers().stream() - .filter(member -> roleId.equals(member.getRoleId())) - .forEach(member -> apiKeyService.syncUserProjectPermissions(member.getUserId(), id, perms)); - } } - saveProject(project); + saveProject(snapshot); + if (perms != null && project.getTeamMembers() != null) project.getTeamMembers().stream() + .filter(member -> roleId.equals(member.getRoleId())) + .forEach(member -> apiKeyService.syncUserProjectPermissions(member.getUserId(), id, perms)); return project; } public Project deleteProjectRole(String id, String roleId, User requester) { Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_MEMBER_EDIT_ROLE", "You do not have permission to manage project roles."); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); boolean inUse = (project.getTeamMembers() != null && project.getTeamMembers().stream().anyMatch(member -> roleId.equals(member.getRoleId()))) || (project.getTeamInvites() != null && project.getTeamInvites().stream().anyMatch(invite -> roleId.equals(invite.getRoleId()))); @@ -85,12 +90,12 @@ public Project deleteProjectRole(String id, String roleId, User requester) { } project.getProjectRoles().removeIf(role -> role.getId().equals(roleId)); - saveProject(project); + saveProject(snapshot); return project; } - private void saveProject(Project project) { - projectRepository.save(project); - projectService.evictProjectCache(project); + private void saveProject(ProjectReviewPersistence.Snapshot snapshot) { + if (!reviewPersistence.applyTeam(snapshot)) throw ProjectReviewSnapshot.conflict(); + projectService.evictProjectCache(snapshot.project()); } } diff --git a/backend/src/main/java/net/modtale/service/project/team/TeamService.java b/backend/src/main/java/net/modtale/service/project/team/TeamService.java index 433473f9a..6bd5f36e4 100644 --- a/backend/src/main/java/net/modtale/service/project/team/TeamService.java +++ b/backend/src/main/java/net/modtale/service/project/team/TeamService.java @@ -27,8 +27,8 @@ public void requestTransfer(String id, String targetUserId, User requester) { teamTransferService.requestTransfer(id, targetUserId, requester); } - public void resolveTransfer(String id, boolean accept, User responder) { - teamTransferService.resolveTransfer(id, accept, responder); + public void resolveTransfer(String id, boolean accept, String requestId, User responder) { + teamTransferService.resolveTransfer(id, accept, requestId, responder); } public Project createProjectRole(String id, String name, String color, Set perms, User requester) { @@ -47,8 +47,8 @@ public void inviteContributor(String id, String targetUserId, String roleId, Use teamMembershipService.inviteContributor(id, targetUserId, roleId, requester); } - public void cancelInvite(String id, String targetUserId, User requester) { - teamMembershipService.cancelInvite(id, targetUserId, requester); + public void cancelInvite(String id, String targetUserId, String requestId, User requester) { + teamMembershipService.cancelInvite(id, targetUserId, requestId, requester); } public void updateContributorRole(String id, String targetUserId, String roleId, User requester) { @@ -59,11 +59,11 @@ public void removeContributor(String id, String targetUserId, User requester) { teamMembershipService.removeContributor(id, targetUserId, requester); } - public void acceptInvite(String id, String userId) { - teamMembershipService.acceptInvite(id, userId); + public void acceptInvite(String id, String userId, String requestId) { + teamMembershipService.acceptInvite(id, userId, requestId); } - public void declineInvite(String id, String userId) { - teamMembershipService.declineInvite(id, userId); + public void declineInvite(String id, String userId, String requestId) { + teamMembershipService.declineInvite(id, userId, requestId); } } diff --git a/backend/src/main/java/net/modtale/service/project/team/TeamTransferService.java b/backend/src/main/java/net/modtale/service/project/team/TeamTransferService.java index 65c25253f..db634d227 100644 --- a/backend/src/main/java/net/modtale/service/project/team/TeamTransferService.java +++ b/backend/src/main/java/net/modtale/service/project/team/TeamTransferService.java @@ -9,7 +9,8 @@ import net.modtale.model.project.Project; import net.modtale.model.user.ApiKey; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.repository.user.UserRepository; import net.modtale.service.auth.ApiKeyService; import net.modtale.service.project.access.ProjectAccessService; @@ -21,7 +22,7 @@ @Service public class TeamTransferService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final UserRepository userRepository; private final ProjectService projectService; private final ProjectAccessService projectAccessService; @@ -31,7 +32,7 @@ public class TeamTransferService { private final AccessControlService accessControlService; public TeamTransferService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, UserRepository userRepository, ProjectService projectService, ProjectAccessService projectAccessService, @@ -40,7 +41,7 @@ public TeamTransferService( ApiKeyService apiKeyService, AccessControlService accessControlService ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.userRepository = userRepository; this.projectService = projectService; this.projectAccessService = projectAccessService; @@ -54,6 +55,8 @@ public void requestTransfer(String id, String targetUserId, User requester) { Project project = projectAccessService.requireProjectPermission(id, requester, "PROJECT_TRANSFER_REQUEST", "You do not have permission to transfer this project."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); User target = userRepository.findById(targetUserId) .orElseThrow(() -> new ResourceNotFoundException("We couldn't find the transfer target.")); @@ -62,19 +65,30 @@ public void requestTransfer(String id, String targetUserId, User requester) { } project.setPendingTransferTo(target.getId()); - projectRepository.save(project); + project.setPendingTransferRequestId(java.util.UUID.randomUUID().toString()); + project.setPendingTransferOwnerId(project.getAuthorId()); + project.setPendingTransferExpiresAt(System.currentTimeMillis() + java.time.Duration.ofDays(7).toMillis()); + if (!reviewPersistence.applyTeam(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); User author = userRepository.findById(project.getAuthorId()).orElse(null); teamNotificationService.sendTransferRequest(project, target, author); } - public void resolveTransfer(String id, boolean accept, User responder) { + public void resolveTransfer(String id, boolean accept, String requestId, User responder) { Project project = projectService.getRawProjectById(id); if (project == null || project.getPendingTransferTo() == null) { throw new InvalidProjectRequestException("We couldn't find a pending transfer request for that project."); } projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); + + if (requestId == null || requestId.isBlank() || !requestId.equals(project.getPendingTransferRequestId()) + || project.getPendingTransferExpiresAt() <= System.currentTimeMillis() + || project.getPendingTransferOwnerId() == null + || !project.getPendingTransferOwnerId().equals(project.getAuthorId())) + throw new InvalidProjectRequestException("This transfer request is no longer current. Ask the owner to send a new request."); if (!responder.getId().equals(project.getPendingTransferTo())) { User targetUser = userRepository.findById(project.getPendingTransferTo()).orElse(null); @@ -91,16 +105,6 @@ public void resolveTransfer(String id, boolean accept, User responder) { User newOwner = userRepository.findById(project.getPendingTransferTo()) .orElseThrow(() -> new ResourceNotFoundException("We couldn't find the transfer target.")); - if (oldOwner != null) { - if (oldOwner.getAccountType() == User.AccountType.ORGANIZATION && oldOwner.getOrganizationMembers() != null) { - oldOwner.getOrganizationMembers().forEach(member -> - apiKeyService.syncUserProjectPermissions(member.getUserId(), project.getId(), EnumSet.noneOf(ApiKey.ApiPermission.class)) - ); - } else { - apiKeyService.syncUserProjectPermissions(oldOwner.getId(), project.getId(), EnumSet.noneOf(ApiKey.ApiPermission.class)); - } - } - project.setAuthorId(newOwner.getId()); project.setAuthor(null); project.setPendingTransferTo(null); @@ -108,14 +112,23 @@ public void resolveTransfer(String id, boolean accept, User responder) { project.getTeamMembers().removeIf(member -> member.getUserId().equals(newOwner.getId())); } - projectRepository.save(project); + if (!reviewPersistence.resolveTransfer(snapshot, requestId)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); + if (oldOwner != null) { + if (oldOwner.getAccountType() == User.AccountType.ORGANIZATION && oldOwner.getOrganizationMembers() != null) { + oldOwner.getOrganizationMembers().forEach(member -> + apiKeyService.syncUserProjectPermissions(member.getUserId(), id, EnumSet.noneOf(ApiKey.ApiPermission.class)) + ); + } else { + apiKeyService.syncUserProjectPermissions(oldOwner.getId(), id, EnumSet.noneOf(ApiKey.ApiPermission.class)); + } + } teamNotificationService.sendTransferAccepted(project, oldOwner, newOwner); return; } project.setPendingTransferTo(null); - projectRepository.save(project); + if (!reviewPersistence.resolveTransfer(snapshot, requestId)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); User oldOwner = userRepository.findById(project.getAuthorId()).orElse(null); teamNotificationService.sendTransferDeclined(project, oldOwner); diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java b/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java index f5366356b..5d20c9974 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java @@ -16,7 +16,8 @@ import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.access.ProjectMutationGuard; import net.modtale.service.project.query.ProjectService; @@ -26,7 +27,7 @@ @Service public class VersionCreationCommandHandler { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final ProjectAccessService projectAccessService; private final ProjectMutationGuard projectMutationGuard; @@ -34,14 +35,14 @@ public class VersionCreationCommandHandler { private final int maxVersionsPerDay; public VersionCreationCommandHandler( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ProjectAccessService projectAccessService, ProjectMutationGuard projectMutationGuard, VersionMutationOrchestrationService versionMutationOrchestrationService, AppLimitProperties limitProperties ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectAccessService = projectAccessService; this.projectMutationGuard = projectMutationGuard; @@ -64,6 +65,8 @@ public void addVersion( Project project = projectAccessService.requireVersionPermission(projectId, user, "VERSION_CREATE", "You do not have permission to add a version to this project."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(project.getId(), ProjectReviewSnapshot.token(project)); + project = snapshot.project(); ensureProjectVersionList(project); versionMutationOrchestrationService.validateVersionNumber(versionNumber); @@ -107,13 +110,15 @@ public void addVersion( project.setChildProjectIds(simpleProjectIds); } + List changedTargets = new ArrayList<>(); List replacedVersions = replaceExisting - ? replaceMatchingVersionTargets(project, versionNumber, gameVersions) + ? replaceMatchingVersionTargets(project, versionNumber, gameVersions, changedTargets) : List.of(); project.getVersions().add(0, version); - projectRepository.save(project); + if (!reviewPersistence.applyVersionList(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); versionMutationOrchestrationService.enqueueInitialScan(project, version, file, modpack, preparedArtifact.filePath()); + for (var changed : changedTargets) versionMutationOrchestrationService.enqueueContextChangeScan(project, changed); replacedVersions.forEach(versionMutationOrchestrationService::deleteVersionFile); } @@ -186,7 +191,7 @@ && gameVersionsOverlap(version.getGameVersions(), gameVersions)) .toList(); } - private List replaceMatchingVersionTargets(Project project, String versionNumber, List gameVersions) { + private List replaceMatchingVersionTargets(Project project, String versionNumber, List gameVersions, List changedTargets) { List replacedVersions = new ArrayList<>(); for (ProjectVersion existing : new ArrayList<>(project.getVersions())) { if (existing.getVersionNumber() == null @@ -201,7 +206,11 @@ private List replaceMatchingVersionTargets(Project project, Stri continue; } + var previousScan = existing.getScanResult(); existing.setGameVersions(removeRequestedGameVersions(existing.getGameVersions(), gameVersions)); + existing.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + existing.setScheduledPublishDate(null); existing.setScanResult(null); + if (versionMutationOrchestrationService.prepareContextChangeScan(project, existing, previousScan)) changedTargets.add(existing); } return replacedVersions; } diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionMutationOrchestrationService.java b/backend/src/main/java/net/modtale/service/project/version/VersionMutationOrchestrationService.java index 864180e60..5eb9490d9 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionMutationOrchestrationService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionMutationOrchestrationService.java @@ -126,7 +126,7 @@ public void enqueueInitialScan(Project project, ProjectVersion version, Multipar filePath, file.getOriginalFilename(), false, - 1 + 1, version.getScanResult() == null ? null : version.getScanResult().getScanRequestId() ); } @@ -157,7 +157,7 @@ public void enqueueSubmissionScan(Project project, ProjectVersion version) { version.getFileUrl(), version.getFileUrl(), false, - 1 + 1, version.getScanResult() == null ? null : version.getScanResult().getScanRequestId() ); } @@ -168,6 +168,21 @@ private boolean shouldScanImmediately(Project project, MultipartFile file, boole && !modpack; } + public boolean prepareContextChangeScan(Project project, ProjectVersion version, ScanResult previous) { + if (project.getStatus() == ProjectStatus.DRAFT || project.getClassification() == ProjectClassification.MODPACK + || version.getFileUrl() == null || version.getFileUrl().isBlank()) return false; + int attempt = scanService.nextScanAttempt(previous); + version.setScanResult(scanService.createQueuedScanResult(attempt, "Runtime or dependencies changed; current review required.")); + return true; + } + + public void enqueueContextChangeScan(Project project, ProjectVersion version) { + scanService.enqueueBackgroundScan(project.getId(), version.getId(), version.getFileUrl(), version.getFileUrl(), + false, version.getScanResult().getScanAttempt(), version.getScanResult().getScanRequestId()); + } + + public void deleteCachedArtifact(String path) { projectDeletionService.deleteStoredFile(path); } + public void invalidateCachedModpackArtifact(ProjectVersion version, List dependencies) { if (version == null || dependencies == null) { return; diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionService.java b/backend/src/main/java/net/modtale/service/project/version/VersionService.java index 6c22b6fca..2b6797ad8 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionService.java @@ -10,7 +10,8 @@ import net.modtale.model.project.ProjectStatus; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.access.ProjectMutationGuard; import net.modtale.service.project.access.ProjectVersionAccessService; @@ -25,7 +26,7 @@ @Service public class VersionService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final ProjectAccessService projectAccessService; private final ProjectMutationGuard projectMutationGuard; @@ -37,7 +38,7 @@ public class VersionService { private final VersionUpdateCommandHandler versionUpdateCommandHandler; public VersionService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ProjectAccessService projectAccessService, ProjectMutationGuard projectMutationGuard, @@ -48,7 +49,7 @@ public VersionService( VersionCreationCommandHandler versionCreationCommandHandler, VersionUpdateCommandHandler versionUpdateCommandHandler ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectAccessService = projectAccessService; this.projectMutationGuard = projectMutationGuard; @@ -119,6 +120,8 @@ public void deleteVersion(String id, String versionId, User user) { Project project = projectAccessService.requireVersionPermission(id, user, "VERSION_DELETE", "You do not have permission to delete this version."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(project.getId(), ProjectReviewSnapshot.token(project)); + project = snapshot.project(); if (project.getStatus() != ProjectStatus.DRAFT && project.getStatus() != ProjectStatus.PRIVATE && project.getVersions().size() <= 1) { @@ -127,10 +130,10 @@ public void deleteVersion(String id, String versionId, User user) { ProjectVersion version = projectVersionAccessService.requireById(project, versionId, () -> new VersionNotFoundException("We couldn't find that project version.")); - projectDeletionService.deleteVersionFile(version); project.getVersions().removeIf(existing -> existing.getId().equals(versionId)); - projectRepository.save(project); + if (!reviewPersistence.applyVersionList(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); + projectDeletionService.deleteVersionFile(version); } } diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionUpdateCommandHandler.java b/backend/src/main/java/net/modtale/service/project/version/VersionUpdateCommandHandler.java index d34165332..8b1e5d11a 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionUpdateCommandHandler.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionUpdateCommandHandler.java @@ -8,7 +8,8 @@ import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.project.access.ProjectAccessService; import net.modtale.service.project.access.ProjectMutationGuard; import net.modtale.service.project.access.ProjectVersionAccessService; @@ -18,7 +19,7 @@ @Service public class VersionUpdateCommandHandler { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final ProjectService projectService; private final ProjectAccessService projectAccessService; private final ProjectMutationGuard projectMutationGuard; @@ -26,14 +27,14 @@ public class VersionUpdateCommandHandler { private final VersionMutationOrchestrationService versionMutationOrchestrationService; public VersionUpdateCommandHandler( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, ProjectAccessService projectAccessService, ProjectMutationGuard projectMutationGuard, ProjectVersionAccessService projectVersionAccessService, VersionMutationOrchestrationService versionMutationOrchestrationService ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.projectAccessService = projectAccessService; this.projectMutationGuard = projectMutationGuard; @@ -54,10 +55,18 @@ public void updateVersion( Project project = projectAccessService.requireVersionPermission(projectId, user, "VERSION_EDIT", "You do not have permission to update this version."); projectMutationGuard.ensureEditable(project); + var snapshot = reviewPersistence.capture(projectId, ProjectReviewSnapshot.token(project)); + project = snapshot.project(); ProjectVersion version = projectVersionAccessService.requireById(project, versionId, () -> new VersionNotFoundException("We couldn't find that project version.")); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + var originalContext = mapper.valueToTree(java.util.Arrays.asList(version.getGameVersions(), version.getDependencies())); + String originalFingerprint = net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(version); + var previousScan = version.getScanResult(); + String oldCachedArchive = null; + boolean childIdsChanged = false; if (gameVersions != null) { versionMutationOrchestrationService.validateGameVersions(gameVersions); version.setGameVersions(gameVersions); @@ -79,11 +88,16 @@ public void updateVersion( catch (java.io.IOException ex) { throw new net.modtale.exception.InvalidVersionRequestException("This mod has attached configs. Upload a new version to change its config ownership."); } } if (modpack) { - versionMutationOrchestrationService.invalidateCachedModpackArtifact(version, resolvedProjectDependencies); + if (!java.util.Objects.equals(mapper.valueToTree(version.getDependencies()), mapper.valueToTree(resolvedProjectDependencies)) + && version.getFileUrl() != null && version.getFileUrl().endsWith(".zip")) { + oldCachedArchive = version.getFileUrl(); + version.setFileUrl(null); + } } version.setDependencies(resolvedProjectDependencies); if (modpack && project.getVersions().get(0).getId().equals(versionId)) { project.setChildProjectIds(resolvedDependencies.simpleProjectIds()); + childIdsChanged = true; } } if (incompatibleProjectIds != null) { @@ -92,7 +106,18 @@ public void updateVersion( )); } - projectRepository.save(project); + String updatedFingerprint = net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(version); + boolean contextChanged = originalFingerprint != null && updatedFingerprint != null + ? !originalFingerprint.equals(updatedFingerprint) : !originalContext.equals(mapper.valueToTree(java.util.Arrays.asList(version.getGameVersions(), version.getDependencies()))); + if (contextChanged) { + version.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + version.setScheduledPublishDate(null); + version.setScanResult(null); + } + boolean queued = contextChanged && versionMutationOrchestrationService.prepareContextChangeScan(project, version, previousScan); + if (!reviewPersistence.applyVersionEdit(snapshot, versionId, contextChanged, childIdsChanged)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); + if (queued) versionMutationOrchestrationService.enqueueContextChangeScan(project, version); + if (oldCachedArchive != null) versionMutationOrchestrationService.deleteCachedArtifact(oldCachedArchive); } } diff --git a/backend/src/main/java/net/modtale/service/security/issue/FindingDecisionValidity.java b/backend/src/main/java/net/modtale/service/security/issue/FindingDecisionValidity.java new file mode 100644 index 000000000..3671feba5 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/issue/FindingDecisionValidity.java @@ -0,0 +1,98 @@ +package net.modtale.service.security.issue; + +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ArtifactClearancePolicy; +import net.modtale.service.security.scan.ArtifactReviewContext; + +/** Evaluates recorded reasoning; no assessment here grants whole-artifact clearance. */ +final class FindingDecisionValidity { + enum State { + APPLICABLE, REVIEW_REQUIRED, REVOKED, SUPERSEDED, EXPIRED, INVALID_RECORD, + INCOMPLETE_EVIDENCE, ARTIFACT_CHANGED, CONTEXT_CHANGED, POLICY_CHANGED, + POLICY_UNAVAILABLE, FINDING_CHANGED, ADVERSE_EVIDENCE, UNSUPPORTED_SCOPE, REVOCATION + } + record Assessment(State state, String explanation) {} + + Map assess(String projectId, ProjectVersion version, List events, + String currentPolicy, long now) { + var results = new LinkedHashMap(); + var revoked = new HashSet(); + var superseded = new HashSet(); + var scan = version == null ? null : version.getScanResult(); + var evidence = scan == null ? null : scan.getSecurityEvidence(); + boolean complete = ArtifactClearancePolicy.complete(scan); + var identities = IssueEvidenceIdentity.from(scan); + var occurrences = new HashMap(); + if (scan != null && scan.getIssues() != null) for (var issue : scan.getIssues()) { + String identity = identities.identify(issue); + if (identity != null) occurrences.put(identity, issue); + } + String context = ArtifactReviewContext.fingerprint(version); + // The caller supplies the complete, linked newest-first chain. Revoked superseding decisions + // do not resurrect earlier conclusions: a fresh explicit decision is required. + for (var event : events) { + if (event.revokedDecisionId() != null) revoked.add(event.revokedDecisionId()); + if (event.supersedesDecisionId() != null) superseded.add(event.supersedesDecisionId()); + } + for (var event : events) { + State state; + if (!validRecord(event, projectId, version, now)) state = State.INVALID_RECORD; + else if (event.disposition() == FindingReviewService.Disposition.REVOKE) state = State.REVOCATION; + else if (revoked.contains(event.id())) state = State.REVOKED; + else if (superseded.contains(event.id())) state = State.SUPERSEDED; + else if (event.disposition() == FindingReviewService.Disposition.REQUIRE_REVIEW) state = State.REVIEW_REQUIRED; + else if (event.expiresAt() <= now) state = State.EXPIRED; + else if (!"WHOLE_ARTIFACT".equals(event.scope())) state = State.UNSUPPORTED_SCOPE; + else if (!complete) state = State.INCOMPLETE_EVIDENCE; + else if ("BLOCK".equals(scan.getVerdict()) || scan.getStatus() == ScanStatus.INFECTED + || "NEW_SECURITY_EVIDENCE".equals(evidence.reviewState())) state = State.ADVERSE_EVIDENCE; + else if (!Objects.equals(version.getHash(), evidence.artifactSha256()) + || !event.contentSha256().equals(evidence.contentSha256())) state = State.ARTIFACT_CHANGED; + else if (context == null || !context.equals(scan.getReviewedContextSha256()) + || !context.equals(event.contextSha256())) state = State.CONTEXT_CHANGED; + else if (currentPolicy == null) state = State.POLICY_UNAVAILABLE; + else if (!currentPolicy.equals(evidence.policyVersion()) || !currentPolicy.equals(event.policyVersion())) state = State.POLICY_CHANGED; + else if (!sameFinding(event.finding(), occurrences.get(event.finding().identity()))) state = State.FINDING_CHANGED; + else state = State.APPLICABLE; + results.put(event.id(), new Assessment(state, explanation(state))); + } + return Collections.unmodifiableMap(results); + } + private boolean sameFinding(FindingReviewService.Finding recorded, ScanResult.ScanIssue current) { + return current != null && Objects.equals(recorded.path(), current.getFilePath()) + && Objects.equals(recorded.type(), current.getType()) && Objects.equals(recorded.description(), current.getDescription()) + && recorded.lineStart() == current.getLineStart() && recorded.lineEnd() == current.getLineEnd(); + } + boolean validRecord(FindingReviewService.Event event, String projectId, ProjectVersion version, long now) { + if (version == null || !Objects.equals(projectId, event.projectId()) || !Objects.equals(version.getId(), event.versionId()) + || event.disposition() == null || event.actorId() == null || event.actorId().isBlank() || event.actorId().length() > 256 + || event.rationale() == null || event.rationale().strip().length() < 10 || event.rationale().length() > 4000 || event.createdAt() <= 0 + || event.createdAt() > now || !SecurityManifest.digest(event.artifactSha256()) + || !SecurityManifest.digest(event.contentSha256()) || !SecurityManifest.digest(event.contextSha256()) + || event.policyVersion() == null || !event.policyVersion().matches("warden-3\\.0\\.0:[0-9a-f]{64}") + || event.finding() == null || event.finding().identity() == null + || !event.finding().identity().matches("ie1:[0-9a-f]{64}")) return false; + return event.disposition() != FindingReviewService.Disposition.ACCEPT + || event.expiresAt() > event.createdAt() && event.expiresAt() - event.createdAt() <= 30L * 86400000; + } + private String explanation(State state) { + return switch (state) { + case APPLICABLE -> "The recorded finding, complete artifact contents, context and current policy still match. This acceptance covers the finding, not publication."; + case REVIEW_REQUIRED -> "A reviewer requested further investigation. This requirement does not expire automatically."; + case REVOKED -> "A later decision revoked this conclusion. It cannot be reused."; + case SUPERSEDED -> "A later conclusion replaced this decision. Revoking its replacement does not restore it."; + case EXPIRED -> "The acceptance has expired; inspect the current evidence again."; + case INVALID_RECORD -> "The decision lacks valid provenance or time bounds."; + case INCOMPLETE_EVIDENCE -> "Current complete, verified evidence is unavailable. Earlier reasoning cannot fill this gap."; + case ARTIFACT_CHANGED -> "The artifact identity or contents changed. This decision covers the whole artifact, including callers and resources."; + case CONTEXT_CHANGED -> "The runtime, dependencies, supplemental content or reviewed context no longer matches."; + case POLICY_CHANGED -> "The scanner policy changed; the earlier conclusion needs evaluation under the current policy."; + case POLICY_UNAVAILABLE -> "The current scanner policy could not be verified."; + case FINDING_CHANGED -> "The exact finding is absent or changed in the current scan."; + case ADVERSE_EVIDENCE -> "The current scan contains adverse evidence that prevents retaining this acceptance."; + case UNSUPPORTED_SCOPE -> "This decision scope is not supported by the current evaluator."; + case REVOCATION -> "This event records a revocation and grants no acceptance."; + }; + } +} diff --git a/backend/src/main/java/net/modtale/service/security/issue/FindingReviewHistory.java b/backend/src/main/java/net/modtale/service/security/issue/FindingReviewHistory.java new file mode 100644 index 000000000..465cd0a79 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/issue/FindingReviewHistory.java @@ -0,0 +1,66 @@ +package net.modtale.service.security.issue; + +import java.util.*; +import net.modtale.model.project.ProjectVersion; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** Reads only the immutable history reachable from the version's authoritative head. */ +public final class FindingReviewHistory { + public static final int MAX_EVENTS = 1000; + private FindingReviewHistory() {} + + public static List load(MongoTemplate mongo, String projectId, String versionId, String head) { + var events = new ArrayList(); + var visited = new HashSet(); + Integer expected = null; + for (String id = head; id != null;) { + if (!visited.add(id) || events.size() >= MAX_EVENTS) throw unavailable(); + var event = mongo.findById(id, FindingReviewService.Event.class, FindingReviewService.COLLECTION); + if (event == null || !id.equals(event.id()) || !projectId.equals(event.projectId()) + || !versionId.equals(event.versionId()) || event.sequence() <= 0 || event.sequence() > MAX_EVENTS + || expected != null && event.sequence() != expected) throw unavailable(); + expected = event.sequence() - 1; + events.add(event); id = event.previousId(); + } + if (expected != null && expected != 0) throw unavailable(); + var older = new HashMap(); + for (int i = events.size() - 1; i >= 0; i--) { + var event = events.get(i); + if (event.disposition() == null) throw unavailable(); + if (event.disposition() == FindingReviewService.Disposition.REVOKE) { + var target = older.get(event.revokedDecisionId()); + if (target == null || target.disposition() == FindingReviewService.Disposition.REVOKE + || event.supersedesDecisionId() != null) throw unavailable(); + } else { + if (event.revokedDecisionId() != null) throw unavailable(); + if (event.supersedesDecisionId() != null) { + var target = older.get(event.supersedesDecisionId()); + if (target == null || target.disposition() == FindingReviewService.Disposition.REVOKE + || event.finding() == null || target.finding() == null + || !Objects.equals(event.finding().identity(), target.finding().identity()) + || !Objects.equals(event.contentSha256(), target.contentSha256()) + || !Objects.equals(event.contextSha256(), target.contextSha256())) throw unavailable(); + } + } + older.put(event.id(), event); + } + return List.copyOf(events); + } + + /** This gate removes no findings and grants no automatic clearance. The caller must CAS the head. */ + public static void requireManualApproval(MongoTemplate mongo, String projectId, ProjectVersion version) { + var events = load(mongo, projectId, version.getId(), version.getFindingReviewHead()); + var assessments = new FindingDecisionValidity().assess(projectId, version, events, null, System.currentTimeMillis()); + if (assessments.values().stream().anyMatch(value -> value.state() == FindingDecisionValidity.State.INVALID_RECORD)) + throw unavailable(); + if (assessments.values().stream().anyMatch(value -> value.state() == FindingDecisionValidity.State.REVIEW_REQUIRED)) + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Resolve or explicitly revoke the outstanding finding-review requirements before approving this version."); + } + + private static ResponseStatusException unavailable() { + return new ResponseStatusException(HttpStatus.CONFLICT, "Decision history is incomplete or inconsistent"); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/issue/FindingReviewService.java b/backend/src/main/java/net/modtale/service/security/issue/FindingReviewService.java new file mode 100644 index 000000000..265cc5482 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/issue/FindingReviewService.java @@ -0,0 +1,147 @@ +package net.modtale.service.security.issue; + +import java.time.Instant; +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.service.admin.review.VersionReviewPersistence; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.ArtifactReviewContext; +import net.modtale.service.security.scan.ArtifactClearancePolicy; +import net.modtale.service.security.scan.WardenClientService; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class FindingReviewService { + public static final String COLLECTION = "finding_review_events"; + private static final int MAX_EVENTS = FindingReviewHistory.MAX_EVENTS; + private final MongoTemplate mongo; + private final VersionReviewPersistence persistence; + private final ProjectService projects; + private final WardenClientService warden; + + public FindingReviewService(MongoTemplate mongo, VersionReviewPersistence persistence, ProjectService projects, WardenClientService warden) { + this.mongo = mongo; this.persistence = persistence; this.projects = projects; this.warden = warden; + } + + public enum Disposition { ACCEPT, REQUIRE_REVIEW, REVOKE } + public record Request(Integer issueIndex, Disposition disposition, String rationale) {} + public record Finding(String identity, String path, String type, String description, int lineStart, int lineEnd) {} + public record Event(@Id String id, String projectId, String versionId, String previousId, int sequence, + String actorId, long createdAt, long expiresAt, Disposition disposition, String rationale, + String scope, String artifactSha256, String contentSha256, String policyVersion, + String contextSha256, Finding finding, String revokedDecisionId, String supersedesDecisionId) {} + public record DecisionAssessment(String state, String explanation) {} + public record History(List events, Integer nextOffset, Map assessments, long assessedAt) {} + + public Event record(String projectId, String versionId, String token, String actorId, Request request) { + if (request == null || request.disposition() == null || request.disposition() == Disposition.REVOKE) + throw invalid("Choose an acceptance or a requirement for further review"); + validateText(actorId, request.rationale()); + var snapshot = persistence.capture(projectId, versionId, token); + var version = mongo.getConverter().read(ProjectVersion.class, snapshot.version()); + var scan = version.getScanResult(); + if (scan == null || scan.getIssues() == null || request.issueIndex() == null || request.issueIndex() < 0 || request.issueIndex() >= scan.getIssues().size()) + throw invalid("Select a finding from the inspected scan"); + var issue = scan.getIssues().get(request.issueIndex()); + var evidence = scan.getSecurityEvidence(); + String context = ArtifactReviewContext.fingerprint(version); + String identity = IssueEvidenceIdentity.from(scan).identify(issue); + if (identity == null || evidence == null || !Objects.equals(version.getHash(), evidence.artifactSha256()) + || context == null || !context.equals(scan.getReviewedContextSha256())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "Complete artifact and context evidence is required to record this decision"); + if (issue.getDescription() != null && issue.getDescription().length() > 16000) + throw invalid("The finding exceeds the supported decision record size"); + var previous = head(projectId, versionId, version.getFindingReviewHead()); + String supersedes = chain(projectId, versionId, previous).stream() + .filter(event -> event.disposition() != Disposition.REVOKE && event.finding() != null + && identity.equals(event.finding().identity()) + && evidence.contentSha256().equals(event.contentSha256()) && context.equals(event.contextSha256())) + .map(Event::id).findFirst().orElse(null); + long now = Instant.now().toEpochMilli(); + var event = new Event(UUID.randomUUID().toString(), projectId, versionId, version.getFindingReviewHead(), + nextSequence(previous), actorId, now, request.disposition() == Disposition.ACCEPT ? now + 30L * 86_400_000 : 0, + request.disposition(), request.rationale().strip(), + "WHOLE_ARTIFACT", evidence.artifactSha256(), evidence.contentSha256(), evidence.policyVersion(), context, + new Finding(identity, issue.getFilePath(), issue.getType(), issue.getDescription(), issue.getLineStart(), issue.getLineEnd()), null, supersedes); + return persist(snapshot, event); + } + + public Event revoke(String projectId, String versionId, String token, String actorId, String decisionId, String rationale) { + validateText(actorId, rationale); + var snapshot = persistence.capture(projectId, versionId, token); + var version = mongo.getConverter().read(ProjectVersion.class, snapshot.version()); + var previous = head(projectId, versionId, version.getFindingReviewHead()); + Event target = null; + for (Event event : chain(projectId, versionId, previous)) { + if (Objects.equals(event.revokedDecisionId(), decisionId)) throw invalid("This decision was already revoked"); + if (event.id().equals(decisionId)) { target = event; break; } + } + if (target == null || target.disposition() == Disposition.REVOKE) throw invalid("Select an active history decision to revoke"); + var event = new Event(UUID.randomUUID().toString(), projectId, versionId, version.getFindingReviewHead(), + nextSequence(previous), actorId, Instant.now().toEpochMilli(), target.expiresAt(), Disposition.REVOKE, + rationale.strip(), target.scope(), target.artifactSha256(), target.contentSha256(), target.policyVersion(), + target.contextSha256(), target.finding(), target.id(), null); + return persist(snapshot, event); + } + + public History history(String projectId, String versionId, String token, int offset) { + if (offset < 0 || offset >= MAX_EVENTS) throw invalid("Invalid history offset"); + var snapshot = persistence.capture(projectId, versionId, token); + var version = mongo.getConverter().read(ProjectVersion.class, snapshot.version()); + var events = chain(projectId, versionId, head(projectId, versionId, version.getFindingReviewHead())); + int end = Math.min(events.size(), offset + 50); + var page = List.copyOf(events.subList(Math.min(offset, events.size()), end)); + String policy = ArtifactClearancePolicy.complete(version.getScanResult()) && !events.isEmpty() + ? warden.currentPolicyVersion() : null; + long assessedAt = Instant.now().toEpochMilli(); + var validity = new FindingDecisionValidity().assess(projectId, version, events, policy, assessedAt); + var assessments = new LinkedHashMap(); + for (var event : events) { + var assessment = validity.get(event.id()); + assessments.put(event.id(), new DecisionAssessment(assessment.state().name(), assessment.explanation())); + } + // Policy lookup and history reads must not conceal a concurrent change to this version. + persistence.capture(projectId, versionId, token); + return new History(page, end < events.size() ? end : null, Map.copyOf(assessments), assessedAt); + } + + private Event persist(VersionReviewPersistence.Snapshot snapshot, Event event) { + // Only a successfully linked event is active; interrupted or losing inserts are unreachable history. + mongo.insert(event, COLLECTION); + if (!persistence.appendFindingReview(snapshot, event.id())) throw VersionReviewPersistence.conflict(); + var project = projects.getRawProjectById(event.projectId()); + if (project != null) projects.evictProjectCache(project); + return event; + } + private Event head(String projectId, String versionId, String id) { + if (id == null) return null; + var event = mongo.findById(id, Event.class, COLLECTION); + if (event == null || !projectId.equals(event.projectId()) || !versionId.equals(event.versionId()) + || event.sequence() <= 0 || event.sequence() > MAX_EVENTS) throw unavailable(); + return event; + } + private List chain(String projectId, String versionId, Event head) { + return FindingReviewHistory.load(mongo, projectId, versionId, head == null ? null : head.id()); + } + + private int nextSequence(Event previous) { + if (previous != null && previous.sequence() >= MAX_EVENTS) + throw new ResponseStatusException(HttpStatus.CONFLICT, "This version reached the decision history limit"); + return previous == null ? 1 : previous.sequence() + 1; + } + private static void validateText(String actorId, String rationale) { + if (actorId == null || actorId.isBlank() || actorId.length() > 256) throw invalid("A reviewer identity is required"); + if (rationale == null || rationale.strip().length() < 10 || rationale.length() > 4000) + throw invalid("Explain the decision in 10 to 4000 characters"); + } + private static ResponseStatusException invalid(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + private static ResponseStatusException unavailable() { + return new ResponseStatusException(HttpStatus.CONFLICT, "Decision history is incomplete or inconsistent"); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/issue/IssueEvidenceIdentity.java b/backend/src/main/java/net/modtale/service/security/issue/IssueEvidenceIdentity.java new file mode 100644 index 000000000..ce427bd63 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/issue/IssueEvidenceIdentity.java @@ -0,0 +1,59 @@ +package net.modtale.service.security.issue; + +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Map; +import net.modtale.model.project.ScanResult; +import net.modtale.service.security.scan.ArtifactClearancePolicy; + +/** Exact local evidence comparison only; it does not establish cross-file safety or clearance. */ +final class IssueEvidenceIdentity { + private final String policy; + private final Map entries; + + private IssueEvidenceIdentity(String policy, Map entries) { + this.policy = policy; + this.entries = entries; + } + + static IssueEvidenceIdentity from(ScanResult scan) { + if (!ArtifactClearancePolicy.complete(scan)) return new IssueEvidenceIdentity(null, Map.of()); + return new IssueEvidenceIdentity(scan.getSecurityEvidence().policyVersion(), + Map.copyOf(scan.getSecurityEvidence().entryHashes())); + } + + String identify(ScanResult.ScanIssue issue) { + if (policy == null || issue == null || issue.getFilePath() == null) return null; + String fileHash = entries.get(issue.getFilePath()); + if (fileHash == null) return null; + try { + MessageDigest hash = MessageDigest.getInstance("SHA-256"); + for (String value : new String[]{"finding-evidence-v1", policy, issue.getFilePath(), fileHash, + issue.getType(), issue.getCategory(), issue.getDescription(), issue.getSeverity(), + Integer.toString(issue.getLineStart()), Integer.toString(issue.getLineEnd()), + Integer.toString(issue.getScoreImpact()), Integer.toString(issue.getConfidence()), + issue.getEvidenceLevel(), issue.getReviewCadence(), issue.getReviewPriority(), + Boolean.toString(issue.isNoiseSuppressed())}) { + append(hash, value); + } + hash.update(ByteBuffer.allocate(4).putInt(issue.getTactics().size()).array()); + for (String tactic : issue.getTactics()) append(hash, tactic); + return "ie1:" + HexFormat.of().formatHex(hash.digest()); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } + + private static void append(MessageDigest hash, String value) { + hash.update(ByteBuffer.allocate(4).putInt(value == null ? -1 : value.length()).array()); + if (value == null) return; + // Preserve exact Java strings, including unpaired surrogates, without replacement encoding. + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + hash.update((byte) (character >>> 8)); + hash.update((byte) character); + } + } +} diff --git a/backend/src/main/java/net/modtale/service/security/issue/PriorFindingReasoningService.java b/backend/src/main/java/net/modtale/service/security/issue/PriorFindingReasoningService.java new file mode 100644 index 000000000..61676907f --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/issue/PriorFindingReasoningService.java @@ -0,0 +1,74 @@ +package net.modtale.service.security.issue; + +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.service.admin.review.ProjectReviewSnapshot; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +/** Read-only historical context. Matching reasoning never resolves a current finding. */ +@Service +public class PriorFindingReasoningService { + private final ProjectService projects; + private final MongoTemplate mongo; + public PriorFindingReasoningService(ProjectService projects, MongoTemplate mongo) { this.projects=projects;this.mongo=mongo; } + public record Reasoning(String reviewToken, String sourceVersionId, String sourceVersion, long assessedAt, + List reviewReasons, List decisions, int omitted) {} + public Reasoning read(String projectId,String targetId,String sourceId,int issueIndex,String token) { + var project=projects.getRawProjectById(projectId); + if(project==null || project.getVersions()==null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); + ProjectReviewSnapshot.requireCurrent(project,token); + var target=version(project,targetId);var source=version(project,sourceId); + if(Objects.equals(sourceId,targetId)) throw unavailable(); + var scan=target.getScanResult(); + if(scan==null || scan.getIssues()==null || issueIndex<0 || issueIndex>=scan.getIssues().size()) throw unavailable(); + var issue=scan.getIssues().get(issueIndex); + String identity=IssueEvidenceIdentity.from(scan).identify(issue); + String context=ArtifactReviewContext.fingerprint(target); + if(identity==null || !Objects.equals(target.getHash(),scan.getSecurityEvidence().artifactSha256()) + || context==null || !context.equals(scan.getReviewedContextSha256())) throw unavailable(); + if(source.getFindingReviewHead()==null || ArtifactReviewLineage.extend(project,source)==null) throw unavailable(); + var approved=source.getApprovedSecurityEvidence(); + var events=FindingReviewHistory.load(mongo,projectId,sourceId,source.getFindingReviewHead()); + long now=System.currentTimeMillis(); + var removed=new HashSet(); + for(var event:events) { + if(!new FindingDecisionValidity().validRecord(event,projectId,source,now)) throw unavailable(); + if(event.revokedDecisionId()!=null) removed.add(event.revokedDecisionId()); + if(event.supersedesDecisionId()!=null) removed.add(event.supersedesDecisionId()); + } + if(events.stream().anyMatch(e->e.disposition()==FindingReviewService.Disposition.REQUIRE_REVIEW && !removed.contains(e.id()))) throw unavailable(); + boolean approvedOccurrence=source.getApprovedIssueBaselines()!=null && source.getApprovedIssueBaselines().stream() + .filter(Objects::nonNull).anyMatch(value->identity.equals(value.getEvidenceIdentity())); + var matched=events.stream().filter(e->approvedOccurrence && e.disposition()==FindingReviewService.Disposition.ACCEPT + && !removed.contains(e.id()) && "WHOLE_ARTIFACT".equals(e.scope()) + && identity.equals(e.finding().identity()) && Objects.equals(issue.getFilePath(),e.finding().path()) + && Objects.equals(issue.getType(),e.finding().type()) && Objects.equals(issue.getDescription(),e.finding().description()) + && issue.getLineStart()==e.finding().lineStart() && issue.getLineEnd()==e.finding().lineEnd() + && approved.contentSha256().equals(e.contentSha256()) && approved.policyVersion().equals(e.policyVersion()) + && source.getApprovedSecurityContextSha256().equals(e.contextSha256()) + && e.createdAt()<=source.getSecurityApprovedAt()).toList(); + var reasons=new ArrayList(); + if(!approved.contentSha256().equals(scan.getSecurityEvidence().contentSha256())) reasons.add("Artifact contents changed; callers and resources need current review."); + if(!source.getApprovedSecurityContextSha256().equals(context)) reasons.add("Runtime, dependency or manifest context changed."); + if("BLOCK".equals(scan.getVerdict()) || scan.getStatus()==ScanStatus.INFECTED + || "NEW_SECURITY_EVIDENCE".equals(scan.getSecurityEvidence().reviewState())) reasons.add("Current adverse evidence prevents retaining an acceptance."); + if(matched.stream().anyMatch(e->e.expiresAt()<=now)) reasons.add("Some earlier acceptances have expired."); + reasons.add("Historical reasoning only. Current scope, scanner policy and publication requirements still need evaluation."); + var current=projects.getRawProjectById(projectId); + if(current==null) throw ProjectReviewSnapshot.conflict(); + ProjectReviewSnapshot.requireCurrent(current,token); + return new Reasoning(token,sourceId,source.getVersionNumber(),now,List.copyOf(reasons),matched.stream().limit(20).toList(),Math.max(0,matched.size()-20)); + } + private ProjectVersion version(Project project,String id) { + var versions=project.getVersions().stream().filter(Objects::nonNull).filter(v->Objects.equals(id,v.getId())).toList(); + if(id==null || versions.size()!=1) throw unavailable();return versions.getFirst(); + } + private ResponseStatusException unavailable() { + return new ResponseStatusException(HttpStatus.CONFLICT,"Verified prior reasoning is unavailable for this evidence and source approval. Refresh the review or inspect source history."); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueAnalysisService.java b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueAnalysisService.java index 546092797..2d7cd681c 100644 --- a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueAnalysisService.java +++ b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueAnalysisService.java @@ -56,8 +56,14 @@ public record IssueBaseline( String versionId, String versionNumber, long lastSeenTimestamp, - int seenCount + int seenCount, + String evidenceIdentity ) { + public IssueBaseline(String fingerprint, String looseFingerprint, String severity, int scoreImpact, + int confidence, String versionId, String versionNumber, long lastSeenTimestamp, int seenCount) { + this(fingerprint, looseFingerprint, severity, scoreImpact, confidence, versionId, versionNumber, + lastSeenTimestamp, seenCount, null); + } IssueBaseline mergeWith(IssueBaseline other) { if (other == null) return this; @@ -74,7 +80,8 @@ IssueBaseline mergeWith(IssueBaseline other) { thisLatest ? this.versionId : other.versionId, thisLatest ? this.versionNumber : other.versionNumber, Math.max(this.lastSeenTimestamp, other.lastSeenTimestamp), - this.seenCount + other.seenCount + this.seenCount + other.seenCount, + thisLatest ? this.evidenceIdentity : other.evidenceIdentity ); } diff --git a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueApprovalService.java b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueApprovalService.java index 197eea401..29aae1eab 100644 --- a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueApprovalService.java +++ b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueApprovalService.java @@ -21,13 +21,27 @@ public SecurityIssueApprovalService(SecurityIssueClassificationService securityI } public void markIssuesAcceptedForApprovedVersion(ProjectVersion version) { - if (version == null || version.getScanResult() == null) return; + if (version == null) return; + // Only the conditional manual approval writer may attest a reviewed finding-history head. + version.setApprovedFindingReviewHead(null); + if (version.getScanResult() == null) { + version.setSecurityApprovalProjectId(null); + version.setApprovedReviewOrigins(null); + version.setApprovedSecurityEvidence(null); + version.setApprovedSecurityContextSha256(null); + version.setSecurityApprovedAt(0); + version.setApprovedIssueBaselines(null); + return; + } ScanResult scanResult = version.getScanResult(); securityIssueClassificationService.normalizeScanResult(scanResult); List issues = scanResult.getIssues(); List approvedIssueBaselines = new ArrayList<>(); long approvedAt = approvedAt(version); + var identities = IssueEvidenceIdentity.from(java.util.Objects.equals(version.getHash(), + scanResult.getSecurityEvidence() == null ? null : scanResult.getSecurityEvidence().artifactSha256()) + ? scanResult : null); for (ScanResult.ScanIssue issue : issues) { if (issue == null) continue; @@ -49,19 +63,38 @@ public void markIssuesAcceptedForApprovedVersion(ProjectVersion version) { issue.setBaselineScoreImpact(Math.max(0, issue.getScoreImpact())); } - approvedIssueBaselines.add(new ProjectVersion.ApprovedIssueBaseline( + var stored = new ProjectVersion.ApprovedIssueBaseline( fingerprint, looseFingerprint, severity, Math.max(0, issue.getScoreImpact()), Math.max(0, issue.getConfidence()), approvedAt - )); + ); + stored.setEvidenceIdentity(identities.identify(issue)); + approvedIssueBaselines.add(stored); } scanResult.setKnownIssueCount(issues.size()); scanResult.setNewIssueCount(0); scanResult.setEscalatedIssueCount(0); + var evidence = scanResult.getSecurityEvidence(); + boolean reusableEvidence = net.modtale.service.security.scan.ArtifactClearancePolicy.complete(scanResult) + && java.util.Objects.equals(version.getHash(), evidence.artifactSha256()) + && java.util.Objects.equals(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(version), + scanResult.getReviewedContextSha256()); + reusableEvidence = reusableEvidence && (scanResult.getReusedReviewVersion() == null + || net.modtale.service.security.scan.ArtifactReviewLineage.wellFormed(scanResult.getReusedReviewOrigins()) + && !scanResult.getReusedReviewOrigins().isEmpty()); + if (!reusableEvidence) version.setSecurityApprovalProjectId(null); + version.setApprovedReviewOrigins(!reusableEvidence ? null : scanResult.getReusedReviewVersion() == null + ? java.util.Map.of() : java.util.Map.copyOf(scanResult.getReusedReviewOrigins())); + version.setApprovedSecurityEvidence(!reusableEvidence ? null : new ScanResult.SecurityEvidence( + evidence.policyVersion(), evidence.artifactSha256(), evidence.contentSha256(), evidence.complete(), + evidence.clearanceGranted(), evidence.reviewState(), java.util.Map.of())); + version.setApprovedSecurityContextSha256(reusableEvidence ? scanResult.getReviewedContextSha256() : null); + version.setSecurityApprovedAt(!reusableEvidence ? 0 : scanResult.getReusedReviewApprovedAt() > 0 + ? scanResult.getReusedReviewApprovedAt() : Instant.now().toEpochMilli()); version.setApprovedIssueBaselines(approvedIssueBaselines); version.setScanResult(null); } @@ -78,6 +111,7 @@ public int pruneApprovedScanResults(Project project) { || version.getScanResult() == null) { continue; } + version.setSecurityApprovalProjectId(project.getId()); markIssuesAcceptedForApprovedVersion(version); pruned++; } diff --git a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueBaselineService.java b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueBaselineService.java index 5429bbb7a..f45e2041a 100644 --- a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueBaselineService.java +++ b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueBaselineService.java @@ -47,6 +47,9 @@ SecurityIssueAnalysisService.BaselineIndex collectApprovedIssueBaselines( continue; } + var identities = IssueEvidenceIdentity.from(java.util.Objects.equals(version.getHash(), + scanResult.getSecurityEvidence() == null ? null : scanResult.getSecurityEvidence().artifactSha256()) + ? scanResult : null); for (ScanResult.ScanIssue issue : scanResult.getIssues()) { if (issue == null) { continue; @@ -64,7 +67,8 @@ SecurityIssueAnalysisService.BaselineIndex collectApprovedIssueBaselines( version.getId(), version.getVersionNumber(), approvedAt, - 1 + 1, + identities.identify(issue) ); exactBaselines.merge(fingerprint, candidate, SecurityIssueAnalysisService.IssueBaseline::mergeWith); @@ -103,7 +107,8 @@ private boolean collectStoredBaselines( version.getId(), version.getVersionNumber(), storedBaseline.getApprovedAt() > 0 ? storedBaseline.getApprovedAt() : approvedAt, - 1 + 1, + storedBaseline.getEvidenceIdentity() ); exactBaselines.merge(candidate.fingerprint(), candidate, SecurityIssueAnalysisService.IssueBaseline::mergeWith); diff --git a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueEvaluationService.java b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueEvaluationService.java index 3b562a53c..ab67fa529 100644 --- a/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueEvaluationService.java +++ b/backend/src/main/java/net/modtale/service/security/issue/SecurityIssueEvaluationService.java @@ -28,6 +28,7 @@ SecurityIssueAnalysisService.ClassificationStats annotateAgainstBaselines( ? new SecurityIssueAnalysisService.BaselineIndex(new HashMap<>(), new HashMap<>()) : approvedBaselines; + var identities = IssueEvidenceIdentity.from(scanResult); int known = 0; int fresh = 0; int escalated = 0; @@ -40,6 +41,7 @@ SecurityIssueAnalysisService.ClassificationStats annotateAgainstBaselines( continue; } + issue.setHistoricalFileEvidenceIdentical(false); String fingerprint = fingerprint(issue); String looseFingerprint = looseFingerprint(issue); issue.setFingerprint(fingerprint); @@ -63,6 +65,9 @@ SecurityIssueAnalysisService.ClassificationStats annotateAgainstBaselines( continue; } + String identity = identities.identify(issue); + issue.setHistoricalFileEvidenceIdentical(!looseMatch && identity != null + && identity.equals(baseline.evidenceIdentity())); issue.setKnownIssue(true); issue.setBaselineVersion(baseline.versionNumber()); issue.setBaselineScoreImpact(baseline.scoreImpact()); @@ -86,7 +91,7 @@ SecurityIssueAnalysisService.ClassificationStats annotateAgainstBaselines( || alwaysReview; issue.setEscalated(escalatedIssue); - issue.setResolved(!escalatedIssue); + issue.setResolved(false); if (escalatedIssue) { escalated++; @@ -131,8 +136,11 @@ void normalizeScanResult(ScanResult scanResult) { scanResult.setStatus(ScanStatus.INFECTED); } else if ("REVIEW".equalsIgnoreCase(scanResult.getVerdict())) { scanResult.setStatus(ScanStatus.SUSPICIOUS); - } else { + } else if ("AUTO_APPROVE".equalsIgnoreCase(scanResult.getVerdict())) { scanResult.setStatus(ScanStatus.CLEAN); + } else { + scanResult.setStatus(ScanStatus.SUSPICIOUS); + scanResult.setVerdict("REVIEW"); } } } @@ -183,46 +191,11 @@ private String normalizeToken(String value) { } private String normalizePathForFingerprint(String path) { - if (path == null || path.isBlank()) { - return "archive-root"; - } - - String normalized = path.replace('\\', '/').toLowerCase(Locale.ROOT); - String[] nestedParts = normalized.split("->"); - String tail = nestedParts[nestedParts.length - 1].trim(); - if (tail.isBlank()) { - tail = normalized; - } - - String[] segments = tail.split("/"); - StringBuilder canonical = new StringBuilder(); - int start = Math.max(0, segments.length - 4); - for (int i = start; i < segments.length; i++) { - String segment = segments[i].replaceAll("\\s+", ""); - if (segment.isBlank()) { - continue; - } - if (canonical.length() > 0) { - canonical.append('/'); - } - canonical.append(segment); - } - - String out = canonical.length() == 0 ? "archive-root" : canonical.toString(); - return out.replaceAll("[^a-z0-9./$:_-]+", ""); + return path == null || path.isBlank() ? "archive-root" : path; } private String normalizeDescription(String text) { - if (text == null || text.isBlank()) { - return "na"; - } - String normalized = text.toLowerCase(Locale.ROOT); - normalized = normalized.replaceAll("0x[0-9a-f]+", "0x#"); - normalized = normalized.replaceAll("\\b\\d{1,3}(?:\\.\\d{1,3}){3}\\b", "ip#"); - normalized = normalized.replaceAll("\\d+", "#"); - normalized = normalized.replaceAll("[^a-z0-9#:/._-]+", " "); - normalized = normalized.trim().replaceAll("\\s{2,}", " "); - return normalized; + return text == null || text.isBlank() ? "na" : text; } private String hash(String input) { @@ -233,9 +206,9 @@ private String hash(String input) { for (byte b : bytes) { out.append(String.format("%02x", b)); } - return out.substring(0, 24); + return out.toString(); } catch (NoSuchAlgorithmException e) { - return Integer.toHexString(input.hashCode()); + throw new IllegalStateException("Required fingerprint digest unavailable", e); } } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ArtifactClearancePolicy.java b/backend/src/main/java/net/modtale/service/security/scan/ArtifactClearancePolicy.java new file mode 100644 index 000000000..945c81e14 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/ArtifactClearancePolicy.java @@ -0,0 +1,33 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import java.util.*; + +public final class ArtifactClearancePolicy { + private ArtifactClearancePolicy() {} + public static boolean complete(ScanResult result) { + if (result == null || !result.isArtifactVerified() || !"COMPLETED".equals(result.getScanState())) return false; + var summary = result.getSummary(); + var evidence = result.getSecurityEvidence(); + if (summary == null || summary.getRecoverableErrors() != 0 || summary.getOversizedEntriesSkipped() != 0 + || summary.getNestedArchiveReadFailures() != 0 || summary.getFilesScanned() <= 0 + || evidence == null || !evidence.complete() || (evidence.policyVersion() == null || !evidence.policyVersion().matches("warden-3\\.0\\.0:[0-9a-f]{64}")) + || !digest(evidence.artifactSha256()) || !digest(evidence.contentSha256()) + || !SecurityManifest.valid(evidence.entryHashes(), false)) return false; + return SecurityManifest.identity(evidence.entryHashes()).equals(evidence.contentSha256()); + } + + public static boolean cleared(ScanResult result) { + if (!complete(result) || "NEW_SECURITY_EVIDENCE".equals(result.getSecurityEvidence().reviewState()) || "BLOCK".equals(result.getVerdict()) || result.getStatus() == ScanStatus.INFECTED) return false; + return ("AUTO_APPROVE".equals(result.getVerdict()) && result.getStatus() == ScanStatus.CLEAN && result.getSecurityEvidence().clearanceGranted()) + || result.getReusedReviewVersion() != null && ArtifactReviewLineage.wellFormed(result.getReusedReviewOrigins()) + && !result.getReusedReviewOrigins().isEmpty(); + } + public static boolean boundToVersion(ProjectVersion version) { + if (version == null || version.getFindingReviewHead() != null || !cleared(version.getScanResult())) return false; + String context = ArtifactReviewContext.automaticallyReviewableFingerprint(version); + return context != null && context.equals(version.getScanResult().getReviewedContextSha256()) + && Objects.equals(version.getHash(), version.getScanResult().getSecurityEvidence().artifactSha256()); + } + private static boolean digest(String value) { return SecurityManifest.digest(value); } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewContext.java b/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewContext.java new file mode 100644 index 000000000..48eeb0e5c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewContext.java @@ -0,0 +1,50 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import net.modtale.model.project.ProjectVersion; +import java.security.*; +import java.util.*; + +public final class ArtifactReviewContext { + private static final ObjectMapper MAPPER = new ObjectMapper().configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + private ArtifactReviewContext() {} + public static String fingerprint(ProjectVersion version) { + if (version == null || hasSupplementalContent(version)) return null; + try { + List dependencies = new ArrayList<>(); + if (version.getDependencies() != null) for (var dependency : version.getDependencies()) { + if (dependency == null || dependency.isExternal() || dependency.getVersionNumber() == null + || dependency.getVersionNumber().isBlank() || dependency.getProjectId() == null) return null; + dependencies.add(MAPPER.writeValueAsString(Arrays.asList(dependency.getProjectId(), + dependency.getVersionNumber(), dependency.getDependencyType().name(), dependency.getSource().name()))); + } + Collections.sort(dependencies); + List games = new ArrayList<>(version.getGameVersions() == null ? List.of() : version.getGameVersions()); + if (games.stream().anyMatch(Objects::isNull)) return null; + Collections.sort(games); + byte[] canonical = MAPPER.writeValueAsBytes(Arrays.asList("artifact-context-1", games, dependencies, + version.getManifestId(), version.getManifestVersion())); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical)); + } catch (Exception invalidContext) { + return null; + } + } + public static String automaticallyReviewableFingerprint(ProjectVersion version) { + if (version == null || version.getDependencies() != null && !version.getDependencies().isEmpty()) return null; + return fingerprint(version); + } + public static org.springframework.data.mongodb.core.query.Criteria bindSnapshot( + org.springframework.data.mongodb.core.query.Criteria criteria, ProjectVersion version) { + return criteria.and("findingReviewHead").is(version.getFindingReviewHead()).and("gameVersions").is(version.getGameVersions()) + .and("dependencies").is(version.getDependencies()) + .and("manifestId").is(version.getManifestId()) + .and("manifestVersion").is(version.getManifestVersion()) + .and("overrideFileUrl").is(version.getOverrideFileUrl()) + .and("modpackConfigs").is(version.getModpackConfigs()); + } + public static boolean hasSupplementalContent(ProjectVersion version) { + return version.getOverrideFileUrl() != null && !version.getOverrideFileUrl().isBlank() + || version.getModpackConfigs() != null && !version.getModpackConfigs().isEmpty(); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewLineage.java b/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewLineage.java new file mode 100644 index 000000000..c87ed6d97 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewLineage.java @@ -0,0 +1,128 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.security.*; +import java.util.*; +import net.modtale.model.project.*; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.data.mongodb.core.query.*; + +/** Flattened approval ancestry; copied approvals never become independent trust roots. */ +public final class ArtifactReviewLineage { + private static final ObjectMapper JSON = new ObjectMapper().enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private static final List FIELDS = List.of("_id", "hash", "reviewStatus", "findingReviewHead", "approvedFindingReviewHead", + "securityApprovalProjectId", "approvedSecurityEvidence", "approvedSecurityContextSha256", "securityApprovedAt", "approvedReviewOrigins", + "gameVersions", "dependencies", "manifestId", "manifestVersion", "overrideFileUrl", "modpackConfigs"); + private ArtifactReviewLineage() {} + + public static boolean wellFormed(Map origins) { + return origins != null && origins.size() <= 32 && origins.entrySet().stream().allMatch(entry -> + validId(entry.getKey()) && SecurityManifest.digest(entry.getValue())); + } + private static boolean validId(String id) { return id != null && id.matches("[a-zA-Z0-9_-]{1,128}"); } + public static String stamp(ProjectVersion source) { + if (source == null || source.getReviewStatus() != ProjectVersion.ReviewStatus.APPROVED + || !Objects.equals(source.getFindingReviewHead(), source.getApprovedFindingReviewHead()) || !wellFormed(source.getApprovedReviewOrigins())) return null; + var evidence = source.getApprovedSecurityEvidence(); + String context = ArtifactReviewContext.fingerprint(source); + long now = System.currentTimeMillis(); + if (!validId(source.getId()) + || source.getSecurityApprovalProjectId() == null || source.getSecurityApprovalProjectId().isBlank() || evidence == null || !evidence.complete() || context == null + || !context.equals(source.getApprovedSecurityContextSha256()) + || !Objects.equals(source.getHash(), evidence.artifactSha256()) + || !SecurityManifest.digest(evidence.contentSha256()) || !SecurityManifest.digest(evidence.artifactSha256()) + || evidence.policyVersion() == null || !evidence.policyVersion().matches("warden-3\\.0\\.0:[0-9a-f]{64}") + || source.getSecurityApprovedAt() <= 0 || source.getSecurityApprovedAt() > now + || now - source.getSecurityApprovedAt() > 30L * 86400000) return null; + try { + var fields = Arrays.asList("approval-origin-2", source.getFindingReviewHead(), source.getApprovedFindingReviewHead(), source.getSecurityApprovalProjectId(), source.getId(), source.getHash(), evidence.policyVersion(), + evidence.contentSha256(), evidence.reviewState(), evidence.clearanceGranted(), context, + source.getSecurityApprovedAt(), source.getApprovedReviewOrigins()); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(fields))); + } catch (Exception invalid) { return null; } + } + public static boolean valid(Project project, Map origins) { + if (!wellFormed(origins) || project == null || project.getVersions() == null) return false; + var versions = new HashMap(); + for (var version : project.getVersions()) { + if (version == null || version.getId() == null || versions.put(version.getId(), version) != null) return false; + } + for (var origin : origins.entrySet()) { + var source = versions.get(origin.getKey()); + if (source == null || !Objects.equals(project.getId(), source.getSecurityApprovalProjectId()) || !origin.getValue().equals(stamp(source))) return false; + // Every ancestor of every copied source must also appear in this flattened proof. + for (var ancestor : source.getApprovedReviewOrigins().entrySet()) + if (!ancestor.getValue().equals(origins.get(ancestor.getKey()))) return false; + } + return true; + } + public static Map extend(Project project, ProjectVersion source) { + if (project == null || source == null || !Objects.equals(project.getId(), source.getSecurityApprovalProjectId())) return null; + String stamp = stamp(source); + if (stamp == null || !valid(project, source.getApprovedReviewOrigins())) return null; + var origins = new TreeMap<>(source.getApprovedReviewOrigins()); + if (origins.putIfAbsent(source.getId(), stamp) != null || origins.size() > 32) return null; + return Map.copyOf(origins); + } + + public static void invalidate(ScanResult scan) { + scan.setReusedReviewVersion(null); scan.setReusedReviewApprovedAt(0); + if (!"BLOCK".equals(scan.getVerdict()) && scan.getStatus() != ScanStatus.INFECTED) { + scan.setVerdict("REVIEW"); scan.setStatus(ScanStatus.SUSPICIOUS); + } + if (scan.getIssues() != null) for (var issue : scan.getIssues()) if (issue != null) { + issue.setResolved(false); issue.setHistoricalFileEvidenceIdentical(false); + } + var notes = new ArrayList<>(scan.getReviewerNotes() == null ? List.of() : scan.getReviewerNotes()); + notes.add("The source approval changed or is unavailable. A current moderator review is required."); + scan.setReviewerNotes(notes); + } + /** Adds source checks to the same atomic write as publication; false never authorizes a write. */ + public static boolean bind(MongoTemplate mongo, String projectId, ScanResult scan, Query query) { + if (scan.getReusedReviewVersion() == null) return scan.getReusedReviewOrigins() == null || scan.getReusedReviewOrigins().isEmpty(); + var origins = scan.getReusedReviewOrigins(); + if (!wellFormed(origins) || origins.isEmpty()) return false; + var entity = mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var filter = new QueryMapper(mongo.getConverter()).getMappedObject(new Document("_id", projectId), entity); + var raw = mongo.getCollection(mongo.getCollectionName(Project.class)).find(filter).first(); + if (raw == null) return false; + var project = mongo.getConverter().read(Project.class, raw); + var evidence = scan.getSecurityEvidence(); + if (!valid(project, origins) || evidence == null || !SecurityManifest.digest(scan.getReviewedContextSha256())) return false; + for (var source : project.getVersions()) if (origins.containsKey(source.getId())) { + try { + net.modtale.service.security.issue.FindingReviewHistory.requireManualApproval(mongo, projectId, source); + } catch (org.springframework.web.server.ResponseStatusException invalidHistory) { + return false; + } + var prior = source.getApprovedSecurityEvidence(); + if (!Objects.equals(evidence.contentSha256(), prior.contentSha256()) + || !Objects.equals(evidence.policyVersion(), prior.policyVersion()) + || !Objects.equals(scan.getReviewedContextSha256(), source.getApprovedSecurityContextSha256()) + || scan.getReusedReviewApprovedAt() != source.getSecurityApprovedAt()) return false; + } + var checks = new ArrayList(); + for (var item : raw.getList("versions", Document.class)) { + if (!origins.containsKey(Objects.toString(item.get("_id"), null))) continue; + var terms = new ArrayList(); + for (String field : FIELDS) { + var actual = new Document("$ifNull", Arrays.asList("$$origin." + field, null)); + terms.add(new Document("$eq", Arrays.asList(actual, new Document("$literal", item.get(field))))); + } + var databaseNow = new Document("$toLong", "$$NOW"); + terms.add(new Document("$lte", List.of("$$origin.securityApprovedAt", databaseNow))); + terms.add(new Document("$gt", List.of("$$origin.securityApprovedAt", + new Document("$subtract", List.of(databaseNow, 30L * 86400000))))); + var matches = new Document("$filter", new Document("input", "$versions").append("as", "origin") + .append("cond", new Document("$and", terms))); + checks.add(new Document("$eq", List.of(new Document("$size", matches), 1))); + } + if (checks.size() != origins.size()) return false; + // Expressions inspect source versions without competing with the target's positional match. + query.addCriteria(Criteria.where("$expr").is(new Document("$and", checks))); + return true; + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewReuseService.java b/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewReuseService.java new file mode 100644 index 000000000..d57c4a150 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/ArtifactReviewReuseService.java @@ -0,0 +1,54 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import org.springframework.stereotype.Service; +import java.time.Duration; +import java.util.*; + +@Service +public class ArtifactReviewReuseService { + private static final long MAX_AGE_MS = Duration.ofDays(30).toMillis(); + public void annotate(Project project, String currentVersionId, ScanResult result) { + result.setReusedReviewVersion(null); + result.setReusedReviewApprovedAt(0); + var current = result.getSecurityEvidence(); + if (!ArtifactClearancePolicy.complete(result) || ArtifactClearancePolicy.cleared(result) || current == null || !current.complete() + || "NEW_SECURITY_EVIDENCE".equals(current.reviewState()) + || "BLOCK".equals(result.getVerdict()) || result.getStatus() == ScanStatus.INFECTED + || project == null || project.getVersions() == null) return; + ProjectVersion target = project.getVersions().stream().filter(Objects::nonNull) + .filter(version -> Objects.equals(currentVersionId, version.getId())).findFirst().orElse(null); + String context = ArtifactReviewContext.fingerprint(target); + if (context == null || target.getFindingReviewHead() != null) return; + long now = System.currentTimeMillis(); + for (ProjectVersion version : project.getVersions()) { + if (version == null || Objects.equals(version.getId(), currentVersionId) + || version.getReviewStatus() != ProjectVersion.ReviewStatus.APPROVED + || version.getSecurityApprovedAt() <= 0 || version.getSecurityApprovedAt() > now + || now - version.getSecurityApprovedAt() > MAX_AGE_MS) continue; + var prior = version.getApprovedSecurityEvidence(); + if (prior == null || !prior.complete() || current.policyVersion() == null + || !current.policyVersion().equals(prior.policyVersion()) + || current.contentSha256() == null || !current.contentSha256().equals(prior.contentSha256()) + || current.entryHashes() == null || current.entryHashes().isEmpty() + || !context.equals(version.getApprovedSecurityContextSha256()) + || !context.equals(ArtifactReviewContext.fingerprint(version))) continue; + var origins = ArtifactReviewLineage.extend(project, version); + if (origins == null) continue; + result.setReusedReviewVersion(version.getVersionNumber()); + result.setReusedReviewOrigins(origins); + result.setReusedReviewApprovedAt(version.getSecurityApprovedAt()); + for (var issue : result.getIssues()) { + if (issue == null) continue; + issue.setKnownIssue(true); + issue.setEscalated(false); + issue.setResolved(true); + issue.setBaselineVersion(version.getVersionNumber()); + } + result.setKnownIssueCount(result.getIssues().size()); + result.setNewIssueCount(0); + result.setEscalatedIssueCount(0); + return; + } + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewBootstrap.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewBootstrap.java new file mode 100644 index 000000000..4dc50ae96 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewBootstrap.java @@ -0,0 +1,56 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.RemoteReviewBinding; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +@Service +@ConditionalOnProperty(name="app.warden.jobs.enabled",havingValue="true") +public final class RemoteReviewBootstrap { + public record Prepared(String state,RemoteReviewBinding binding) {} + private final RemoteReviewPersistence persistence; + private final RemoteReviewClient client; + private final RemoteReviewStep step; + public RemoteReviewBootstrap(RemoteReviewPersistence persistence,RemoteReviewClient client,RemoteReviewStep step) { + this.persistence=persistence;this.client=client;this.step=step; + } + public Prepared prepare(String projectId,String versionId,int attempt,String requestId) {return prepare(projectId,versionId,attempt,requestId,()->true);} + private Prepared prepare(String projectId,String versionId,int attempt,String requestId,java.util.function.BooleanSupplier running) { + if(!running.getAsBoolean())throw new RemoteReviewClient.Superseded(); + var current=persistence.current(projectId,versionId,attempt,requestId); + if(current==null)return new Prepared("NO_WORK",null); + if(current.getScanResult().getRemoteReview()!=null || "REMOTE_REVIEW".equals(current.getScanResult().getScanState())) + return retainedOrBroken(projectId,versionId,attempt,requestId,running); + String context=ArtifactReviewContext.automaticallyReviewableFingerprint(current); + if(context==null) { + if(!running.getAsBoolean())throw new RemoteReviewClient.Superseded(); + return new Prepared(persistence.finishUnsupportedContext(projectId,current)?"UNAVAILABLE":"NO_WORK",null); + } + if(!running.getAsBoolean())throw new RemoteReviewClient.Superseded(); + var configuration=client.configuration(); + if(!running.getAsBoolean())throw new RemoteReviewClient.Superseded(); + var binding=new RemoteReviewBinding(projectId,versionId,requestId,attempt,current.getFileUrl(),current.getHash(),context, + configuration.policyVersion(),configuration.reviewConfigSha256(),null,current.getScanResult().isManualRescan(),configuration.origin()); + try { persistence.bind(current,binding); } + catch(RuntimeException unknown) { + var recovered=retainedOrBroken(projectId,versionId,attempt,requestId,running); + if(!"NO_WORK".equals(recovered.state()))return recovered; + throw unknown; + } + return retainedOrBroken(projectId,versionId,attempt,requestId,running); + } + private Prepared retainedOrBroken(String projectId,String versionId,int attempt,String requestId,java.util.function.BooleanSupplier running) { + var retained=persistence.retained(projectId,versionId,attempt,requestId); + if(retained!=null && retained.origin()!=null)return new Prepared("READY",retained); + if(!running.getAsBoolean())throw new RemoteReviewClient.Superseded(); + return new Prepared(persistence.finishBrokenBinding(projectId,versionId,attempt,requestId)==null?"NO_WORK":"UNAVAILABLE",null); + } + public RemoteReviewStep.Outcome advance(String projectId,String versionId,int attempt,String requestId) {return advance(projectId,versionId,attempt,requestId,()->true);} + public RemoteReviewStep.Outcome advance(String projectId,String versionId,int attempt,String requestId,java.util.function.BooleanSupplier running) { + try { + var prepared=prepare(projectId,versionId,attempt,requestId,running); + return prepared.binding()==null?new RemoteReviewStep.Outcome(prepared.state(),null):step.advance(prepared.binding(),running); + } catch(RemoteReviewClient.Superseded stopped) {return new RemoteReviewStep.Outcome("SHUTDOWN",null); + } catch(RuntimeException unavailable) {return new RemoteReviewStep.Outcome("RETRY",null);} + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewClient.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewClient.java new file mode 100644 index 000000000..c8db20bdf --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewClient.java @@ -0,0 +1,173 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.core.*; +import com.fasterxml.jackson.databind.*; +import net.modtale.config.properties.AppWardenProperties; +import net.modtale.model.project.*; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.resources.ConnectionProvider; +import reactor.core.publisher.Sinks; +import java.net.URI; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; + +@Service +@ConditionalOnProperty(name="app.warden.jobs.enabled",havingValue="true") +public final class RemoteReviewClient implements AutoCloseable { + public record Configuration(String policyVersion,String reviewConfigSha256,RemoteReviewOrigin origin) {} + public record Status(String jobId,String state,boolean artifactRetained,long createdAt,long expiresAt,String workState) {} + public static final class Unavailable extends RuntimeException { + private final int status; + Unavailable(int status) { super("Remote review service unavailable ("+status+")");this.status=status; } + public int status() { return status; } + } + private final WebClient client; + private final ConnectionProvider connections; + private final Semaphore slots=new Semaphore(2); + private final AtomicBoolean closed=new AtomicBoolean(); + private final Sinks.Empty stop=Sinks.empty(); + private final Duration timeout; + private final ObjectMapper mapper=new ObjectMapper(JsonFactory.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(64).maxStringLength(16*1024*1024).maxNumberLength(20).build()).build()) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + @org.springframework.beans.factory.annotation.Autowired + public RemoteReviewClient(AppWardenProperties properties) { this(properties,Duration.ofSeconds(30)); } + RemoteReviewClient(AppWardenProperties properties,Duration timeout) { + URI base=URI.create(properties.url()); + if (!properties.enabled() || properties.apiKey()==null || properties.apiKey().isBlank() + || base.getHost()==null || !Set.of("http","https").contains(base.getScheme()) || base.getUserInfo()!=null + || base.getQuery()!=null || base.getFragment()!=null || !(base.getPath().isEmpty() || base.getPath().equals("/")) + || timeout.isNegative() || timeout.isZero() || timeout.compareTo(Duration.ofSeconds(30))>0) + throw new IllegalArgumentException("Invalid remote review configuration"); + this.timeout=timeout; + connections=ConnectionProvider.builder("remote-review").maxConnections(2).pendingAcquireMaxCount(2) + .pendingAcquireTimeout(timeout).build(); + var http=reactor.netty.http.client.HttpClient.create(connections).disableRetry(true).followRedirect(false) + .option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,5000).responseTimeout(timeout); + client=WebClient.builder().baseUrl(base.toString()).clientConnector(new ReactorClientHttpConnector(http)) + .defaultHeader("X-Warden-Api-Key",properties.apiKey()) + .codecs(c->c.defaultCodecs().maxInMemorySize(16*1024*1024)).build(); + } + public Configuration configuration() { + var origin=origin(); + var body=exchange(scoped(client.get().uri("/api/v1/review-jobs/configuration"),origin),200,65536); + fields(body,"policyVersion","reviewConfigSha256");String policy=text(body,"policyVersion"),config=text(body,"reviewConfigSha256"); + if (!policy.matches("warden-3\\.0\\.0:[0-9a-f]{64}") || !digest(config)) throw new Unavailable(502); + return new Configuration(policy,config,origin); + } + public RemoteReviewOrigin origin() { + var body=exchange(client.get().uri("/api/v1/review-jobs/identity"),200,65536);fields(body,"deploymentId","callerScope"); + try{return new RemoteReviewOrigin(text(body,"deploymentId"),text(body,"callerScope"));} + catch(IllegalArgumentException invalid){throw new Unavailable(502);} + } + public static final class MissingOrigin extends RuntimeException {public MissingOrigin(){super("Remote review origin is unavailable");}} + private static WebClient.RequestHeadersSpec scoped(WebClient.RequestHeadersSpec request,RemoteReviewOrigin origin) { + if(origin==null)throw new MissingOrigin(); + return request.header("X-Warden-Deployment-Id",origin.deploymentId()).header("X-Warden-Caller-Scope",origin.callerScope()); + } + public Optional find(RemoteReviewBinding binding) { + try { return Optional.of(statusBody(exchange(scoped(client.get().uri(uri(binding,"/requests/"+binding.requestId(),false)),binding.origin()),200,65536),binding)); } + catch(Unavailable failure) { if(failure.status()==404)return Optional.empty();throw failure; } + } + public static final class Superseded extends RuntimeException { public Superseded(){super("Remote review ownership changed");} } + public Status submitOrFind(RemoteReviewBinding binding,byte[] bytes) { + if(binding.jobId()==null)validateOriginal(binding,bytes); + return submitOrFind(binding,()->bytes,()->true); + } + public Status submitOrFind(RemoteReviewBinding binding,java.util.function.Supplier original,java.util.function.BooleanSupplier current) { + if(!current.getAsBoolean())throw new Superseded(); + var found=binding.jobId()!=null?Optional.of(status(binding)):find(binding); + if(!current.getAsBoolean())throw new Superseded(); + if(found.isPresent() && !found.get().state().equals("AWAITING_UPLOAD"))return found.get(); + byte[] bytes=original.get();validateOriginal(binding,bytes); + if(!current.getAsBoolean())throw new Superseded(); + var form=new MultipartBodyBuilder();form.part("requestId",binding.requestId());form.part("artifactSha256",binding.artifactSha256()); + form.part("contextSha256",binding.contextSha256());form.part("policyVersion",binding.policyVersion());form.part("reviewConfigSha256",binding.reviewConfigSha256()); + form.part("file",new ByteArrayResource(bytes){@Override public String getFilename(){return "artifact.zip";}}); + return statusBody(exchange(scoped(client.post().uri("/api/v1/review-jobs").contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(form.build())),binding.origin()),202,65536,current),found.isPresent()?binding.withJobId(found.get().jobId()):binding); + } + private static void validateOriginal(RemoteReviewBinding binding,byte[] bytes) { + if(bytes==null || bytes.length==0 || bytes.length>100*1024*1024 || !binding.artifactSha256().equals(hash(bytes))) + throw new IllegalArgumentException("Original artifact does not match review binding"); + } + public Status status(RemoteReviewBinding binding) {return status(binding,()->true,timeout::toNanos);} + public Status status(RemoteReviewBinding binding,java.util.function.BooleanSupplier current,java.util.function.LongSupplier remainingNanos) { + requireJob(binding);return statusBody(exchange(scoped(client.get().uri(uri(binding,"/"+binding.jobId(),true)),binding.origin()),200,65536,current,remainingNanos),binding); + } + public Status cancel(RemoteReviewBinding binding) {return cancel(binding,()->true,timeout::toNanos);} + public Status cancel(RemoteReviewBinding binding,java.util.function.BooleanSupplier current,java.util.function.LongSupplier remainingNanos) { + requireJob(binding);return statusBody(exchange(scoped(client.delete().uri(uri(binding,"/"+binding.jobId(),true)),binding.origin()),200,65536,current,remainingNanos),binding); + } + public ScanResult result(RemoteReviewBinding binding) { + requireJob(binding);var body=exchange(scoped(client.get().uri(uri(binding,"/"+binding.jobId()+"/result",true)),binding.origin()),200,16*1024*1024); + fields(body,"jobId","requestId","binding","completedAt","scan");identity(body,binding); + if(number(body,"completedAt")<=0 || !body.path("scan").isObject())throw new Unavailable(502); + try { + var scan=mapper.treeToValue(body.get("scan"),ScanResult.class);var evidence=scan.getSecurityEvidence(); + if(evidence==null || !binding.artifactSha256().equals(evidence.artifactSha256()) || !binding.policyVersion().equals(evidence.policyVersion()))throw new Unavailable(502); + scan.setArtifactVerified(evidence.complete());scan.setReviewedContextSha256(binding.contextSha256());scan.setScanRequestId(binding.requestId()); + return scan; + }catch(Unavailable failure){throw failure;}catch(Exception invalid){throw new Unavailable(502);} + } + private Status statusBody(JsonNode body,RemoteReviewBinding binding) { + fields(body,"jobId","requestId","binding","state","artifactRetained","createdAt","expiresAt","workState");identity(body,binding); + String state=text(body,"state");long created=number(body,"createdAt"),expires=number(body,"expiresAt"); + if(!Set.of("QUEUED","RUNNING","COMPLETED","CANCELLED","EXPIRED","HELD","UPLOADING","AWAITING_UPLOAD").contains(state) + || !body.path("artifactRetained").isBoolean() || created<=0 || expires<=created + || !(body.path("workState").isNull() || body.path("workState").isTextual() && body.path("workState").textValue().length()<=128))throw new Unavailable(502); + boolean retained=body.get("artifactRetained").booleanValue(); + if(Set.of("QUEUED","RUNNING","COMPLETED","HELD").contains(state)&&!retained)throw new Unavailable(502); + return new Status(text(body,"jobId"),state,retained,created,expires,body.get("workState").isNull()?null:body.get("workState").textValue()); + } + private void identity(JsonNode body,RemoteReviewBinding expected) { + String job=text(body,"jobId"); + if(!job.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || expected.jobId()!=null&&!expected.jobId().equals(job) || !expected.requestId().equals(text(body,"requestId")))throw new Unavailable(502); + var b=body.path("binding");fields(b,"artifactSha256","contextSha256","policyVersion","reviewConfigSha256"); + if(!expected.artifactSha256().equals(text(b,"artifactSha256")) || !expected.contextSha256().equals(text(b,"contextSha256")) + || !expected.policyVersion().equals(text(b,"policyVersion")) || !expected.reviewConfigSha256().equals(text(b,"reviewConfigSha256")))throw new Unavailable(502); + } + private JsonNode exchange(WebClient.RequestHeadersSpec request,int expected,int max) {return exchange(request,expected,max,()->true);} + private JsonNode exchange(WebClient.RequestHeadersSpec request,int expected,int max,java.util.function.BooleanSupplier current) { + return exchange(request,expected,max,current,timeout::toNanos); + } + private JsonNode exchange(WebClient.RequestHeadersSpec request,int expected,int max,java.util.function.BooleanSupplier current,java.util.function.LongSupplier remainingNanos) { + if(closed.get() || !slots.tryAcquire())throw new Unavailable(503); + try { + if(closed.get())throw new Unavailable(503); + if(!current.getAsBoolean())throw new Superseded(); + long remaining=Math.min(timeout.toNanos(),remainingNanos.getAsLong());if(remaining<=0)throw new Superseded(); + byte[] bytes=request.exchangeToMono(response->{ + if(response.statusCode().value()!=expected)return response.releaseBody().then(reactor.core.publisher.Mono.error(new Unavailable(response.statusCode().value()))); + return response.bodyToMono(byte[].class); + }).takeUntilOther(stop.asMono()).timeout(Duration.ofNanos(remaining)).block(); + if(closed.get() || bytes==null || bytes.length==0 || bytes.length>max)throw new Unavailable(502); + var parsed=mapper.readTree(bytes);if(parsed==null || !parsed.isObject())throw new Unavailable(502);return parsed; + }catch(Unavailable | Superseded failure){throw failure;}catch(Exception failure){throw new Unavailable(503);}finally{slots.release();} + } + private static String uri(RemoteReviewBinding b,String suffix,boolean request) { + return "/api/v1/review-jobs"+suffix+"?artifactSha256="+b.artifactSha256()+"&contextSha256="+b.contextSha256() + +"&policyVersion="+b.policyVersion()+"&reviewConfigSha256="+b.reviewConfigSha256()+(request?"&requestId="+b.requestId():""); + } + private static void requireJob(RemoteReviewBinding binding){if(binding.jobId()==null)throw new IllegalArgumentException("Remote job identity is required");} + private static void fields(JsonNode node,String... fields) { + if(!node.isObject() || node.size()!=fields.length)throw new Unavailable(502); + for(String field:fields)if(!node.has(field))throw new Unavailable(502); + } + private static String text(JsonNode node,String key){if(!node.path(key).isTextual())throw new Unavailable(502);return node.get(key).textValue();} + private static long number(JsonNode node,String key){if(!node.path(key).isIntegralNumber()||!node.get(key).canConvertToLong())throw new Unavailable(502);return node.get(key).longValue();} + private static boolean digest(String value){return value.matches("[0-9a-f]{64}");} + private static String hash(byte[] bytes){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes));}catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);}} + @Override @jakarta.annotation.PreDestroy public void close(){if(closed.compareAndSet(false,true)){stop.tryEmitEmpty();connections.dispose();}} +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewDiscovery.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewDiscovery.java new file mode 100644 index 000000000..d0087444a --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewDiscovery.java @@ -0,0 +1,83 @@ +package net.modtale.service.security.scan; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import net.modtale.model.project.Project; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; +import java.util.*; +import java.util.concurrent.TimeUnit; + +@Service +public final class RemoteReviewDiscovery { + public record Cursor(Object projectId,int offset,boolean afterProject) { + public Cursor { + if(!(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.codePointCount(0,s.length())<=128) + || offset<0 || offset>16*1024*1024 || afterProject && offset!=0)throw new IllegalArgumentException("Invalid discovery cursor"); + } + } + public record Candidate(String projectId,String versionId,int attempt,String requestId) {} + public record Page(List candidates,Cursor next,int examined) { public Page { candidates=List.copyOf(candidates); } } + private final MongoCollection projects; + public RemoteReviewDiscovery(MongoTemplate mongo) { + projects=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY); + } + public Page page(Cursor cursor,int limit) { + if(limit<1 || limit>64)throw new IllegalArgumentException("Invalid discovery page size"); + var eligibility=new Document("reviewStatus","PENDING").append("scanResult.status","SCANNING") + .append("scanResult.scanState",new Document("$in",List.of("QUEUED","SCANNING","REMOTE_REVIEW"))); + var terms=new ArrayList();terms.add(boundedString("$_id",128,true)); + if(cursor!=null)terms.add(new Document(cursor.afterProject()?"$gt":"$gte",List.of("$_id",literal(cursor.projectId())))); + var match=new Document("_id",new Document("$type",List.of("string","objectId"))).append("versions",new Document("$elemMatch",eligibility)) + .append("$expr",new Document("$and",terms)); + Object offset=cursor==null?literal(0):new Document("$cond",List.of(new Document("$eq",List.of("$_id",literal(cursor.projectId()))),cursor.offset(),0)); + var array=new Document("$cond",List.of(new Document("$isArray","$versions"),"$versions",List.of())); + var view=new Document("versionId",safeString("$$v._id",128)).append("requestId",safeString("$$v.scanResult.scanRequestId",36)) + .append("attempt",new Document("$cond",Arrays.asList(new Document("$in",List.of(new Document("$type","$$v.scanResult.scanAttempt"),List.of("int","long"))),"$$v.scanResult.scanAttempt",null))) + .append("eligible",eligible()); + var projection=new Document("_id",1).append("offset",offset).append("total",new Document("$size",array)) + .append("items",new Document("$map",new Document("input",new Document("$slice",List.of(array,offset,limit))).append("as","v").append("in",view))); + var pipeline=List.of(new Document("$match",match),new Document("$sort",new Document("_id",1)),new Document("$limit",1),new Document("$project",projection)); + Document root; + try(var rows=projects.aggregate(pipeline).collation(Collation.builder().locale("simple").build()).allowDiskUse(false) + .maxTime(5,TimeUnit.SECONDS).batchSize(1).iterator()) {root=rows.hasNext()?rows.next():null;} + if(root==null)return new Page(List.of(),null,0); + Object id=root.get("_id");String projectId=id instanceof ObjectId objectId?objectId.toHexString():(String)id; + var items=root.getList("items",Document.class);var candidates=new ArrayList(); + for(var item:items) { + if(!Boolean.TRUE.equals(item.get("eligible")))continue; + String version=item.getString("versionId"),request=item.getString("requestId");Object attempt=item.get("attempt"); + if(version==null || version.isBlank() || version.chars().anyMatch(Character::isISOControl) || request==null + || !request.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + || !(attempt instanceof Number number) || number.longValue()<1 || number.longValue()>Integer.MAX_VALUE)continue; + candidates.add(new Candidate(projectId,version,((Number)attempt).intValue(),request)); + } + int end=Math.addExact(root.getInteger("offset"),items.size()); + var next=end(base);conditions.add(available);return new Document("$and",conditions); + } + private static Document safeString(String field,int max) {return new Document("$cond",Arrays.asList(boundedString(field,max,false),field,null));} + private static Document boundedString(String field,int max,boolean objectIdAllowed) { + return new Document("$cond",List.of(new Document("$eq",List.of(new Document("$type",field),"string")), + new Document("$and",List.of(new Document("$gt",List.of(new Document("$strLenCP",field),0)),new Document("$lte",List.of(new Document("$strLenCP",field),max)))), + objectIdAllowed?new Document("$eq",List.of(new Document("$type",field),"objectId")):false)); + } + private static Document literal(Object value){return new Document("$literal",value);} +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPersistence.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPersistence.java new file mode 100644 index 000000000..0a865884b --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPersistence.java @@ -0,0 +1,141 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.*; +import org.springframework.stereotype.Service; +import java.util.Objects; + +@Service +public class RemoteReviewPersistence { + private final MongoTemplate mongo; + public RemoteReviewPersistence(MongoTemplate mongo) { + this.mongo = new MongoTemplate(mongo.getMongoDatabaseFactory(),mongo.getConverter()); + this.mongo.setWriteConcern(com.mongodb.WriteConcern.MAJORITY.withJournal(true) + .withWTimeout(10,java.util.concurrent.TimeUnit.SECONDS)); + } + + public boolean bind(ProjectVersion observed, RemoteReviewBinding binding) { + if (binding == null || binding.origin()==null || binding.jobId() != null || !matches(observed,binding)) return false; + var version = target(observed,binding).and("scanResult.scanState").in("QUEUED","SCANNING","REMOTE_REVIEW") + .orOperator(Criteria.where("scanResult.remoteReview").is(null),Criteria.where("scanResult.remoteReview").is(binding)); + return mongo.updateFirst(query(binding,version), new Update() + .set("versions.$.scanResult.remoteReview",binding) + .set("versions.$.scanResult.scanState","REMOTE_REVIEW"),Project.class).getMatchedCount() == 1; + } + + public boolean attachJob(ProjectVersion observed, RemoteReviewBinding binding, String jobId) { + if (binding == null || binding.origin()==null || !matches(observed,binding)) return false; + var attached = binding.withJobId(jobId); + var version = target(observed,binding).and("scanResult.scanState").is("REMOTE_REVIEW").and("scanResult.remotePoll").exists(false) + .orOperator(Criteria.where("scanResult.remoteReview").is(binding),Criteria.where("scanResult.remoteReview").is(attached)); + return mongo.updateFirst(query(binding,version),new Update() + .set("versions.$.scanResult.remoteReview",attached),Project.class).getMatchedCount() == 1; + } + + public boolean finishUnsupportedContext(String projectId,ProjectVersion observed) { + if(projectId==null || observed==null || observed.getId()==null || observed.getScanResult()==null + || observed.getReviewStatus()!=ProjectVersion.ReviewStatus.PENDING + || ArtifactReviewContext.automaticallyReviewableFingerprint(observed)!=null)return false; + var scan=observed.getScanResult(); + if(scan.getStatus()!=ScanStatus.SCANNING || !java.util.Set.of("QUEUED","SCANNING").contains(Objects.toString(scan.getScanState(),"")) + || scan.getRemoteReview()!=null || scan.getScanAttempt()<1 || scan.getScanRequestId()==null + || !scan.getScanRequestId().matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))return false; + var version=ArtifactReviewContext.bindSnapshot(Criteria.where("_id").is(observed.getId()) + .and("hash").is(observed.getHash()).and("fileUrl").is(observed.getFileUrl()) + .and("reviewStatus").is(ProjectVersion.ReviewStatus.PENDING) + .and("scanResult.status").is(ScanStatus.SCANNING).and("scanResult.scanState").is(scan.getScanState()).and("scanResult.verdict").is(scan.getVerdict()) + .and("scanResult.scanRequestId").is(scan.getScanRequestId()).and("scanResult.scanAttempt").is(scan.getScanAttempt()) + .and("scanResult.manualRescan").in(scan.isManualRescan()?java.util.List.of(true):java.util.Arrays.asList(false,null)) + .and("scanResult.remoteReview").is(null).and("scanResult.remotePoll").exists(false),observed); + return mongo.updateFirst(query(projectId,observed.getId(),version),new Update() + .set("versions.$.scanResult.status",ScanStatus.FAILED).set("versions.$.scanResult.scanState","REMOTE_UNSUPPORTED_CONTEXT") + .set("versions.$.scanResult.verdict","BLOCK".equals(scan.getVerdict())?"BLOCK":"REVIEW") + .set("versions.$.scanResult.scanTimestamp",System.currentTimeMillis()) + .set("versions.$.scanResult.securityEvidence",null).set("versions.$.scanResult.artifactVerified",false) + .set("versions.$.scanResult.reviewedContextSha256",null).set("versions.$.scanResult.reusedReviewVersion",null) + .set("versions.$.scanResult.reusedReviewOrigins",null).set("versions.$.scanResult.holdUntilTimestamp",0) + .set("versions.$.scanResult.reviewerNotes",java.util.List.of("Security review cannot cover this version's current dependencies, runtime metadata or supplemental content. Resolve the review context before requesting another scan. No security clearance was granted.")),Project.class) + .getModifiedCount()==1; + } + + private record Current(org.bson.Document projectQuery,org.bson.Document rawVersion,ProjectVersion version) {} + public ProjectVersion current(String projectId,String versionId,int attempt,String requestId) { + var snapshot=readCurrent(projectId,versionId,attempt,requestId);return snapshot==null?null:snapshot.version(); + } + private Current readCurrent(String projectId,String versionId,int attempt,String requestId) { + if(projectId==null || versionId==null || attempt<1 || requestId==null || !requestId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))return null; + var entity=mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var query=new org.springframework.data.mongodb.core.convert.QueryMapper(mongo.getConverter()).getMappedObject(new org.bson.Document("_id",projectId),entity); + var raw=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(com.mongodb.ReadPreference.primary()).withReadConcern(com.mongodb.ReadConcern.MAJORITY) + .find(query).collation(com.mongodb.client.model.Collation.builder().locale("simple").build()).maxTime(5,java.util.concurrent.TimeUnit.SECONDS).first(); + if(raw==null)return null;var versions=raw.getList("versions",org.bson.Document.class);if(versions==null)return null; + var selected=versions.stream().filter(v->versionId.equals(v.get("_id"))).toList();if(selected.size()!=1)return null; + ProjectVersion version;try{version=mongo.getConverter().read(ProjectVersion.class,selected.getFirst());}catch(RuntimeException invalid){return null;} + var scan=version.getScanResult(); + if(version.getReviewStatus()!=ProjectVersion.ReviewStatus.PENDING || scan==null || scan.getStatus()!=ScanStatus.SCANNING + || !java.util.Set.of("QUEUED","SCANNING","REMOTE_REVIEW").contains(Objects.toString(scan.getScanState(),"")) + || !requestId.equals(scan.getScanRequestId()) || attempt!=scan.getScanAttempt())return null; + return new Current(query,selected.getFirst(),version); + } + public String finishBrokenBinding(String projectId,String versionId,int attempt,String requestId) { + var snapshot=readCurrent(projectId,versionId,attempt,requestId);if(snapshot==null)return null; + var scan=snapshot.version().getScanResult();var binding=scan.getRemoteReview(); + String reason; + if(binding==null) { + if(!"REMOTE_REVIEW".equals(scan.getScanState()))return null; + reason="REMOTE_BINDING_MISSING"; + } else { + if(projectId.equals(binding.projectId()) && "REMOTE_REVIEW".equals(scan.getScanState()) && matches(snapshot.version(),binding)) { + if(binding.origin()!=null)return null; + reason="REMOTE_ORIGIN_UNVERIFIED"; + } else reason="REMOTE_BINDING_MISMATCH"; + } + var uniqueness=new org.bson.Document("$eq",java.util.List.of(new org.bson.Document("$size",new org.bson.Document("$filter", + new org.bson.Document("input","$versions").append("as","v").append("cond",new org.bson.Document("$eq",java.util.List.of("$$v._id",new org.bson.Document("$literal",versionId)))))),1)); + var query=new org.bson.Document(snapshot.projectQuery()).append("versions",snapshot.rawVersion()).append("$expr",uniqueness); + var fields=new org.bson.Document("status","FAILED").append("scanState",reason) + .append("verdict","BLOCK".equals(scan.getVerdict())?"BLOCK":"REVIEW").append("scanTimestamp",new org.bson.Document("$toLong","$$NOW")) + .append("securityEvidence",null).append("artifactVerified",false).append("reviewedContextSha256",null) + .append("reusedReviewVersion",null).append("reusedReviewOrigins",null).append("holdUntilTimestamp",0) + .append("remotePoll",null).append("reviewerNotes",java.util.List.of(reason.equals("REMOTE_ORIGIN_UNVERIFIED")?"The original review service identity was not recorded. Reconcile the original job before requesting another scan. No security clearance was granted.":"The stored review job no longer matches this version or its binding is missing. Repair review state before requesting another scan. No security clearance was granted.")); + var updated=new org.bson.Document("$mergeObjects",java.util.List.of("$$v",new org.bson.Document("scanResult",new org.bson.Document("$mergeObjects",java.util.List.of("$$v.scanResult",fields))))); + var patch=java.util.List.of(new org.bson.Document("$set",new org.bson.Document("versions",new org.bson.Document("$map",new org.bson.Document("input","$versions").append("as","v") + .append("in",new org.bson.Document("$cond",java.util.List.of(new org.bson.Document("$eq",java.util.List.of("$$v",new org.bson.Document("$literal",snapshot.rawVersion()))),updated,"$$v"))))))); + var result=mongo.getCollection(mongo.getCollectionName(Project.class)).withWriteConcern(com.mongodb.WriteConcern.MAJORITY.withJournal(true) + .withWTimeout(10,java.util.concurrent.TimeUnit.SECONDS)).updateOne(query,patch,new com.mongodb.client.model.UpdateOptions().collation(com.mongodb.client.model.Collation.builder().locale("simple").build())); + return result.getModifiedCount()==1?reason:null; + } + public RemoteReviewBinding retained(String projectId,String versionId,int attempt,String requestId) { + var current=current(projectId,versionId,attempt,requestId);if(current==null)return null; + var binding=current.getScanResult().getRemoteReview(); + return binding!=null && projectId.equals(binding.projectId()) && "REMOTE_REVIEW".equals(current.getScanResult().getScanState()) && matches(current,binding)?binding:null; + } + + private static boolean matches(ProjectVersion observed,RemoteReviewBinding binding) { + if (observed == null || observed.getScanResult() == null) return false; + var scan=observed.getScanResult(); + return Objects.equals(observed.getId(),binding.versionId()) && Objects.equals(observed.getHash(),binding.artifactSha256()) + && Objects.equals(observed.getFileUrl(),binding.filePath()) + && Objects.equals(ArtifactReviewContext.automaticallyReviewableFingerprint(observed),binding.contextSha256()) + && Objects.equals(scan.getScanRequestId(),binding.requestId()) && scan.getScanAttempt() == binding.attempt() && scan.isManualRescan()==binding.manualRescan(); + } + private static Criteria target(ProjectVersion observed,RemoteReviewBinding binding) { + return ArtifactReviewContext.bindSnapshot(Criteria.where("_id").is(binding.versionId()) + .and("hash").is(binding.artifactSha256()).and("fileUrl").is(binding.filePath()) + .and("reviewStatus").is(ProjectVersion.ReviewStatus.PENDING) + .and("scanResult.status").is(ScanStatus.SCANNING) + .and("scanResult.scanRequestId").is(binding.requestId()) + .and("scanResult.scanAttempt").is(binding.attempt()) + .and("scanResult.manualRescan").in(binding.manualRescan()?java.util.List.of(true):java.util.Arrays.asList(false,null)),observed); + } + private static Query query(RemoteReviewBinding binding,Criteria version) { + return query(binding.projectId(),binding.versionId(),version); + } + private static Query query(String projectId,String versionId,Criteria version) { + var query=Query.query(Criteria.where("_id").is(projectId).and("versions").elemMatch(version)); + query.addCriteria(Criteria.where("$expr").is(new org.bson.Document("$eq",java.util.List.of(new org.bson.Document("$size",new org.bson.Document("$filter", + new org.bson.Document("input","$versions").append("as","v").append("cond",new org.bson.Document("$eq",java.util.List.of("$$v._id",new org.bson.Document("$literal",versionId)))))),1)))); + return query.collation(Collation.of("simple")); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPollStore.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPollStore.java new file mode 100644 index 000000000..86e8e9f4b --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewPollStore.java @@ -0,0 +1,137 @@ +package net.modtale.service.security.scan; + +import com.mongodb.*; +import com.mongodb.client.MongoCollection; +import net.modtale.model.project.*; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.stereotype.Service; +import java.util.*; +import java.util.concurrent.TimeUnit; + +@Service +public final class RemoteReviewPollStore { + public record Claim(RemoteReviewBinding binding,String token) { + public Claim { if(binding==null || token==null || !token.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))throw new IllegalArgumentException("Invalid poll claim"); } + } + private record Snapshot(Document project,Document version,Document scan,Document poll) {} + private static final com.mongodb.client.model.Collation BINARY=com.mongodb.client.model.Collation.builder().locale("simple").build(); + private final MongoTemplate mongo; + private final MongoCollection projects; + public RemoteReviewPollStore(MongoTemplate mongo) { + this.mongo=mongo; + projects=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(ReadPreference.primary()) + .withReadConcern(ReadConcern.MAJORITY).withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(10,TimeUnit.SECONDS)); + } + public Claim claim(RemoteReviewBinding binding,long leaseMillis) { + if(leaseMillis<1000 || leaseMillis>120000)throw new IllegalArgumentException("Invalid poll lease"); + if(binding.origin()==null)return null; + var snapshot=read(binding);if(snapshot==null)return null; + String token=UUID.randomUUID().toString();Date lease=date(snapshot.poll(),"leaseUntil"),next=date(snapshot.poll(),"nextPollAt"); + if(lease==null || next==null)return null; + var query=query(snapshot,binding,List.of(new Document("$lte",List.of(lease,"$$NOW")),new Document("$lte",List.of(next,"$$NOW")))); + var poll=new Document("token",token).append("leaseUntil",new Document("$add",List.of("$$NOW",leaseMillis))).append("nextPollAt",literal(next)); + if(projects.updateOne(query,patch(snapshot,new Document("remotePoll",poll)),new com.mongodb.client.model.UpdateOptions().collation(BINARY)).getModifiedCount()!=1)return null; + return new Claim(binding,token); + } + public boolean isCurrent(Claim claim) { + var snapshot=liveSnapshot(claim);if(snapshot==null)return false; + return projects.find(liveQuery(snapshot,claim)).collation(BINARY).projection(new Document("_id",1)).maxTime(5,TimeUnit.SECONDS).first()!=null; + } + public Claim attachJob(Claim claim,String jobId) { + var attached=claim.binding().withJobId(jobId);var snapshot=liveSnapshot(claim);if(snapshot==null)return null; + var binding=new Document(snapshot.scan().get("remoteReview",Document.class));binding.put("jobId",jobId); + if(projects.updateOne(liveQuery(snapshot,claim),patch(snapshot,new Document("remoteReview",literal(binding))),new com.mongodb.client.model.UpdateOptions().collation(BINARY)).getMatchedCount()!=1)return null; + return new Claim(attached,claim.token()); + } + public boolean release(Claim claim,long delayMillis) { + if(delayMillis<100 || delayMillis>3600000)throw new IllegalArgumentException("Invalid poll delay"); + var snapshot=liveSnapshot(claim);if(snapshot==null)return false; + var poll=new Document("token",null).append("leaseUntil",literal(new Date(0))).append("nextPollAt",new Document("$add",List.of("$$NOW",delayMillis))); + return projects.updateOne(liveQuery(snapshot,claim),patch(snapshot,new Document("remotePoll",poll)),new com.mongodb.client.model.UpdateOptions().collation(BINARY)).getModifiedCount()==1; + } + public boolean recordStatusAndRelease(Claim claim,RemoteReviewClient.Status status,long delayMillis) { + if(delayMillis<100 || delayMillis>3600000)throw new IllegalArgumentException("Invalid poll delay"); + if(status==null || !Objects.equals(claim.binding().jobId(),status.jobId()) || status.jobId()==null + || !Set.of("QUEUED","RUNNING","COMPLETED","UPLOADING","AWAITING_UPLOAD").contains(status.state()) + || status.createdAt()<=0 || status.expiresAt()<=status.createdAt() || status.workState()!=null && status.workState().length()>128 + || Set.of("QUEUED","RUNNING","COMPLETED","HELD").contains(status.state()) && !status.artifactRetained())return false; + var snapshot=liveSnapshot(claim);if(snapshot==null)return false; + var value=new Document("jobId",status.jobId()).append("state",status.state()).append("artifactRetained",status.artifactRetained()) + .append("createdAt",status.createdAt()).append("expiresAt",status.expiresAt()).append("workState",status.workState()); + var poll=new Document("token",null).append("leaseUntil",literal(new Date(0))).append("nextPollAt",new Document("$add",List.of("$$NOW",delayMillis))); + return projects.updateOne(liveQuery(snapshot,claim),patch(snapshot,new Document("remoteStatus",literal(value)).append("remotePoll",poll)), + new com.mongodb.client.model.UpdateOptions().collation(BINARY)).getModifiedCount()==1; + } + public boolean finishUnavailable(Claim claim,RemoteReviewClient.Status status) { + if(status==null || status.state()==null || !Set.of("CANCELLED","EXPIRED","HELD").contains(status.state()) + || status.jobId()==null || !status.jobId().equals(claim.binding().jobId()) + || status.createdAt()<=0 || status.expiresAt()<=status.createdAt() + || status.workState()!=null && status.workState().length()>128 + || "HELD".equals(status.state()) && !status.artifactRetained())return false; + var snapshot=liveSnapshot(claim);if(snapshot==null)return false; + var value=new Document("jobId",status.jobId()).append("state",status.state()).append("artifactRetained",status.artifactRetained()) + .append("createdAt",status.createdAt()).append("expiresAt",status.expiresAt()).append("workState",status.workState()); + var fields=new Document("remoteStatus",literal(value)).append("remotePoll",literal(null)) + .append("status","FAILED").append("scanState","REMOTE_"+status.state()).append("verdict","BLOCK".equals(snapshot.scan().get("verdict"))?"BLOCK":"REVIEW") + .append("scanTimestamp",new Document("$toLong","$$NOW")) + .append("securityEvidence",literal(null)).append("artifactVerified",false) + .append("reviewedContextSha256",literal(null)).append("reusedReviewVersion",literal(null)) + .append("reusedReviewOrigins",literal(null)).append("holdUntilTimestamp",0) + .append("reviewerNotes",literal(List.of("Security review did not complete ("+status.state().toLowerCase(Locale.ROOT)+"). No security clearance was granted."))); + return projects.updateOne(liveQuery(snapshot,claim),patch(snapshot,fields),new com.mongodb.client.model.UpdateOptions().collation(BINARY)).getModifiedCount()==1; + } + public boolean finishContextConflict(Claim claim) { + var snapshot=liveSnapshot(claim);if(snapshot==null)return false; + var fields=new Document("remotePoll",literal(null)).append("status","FAILED").append("scanState","REMOTE_CONTEXT_CONFLICT") + .append("verdict","BLOCK".equals(snapshot.scan().get("verdict"))?"BLOCK":"REVIEW") + .append("scanTimestamp",new Document("$toLong","$$NOW")).append("securityEvidence",literal(null)).append("artifactVerified",false) + .append("reviewedContextSha256",literal(null)).append("reusedReviewVersion",literal(null)).append("reusedReviewOrigins",literal(null)).append("holdUntilTimestamp",0) + .append("reviewerNotes",literal(List.of("The review service rejected the stored request context or service identity. Reconcile the original job before requesting another scan. No security clearance was granted."))); + return projects.updateOne(liveQuery(snapshot,claim),patch(snapshot,fields),new com.mongodb.client.model.UpdateOptions().collation(BINARY)).getModifiedCount()==1; + } + private Snapshot liveSnapshot(Claim claim) { + var snapshot=read(claim.binding());return snapshot!=null && snapshot.poll()!=null && claim.token().equals(snapshot.poll().getString("token"))?snapshot:null; + } + private Document liveQuery(Snapshot snapshot,Claim claim) { + return query(snapshot,claim.binding(),List.of(new Document("$gt",List.of(date(snapshot.poll(),"leaseUntil"),"$$NOW")))); + } + private Snapshot read(RemoteReviewBinding binding) { + Objects.requireNonNull(binding); + var entity=mongo.getConverter().getMappingContext().getPersistentEntity(Project.class); + var id=new QueryMapper(mongo.getConverter()).getMappedObject(new Document("_id",binding.projectId()),entity); + var project=projects.find(id).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();if(project==null)return null; + var versions=project.getList("versions",Document.class);if(versions==null)return null; + var candidates=versions.stream().filter(v->binding.versionId().equals(v.get("_id"))).toList();if(candidates.size()!=1)return null; + var raw=candidates.getFirst();ProjectVersion version; + try {version=mongo.getConverter().read(ProjectVersion.class,raw);}catch(RuntimeException invalid){return null;} + var scan=version.getScanResult(); + if(version.getReviewStatus()!=ProjectVersion.ReviewStatus.PENDING || scan==null || scan.getStatus()!=ScanStatus.SCANNING + || !"REMOTE_REVIEW".equals(scan.getScanState()) || !binding.equals(scan.getRemoteReview()) + || !binding.requestId().equals(scan.getScanRequestId()) || binding.attempt()!=scan.getScanAttempt() || binding.manualRescan()!=scan.isManualRescan() + || !binding.artifactSha256().equals(version.getHash()) || !binding.filePath().equals(version.getFileUrl()) + || !binding.contextSha256().equals(ArtifactReviewContext.automaticallyReviewableFingerprint(version)))return null; + var rawScan=raw.get("scanResult",Document.class);Document poll; + try {poll=rawScan.get("remotePoll",Document.class);}catch(ClassCastException invalid){return null;} + if(poll!=null && (poll.size()!=3 || !poll.containsKey("token") || date(poll,"leaseUntil")==null || date(poll,"nextPollAt")==null + || poll.get("token")!=null && !(poll.get("token") instanceof String token && token.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))))return null; + if(rawScan.containsKey("remotePoll") && poll==null)return null; + return new Snapshot(id,raw,rawScan,poll); + } + private static Date date(Document poll,String key) { + if(poll==null)return new Date(0);return poll.get(key) instanceof Date value?value:null; + } + private static Document query(Snapshot snapshot,RemoteReviewBinding binding,List timing) { + var clauses=new ArrayList(timing); + clauses.add(new Document("$eq",List.of(new Document("$size",new Document("$filter",new Document("input","$versions").append("as","v") + .append("cond",new Document("$eq",List.of("$$v._id",literal(binding.versionId())))))),1))); + return new Document(snapshot.project()).append("versions",snapshot.version()).append("$expr",new Document("$and",clauses)); + } + private static List patch(Snapshot snapshot,Document scanFields) { + var updated=new Document("$mergeObjects",List.of("$$v",new Document("scanResult",new Document("$mergeObjects",List.of("$$v.scanResult",scanFields))))); + return List.of(new Document("$set",new Document("versions",new Document("$map",new Document("input","$versions").append("as","v") + .append("in",new Document("$cond",List.of(new Document("$eq",List.of("$$v",literal(snapshot.version()))),updated,"$$v"))))))); + } + private static Document literal(Object value){return new Document("$literal",value);} +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewScheduler.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewScheduler.java new file mode 100644 index 000000000..becbb05ea --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewScheduler.java @@ -0,0 +1,106 @@ +package net.modtale.service.security.scan; + +import org.springframework.context.SmartLifecycle; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; + +public final class RemoteReviewScheduler implements SmartLifecycle,AutoCloseable { + public record Settings(int workers,int pageSize,long pollMillis,long drainMillis) { + public Settings {if(workers<1 || workers>2 || pageSize<1 || pageSize>64 || pollMillis<100 || pollMillis>60000 || drainMillis<100 || drainMillis>30000)throw new IllegalArgumentException("Invalid remote scheduler limits");} + } + public record Status(String state,int active,int buffered,long pages,long processed,long failures,String lastOutcome) {} + private final RemoteReviewDiscovery discovery; + private final RemoteReviewBootstrap bootstrap; + private final Settings settings; + private final Object lifecycle=new Object(),queueLock=new Object(); + private final CountDownLatch stopping=new CountDownLatch(1); + private final ArrayDeque queue=new ArrayDeque<>(); + private final Set inFlight=new HashSet<>(); + private RemoteReviewDiscovery.Cursor cursor; + private final AtomicInteger active=new AtomicInteger(),buffered=new AtomicInteger(); + private final AtomicLong pages=new AtomicLong(),processed=new AtomicLong(),failures=new AtomicLong(); + private final Queue callbacks=new ConcurrentLinkedQueue<>(); + private final AtomicBoolean watching=new AtomicBoolean(); + private final Set threads=ConcurrentHashMap.newKeySet(); + private volatile String state="NEW",lastOutcome="NONE"; + private volatile ExecutorService executor; + public RemoteReviewScheduler(RemoteReviewDiscovery discovery,RemoteReviewBootstrap bootstrap,Settings settings) { + this.discovery=Objects.requireNonNull(discovery);this.bootstrap=Objects.requireNonNull(bootstrap);this.settings=Objects.requireNonNull(settings); + } + @Override public void start() { + synchronized(lifecycle) { + if("RUNNING".equals(state))return;if(!"NEW".equals(state))throw new IllegalStateException("Closed scheduler cannot restart"); + executor=Executors.newFixedThreadPool(settings.workers(),r->{var t=new Thread(r,"remote-review-poll");t.setDaemon(true);return t;});state="RUNNING"; + try{for(int i=0;isettings.pageSize())throw new IllegalStateException("Oversized discovery page"); + queue.addAll(page.candidates());cursor=page.next();pages.incrementAndGet();buffered.set(queue.size()); + } + while(!queue.isEmpty()) {var candidate=queue.removeFirst();buffered.set(queue.size());if(inFlight.add(candidate))return candidate;} + return null; + } + } + private void loop() { + threads.add(Thread.currentThread()); + try { + while(stopping.getCount()!=0) { + long pause=settings.pollMillis();RemoteReviewDiscovery.Candidate candidate=null; + try { + candidate=next(); + if(candidate!=null) { + active.incrementAndGet();var outcome=bootstrap.advance(candidate.projectId(),candidate.versionId(),candidate.attempt(),candidate.requestId(), + ()->stopping.getCount()!=0 && !Thread.currentThread().isInterrupted()); + lastOutcome=outcome.state();processed.incrementAndGet(); + if(Set.of("RETRY","UNKNOWN").contains(outcome.state())){failures.incrementAndGet();pause=Math.max(5000,pause);} + } + } catch(RuntimeException failure) {if(stopping.getCount()==0)break;failures.incrementAndGet();lastOutcome="POLL_ERROR";pause=Math.max(5000,pause);} + finally {if(candidate!=null){active.decrementAndGet();synchronized(queueLock){inFlight.remove(candidate);}}} + if(stopping.await(pause,TimeUnit.MILLISECONDS))break; + } + } catch(InterruptedException interrupted) {Thread.currentThread().interrupt();if(stopping.getCount()!=0){failures.incrementAndGet();lastOutcome="LOOP_INTERRUPTED";requestStop();}} + catch(Error fatal){failures.incrementAndGet();lastOutcome="LOOP_FAILED";requestStop();throw fatal;} + finally {threads.remove(Thread.currentThread());} + } + private void requestStop() { + synchronized(lifecycle) {if(stopping.getCount()==0)return;state="STOPPING";stopping.countDown();if(executor!=null)executor.shutdownNow();} + } + private boolean stopped() { + var service=executor; + if(stopping.getCount()==0 && (service==null || service.isTerminated()) && active.get()==0) {state="STOPPED";return true;} + return false; + } + private boolean awaitStop(long millis)throws InterruptedException { + var service=executor;if(service!=null)service.awaitTermination(millis,TimeUnit.MILLISECONDS);return stopped(); + } + @Override public void stop() { + requestStop();if(threads.contains(Thread.currentThread()))return; + try {if(!awaitStop(settings.drainMillis()))state="DRAIN_TIMEOUT";}catch(InterruptedException interrupted){Thread.currentThread().interrupt();state="DRAIN_INTERRUPTED";} + } + @Override public void stop(Runnable callback) { + callbacks.add(Objects.requireNonNull(callback));requestStop();watch(); + } + private void watch() { + if(!watching.compareAndSet(false,true))return; + Thread.startVirtualThread(()->{ + try { + if(!awaitStop(settings.drainMillis()))state="DRAIN_TIMEOUT"; + while(!stopped())awaitStop(1000); + Runnable callback;while((callback=callbacks.poll())!=null)try{callback.run();}catch(RuntimeException failure){failures.incrementAndGet();} + } catch(InterruptedException interrupted){Thread.currentThread().interrupt();state="DRAIN_INTERRUPTED";} + finally {watching.set(false);if(!callbacks.isEmpty())watch();} + }); + } + public Status status(){stopped();return new Status(state,active.get(),buffered.get(),pages.get(),processed.get(),failures.get(),lastOutcome);} + @Override public boolean isRunning(){return !"NEW".equals(state) && !stopped();} + @Override public boolean isAutoStartup(){return true;} + @Override public int getPhase(){return Integer.MAX_VALUE;} + @Override public void close(){stop();if(!stopped())throw new IllegalStateException("Remote scheduler drain incomplete");} +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewSchedulerConfig.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewSchedulerConfig.java new file mode 100644 index 000000000..cfcea3671 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewSchedulerConfig.java @@ -0,0 +1,21 @@ +package net.modtale.service.security.scan; + +import org.springframework.context.annotation.*; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +@Configuration +@ConditionalOnProperty(name="app.warden.jobs.scheduler.enabled",havingValue="true") +public class RemoteReviewSchedulerConfig { + @Bean(destroyMethod="close") + RemoteReviewScheduler remoteReviewScheduler(RemoteReviewDiscovery discovery,ObjectProvider bootstrap, + @Value("${app.warden.jobs.enabled:false}") boolean enabled, + @Value("${app.warden.jobs.scheduler.workers:2}") int workers, + @Value("${app.warden.jobs.scheduler.page-size:16}") int pageSize, + @Value("${app.warden.jobs.scheduler.poll-millis:1000}") long poll, + @Value("${app.warden.jobs.scheduler.drain-millis:10000}") long drain) { + if(!enabled)throw new IllegalStateException("Remote scheduler requires remote jobs enabled"); + return new RemoteReviewScheduler(discovery,bootstrap.getObject(),new RemoteReviewScheduler.Settings(workers,pageSize,poll,drain)); + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewStep.java b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewStep.java new file mode 100644 index 000000000..a8917b92b --- /dev/null +++ b/backend/src/main/java/net/modtale/service/security/scan/RemoteReviewStep.java @@ -0,0 +1,58 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.RemoteReviewBinding; +import net.modtale.service.storage.StorageService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import java.util.concurrent.Semaphore; + +@Service +@ConditionalOnProperty(name="app.warden.jobs.enabled",havingValue="true") +public final class RemoteReviewStep { + public record Outcome(String state,String remoteState) {} + private final RemoteReviewPollStore polls; + private final RemoteReviewClient client; + private final StorageService storage; + private final ScanCompletionService completion; + private final Semaphore slots=new Semaphore(2); + public RemoteReviewStep(RemoteReviewPollStore polls,RemoteReviewClient client,StorageService storage,ScanCompletionService completion) { + this.polls=polls;this.client=client;this.storage=storage;this.completion=completion; + } + public Outcome advance(RemoteReviewBinding binding) {return advance(binding,()->true);} + public Outcome advance(RemoteReviewBinding binding,java.util.function.BooleanSupplier running) { + if(!running.getAsBoolean() || Thread.currentThread().isInterrupted())return new Outcome("SHUTDOWN",null); + if(binding.origin()==null)return new Outcome("UNVERIFIED_ORIGIN",null); + if(!slots.tryAcquire())return new Outcome("BUSY",null); + RemoteReviewPollStore.Claim claim=null; + try { + claim=polls.claim(binding,120000);if(claim==null)return new Outcome("NO_WORK",null); + var acquired=claim; + var status=client.submitOrFind(binding,()->storage.downloadBounded(binding.filePath(),100*1024*1024),()->running.getAsBoolean() && !Thread.currentThread().isInterrupted() && polls.isCurrent(acquired)); + if(!running.getAsBoolean() || Thread.currentThread().isInterrupted())return new Outcome("SHUTDOWN",null); + if(binding.jobId()==null) { + claim=polls.attachJob(claim,status.jobId());if(claim==null)return new Outcome("SUPERSEDED",null); + } + if("COMPLETED".equals(status.state())) { + if(!polls.isCurrent(claim))return new Outcome("SUPERSEDED",null); + var result=client.result(claim.binding()); + if(!running.getAsBoolean() || Thread.currentThread().isInterrupted())return new Outcome("SHUTDOWN",null); + boolean applied=completion.handleRemoteCompletedScan(claim,result); + return new Outcome(applied?"APPLIED":"SUPERSEDED",applied?"COMPLETED":null); + } + if(java.util.Set.of("CANCELLED","EXPIRED","HELD").contains(status.state())) { + boolean saved=polls.finishUnavailable(claim,status); + return new Outcome(saved?"UNAVAILABLE":"SUPERSEDED",saved?status.state():null); + } + boolean saved=polls.recordStatusAndRelease(claim,status,10000); + return new Outcome(saved?"RECORDED":"SUPERSEDED",saved?status.state():null); + } catch(RemoteReviewClient.Superseded stale) {return new Outcome("SUPERSEDED",null);} + catch(RuntimeException unavailable) { + if(claim!=null && unavailable instanceof RemoteReviewClient.Unavailable conflict && conflict.status()==409) { + try {return new Outcome(polls.finishContextConflict(claim)?"UNAVAILABLE":"SUPERSEDED",null);} + catch(RuntimeException unknown){return new Outcome("UNKNOWN",null);} + } + if(claim!=null)try {if(polls.release(claim,30000))return new Outcome("RETRY",null);}catch(RuntimeException unknown) { /* Ownership expires in the database. */ } + return new Outcome("UNKNOWN",null); + } finally {slots.release();} + } +} diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanCompletionService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanCompletionService.java index 3cc0ba595..fd70ac7d4 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanCompletionService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanCompletionService.java @@ -20,6 +20,7 @@ public class ScanCompletionService { private static final Logger logger = LoggerFactory.getLogger(ScanCompletionService.class); + private final WardenClientService warden; private final ProjectRepository projectRepository; private final ProjectService projectService; private final ProjectNotificationService projectNotificationService; @@ -37,8 +38,10 @@ public ScanCompletionService( SecurityIssueAnalysisService securityIssueAnalysisService, ScanRoutingService scanRoutingService, ScanPersistenceService scanPersistenceService, - ProjectVersionAccessService projectVersionAccessService + ProjectVersionAccessService projectVersionAccessService, + WardenClientService warden ) { + this.warden = warden; this.projectRepository = projectRepository; this.projectService = projectService; this.projectNotificationService = projectNotificationService; @@ -56,34 +59,110 @@ public void handleCompletedScan( boolean isManualRescan, ScanResult scanResult ) { + handleCompletedScan(projectId, versionId, expectedAttempt, isManualRescan, scanResult, null); + } + public void handleCompletedScan(String projectId, String versionId, int expectedAttempt, boolean isManualRescan, + ScanResult scanResult, String requestId) { + complete(projectId,versionId,expectedAttempt,isManualRescan,scanResult,requestId,null); + } + public boolean handleRemoteCompletedScan(RemoteReviewPollStore.Claim claim,ScanResult result) { + var binding=claim.binding(); + if(binding.origin()==null)return false; + if(result==null || !binding.requestId().equals(result.getScanRequestId()))return false; + return complete(binding.projectId(),binding.versionId(),binding.attempt(),binding.manualRescan(),result,binding.requestId(),claim); + } + private boolean complete(String projectId,String versionId,int expectedAttempt,boolean isManualRescan, + ScanResult scanResult,String requestId,RemoteReviewPollStore.Claim remote) { + scanResult.setScanRequestId(requestId); securityIssueAnalysisService.normalizeScanResult(scanResult); scanResult.setScanAttempt(expectedAttempt); Project project = projectRepository.findById(projectId).orElse(null); if (project == null) { logger.warn("Scan completed but project no longer exists project={} version={} attempt={}", projectId, versionId, expectedAttempt); - return; + return false; } ProjectVersion targetVersion = projectVersionAccessService.findById(project, versionId); if (targetVersion == null) { logger.warn("Scan completed but version no longer exists project={} version={} attempt={}", projectId, versionId, expectedAttempt); - return; + return false; } + ScanResult.RemoteReviewPoll remotePoll=null; + if(remote!=null) { + var current=targetVersion.getScanResult(); + if(current==null || !remote.binding().equals(current.getRemoteReview()) || !"REMOTE_REVIEW".equals(current.getScanState()))return false; + remotePoll=current.getRemotePoll(); + if(!ScanPersistenceService.remoteResultMatches(remote,remotePoll,scanResult,targetVersion))return false; + } + + if (scanResult.getSecurityEvidence() == null || !java.util.Objects.equals(targetVersion.getHash(), + scanResult.getSecurityEvidence().artifactSha256())) { + scanResult.setArtifactVerified(false); + } SecurityIssueAnalysisService.BaselineIndex baselines = securityIssueAnalysisService.collectApprovedIssueBaselines(project, versionId); SecurityIssueAnalysisService.ClassificationStats classification = securityIssueAnalysisService.annotateAgainstBaselines(scanResult, baselines); + new ArtifactReviewReuseService().annotate(project, versionId, scanResult); ScanRoutingService.RoutingDecision routingDecision = scanRoutingService.decideRouting(scanResult, classification, isManualRescan); + if(remote==null)scanResult.setReviewedContextSha256(ArtifactReviewContext.automaticallyReviewableFingerprint(targetVersion)); + if(remote!=null && routingDecision.action()==ScanRoutingService.RoutingAction.DEFER) + routingDecision=new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.REQUIRE_REVIEW,0); + if (scanResult.getReviewedContextSha256() == null) { + scanResult.setReusedReviewVersion(null); + scanResult.setReusedReviewApprovedAt(0); + if (!"BLOCK".equals(scanResult.getVerdict()) && scanResult.getStatus() != ScanStatus.INFECTED) { + scanResult.setVerdict("REVIEW"); + scanResult.setStatus(ScanStatus.SUSPICIOUS); + } + routingDecision = new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, 0); + var notes = new java.util.ArrayList<>(scanResult.getReviewerNotes() == null + ? java.util.List.of() : scanResult.getReviewerNotes()); + notes.add("Separate files or unresolved dependency context require additional review before publishing."); + scanResult.setReviewerNotes(notes); + } + + if ((routingDecision.action() == ScanRoutingService.RoutingAction.SCHEDULE + || routingDecision.action() == ScanRoutingService.RoutingAction.APPROVE_NOW) + && !java.util.Objects.equals(scanResult.getSecurityEvidence().policyVersion(), warden.currentPolicyVersion())) { + routingDecision = new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, 0); + scanResult.setVerdict("REVIEW"); + scanResult.setStatus(ScanStatus.SUSPICIOUS); + scanResult.setReusedReviewVersion(null); + var notes = new java.util.ArrayList<>(scanResult.getReviewerNotes() == null + ? java.util.List.of() : scanResult.getReviewerNotes()); + notes.add("The current inspection policy could not validate this result. A fresh review is required."); + scanResult.setReviewerNotes(notes); + } + if (targetVersion.getFindingReviewHead() != null) { + scanResult.setReusedReviewVersion(null); + scanResult.setReusedReviewApprovedAt(0); + if (!"BLOCK".equals(scanResult.getVerdict()) && scanResult.getStatus() != ScanStatus.INFECTED) { + scanResult.setVerdict("REVIEW"); + scanResult.setStatus(ScanStatus.SUSPICIOUS); + } + routingDecision = new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, 0); + var notes = new java.util.ArrayList<>(scanResult.getReviewerNotes()); + notes.add("Recorded finding decisions require a current moderator review before publication."); + scanResult.setReviewerNotes(notes); + } boolean approvedImmediately = routingDecision.action() == ScanRoutingService.RoutingAction.APPROVE_NOW; boolean notifyFlagged = routingDecision.action() == ScanRoutingService.RoutingAction.REQUIRE_REVIEW; - if (!scanPersistenceService.applyScanOutcome(projectId, versionId, expectedAttempt, scanResult, routingDecision)) { + if (approvedImmediately) { + targetVersion.setScanResult(scanResult); + targetVersion.setSecurityApprovalProjectId(project.getId()); + securityIssueAnalysisService.markIssuesAcceptedForApprovedVersion(targetVersion); + } + if (!(remote!=null ? scanPersistenceService.applyRemoteScanOutcome(remote,remotePoll,scanResult,routingDecision,targetVersion) + : requestId == null ? scanPersistenceService.applyScanOutcome(projectId, versionId, expectedAttempt, scanResult, routingDecision, targetVersion) + : scanPersistenceService.applyScanOutcome(projectId, versionId, expectedAttempt, scanResult, routingDecision, targetVersion, requestId))) { logger.info("Scan result ignored because a newer attempt already exists project={} version={} attempt={}", projectId, versionId, expectedAttempt); - return; + return false; } Project refreshed = projectRepository.findById(projectId).orElse(null); @@ -107,22 +186,18 @@ public void handleCompletedScan( webhookService.triggerAdminFlaggedVersionWebhook(refreshed, refreshedVersion, scanResult); } - notifyProjectSubmissionIfReady(projectId, versionId); + if (routingDecision.action() != ScanRoutingService.RoutingAction.DEFER) notifyProjectSubmissionIfReady(projectId, versionId); if (approvedImmediately && refreshed != null) { ProjectVersion approvedVersion = projectVersionAccessService.findById(refreshed, versionId); if (approvedVersion != null) { - int pruned = securityIssueAnalysisService.pruneApprovedScanResults(refreshed); - if (pruned > 0) { - projectRepository.save(refreshed); - projectService.evictProjectCache(refreshed); - } if (refreshed.getStatus() == ProjectStatus.PUBLISHED) { projectNotificationService.notifyUpdates(refreshed, approvedVersion.getVersionNumber()); projectNotificationService.notifyDependents(refreshed, approvedVersion.getVersionNumber()); } } } + return true; } public void handleScanFailure( @@ -132,8 +207,13 @@ public void handleScanFailure( int expectedAttempt, RuntimeException exception ) { + handleScanFailure(projectId, versionId, originalFilename, expectedAttempt, exception, null); + } + public void handleScanFailure(String projectId, String versionId, String originalFilename, int expectedAttempt, + RuntimeException exception, String requestId) { ScanResult degraded = scanRoutingService.buildPipelineErrorResult(exception, originalFilename, expectedAttempt); - boolean applied = scanPersistenceService.updateFailedScan(projectId, versionId, degraded, expectedAttempt); + boolean applied = requestId == null ? scanPersistenceService.updateFailedScan(projectId, versionId, degraded, expectedAttempt) + : scanPersistenceService.updateFailedScan(projectId, versionId, degraded, expectedAttempt, requestId); if (applied) { Project project = projectRepository.findById(projectId).orElse(null); @@ -151,10 +231,10 @@ public void handleTimedOutScan( String projectId, String versionId, String originalFilename, - int expectedAttempt + int expectedAttempt, ScanResult observed, long timeoutMillis ) { ScanResult timedOut = scanRoutingService.buildScanTimeoutResult(originalFilename, expectedAttempt); - boolean applied = scanPersistenceService.updateFailedScan(projectId, versionId, timedOut, expectedAttempt); + boolean applied = scanPersistenceService.updateTimedOutScan(projectId, versionId, timedOut, expectedAttempt, observed, timeoutMillis); if (!applied) { return; } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanExecutionService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanExecutionService.java index 8727b9dae..8490d8979 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanExecutionService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanExecutionService.java @@ -65,7 +65,8 @@ public ScanExecutionService( securityIssueAnalysisService, scanRoutingService, scanPersistenceService, - projectVersionAccessService + projectVersionAccessService, + wardenService ); this.wardenService = wardenService; this.storageService = storageService; @@ -88,18 +89,25 @@ public void enqueueBackgroundScan( boolean isManualRescan, int expectedAttempt ) { + enqueueBackgroundScan(projectId, versionId, filePath, originalFilename, isManualRescan, expectedAttempt, null); + } + public void enqueueBackgroundScan(String projectId, String versionId, String filePath, String originalFilename, + boolean isManualRescan, int expectedAttempt, String requestId) { + // The persisted request is the durable handoff; polling owns remote dispatch. + if (wardenService.remoteJobsEnabled()) return; taskExecutor.execute(() -> processBackgroundScan( projectId, versionId, filePath, originalFilename, isManualRescan, - expectedAttempt + expectedAttempt, requestId )); } @Scheduled(fixedDelayString = "${app.security.scan-recovery-check-ms:120000}") public void recoverStaleScanningVersions() { + if (wardenService.remoteJobsEnabled()) return; scanRecoveryService.recoverStaleScanningVersions(this::enqueueBackgroundScan); } @@ -118,9 +126,11 @@ private void processBackgroundScan( String filePath, String originalFilename, boolean isManualRescan, - int expectedAttempt + int expectedAttempt, String requestId ) { - if (!scanPersistenceService.markAttemptRunning(projectId, versionId, expectedAttempt)) { + if (wardenService.remoteJobsEnabled()) return; + if (!(requestId == null ? scanPersistenceService.markAttemptRunning(projectId, versionId, expectedAttempt) + : scanPersistenceService.markAttemptRunning(projectId, versionId, expectedAttempt, requestId))) { logger.info("Scan attempt skipped because state moved ahead project={} version={} attempt={}", projectId, versionId, expectedAttempt); return; } @@ -129,15 +139,12 @@ private void processBackgroundScan( try { byte[] fileBytes = storageService.download(filePath); - scanCompletionService.handleCompletedScan( - projectId, - versionId, - expectedAttempt, - isManualRescan, - wardenService.scanFile(fileBytes, originalFilename) - ); + var result = wardenService.scanFile(fileBytes, originalFilename); + if (requestId == null) scanCompletionService.handleCompletedScan(projectId, versionId, expectedAttempt, isManualRescan, result); + else scanCompletionService.handleCompletedScan(projectId, versionId, expectedAttempt, isManualRescan, result, requestId); } catch (RuntimeException e) { - scanCompletionService.handleScanFailure(projectId, versionId, originalFilename, expectedAttempt, e); + if (requestId == null) scanCompletionService.handleScanFailure(projectId, versionId, originalFilename, expectedAttempt, e); + else scanCompletionService.handleScanFailure(projectId, versionId, originalFilename, expectedAttempt, e, requestId); } } } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanPersistenceService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanPersistenceService.java index d92cfa2f9..3ee66045b 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanPersistenceService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanPersistenceService.java @@ -32,6 +32,9 @@ public ScanPersistenceService( } public boolean markAttemptRunning(String projectId, String versionId, int attempt) { + return markAttemptRunning(projectId, versionId, attempt, null); + } + public boolean markAttemptRunning(String projectId, String versionId, int attempt, String requestId) { Update update = new Update() .set("versions.$.scanResult.status", ScanStatus.SCANNING) .set("versions.$.scanResult.scanState", "SCANNING") @@ -41,7 +44,7 @@ public boolean markAttemptRunning(String projectId, String versionId, int attemp .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) .set("updatedAt", LocalDateTime.now().toString()); - return mongoTemplate.updateFirst(buildVersionAttemptQuery(projectId, versionId, attempt), update, Project.class) + return mongoTemplate.updateFirst(bindRequest(buildVersionAttemptQuery(projectId, versionId, attempt, "QUEUED"), requestId), update, Project.class) .getModifiedCount() > 0; } @@ -50,32 +53,125 @@ public boolean applyScanOutcome( String versionId, int expectedAttempt, ScanResult scanResult, - ScanRoutingService.RoutingDecision routingDecision + ScanRoutingService.RoutingDecision routingDecision, + ProjectVersion reviewedVersion ) { + return applyScanOutcome(projectId, versionId, expectedAttempt, scanResult, routingDecision, reviewedVersion, null); + } + public boolean applyScanOutcome(String projectId, String versionId, int expectedAttempt, ScanResult scanResult, + ScanRoutingService.RoutingDecision routingDecision, ProjectVersion reviewedVersion, String requestId) { + return applyOutcome(projectId,versionId,expectedAttempt,scanResult,routingDecision,reviewedVersion,requestId,null,null); + } + public boolean applyRemoteScanOutcome(RemoteReviewPollStore.Claim claim,ScanResult.RemoteReviewPoll poll,ScanResult result, + ScanRoutingService.RoutingDecision routing,ProjectVersion version) { + if(!remoteResultMatches(claim,poll,result,version) || routing.action()==ScanRoutingService.RoutingAction.DEFER)return false; + result.setRemoteReview(claim.binding());result.setRemotePoll(null);result.setManualRescan(claim.binding().manualRescan()); + var binding=claim.binding(); + return applyOutcome(binding.projectId(),binding.versionId(),binding.attempt(),result,routing,version,binding.requestId(),claim,poll); + } + private boolean applyOutcome(String projectId,String versionId,int expectedAttempt,ScanResult scanResult, + ScanRoutingService.RoutingDecision routingDecision,ProjectVersion reviewedVersion,String requestId, + RemoteReviewPollStore.Claim remote,ScanResult.RemoteReviewPoll poll) { + scanResult.setScanRequestId(requestId); + if (routingDecision.action() == ScanRoutingService.RoutingAction.SCHEDULE + || routingDecision.action() == ScanRoutingService.RoutingAction.APPROVE_NOW) { + String context = ArtifactReviewContext.automaticallyReviewableFingerprint(reviewedVersion); + if (reviewedVersion.getFindingReviewHead() != null || context == null || !context.equals(scanResult.getReviewedContextSha256()) + || !ArtifactClearancePolicy.cleared(scanResult)) return false; + } + if (routingDecision.action() == ScanRoutingService.RoutingAction.DEFER) { + scanResult.setStatus(ScanStatus.SCANNING); + scanResult.setScanState("WAITING_RETRY"); + scanResult.setScanTimestamp(System.currentTimeMillis()); + scanResult.setReviewerNotes(List.of("Inspection service capacity was temporarily unavailable. An automatic retry is pending; this version remains unpublished.")); + } Update update = new Update() .set("versions.$.scanResult", scanResult) .set("updatedAt", LocalDateTime.now().toString()); switch (routingDecision.action()) { case APPROVE_NOW -> { - update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.APPROVED); + update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.APPROVED) + .set("versions.$.securityApprovalProjectId", reviewedVersion.getSecurityApprovalProjectId()) + .set("versions.$.approvedReviewOrigins", reviewedVersion.getApprovedReviewOrigins()) + .set("versions.$.approvedFindingReviewHead", null) + .set("versions.$.approvedSecurityEvidence", reviewedVersion.getApprovedSecurityEvidence()) + .set("versions.$.approvedSecurityContextSha256", reviewedVersion.getApprovedSecurityContextSha256()) + .set("versions.$.securityApprovedAt", reviewedVersion.getSecurityApprovedAt()) + .set("versions.$.approvedIssueBaselines", reviewedVersion.getApprovedIssueBaselines()) + .set("versions.$.scanResult", null); update.set("versions.$.scheduledPublishDate", null); } case SCHEDULE -> { update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.SCHEDULED); update.set("versions.$.scheduledPublishDate", LocalDateTime.now().plusMinutes(routingDecision.delayMinutes()).toString()); } + case DEFER -> { + update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set("versions.$.scheduledPublishDate", null); + } case REQUIRE_REVIEW -> { update.set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING); update.set("versions.$.scheduledPublishDate", null); } } - return mongoTemplate.updateFirst(buildVersionAttemptQuery(projectId, versionId, expectedAttempt), update, Project.class) - .getModifiedCount() > 0; + String expectedHash = scanResult.getSecurityEvidence() == null ? null : scanResult.getSecurityEvidence().artifactSha256(); + Query target = bindRequest(buildVersionAttemptQueryBound(projectId, versionId, expectedAttempt, expectedHash, reviewedVersion, remote==null?"SCANNING":"REMOTE_REVIEW"), requestId); + boolean automatic = routingDecision.action() == ScanRoutingService.RoutingAction.APPROVE_NOW + || routingDecision.action() == ScanRoutingService.RoutingAction.SCHEDULE; + boolean linked = scanResult.getReusedReviewVersion() != null; + boolean validOrigins = !automatic || ArtifactReviewLineage.bind(mongoTemplate, projectId, scanResult, target); + if (validOrigins && outcomeWrite(remoteTarget(target,remote,poll),update,remote)) return true; + if (automatic && linked) { + ArtifactReviewLineage.invalidate(scanResult); + var hold = new Update().set("versions.$.scanResult", scanResult) + .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) + .set("versions.$.scheduledPublishDate", null).set("updatedAt", LocalDateTime.now().toString()); + if (outcomeWrite(remoteTarget(bindRequest(buildVersionAttemptQueryBound(projectId, versionId, expectedAttempt, expectedHash, + reviewedVersion, remote==null?"SCANNING":"REMOTE_REVIEW"), requestId),remote,poll),hold,remote)) + projectRepository.findById(projectId).ifPresent(projectService::evictProjectCache); + } + // A held fallback must not make the caller announce an approval. + return false; + } + + static boolean remoteResultMatches(RemoteReviewPollStore.Claim claim,ScanResult.RemoteReviewPoll poll,ScanResult result,ProjectVersion version) { + if(claim==null || claim.binding().origin()==null || claim.binding().jobId()==null || poll==null || !claim.token().equals(poll.token()) || poll.leaseUntil()==null + || poll.nextPollAt()==null || result==null || version==null || result.getSecurityEvidence()==null)return false; + var b=claim.binding();var e=result.getSecurityEvidence(); + return b.versionId().equals(version.getId()) && b.artifactSha256().equals(version.getHash()) && b.filePath().equals(version.getFileUrl()) + && b.contextSha256().equals(ArtifactReviewContext.automaticallyReviewableFingerprint(version)) + && b.contextSha256().equals(result.getReviewedContextSha256()) && b.requestId().equals(result.getScanRequestId()) + && b.artifactSha256().equals(e.artifactSha256()) && b.policyVersion().equals(e.policyVersion()); + } + private Query remoteTarget(Query target,RemoteReviewPollStore.Claim claim,ScanResult.RemoteReviewPoll poll) { + if(claim==null)return target; + var raw=target.getQueryObject();var version=raw.get("versions",org.bson.Document.class).get("$elemMatch",org.bson.Document.class); + version.put("scanResult.manualRescan",claim.binding().manualRescan()?true:new org.bson.Document("$in",java.util.Arrays.asList(false,null))); + var b=claim.binding(); + var bindingFields=new org.bson.Document("projectId",b.projectId()).append("versionId",b.versionId()).append("requestId",b.requestId()) + .append("attempt",b.attempt()).append("filePath",b.filePath()).append("artifactSha256",b.artifactSha256()).append("contextSha256",b.contextSha256()) + .append("policyVersion",b.policyVersion()).append("reviewConfigSha256",b.reviewConfigSha256()).append("origin",new org.bson.Document("deploymentId",b.origin().deploymentId()).append("callerScope",b.origin().callerScope())).append("jobId",b.jobId()).append("manualRescan",b.manualRescan()); + bindingFields.forEach((key,value)->version.put("scanResult.remoteReview."+key,value)); + version.put("scanResult.remotePoll",poll);version.put("fileUrl",b.filePath()); + var terms=new java.util.ArrayList();if(raw.containsKey("$expr"))terms.add(raw.get("$expr")); + terms.add(new org.bson.Document("$gt",List.of(poll.leaseUntil(),"$$NOW"))); + terms.add(new org.bson.Document("$eq",List.of(new org.bson.Document("$size",new org.bson.Document("$filter",new org.bson.Document("input","$versions") + .append("as","v").append("cond",new org.bson.Document("$eq",List.of("$$v._id",new org.bson.Document("$literal",claim.binding().versionId())))))),1))); + raw.put("$expr",new org.bson.Document("$and",terms)); + return new org.springframework.data.mongodb.core.query.BasicQuery(raw).collation(org.springframework.data.mongodb.core.query.Collation.of("simple")); + } + private boolean outcomeWrite(Query query,Update update,RemoteReviewPollStore.Claim remote) { + if(remote==null)return mongoTemplate.updateFirst(query,update,Project.class).getModifiedCount()>0; + var durable=new MongoTemplate(mongoTemplate.getMongoDatabaseFactory(),mongoTemplate.getConverter()); + durable.setWriteConcern(com.mongodb.WriteConcern.MAJORITY.withJournal(true).withWTimeout(10,java.util.concurrent.TimeUnit.SECONDS)); + return durable.updateFirst(query,update,Project.class).getModifiedCount()>0; } - public boolean queueRetryAttempt(String projectId, String versionId, int currentAttempt, ScanResult queued) { + public boolean queueRetryAttempt(String projectId, String versionId, int currentAttempt, ScanResult queued, ScanResult observed, long timeoutMillis) { + Query target = recoveryQuery(projectId, versionId, currentAttempt, observed, timeoutMillis); + if (target == null) return false; Update retryUpdate = new Update() .set("versions.$.scanResult", queued) .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) @@ -83,20 +179,54 @@ public boolean queueRetryAttempt(String projectId, String versionId, int current .set("updatedAt", LocalDateTime.now().toString()); return mongoTemplate.updateFirst( - buildVersionAttemptQuery(projectId, versionId, currentAttempt), + target, retryUpdate, Project.class ).getModifiedCount() > 0; } public boolean updateFailedScan(String projectId, String versionId, ScanResult failed, int expectedAttempt) { + return updateFailedScan(projectId, versionId, failed, expectedAttempt, null); + } + public boolean updateFailedScan(String projectId, String versionId, ScanResult failed, int expectedAttempt, String requestId) { + failed.setScanRequestId(requestId); + return updateFailure(projectId, failed, bindRequest(buildVersionAttemptQuery(projectId, versionId, expectedAttempt, "SCANNING", "QUEUED", "WAITING_RETRY", null), requestId)); + } + public boolean updateTimedOutScan(String projectId, String versionId, ScanResult failed, int expectedAttempt, + ScanResult observed, long timeoutMillis) { + Query target = recoveryQuery(projectId, versionId, expectedAttempt, observed, timeoutMillis); + if (observed != null) failed.setScanRequestId(observed.getScanRequestId()); + return target != null && updateFailure(projectId, failed, target); + } + private Query recoveryQuery(String projectId, String versionId, int attempt, ScanResult observed, long timeoutMillis) { + if (observed == null || observed.getStatus() != ScanStatus.SCANNING || timeoutMillis <= 0 + || Math.max(1, observed.getScanAttempt()) != attempt) return null; + String state = observed.getScanState(); + if (state != null && !java.util.Set.of("SCANNING", "QUEUED", "WAITING_RETRY").contains(state)) return null; + long expires; + try { expires = Math.addExact(Math.max(0, observed.getScanTimestamp()), timeoutMillis); } + catch (ArithmeticException overflow) { return null; } + var raw = buildVersionAttemptQuery(projectId, versionId, attempt, state).getQueryObject(); + var version = raw.get("versions", org.bson.Document.class).get("$elemMatch", org.bson.Document.class); + version.put("scanResult.scanTimestamp", observed.getScanTimestamp() == 0 + ? new org.bson.Document("$in", java.util.Arrays.asList(0L, null)) : observed.getScanTimestamp()); + raw.put("$expr", new org.bson.Document("$lt", List.of(expires, new org.bson.Document("$toLong", "$$NOW")))); + return bindRequest(new org.springframework.data.mongodb.core.query.BasicQuery(raw), observed.getScanRequestId()); + } + private Query bindRequest(Query query, String requestId) { + var raw = query.getQueryObject(); + raw.get("versions", org.bson.Document.class).get("$elemMatch", org.bson.Document.class) + .put("scanResult.scanRequestId", requestId); + return new org.springframework.data.mongodb.core.query.BasicQuery(raw); + } + private boolean updateFailure(String projectId, ScanResult failed, Query target) { Update update = new Update() .set("versions.$.scanResult", failed) .set("versions.$.reviewStatus", ProjectVersion.ReviewStatus.PENDING) .set("versions.$.scheduledPublishDate", null) .set("updatedAt", LocalDateTime.now().toString()); - boolean modified = mongoTemplate.updateFirst(buildVersionAttemptQuery(projectId, versionId, expectedAttempt), update, Project.class) + boolean modified = mongoTemplate.updateFirst(target, update, Project.class) .getModifiedCount() > 0; Project project = projectRepository.findById(projectId).orElse(null); @@ -112,6 +242,12 @@ public List findProjectsWithScanningVersions() { } private Query buildVersionAttemptQuery(String projectId, String versionId, int attempt) { + return buildVersionAttemptQuery(projectId, versionId, attempt, "SCANNING"); + } + private Query buildVersionAttemptQuery(String projectId, String versionId, int attempt, String... states) { + return buildVersionAttemptQueryBound(projectId, versionId, attempt, null, null, states); + } + private Query buildVersionAttemptQueryBound(String projectId, String versionId, int attempt, String expectedHash, ProjectVersion reviewedVersion, String... states) { Criteria attemptCriteria; if (attempt <= 1) { attemptCriteria = new Criteria().orOperator( @@ -123,12 +259,13 @@ private Query buildVersionAttemptQuery(String projectId, String versionId, int a attemptCriteria = Criteria.where("scanResult.scanAttempt").is(attempt); } - return new Query( - Criteria.where("_id").is(projectId) - .and("versions").elemMatch( - Criteria.where("_id").is(versionId) - .andOperator(attemptCriteria) - ) - ); + Criteria version = Criteria.where("_id").is(versionId) + .and("reviewStatus").is(ProjectVersion.ReviewStatus.PENDING) + .and("scanResult.status").is(ScanStatus.SCANNING) + .and("scanResult.scanState").in((Object[]) states) + .andOperator(attemptCriteria); + if (reviewedVersion != null) ArtifactReviewContext.bindSnapshot(version, reviewedVersion); + if (expectedHash != null) version.and("hash").is(expectedHash); + return new Query(Criteria.where("_id").is(projectId).and("versions").elemMatch(version)); } } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanRecoveryService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanRecoveryService.java index 18204ceb9..0a374ae19 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanRecoveryService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanRecoveryService.java @@ -63,10 +63,13 @@ public void recoverStaleScanningVersions(BackgroundScanScheduler backgroundScanS int nextAttempt = currentAttempt + 1; ScanResult queued = scanRoutingService.createQueuedScanResult( nextAttempt, - "Previous scan attempt timed out and was re-queued automatically." + "WAITING_RETRY".equals(scanResult.getScanState()) + ? "Inspection capacity was temporarily unavailable; review was re-queued automatically." + : "Previous scan attempt timed out and was re-queued automatically." ); - if (scanPersistenceService.queueRetryAttempt(project.getId(), version.getId(), currentAttempt, queued)) { + queued.setManualRescan(scanResult.isManualRescan()); + if (scanPersistenceService.queueRetryAttempt(project.getId(), version.getId(), currentAttempt, queued, scanResult, timeoutMs)) { logger.warn( "Recovered stale scan by retrying project={} version={} previousAttempt={} nextAttempt={}", project.getId(), @@ -80,8 +83,8 @@ public void recoverStaleScanningVersions(BackgroundScanScheduler backgroundScanS version.getId(), version.getFileUrl(), extractOriginalFilename(version.getFileUrl()), - false, - nextAttempt + queued.isManualRescan(), + nextAttempt, queued.getScanRequestId() ); } } else { @@ -89,7 +92,7 @@ public void recoverStaleScanningVersions(BackgroundScanScheduler backgroundScanS project.getId(), version.getId(), extractOriginalFilename(version.getFileUrl()), - currentAttempt + currentAttempt, scanResult, timeoutMs ); } } @@ -117,7 +120,7 @@ void enqueue( String filePath, String originalFilename, boolean isManualRescan, - int expectedAttempt + int expectedAttempt, String requestId ); } } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanRequestService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanRequestService.java index 1fd1964b0..586ccc1e3 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanRequestService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanRequestService.java @@ -7,7 +7,8 @@ import net.modtale.model.project.ProjectVersion; import net.modtale.model.project.ScanResult; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.VersionReviewPersistence; +import net.modtale.service.admin.review.VersionReviewSnapshot; import net.modtale.service.project.access.ProjectVersionAccessService; import net.modtale.service.project.query.ProjectService; import org.slf4j.Logger; @@ -19,7 +20,7 @@ public class ScanRequestService { private static final Logger logger = LoggerFactory.getLogger(ScanRequestService.class); - private final ProjectRepository projectRepository; + private final VersionReviewPersistence reviewPersistence; private final ProjectService projectService; private final ScanThrottleService scanThrottleService; private final ScanRoutingService scanRoutingService; @@ -27,14 +28,14 @@ public class ScanRequestService { private final ScanExecutionService scanExecutionService; public ScanRequestService( - ProjectRepository projectRepository, + VersionReviewPersistence reviewPersistence, ProjectService projectService, ScanThrottleService scanThrottleService, ScanRoutingService scanRoutingService, ProjectVersionAccessService projectVersionAccessService, ScanExecutionService scanExecutionService ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; this.projectService = projectService; this.scanThrottleService = scanThrottleService; this.scanRoutingService = scanRoutingService; @@ -58,13 +59,15 @@ public void triggerRescan(String projectId, String versionId, User user) { throw new InvalidProjectRequestException("This version does not have an uploaded file to scan."); } + var snapshot = reviewPersistence.captureForRescan(projectId, versionId, VersionReviewSnapshot.rescanToken(version)); int attempt = scanRoutingService.nextScanAttempt(version.getScanResult()); ScanResult pending = scanRoutingService.createQueuedScanResult(attempt, "Manual rescan requested."); + pending.setManualRescan(true); + if (!reviewPersistence.queueRescan(snapshot, pending)) throw VersionReviewPersistence.conflict(); version.setScanResult(pending); version.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); version.setScheduledPublishDate(null); - projectRepository.save(project); projectService.evictProjectCache(project); String originalFilename = scanExecutionService.extractOriginalFilename(version.getFileUrl()); @@ -76,7 +79,7 @@ public void triggerRescan(String projectId, String versionId, User user) { version.getFileUrl(), originalFilename, true, - attempt + attempt, pending.getScanRequestId() ); } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanRoutingService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanRoutingService.java index 348afe284..d504ac171 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanRoutingService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanRoutingService.java @@ -20,6 +20,7 @@ public ScanRoutingService(AppSecurityProperties securityProperties) { public ScanResult createQueuedScanResult(int attempt, String note) { ScanResult pending = new ScanResult(); + pending.setScanRequestId(java.util.UUID.randomUUID().toString()); pending.setStatus(ScanStatus.SCANNING); pending.setScanState("QUEUED"); pending.setRiskScore(0); @@ -47,46 +48,30 @@ public RoutingDecision decideRouting( SecurityIssueAnalysisService.ClassificationStats classification, boolean isManualRescan ) { - String verdict = scanResult.getVerdict() == null ? "" : scanResult.getVerdict().toUpperCase(); - ScanStatus status = scanResult.getStatus(); - - boolean block = "BLOCK".equals(verdict) || status == ScanStatus.INFECTED; - boolean explicitReview = "REVIEW".equals(verdict) - || status == ScanStatus.SUSPICIOUS - || status == ScanStatus.FLAGGED - || "UPSTREAM_UNAVAILABLE".equalsIgnoreCase(scanResult.getScanState()) - || hasRecoverableScannerErrors(scanResult); - - boolean knownOnly = classification.knownOnly(); - boolean hasEscalation = classification.escalatedIssueCount() > 0; - - if (isManualRescan) { - if (block || (explicitReview && (!knownOnly || hasEscalation))) { - return new RoutingDecision(RoutingAction.REQUIRE_REVIEW, 0); - } - return new RoutingDecision(RoutingAction.APPROVE_NOW, 0); - } - - if (block) { + if (!hasCompleteEvidence(scanResult) || "NEW_SECURITY_EVIDENCE".equals(scanResult.getSecurityEvidence().reviewState())) return new RoutingDecision(RoutingAction.REQUIRE_REVIEW, 0); + String verdict = scanResult.getVerdict(); + if ("BLOCK".equals(verdict) || scanResult.getStatus() == ScanStatus.INFECTED) { return new RoutingDecision(RoutingAction.REQUIRE_REVIEW, 0); } - - if (explicitReview) { - if (knownOnly && !hasEscalation && !"UPSTREAM_UNAVAILABLE".equalsIgnoreCase(scanResult.getScanState())) { - return new RoutingDecision( - RoutingAction.SCHEDULE, - randomDelay(securityProperties.knownRiskDelayMinutesMin(), securityProperties.knownRiskDelayMinutesMax()) - ); - } + boolean cleared = "AUTO_APPROVE".equals(verdict) && scanResult.getStatus() == ScanStatus.CLEAN + && scanResult.getSecurityEvidence().clearanceGranted(); + boolean reused = scanResult.getReusedReviewVersion() != null; + if (!cleared && !reused) { + if (java.util.Set.of("RATE_LIMITED", "TIMEOUT", "UPSTREAM_ERROR", "INTERRUPTED") + .contains(scanResult.getSecurityEvidence().reviewState() == null ? "" : scanResult.getSecurityEvidence().reviewState()) + && Math.max(1, scanResult.getScanAttempt()) <= Math.max(1, scanMaxRetries())) + return new RoutingDecision(RoutingAction.DEFER, 0); return new RoutingDecision(RoutingAction.REQUIRE_REVIEW, 0); } + if (isManualRescan) return new RoutingDecision(RoutingAction.APPROVE_NOW, 0); + long delay = reused + ? randomDelay(securityProperties.knownRiskDelayMinutesMin(), securityProperties.knownRiskDelayMinutesMax()) + : randomDelay(securityProperties.autoApproveDelayMinutesMin(), securityProperties.autoApproveDelayMinutesMax()); + return new RoutingDecision(RoutingAction.SCHEDULE, Math.max(holdDelayFromWarden(scanResult), delay)); + } - long holdDelayMinutes = holdDelayFromWarden(scanResult); - long randomizedDelay = randomDelay( - securityProperties.autoApproveDelayMinutesMin(), - securityProperties.autoApproveDelayMinutesMax() - ); - return new RoutingDecision(RoutingAction.SCHEDULE, Math.max(holdDelayMinutes, randomizedDelay)); + public boolean hasCompleteEvidence(ScanResult result) { + return ArtifactClearancePolicy.complete(result); } public long scanTimeoutMillis() { @@ -184,6 +169,7 @@ private long randomDelay(long minMinutes, long maxMinutes) { public enum RoutingAction { REQUIRE_REVIEW, + DEFER, SCHEDULE, APPROVE_NOW } diff --git a/backend/src/main/java/net/modtale/service/security/scan/ScanService.java b/backend/src/main/java/net/modtale/service/security/scan/ScanService.java index 506568968..36c67c057 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/ScanService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/ScanService.java @@ -47,4 +47,9 @@ public void enqueueBackgroundScan( expectedAttempt ); } + public void enqueueBackgroundScan(String projectId, String versionId, String filePath, String originalFilename, + boolean isManualRescan, int expectedAttempt, String requestId) { + scanExecutionService.enqueueBackgroundScan(projectId, versionId, filePath, originalFilename, isManualRescan, expectedAttempt, requestId); + } + } diff --git a/backend/src/main/java/net/modtale/service/security/scan/WardenClientService.java b/backend/src/main/java/net/modtale/service/security/scan/WardenClientService.java index cb54f89bd..5879959b9 100644 --- a/backend/src/main/java/net/modtale/service/security/scan/WardenClientService.java +++ b/backend/src/main/java/net/modtale/service/security/scan/WardenClientService.java @@ -9,6 +9,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.ByteArrayResource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.stereotype.Service; @@ -22,16 +24,43 @@ public class WardenClientService { private final WebClient webClient; private final AppWardenProperties wardenProperties; - public WardenClientService( - AppWardenProperties wardenProperties) { + private final boolean remoteJobsEnabled; + + public WardenClientService(AppWardenProperties wardenProperties) { + this(wardenProperties, false); + } + + @Autowired + public WardenClientService(AppWardenProperties wardenProperties, + @Value("${app.warden.jobs.enabled:false}") boolean remoteJobsEnabled) { + this.remoteJobsEnabled = remoteJobsEnabled; this.wardenProperties = wardenProperties; this.webClient = WebClient.builder() .baseUrl(wardenProperties.url()) + .codecs(config -> config.defaultCodecs().maxInMemorySize(32 * 1024 * 1024)) .defaultHeader("X-Warden-Api-Key", wardenProperties.apiKey()) .build(); } + public boolean remoteJobsEnabled() { return remoteJobsEnabled; } + + public record PolicyResponse(String policyVersion) {} + + public String currentPolicyVersion() { + if (!wardenProperties.enabled()) return null; + try { + var response = webClient.get().uri("/api/v1/policy").retrieve() + .bodyToMono(PolicyResponse.class).timeout(Duration.ofSeconds(10)).block(); + String policy = response == null ? null : response.policyVersion(); + return policy != null && policy.matches("warden-3\\.0\\.0:[0-9a-f]{64}") ? policy : null; + } catch (RuntimeException unavailable) { + logger.warn("Current scanner policy is unavailable; automatic publication is deferred"); + return null; + } + } + public ScanResult scanFile(byte[] fileBytes, String filename) { + if (remoteJobsEnabled) throw new IllegalStateException("Synchronous scans are disabled in remote jobs mode"); if (!wardenProperties.enabled()) { logger.error("Warden scanner is DISABLED. Falling back to manual-review degraded result for file: {}", filename); return buildDegradedResult(filename, new IllegalStateException("Warden scanner disabled")); @@ -59,6 +88,11 @@ public String getFilename() { .block(); if (response != null) { + var evidence = response.getSecurityEvidence(); + String expectedDigest = java.util.HexFormat.of().formatHex( + java.security.MessageDigest.getInstance("SHA-256").digest(fileBytes)); + response.setArtifactVerified(evidence != null && evidence.complete() + && expectedDigest.equals(evidence.artifactSha256())); return response; } throw new IllegalStateException("Warden returned empty response body"); @@ -75,6 +109,34 @@ public String getFilename() { return buildDegradedResult(filename, lastError); } + public record InspectionResponse(String artifactSha256, List paths, String content, String format, java.util.Map entryHashes, String policyVersion) {} + + public InspectionResponse inspectFile(byte[] bytes, String filename, String path) { + if (!wardenProperties.enabled()) throw new IllegalStateException("Artifact inspection is unavailable"); + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + builder.part("file", new ByteArrayResource(bytes) { + @Override public String getFilename() { return filename; } + }); + if (path != null) builder.part("path", path); + return webClient.post().uri("/api/v1/inspect").contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(builder.build())).retrieve() + .bodyToMono(InspectionResponse.class).timeout(Duration.ofSeconds(90)).block(); + } + + public record InspectionWindow(String artifactSha256, String path, String entrySha256, String policyVersion, + String representationSha256, String format, int start, int end, int totalCharacters, int firstLine, + boolean lineMatched, boolean representationComplete, String content, List gaps) {} + public InspectionWindow inspectWindow(byte[] bytes, String path, int offset, int characters, int sourceLine) { + if (!wardenProperties.enabled()) throw new IllegalStateException("Artifact inspection is unavailable"); + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + builder.part("file", new ByteArrayResource(bytes) { @Override public String getFilename() { return "artifact.zip"; } }); + builder.part("path", path); builder.part("offset", Integer.toString(offset)); + builder.part("characters", Integer.toString(characters)); builder.part("sourceLine", Integer.toString(sourceLine)); + return webClient.post().uri("/api/v1/inspect-window").contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(builder.build())).retrieve() + .bodyToMono(InspectionWindow.class).timeout(Duration.ofSeconds(90)).block(); + } + private void backoff(int attempt) { long delayMs = Math.min(3000L, 400L * attempt); try { diff --git a/backend/src/main/java/net/modtale/service/social/FavoritePersistence.java b/backend/src/main/java/net/modtale/service/social/FavoritePersistence.java new file mode 100644 index 000000000..c4bbe32d4 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/social/FavoritePersistence.java @@ -0,0 +1,63 @@ +package net.modtale.service.social; + +import com.mongodb.ReadConcern; +import com.mongodb.ReadPreference; +import com.mongodb.TransactionOptions; +import com.mongodb.WriteConcern; +import com.mongodb.ClientSessionOptions; +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoCollection; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import net.modtale.exception.ResourceNotFoundException; +import net.modtale.model.project.Project; +import net.modtale.model.user.User; +import net.modtale.util.MongoIdUtils; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; + +@Service +public class FavoritePersistence { + private final MongoTemplate mongo; + private static final TransactionOptions OPTIONS = TransactionOptions.builder() + .readConcern(ReadConcern.SNAPSHOT).writeConcern(WriteConcern.MAJORITY) + .readPreference(ReadPreference.primary()).maxCommitTime(10L, TimeUnit.SECONDS).build(); + + public FavoritePersistence(MongoTemplate mongo) { this.mongo = mongo; } + + public int toggle(String projectId, String userId) { + try (var session = mongo.getMongoDatabaseFactory().getSession(ClientSessionOptions.builder().build())) { + return session.withTransaction(() -> { + var projects = mongo.getCollection(mongo.getCollectionName(Project.class)); + var users = mongo.getCollection(mongo.getCollectionName(User.class)); + var project = requireUnique(projects, session, projectId); + var user = requireUnique(users, session, userId); + if (user.get("deletedAt") != null || "DELETED".equals(project.getString("status"))) + throw new ResourceNotFoundException("Project or account is no longer available."); + String canonicalId = project.get("_id").toString(); + var likes = user.getList("likedModIds", String.class, List.of()); + boolean removing = likes != null && likes.contains(canonicalId); + var count = project.get("favoriteCount", Number.class); + int nextCount = Math.toIntExact(Math.max(0L, (count == null ? 0L : count.longValue()) + (removing ? -1 : 1))); + var userChange = new Document(removing ? "$pull" : "$addToSet", new Document("likedModIds", canonicalId)); + // Legacy null arrays are initialized in the same transaction before the set operation. + if (user.containsKey("likedModIds") && user.get("likedModIds") == null) + users.updateOne(session, new Document("_id", user.get("_id")), new Document("$set", new Document("likedModIds", List.of()))); + users.updateOne(session, new Document("_id", user.get("_id")), userChange); + projects.updateOne(session, new Document("_id", project.get("_id")), + new Document("$set", new Document("favoriteCount", nextCount).append("rankingDirty", true))); + return nextCount; + }, OPTIONS); + } + } + + private Document requireUnique(MongoCollection collection, ClientSession session, String id) { + if (id == null || id.isBlank()) throw new ResourceNotFoundException("Project or account not found."); + var matches = collection.find(session, new Document("_id", new Document("$in", MongoIdUtils.expandIds(List.of(id))))) + .limit(2).into(new ArrayList<>()); + if (matches.size() != 1) throw new ResourceNotFoundException("Project or account not found."); + return matches.getFirst(); + } +} diff --git a/backend/src/main/java/net/modtale/service/social/ProjectSocialService.java b/backend/src/main/java/net/modtale/service/social/ProjectSocialService.java index 3443d1c5b..e238f266f 100644 --- a/backend/src/main/java/net/modtale/service/social/ProjectSocialService.java +++ b/backend/src/main/java/net/modtale/service/social/ProjectSocialService.java @@ -9,68 +9,48 @@ import net.modtale.model.project.Comment; import net.modtale.model.project.Project; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; import net.modtale.repository.user.UserRepository; -import net.modtale.service.analytics.ScoringService; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; import net.modtale.service.communication.NotificationService; import net.modtale.service.project.query.ProjectService; import net.modtale.service.security.validation.SanitizationService; final class ProjectSocialService { - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; + private final FavoritePersistence favorites; private final UserRepository userRepository; private final ProjectService projectService; private final NotificationService notificationService; private final SanitizationService sanitizer; - private final ScoringService scoringService; ProjectSocialService( - ProjectRepository projectRepository, + FavoritePersistence favorites, UserRepository userRepository, ProjectService projectService, NotificationService notificationService, SanitizationService sanitizer, - ScoringService scoringService + ProjectReviewPersistence reviewPersistence ) { - this.projectRepository = projectRepository; + this.reviewPersistence = reviewPersistence; + this.favorites = favorites; this.userRepository = userRepository; this.projectService = projectService; this.notificationService = notificationService; this.sanitizer = sanitizer; - this.scoringService = scoringService; } void toggleFavorite(String projectId, String userId) { Project project = getProject(projectId); - User user = getUser(userId); - int originalFavoriteCount = project.getFavoriteCount(); - - List likes = user.getLikedModIds(); - if (likes == null) { - likes = new ArrayList<>(); - user.setLikedModIds(likes); - } - - String canonicalProjectId = project.getId(); - if (likes.contains(canonicalProjectId)) { - likes.remove(canonicalProjectId); - project.setFavoriteCount(Math.max(0, project.getFavoriteCount() - 1)); - } else { - likes.add(canonicalProjectId); - project.setFavoriteCount(project.getFavoriteCount() + 1); - } - if (project.getFavoriteCount() != originalFavoriteCount) { - scoringService.markProjectRankingDirty(project); - } - - userRepository.save(user); - projectRepository.save(project); + project.setFavoriteCount(favorites.toggle(project.getId(), userId)); projectService.evictProjectCache(project); } void addComment(String projectId, String userId, String content) { - Project project = getProject(projectId); + Project original = getProject(projectId); + var snapshot = reviewPersistence.capture(original.getId(), ProjectReviewSnapshot.token(original)); + Project project = snapshot.project(); if (!project.isAllowComments()) { throw new ForbiddenOperationException("Comments are disabled for this project."); } @@ -80,7 +60,7 @@ void addComment(String projectId, String userId, String content) { project.setComments(new ArrayList<>()); } project.getComments().add(0, new Comment(user.getId(), sanitizer.sanitizePlainText(content))); - projectRepository.save(project); + if (!reviewPersistence.applyComments(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); if (project.getAuthorId() != null && !project.getAuthorId().equals(userId)) { @@ -98,7 +78,9 @@ void addComment(String projectId, String userId, String content) { } void editComment(String projectId, String commentId, String userId, String newContent) { - Project project = getProject(projectId); + Project original = getProject(projectId); + var snapshot = reviewPersistence.capture(original.getId(), ProjectReviewSnapshot.token(original)); + Project project = snapshot.project(); Comment comment = project.getComments().stream() .filter(candidate -> candidate.getId().equals(commentId)) @@ -110,12 +92,14 @@ void editComment(String projectId, String commentId, String userId, String newCo comment.setContent(sanitizer.sanitizePlainText(newContent)); comment.setUpdatedAt(LocalDateTime.now().toString()); - projectRepository.save(project); + if (!reviewPersistence.applyComments(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } void voteComment(String projectId, String commentId, String userId, boolean upvote) { - Project project = getProject(projectId); + Project original = getProject(projectId); + var snapshot = reviewPersistence.capture(original.getId(), ProjectReviewSnapshot.token(original)); + Project project = snapshot.project(); if (project.getComments() == null) { throw new ResourceNotFoundException("Project not found."); } @@ -139,12 +123,14 @@ void voteComment(String projectId, String commentId, String userId, boolean upvo comment.getUpvotes().remove(userId); } - projectRepository.save(project); + if (!reviewPersistence.applyComments(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } void setCommentPinned(String projectId, String commentId, boolean pinned) { - Project project = getProject(projectId); + Project original = getProject(projectId); + var snapshot = reviewPersistence.capture(original.getId(), ProjectReviewSnapshot.token(original)); + Project project = snapshot.project(); if (project.getComments() == null) { throw new ResourceNotFoundException("Comment not found."); } @@ -153,7 +139,7 @@ void setCommentPinned(String projectId, String commentId, boolean pinned) { .findFirst() .orElseThrow(() -> new ResourceNotFoundException("Comment not found.")); comment.setPinned(pinned); - projectRepository.save(project); + if (!reviewPersistence.applyComments(snapshot)) throw ProjectReviewSnapshot.conflict(); projectService.evictProjectCache(project); } diff --git a/backend/src/main/java/net/modtale/service/social/SocialService.java b/backend/src/main/java/net/modtale/service/social/SocialService.java index 7ec5d8c4b..ab0bfd746 100644 --- a/backend/src/main/java/net/modtale/service/social/SocialService.java +++ b/backend/src/main/java/net/modtale/service/social/SocialService.java @@ -2,9 +2,8 @@ import java.util.List; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; import net.modtale.repository.user.UserRepository; -import net.modtale.service.analytics.ScoringService; +import net.modtale.service.admin.review.ProjectReviewPersistence; import net.modtale.service.communication.NotificationService; import net.modtale.service.project.query.ProjectService; import net.modtale.service.security.validation.SanitizationService; @@ -18,21 +17,21 @@ public class SocialService { private final UserFollowService userFollowService; public SocialService( - ProjectRepository projectRepository, + FavoritePersistence favorites, UserRepository userRepository, ProjectService projectService, NotificationService notificationService, SanitizationService sanitizer, MongoTemplate mongoTemplate, - ScoringService scoringService + ProjectReviewPersistence reviewPersistence ) { this.projectSocialService = new ProjectSocialService( - projectRepository, + favorites, userRepository, projectService, notificationService, sanitizer, - scoringService + reviewPersistence ); this.userFollowService = new UserFollowService(userRepository, notificationService, mongoTemplate); } diff --git a/backend/src/main/java/net/modtale/service/storage/DownloadService.java b/backend/src/main/java/net/modtale/service/storage/DownloadService.java index 323185163..498913382 100644 --- a/backend/src/main/java/net/modtale/service/storage/DownloadService.java +++ b/backend/src/main/java/net/modtale/service/storage/DownloadService.java @@ -6,7 +6,7 @@ import net.modtale.model.project.Project; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; import net.modtale.service.project.query.ProjectService; import org.springframework.stereotype.Service; @@ -18,14 +18,14 @@ public class DownloadService { private final BundlePackagingService bundlePackagingService; public DownloadService( - ProjectRepository projectRepository, + ProjectReviewPersistence reviewPersistence, ProjectService projectService, StorageService storageService, AppLimitProperties limitProperties ) { DownloadArchiveSupport archiveSupport = new DownloadArchiveSupport(projectService, storageService); this.rateLimitService = new DownloadRateLimitService(limitProperties.modpackGenPerHour()); - this.modpackArchiveService = new ModpackArchiveService(projectRepository, archiveSupport); + this.modpackArchiveService = new ModpackArchiveService(reviewPersistence, archiveSupport); this.bundlePackagingService = new BundlePackagingService(archiveSupport); } diff --git a/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java index 38ee5a065..5ac027ba7 100644 --- a/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java +++ b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java @@ -25,7 +25,9 @@ import net.modtale.model.project.Project; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; +import net.modtale.service.admin.review.ProjectReviewSnapshot; +import net.modtale.service.admin.review.VersionReviewSnapshot; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,22 +41,24 @@ final class ModpackArchiveService { private static final String MANIFEST = "manifest.json"; private static final String LOCKFILE = "modtale.lock.json"; - private final ProjectRepository projectRepository; + private final ProjectReviewPersistence reviewPersistence; private final DownloadArchiveSupport archiveSupport; - ModpackArchiveService(ProjectRepository projectRepository, DownloadArchiveSupport archiveSupport) { - this.projectRepository = projectRepository; + ModpackArchiveService(ProjectReviewPersistence reviewPersistence, DownloadArchiveSupport archiveSupport) { + this.reviewPersistence = reviewPersistence; this.archiveSupport = archiveSupport; } byte[] generateModpackZip(Project pack, ProjectVersion version) throws IOException { + String projectToken = ProjectReviewSnapshot.token(pack); + String versionToken = VersionReviewSnapshot.token(version); byte[] cachedArchive = downloadCachedArchive(pack, version); if (cachedArchive != null) { return cachedArchive; } byte[] zipBytes = buildArchive(pack, version); - cacheArchive(pack, version, zipBytes); + cacheArchive(pack, version, zipBytes, projectToken, versionToken); return zipBytes; } @@ -438,7 +442,7 @@ private String sha256(byte[] bytes) { } } - private void cacheArchive(Project pack, ProjectVersion version, byte[] zipBytes) { + private void cacheArchive(Project pack, ProjectVersion version, byte[] zipBytes, String projectToken, String versionToken) { try { String fileName = (pack.getSlug() != null && !pack.getSlug().isEmpty() ? pack.getSlug() : pack.getId()) + "-" @@ -449,8 +453,9 @@ private void cacheArchive(Project pack, ProjectVersion version, byte[] zipBytes) "modpacks" ); + if (!reviewPersistence.cacheModpackArchive(pack.getId(), projectToken, version.getId(), versionToken, uploadPath)) + throw ProjectReviewSnapshot.conflict(); version.setFileUrl(uploadPath); - projectRepository.save(pack); } catch (StorageUploadException ex) { logger.warn("Generated modpack archive could not be cached for project={} version={}", pack.getId(), version.getVersionNumber(), ex); diff --git a/backend/src/main/java/net/modtale/service/storage/ProjectMediaKeys.java b/backend/src/main/java/net/modtale/service/storage/ProjectMediaKeys.java new file mode 100644 index 000000000..926195b8c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/storage/ProjectMediaKeys.java @@ -0,0 +1,24 @@ +package net.modtale.service.storage; + +/** Server-created project namespaces provide deletion authority for new media uploads. */ +public final class ProjectMediaKeys { + private ProjectMediaKeys() {} + public static String prefix(String projectId, String kind) { + if (projectId == null || !projectId.matches("[a-zA-Z0-9_-]{1,128}") + || !("images".equals(kind) || "gallery".equals(kind))) throw new IllegalArgumentException("Invalid project media namespace"); + return "project-media/" + projectId + "/" + kind; + } + public static String ownedKey(String projectId, String location, String publicDomain) { + if (projectId == null || !projectId.matches("[a-zA-Z0-9_-]{1,128}") || location == null) return null; + String key = location; + if (key.startsWith("/api/files/proxy/")) key = key.substring("/api/files/proxy/".length()); + else if (publicDomain != null && !publicDomain.isBlank()) { + String base = publicDomain.replaceAll("/+$", "") + "/"; + if (key.startsWith(base)) key = key.substring(base.length()); + } + String namespace = "project-media/" + projectId + "/"; + if (!key.startsWith(namespace)) return null; + String leaf = key.substring(namespace.length()); + return leaf.matches("(images|gallery)/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-[a-zA-Z0-9._-]+") ? key : null; + } +} diff --git a/backend/src/main/java/net/modtale/service/storage/StorageService.java b/backend/src/main/java/net/modtale/service/storage/StorageService.java index 0c7d8f8ce..144605ec3 100644 --- a/backend/src/main/java/net/modtale/service/storage/StorageService.java +++ b/backend/src/main/java/net/modtale/service/storage/StorageService.java @@ -138,6 +138,15 @@ public void uploadDirect(String path, byte[] data, String contentType) { s3Client.putObject(putOb, RequestBody.fromBytes(data)); } + public void deleteOwnedProjectMedia(String projectId, String location, java.util.Collection remainingReferences) { + String key = ProjectMediaKeys.ownedKey(projectId, location, publicDomain); + // Legacy or foreign references are not proof of ownership and require reconciled cleanup. + if (key == null || remainingReferences == null) return; + for (String reference : remainingReferences) + if (key.equals(ProjectMediaKeys.ownedKey(projectId, reference, publicDomain))) return; + deleteFile(key); + } + public void deleteFile(String fileName) { if (fileName == null || fileName.isEmpty()) return; @@ -199,6 +208,17 @@ public byte[] download(String fileName) { } } + public byte[] downloadBounded(String fileName,int maxBytes) { + if(maxBytes<1 || maxBytes>100*1024*1024)throw new IllegalArgumentException("Invalid download limit"); + try (ResponseInputStream response=s3Client.getObject(GetObjectRequest.builder().bucket(bucketName).key(fileName).build())) { + Long size=response.response().contentLength(); + if(size!=null && (size<0 || size>maxBytes)){response.abort();throw new IOException("Stored artifact exceeds download limit");} + byte[] bytes=response.readNBytes(maxBytes+1); + if(bytes.length>maxBytes || size!=null && size!=bytes.length){response.abort();throw new IOException("Stored artifact length is invalid");} + return bytes; + } catch(IOException | SdkException failure) {throw StorageDownloadException.from(failure,"Failed to download the requested artifact.");} + } + public java.util.Set findExistingKeys(java.util.Set requiredKeys) { java.util.Set found = new java.util.HashSet<>(); if (requiredKeys.isEmpty()) return found; diff --git a/backend/src/main/java/net/modtale/service/user/account/AccountPreferencesPersistence.java b/backend/src/main/java/net/modtale/service/user/account/AccountPreferencesPersistence.java new file mode 100644 index 000000000..9e1788ef1 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/user/account/AccountPreferencesPersistence.java @@ -0,0 +1,46 @@ +package net.modtale.service.user.account; + +import java.util.ArrayList; +import java.util.List; +import net.modtale.model.user.User; +import net.modtale.util.MongoIdUtils; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; + +@Service +public class AccountPreferencesPersistence { + public enum Field { + AVATAR("avatarUrl"), BANNER("bannerUrl"), NOTIFICATIONS("notificationPreferences"), + LAUNCHER("launcherSettings"); + private final String key; + Field(String key) { this.key = key; } + } + + public record Snapshot(Document raw, User user) {} + private final MongoTemplate mongo; + + public AccountPreferencesPersistence(MongoTemplate mongo) { this.mongo = mongo; } + + public Snapshot capture(String id) { + if (id == null || id.isBlank()) return null; + var matches = mongo.getCollection(mongo.getCollectionName(User.class)) + .find(new Document("_id", new Document("$in", MongoIdUtils.expandIds(List.of(id))))) + .limit(2).into(new ArrayList<>()); + if (matches.size() != 1 || matches.getFirst().get("deletedAt") != null) return null; + var raw = matches.getFirst(); + return new Snapshot(raw, mongo.getConverter().read(User.class, raw)); + } + + public boolean update(Snapshot snapshot, Field field) { + if (snapshot == null || field == null) return false; + var converted = new Document(); + mongo.getConverter().write(snapshot.user(), converted); + // The enum is the complete writable surface; no account authority fields are accepted. + var update = new Document("$set", new Document(field.key, converted.get(field.key))); + return mongo.getCollection(mongo.getCollectionName(User.class)).updateOne( + new Document("_id", snapshot.raw().get("_id")).append("$expr", + new Document("$eq", List.of("$$ROOT", new Document("$literal", snapshot.raw())))), + update).getMatchedCount() == 1; + } +} diff --git a/backend/src/main/java/net/modtale/service/user/account/AccountService.java b/backend/src/main/java/net/modtale/service/user/account/AccountService.java index 33bdf08c1..2efb31f28 100644 --- a/backend/src/main/java/net/modtale/service/user/account/AccountService.java +++ b/backend/src/main/java/net/modtale/service/user/account/AccountService.java @@ -33,6 +33,7 @@ public class AccountService { private static final int MAX_LAUNCHER_SYNC_HASH = 128; private final UserRepository userRepository; + private final AccountPreferencesPersistence preferencesPersistence; private final MongoTemplate mongoTemplate; private final SanitizationService sanitizer; private final CurrentUserResolutionService currentUserResolutionService; @@ -47,9 +48,11 @@ public AccountService( CurrentUserResolutionService currentUserResolutionService, OAuthAvatarHealingService oauthAvatarHealingService, AccountLifecycleService accountLifecycleService, - ConnectedAccountMutationService connectedAccountMutationService + ConnectedAccountMutationService connectedAccountMutationService, + AccountPreferencesPersistence preferencesPersistence ) { this.userRepository = userRepository; + this.preferencesPersistence = preferencesPersistence; this.mongoTemplate = mongoTemplate; this.sanitizer = sanitizer; this.currentUserResolutionService = currentUserResolutionService; @@ -83,10 +86,6 @@ public List searchUsers(String query) { return mongoTemplate.find(dbQuery, User.class); } - public User saveUser(User user) { - return userRepository.save(user); - } - public User getPublicProfile(String userId) { if (userId == null || userId.isBlank()) { return null; @@ -150,21 +149,24 @@ public User updateUserProfile(String userId, String bio, String newUsername) { } public void updateUserAvatar(String userId, String url) { - User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found.")); + var state = requirePreferencesSnapshot(userId); + User user = state.user(); user.setAvatarUrl(url); - userRepository.save(user); + persistPreferences(state, AccountPreferencesPersistence.Field.AVATAR); } public void updateUserBanner(String userId, String url) { - User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found.")); + var state = requirePreferencesSnapshot(userId); + User user = state.user(); user.setBannerUrl(url); - userRepository.save(user); + persistPreferences(state, AccountPreferencesPersistence.Field.BANNER); } public void updateNotificationPreferences(String userId, User.NotificationPreferences prefs) { - User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found.")); + var state = requirePreferencesSnapshot(userId); + User user = state.user(); user.setNotificationPreferences(prefs); - userRepository.save(user); + persistPreferences(state, AccountPreferencesPersistence.Field.NOTIFICATIONS); } public LauncherSettingsSnapshot getLauncherSettings(String userId) { @@ -173,18 +175,20 @@ public LauncherSettingsSnapshot getLauncherSettings(String userId) { } public LauncherSettingsSnapshot updateLauncherSettings(String userId, LauncherSettingsSnapshot snapshot) { - User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found.")); + var state = requirePreferencesSnapshot(userId); + User user = state.user(); LauncherSettingsSnapshot normalized = normalizeLauncherSettings(snapshot, user.getLauncherSettings()); user.setLauncherSettings(normalized); - userRepository.save(user); + persistPreferences(state, AccountPreferencesPersistence.Field.LAUNCHER); return normalized; } public LauncherSettingsSnapshot updateLauncherSettingsPreferences(String userId, LauncherSettingsSnapshot snapshot) { - User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found.")); + var state = requirePreferencesSnapshot(userId); + User user = state.user(); LauncherSettingsSnapshot normalized = normalizeLauncherSettingsPreferences(snapshot, user.getLauncherSettings()); user.setLauncherSettings(normalized); - userRepository.save(user); + persistPreferences(state, AccountPreferencesPersistence.Field.LAUNCHER); return normalized; } @@ -241,6 +245,21 @@ public void recoverUser(String userId) { accountLifecycleService.recoverUser(userId); } + private AccountPreferencesPersistence.Snapshot requirePreferencesSnapshot(String userId) { + var snapshot = preferencesPersistence.capture(userId); + if (snapshot == null) throw new ResourceNotFoundException("User not found."); + return snapshot; + } + + private void persistPreferences(AccountPreferencesPersistence.Snapshot snapshot, + AccountPreferencesPersistence.Field field) { + if (!preferencesPersistence.update(snapshot, field)) { + throw new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.CONFLICT, + "Account changed while saving settings. Refresh and retry."); + } + } + private LauncherSettingsSnapshot normalizeLauncherSettings(LauncherSettingsSnapshot snapshot, LauncherSettingsSnapshot existing) { LauncherSettingsSnapshot source = snapshot == null ? new LauncherSettingsSnapshot() : snapshot; LauncherSettingsSnapshot normalized = new LauncherSettingsSnapshot(); diff --git a/backend/src/main/java/net/modtale/service/user/account/OAuthAvatarHealingService.java b/backend/src/main/java/net/modtale/service/user/account/OAuthAvatarHealingService.java index 5a03eb365..21aec60fa 100644 --- a/backend/src/main/java/net/modtale/service/user/account/OAuthAvatarHealingService.java +++ b/backend/src/main/java/net/modtale/service/user/account/OAuthAvatarHealingService.java @@ -8,7 +8,6 @@ import java.util.concurrent.ConcurrentHashMap; import net.modtale.model.user.OAuthProvider; import net.modtale.model.user.User; -import net.modtale.repository.user.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -19,16 +18,17 @@ public class OAuthAvatarHealingService { private static final Logger logger = LoggerFactory.getLogger(OAuthAvatarHealingService.class); - private final UserRepository userRepository; + private final UserAvatarPersistence persistence; private final RestClient restClient; private final Map avatarHealCooldown = new ConcurrentHashMap<>(); - public OAuthAvatarHealingService(UserRepository userRepository) { - this.userRepository = userRepository; + public OAuthAvatarHealingService(UserAvatarPersistence persistence) { + this.persistence = persistence; this.restClient = RestClient.create(); } public void maybeHealOAuthAvatar(User user) { + if (user == null || user.isDeleted()) return; if (user.getAvatarUrl() == null || user.getAvatarUrl().isBlank()) { return; } @@ -39,26 +39,33 @@ public void maybeHealOAuthAvatar(User user) { return; } + var snapshot = persistence.capture(user.getId()); + if (snapshot == null) return; + User current = snapshot.user(); + if (current.getAvatarUrl() == null || current.getConnectedAccounts() == null + || current.getConnectedAccounts().isEmpty() || !isOAuthManagedAvatar(current)) return; + LocalDateTime lastAttempt = avatarHealCooldown.get(user.getId()); if (lastAttempt != null && lastAttempt.isAfter(LocalDateTime.now().minusMinutes(30))) { return; } avatarHealCooldown.put(user.getId(), LocalDateTime.now()); - if (isImageUrlReachable(user.getAvatarUrl())) { + if (isImageUrlReachable(current.getAvatarUrl())) { return; } - String refreshed = refreshAvatarFromLinkedProvider(user); + String refreshed = refreshAvatarFromLinkedProvider(current); if (refreshed != null && !refreshed.isBlank() && isImageUrlReachable(refreshed)) { + if (!persistence.update(snapshot, refreshed)) return; user.setAvatarUrl(refreshed); - userRepository.save(user); logger.info("Healed broken provider avatar for user {} using linked provider.", user.getId()); return; } - user.setAvatarUrl("https://ui-avatars.com/api/?name=" + user.getUsername() + "&background=random"); - userRepository.save(user); + String fallback = "https://ui-avatars.com/api/?name=" + java.net.URLEncoder.encode(current.getUsername() == null ? "User" : current.getUsername(), java.nio.charset.StandardCharsets.UTF_8) + "&background=random"; + if (!persistence.update(snapshot, fallback)) return; + user.setAvatarUrl(fallback); logger.warn("Reset broken provider avatar to default for user {}", user.getId()); } @@ -83,7 +90,7 @@ private boolean isOAuthManagedAvatar(User user) { || (hasBluesky && avatarUrl.contains("bsky")); } - private boolean isImageUrlReachable(String rawUrl) { + boolean isImageUrlReachable(String rawUrl) { try { HttpURLConnection connection = (HttpURLConnection) new URL(rawUrl).openConnection(); connection.setInstanceFollowRedirects(true); @@ -99,7 +106,7 @@ private boolean isImageUrlReachable(String rawUrl) { } } - private String refreshAvatarFromLinkedProvider(User user) { + String refreshAvatarFromLinkedProvider(User user) { for (User.ConnectedAccount account : user.getConnectedAccounts()) { if (account == null || account.getProvider() == null) { continue; diff --git a/backend/src/main/java/net/modtale/service/user/account/UserAvatarPersistence.java b/backend/src/main/java/net/modtale/service/user/account/UserAvatarPersistence.java new file mode 100644 index 000000000..fc07e4390 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/user/account/UserAvatarPersistence.java @@ -0,0 +1,32 @@ +package net.modtale.service.user.account; + +import java.util.ArrayList; +import java.util.List; +import net.modtale.model.user.User; +import net.modtale.util.MongoIdUtils; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; + +@Service +public class UserAvatarPersistence { + private final MongoTemplate mongo; + public UserAvatarPersistence(MongoTemplate mongo) { this.mongo = mongo; } + public record Snapshot(Document raw, User user) {} + public Snapshot capture(String id) { + if (id == null || id.isBlank()) return null; + var matches = mongo.getCollection(mongo.getCollectionName(User.class)) + .find(new Document("_id", new Document("$in", MongoIdUtils.expandIds(List.of(id))))) + .limit(2).into(new ArrayList<>()); + if (matches.size() != 1 || matches.getFirst().get("deletedAt") != null) return null; + var raw = matches.getFirst(); + return new Snapshot(raw, mongo.getConverter().read(User.class, raw)); + } + public boolean update(Snapshot snapshot, String avatarUrl) { + if (snapshot == null || avatarUrl == null || avatarUrl.isBlank()) return false; + return mongo.getCollection(mongo.getCollectionName(User.class)).updateOne( + new Document("_id", snapshot.raw().get("_id")).append("$expr", + new Document("$eq", List.of("$$ROOT", new Document("$literal", snapshot.raw())))), + new Document("$set", new Document("avatarUrl", avatarUrl))).getModifiedCount() == 1; + } +} diff --git a/backend/src/main/java/net/modtale/service/user/organization/OrganizationApplicationService.java b/backend/src/main/java/net/modtale/service/user/organization/OrganizationApplicationService.java index 11d4ba55d..fc90fdc2f 100644 --- a/backend/src/main/java/net/modtale/service/user/organization/OrganizationApplicationService.java +++ b/backend/src/main/java/net/modtale/service/user/organization/OrganizationApplicationService.java @@ -95,15 +95,15 @@ public ResourceUrlResponse uploadOrganizationBanner(String orgId, MultipartFile return new ResourceUrlResponse(url); } - public void acceptOrganizationInvite(String orgId, User currentUser) { - organizationService.resolveOrgInvite(orgId, true, currentUser); + public void acceptOrganizationInvite(String orgId, String requestId, User currentUser) { + organizationService.resolveOrgInvite(orgId, true, requestId, currentUser); } - public void declineOrganizationInvite(String orgId, User currentUser) { - organizationService.resolveOrgInvite(orgId, false, currentUser); + public void declineOrganizationInvite(String orgId, String requestId, User currentUser) { + organizationService.resolveOrgInvite(orgId, false, requestId, currentUser); } - public void cancelOrganizationInvite(String orgId, String userId, User currentUser) { - organizationService.voidOrgInvite(orgId, userId, currentUser); + public void cancelOrganizationInvite(String orgId, String userId, String requestId, User currentUser) { + organizationService.voidOrgInvite(orgId, userId, requestId, currentUser); } } diff --git a/backend/src/main/java/net/modtale/service/user/organization/OrganizationInvitationPolicy.java b/backend/src/main/java/net/modtale/service/user/organization/OrganizationInvitationPolicy.java new file mode 100644 index 000000000..00f01b2be --- /dev/null +++ b/backend/src/main/java/net/modtale/service/user/organization/OrganizationInvitationPolicy.java @@ -0,0 +1,36 @@ +package net.modtale.service.user.organization; +import java.util.*; +import java.util.stream.Collectors; +import net.modtale.model.user.User; +import net.modtale.exception.InvalidOrganizationRequestException; + +public final class OrganizationInvitationPolicy { + private OrganizationInvitationPolicy() {} + public static Set owners(User org) { + if (org.getOrganizationRoles() == null || org.getOrganizationMembers() == null) return Set.of(); + var ownerRoles = org.getOrganizationRoles().stream().filter(User.OrganizationRole::isOwner).map(User.OrganizationRole::getId).filter(id -> id != null && !id.isBlank()).collect(Collectors.toSet()); + return org.getOrganizationMembers().stream().filter(member -> ownerRoles.contains(member.getRoleId())) + .map(User.OrganizationMember::getUserId).filter(Objects::nonNull).collect(Collectors.toSet()); + } + public static User.OrganizationMember require(User org, String userId, String requestId, boolean accepting) { + var matches = org.getPendingOrgInvites() == null ? List.of() : org.getPendingOrgInvites().stream() + .filter(invite -> Objects.equals(userId, invite.getUserId())).toList(); + if (matches.size() != 1 || requestId == null || requestId.isBlank()) throw invalid(); + var invite = matches.getFirst(); + if (!("legacy".equals(requestId) && invite.getRequestId() == null && !accepting) && !requestId.equals(invite.getRequestId())) throw invalid(); + if (accepting) { + if (invite.getRequestExpiresAt() <= System.currentTimeMillis() || invite.getRequestPermissions() == null + || invite.getRequestOwnerIds() == null || invite.getRequestOwnerIds().isEmpty() + || !invite.getRequestOwnerIds().equals(owners(org))) throw invalid(); + var roles = org.getOrganizationRoles() == null ? List.of() : org.getOrganizationRoles().stream() + .filter(role -> Objects.equals(invite.getRoleId(), role.getId())).toList(); + if (roles.size() != 1 || roles.getFirst().isOwner() || !invite.getRequestPermissions().equals( + roles.getFirst().getPermissions() == null ? Set.of() : roles.getFirst().getPermissions())) throw invalid(); + if (org.getOrganizationMembers() != null && org.getOrganizationMembers().stream().anyMatch(member -> Objects.equals(userId, member.getUserId()))) throw invalid(); + } + return invite; + } + private static InvalidOrganizationRequestException invalid() { + return new InvalidOrganizationRequestException("This invitation is no longer current. Ask the organization owner to send a new invitation."); + } +} diff --git a/backend/src/main/java/net/modtale/service/user/organization/OrganizationInvitePersistence.java b/backend/src/main/java/net/modtale/service/user/organization/OrganizationInvitePersistence.java new file mode 100644 index 000000000..1240c1c02 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/user/organization/OrganizationInvitePersistence.java @@ -0,0 +1,49 @@ +package net.modtale.service.user.organization; +import java.util.*; +import net.modtale.model.user.User; +import net.modtale.util.MongoIdUtils; +import net.modtale.exception.InvalidOrganizationRequestException; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; + +@Service +public class OrganizationInvitePersistence { + private final MongoTemplate mongo; + public OrganizationInvitePersistence(MongoTemplate mongo) { this.mongo = mongo; } + public record Snapshot(Document raw, User organization) {} + public Snapshot capture(String id) { + var matches = mongo.getCollection(mongo.getCollectionName(User.class)) + .find(new Document("_id", new Document("$in", MongoIdUtils.expandIds(List.of(id))))).limit(2).into(new ArrayList<>()); + if (matches.size() != 1) throw new InvalidOrganizationRequestException("Organization not found."); + var raw = matches.getFirst(); var organization = mongo.getConverter().read(User.class, raw); + if (organization.isDeleted() || organization.getAccountType() != User.AccountType.ORGANIZATION) + throw new InvalidOrganizationRequestException("Organization not available."); + return new Snapshot(raw, organization); + } + public boolean create(Snapshot snapshot, User.OrganizationMember invite) { + Object encoded = mongo.getConverter().convertToMongoType(invite); + var update = snapshot.raw().get("pendingOrgInvites") == null + ? new Document("$set", new Document("pendingOrgInvites", List.of(encoded))) + : new Document("$push", new Document("pendingOrgInvites", encoded)); + return write(snapshot, update, null); + } + public boolean resolve(Snapshot snapshot, String userId, String requestId, boolean accepting) { + var original = mongo.getConverter().read(User.class, snapshot.raw()); + var invite = OrganizationInvitationPolicy.require(original, userId, requestId, accepting); + var update = new Document("$pull", new Document("pendingOrgInvites", new Document("userId", userId).append("requestId", invite.getRequestId()))); + if (accepting) { + Object member = mongo.getConverter().convertToMongoType(new User.OrganizationMember(userId, invite.getRoleId())); + update.append(snapshot.raw().get("organizationMembers") == null ? "$set" : "$push", + new Document("organizationMembers", snapshot.raw().get("organizationMembers") == null ? List.of(member) : member)); + } + Object expiry = accepting ? new Document("$gt", List.of(invite.getRequestExpiresAt(), new Document("$toLong", "$$NOW"))) : null; + return write(snapshot, update, expiry); + } + private boolean write(Snapshot snapshot, Document update, Object extraGuard) { + Object match = new Document("$eq", List.of("$$ROOT", new Document("$literal", snapshot.raw()))); + if (extraGuard != null) match = new Document("$and", List.of(match, extraGuard)); + return mongo.getCollection(mongo.getCollectionName(User.class)).updateOne( + new Document("_id", snapshot.raw().get("_id")).append("$expr", match), update).getModifiedCount() == 1; + } +} diff --git a/backend/src/main/java/net/modtale/service/user/organization/OrganizationInviteService.java b/backend/src/main/java/net/modtale/service/user/organization/OrganizationInviteService.java index 33cf8bc17..585b92830 100644 --- a/backend/src/main/java/net/modtale/service/user/organization/OrganizationInviteService.java +++ b/backend/src/main/java/net/modtale/service/user/organization/OrganizationInviteService.java @@ -17,6 +17,7 @@ @Service public class OrganizationInviteService { + private final OrganizationInvitePersistence persistence; private final UserRepository userRepository; private final OrganizationAccessService organizationAccessService; private final NotificationService notificationService; @@ -24,15 +25,18 @@ public class OrganizationInviteService { public OrganizationInviteService( UserRepository userRepository, OrganizationAccessService organizationAccessService, - NotificationService notificationService + NotificationService notificationService, + OrganizationInvitePersistence persistence ) { + this.persistence = persistence; this.userRepository = userRepository; this.organizationAccessService = organizationAccessService; this.notificationService = notificationService; } public void inviteOrganizationMember(String orgId, String targetUserId, String roleId, User requester) { - User org = organizationAccessService.getOrganizationOrThrow(orgId); + var snapshot = persistence.capture(orgId); + User org = snapshot.organization(); organizationAccessService.requireOrgPermission( org, requester, @@ -42,7 +46,7 @@ public void inviteOrganizationMember(String orgId, String targetUserId, String r User target = userRepository.findById(targetUserId) .orElseThrow(() -> new InvalidOrganizationRequestException("We couldn't find the user you tried to invite.")); - if (org.getOrganizationMembers().stream().anyMatch(member -> member.getUserId().equals(target.getId()))) { + if (org.getOrganizationMembers() != null && org.getOrganizationMembers().stream().anyMatch(member -> member.getUserId().equals(target.getId()))) { throw new InvalidOrganizationRequestException("That user is already a member of this organization."); } if (org.getPendingOrgInvites() != null && org.getPendingOrgInvites().stream().anyMatch(member -> member.getUserId().equals(target.getId()))) { @@ -57,12 +61,21 @@ public void inviteOrganizationMember(String orgId, String targetUserId, String r if (org.getPendingOrgInvites() == null) { org.setPendingOrgInvites(new ArrayList<>()); } - org.getPendingOrgInvites().add(new User.OrganizationMember(target.getId(), roleId)); - userRepository.save(org); + if (target.isDeleted() || target.getAccountType() == User.AccountType.ORGANIZATION) + throw new InvalidOrganizationRequestException("Invite an active personal account."); + var invitation = new User.OrganizationMember(target.getId(), roleId); + invitation.setRequestId(java.util.UUID.randomUUID().toString()); + invitation.setRequestExpiresAt(System.currentTimeMillis() + java.time.Duration.ofDays(7).toMillis()); + invitation.setRequestPermissions(role.getPermissions() == null ? java.util.Set.of() : java.util.Set.copyOf(role.getPermissions())); + invitation.setRequestOwnerIds(OrganizationInvitationPolicy.owners(org)); + if (invitation.getRequestOwnerIds().isEmpty()) throw new InvalidOrganizationRequestException("Organization ownership must be repaired before inviting members."); + org.getPendingOrgInvites().add(invitation); + if (!persistence.create(snapshot, invitation)) throw conflict(); Map metadata = new HashMap<>(); metadata.put("orgId", org.getId()); metadata.put("action", "ORG_INVITE"); + metadata.put("requestId", invitation.getRequestId()); notificationService.sendNotifcation( List.of(target.getId()), "Organization Invite", @@ -74,23 +87,17 @@ public void inviteOrganizationMember(String orgId, String targetUserId, String r ); } - public void resolveOrgInvite(String orgId, boolean accept, User responder) { - User org = organizationAccessService.getOrganizationOrThrow(orgId); - User.OrganizationMember invite = org.getPendingOrgInvites() != null - ? org.getPendingOrgInvites().stream() - .filter(member -> member.getUserId().equals(responder.getId())) - .findFirst() - .orElse(null) - : null; - - if (invite == null) { - throw new InvalidOrganizationRequestException("We couldn't find a pending invite for this organization."); - } + public void resolveOrgInvite(String orgId, boolean accept, String requestId, User responder) { + var snapshot = persistence.capture(orgId); + User org = snapshot.organization(); + if (responder.isDeleted() || responder.getAccountType() == User.AccountType.ORGANIZATION) + throw new InvalidOrganizationRequestException("An active personal account is required."); + var invite = OrganizationInvitationPolicy.require(org, responder.getId(), requestId, accept); if (accept) { - org.getOrganizationMembers().add(invite); + org.getOrganizationMembers().add(new User.OrganizationMember(invite.getUserId(), invite.getRoleId())); org.getPendingOrgInvites().remove(invite); - userRepository.save(org); + if (!persistence.resolve(snapshot, responder.getId(), requestId, accept)) throw conflict(); String msg = responder.getUsername() + " accepted the invitation to join " + org.getUsername(); org.getOrganizationMembers().stream() @@ -105,12 +112,13 @@ public void resolveOrgInvite(String orgId, boolean accept, User responder) { )); } else { org.getPendingOrgInvites().remove(invite); - userRepository.save(org); + if (!persistence.resolve(snapshot, responder.getId(), requestId, accept)) throw conflict(); } } - public void voidOrgInvite(String orgId, String userId, User requester) { - User org = organizationAccessService.getOrganizationOrThrow(orgId); + public void voidOrgInvite(String orgId, String userId, String requestId, User requester) { + var snapshot = persistence.capture(orgId); + User org = snapshot.organization(); organizationAccessService.requireOrgPermission( org, requester, @@ -118,9 +126,12 @@ public void voidOrgInvite(String orgId, String userId, User requester) { "You do not have permission to cancel invites for this organization." ); - if (org.getPendingOrgInvites() == null || !org.getPendingOrgInvites().removeIf(member -> member.getUserId().equals(userId))) { - throw new InvalidOrganizationRequestException("We couldn't find that pending organization invite."); - } - userRepository.save(org); + var invite = OrganizationInvitationPolicy.require(org, userId, requestId, false); + org.getPendingOrgInvites().remove(invite); + if (!persistence.resolve(snapshot, userId, requestId, false)) throw conflict(); + } + private org.springframework.web.server.ResponseStatusException conflict() { + return new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, + "The organization changed. Refresh before responding to the invitation."); } } diff --git a/backend/src/main/java/net/modtale/service/user/organization/OrganizationService.java b/backend/src/main/java/net/modtale/service/user/organization/OrganizationService.java index 7270ae8af..78819a9ba 100644 --- a/backend/src/main/java/net/modtale/service/user/organization/OrganizationService.java +++ b/backend/src/main/java/net/modtale/service/user/organization/OrganizationService.java @@ -157,12 +157,12 @@ public void inviteOrganizationMember(String orgId, String targetUserId, String r organizationInviteService.inviteOrganizationMember(orgId, targetUserId, roleId, requester); } - public void resolveOrgInvite(String orgId, boolean accept, User responder) { - organizationInviteService.resolveOrgInvite(orgId, accept, responder); + public void resolveOrgInvite(String orgId, boolean accept, String requestId, User responder) { + organizationInviteService.resolveOrgInvite(orgId, accept, requestId, responder); } - public void voidOrgInvite(String orgId, String userId, User requester) { - organizationInviteService.voidOrgInvite(orgId, userId, requester); + public void voidOrgInvite(String orgId, String userId, String requestId, User requester) { + organizationInviteService.voidOrgInvite(orgId, userId, requestId, requester); } public void updateOrganizationMemberRole(String orgId, String targetUserId, String newRoleId, User requester) { diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 7f404585b..e3529b131 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -129,10 +129,16 @@ app.hytalemodding.wiki-url=https://wiki.hytalemodding.dev/api app.hytalemodding.wiki-key=${WIKI_API_KEY:} app.warden.url=${WARDEN_URL:http://localhost:8081} +app.warden.jobs.enabled=${WARDEN_REVIEW_JOBS_ENABLED:false} +app.warden.jobs.scheduler.enabled=${WARDEN_REVIEW_SCHEDULER_ENABLED:false} +app.warden.jobs.scheduler.workers=${WARDEN_REVIEW_SCHEDULER_WORKERS:2} +app.warden.jobs.scheduler.page-size=${WARDEN_REVIEW_SCHEDULER_PAGE_SIZE:16} +app.warden.jobs.scheduler.poll-millis=${WARDEN_REVIEW_SCHEDULER_POLL_MILLIS:1000} +app.warden.jobs.scheduler.drain-millis=${WARDEN_REVIEW_SCHEDULER_DRAIN_MILLIS:10000} app.warden.enabled=${WARDEN_ENABLED:true} app.warden.api-key=${WARDEN_API_KEY:} -app.warden.max-attempts=${WARDEN_MAX_ATTEMPTS:3} -app.warden.request-timeout-seconds=${WARDEN_REQUEST_TIMEOUT_SECONDS:75} +app.warden.max-attempts=${WARDEN_MAX_ATTEMPTS:1} +app.warden.request-timeout-seconds=${WARDEN_REQUEST_TIMEOUT_SECONDS:600} app.security.pre-auth-secret=${PRE_AUTH_SECRET:} app.security.pre-auth-expiry-seconds=600 diff --git a/backend/src/test/java/net/modtale/config/security/SecurityConfigLauncherOAuthTest.java b/backend/src/test/java/net/modtale/config/security/SecurityConfigLauncherOAuthTest.java index ca884636d..349a6809a 100644 --- a/backend/src/test/java/net/modtale/config/security/SecurityConfigLauncherOAuthTest.java +++ b/backend/src/test/java/net/modtale/config/security/SecurityConfigLauncherOAuthTest.java @@ -80,6 +80,35 @@ void oauthFailureRedirectsLauncherOAuthToLoopbackCallbackWithError() throws Exce ); } + @Test + void enabledMfaAlwaysChallengesWebAndLauncherLoginsWithoutAccountMutation() throws Exception { + for (String secret : new String[]{null, "", " ", "fixture-secret"}) { + for (boolean launcher : new boolean[]{false, true}) { + AccountService accounts = mock(AccountService.class); + LauncherAuthService grants = mock(LauncherAuthService.class); + User user = new User(); user.setId("user-1"); user.setUsername("ada"); + user.setMfaEnabled(true); user.setMfaSecret(secret); + when(accounts.getPublicProfile("ada")).thenReturn(user); + var request = launcher ? launcherOAuthRequest() : new MockHttpServletRequest(); + var session = request.getSession(); + var response = new MockHttpServletResponse(); + var auth = authentication(); + SecurityContextHolder.getContext().setAuthentication(auth); + session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext()); + try { + config(accounts, grants).oauthSuccessHandler().onAuthenticationSuccess(request, response, auth); + org.junit.jupiter.api.Assertions.assertTrue(response.getRedirectedUrl().startsWith("http://localhost:5173/mfa?token=")); + org.junit.jupiter.api.Assertions.assertTrue(user.isMfaEnabled()); + org.junit.jupiter.api.Assertions.assertTrue(((org.springframework.mock.web.MockHttpSession) session).isInvalid()); + org.junit.jupiter.api.Assertions.assertNull(SecurityContextHolder.getContext().getAuthentication()); + org.mockito.Mockito.verifyNoInteractions(grants); + org.mockito.Mockito.verify(accounts).getPublicProfile("ada"); + org.mockito.Mockito.verifyNoMoreInteractions(accounts); + } finally { SecurityContextHolder.clearContext(); } + } + } + } + private static SecurityConfig config(AccountService accountService, LauncherAuthService launcherAuthService) { return new SecurityConfig( mock(ApiKeyAuthFilter.class), diff --git a/backend/src/test/java/net/modtale/config/security/SecurityFilterChainTest.java b/backend/src/test/java/net/modtale/config/security/SecurityFilterChainTest.java index 9247b72ec..fd247c598 100644 --- a/backend/src/test/java/net/modtale/config/security/SecurityFilterChainTest.java +++ b/backend/src/test/java/net/modtale/config/security/SecurityFilterChainTest.java @@ -44,6 +44,47 @@ void localhostTextInsideRemoteFrontendDoesNotDisableSecureCookies() { assertTrue(response.getHeader("Set-Cookie").contains("Secure")); } + @Test + void enrollmentAuthorizationRejectsApiKeysAndRequiresBrowserCsrf() throws Exception { + try (var context = new AnnotationConfigWebApplicationContext()) { + context.setServletContext(new MockServletContext()); + context.register(TestSecurity.class); context.refresh(); + var filters = context.getBean(SecurityFilterChain.class).getFilters(); + var authorization = filters.stream().filter(org.springframework.security.web.access.intercept.AuthorizationFilter.class::isInstance) + .map(org.springframework.security.web.access.intercept.AuthorizationFilter.class::cast).findFirst().orElseThrow(); + var csrf = filters.stream().filter(CsrfFilter.class::isInstance).map(CsrfFilter.class::cast).findFirst().orElseThrow(); + for (String path : new String[]{"/api/v1/auth/mfa/setup", "/api/v1/auth/mfa/verify"}) { + for (boolean apiKey : new boolean[]{false, true}) { + for (boolean token : new boolean[]{false, true}) { + var request = new org.springframework.mock.web.MockHttpServletRequest("POST", path); + request.setServletPath(path); + var response = new org.springframework.mock.web.MockHttpServletResponse(); + var reached = new java.util.concurrent.atomic.AtomicBoolean(); + var auth = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken("user", null, + java.util.List.of(new org.springframework.security.core.authority.SimpleGrantedAuthority(apiKey ? "ROLE_API" : "ROLE_USER"))); + org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(auth); + if (apiKey) request.addHeader("X-MODTALE-KEY", "fixture-key"); + if (token) { + request.setCookies(new jakarta.servlet.http.Cookie("XSRF-TOKEN", "fixture-csrf")); + request.addHeader("X-XSRF-TOKEN", "fixture-csrf"); + } + try { + if (apiKey) { + assertThrows(org.springframework.security.access.AccessDeniedException.class, + () -> csrf.doFilter(request, response, (req, res) -> authorization.doFilter(req, res, (r, s) -> reached.set(true)))); + } else { + csrf.doFilter(request, response, (req, res) -> authorization.doFilter(req, res, (r, s) -> reached.set(true))); + assertEquals(token, reached.get()); + if (!token) assertEquals(403, response.getStatus()); + } + if (apiKey) assertFalse(reached.get()); + } finally { org.springframework.security.core.context.SecurityContextHolder.clearContext(); } + } + } + } + } + } + @Configuration @EnableWebSecurity static class TestSecurity { diff --git a/backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionAuthorizationTest.java b/backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionAuthorizationTest.java new file mode 100644 index 000000000..fb0097f86 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionAuthorizationTest.java @@ -0,0 +1,50 @@ +package net.modtale.controller.admin; + +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.storage.StorageService; +import net.modtale.service.security.scan.WardenClientService; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(ArtifactInspectionAuthorizationTest.Config.class) +class ArtifactInspectionAuthorizationTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ProjectService projects() {return mock(ProjectService.class);} + @Bean StorageService storage() {return mock(StorageService.class);} + @Bean WardenClientService inspector() {return mock(WardenClientService.class);} + @Bean(name="apiSecurity") PermissionEvaluator permissionEvaluator() {return new PermissionEvaluator();} + @Bean ArtifactInspectionController controller(ProjectService projects, StorageService storage, WardenClientService inspector) { + return new ArtifactInspectionController(projects,storage,inspector); + } + } + public static class PermissionEvaluator { + public boolean hasAdminPermission(String permission, Authentication auth) { + return auth!=null && auth.getAuthorities().stream().anyMatch(authority -> authority.getAuthority().equals(permission)); + } + } + @Autowired ArtifactInspectionController controller; + @Autowired ProjectService projects; + @Autowired StorageService storage; + @Autowired WardenClientService inspector; + @AfterEach void clearContext() {SecurityContextHolder.clearContext();} + @Test void comparisonAndSourceInspectionRequireReviewReadPermission() { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("ordinary-user",null, + List.of(new SimpleGrantedAuthority("PROJECT_REVIEW_DECIDE")))); + assertThrows(AccessDeniedException.class,()->controller.structure("project","version",null)); + assertThrows(AccessDeniedException.class,()->controller.file("project","version","manifest.json",null)); + assertThrows(AccessDeniedException.class,()->controller.window("project","version","manifest.json",0,32000,0,null,null)); + assertThrows(AccessDeniedException.class,()->controller.changes("project","version",null)); + verifyNoInteractions(projects,storage,inspector); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionControllerTest.java new file mode 100644 index 000000000..c25cac328 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ArtifactInspectionControllerTest.java @@ -0,0 +1,163 @@ +package net.modtale.controller.admin; + +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.WardenClientService; +import net.modtale.service.storage.StorageService; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ArtifactInspectionControllerTest { + private WardenClientService.InspectionWindow window(Fixture f, int start, String policy) { + return new WardenClientService.InspectionWindow(f.after.getHash(),"file.json","a".repeat(64),policy,"b".repeat(64), + "TEXT_RESOURCE",start,start+2,4,1,true,true,"{}",List.of()); + } + @Test void windowContinuationsBindRepresentationAndReviewState() throws Exception { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + when(f.inspector.inspectWindow(any(),anyString(),anyInt(),anyInt(),anyInt())).thenReturn(window(f,0,"policy")); + var first=f.controller.window("project","2","file.json",0,2,0,null,token).getBody(); + assertEquals(64,first.identity().length()); + when(f.inspector.inspectWindow(any(),anyString(),anyInt(),anyInt(),anyInt())).thenReturn(window(f,2,"policy")); + assertEquals(2,f.controller.window("project","2","file.json",2,2,0,first.identity(),token).getBody().start()); + when(f.inspector.inspectWindow(any(),anyString(),anyInt(),anyInt(),anyInt())).thenReturn(window(f,2,"changed-policy")); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.window("project","2","file.json",2,2,0,first.identity(),token)).getStatusCode().value()); + doAnswer(invocation->{f.after.setFindingReviewHead("new-head");return window(f,0,"policy");}) + .when(f.inspector).inspectWindow(any(),anyString(),anyInt(),anyInt(),anyInt()); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.window("project","2","file.json",0,2,0,null,token)).getStatusCode().value()); + } + @Test void invalidWindowRequestsStopBeforeArtifactAccessAndMalformedRepliesAreRejected() throws Exception { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.window("project","2","file.json",0,2,0,null,"stale")).getStatusCode().value()); + assertEquals(400,assertThrows(ResponseStatusException.class,()->f.controller.window("project","2","file.json",2,2,0,null,token)).getStatusCode().value()); + assertEquals(400,assertThrows(ResponseStatusException.class,()->f.controller.window("project","2","file.json",0,32001,0,null,token)).getStatusCode().value()); + verifyNoInteractions(f.storage,f.inspector); + when(f.inspector.inspectWindow(any(),anyString(),anyInt(),anyInt(),anyInt())).thenReturn(window(f,1,"policy")); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.window("project","2","file.json",0,2,0,null,token)).getStatusCode().value()); + } + @Test void windowHttpRouteRequiresTheOpenedReviewAndReturnsBoundedJsonWithoutCaching() throws Exception { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + when(f.inspector.inspectWindow(any(),anyString(),anyInt(),anyInt(),anyInt())).thenReturn(window(f,0,"policy")); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(f.controller).build(); + String route="/api/v1/admin/projects/project/versions/2/file-window"; + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(route).param("path","file.json")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isConflict()); + verifyNoInteractions(f.storage,f.inspector); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(route).param("path","file.json").header("If-Match",token)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.content").value("{}")); + } + @Test void comparesFullCaseSensitiveNestedPathsAndRemovals() { + var before=Map.of("nested.jar!/A.class","a", "removed.class","b", "same.json","c"); + var after=Map.of("nested.jar!/A.class","changed", "nested.jar!/a.class","a", "same.json","c"); + var diff=ArtifactInspectionController.compare("snapshot","1.0",true,true,before,after); + assertEquals(1,diff.added()); assertEquals(1,diff.modified()); assertEquals(1,diff.removed()); assertEquals(1,diff.unchanged()); + assertTrue(diff.contextChanged()); + assertTrue(diff.files().contains(new ArtifactInspectionController.FileChange("nested.jar!/a.class","ADDED"))); + } + @Test void refusesStoredArtifactMismatchBeforeRequestingInspection() { + var projects=mock(ProjectService.class); var storage=mock(StorageService.class); var inspector=mock(WardenClientService.class); + var project=new Project();var version=new ProjectVersion();version.setVersionNumber("1.0");version.setHash("a".repeat(64)); + version.setFileUrl("stored.zip");project.setVersions(List.of(version)); + when(projects.getRawProjectById("project")).thenReturn(project); + when(storage.download("stored.zip")).thenReturn("different".getBytes()); + var controller=new ArtifactInspectionController(projects,storage,inspector); + assertEquals(409,assertThrows(ResponseStatusException.class,()->controller.structure("project","1.0",net.modtale.service.admin.review.ProjectReviewSnapshot.token(project))).getStatusCode().value()); + verifyNoInteractions(inspector); + } + private static final class Fixture { + final ProjectService projects=mock(ProjectService.class); + final StorageService storage=mock(StorageService.class); + final WardenClientService inspector=mock(WardenClientService.class); + final Project project=new Project(); + final ProjectVersion before=new ProjectVersion(), after=new ProjectVersion(); + final byte[] bytes="fixture artifact".getBytes(java.nio.charset.StandardCharsets.UTF_8); + final ArtifactInspectionController controller=new ArtifactInspectionController(projects,storage,inspector); + final WardenClientService.InspectionResponse response; + Fixture() throws Exception { + String hash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); + before.setId("before");before.setVersionNumber("1");before.setHash(hash);before.setFileUrl("before.zip"); + before.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);before.setSecurityApprovedAt(1); + before.setApprovedSecurityContextSha256(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(before)); + after.setId("after");after.setVersionNumber("2");after.setHash(hash);after.setFileUrl("after.zip"); + project.setId("project");project.setVersions(List.of(before,after)); + response=new WardenClientService.InspectionResponse(hash,List.of("file.json"),"{}","TEXT",Map.of("file.json","a".repeat(64)),"policy"); + when(projects.getRawProjectById("project")).thenReturn(project); + when(storage.download(anyString())).thenReturn(bytes); + when(inspector.inspectFile(any(),anyString(),nullable(String.class))).thenReturn(response); + } + } + @Test void comparisonRejectsReviewAndContextChangesDuringInspection() throws Exception { + for (int scenario=0;scenario<4;scenario++) { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project);final int change=scenario; + doAnswer(invocation->{ + switch(change) { + case 0 -> f.before.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + case 1 -> f.before.setFindingReviewHead("new-head"); + case 2 -> f.after.setGameVersions(List.of("changed-runtime")); + case 3 -> f.project.setVersions(List.of(f.after)); + } + return f.response; + }).when(f.inspector).inspectFile(any(),anyString(),nullable(String.class)); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.changes("project","2",token)).getStatusCode().value()); + } + } + @Test void fileAndStructureRejectChangesWhileArtifactIsInspected() throws Exception { + for(boolean content:new boolean[]{false,true}) { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + doAnswer(invocation->{f.after.setFindingReviewHead("new-review");return f.response;}) + .when(f.inspector).inspectFile(any(),anyString(),nullable(String.class)); + assertEquals(409,assertThrows(ResponseStatusException.class,()->{ + if(content)f.controller.file("project","2","file.json",token);else f.controller.structure("project","2",token); + }).getStatusCode().value()); + } + } + @Test void ambiguousVersionNamesNeverSelectAnArbitraryArtifact() throws Exception { + var f=new Fixture();f.before.setVersionNumber("2");String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.changes("project","2",token)).getStatusCode().value()); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.structure("project","2",token)).getStatusCode().value()); + verifyNoInteractions(f.storage,f.inspector); + } + @Test void unchangedInspectionUsesOneCapturedSelectionAndIgnoresDownloadCounters() throws Exception { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + doAnswer(invocation->{f.after.setDownloadCount(2);return f.response;}) + .when(f.inspector).inspectFile(any(),anyString(),nullable(String.class)); + var result=f.controller.changes("project","2",token).getBody(); + assertEquals("1",result.baselineVersion());assertEquals(1,result.unchanged());assertTrue(result.contextComparable()); + verify(f.projects,times(2)).getRawProjectById("project"); + verify(f.storage).download("before.zip");verify(f.storage).download("after.zip"); + } + @Test void editedBaselineContextCannotBePresentedAsComparableApprovedContext() throws Exception { + var f=new Fixture();f.before.setGameVersions(List.of("edited-since-approval"));String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + assertFalse(f.controller.changes("project","2",token).getBody().contextComparable()); + } + + @Test void comparisonTokenBindsLaterFileAndStructureReadsBeforeAnyDownload() throws Exception { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + assertEquals(token,f.controller.changes("project","2",token).getBody().reviewToken()); + clearInvocations(f.storage,f.inspector); + f.before.setFindingReviewHead("revoked-after-comparison"); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.file("project","1","file.json",token)).getStatusCode().value()); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.controller.structure("project","2",token)).getStatusCode().value()); + verifyNoInteractions(f.storage,f.inspector); + } + @Test void httpInspectionRequiresTheOpenedProjectTokenOnEveryRoute() throws Exception { + var f=new Fixture();String token=net.modtale.service.admin.review.ProjectReviewSnapshot.token(f.project); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(f.controller).build(); + for(String route:List.of("changes","structure","file")) { + var path="/api/v1/admin/projects/project/versions/2/"+route; + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(path).param("path","file.json")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isConflict()); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(path).param("path","file.json").header("If-Match","stale")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isConflict()); + verifyNoInteractions(f.storage,f.inspector); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get(path).param("path","file.json").header("If-Match",token)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")); + clearInvocations(f.storage,f.inspector); + } + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/FindingReviewAuthorizationTest.java b/backend/src/test/java/net/modtale/controller/admin/FindingReviewAuthorizationTest.java new file mode 100644 index 000000000..97cddc626 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/FindingReviewAuthorizationTest.java @@ -0,0 +1,61 @@ +package net.modtale.controller.admin; + +import java.util.List; +import net.modtale.model.user.User; +import net.modtale.service.security.issue.FindingReviewService; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(FindingReviewAuthorizationTest.Config.class) +class FindingReviewAuthorizationTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean FindingReviewService reviews() { return mock(FindingReviewService.class); } + @Bean AccountService accounts() { return mock(AccountService.class); } + @Bean(name="apiSecurity") Permissions permissions() { return new Permissions(); } + @Bean FindingReviewController controller(FindingReviewService reviews, AccountService accounts) { return new FindingReviewController(reviews, accounts); } + } + public static class Permissions { + public boolean hasAdminPermission(String permission, Authentication auth) { + return auth != null && auth.getAuthorities().stream().anyMatch(a -> permission.equals(a.getAuthority())); + } + } + @Autowired FindingReviewController controller; + @Autowired FindingReviewService reviews; + @Autowired AccountService accounts; + @BeforeEach void resetMocks() { reset(reviews, accounts); } + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + private void permission(String permission) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user", null, + List.of(new SimpleGrantedAuthority(permission)))); + } + @Test void readPermissionCannotWriteOrRevoke() { + permission("PROJECT_REVIEW_READ"); + assertThrows(AccessDeniedException.class, () -> controller.record("p", "v", "token", null)); + assertThrows(AccessDeniedException.class, () -> controller.revoke("p", "v", "decision", "token", null)); + verifyNoInteractions(reviews, accounts); + controller.history("p", "v", "token", 0); + verify(reviews).history("p", "v", "token", 0); + } + @Test void decisionPermissionUsesAuthenticatedActorAndSnapshot() { + permission("PROJECT_REVIEW_DECIDE"); + var actor = new User(); actor.setId("server-actor"); + when(accounts.requireCurrentUser(anyString())).thenReturn(actor); + var request = new FindingReviewService.Request(2, FindingReviewService.Disposition.REQUIRE_REVIEW, "Needs further investigation"); + controller.record("p", "v", "snapshot", request); + verify(reviews).record("p", "v", "snapshot", "server-actor", request); + controller.revoke("p", "v", "decision", "snapshot", new FindingReviewController.Revocation("Incorrect previous decision")); + verify(reviews).revoke("p", "v", "snapshot", "server-actor", "decision", "Incorrect previous decision"); + assertThrows(AccessDeniedException.class, () -> controller.history("p", "v", "snapshot", 0)); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ModerationQueuePageControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ModerationQueuePageControllerTest.java new file mode 100644 index 000000000..59c92640f --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ModerationQueuePageControllerTest.java @@ -0,0 +1,83 @@ +package net.modtale.controller.admin; + +import java.util.List; +import net.modtale.service.admin.review.*; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringJUnitConfig(ModerationQueuePageControllerTest.Config.class) +class ModerationQueuePageControllerTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ModerationQueuePageReader reader(){return mock(ModerationQueuePageReader.class);} + @Bean ModerationQueuePageController controller(ModerationQueuePageReader reader){return new ModerationQueuePageController(reader);} + @Bean net.modtale.service.admin.review.ProjectReviewAdminService reviews(){return mock(net.modtale.service.admin.review.ProjectReviewAdminService.class);} + @Bean net.modtale.service.user.account.AccountService accounts(){return mock(net.modtale.service.user.account.AccountService.class);} + @Bean net.modtale.service.admin.project.ProjectAdminOperationsService operations(){return mock(net.modtale.service.admin.project.ProjectAdminOperationsService.class);} + @Bean ProjectManagementController legacy(net.modtale.service.user.account.AccountService accounts,net.modtale.service.admin.review.ProjectReviewAdminService reviews,net.modtale.service.admin.project.ProjectAdminOperationsService operations){return new ProjectManagementController(accounts,reviews,operations);} + @Bean(name="apiSecurity") Permissions permissions(){return new Permissions();} + } + public static class Permissions { + public boolean hasAdminPermission(String permission,Authentication auth){return auth!=null&&auth.getAuthorities().stream().anyMatch(a->a.getAuthority().equals(permission));} + } + @Autowired ModerationQueuePageController controller; + @Autowired ModerationQueuePageReader reader; + @Autowired ProjectManagementController legacy; + @Autowired net.modtale.service.admin.review.ProjectReviewAdminService reviews; + @Autowired net.modtale.service.user.account.AccountService accounts; + @Autowired net.modtale.service.admin.project.ProjectAdminOperationsService operations; + @BeforeEach void setup(){reset(reader);auth("PROJECT_REVIEW_READ");} + @AfterEach void cleanup(){SecurityContextHolder.clearContext();} + void auth(String permission){SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("reviewer",null,List.of(new SimpleGrantedAuthority(permission))));} + @Test void permissionIsCheckedBeforeCursorOrDatabaseAccess() { + auth("PROJECT_REVIEW_DECIDE");assertThrows(AccessDeniedException.class,()->controller.read("bad",25,ModerationQueuePageReader.Filter.ALL));verifyNoInteractions(reader); + } + @Test void httpUsesDefaultPageSizeAndReturnsContinuationEvenWhenAllRowsAreUnavailable()throws Exception { + var next=new ModerationQueuePageReader.Cursor("project",7); + when(reader.page(null,25,ModerationQueuePageReader.Filter.ALL)).thenReturn(new ModerationQueuePageReader.Page(List.of(),next,25)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(get("/api/v1/admin/verification/queue/page")).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")) + .andExpect(jsonPath("$.nextCursor").value(ModerationQueueCursor.encode(next))).andExpect(jsonPath("$.unavailableItems").value(25)) + .andExpect(jsonPath("$.order").value("PROJECT_VERSION")).andExpect(jsonPath("$.items").isEmpty()); + verify(reader).page(null,25,ModerationQueuePageReader.Filter.ALL); + } + @Test void invalidHttpParametersNeverReachTheDatabase()throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + for(String limit:List.of("0","51","2147483648","no"))mvc.perform(get("/api/v1/admin/verification/queue/page").param("limit",limit)).andExpect(status().isBadRequest()); + mvc.perform(get("/api/v1/admin/verification/queue/page").param("cursor","invalid")).andExpect(status().isBadRequest());verifyNoInteractions(reader); + } + @Test void retiredEndpointReturnsExplicitMigrationWithoutReadingServices()throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(legacy).build(); + mvc.perform(get("/api/v1/admin/verification/queue")).andExpect(status().isGone()) + .andExpect(header().string("Cache-Control","no-store")) + .andExpect(header().string("Link","; rel=\"successor-version\"")) + .andExpect(jsonPath("$.status").value(410)); + verifyNoInteractions(reader,reviews,accounts,operations); + } + @Test void retiredEndpointStillRequiresReviewReadPermission() { + auth("PROJECT_REVIEW_DECIDE");assertThrows(AccessDeniedException.class,()->legacy.getVerificationQueue()); + verifyNoInteractions(reader,reviews,accounts,operations); + } + @Test void filterMismatchAndUnknownFilterCannotReadDatabase()throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + var cursor=ModerationQueueCursor.encode(new ModerationQueuePageReader.Cursor("a",1,ModerationQueuePageReader.Filter.SECURITY)); + mvc.perform(get("/api/v1/admin/verification/queue/page").param("filter","OPERATIONS").param("cursor",cursor)).andExpect(status().isBadRequest()); + mvc.perform(get("/api/v1/admin/verification/queue/page").param("filter","UNKNOWN")).andExpect(status().isBadRequest());verifyNoInteractions(reader); + } + @Test void exactTypedCursorIsPassedToReader() { + var cursor=new ModerationQueuePageReader.Cursor(new org.bson.types.ObjectId(),8); + when(reader.page(cursor,1,ModerationQueuePageReader.Filter.ALL)).thenReturn(new ModerationQueuePageReader.Page(List.of(),null,0)); + var result=controller.read(ModerationQueueCursor.encode(cursor),1,ModerationQueuePageReader.Filter.ALL);assertNull(result.getBody().nextCursor());verify(reader).page(cursor,1,ModerationQueuePageReader.Filter.ALL); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/PriorFindingReasoningControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/PriorFindingReasoningControllerTest.java new file mode 100644 index 000000000..664f84b99 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/PriorFindingReasoningControllerTest.java @@ -0,0 +1,48 @@ +package net.modtale.controller.admin; + +import java.util.List; +import net.modtale.service.security.issue.PriorFindingReasoningService; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringJUnitConfig(PriorFindingReasoningControllerTest.Config.class) +class PriorFindingReasoningControllerTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean PriorFindingReasoningService service(){return mock(PriorFindingReasoningService.class);} + @Bean PriorFindingReasoningController controller(PriorFindingReasoningService service){return new PriorFindingReasoningController(service);} + @Bean(name="apiSecurity") Permissions permissions(){return new Permissions();} + } + public static class Permissions { + public boolean hasAdminPermission(String permission,Authentication auth){return auth!=null&&auth.getAuthorities().stream().anyMatch(a->a.getAuthority().equals(permission));} + } + @Autowired PriorFindingReasoningController controller; + @Autowired PriorFindingReasoningService service; + @BeforeEach void resetMock(){reset(service);} + @AfterEach void clear(){SecurityContextHolder.clearContext();} + @Test void permissionRequiredBeforeHistoryAccess() { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user",null,List.of(new SimpleGrantedAuthority("PROJECT_REVIEW_DECIDE")))); + assertThrows(AccessDeniedException.class,()->controller.read("project","target","source",0,"snapshot"));verifyNoInteractions(service); + } + @Test void httpBindsSelectedSourceFindingAndProjectTokenAndNeverCaches() throws Exception { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("reviewer",null,List.of(new SimpleGrantedAuthority("PROJECT_REVIEW_READ")))); + when(service.read("project","target","source",3,"snapshot")).thenReturn(new PriorFindingReasoningService.Reasoning("snapshot","source","1",1,List.of(),List.of(),0)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + String path="/api/v1/admin/projects/project/versions/target/prior-finding-reasoning"; + mvc.perform(get(path).param("sourceVersionId","source").param("issueIndex","3")).andExpect(status().isBadRequest());verifyNoInteractions(service); + mvc.perform(get(path).param("sourceVersionId","source").param("issueIndex","3").header("If-Match","snapshot")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.reviewToken").value("snapshot")); + verify(service).read("project","target","source",3,"snapshot"); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ProjectManagementControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ProjectManagementControllerTest.java index e062feb25..e6ab75cbb 100644 --- a/backend/src/test/java/net/modtale/controller/admin/ProjectManagementControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/admin/ProjectManagementControllerTest.java @@ -33,9 +33,9 @@ void publishProjectDelegatesWithTheResolvedCurrentUser() { admin.setId("admin-1"); when(accountService.requireCurrentUser("publishing projects")).thenReturn(admin); - var response = controller.publishProject("project-1"); + var response = controller.publishProject("project-1", "review-token", "version-1"); assertEquals(200, response.getStatusCode().value()); - verify(projectReviewAdminService).publishProject(admin, "project-1"); + verify(projectReviewAdminService).publishProject(admin, "project-1", "review-token", "version-1"); } } diff --git a/backend/src/test/java/net/modtale/controller/admin/ReviewCancellationControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ReviewCancellationControllerTest.java new file mode 100644 index 000000000..f10147af2 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ReviewCancellationControllerTest.java @@ -0,0 +1,97 @@ +package net.modtale.controller.admin; + +import net.modtale.service.admin.review.*; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringJUnitConfig(ReviewCancellationControllerTest.Config.class) +class ReviewCancellationControllerTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ReviewCancellationAccess access(){return mock(ReviewCancellationAccess.class);} + @Bean ReviewCancellationController controller(ReviewCancellationAccess access){return new ReviewCancellationController(access);} + @Bean(name="apiSecurity") ReviewRepairControllerTest.Permissions permissions(){return new ReviewRepairControllerTest.Permissions();} + } + @Autowired ReviewCancellationController controller;@Autowired ReviewCancellationAccess access; + String id="11111111-1111-1111-1111-111111111111",checkId="22222222-2222-2222-2222-222222222222"; + ReviewOrphanCancellationJournal.Prepared original=new ReviewOrphanCancellationJournal.Prepared(id,"a".repeat(64),1,60001); + String body(){return "{\"isolationId\":\""+id+"\",\"targetSha256\":\""+"a".repeat(64)+"\",\"createdAt\":1,\"expiresAt\":60001}";} + @BeforeEach void setup(){reset(access);auth("PROJECT_REVIEW_READ","PROJECT_VERSION_RESCAN");} + @AfterEach void cleanup(){SecurityContextHolder.clearContext();} + void auth(String...permissions){SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("actor",null,Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList()));} + @Test void allRoutesRequireBothPermissionsBeforeAccess() { + for(String permission:List.of("PROJECT_REVIEW_READ","PROJECT_VERSION_RESCAN","PROJECT_REVIEW_DECIDE","ROLE_USER")) { + auth(permission);assertThrows(AccessDeniedException.class,()->controller.preview(id));assertThrows(AccessDeniedException.class,()->controller.capabilities());assertThrows(AccessDeniedException.class,()->controller.prepare(null)); + assertThrows(AccessDeniedException.class,()->controller.execute(null));assertThrows(AccessDeniedException.class,()->controller.receipt(null)); + assertThrows(AccessDeniedException.class,()->controller.recover(id));assertThrows(AccessDeniedException.class,()->controller.check(null));assertThrows(AccessDeniedException.class,()->controller.checkReceipt(null)); + } + SecurityContextHolder.clearContext();var missing=assertThrows(IllegalArgumentException.class,()->controller.capabilities());Throwable root=missing;while(root.getCause()!=null)root=root.getCause();assertInstanceOf(org.springframework.security.authentication.AuthenticationCredentialsNotFoundException.class,root);verifyNoInteractions(access); + } + @Test void previewIsReadOnlyNoStoreAndContainsOnlyConfirmationFields()throws Exception { + when(access.preview(id)).thenReturn(new ReviewOrphanCancellationJournal.Preview(id,"OBJECT_ID","0123456789abcdef01234567",2,"v",checkId,id,"b".repeat(64),"a".repeat(64),"c".repeat(64))); + MockMvcBuilders.standaloneSetup(controller).build().perform(get("/api/v1/admin/verification/cancellations/targets/"+id)) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")) + .andExpect(jsonPath("$.projectIdType").value("OBJECT_ID")).andExpect(jsonPath("$.versionIndex").value(2)) + .andExpect(jsonPath("$.targetSha256").value("a".repeat(64))).andExpect(jsonPath("$.jobId").value(id)) + .andExpect(jsonPath("$.binding").doesNotExist()).andExpect(jsonPath("$.filePath").doesNotExist()).andExpect(jsonPath("$.actor").doesNotExist()) + .andExpect(jsonPath("$.origin").doesNotExist()).andExpect(jsonPath("$.token").doesNotExist()); + verify(access).preview(id);verifyNoMoreInteractions(access); + } + @Test void preparationAndExecutionRemainSeparateHttpActions()throws Exception { + var mvc=MockMvcBuilders.standaloneSetup(controller).build();when(access.prepare(id)).thenReturn(original); + mvc.perform(post("/api/v1/admin/verification/cancellations/prepare").contentType("application/json").content("{\"isolationId\":\""+id+"\"}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.isolationId").value(id)).andExpect(jsonPath("$.token").doesNotExist()); + verify(access).prepare(id);verifyNoMoreInteractions(access); + when(access.execute(original)).thenReturn(new ReviewOrphanCancellationExecutor.Execution("UNKNOWN",null)); + mvc.perform(post("/api/v1/admin/verification/cancellations/execute").contentType("application/json").content(body())) + .andExpect(status().isOk()).andExpect(jsonPath("$.state").value("UNKNOWN")).andExpect(header().string("Cache-Control","no-store"));verify(access).execute(original); + } + @Test void recoveryAndReceiptRoutesNeverDispatchNewChecks()throws Exception { + var mvc=MockMvcBuilders.standaloneSetup(controller).build();var receipt=new ReviewOrphanCancellationJournal.Receipt(original,"UNKNOWN",null,null); + when(access.recover(id)).thenReturn(receipt);when(access.receipt(original)).thenReturn(receipt); + var request=new ReviewCancellationAccess.Check(checkId,original);when(access.checkReceipt(request)).thenReturn(new ReviewCancellationReconciler.Receipt(checkId,original,"READING",null,null)); + mvc.perform(get("/api/v1/admin/verification/cancellations/operations/"+id)).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.prepared.expiresAt").value(60001)); + mvc.perform(post("/api/v1/admin/verification/cancellations/receipt").contentType("application/json").content(body())).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")); + mvc.perform(post("/api/v1/admin/verification/cancellations/checks/receipt").contentType("application/json").content("{\"id\":\""+checkId+"\",\"original\":"+body()+"}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.state").value("READING")); + verify(access).recover(id);verify(access).receipt(original);verify(access).checkReceipt(request);verifyNoMoreInteractions(access); + } + @Test void explicitCheckPreservesItsOwnIdentityAndOriginalIntent()throws Exception { + var request=new ReviewCancellationAccess.Check(checkId,original); + when(access.check(request)).thenReturn(new ReviewCancellationReconciler.Receipt(checkId,original,"UNKNOWN",null,null)); + MockMvcBuilders.standaloneSetup(controller).build().perform(post("/api/v1/admin/verification/cancellations/checks").contentType("application/json").content("{\"id\":\""+checkId+"\",\"original\":"+body()+"}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.id").value(checkId)).andExpect(jsonPath("$.original.isolationId").value(id)); + verify(access).check(request);verifyNoMoreInteractions(access); + } + @Test void errorsAndMalformedBodiesAreEmptyAndNotCacheable()throws Exception { + var mvc=MockMvcBuilders.standaloneSetup(controller).build(); + for(var failure:List.of(new SecurityException("private"),new IllegalArgumentException("private"),new IllegalStateException("private"),new com.mongodb.MongoException("private"))) { + when(access.recover(id)).thenThrow(failure);int code=failure instanceof SecurityException?403:failure instanceof IllegalArgumentException?400:failure instanceof IllegalStateException?409:503; + mvc.perform(get("/api/v1/admin/verification/cancellations/operations/"+id)).andExpect(status().is(code)).andExpect(content().string("")).andExpect(header().string("Cache-Control","no-store"));reset(access); + } + mvc.perform(post("/api/v1/admin/verification/cancellations/execute").contentType("application/json").content("{")) + .andExpect(status().isBadRequest()).andExpect(header().string("Cache-Control","no-store"));verifyNoInteractions(access); + } + @Test void capabilityIsAccountGatedAndNoStore()throws Exception { + when(access.capability()).thenReturn(new ReviewCancellationAccess.Capability("CANCEL_ORIGINAL_REVIEW")); + MockMvcBuilders.standaloneSetup(controller).build().perform(get("/api/v1/admin/verification/cancellations/capabilities")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.action").value("CANCEL_ORIGINAL_REVIEW")); + } + @Test void controllerAbsentUnlessCancellationExplicitlyEnabled() { + new org.springframework.boot.test.context.runner.ApplicationContextRunner().withUserConfiguration(ReviewCancellationController.class) + .withPropertyValues("app.warden.repair.enabled=true","app.warden.jobs.enabled=true") + .run(c->{assertNull(c.getStartupFailure());assertFalse(c.containsBean("reviewCancellationController"));}); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ReviewOriginControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ReviewOriginControllerTest.java new file mode 100644 index 000000000..f2534a6b1 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ReviewOriginControllerTest.java @@ -0,0 +1,69 @@ +package net.modtale.controller.admin; + +import net.modtale.service.admin.review.*; +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringJUnitConfig(ReviewOriginControllerTest.Config.class) +class ReviewOriginControllerTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ReviewOriginInventory inventory(){return mock(ReviewOriginInventory.class);} + @Bean ReviewOriginController controller(ReviewOriginInventory inventory){return new ReviewOriginController(inventory);} + @Bean(name="apiSecurity") Permissions permissions(){return new Permissions();} + } + public static class Permissions {public boolean hasAdminPermission(String permission,Authentication auth){return auth!=null && auth.getAuthorities().stream().anyMatch(a->a.getAuthority().equals(permission));}} + @Autowired ReviewOriginController controller;@Autowired ReviewOriginInventory inventory; + void auth(String permission){SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("reviewer",null,List.of(new SimpleGrantedAuthority(permission))));} + @BeforeEach void setup(){reset(inventory);auth("PROJECT_REVIEW_READ");} + @AfterEach void cleanup(){SecurityContextHolder.clearContext();} + @Test void permissionPrecedesValidationAndDatabaseAccess() { + for(String p:List.of("PROJECT_REVIEW_DECIDE","PROJECT_VERSION_RESCAN","ROLE_USER","ROLE_ANONYMOUS")){auth(p);assertThrows(AccessDeniedException.class,()->controller.read("bad",0));}verifyNoInteractions(inventory); + } + @Test void cursorScopeAndCanonicalEncodingAreStrict() { + for(Object id:List.of("a",new ObjectId("abcdefabcdefabcdefabcdef")))for(boolean after:List.of(false,true)){ + var cursor=new RemoteReviewDiscovery.Cursor(id,after?0:12,after);assertEquals(cursor,ReviewOriginCursor.decode(ReviewOriginCursor.encode(cursor))); + assertThrows(IllegalArgumentException.class,()->ReviewOriginCursor.decode(ReviewStateDiagnosticCursor.encode(cursor))); + assertThrows(IllegalArgumentException.class,()->ReviewStateDiagnosticCursor.decode(ReviewOriginCursor.encode(cursor))); + } + for(String token:List.of("o1.a.1.s.1.YQ","o1.v.1.s.00.YQ","o1.v.1.s.0.YQ==","o1.v.1.o.0.YQ","o1.v.1.s.0.YR"))assertThrows(IllegalArgumentException.class,()->ReviewOriginCursor.decode(token)); + } + @Test void invalidHttpParametersNeverReadRecords()throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + for(String limit:List.of("0","65","no","2147483648"))mvc.perform(get("/api/v1/admin/verification/origins/page").param("limit",limit)).andExpect(status().isBadRequest()); + for(String cursor:List.of("bad","d1.v.1.s.0.YQ","1.s.0.YQ"))mvc.perform(get("/api/v1/admin/verification/origins/page").param("cursor",cursor)).andExpect(status().isBadRequest());verifyNoInteractions(inventory); + } + @Test void emptyPageKeepsContinuationAndDoesNotClaimCompletion()throws Exception { + var next=new RemoteReviewDiscovery.Cursor("a",25,false);when(inventory.page(null,25)).thenReturn(new ReviewOriginInventory.Page(List.of(),next,25)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(get("/api/v1/admin/verification/origins/page")).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")) + .andExpect(jsonPath("$.scope").value("RETAINED_REVIEW_ORIGINS")).andExpect(jsonPath("$.items").isEmpty()).andExpect(jsonPath("$.nextCursor").value(ReviewOriginCursor.encode(next))).andExpect(jsonPath("$.examinedSlots").value(25)); + } + @Test void recordsPreserveBsonIdentityAndAmbiguityWithoutOriginPayload()throws Exception { + String hex="abcdefabcdefabcdefabcdef"; + when(inventory.page(null,25)).thenReturn(new ReviewOriginInventory.Page(List.of(new ReviewOriginInventory.Item(new ObjectId(hex),2,null,true,null,null,ReviewOriginInventory.OriginState.UNREADABLE_BINDING)),null,3)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(get("/api/v1/admin/verification/origins/page")).andExpect(status().isOk()).andExpect(jsonPath("$.items[0].position.projectIdType").value("OBJECT_ID")) + .andExpect(jsonPath("$.items[0].position.projectId").value(hex)).andExpect(jsonPath("$.items[0].ambiguousVersion").value(true)) + .andExpect(jsonPath("$.items[0].originState").value("UNREADABLE_BINDING")).andExpect(jsonPath("$.items[0].deploymentId").doesNotExist()).andExpect(jsonPath("$.items[0].callerScope").doesNotExist()); + } + @Test void databaseFailuresHaveGenericOperationalResponse()throws Exception { + when(inventory.page(null,25)).thenThrow(new IllegalStateException("private connection details")); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + var response=mvc.perform(get("/api/v1/admin/verification/origins/page")).andExpect(status().isServiceUnavailable()).andReturn().getResponse();assertFalse(response.getContentAsString().contains("private connection details")); + } +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ReviewRepairControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ReviewRepairControllerTest.java new file mode 100644 index 000000000..3063a593d --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ReviewRepairControllerTest.java @@ -0,0 +1,80 @@ +package net.modtale.controller.admin; + +import net.modtale.service.admin.review.*; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringJUnitConfig(ReviewRepairControllerTest.Config.class) +class ReviewRepairControllerTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ReviewRepairAccess access(){return mock(ReviewRepairAccess.class);} + @Bean ReviewRepairController controller(ReviewRepairAccess access){return new ReviewRepairController(access);} + @Bean(name="apiSecurity") Permissions permissions(){return new Permissions();} + } + public static class Permissions { + public boolean hasAdminPermission(String permission,Authentication auth){return auth!=null&&auth.getAuthorities().stream().anyMatch(a->a.getAuthority().equals(permission));} + } + @Autowired ReviewRepairController controller;@Autowired ReviewRepairAccess access; + @BeforeEach void setup(){reset(access);auth("PROJECT_REVIEW_READ","PROJECT_VERSION_RESCAN");} + @AfterEach void cleanup(){SecurityContextHolder.clearContext();} + void auth(String...permissions){SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("actor",null,Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList()));} + @Test void everyRouteRequiresBothPermissionsBeforeServiceAccess() { + for(String permission:List.of("PROJECT_REVIEW_READ","PROJECT_VERSION_RESCAN","PROJECT_REVIEW_DECIDE","ROLE_USER")) { + auth(permission);assertThrows(AccessDeniedException.class,()->controller.closeExpired(null));assertThrows(AccessDeniedException.class,()->controller.operations(null,25));assertThrows(AccessDeniedException.class,()->controller.recover("invalid"));assertThrows(AccessDeniedException.class,()->controller.capabilities());assertThrows(AccessDeniedException.class,()->controller.inspect(null));assertThrows(AccessDeniedException.class,()->controller.prepare(null)); + assertThrows(AccessDeniedException.class,()->controller.execute(null));assertThrows(AccessDeniedException.class,()->controller.receipt(null)); + } + verifyNoInteractions(access); + } + @Test void preparedAndReceiptResponsesAreBoundedAndNotCacheable()throws Exception { + var prepared=new ReviewRepairPreparation.Prepared(UUID.randomUUID().toString(),"a".repeat(64),1,2); + when(access.prepare(any())).thenReturn(prepared);when(access.receipt(any())).thenReturn(new ReviewRepairAccess.Receipt(new ReviewRepairAccess.Position("STRING","p",0),"v",prepared.sha256(),"UNKNOWN",null)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(post("/api/v1/admin/verification/repairs/prepare").contentType("application/json").content("{\"id\":\""+prepared.id()+"\",\"position\":{\"projectIdType\":\"STRING\",\"projectId\":\"p\",\"versionIndex\":0},\"versionId\":\"v\",\"expectedSha256\":\""+prepared.sha256()+"\"}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.id").value(prepared.id())).andExpect(jsonPath("$.actor").doesNotExist()); + mvc.perform(post("/api/v1/admin/verification/repairs/receipt").contentType("application/json").content("{\"id\":\""+prepared.id()+"\",\"sha256\":\""+prepared.sha256()+"\",\"createdAt\":1,\"expiresAt\":2}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.state").value("UNKNOWN")); + } + @Test void serviceErrorsNeverExposeStoredPayloads()throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + for(var failure:List.of(new SecurityException("private payload"),new IllegalArgumentException("private payload"),new IllegalStateException("private payload"),new com.mongodb.MongoException("private payload"))) { + when(access.inspect(any())).thenThrow(failure);int expected=failure instanceof SecurityException?403:failure instanceof IllegalArgumentException?400:failure instanceof IllegalStateException?409:503; + mvc.perform(post("/api/v1/admin/verification/repairs/inspect").contentType("application/json").content("{}")) + .andExpect(status().is(expected)).andExpect(header().string("Cache-Control","no-store")).andExpect(content().string(""));reset(access); + } + } + @Test void controllerIsAbsentUnlessFeatureExplicitlyEnabled() { + new org.springframework.boot.test.context.runner.ApplicationContextRunner().withUserConfiguration(ReviewRepairController.class) + .run(context->{assertNull(context.getStartupFailure());assertFalse(context.containsBean("reviewRepairController"));}); + } + @Test void capabilityUsesAuthenticatedNoStoreContract()throws Exception { + when(access.capability()).thenReturn(new ReviewRepairAccess.Capability("ISOLATE_LOCAL_REVIEW")); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/repairs/capabilities")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.action").value("ISOLATE_LOCAL_REVIEW")); + } + + @Test void operationDiscoveryAndRecoveryUseNoStoreHttpResponses()throws Exception { + String id="11111111-1111-1111-1111-111111111111";var prepared=new ReviewRepairPreparation.Prepared(id,"a".repeat(64),1,2); + when(access.operations(null,25)).thenReturn(new ReviewRepairOperationReader.Page(List.of(new ReviewRepairOperationReader.Item(id,"UNKNOWN")),null,"OPERATION_ID")); + when(access.recover(id)).thenReturn(new ReviewRepairAccess.Recovered(prepared,new ReviewRepairAccess.Receipt(new ReviewRepairAccess.Position("STRING","p",0),"v",prepared.sha256(),"UNKNOWN",null))); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/repairs/operations")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.items[0].id").value(id)); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/repairs/operations/"+id)) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.prepared.id").value(id)).andExpect(jsonPath("$.receipt.beforeSha256").value(prepared.sha256())); + } + +} diff --git a/backend/src/test/java/net/modtale/controller/admin/ReviewStateDiagnosticControllerTest.java b/backend/src/test/java/net/modtale/controller/admin/ReviewStateDiagnosticControllerTest.java new file mode 100644 index 000000000..9d618fb9f --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/admin/ReviewStateDiagnosticControllerTest.java @@ -0,0 +1,71 @@ +package net.modtale.controller.admin; + +import net.modtale.service.admin.review.*; +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringJUnitConfig(ReviewStateDiagnosticControllerTest.Config.class) +class ReviewStateDiagnosticControllerTest { + @Configuration @EnableMethodSecurity static class Config { + @Bean ReviewStateDiagnosticReader reader(){return mock(ReviewStateDiagnosticReader.class);} + @Bean ReviewStateDiagnosticController controller(ReviewStateDiagnosticReader reader){return new ReviewStateDiagnosticController(reader);} + @Bean(name="apiSecurity") Permissions permissions(){return new Permissions();} + } + public static class Permissions { + public boolean hasAdminPermission(String permission,Authentication auth){return auth!=null && auth.getAuthorities().stream().anyMatch(a->a.getAuthority().equals(permission));} + } + @Autowired ReviewStateDiagnosticController controller; + @Autowired ReviewStateDiagnosticReader reader; + void auth(String permission){SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("reviewer",null,List.of(new SimpleGrantedAuthority(permission))));} + @BeforeEach void setup(){reset(reader);auth("PROJECT_REVIEW_READ");} + @AfterEach void cleanup(){SecurityContextHolder.clearContext();} + @Test void permissionPrecedesEvenInvalidCursorAndDatabaseAccess() { + for(String permission:List.of("PROJECT_REVIEW_DECIDE","ROLE_USER","ROLE_ANONYMOUS")) { + auth(permission);assertThrows(AccessDeniedException.class,()->controller.read("bad",0)); + } + verifyNoInteractions(reader); + } + @Test void emptyDiagnosticPageRetainsPhysicalContinuation()throws Exception { + var next=new RemoteReviewDiscovery.Cursor("a",25,false); + when(reader.page(null,25)).thenReturn(new ReviewStateDiagnosticReader.Page(List.of(),next,25)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(get("/api/v1/admin/verification/diagnostics/page")).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")) + .andExpect(jsonPath("$.items").isEmpty()).andExpect(jsonPath("$.nextCursor").value(ReviewStateDiagnosticCursor.encode(next))) + .andExpect(jsonPath("$.examinedSlots").value(25)).andExpect(jsonPath("$.scope").value("PENDING_SCAN_STRUCTURE")); + } + @Test void invalidHttpParametersNeverReachDatabase()throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + for(String limit:List.of("0","65","no","2147483648"))mvc.perform(get("/api/v1/admin/verification/diagnostics/page").param("limit",limit)).andExpect(status().isBadRequest()); + for(String cursor:List.of("bad","1.s.0.YQ","d1.a.1.s.1.YQ"))mvc.perform(get("/api/v1/admin/verification/diagnostics/page").param("cursor",cursor)).andExpect(status().isBadRequest()); + verifyNoInteractions(reader); + } + @Test void responsePreservesAmbiguousBsonIdentityWithoutFlattening()throws Exception { + String hex="abcdefabcdefabcdefabcdef";var reasons=List.of(ReviewStateDiagnosticReader.Reason.DUPLICATE_VERSION_ID); + var cursor=new RemoteReviewDiscovery.Cursor(hex,1,false); + when(reader.page(cursor,2)).thenReturn(new ReviewStateDiagnosticReader.Page(List.of( + new ReviewStateDiagnosticReader.Item(hex,1,null,reasons),new ReviewStateDiagnosticReader.Item(new ObjectId(hex),0,"v",reasons)),null,2)); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(get("/api/v1/admin/verification/diagnostics/page").param("limit","2").param("cursor",ReviewStateDiagnosticCursor.encode(cursor))) + .andExpect(status().isOk()).andExpect(jsonPath("$.items[0].position.projectIdType").value("STRING")) + .andExpect(jsonPath("$.items[1].position.projectIdType").value("OBJECT_ID")) + .andExpect(jsonPath("$.items[0].position.projectId").value(hex)).andExpect(jsonPath("$.items[1].position.projectId").value(hex)) + .andExpect(jsonPath("$.items[0].position.versionIndex").value(1)).andExpect(jsonPath("$.items[0].versionId").isEmpty()) + .andExpect(jsonPath("$.items[0].reasons[0]").value("DUPLICATE_VERSION_ID")).andExpect(jsonPath("$.nextCursor").isEmpty()); + verify(reader).page(cursor,2); + } +} diff --git a/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java b/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java index 2d1249592..7cc027300 100644 --- a/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java @@ -50,10 +50,19 @@ void setUp() { accountService, twoFactorService, launcherAuthService, - securityContextRepository + securityContextRepository, + mock(net.modtale.service.auth.MfaEnrollmentService.class) ); } + @Test + void getCannotStartMfaEnrollment() throws Exception { + var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/auth/mfa/setup")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isMethodNotAllowed()); + org.mockito.Mockito.verifyNoInteractions(accountService, twoFactorService, authenticationMutationService); + } + @Test void loginReturnsAcceptedChallengeWhenMfaIsEnabled() { User user = new User(); diff --git a/backend/src/test/java/net/modtale/controller/project/TeamControllerTest.java b/backend/src/test/java/net/modtale/controller/project/TeamControllerTest.java index d2a0d08b7..2c4846616 100644 --- a/backend/src/test/java/net/modtale/controller/project/TeamControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/project/TeamControllerTest.java @@ -81,10 +81,10 @@ void acceptInviteRouteUsesTheAuthenticatedUser() throws Exception { User user = user("user-2"); when(accountService.requireCurrentUser("accepting a project invite")).thenReturn(user); - mockMvc.perform(post("/api/v1/projects/project-1/invite/accept")) + mockMvc.perform(post("/api/v1/projects/project-1/invite/accept").contentType(org.springframework.http.MediaType.APPLICATION_JSON).content("{\"requestId\":\"invite-1\"}")) .andExpect(status().isOk()); - verify(teamService).acceptInvite("project-1", "user-2"); + verify(teamService).acceptInvite("project-1", "user-2", "invite-1"); } private static User user(String id) { diff --git a/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java b/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java index 0ad7499b4..d819a623d 100644 --- a/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java +++ b/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java @@ -165,6 +165,7 @@ void verificationQueueMappingSelectsPendingVersionAndKeepsScanPayloadSmall() { pendingVersion.setChangelog("Needs review"); pendingVersion.getScanResult().setStatus(ScanStatus.SUSPICIOUS); pendingVersion.getScanResult().setVerdict("REVIEW"); + pendingVersion.getScanResult().setScanState("REMOTE_HELD"); pendingVersion.getScanResult().setRiskScore(73); pendingVersion.getScanResult().setKnownIssueCount(2); pendingVersion.getScanResult().setNewIssueCount(3); @@ -177,6 +178,7 @@ void verificationQueueMappingSelectsPendingVersionAndKeepsScanPayloadSmall() { assertEquals("Needs review", item.pendingVersion().changelog()); assertEquals(ScanStatus.SUSPICIOUS, item.pendingVersion().scan().status()); assertEquals("REVIEW", item.pendingVersion().scan().verdict()); + assertEquals("REMOTE_HELD", item.pendingVersion().scan().scanState()); assertEquals(73, item.pendingVersion().scan().riskScore()); assertEquals(3, item.pendingVersion().scan().newIssueCount()); assertEquals(1, item.pendingVersion().scan().escalatedIssueCount()); diff --git a/backend/src/test/java/net/modtale/model/project/RemoteReviewOriginTest.java b/backend/src/test/java/net/modtale/model/project/RemoteReviewOriginTest.java new file mode 100644 index 000000000..cdc0db524 --- /dev/null +++ b/backend/src/test/java/net/modtale/model/project/RemoteReviewOriginTest.java @@ -0,0 +1,19 @@ +package net.modtale.model.project; + +import org.junit.jupiter.api.Test; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class RemoteReviewOriginTest { + @Test void rejectsMalformedOriginAndPreservesValidOriginOnAttachment() { + String id="abcdefab-1111-1111-1111-111111111111";var origin=new RemoteReviewOrigin(id,"a".repeat(64)); + assertThrows(IllegalArgumentException.class,()->new RemoteReviewOrigin(id.toUpperCase(),"a".repeat(64))); + assertThrows(IllegalArgumentException.class,()->new RemoteReviewOrigin(id,null));assertThrows(IllegalArgumentException.class,()->new RemoteReviewOrigin(null,"a".repeat(64))); + var binding=new RemoteReviewBinding("p","v",UUID.randomUUID().toString(),1,"file","a".repeat(64),"b".repeat(64),"warden-3.0.0:"+"c".repeat(64),"d".repeat(64),null,false,origin); + assertEquals(origin,binding.withJobId(UUID.randomUUID().toString()).origin()); + } + @Test void legacyConstructorDoesNotInventAnOrigin() { + var legacy=new RemoteReviewBinding("p","v",UUID.randomUUID().toString(),1,"file","a".repeat(64),"b".repeat(64),"warden-3.0.0:"+"c".repeat(64),"d".repeat(64),null); + assertNull(legacy.origin());assertNull(legacy.withJobId(UUID.randomUUID().toString()).origin()); + } +} diff --git a/backend/src/test/java/net/modtale/model/project/ReviewIsolationProvenanceTest.java b/backend/src/test/java/net/modtale/model/project/ReviewIsolationProvenanceTest.java new file mode 100644 index 000000000..992a2a2b3 --- /dev/null +++ b/backend/src/test/java/net/modtale/model/project/ReviewIsolationProvenanceTest.java @@ -0,0 +1,19 @@ +package net.modtale.model.project; + +import org.junit.jupiter.api.Test; +import java.util.Date; +import static org.junit.jupiter.api.Assertions.*; + +class ReviewIsolationProvenanceTest { + @Test void provenanceDoesNotExposeMutableDates() { + var date=new Date(1000);var record=new ProjectVersion.ReviewIsolation("11111111-1111-1111-1111-111111111111","actor","a".repeat(64),date); + date.setTime(2000);record.isolatedAt().setTime(3000);assertEquals(1000,record.isolatedAt().getTime()); + } + @Test void publicJsonNeitherExposesNorAcceptsProvenance()throws Exception { + var mapper=new com.fasterxml.jackson.databind.ObjectMapper();var version=new ProjectVersion();version.setVersionNumber("v"); + version.setReviewIsolation(new ProjectVersion.ReviewIsolation("11111111-1111-1111-1111-111111111111","actor","a".repeat(64),new Date(1000))); + String json=mapper.writeValueAsString(version);assertFalse(json.contains("reviewIsolation"));assertFalse(json.contains("actor")); + var incoming=mapper.readValue("{\"versionNumber\":\"v\",\"reviewIsolation\":{\"operationId\":\"forged\",\"actorId\":\"forged\"}}",ProjectVersion.class); + assertNull(incoming.getReviewIsolation());assertEquals("v",incoming.getVersionNumber()); + } +} diff --git a/backend/src/test/java/net/modtale/model/project/SecurityManifestTest.java b/backend/src/test/java/net/modtale/model/project/SecurityManifestTest.java new file mode 100644 index 000000000..44b990107 --- /dev/null +++ b/backend/src/test/java/net/modtale/model/project/SecurityManifestTest.java @@ -0,0 +1,41 @@ +package net.modtale.model.project; + +import net.modtale.config.db.SecurityEvidenceConverters; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class SecurityManifestTest { + @Test void streamedIdentityMatchesCanonicalOrderingAndUtf16PathLengths() throws Exception { + var entries = new LinkedHashMap(); + entries.put("z/😀.class", "a".repeat(64)); entries.put("a.json", "b".repeat(64)); + StringBuilder canonical = new StringBuilder(); + new TreeMap<>(entries).forEach((path, hash) -> canonical.append(path.length()).append(':').append(path).append(':').append(hash).append('\n')); + String expected = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical.toString().getBytes(StandardCharsets.UTF_8))); + assertEquals(expected, SecurityManifest.identity(entries)); + } + @Test void aggregatePathBudgetAppliesToValidationAndPersistence() { + var entries = new LinkedHashMap(); + for (int i = 0; i < 126; i++) entries.put(i + "x".repeat(8000), "a".repeat(64)); + assertFalse(SecurityManifest.valid(entries, false)); + assertThrows(Exception.class, () -> SecurityManifest.identity(entries)); + var evidence = new ScanResult.SecurityEvidence("policy", "a".repeat(64), "b".repeat(64), true, true, "COMPLETED", entries); + assertThrows(Exception.class, () -> new SecurityEvidenceConverters.Write().convert(evidence)); + assertThrows(Exception.class, () -> new SecurityEvidenceConverters.Read().convert(new Document("entryHashes", entries))); + } + @Test void exactAggregateBoundaryIsAcceptedAndOneMorePathIsRejected() { + var entries = new LinkedHashMap(); + for (int i = 0; i < 125; i++) entries.put(String.format("%04d", i) + "x".repeat(7996), "a".repeat(64)); + assertTrue(SecurityManifest.valid(entries, false)); + entries.put("overflow", "a".repeat(64)); + assertFalse(SecurityManifest.valid(entries, false)); + } + @Test void compactApprovalSummariesAllowEmptyManifestsButCannotStandInForFullEvidence() { + assertTrue(SecurityManifest.valid(Map.of(), true)); + assertFalse(SecurityManifest.valid(Map.of(), false)); + assertFalse(SecurityManifest.valid(Map.of("manifest.json", "invalid"), false)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/project/ProjectModerationServiceTest.java b/backend/src/test/java/net/modtale/service/admin/project/ProjectModerationServiceTest.java new file mode 100644 index 000000000..3fd330a9f --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/project/ProjectModerationServiceTest.java @@ -0,0 +1,74 @@ +package net.modtale.service.admin.project; + +import java.util.ArrayList; +import java.util.List; +import net.modtale.model.project.*; +import net.modtale.model.user.User; +import net.modtale.service.admin.audit.AdminAuditLogger; +import net.modtale.service.admin.review.*; +import net.modtale.service.analytics.ScoringService; +import net.modtale.service.communication.NotificationService; +import net.modtale.service.project.access.ProjectVersionAccessService; +import net.modtale.service.project.lifecycle.*; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.ScanService; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ProjectModerationServiceTest { + private final ProjectReviewPersistence writes = mock(ProjectReviewPersistence.class); + private final ProjectService projects = mock(ProjectService.class); + private final ProjectDeletionService deletion = mock(ProjectDeletionService.class); + private final NotificationService notifications = mock(NotificationService.class); + private final AdminAuditLogger audit = mock(AdminAuditLogger.class); + private final ProjectModerationService service = new ProjectModerationService(writes, projects, + mock(ProjectRetentionService.class), deletion, mock(ScoringService.class), notifications, + mock(ScanService.class), new ProjectVersionAccessService(null), audit); + private final User admin = new User(); + private ProjectReviewPersistence.Snapshot setup() { + var project = new Project(); project.setId("project"); project.setAuthorId("author"); project.setStatus(ProjectStatus.PUBLISHED); + var version = new ProjectVersion(); version.setId("v1"); + project.setVersions(new ArrayList<>(List.of(version))); admin.setId("admin"); + var snapshot = new ProjectReviewPersistence.Snapshot(new Document(), project); + when(projects.getRawProjectById("project")).thenReturn(project); + when(writes.capture(eq("project"), anyString())).thenReturn(snapshot); + when(projects.getProjectLink(project)).thenReturn("/project/project"); + return snapshot; + } + @Test void staleDeleteCannotRemoveFilesOrAnnounceSuccess() { + setup(); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.deleteProjectVersion(admin, "project", "v1")); + verifyNoInteractions(deletion, notifications, audit); + verify(projects, never()).evictProjectCache(any(Project.class)); + } + @Test void successfulDeletePersistsBeforeCleanup() { + var snapshot = setup(); var removed = snapshot.project().getVersions().getFirst(); + when(writes.applyVersionList(snapshot)).thenReturn(true); + service.deleteProjectVersion(admin, "project", "v1"); + assertTrue(snapshot.project().getVersions().isEmpty()); + var order = inOrder(writes, projects, deletion, audit); + order.verify(writes).applyVersionList(snapshot); + order.verify(projects).evictProjectCache(snapshot.project()); + order.verify(deletion).deleteVersionFile(removed); + order.verify(audit).logAction("admin", "DELETE_VERSION", "project", "VERSION", "VerID: v1"); + } + @Test void staleUnlistCannotNotifyOrEvict() { + setup(); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.unlistProject(admin, "project", "reason")); + verifyNoInteractions(notifications, audit); + verify(projects, never()).evictProjectCache(any(Project.class)); + } + @Test void successfulUnlistNotifiesAfterPersistence() { + var snapshot = setup(); when(writes.unlist(snapshot)).thenReturn(true); + service.unlistProject(admin, "project", "reason"); + var order = inOrder(writes, projects, notifications, audit); + order.verify(writes).unlist(snapshot); + order.verify(projects).evictProjectCache(snapshot.project()); + order.verify(notifications).sendNotifcation(anyList(), eq("Project Unlisted"), anyString(), any(), isNull()); + order.verify(audit).logAction("admin", "UNLIST_PROJECT", "project", "PROJECT", "Reason: reason"); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ArtifactReviewLineageIntegrationTest.java b/backend/src/test/java/net/modtale/service/admin/review/ArtifactReviewLineageIntegrationTest.java new file mode 100644 index 000000000..5958e73bf --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ArtifactReviewLineageIntegrationTest.java @@ -0,0 +1,285 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import com.mongodb.client.model.*; +import java.util.*; +import net.modtale.config.db.*; +import net.modtale.config.properties.AppSecurityProperties; +import net.modtale.model.project.*; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.issue.*; +import net.modtale.service.security.scan.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.*; +import org.springframework.data.mongodb.core.convert.*; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.core.query.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ArtifactReviewLineageIntegrationTest { + private MongoClient client; private MongoTemplate mongo; private String database; + private final String id = "abcdefabcdefabcdefabcdef"; + private Project project; private ProjectVersion target; private ScanResult result; + @BeforeEach void setup() throws Exception { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27029"); + if (!Set.of("27029","27030").contains(port)) throw new IllegalArgumentException("Unexpected port"); + database="warden_origin_test_"+UUID.randomUUID().toString().replace("-",""); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000"); + var factory=new SimpleMongoClientDatabaseFactory(client,database); + var conversions=new MongoConfig().mongoCustomConversions(new MongoArtifactManifestStore(factory)); + var context=new MongoMappingContext();context.setSimpleTypeHolder(conversions.getSimpleTypeHolder());context.afterPropertiesSet(); + var converter=new MappingMongoConverter(new DefaultDbRefResolver(factory),context);converter.setCustomConversions(conversions);converter.afterPropertiesSet(); + mongo=spy(new MongoTemplate(factory,converter)); + var source=new ProjectVersion(); source.setId("source");source.setVersionNumber("1.0"); + result=ScanEvidenceFixtures.complete(false); + source.setHash(result.getSecurityEvidence().artifactSha256());source.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + source.setApprovedSecurityEvidence(result.getSecurityEvidence());source.setApprovedSecurityContextSha256(ArtifactReviewContext.fingerprint(source)); + source.setSecurityApprovalProjectId(id); + source.setSecurityApprovedAt(System.currentTimeMillis()-1000);source.setApprovedReviewOrigins(Map.of()); + target=new ProjectVersion();target.setId("target");target.setVersionNumber("2.0");target.setHash(source.getHash()); + var queued=new ScanResult();queued.setScanState("SCANNING");queued.setStatus(ScanStatus.SCANNING);queued.setScanAttempt(1);target.setScanResult(queued); + project=new Project();project.setId(id);project.setVersions(List.of(source,target));mongo.insert(project); + new ArtifactReviewReuseService().annotate(project,"target",result); + result.setReviewedContextSha256(ArtifactReviewContext.fingerprint(target)); + assertNotNull(result.getReusedReviewOrigins()); + target.setScanResult(result); target.setSecurityApprovalProjectId(id); + new SecurityIssueApprovalService(new SecurityIssueClassificationService(new AppSecurityProperties("test",60,120,2,12,15,120,25,2))) + .markIssuesAcceptedForApprovedVersion(target); + } + @AfterEach void cleanup() { if(client!=null){client.getDatabase(database).drop();client.close();} } + private boolean apply() { + return new ScanPersistenceService(mongo,mock(ProjectRepository.class),mock(ProjectService.class)).applyScanOutcome( + id,"target",1,result,new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.APPROVE_NOW,0),target); + } + private RemoteReviewPollStore.Claim remoteClaim() { + String request=UUID.randomUUID().toString();target.setFileUrl("original.zip");result.setScanRequestId(request); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.1.fileUrl","original.zip").set("versions.1.scanResult.scanRequestId",request),Project.class); + var current=mongo.findById(id,Project.class).getVersions().get(1); + var binding=new RemoteReviewBinding(id,"target",request,1,"original.zip",target.getHash(),result.getReviewedContextSha256(),result.getSecurityEvidence().policyVersion(),"c".repeat(64),null,false,new net.modtale.model.project.RemoteReviewOrigin("11111111-1111-1111-1111-111111111111","e".repeat(64))); + assertTrue(new RemoteReviewPersistence(mongo).bind(current,binding));var polls=new RemoteReviewPollStore(mongo); + var claim=polls.claim(binding,30000);assertNotNull(claim);return polls.attachJob(claim,UUID.randomUUID().toString()); + } + @Test void remoteCompletionCombinesLiveLeaseWithValidSourceGuard() { + var claim=remoteClaim();var poll=mongo.findById(id,Project.class).getVersions().get(1).getScanResult().getRemotePoll(); + assertTrue(new ScanPersistenceService(mongo,mock(ProjectRepository.class),mock(ProjectService.class)).applyRemoteScanOutcome(claim,poll,result, + new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.APPROVE_NOW,0),target)); + var stored=mongo.findById(id,Project.class);assertEquals(ProjectVersion.ReviewStatus.APPROVED,stored.getVersions().get(1).getReviewStatus()); + assertEquals(Set.of("source"),stored.getVersions().get(1).getApprovedReviewOrigins().keySet()); + } + @Test void remoteHeldFallbackCannotWriteAfterPollOwnershipExpires() { + var claim=remoteClaim();var poll=mongo.findById(id,Project.class).getVersions().get(1).getScanResult().getRemotePoll(); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.reviewStatus",ProjectVersion.ReviewStatus.REJECTED) + .set("versions.1.scanResult.remotePoll.leaseUntil",new Date(0)),Project.class); + assertFalse(new ScanPersistenceService(mongo,mock(ProjectRepository.class),mock(ProjectService.class)).applyRemoteScanOutcome(claim,poll,result, + new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.APPROVE_NOW,0),target)); + assertEquals("REMOTE_REVIEW",mongo.findById(id,Project.class).getVersions().get(1).getScanResult().getScanState()); + } + @Test void sourceGuardPublishesOnlyTargetAndRetainsOriginProof() { + assertTrue(apply());var stored=mongo.findById(id,Project.class); + assertEquals(ProjectVersion.ReviewStatus.APPROVED,stored.getVersions().get(1).getReviewStatus()); + assertEquals(Map.of(),stored.getVersions().getFirst().getApprovedReviewOrigins()); + assertEquals(Set.of("source"),stored.getVersions().get(1).getApprovedReviewOrigins().keySet()); + assertEquals("1.0",stored.getVersions().getFirst().getVersionNumber()); + assertNull(stored.getVersions().get(1).getScanResult()); + } + @Test void proofCannotBeAttachedToDifferentContentsOrExtendedApprovalTime() { + result.setReusedReviewApprovedAt(result.getReusedReviewApprovedAt()+1); + assertFalse(apply());assertHeld(); + } + @Test void proofForOtherContentsDoesNotAuthorizeCurrentResult() { + var evidence=result.getSecurityEvidence();var entries=Map.of("Changed.class","f".repeat(64)); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(evidence.policyVersion(),evidence.artifactSha256(), + SecurityManifest.identity(entries),true,false,evidence.reviewState(),entries)); + assertFalse(apply());assertHeld(); + } + @Test void objectIdBackedVersionIdentifiersRemainBoundToTheCorrectSource() { + var sourceId=new org.bson.types.ObjectId(); + mongo.getCollection("projects").updateOne(new Document(),Updates.set("versions.0._id",sourceId)); + var snapshot=mongo.findById(id,Project.class); + new ArtifactReviewReuseService().annotate(snapshot,"target",result); + target.setApprovedReviewOrigins(result.getReusedReviewOrigins()); + assertEquals(Set.of(sourceId.toHexString()),result.getReusedReviewOrigins().keySet()); + assertTrue(apply()); + assertEquals(Set.of(sourceId.toHexString()),mongo.findById(id,Project.class).getVersions().get(1).getApprovedReviewOrigins().keySet()); + } + @Test void rejectionBeforePersistenceHoldsInsteadOfPublishing() { + mongo.getCollection("projects").updateOne(new Document(),Updates.set("versions.0.reviewStatus","REJECTED")); + assertFalse(apply());assertHeld(); + } + @Test void rejectionBetweenOriginReadAndWriteCannotPublishOrModifySource() { + doAnswer(invocation -> { + Query query=invocation.getArgument(0); + if(query.getQueryObject().containsKey("$expr")) + mongo.getCollection("projects").updateOne(new Document(),Updates.set("versions.0.reviewStatus","REJECTED")); + return invocation.callRealMethod(); + }).when(mongo).updateFirst(any(Query.class),any(Update.class),eq(Project.class)); + assertFalse(apply());assertHeld(); + assertEquals(ProjectVersion.ReviewStatus.REJECTED,mongo.findById(id,Project.class).getVersions().getFirst().getReviewStatus()); + } + private Project readyForManual() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.1.scanResult",result).set("status",ProjectStatus.PENDING),Project.class); + return mongo.findById(id,Project.class); + } + private void accept(ProjectVersion version) { + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);version.setSecurityApprovalProjectId(id); + new SecurityIssueApprovalService(new SecurityIssueClassificationService(new AppSecurityProperties("test",60,120,2,12,15,120,25,2))) + .markIssuesAcceptedForApprovedVersion(version); + } + @Test void manualVersionApprovalRetainsProof() { + var loaded=readyForManual();var version=loaded.getVersions().get(1); + var persistence=new VersionReviewPersistence(mongo); + var snapshot=persistence.capture(id,"target",VersionReviewSnapshot.token(version));accept(version); + assertTrue(persistence.apply(snapshot,version)); + assertEquals(Set.of("source"),mongo.findById(id,Project.class).getVersions().get(1).getApprovedReviewOrigins().keySet()); + } + @Test void manualVersionApprovalRejectsSourceMutationAtItsAtomicWrite() { + var loaded=readyForManual();var version=loaded.getVersions().get(1); + var persistence=new VersionReviewPersistence(mongo); + var snapshot=persistence.capture(id,"target",VersionReviewSnapshot.token(version));accept(version); + var realCollection=mongo.getCollection("projects");var intercepted=spy(realCollection.withWriteConcern(com.mongodb.WriteConcern.MAJORITY.withJournal(true))); + doReturn(intercepted).when(mongo).getCollection("projects"); + doReturn(intercepted).when(intercepted).withWriteConcern(any(com.mongodb.WriteConcern.class)); + doAnswer(invocation -> { + realCollection.updateOne(new Document(),Updates.set("versions.0.reviewStatus","REJECTED")); + return invocation.callRealMethod(); + }).when(intercepted).updateOne(any(org.bson.conversions.Bson.class),any(org.bson.conversions.Bson.class),any(com.mongodb.client.model.UpdateOptions.class)); + assertFalse(persistence.apply(snapshot,version)); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(id,Project.class).getVersions().get(1).getReviewStatus()); + } + @Test void projectPublicationPreservesValidProof() { + var loaded=readyForManual();var persistence=new ProjectReviewPersistence(mongo); + var snapshot=persistence.capture(id,ProjectReviewSnapshot.token(loaded)); + accept(snapshot.project().getVersions().get(1));snapshot.project().setStatus(ProjectStatus.PUBLISHED); + assertTrue(persistence.apply(snapshot,"target")); + assertEquals(Set.of("source"),mongo.findById(id,Project.class).getVersions().get(1).getApprovedReviewOrigins().keySet()); + } + @Test void freshlyOpenedProjectCannotPublishAnAlreadyRevokedReuse() { + readyForManual();mongo.getCollection("projects").updateOne(new Document(),Updates.set("versions.0.reviewStatus","REJECTED")); + var loaded=mongo.findById(id,Project.class);var persistence=new ProjectReviewPersistence(mongo); + var snapshot=persistence.capture(id,ProjectReviewSnapshot.token(loaded)); + accept(snapshot.project().getVersions().get(1));snapshot.project().setStatus(ProjectStatus.PUBLISHED); + assertFalse(persistence.apply(snapshot,"target")); + assertNotEquals(ProjectStatus.PUBLISHED,mongo.findById(id,Project.class).getStatus()); + } + @Test void legacyReusedScanCannotBypassOriginsThroughManualApproval() { + result.setReusedReviewOrigins(null);var loaded=readyForManual();var version=loaded.getVersions().get(1); + var persistence=new VersionReviewPersistence(mongo); + var snapshot=persistence.capture(id,"target",VersionReviewSnapshot.token(version));accept(version); + assertFalse(persistence.apply(snapshot,version)); + } + private Project scheduled() { + mongo.getCollection("projects").updateOne(new Document(),Updates.combine( + Updates.set("versions.1.reviewStatus","SCHEDULED"),Updates.set("versions.1.scheduledPublishDate","2020-01-01T00:00:00"))); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.1.scanResult",result),Project.class); + return mongo.findById(id,Project.class); + } + private List publishScheduled(Project snapshot, net.modtale.service.communication.ProjectNotificationService notifications) { + var classification=new SecurityIssueClassificationService(new AppSecurityProperties("test",60,120,2,12,15,120,25,2)); + var analysis=new SecurityIssueAnalysisService(classification,new SecurityIssueApprovalService(classification)); + var warden=mock(WardenClientService.class);when(warden.currentPolicyVersion()).thenReturn(result.getSecurityEvidence().policyVersion()); + return new net.modtale.service.project.lifecycle.ScheduledReleaseExecutionService(mongo,mock(ProjectService.class),notifications,analysis,warden) + .publishDueVersions(snapshot,java.time.LocalDateTime.now()); + } + @Test void scheduledPublicationRetainsOriginsAndLeavesSourceUnchanged() { + var notifications=mock(net.modtale.service.communication.ProjectNotificationService.class); + assertEquals(List.of("2.0"),publishScheduled(scheduled(),notifications)); + var stored=mongo.findById(id,Project.class); + assertEquals(Set.of("source"),stored.getVersions().get(1).getApprovedReviewOrigins().keySet()); + assertEquals(id,stored.getVersions().get(1).getSecurityApprovalProjectId()); + assertEquals(Map.of(),stored.getVersions().getFirst().getApprovedReviewOrigins()); + } + @Test void scheduledPublicationRejectsSourceChangeAfterGuardWasBuilt() { + var snapshot=scheduled();var notifications=mock(net.modtale.service.communication.ProjectNotificationService.class); + doAnswer(invocation -> { + Query query=invocation.getArgument(0); + if(query.getQueryObject().containsKey("$expr")) mongo.getCollection("projects").updateOne(new Document(),Updates.set("versions.0.findingReviewHead","revocation")); + return invocation.callRealMethod(); + }).when(mongo).updateFirst(any(Query.class),any(Update.class),eq(Project.class)); + assertTrue(publishScheduled(snapshot,notifications).isEmpty());assertHeld();verifyNoInteractions(notifications); + assertNull(mongo.findById(id,Project.class).getVersions().get(1).getScheduledPublishDate()); + } + @Test void scheduledPublicationRejectsAlreadyChangedSource() { + var snapshot=scheduled();var notifications=mock(net.modtale.service.communication.ProjectNotificationService.class); + mongo.getCollection("projects").updateOne(new Document(),Updates.set("versions.0.reviewStatus","REJECTED")); + assertTrue(publishScheduled(snapshot,notifications).isEmpty());assertHeld();verifyNoInteractions(notifications); + } + private FindingReviewService.Event approveSourceWithRecordedFinding() { + var source = mongo.findById(id, Project.class).getVersions().getFirst(); + var sourceScan = ScanEvidenceFixtures.complete(false); + sourceScan.setReviewedContextSha256(ArtifactReviewContext.fingerprint(source)); + var issue = new ScanResult.ScanIssue(); issue.setFilePath("Mod.class"); issue.setType("Network"); + issue.setDescription("Documented service integration"); issue.setSeverity("LOW"); + sourceScan.setIssues(new ArrayList<>(List.of(issue))); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update().set("versions.0.scanResult", sourceScan), Project.class); + var persistence = new VersionReviewPersistence(mongo); + var history = new FindingReviewService(mongo, persistence, mock(ProjectService.class), mock(WardenClientService.class)); + source = mongo.findById(id, Project.class).getVersions().getFirst(); + var event = history.record(id, "source", VersionReviewSnapshot.token(source), "moderator", + new FindingReviewService.Request(0, FindingReviewService.Disposition.ACCEPT, "Checked the endpoint and all its callers")); + source = mongo.findById(id, Project.class).getVersions().getFirst(); + var snapshot = persistence.capture(id, "source", VersionReviewSnapshot.token(source)); + accept(source); assertTrue(persistence.apply(snapshot, source)); + source = mongo.findById(id, Project.class).getVersions().getFirst(); + assertEquals(event.id(), source.getApprovedFindingReviewHead()); + assertNull(source.getScanResult()); + result = ScanEvidenceFixtures.complete(false); + result.setIssues(new ArrayList<>(List.of(issue))); + result.setReviewedContextSha256(ArtifactReviewContext.fingerprint(target)); + new ArtifactReviewReuseService().annotate(mongo.findById(id, Project.class), "target", result); + assertEquals("1.0", result.getReusedReviewVersion()); + assertTrue(result.getIssues().getFirst().isResolved()); + assertEquals(1, result.getKnownIssueCount()); + assertEquals(0, result.getNewIssueCount()); + target.setScanResult(result); accept(target); + return event; + } + @Test void approvedFindingHistoryCanClearAnExactArtifactWithoutRepeatingTheReview() { + approveSourceWithRecordedFinding(); + assertTrue(apply()); + var versions = mongo.findById(id, Project.class).getVersions(); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, versions.get(1).getReviewStatus()); + assertEquals(Set.of("source"), versions.get(1).getApprovedReviewOrigins().keySet()); + assertNull(versions.get(1).getApprovedFindingReviewHead()); + } + @Test void revokingApprovedSourceFindingInvalidatesTheCarriedApproval() { + var event = approveSourceWithRecordedFinding(); + var source = mongo.findById(id, Project.class).getVersions().getFirst(); + new FindingReviewService(mongo, new VersionReviewPersistence(mongo), mock(ProjectService.class), mock(WardenClientService.class)) + .revoke(id, "source", VersionReviewSnapshot.token(source), "moderator", event.id(), "New evidence invalidates the source conclusion"); + assertFalse(apply()); assertHeld(); + } + @Test void sourceHistoryChangeAtTheFinalWriteCannotPublishAnExactCopy() { + approveSourceWithRecordedFinding(); + doAnswer(invocation -> { + Query query = invocation.getArgument(0); + if (query.getQueryObject().containsKey("$expr")) + mongo.getCollection("projects").updateOne(new Document(), Updates.set("versions.0.findingReviewHead", "new-requirement")); + return invocation.callRealMethod(); + }).when(mongo).updateFirst(any(Query.class), any(Update.class), eq(Project.class)); + assertFalse(apply()); assertHeld(); + } + @Test void missingApprovedHistoryCannotAuthorizeReusedPublication() { + var event = approveSourceWithRecordedFinding(); + mongo.getCollection(FindingReviewService.COLLECTION).deleteOne(new Document("_id", event.id())); + assertFalse(apply()); assertHeld(); + } + @Test void anUnattestedHistoryHeadCannotBeMadeReusableByMarkingStatusApproved() { + approveSourceWithRecordedFinding(); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update().set("versions.0.approvedFindingReviewHead", null), Project.class); + var fresh = ScanEvidenceFixtures.complete(false); + new ArtifactReviewReuseService().annotate(mongo.findById(id, Project.class), "target", fresh); + assertNull(fresh.getReusedReviewVersion()); + assertFalse(apply()); assertHeld(); + } + private void assertHeld() { + var stored=mongo.findById(id,Project.class).getVersions().get(1); + assertEquals(ProjectVersion.ReviewStatus.PENDING,stored.getReviewStatus()); + assertEquals(ScanStatus.SUSPICIOUS,stored.getScanResult().getStatus()); + assertNull(stored.getScanResult().getReusedReviewVersion()); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/FindingReviewPersistenceIntegrationTest.java b/backend/src/test/java/net/modtale/service/admin/review/FindingReviewPersistenceIntegrationTest.java new file mode 100644 index 000000000..1edd74a80 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/FindingReviewPersistenceIntegrationTest.java @@ -0,0 +1,654 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import java.util.*; +import net.modtale.config.db.*; +import net.modtale.model.project.*; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.issue.FindingReviewService; +import net.modtale.service.security.scan.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.*; +import org.springframework.data.mongodb.core.convert.*; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.core.query.*; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST", matches="true") +class FindingReviewPersistenceIntegrationTest { + private MongoClient client; + private MongoTemplate mongo; + private VersionReviewPersistence persistence; + private FindingReviewService service; + private ProjectService projects; + private WardenClientService warden; + private String database; + private final String projectId = "abcdefabcdefabcdefabcdef"; + + @BeforeEach void setup() throws Exception { + String port = System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT", "27029"); + if (!Set.of("27029", "27030").contains(port)) throw new IllegalArgumentException("Unexpected test database port"); + database = "warden_finding_test_" + UUID.randomUUID().toString().replace("-", ""); + client = MongoClients.create("mongodb://127.0.0.1:" + port + "/?serverSelectionTimeoutMS=3000"); + var factory = new SimpleMongoClientDatabaseFactory(client, database); + var conversions = new MongoConfig().mongoCustomConversions(new MongoArtifactManifestStore(factory)); + var context = new MongoMappingContext(); context.setSimpleTypeHolder(conversions.getSimpleTypeHolder()); context.afterPropertiesSet(); + var converter = new MappingMongoConverter(new DefaultDbRefResolver(factory), context); + converter.setCustomConversions(conversions); converter.afterPropertiesSet(); + mongo = new MongoTemplate(factory, converter); persistence = spy(new VersionReviewPersistence(mongo)); + projects = mock(ProjectService.class); warden = mock(WardenClientService.class); + when(warden.currentPolicyVersion()).thenReturn(ScanEvidenceFixtures.complete(false).getSecurityEvidence().policyVersion()); + service = new FindingReviewService(mongo, persistence, projects, warden); + var project = new Project(); project.setId(projectId); + var version = new ProjectVersion(); version.setId("v1"); version.setVersionNumber("1.0"); + var scan = ScanEvidenceFixtures.complete(false); + version.setHash(scan.getSecurityEvidence().artifactSha256()); + scan.setReviewedContextSha256(ArtifactReviewContext.fingerprint(version)); + var issue = new ScanResult.ScanIssue(); issue.setFilePath("Mod.class"); issue.setType("Network"); + issue.setDescription("Connects to a service"); issue.setSeverity("LOW"); + scan.setIssues(new ArrayList<>(List.of(issue))); version.setScanResult(scan); + project.setVersions(List.of(version)); mongo.insert(project); + when(projects.getRawProjectById(projectId)).thenReturn(project); + } + @AfterEach void cleanup() { if (client != null) { client.getDatabase(database).drop(); client.close(); } } + private ProjectVersion version() { return mongo.findById(projectId, Project.class).getVersions().getFirst(); } + private String token() { return VersionReviewSnapshot.token(version()); } + private FindingReviewService.Event record(String token) { + return service.record(projectId, "v1", token, "moderator", new FindingReviewService.Request(0, + FindingReviewService.Disposition.ACCEPT, "Verified documented service integration")); + } + + @Test void persistsActorRationaleWholeArtifactScopeAndInvalidatesBrowserSnapshot() { + String before = token(); var event = record(before); + assertEquals("moderator", event.actorId()); assertEquals("WHOLE_ARTIFACT", event.scope()); + assertEquals(30L * 86400000, event.expiresAt() - event.createdAt()); + assertEquals(version().getHash(), event.artifactSha256()); + assertEquals(ArtifactReviewContext.fingerprint(version()), event.contextSha256()); + assertTrue(event.finding().identity().matches("ie1:[0-9a-f]{64}")); + assertNotEquals(before, token()); assertEquals(event.id(), version().getFindingReviewHead()); + assertEquals(event, service.history(projectId, "v1", token(), 0).events().getFirst()); + assertFalse(version().getScanResult().getIssues().getFirst().isResolved()); + assertFalse(ArtifactClearancePolicy.cleared(version().getScanResult())); + verify(projects).evictProjectCache(any(Project.class)); + } + @Test void decisionCancelsSchedulingAndCannotBeBypassedByAStaleScanOutcome() { + var previous = version(); + var scan = ScanEvidenceFixtures.complete(true); + scan.setReviewedContextSha256(ArtifactReviewContext.fingerprint(previous)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update() + .set("versions.0.reviewStatus", ProjectVersion.ReviewStatus.SCHEDULED) + .set("versions.0.scheduledPublishDate", "2020-01-01T00:00:00"), Project.class); + record(token()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + assertNull(version().getScheduledPublishDate()); + var current = version(); current.setScanResult(scan); + assertFalse(ArtifactClearancePolicy.boundToVersion(current)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update() + .set("versions.0.scanResult.status", ScanStatus.SCANNING) + .set("versions.0.scanResult.scanState", "SCANNING"), Project.class); + var scans = new ScanPersistenceService(mongo, mock(net.modtale.repository.project.ProjectRepository.class), projects); + var approve = new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.APPROVE_NOW, 0); + assertFalse(scans.applyScanOutcome(projectId, "v1", 1, scan, approve, previous)); + assertFalse(scans.applyScanOutcome(projectId, "v1", 1, scan, approve, current)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + } + @Test void subsequentConclusionSupersedesOnlyTheSameReviewedScope() { + var first = record(token()); + var second = service.record(projectId, "v1", token(), "other-reviewer", new FindingReviewService.Request(0, + FindingReviewService.Disposition.REQUIRE_REVIEW, "The caller needs further investigation")); + assertEquals(first.id(), second.supersedesDecisionId()); + assertEquals(0, second.expiresAt()); + assertEquals(first, mongo.findById(first.id(), FindingReviewService.Event.class, FindingReviewService.COLLECTION)); + assertEquals(2, service.history(projectId, "v1", token(), 0).events().size()); + } + @Test void historyExplainsScopeChangesAndUnavailablePolicy() { + var event = record(token()); + assertEquals("APPLICABLE", service.history(projectId, "v1", token(), 0).assessments().get(event.id()).state()); + when(warden.currentPolicyVersion()).thenReturn(null); + assertEquals("POLICY_UNAVAILABLE", service.history(projectId, "v1", token(), 0).assessments().get(event.id()).state()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update() + .set("versions.0.gameVersions", List.of("different-runtime")), Project.class); + assertEquals("CONTEXT_CHANGED", service.history(projectId, "v1", token(), 0).assessments().get(event.id()).state()); + } + @Test void historyRejectsVersionChangeDuringPolicyLookup() { + record(token()); + when(warden.currentPolicyVersion()).thenAnswer(invocation -> { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update() + .set("versions.0.hash", "f".repeat(64)), Project.class); + return ScanEvidenceFixtures.complete(false).getSecurityEvidence().policyVersion(); + }); + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> service.history(projectId, "v1", token(), 0)).getStatusCode().value()); + } + @Test void staleConcurrentRecordCannotOverwriteHead() { + String before = token(); var winner = record(before); + assertEquals(409, assertThrows(ResponseStatusException.class, () -> record(before)).getStatusCode().value()); + assertEquals(winner.id(), version().getFindingReviewHead()); + assertEquals(1, mongo.getCollection(FindingReviewService.COLLECTION).countDocuments()); + } + @Test void artifactChangeBetweenInsertAndCasLeavesOnlyUnreachableEvent() { + doAnswer(invocation -> { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), + new Update().set("versions.0.hash", "f".repeat(64)), Project.class); + return invocation.callRealMethod(); + }).when(persistence).appendFindingReview(any(), anyString()); + assertEquals(409, assertThrows(ResponseStatusException.class, () -> record(token())).getStatusCode().value()); + assertNull(version().getFindingReviewHead()); + assertEquals(1, mongo.getCollection(FindingReviewService.COLLECTION).countDocuments()); + assertTrue(service.history(projectId, "v1", token(), 0).events().isEmpty()); + verifyNoInteractions(projects); + } + @Test void revocationSurvivesPrunedScanAndRetainsOriginalDecision() { + var original = record(token()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.scanResult", null), Project.class); + var revoked = service.revoke(projectId, "v1", token(), "second-moderator", original.id(), "New information invalidates the earlier review"); + assertEquals(original.id(), revoked.revokedDecisionId()); + assertEquals("second-moderator", revoked.actorId()); + assertEquals(original, mongo.findById(original.id(), FindingReviewService.Event.class, FindingReviewService.COLLECTION)); + assertEquals(List.of(revoked, original), service.history(projectId, "v1", token(), 0).events()); + assertThrows(ResponseStatusException.class, () -> service.revoke(projectId, "v1", token(), "reviewer", original.id(), "Duplicate revocation should fail")); + } + @Test void cannotRevokeAnOrphanOrReadCrossVersionHistory() { + var original = record(token()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", null), Project.class); + assertThrows(ResponseStatusException.class, () -> service.revoke(projectId, "v1", token(), "reviewer", original.id(), "An orphan is not active history")); + var foreign = new FindingReviewService.Event("foreign", projectId, "another-version", null, 1, "actor", 0, 0, + FindingReviewService.Disposition.ACCEPT, "foreign decision", "WHOLE_ARTIFACT", null, null, null, null, null, null, null); + mongo.insert(foreign, FindingReviewService.COLLECTION); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", "foreign"), Project.class); + assertThrows(ResponseStatusException.class, () -> service.history(projectId, "v1", token(), 0)); + } + @Test void historyPagesAreStableAndMissingPredecessorsFailExplicitly() { + var first = record(token()); var previous = first; + for (int i = 2; i <= 51; i++) { + var next = new FindingReviewService.Event("event-" + i, projectId, "v1", previous.id(), i, + first.actorId(), first.createdAt() + i, first.expiresAt(), first.disposition(), first.rationale(), + first.scope(), first.artifactSha256(), first.contentSha256(), first.policyVersion(), + first.contextSha256(), first.finding(), null, null); + mongo.insert(next, FindingReviewService.COLLECTION); previous = next; + } + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", previous.id()), Project.class); + var page = service.history(projectId, "v1", token(), 0); + assertEquals(50, page.events().size()); assertEquals(50, page.nextOffset()); + assertEquals("event-51", page.events().getFirst().id()); + var older = service.history(projectId, "v1", token(), page.nextOffset()); + assertEquals(List.of(first), older.events()); assertNull(older.nextOffset()); + assertThrows(ResponseStatusException.class, () -> service.history(projectId, "v1", token(), -1)); + mongo.getCollection(FindingReviewService.COLLECTION).deleteOne(new Document("_id", "event-10")); + assertThrows(ResponseStatusException.class, () -> service.history(projectId, "v1", token(), 0)); + assertThrows(ResponseStatusException.class, () -> record(token())); + } + @Test void missingHistoryFailsExplicitlyInsteadOfAppearingEmpty() { + var event = record(token()); + mongo.getCollection(FindingReviewService.COLLECTION).deleteOne(new Document("_id", event.id())); + assertThrows(ResponseStatusException.class, () -> service.history(projectId, "v1", token(), 0)); + assertThrows(ResponseStatusException.class, () -> record(token())); + } + @Test void invalidRationaleOrUnboundEvidenceCannotCreateRecords() { + for (String rationale : Arrays.asList(null, "short", "x".repeat(4001))) + assertThrows(ResponseStatusException.class, () -> service.record(projectId, "v1", token(), "reviewer", + new FindingReviewService.Request(0, FindingReviewService.Disposition.ACCEPT, rationale))); + assertThrows(ResponseStatusException.class, () -> service.record(projectId, "v1", token(), "reviewer", + new FindingReviewService.Request(null, FindingReviewService.Disposition.ACCEPT, "No finding was selected"))); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.gameVersions", List.of("changed-runtime")), Project.class); + assertThrows(ResponseStatusException.class, () -> record(token())); + assertEquals(0, mongo.getCollection(FindingReviewService.COLLECTION).countDocuments()); + } + private FindingReviewService.Event requireFurtherReview() { + return service.record(projectId, "v1", token(), "moderator", new FindingReviewService.Request(0, + FindingReviewService.Disposition.REQUIRE_REVIEW, "Investigate the destination and its caller")); + } + private boolean approveVersion() { + var current = version(); var snapshot = persistence.capture(projectId, "v1", token()); + current.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + return persistence.apply(snapshot, current); + } + private boolean approveProject() { + var current = mongo.findById(projectId, Project.class); + var projectPersistence = new ProjectReviewPersistence(mongo); + var snapshot = projectPersistence.capture(projectId, ProjectReviewSnapshot.token(current)); + snapshot.project().setStatus(ProjectStatus.PUBLISHED); + snapshot.project().getVersions().getFirst().setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + return projectPersistence.apply(snapshot, "v1"); + } + @Test void unresolvedRequirementBlocksBothManualApprovalPathsUntilExplicitResolution() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("status", ProjectStatus.PENDING), Project.class); + var requirement = requireFurtherReview(); + var failure = assertThrows(ResponseStatusException.class, this::approveVersion); + assertEquals(409, failure.getStatusCode().value()); + assertTrue(failure.getReason().contains("outstanding finding-review")); + assertThrows(ResponseStatusException.class, this::approveProject); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + assertEquals(ProjectStatus.PENDING, mongo.findById(projectId, Project.class).getStatus()); + var resolution = record(token()); + assertEquals(requirement.id(), resolution.supersedesDecisionId()); + assertTrue(approveProject()); + assertEquals(resolution.id(), version().getApprovedFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, version().getReviewStatus()); + } + @Test void requirementsSurviveContextChangesAndPruningUntilExplicitRevocation() { + var requirement = requireFurtherReview(); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update() + .set("versions.0.scanResult", null).set("versions.0.gameVersions", List.of("new-runtime")), Project.class); + assertThrows(ResponseStatusException.class, this::approveVersion); + service.revoke(projectId, "v1", token(), "moderator", requirement.id(), "Independent inspection resolves this requirement"); + assertTrue(approveVersion()); + } + @Test void missingOrForgedHistoryCannotAuthorizeManualApproval() { + var event = record(token()); + mongo.getCollection(FindingReviewService.COLLECTION).updateOne(new Document("_id", event.id()), + new Document("$set", new Document("supersedesDecisionId", "unreachable-event"))); + assertThrows(ResponseStatusException.class, this::approveVersion); + assertThrows(ResponseStatusException.class, this::approveProject); + mongo.getCollection(FindingReviewService.COLLECTION).deleteOne(new Document("_id", event.id())); + assertThrows(ResponseStatusException.class, this::approveVersion); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + } + @Test void approvalSnapshotCannotSkipANewlyRecordedRequirement() { + record(token()); + var current = version(); var snapshot = persistence.capture(projectId, "v1", token()); + current.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + requireFurtherReview(); + assertFalse(persistence.apply(snapshot, current)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + } + @Test void revokingReplacementDoesNotResurrectEarlierConclusionOrIgnoreNewRequirement() { + requireFurtherReview(); + var resolution = record(token()); + service.revoke(projectId, "v1", token(), "moderator", resolution.id(), "This acceptance needs to be reconsidered"); + // Manual approval is a new decision; revoked acceptance never provides automatic clearance. + assertTrue(approveVersion()); + requireFurtherReview(); + assertThrows(ResponseStatusException.class, this::approveVersion); + } + + @Test void contextEditsInvalidateApprovalButRetainHistoryAndUnknownStoredFields() { + var event = record(token()); assertTrue(approveVersion()); + mongo.getCollection("projects").updateOne(new Document("_id", new org.bson.types.ObjectId(projectId)), + new Document("$set", new Document("versions.0.futureEvidence", new Document("marker", true)))); + var edits = new ProjectReviewPersistence(mongo); + var project = mongo.findById(projectId, Project.class); + var snapshot = edits.capture(projectId, ProjectReviewSnapshot.token(project)); + var edited = snapshot.project().getVersions().getFirst(); + edited.setGameVersions(List.of("new-runtime")); + var queued = new ScanResult(); queued.setScanState("QUEUED"); queued.setScanAttempt(2); edited.setScanResult(queued); + assertTrue(edits.applyVersionEdit(snapshot, "v1", true, false)); + var stored = version(); + assertEquals(ProjectVersion.ReviewStatus.PENDING, stored.getReviewStatus()); + assertEquals(event.id(), stored.getFindingReviewHead()); + assertNull(stored.getApprovedFindingReviewHead()); assertNull(stored.getApprovedSecurityEvidence()); + assertNull(stored.getApprovedReviewOrigins()); assertEquals(0, stored.getSecurityApprovedAt()); + assertEquals(2, stored.getScanResult().getScanAttempt()); + assertEquals(new Document("marker", true), mongo.getCollection("projects").find().first() + .getList("versions", Document.class).getFirst().get("futureEvidence")); + } + @Test void metadataOnlyVersionEditPreservesReviewStateAndHistory() { + var event = record(token()); assertTrue(approveVersion()); + var edits = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = edits.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().getVersions().getFirst().setChangelog("Documentation corrected"); + assertTrue(edits.applyVersionEdit(snapshot, "v1", false, false)); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, version().getReviewStatus()); + assertEquals(event.id(), version().getApprovedFindingReviewHead()); + assertEquals("Documentation corrected", version().getChangelog()); + } + @Test void staleVersionEditCannotRestoreAnApprovalOrEraseNewFindingHistory() { + record(token()); assertTrue(approveVersion()); + var edits = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = edits.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().getVersions().getFirst().setChangelog("Unrelated correction"); + var requirement = requireFurtherReview(); + assertFalse(edits.applyVersionEdit(snapshot, "v1", false, false)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + assertEquals(requirement.id(), version().getFindingReviewHead()); + } + + @Test void addingAVersionPreservesUnknownSiblingEvidenceAndLinkedApproval() { + var event = record(token()); assertTrue(approveVersion()); + mongo.getCollection("projects").updateOne(new Document(), new Document("$set", new Document("versions.0.futureEvidence", new Document("marker", true)))); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + var added = new ProjectVersion(); added.setId("new-version"); added.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + snapshot.project().getVersions().addFirst(added); + assertTrue(writes.applyVersionList(snapshot)); + var stored = mongo.findById(projectId, Project.class).getVersions(); + assertEquals("new-version", stored.getFirst().getId()); + assertEquals(event.id(), stored.get(1).getApprovedFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, stored.get(1).getReviewStatus()); + assertEquals(new Document("marker", true), mongo.getCollection("projects").find().first().getList("versions", Document.class).get(1).get("futureEvidence")); + } + @Test void staleVersionDeletionCannotEraseANewerReviewRequirement() { + record(token()); assertTrue(approveVersion()); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().getVersions().clear(); + var requirement = requireFurtherReview(); + assertFalse(writes.applyVersionList(snapshot)); + assertEquals(requirement.id(), version().getFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + } + @Test void replacingSomeGameTargetsInvalidatesRetainedApprovalWithoutDeletingHistory() { + var event = record(token()); assertTrue(approveVersion()); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + var retained = snapshot.project().getVersions().getFirst(); + retained.setGameVersions(List.of("remaining-runtime")); + var queued = new ScanResult(); queued.setScanAttempt(3); queued.setScanState("QUEUED"); retained.setScanResult(queued); + assertTrue(writes.applyVersionList(snapshot)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + assertNull(version().getApprovedFindingReviewHead()); assertNull(version().getApprovedSecurityEvidence()); + assertEquals(event.id(), version().getFindingReviewHead()); + assertEquals(3, version().getScanResult().getScanAttempt()); + assertNotNull(mongo.findById(event.id(), FindingReviewService.Event.class, FindingReviewService.COLLECTION)); + } + + @Test void presentationWritesCannotChangeVersionApprovalOrProjectStatus() { + var event = record(token()); assertTrue(approveVersion()); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var originalStatus = project.getStatus(); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setTitle("Updated title"); snapshot.project().setStatus(ProjectStatus.DELETED); + snapshot.project().getVersions().getFirst().setFindingReviewHead("unrelated"); + snapshot.project().getVersions().getFirst().setReviewStatus(ProjectVersion.ReviewStatus.REJECTED); + assertTrue(writes.applyPresentation(snapshot, false)); + var stored = mongo.findById(projectId, Project.class); + assertEquals("Updated title", stored.getTitle()); assertEquals(originalStatus, stored.getStatus()); + assertEquals(event.id(), stored.getVersions().getFirst().getApprovedFindingReviewHead()); + assertEquals(event.id(), stored.getVersions().getFirst().getFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, stored.getVersions().getFirst().getReviewStatus()); + } + @Test void staleGalleryWriteCannotRestoreOlderApprovalOrDeleteNewHistory() { + record(token()); assertTrue(approveVersion()); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setGalleryImages(List.of("new-gallery.png")); + var event = requireFurtherReview(); + assertFalse(writes.applyPresentation(snapshot, true)); + assertEquals(event.id(), version().getFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + } + + @Test void softDeleteAndRestorePreserveVersionReviewHistory() { + var event = record(token()); + var writes = new ProjectReviewPersistence(mongo); + for (var status : List.of(ProjectStatus.DELETED, ProjectStatus.PUBLISHED)) { + var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setStatus(status); + snapshot.project().getVersions().clear(); + assertTrue(writes.applyDeletionState(snapshot, false)); + assertEquals(status, mongo.findById(projectId, Project.class).getStatus()); + assertEquals(event.id(), version().getFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version().getReviewStatus()); + } + } + @Test void staleHardDeleteCannotEraseANewerDecisionButCurrentDeleteRemovesExactlyItsProject() { + record(token()); var writes = new ProjectReviewPersistence(mongo); + var project = mongo.findById(projectId, Project.class); + var stale = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + var event = requireFurtherReview(); + assertFalse(writes.deleteProject(stale)); + assertEquals(event.id(), version().getFindingReviewHead()); + var current = mongo.findById(projectId, Project.class); + assertTrue(writes.deleteProject(writes.capture(projectId, ProjectReviewSnapshot.token(current)))); + assertNull(mongo.findById(projectId, Project.class)); + assertNotNull(mongo.findById(event.id(), FindingReviewService.Event.class, FindingReviewService.COLLECTION)); + } + @Test void dependencyScrubDoesNotRewritePreservedVersionEvidence() { + var event = record(token()); assertTrue(approveVersion()); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setTitle("Deleted Project"); snapshot.project().setImageUrl(null); + snapshot.project().getVersions().getFirst().setApprovedFindingReviewHead("forged"); + assertTrue(writes.applyDeletionState(snapshot, true)); + assertEquals(event.id(), version().getApprovedFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, version().getReviewStatus()); + } + + @Test void teamUpdatesCannotChangeReviewAuthorityOrPublicationState() { + var event = record(token()); assertTrue(approveVersion()); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var originalStatus = project.getStatus(); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setAuthorId("new-owner"); snapshot.project().setStatus(ProjectStatus.DELETED); + snapshot.project().setTeamMembers(List.of(new Project.ProjectMember("contributor", "role"))); + snapshot.project().getVersions().clear(); + assertTrue(writes.applyTeam(snapshot)); + var stored = mongo.findById(projectId, Project.class); + assertEquals("new-owner", stored.getAuthorId()); assertEquals(originalStatus, stored.getStatus()); + assertEquals(event.id(), version().getApprovedFindingReviewHead()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, version().getReviewStatus()); + } + @Test void staleTeamUpdateCannotEraseANewerRequirementOrConcurrentOwnershipChange() { + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setPendingTransferTo("requested-owner"); + var event = requireFurtherReview(); + assertFalse(writes.applyTeam(snapshot)); assertEquals(event.id(), version().getFindingReviewHead()); + project = mongo.findById(projectId, Project.class); + snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setPendingTransferTo("requested-owner"); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("authorId", "different-owner"), Project.class); + assertFalse(writes.applyTeam(snapshot)); + assertEquals("different-owner", mongo.findById(projectId, Project.class).getAuthorId()); + } + + @Test void draftSubmissionPreservesCurrentEvidenceAndUnknownFields() { + var event = record(token()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("status", ProjectStatus.DRAFT), Project.class); + mongo.getCollection("projects").updateOne(new Document(), new Document("$set", new Document("versions.0.futureEvidence", "preserved"))); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setStatus(ProjectStatus.PENDING); + snapshot.project().getVersions().getFirst().setScanResult(null); + assertTrue(writes.submitDraft(snapshot)); + assertNotNull(version().getScanResult()); assertEquals(event.id(), version().getFindingReviewHead()); + assertEquals("preserved", mongo.getCollection("projects").find().first().getList("versions", Document.class).getFirst().get("futureEvidence")); + assertFalse(writes.submitDraft(snapshot)); + } + @Test void draftSubmissionQueuesOnlyUnscannedVersionsAndLosesToConcurrentDecision() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("status", ProjectStatus.DRAFT).set("versions.0.scanResult", null), Project.class); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setStatus(ProjectStatus.PENDING); + var queued = new ScanResult(); queued.setScanState("QUEUED"); queued.setScanAttempt(1); + snapshot.project().getVersions().getFirst().setScanResult(queued); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", "new-decision"), Project.class); + assertFalse(writes.submitDraft(snapshot)); assertNull(version().getScanResult()); + project = mongo.findById(projectId, Project.class); + snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(project)); + snapshot.project().setStatus(ProjectStatus.PENDING); snapshot.project().getVersions().getFirst().setScanResult(queued); + assertTrue(writes.submitDraft(snapshot)); assertEquals(1, version().getScanResult().getScanAttempt()); + assertEquals("new-decision", version().getFindingReviewHead()); + } + + @Test void unlistingPreservesEvidenceAndRejectsStaleState() { + var event = record(token()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("status", ProjectStatus.PUBLISHED), Project.class); + var writes = new ProjectReviewPersistence(mongo); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setVersions(List.of()); + assertTrue(writes.unlist(snapshot)); + assertEquals(ProjectStatus.UNLISTED, mongo.findById(projectId, Project.class).getStatus()); + assertEquals(event.id(), version().getFindingReviewHead()); assertNotNull(version().getScanResult()); + assertFalse(writes.unlist(snapshot)); + snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", "new-head"), Project.class); + assertFalse(writes.unlist(snapshot)); assertEquals("new-head", version().getFindingReviewHead()); + } + @Test void unlistingCannotPublishAnUnreviewedOrDeletedProject() { + var writes = new ProjectReviewPersistence(mongo); + for (var status : List.of(ProjectStatus.DRAFT, ProjectStatus.PENDING, ProjectStatus.PRIVATE, ProjectStatus.ARCHIVED, ProjectStatus.DELETED)) { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("status", status), Project.class); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + assertThrows(ResponseStatusException.class, () -> writes.unlist(snapshot)); + assertEquals(status, mongo.findById(projectId, Project.class).getStatus()); + } + } + + @Test void commentUpdatesCannotChangeReviewStateOrOverwriteNewDecisions() { + var event = record(token()); var writes = new ProjectReviewPersistence(mongo); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setComments(List.of(new Comment("user", "hello"))); + var originalStatus = snapshot.project().getStatus(); + snapshot.project().setStatus(ProjectStatus.DELETED); snapshot.project().setVersions(List.of()); + assertTrue(writes.applyComments(snapshot)); + var stored = mongo.findById(projectId, Project.class); + assertEquals(originalStatus, stored.getStatus()); + assertEquals("hello", stored.getComments().getFirst().getContent()); + assertEquals(event.id(), version().getFindingReviewHead()); assertNotNull(version().getScanResult()); + snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(stored)); + snapshot.project().getComments().getFirst().setPinned(true); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", "later"), Project.class); + assertFalse(writes.applyComments(snapshot)); + assertFalse(mongo.findById(projectId, Project.class).getComments().getFirst().isPinned()); + assertEquals("later", version().getFindingReviewHead()); + } + @Test void commentUpdatesCannotUndoConcurrentCommentPolicyOrEdits() { + var writes = new ProjectReviewPersistence(mongo); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setComments(List.of(new Comment("user", "stale"))); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), + new Update().set("allowComments", false).set("comments", List.of(new Comment("other", "current"))), Project.class); + assertFalse(writes.applyComments(snapshot)); + var stored = mongo.findById(projectId, Project.class); + assertFalse(stored.isAllowComments()); assertEquals("current", stored.getComments().getFirst().getContent()); + } + + @Test void archiveCacheChangesOnlyTheSelectedFileReferenceAndRejectsStaleGeneration() { + var event = record(token()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("classification", ProjectClassification.MODPACK), Project.class); + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + String projectToken = ProjectReviewSnapshot.token(project), versionToken = token(); + assertTrue(writes.cacheModpackArchive(projectId, projectToken, "v1", versionToken, "modpacks/new.zip")); + assertEquals("modpacks/new.zip", version().getFileUrl()); + assertEquals(event.id(), version().getFindingReviewHead()); assertNotNull(version().getScanResult()); + assertThrows(ResponseStatusException.class, () -> writes.cacheModpackArchive(projectId, projectToken, "v1", versionToken, "modpacks/stale.zip")); + project = mongo.findById(projectId, Project.class); + String currentProjectToken = ProjectReviewSnapshot.token(project), currentVersionToken = token(); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("versions.0.findingReviewHead", "later"), Project.class); + assertThrows(ResponseStatusException.class, () -> writes.cacheModpackArchive(projectId, currentProjectToken, "v1", currentVersionToken, "modpacks/stale.zip")); + assertEquals("later", version().getFindingReviewHead()); assertEquals("modpacks/new.zip", version().getFileUrl()); + } + @Test void archiveCacheRejectsForeignVersionsAndNonModpacks() { + var writes = new ProjectReviewPersistence(mongo); var project = mongo.findById(projectId, Project.class); + String original = ProjectReviewSnapshot.token(project); + assertThrows(ResponseStatusException.class, () -> writes.cacheModpackArchive(projectId, original, "v1", token(), "modpacks/new.zip")); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("classification", ProjectClassification.MODPACK), Project.class); + String current = ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class)); + assertThrows(ResponseStatusException.class, () -> writes.cacheModpackArchive(projectId, current, "foreign", token(), "modpacks/new.zip")); + assertThrows(ResponseStatusException.class, () -> writes.cacheModpackArchive(projectId, current, "v1", "wrong", "modpacks/new.zip")); + assertFalse(writes.cacheModpackArchive(projectId, current, "v1", token(), null)); + assertNull(version().getFileUrl()); + } + + @Test void staleTransferNotificationsCannotCancelReplacementRequests() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("pendingTransferTo", "recipient") + .set("pendingTransferRequestId", "new-request").set("pendingTransferOwnerId", "owner") + .set("pendingTransferExpiresAt", System.currentTimeMillis() + 60000), Project.class); + var repository = mock(net.modtale.repository.user.NotificationRepository.class); + var notifications = new net.modtale.service.communication.NotificationService(repository, + mock(net.modtale.repository.user.UserRepository.class), mongo, + mock(net.modtale.service.communication.NotificationDeliveryService.class)); + var otherRecipient = new net.modtale.model.user.Notification("other-recipient", "Request", "Request", java.net.URI.create("/dashboard"), null, + net.modtale.model.user.NotificationType.TRANSFER_REQUEST, + Map.of("projectId", projectId, "requestId", "new-request", "targetUserId", "recipient")); + otherRecipient.setId("other-notification"); when(repository.findById("other-notification")).thenReturn(Optional.of(otherRecipient)); + notifications.deleteNotification("other-notification", "other-recipient"); + assertEquals("recipient", mongo.findById(projectId, Project.class).getPendingTransferTo()); + for (String requestId : List.of("", "old-request", "new-request")) { + var metadata = new HashMap(); metadata.put("projectId", projectId); + metadata.put("targetUserId", "recipient"); if (!requestId.isEmpty()) metadata.put("requestId", requestId); + var notification = new net.modtale.model.user.Notification("recipient", "Request", "Request", java.net.URI.create("/dashboard"), null, + net.modtale.model.user.NotificationType.TRANSFER_REQUEST, metadata); notification.setId("notification"); + when(repository.findById("notification")).thenReturn(Optional.of(notification)); + notifications.deleteNotification("notification", "recipient"); + var current = mongo.findById(projectId, Project.class); + if (requestId.equals("new-request")) assertNull(current.getPendingTransferTo()); + else assertEquals("recipient", current.getPendingTransferTo()); + } + assertNotNull(version().getScanResult()); + } + @Test void expiredTransferCleanupWorksWithoutAnyNotificationAndKeepsNewRequests() { + var notifications = new net.modtale.service.communication.NotificationService(mock(net.modtale.repository.user.NotificationRepository.class), + mock(net.modtale.repository.user.UserRepository.class), mongo, mock(net.modtale.service.communication.NotificationDeliveryService.class)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("pendingTransferTo", "recipient") + .set("pendingTransferRequestId", "expired").set("pendingTransferExpiresAt", 1L), Project.class); + notifications.cleanupExpiredTransfers(); assertNull(mongo.findById(projectId, Project.class).getPendingTransferTo()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("pendingTransferTo", "recipient") + .set("pendingTransferRequestId", "new").set("pendingTransferExpiresAt", System.currentTimeMillis()+60000), Project.class); + notifications.cleanupExpiredTransfers(); assertEquals("new", mongo.findById(projectId, Project.class).getPendingTransferRequestId()); + } + @Test void finalTransferWriteChecksDatabaseExpiryAndOriginalOwner() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("authorId", "owner") + .set("pendingTransferOwnerId", "owner").set("pendingTransferTo", "recipient") + .set("pendingTransferRequestId", "request").set("pendingTransferExpiresAt", 1L), Project.class); + var writes = new ProjectReviewPersistence(mongo); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setAuthorId("recipient"); snapshot.project().setPendingTransferTo(null); + assertFalse(writes.resolveTransfer(snapshot, "request")); assertEquals("owner", mongo.findById(projectId, Project.class).getAuthorId()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("pendingTransferExpiresAt", System.currentTimeMillis()+60000), Project.class); + snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setAuthorId("recipient"); snapshot.project().setPendingTransferTo(null); + assertTrue(writes.resolveTransfer(snapshot, "request")); + assertEquals("recipient", mongo.findById(projectId, Project.class).getAuthorId()); assertNotNull(version().getScanResult()); + } + + private void prepareContributorInvite(String requestId, long expiry) { + var invite = new Project.ProjectMember("recipient", "role"); invite.setRequestId(requestId); + invite.setRequestOwnerId("owner"); invite.setRequestExpiresAt(expiry); + invite.setRequestPermissions(Set.of(net.modtale.model.user.ApiKey.ApiPermission.PROJECT_EDIT_METADATA)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("authorId", "owner") + .set("teamInvites", List.of(invite)).set("projectRoles", List.of(new Project.ProjectRole("role", "Role", "#fff", invite.getRequestPermissions()))), Project.class); + } + @Test void contributorAcceptanceRetainsEvidenceAndBindsTheOfferedRole() { + var event = record(token()); prepareContributorInvite("invite", System.currentTimeMillis()+60000); + var writes = new ProjectReviewPersistence(mongo); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setTeamInvites(List.of()); snapshot.project().setTeamMembers(List.of(new Project.ProjectMember("recipient", "role"))); + assertTrue(writes.resolveContributorInvite(snapshot, "recipient", "invite", true)); + assertEquals(event.id(), version().getFindingReviewHead()); assertNotNull(version().getScanResult()); + prepareContributorInvite("invite2", System.currentTimeMillis()+60000); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("projectRoles.0.permissions", List.of()), Project.class); + var changed = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + assertThrows(net.modtale.exception.InvalidProjectRequestException.class, () -> writes.resolveContributorInvite(changed, "recipient", "invite2", true)); + } + @Test void staleContributorCancellationAndLegacyIdentityCannotRemoveReplacement() { + prepareContributorInvite("old", System.currentTimeMillis()+60000); var writes = new ProjectReviewPersistence(mongo); + var snapshot = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + snapshot.project().setTeamInvites(List.of()); prepareContributorInvite("new", System.currentTimeMillis()+60000); + assertFalse(writes.resolveContributorInvite(snapshot, "recipient", "old", false)); + var current = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + assertThrows(net.modtale.exception.InvalidProjectRequestException.class, () -> writes.resolveContributorInvite(current, "recipient", "legacy", false)); + prepareContributorInvite(null, 0); + var legacy = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + legacy.project().setTeamInvites(List.of()); assertTrue(writes.resolveContributorInvite(legacy, "recipient", "legacy", false)); + } + @Test void contributorNotificationsAndIndependentExpiryKeepCurrentInvitations() { + prepareContributorInvite("new", System.currentTimeMillis()+60000); + var repository = mock(net.modtale.repository.user.NotificationRepository.class); + var notifications = new net.modtale.service.communication.NotificationService(repository, + mock(net.modtale.repository.user.UserRepository.class), mongo, mock(net.modtale.service.communication.NotificationDeliveryService.class)); + var old = new net.modtale.model.user.Notification("recipient", "Invite", "Invite", java.net.URI.create("/dashboard"), null, + net.modtale.model.user.NotificationType.CONTRIBUTOR_INVITE, Map.of("projectId", projectId, "requestId", "old")); + old.setId("old"); when(repository.findById("old")).thenReturn(Optional.of(old)); notifications.deleteNotification("old", "recipient"); + notifications.cleanupExpiredContributorInvites(); + assertEquals("new", mongo.findById(projectId, Project.class).getTeamInvites().getFirst().getRequestId()); + prepareContributorInvite("expired", 1); notifications.cleanupExpiredContributorInvites(); + assertTrue(mongo.findById(projectId, Project.class).getTeamInvites().isEmpty()); + } + @Test void expiredOrOwnerChangedContributorInvitesCannotGrantMembership() { + var writes = new ProjectReviewPersistence(mongo); prepareContributorInvite("invite", 1); + var expired = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + assertThrows(net.modtale.exception.InvalidProjectRequestException.class, () -> writes.resolveContributorInvite(expired, "recipient", "invite", true)); + prepareContributorInvite("invite", System.currentTimeMillis()+60000); + mongo.updateFirst(Query.query(Criteria.where("_id").is(projectId)), new Update().set("authorId", "different"), Project.class); + var changed = writes.capture(projectId, ProjectReviewSnapshot.token(mongo.findById(projectId, Project.class))); + assertThrows(net.modtale.exception.InvalidProjectRequestException.class, () -> writes.resolveContributorInvite(changed, "recipient", "invite", true)); + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ModerationQueueCursorTest.java b/backend/src/test/java/net/modtale/service/admin/review/ModerationQueueCursorTest.java new file mode 100644 index 000000000..1f87da69e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ModerationQueueCursorTest.java @@ -0,0 +1,30 @@ +package net.modtale.service.admin.review; + +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import static org.junit.jupiter.api.Assertions.*; + +class ModerationQueueCursorTest { + @Test void typedIdentityAndUnicodeRoundTripWithoutStringObjectIdAliasing() { + var objectId=new ObjectId(); + for(Object id:new Object[]{objectId,objectId.toHexString(),"😀".repeat(128),"a\u0000b"}) { + var cursor=new ModerationQueuePageReader.Cursor(id,16777216); + assertEquals(cursor,ModerationQueueCursor.decode(ModerationQueueCursor.encode(cursor))); + } + assertNotEquals(ModerationQueueCursor.encode(new ModerationQueuePageReader.Cursor(objectId,0)),ModerationQueueCursor.encode(new ModerationQueuePageReader.Cursor(objectId.toHexString(),0))); + assertNull(ModerationQueueCursor.decode(null));assertNull(ModerationQueueCursor.encode(null)); + } + @Test void filteredCursorPreservesScope() { + for(var filter:ModerationQueuePageReader.Filter.values()) { + var value=new ModerationQueuePageReader.Cursor("project",3,filter); + assertEquals(value,ModerationQueueCursor.decode(ModerationQueueCursor.encode(value))); + } + assertThrows(IllegalArgumentException.class,()->ModerationQueueCursor.decode("2.ALL.s.0.YQ")); + } + @Test void noncanonicalOversizedInvalidUtf8AndOutOfRangeCursorsAreRejected() { + for(String token:new String[]{"","2.s.0.YQ","1.s.-1.YQ","1.s.00.YQ","1.s.16777217.YQ","1.s.0.YQ==","1.s.0.YR","1.s.0._w","1.o.0.YQ","x".repeat(801),"1.s.0."+Base64.getUrlEncoder().withoutPadding().encodeToString("x".repeat(129).getBytes(StandardCharsets.UTF_8))}) + assertThrows(IllegalArgumentException.class,()->ModerationQueueCursor.decode(token)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ModerationQueuePageReaderTest.java b/backend/src/test/java/net/modtale/service/admin/review/ModerationQueuePageReaderTest.java new file mode 100644 index 000000000..5f277857e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ModerationQueuePageReaderTest.java @@ -0,0 +1,91 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ModerationQueuePageReaderTest { + MongoClient client;MongoDatabase db;MongoCollection projects;ModerationQueuePageReader reader; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");db=client.getDatabase("warden_queue_page_"+UUID.randomUUID().toString().replace("-",""));projects=db.getCollection("projects"); + reader=new ModerationQueuePageReader(new MongoTemplate(client,db.getName())); + } + @AfterEach void cleanup(){db.drop();client.close();} + Document version(String id,String status) {return new Document("_id",id).append("reviewStatus","PENDING").append("versionNumber",id) + .append("scanResult",new Document("status",status).append("scanState","REMOTE_HELD").append("riskScore",0));} + void insert(Object id,String status,List versions) {projects.insertOne(new Document("_id",id).append("status",status).append("title","title").append("versions",versions));} + @Test void boundedPagesVisitVersionsAcrossMixedProjectIds() { + var versions=new ArrayList();for(int i=0;i<71;i++)versions.add(version("v"+i,"FAILED"));insert("string","PUBLISHED",versions); + var id=new ObjectId();insert(id,"PUBLISHED",List.of(version("last","SUSPICIOUS"))); + ModerationQueuePageReader.Cursor cursor=null;var ids=new HashSet();int count=0; + do {var page=reader.page(cursor,7);assertTrue(page.items().size()<=7);assertEquals(0,page.unavailableItems()); + for(var row:page.items()){assertTrue(ids.add(row.id()+":"+row.pendingVersion().id()));count++;}cursor=page.next();assertTrue(count<=72); + } while(cursor!=null); + assertEquals(72,count);assertTrue(ids.contains(id.toHexString()+":last")); + } + @Test void scanningSiblingsAreSkippedAndProjectOnlyRowsRemain() { + insert("a","PUBLISHED",List.of(version("scan","SCANNING"),version("failed","FAILED"))); + insert("b","PENDING",List.of());insert("c","PENDING",List.of(version("approved","CLEAN").append("reviewStatus","APPROVED"))); + insert("d","DRAFT",List.of(version("hidden","FAILED"))); + var page=reader.page(null,50);assertEquals(3,page.items().size());assertEquals("failed",page.items().getFirst().pendingVersion().id()); + assertNull(page.items().get(1).pendingVersion());assertNull(page.items().get(2).pendingVersion());assertNull(page.next()); + } + @Test void malformedAndDuplicateVersionIdsAreCountedWithoutLosingContinuation() { + insert("a","PUBLISHED",List.of(version("duplicate","FAILED"),version("duplicate","FAILED"),version("x".repeat(200),"FAILED"),version("valid","FAILED"))); + var first=reader.page(null,2);assertTrue(first.items().isEmpty());assertEquals(2,first.unavailableItems());assertNotNull(first.next()); + var second=reader.page(first.next(),2);assertEquals(1,second.unavailableItems());assertEquals("valid",second.items().getFirst().pendingVersion().id());assertNull(second.next()); + } + @Test void displayFieldsAreBoundedAndEvidenceIsNotReturned() { + var value=version("v","FAILED").append("changelog","x".repeat(10000));value.get("scanResult",Document.class).append("issues",List.of(new Document("private","body"))); + insert("a","PUBLISHED",List.of(value));projects.updateOne(new Document("_id","a"),new Document("$set",new Document("title","t".repeat(10000)))); + var row=reader.page(null,1).items().getFirst();assertEquals(256,row.title().length());assertEquals(1024,row.pendingVersion().changelog().length()); + assertFalse(row.toString().contains("private"));assertEquals("REMOTE_HELD",row.pendingVersion().scan().scanState()); + } + @Test void deletedCursorProjectContinuesAtNextRoot() { + insert("a","PUBLISHED",List.of(version("one","FAILED"),version("two","FAILED")));insert("b","PUBLISHED",List.of(version("next","FAILED"))); + var page=reader.page(null,1);projects.deleteOne(new Document("_id","a")); + assertEquals("next",reader.page(page.next(),1).items().getFirst().pendingVersion().id()); + } + @Test void httpCursorRoundTripContinuesActualDatabasePage()throws Exception { + insert("a","PUBLISHED",List.of(version("one","FAILED"),version("two","FAILED"))); + var controller=new net.modtale.controller.admin.ModerationQueuePageController(reader); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + var first=mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/queue/page").param("limit","1")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()).andReturn(); + var body=new com.fasterxml.jackson.databind.ObjectMapper().readTree(first.getResponse().getContentAsString()); + assertEquals("one",body.get("items").get(0).get("pendingVersion").get("id").asText()); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/queue/page").param("limit","1").param("cursor",body.get("nextCursor").asText())) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items[0].pendingVersion.id").value("two")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.nextCursor").isEmpty()); + } + @Test void filtersApplyBeforeLimitsAndRetainOverlappingSecurityFailures() { + var versions=new ArrayList();for(int i=0;i<60;i++)versions.add(version("ordinary"+i,"CLEAN")); + versions.add(version("security","SUSPICIOUS"));versions.add(version("failure","FAILED")); + var both=version("both","FAILED");both.get("scanResult",Document.class).put("newIssueCount",1);versions.add(both); + insert("a","PUBLISHED",versions); + var first=reader.page(null,1,ModerationQueuePageReader.Filter.SECURITY);assertEquals("security",first.items().getFirst().pendingVersion().id()); + assertEquals(ModerationQueuePageReader.Filter.SECURITY,first.next().filter()); + assertEquals("both",reader.page(first.next(),1,ModerationQueuePageReader.Filter.SECURITY).items().getFirst().pendingVersion().id()); + var failed=reader.page(null,50,ModerationQueuePageReader.Filter.OPERATIONS); + assertEquals(List.of("failure","both"),failed.items().stream().map(row->row.pendingVersion().id()).toList()); + assertThrows(IllegalArgumentException.class,()->reader.page(first.next(),1,ModerationQueuePageReader.Filter.OPERATIONS)); + } + @Test void knownCountsAloneDoNotPutCleanResultsInSecurityFilter() { + var known=version("vetted","CLEAN");known.get("scanResult",Document.class).put("knownIssueCount",8);insert("a","PUBLISHED",List.of(known)); + assertTrue(reader.page(null,50,ModerationQueuePageReader.Filter.SECURITY).items().isEmpty()); + assertEquals(1,reader.page(null,50).items().size()); + } + @Test void invalidLimitsAndCursorAreRejectedBeforeRead() { + assertThrows(IllegalArgumentException.class,()->reader.page(null,0));assertThrows(IllegalArgumentException.class,()->reader.page(null,51)); + assertThrows(IllegalArgumentException.class,()->new ModerationQueuePageReader.Cursor(42,0)); + assertThrows(IllegalArgumentException.class,()->new ModerationQueuePageReader.Cursor("a",-1)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectMetadataRepairTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectMetadataRepairTest.java new file mode 100644 index 000000000..4485d1016 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ProjectMetadataRepairTest.java @@ -0,0 +1,24 @@ +package net.modtale.service.admin.review; + +import java.util.*; +import net.modtale.service.admin.project.ProjectMetadataRepair; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; + +class ProjectMetadataRepairTest { + @Test void acceptsMetadataRepairsWithoutAcceptingAuthorityFields() { + assertDoesNotThrow(() -> ProjectMetadataRepair.validate(Map.of("title", "Repaired title", "tags", List.of("tools"), + "links", Map.of("source", "https://example.com"), "allowComments", true))); + for (String field : List.of("versions", "status", "approvedBy", "authorId", "projectRoles", "teamMembers", + "classification", "childProjectIds", "reviewToken", "securityApprovedAt", "versions.0.reviewStatus", "$set")) + assertThrows(ResponseStatusException.class, () -> ProjectMetadataRepair.validate(Map.of(field, "forged")), field); + } + @Test void rejectsMalformedAndOversizedMetadata() { + for (Map value : List.>of(Map.of(), Map.of("title", " "), + Map.of("allowComments", "true"), Map.of("tags", List.of(Map.of("status", "APPROVED"))), + Map.of("links", Map.of("source", List.of("bad"))), Map.of("description", "x".repeat(100_001)), + Map.of("tags", Collections.nCopies(1001, "x")), Map.of("tags", Collections.nCopies(100, "x".repeat(4096))))) + assertThrows(ResponseStatusException.class, () -> ProjectMetadataRepair.validate(value)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewDecisionServiceTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewDecisionServiceTest.java index 3cf56f001..71535f5f9 100644 --- a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewDecisionServiceTest.java +++ b/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewDecisionServiceTest.java @@ -54,13 +54,16 @@ void setUp() { projectVersionAccessService = mock(ProjectVersionAccessService.class); adminAuditLogger = mock(AdminAuditLogger.class); + var persistence=mock(VersionReviewPersistence.class); + when(persistence.apply(any(),any())).thenReturn(true); ProjectReviewTransitionService transitionService = new ProjectReviewTransitionService( - projectRepository, projectService, lifecycleService, mock(ScoringService.class), securityIssueAnalysisService, - projectVersionAccessService + projectVersionAccessService, + persistence, + mock(ProjectReviewPersistence.class) ); ProjectReviewEffectService effectService = new ProjectReviewEffectService( userRepository, @@ -83,15 +86,14 @@ void approveVersionUpdatesStateBroadcastsAndAudits() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(projectVersionAccessService.requireById(eq(project), eq("version-1"), any())).thenReturn(version); - service.approveVersion(admin, "project-1", "version-1"); + service.approveVersion(admin, "project-1", "version-1", VersionReviewSnapshot.token(version)); assertEquals(ProjectVersion.ReviewStatus.APPROVED, version.getReviewStatus()); assertNull(version.getRejectionReason()); assertNull(version.getScheduledPublishDate()); assertNotNull(project.getUpdatedAt()); - verify(securityIssueAnalysisService).pruneApprovedScanResults(project); - verify(projectRepository).save(project); + verify(securityIssueAnalysisService).markIssuesAcceptedForApprovedVersion(version); verify(projectService).evictProjectCache(project); verify(projectNotificationService).notifyUpdates(project, "1.0.0"); verify(projectNotificationService).notifyDependents(project, "1.0.0"); @@ -111,13 +113,13 @@ void rejectVersionNotifiesAuthorAndAudits() { when(projectVersionAccessService.requireById(eq(project), eq("version-1"), any())).thenReturn(version); when(userRepository.findById("author-1")).thenReturn(Optional.of(author)); - service.rejectVersion(admin, "project-1", "version-1", "Missing metadata"); + service.rejectVersion(admin, "project-1", "version-1", "Missing metadata", VersionReviewSnapshot.token(version)); assertEquals(ProjectVersion.ReviewStatus.REJECTED, version.getReviewStatus()); assertEquals("Missing metadata", version.getRejectionReason()); assertNull(version.getScheduledPublishDate()); - verify(projectRepository).save(project); + verify(projectService).evictProjectCache(project); verify(projectService).evictProjectCache(project); verify(notificationService).sendNotifcation( java.util.List.of("author-1"), diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueryServiceTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueryServiceTest.java deleted file mode 100644 index 9f3272f22..000000000 --- a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueryServiceTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package net.modtale.service.admin.review; - -import java.util.List; -import net.modtale.model.dto.admin.AdminVerificationQueueItemDTO; -import net.modtale.model.project.Project; -import net.modtale.model.project.ProjectStatus; -import net.modtale.model.project.ProjectVersion; -import net.modtale.model.project.ScanResult; -import net.modtale.model.project.ScanStatus; -import net.modtale.repository.user.UserRepository; -import net.modtale.service.project.query.ProjectListingQueryService; -import net.modtale.service.project.query.ProjectService; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class ProjectReviewQueryServiceTest { - - @Test - void verificationQueueReturnsRiskOrderedDedicatedDtos() { - ProjectReviewQueueService queueService = mock(ProjectReviewQueueService.class); - ProjectReviewQueryService queryService = new ProjectReviewQueryService( - mock(UserRepository.class), - mock(ProjectService.class), - queueService, - mock(ProjectListingQueryService.class) - ); - - Project olderClean = queueProject("clean", "2026-01-01T00:00:00", ScanStatus.CLEAN, "ALLOW", 5, 0); - Project newerRisky = queueProject("risky", "2026-02-01T00:00:00", ScanStatus.SUSPICIOUS, "REVIEW", 80, 2); - when(queueService.getVerificationQueue()).thenReturn(List.of(olderClean, newerRisky)); - - List queue = queryService.getVerificationQueue(); - - assertEquals(List.of("risky", "clean"), queue.stream().map(AdminVerificationQueueItemDTO::id).toList()); - assertEquals(80, queue.getFirst().pendingVersion().scan().riskScore()); - assertEquals(2, queue.getFirst().pendingVersion().scan().newIssueCount()); - } - - private static Project queueProject( - String id, - String updatedAt, - ScanStatus scanStatus, - String verdict, - int riskScore, - int newIssueCount - ) { - ScanResult scan = new ScanResult(); - scan.setStatus(scanStatus); - scan.setVerdict(verdict); - scan.setRiskScore(riskScore); - scan.setNewIssueCount(newIssueCount); - - ProjectVersion version = new ProjectVersion(); - version.setId(id + "-version"); - version.setVersionNumber("1.0.0"); - version.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); - version.setScanResult(scan); - - Project project = new Project(); - project.setId(id); - project.setTitle(id); - project.setStatus(ProjectStatus.PUBLISHED); - project.setUpdatedAt(updatedAt); - project.setVersions(List.of(version)); - return project; - } -} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueueServiceTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueueServiceTest.java deleted file mode 100644 index 6363b702a..000000000 --- a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewQueueServiceTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package net.modtale.service.admin.review; - -import java.util.List; -import net.modtale.model.project.Project; -import net.modtale.model.project.ProjectStatus; -import net.modtale.model.project.ProjectVersion; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.query.Query; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class ProjectReviewQueueServiceTest { - - private ProjectReviewQueueService projectReviewQueueService; - private MongoTemplate mongoTemplate; - - @BeforeEach - void setUp() { - mongoTemplate = mock(MongoTemplate.class); - projectReviewQueueService = new ProjectReviewQueueService(mongoTemplate); - } - - @Test - void getVerificationQueueUsesOneFilteredProjection() { - Project pendingProject = project("pending-1", "Pending", ProjectStatus.PENDING); - Project pendingReviewProject = project("published-1", "Review Me", ProjectStatus.PUBLISHED); - ProjectVersion reviewVersion = new ProjectVersion(); - reviewVersion.setVersionNumber("3.0.0"); - reviewVersion.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); - pendingReviewProject.setVersions(List.of(reviewVersion)); - - when(mongoTemplate.find(any(Query.class), eq(Project.class))) - .thenReturn(List.of(pendingProject, pendingReviewProject)); - - List queue = projectReviewQueueService.getVerificationQueue(); - - assertEquals(List.of("pending-1", "published-1"), queue.stream().map(Project::getId).toList()); - - ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(Query.class); - verify(mongoTemplate, times(1)).find(queryCaptor.capture(), eq(Project.class)); - Query query = queryCaptor.getValue(); - String criteria = query.getQueryObject().toString(); - assertTrue(criteria.contains("$or")); - assertTrue(criteria.contains("$not")); - assertTrue(criteria.contains("SCANNING")); - assertEquals(1, query.getFieldsObject().get("versions.scanResult.riskScore")); - assertFalse(query.getFieldsObject().containsKey("versions.scanResult.issues")); - assertFalse(query.getFieldsObject().containsKey("comments")); - } - - private static Project project(String id, String title, ProjectStatus status) { - Project project = new Project(); - project.setId(id); - project.setTitle(title); - project.setStatus(status); - return project; - } - -} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewSnapshotTest.java b/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewSnapshotTest.java new file mode 100644 index 000000000..7a45f986d --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ProjectReviewSnapshotTest.java @@ -0,0 +1,21 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ScanEvidenceFixtures; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class ProjectReviewSnapshotTest { + @Test void coversMetadataAndInternalArtifactVerificationWithoutChurningOnPopularity() { + var project = new Project(); project.setId("project"); project.setTitle("reviewed"); + var version = new ProjectVersion(); version.setId("version"); + version.setScanResult(ScanEvidenceFixtures.complete(true)); project.setVersions(List.of(version)); + String token = ProjectReviewSnapshot.token(project); + project.setDownloadCount(42); project.setFavoriteCount(7); project.setRankingDirty(true); + assertEquals(token, ProjectReviewSnapshot.token(project)); + project.setTitle("changed"); assertNotEquals(token, ProjectReviewSnapshot.token(project)); + project.setTitle("reviewed"); version.getScanResult().setArtifactVerified(false); + assertNotEquals(token, ProjectReviewSnapshot.token(project)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/RawReviewSnapshotReaderTest.java b/backend/src/test/java/net/modtale/service/admin/review/RawReviewSnapshotReaderTest.java new file mode 100644 index 000000000..fdfdfa84f --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/RawReviewSnapshotReaderTest.java @@ -0,0 +1,66 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import org.bson.*; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class RawReviewSnapshotReaderTest { + MongoClient client;MongoTemplate mongo;RawReviewSnapshotReader reader;String database; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");database="warden_raw_capture_"+UUID.randomUUID().toString().replace("-","");mongo=new MongoTemplate(client,database);reader=new RawReviewSnapshotReader(mongo); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + Document version(){return new Document("_id","v").append("scanResult","invalid typed state").append("unknown",new Document("date",new Date(123)).append("number",-0.0));} + void insert(Object id,List versions){mongo.getCollection("projects").insertOne(new Document("_id",id).append("versions",versions));} + byte[] bytes(Document document){var buffer=new RawBsonDocument(document,new org.bson.codecs.DocumentCodec()).getByteBuffer().asNIO();byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);return bytes;} + @Test void sameTextBsonRootsRemainDistinctAndRawBytesAreArchived() { + String hex="abcdefabcdefabcdefabcdef";var v=version();insert(hex,List.of(v));insert(new ObjectId(hex),List.of(new Document(v).append("different",true))); + var captured=reader.capture(hex,0,"v");assertArrayEquals(bytes(v),captured.versionBytes());assertTrue(reader.isCurrent(captured)); + assertNotEquals(captured.sha256(),reader.capture(new ObjectId(hex),0,"v").sha256()); + var archive=new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32]));var saved=archive.retain(captured.forArchive(UUID.randomUUID().toString(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,1000,2000)); + assertArrayEquals(captured.versionBytes(),saved.versionBytes());assertEquals(hex,saved.projectId());assertTrue(reader.isCurrent(captured)); + captured.versionBytes()[0]++;assertArrayEquals(bytes(v),captured.versionBytes()); + } + @ParameterizedTest @ValueSource(strings={"changed","shifted","duplicate","removed"}) + void laterChangesPreventCurrentSnapshotClaim(String mutation) { + insert("p",List.of(version()));var captured=reader.capture("p",0,"v");var collection=mongo.getCollection("projects"); + switch(mutation) { + case "changed"->collection.updateOne(new Document("_id","p"),new Document("$set",new Document("versions.0.unknown.number",1.0))); + case "shifted"->collection.updateOne(new Document("_id","p"),new Document("$push",new Document("versions",new Document("$each",List.of(new Document("_id","other"))).append("$position",0)))); + case "duplicate"->collection.updateOne(new Document("_id","p"),new Document("$push",new Document("versions",new Document("_id","v").append("different",true)))); + default->collection.deleteOne(new Document("_id","p")); + } + assertFalse(reader.isCurrent(captured)); + } + @Test void unrelatedMalformedSlotsDoNotNormalizeOrReplaceSelectedRecord() { + insert("p",List.of("bad sibling",new Document("_id",42),version())); + assertArrayEquals(bytes(version()),reader.capture("p",2,"v").versionBytes()); + assertThrows(IllegalStateException.class,()->reader.capture("p",0,"v"));assertThrows(IllegalStateException.class,()->reader.capture("p",1,"v")); + assertThrows(IllegalStateException.class,()->reader.capture("p",3,"v"));assertThrows(IllegalStateException.class,()->reader.capture("p",2,"other")); + } + @Test void duplicateIdentityKeysCannotBeCaptured() { + var output=new org.bson.io.BasicOutputBuffer();try(var writer=new BsonBinaryWriter(output)) { + writer.writeStartDocument();writer.writeString("_id","p");writer.writeStartArray("versions");writer.writeStartDocument(); + writer.writeString("_id","v");writer.writeString("_id","v");writer.writeEndDocument();writer.writeEndArray();writer.writeEndDocument(); + } + mongo.getCollection("projects").withDocumentClass(RawBsonDocument.class).insertOne(new RawBsonDocument(output.toByteArray())); + assertThrows(IllegalStateException.class,()->reader.capture("p",0,"v")); + } + @Test void databaseCollationCannotSelectAnotherRoot() { + mongo.getDb().createCollection("projects",new com.mongodb.client.model.CreateCollectionOptions().collation(com.mongodb.client.model.Collation.builder().locale("en").collationStrength(com.mongodb.client.model.CollationStrength.SECONDARY).build())); + insert("PROJECT",List.of(version()));assertThrows(IllegalStateException.class,()->reader.capture("project",0,"v"));assertTrue(reader.isCurrent(reader.capture("PROJECT",0,"v"))); + } + @Test void invalidPositionsFailBeforeReading() { + assertThrows(IllegalArgumentException.class,()->reader.capture(42,0,"v"));assertThrows(IllegalArgumentException.class,()->reader.capture("p",-1,"v")); + assertThrows(IllegalArgumentException.class,()->reader.capture("p",0,"bad\n"));assertThrows(IllegalArgumentException.class,()->reader.capture("p",0,null)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationAccessTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationAccessTest.java new file mode 100644 index 000000000..0e72ae876 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationAccessTest.java @@ -0,0 +1,104 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.user.*; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.*; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewCancellationAccessTest { + AccountService accounts;ReviewRepairWorkflow workflow;ReviewOrphanCancellationJournal journal; + ReviewOrphanCancellationExecutor executor;ReviewCancellationReconciler reconciler;ReviewCancellationAccess access;User actor; + AtomicLong time=new AtomicLong(); + ReviewOrphanCancellationJournal.Prepared original=new ReviewOrphanCancellationJournal.Prepared(UUID.randomUUID().toString(),"a".repeat(64),1,1001); + ReviewCancellationAccess.Check check=new ReviewCancellationAccess.Check(UUID.randomUUID().toString(),original); + @BeforeEach void setup() { + accounts=mock(AccountService.class);journal=mock(ReviewOrphanCancellationJournal.class);executor=mock(ReviewOrphanCancellationExecutor.class);reconciler=mock(ReviewCancellationReconciler.class); + workflow=new ReviewRepairWorkflow(mock(ReviewRepairPreparation.class),mock(ReviewIsolationExecutor.class),1,1000,time::get); + access=new ReviewCancellationAccess(accounts,workflow,journal,executor,reconciler);actor=ReviewRepairAccessTest.user("actor"); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(actor,null,List.of())); + when(accounts.getCurrentUser(any())).thenReturn(actor); + } + @AfterEach void cleanup(){workflow.close();SecurityContextHolder.clearContext();} + @Test void allOperationsRequireBothPermissionsBeforeValidationOrWork() { + for(var permissions:List.of(Set.of(AdminPermission.PROJECT_REVIEW_READ),Set.of(AdminPermission.PROJECT_VERSION_RESCAN),Set.of(AdminPermission.PROJECT_REVIEW_DECIDE))) { + actor.setAdminPermissions(permissions);assertThrows(SecurityException.class,()->access.capability());assertThrows(SecurityException.class,()->access.preview(null)); + assertThrows(SecurityException.class,()->access.recover(null));assertThrows(SecurityException.class,()->access.prepare(null));assertThrows(SecurityException.class,()->access.execute(null)); + assertThrows(SecurityException.class,()->access.receipt(null));assertThrows(SecurityException.class,()->access.check(null));assertThrows(SecurityException.class,()->access.checkReceipt(null)); + } + verifyNoInteractions(journal,executor,reconciler); + } + @Test void deletedAndMissingAccountsHaveNoAuthority() { + actor.setDeletedAt(java.time.LocalDateTime.now());assertThrows(SecurityException.class,()->access.execute(original)); + when(accounts.getCurrentUser(any())).thenReturn(null);assertThrows(SecurityException.class,()->access.check(check));verifyNoInteractions(journal,executor,reconciler); + } + @Test void malformedRequestsNeverReachComponents() { + assertThrows(IllegalArgumentException.class,()->access.preview("wrong"));assertThrows(IllegalArgumentException.class,()->access.recover("wrong"));assertThrows(IllegalArgumentException.class,()->access.prepare("wrong")); + assertThrows(IllegalArgumentException.class,()->access.execute(new ReviewOrphanCancellationJournal.Prepared(original.isolationId(),original.targetSha256(),1,120002))); + assertThrows(IllegalArgumentException.class,()->access.receipt(new ReviewOrphanCancellationJournal.Prepared(original.isolationId(),"A".repeat(64),1,2))); + assertThrows(IllegalArgumentException.class,()->access.check(new ReviewCancellationAccess.Check("bad",original))); + assertThrows(IllegalArgumentException.class,()->access.checkReceipt(new ReviewCancellationAccess.Check(check.id(),null))); + verifyNoInteractions(journal,executor,reconciler); + } + @Test void previewRechecksAccountAndNeverPreparesAnOperation() { + when(journal.preview(eq(original.isolationId()),eq("actor"),any())).thenAnswer(i->{ + assertTrue(i.getArgument(2).getAsBoolean());actor.setAdminPermissions(Set.of());return null; + }); + assertThrows(SecurityException.class,()->access.preview(original.isolationId())); + verify(journal).preview(eq(original.isolationId()),eq("actor"),any());verifyNoMoreInteractions(journal);verifyNoInteractions(executor,reconciler); + } + @Test void capabilityUsesAdmissionWithoutRemoteDiscovery() { + assertEquals("CANCEL_ORIGINAL_REVIEW",access.capability().action());verifyNoInteractions(journal,executor,reconciler); + workflow.close();assertThrows(IllegalStateException.class,()->access.capability()); + } + @Test void preparationUsesServerActorAndDoesNotDispatch() { + when(journal.prepare(eq(original.isolationId()),eq("actor"),any())).thenAnswer(i->{assertTrue(i.getArgument(2).getAsBoolean());return original;}); + assertEquals(original,access.prepare(original.isolationId()));verifyNoInteractions(executor,reconciler); + } + @Test void executeRechecksAccountIdentityAndRefusesResponseAfterSwitch() { + when(executor.execute(eq(original),eq("actor"),any())).thenAnswer(i->{ + var allowed=i.getArgument(2);assertTrue(allowed.getAsBoolean()); + when(accounts.getCurrentUser(any())).thenReturn(ReviewRepairAccessTest.user("other"));assertFalse(allowed.getAsBoolean()); + return new ReviewOrphanCancellationExecutor.Execution("UNKNOWN",null); + }); + assertThrows(SecurityException.class,()->access.execute(original));assertEquals(0,workflow.status().active()); + } + @Test void sharedDeadlineIsVisibleInsideRemoteWorkAndAdmissionRemainsUntilReturn() { + when(reconciler.check(eq(check.id()),eq(original),eq("actor"),any())).thenAnswer(i->{ + assertEquals(1,workflow.status().active());time.set(TimeUnit.SECONDS.toNanos(1)); + assertThrows(IllegalStateException.class,()->i.getArgument(3).getAsBoolean()); + assertEquals(1,workflow.status().overdue());return null; + }); + assertThrows(IllegalStateException.class,()->access.check(check));assertEquals(0,workflow.status().active()); + } + @Test void nestedWorkCannotBypassSharedAdmission() { + when(journal.prepare(anyString(),anyString(),any())).thenAnswer(i->{assertThrows(IllegalStateException.class,()->access.check(check));return original;}); + assertEquals(original,access.prepare(original.isolationId()));verifyNoInteractions(reconciler); + } + @Test void closureIsObservedInsideDispatchAndPreventsNewCalls() { + when(executor.execute(any(),anyString(),any())).thenAnswer(i->{workflow.close();assertThrows(IllegalStateException.class,()->i.getArgument(2).getAsBoolean());return null;}); + assertThrows(IllegalStateException.class,()->access.execute(original));assertEquals("CLOSED",workflow.status().state()); + assertThrows(IllegalStateException.class,()->access.prepare(original.isolationId()));verifyNoInteractions(journal); + } + @Test void recoveryUsesOnlyKnownIsolationIdentityAndNeverPreparesOrDispatches() { + var expected=new ReviewOrphanCancellationJournal.Receipt(original,"PREPARED",null,null); + when(journal.recover(eq(original.isolationId()),eq("actor"),any())).thenAnswer(i->{assertTrue(i.getArgument(2).getAsBoolean());return expected;}); + assertEquals(expected,access.recover(original.isolationId())); + verify(journal).recover(eq(original.isolationId()),eq("actor"),any());verifyNoMoreInteractions(journal);verifyNoInteractions(executor,reconciler); + } + @Test void localReceiptLookupsNeverInvokeRemoteOperationsOrRenewPreparation() { + var receipt=new ReviewOrphanCancellationJournal.Receipt(original,"UNKNOWN",null,2L); + var later=new ReviewCancellationReconciler.Receipt(check.id(),original,"READING",null,null); + when(journal.receipt(eq(original),eq("actor"),any())).thenReturn(receipt); + when(reconciler.receipt(eq(check.id()),eq(original),eq("actor"),any())).thenReturn(later); + assertEquals(receipt,access.receipt(original));assertEquals(later,access.checkReceipt(check)); + verify(journal).receipt(eq(original),eq("actor"),any());verify(reconciler).receipt(eq(check.id()),eq(original),eq("actor"),any()); + verifyNoMoreInteractions(journal,reconciler);verifyNoInteractions(executor); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationConfigurationTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationConfigurationTest.java new file mode 100644 index 000000000..829a4a35d --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewCancellationConfigurationTest.java @@ -0,0 +1,36 @@ +package net.modtale.service.admin.review; + +import net.modtale.config.properties.AppReviewRepairProperties; +import net.modtale.service.security.scan.RemoteReviewClient; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.data.mongodb.core.MongoTemplate; +import com.mongodb.client.MongoCollection; +import org.bson.Document; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewCancellationConfigurationTest { + ApplicationContextRunner runner(){return new ApplicationContextRunner().withUserConfiguration(ReviewCancellationConfiguration.class);} + @Test void disabledConfigurationCreatesNoComponentsEvenWithOtherFlagsEnabled() { + runner().withPropertyValues("app.warden.repair.enabled=true","app.warden.jobs.enabled=true").run(c->{assertNull(c.getStartupFailure());assertEquals(0,c.getBeansOfType(ReviewCancellationAccess.class).size());}); + } + @Test void enablingWithoutRequiredRepairAndRemoteDependenciesFailsStartup() { + runner().withPropertyValues("app.warden.repair.cancellation.enabled=true").run(c->assertNotNull(c.getStartupFailure())); + } + @Test void enabledComponentsShareWorkflowAndCloseOnContextDestruction() { + var mongo=mock(MongoTemplate.class);@SuppressWarnings("unchecked") MongoCollection collection=mock(MongoCollection.class,RETURNS_SELF); + when(mongo.getCollection(anyString())).thenReturn(collection); + var workflow=mock(ReviewRepairWorkflow.class);var executor=new ReviewOrphanCancellationExecutor[1];var reconciler=new ReviewCancellationReconciler[1]; + runner().withPropertyValues("app.warden.repair.cancellation.enabled=true","app.warden.jobs.enabled=true") + .withBean(MongoTemplate.class,()->mongo).withBean(ReviewSnapshotArchive.class,()->mock(ReviewSnapshotArchive.class)) + .withBean(ReviewIsolationExecutor.class,()->mock(ReviewIsolationExecutor.class)).withBean(ReviewRepairWorkflow.class,()->workflow) + .withBean(AccountService.class,()->mock(AccountService.class)).withBean(RemoteReviewClient.class,()->mock(RemoteReviewClient.class)) + .withBean(AppReviewRepairProperties.class,()->new AppReviewRepairProperties(false,"",Map.of(),1,300000)) + .run(c->{assertNull(c.getStartupFailure());assertNotNull(c.getBean(ReviewCancellationAccess.class));executor[0]=c.getBean(ReviewOrphanCancellationExecutor.class);reconciler[0]=c.getBean(ReviewCancellationReconciler.class);verifyNoInteractions(workflow);}); + assertEquals("SHUTDOWN",executor[0].execute(null,"actor",()->true).state()); + assertThrows(IllegalStateException.class,()->reconciler[0].check("11111111-1111-1111-1111-111111111111",null,"actor",()->true)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewIsolationExecutorTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewIsolationExecutorTest.java new file mode 100644 index 000000000..182884665 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewIsolationExecutorTest.java @@ -0,0 +1,131 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewIsolationExecutorTest { + MongoClient client;MongoTemplate mongo;MongoCollection projects;RawReviewSnapshotReader reader;ReviewSnapshotArchive archive;ReviewRepairJournal journal; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REPAIR_TX_DB_PORT","27031");if(!Set.of("27031","27032").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?directConnection=true&serverSelectionTimeoutMS=3000&socketTimeoutMS=10000"); + mongo=new MongoTemplate(client,"warden_isolate_"+UUID.randomUUID().toString().replace("-",""));projects=mongo.getCollection("projects"); + var scan=new Document("status","SCANNING").append("scanState","REMOTE_REVIEW").append("verdict","BLOCK").append("riskScore",73) + .append("issues",List.of(new Document("severity","HIGH").append("description","Retained finding").append("resolved",false))) + .append("scanRequestId",UUID.randomUUID().toString()).append("scanAttempt",1).append("manualRescan",true).append("artifactVerified",true); + var version=new Document("_id","v").append("reviewStatus","PENDING").append("scanResult",scan).append("securityApprovedAt",123L) + .append("scheduledPublishDate",123L).append("approvedSecurityContextSha256","a".repeat(64)).append("unknownHistory",List.of("retain")); + projects.insertOne(new Document("_id","p").append("versions",List.of(version))); + reader=new RawReviewSnapshotReader(mongo);archive=new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32]));journal=new ReviewRepairJournal(mongo,archive); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + ReviewRepairPreparation.Prepared prepare() { + var req=new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),"p",0,"v",reader.capture("p",0,"v").sha256(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW); + return new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000).prepare(req,()->true); + } + ReviewIsolationExecutor executor(){return new ReviewIsolationExecutor(mongo,archive,reader,journal);} + Document version(){return projects.find().first().getList("versions",Document.class).getFirst();} + Document operation(){return mongo.getCollection(ReviewRepairJournal.COLLECTION).find().first();} + void set(String path,Object value){projects.updateOne(new Document("_id","p"),new Document("$set",new Document("versions.0."+path,value)));} + @Test void isolatesWithAtomicReceiptPreservedEvidenceAndNoSecurityClearance() { + var before=reader.capture("p",0,"v");var prepared=prepare();var result=executor().execute(prepared,"actor",()->true); + assertEquals("APPLIED",result.state());var v=version();var scan=v.get("scanResult",Document.class); + assertEquals("PENDING",v.get("reviewStatus"));assertEquals("FAILED",scan.get("status"));assertEquals("REMOTE_ISOLATED",scan.get("scanState")); + assertEquals("BLOCK",scan.get("verdict"));assertEquals(73,scan.get("riskScore"));assertEquals(false,scan.get("artifactVerified")); + assertEquals(new org.bson.RawBsonDocument(before.versionBytes()).decode(new org.bson.codecs.DocumentCodec()).get("scanResult",Document.class).get("issues"),scan.get("issues")); + assertEquals(List.of("retain"),v.get("unknownHistory"));assertNull(v.get("scheduledPublishDate"));assertEquals(0L,v.get("securityApprovedAt"));assertNull(v.get("approvedSecurityContextSha256")); + assertEquals(prepared.id(),v.get("reviewIsolation",Document.class).get("operationId"));assertInstanceOf(Date.class,v.get("reviewIsolation",Document.class).get("isolatedAt")); + assertArrayEquals(before.versionBytes(),archive.load(prepared.id()).versionBytes());assertEquals(reader.capture("p",0,"v").sha256(),result.afterSha256());assertEquals("APPLIED",operation().get("state")); + assertEquals(result,executor().execute(prepared,"actor",()->true));assertEquals(v,version()); + } + @Test void healthyQueuedReviewIsIneligibleWithoutClaim() { + set("scanResult.scanState","QUEUED");var before=version();assertEquals("INELIGIBLE",executor().execute(prepare(),"actor",()->true).state());assertNull(operation());assertEquals(before,version()); + } + @Test void unreadableRemainingStateCannotDisappearFromDiagnostics() { + set("scanResult.manualRescan",null);var before=version();assertEquals("INELIGIBLE",executor().execute(prepare(),"actor",()->true).state());assertNull(operation());assertEquals(before,version()); + } + @Test void activeLeasePreventsMutationEvenWhenPollShapeIsBroken() { + set("scanResult.remotePoll",new Document("leaseUntil",new Date(System.currentTimeMillis()+120000)));var before=version(); + assertEquals("NOT_APPLIED",executor().execute(prepare(),"actor",()->true).state());assertEquals(before,version());assertEquals("NOT_APPLIED",operation().get("state")); + } + @Test void changedBytesSincePreparationDoNotApply() { + var prepared=prepare();set("scanResult.scanAttempt",1L);var before=version();assertEquals("NOT_APPLIED",executor().execute(prepared,"actor",()->true).state());assertEquals(before,version()); + } + @Test void concurrentEditAfterSnapshotAbortsBothWrites() { + var prepared=prepare();var intercepted=spy(reader);var once=new AtomicBoolean(); + doAnswer(i->{var result=i.callRealMethod();if(once.compareAndSet(false,true))set("scanResult.scanAttempt",1L);return result;}).when(intercepted).capture(any(ClientSession.class),eq("p"),eq(0),eq("v")); + var result=new ReviewIsolationExecutor(mongo,archive,intercepted,journal).execute(prepared,"actor",()->true); + assertEquals("UNKNOWN",result.state());assertEquals("SCANNING",version().get("scanResult",Document.class).get("status"));assertEquals(1L,version().get("scanResult",Document.class).get("scanAttempt"));assertEquals("UNKNOWN",operation().get("state")); + } + @Test void permissionRevokedAfterVersionWriteRollsBackEverything() { + var prepared=prepare();var before=version();var allowed=new AtomicBoolean(true);var intercepted=spy(reader); + doAnswer(i->{var result=(RawReviewSnapshotReader.Captured)i.callRealMethod();if(!result.sha256().equals(prepared.sha256()))allowed.set(false);return result;}).when(intercepted).capture(any(ClientSession.class),eq("p"),eq(0),eq("v")); + assertThrows(SecurityException.class,()->new ReviewIsolationExecutor(mongo,archive,intercepted,journal).execute(prepared,"actor",allowed::get)); + assertEquals(before,version());assertEquals("UNKNOWN",operation().get("state")); + } + @Test void lostCommitAcknowledgementReconcilesWithoutRepeatingMutation() { + var prepared=prepare();var wrapped=spy(mongo);var factory=spy(mongo.getMongoDatabaseFactory());doReturn(factory).when(wrapped).getMongoDatabaseFactory(); + doAnswer(i->{var session=spy((ClientSession)i.callRealMethod());doAnswer(c->{c.callRealMethod();var lost=new MongoException("lost commit acknowledgement");lost.addLabel("UnknownTransactionCommitResult");throw lost;}).when(session).commitTransaction();return session;}).when(factory).getSession(any(ClientSessionOptions.class)); + var result=new ReviewIsolationExecutor(wrapped,archive,reader,journal).execute(prepared,"actor",()->true);assertEquals("APPLIED",result.state());assertEquals(reader.capture("p",0,"v").sha256(),result.afterSha256());assertEquals(result,executor().execute(prepared,"actor",()->true)); + } + @Test void malformedBindingIsArchivedBeforeDetachment() { + set("scanResult.remoteReview",new Document("jobId","unreadable"));var prepared=prepare();var original=archive.load(prepared.id()).versionBytes(); + assertEquals("APPLIED",executor().execute(prepared,"actor",()->true).state());assertNull(version().get("scanResult",Document.class).get("remoteReview"));assertArrayEquals(original,archive.load(prepared.id()).versionBytes()); + } + @Test void validMismatchedBindingRemainsAvailableForReconciliation() { + var binding=new Document("projectId","other").append("versionId","v").append("requestId",UUID.randomUUID().toString()).append("attempt",1) + .append("filePath","mods/example.jar").append("artifactSha256","a".repeat(64)).append("contextSha256","b".repeat(64)) + .append("policyVersion","warden-3.0.0:"+"c".repeat(64)).append("reviewConfigSha256","d".repeat(64)).append("jobId",UUID.randomUUID().toString()).append("manualRescan",true); + set("scanResult.remoteReview",binding);assertEquals("APPLIED",executor().execute(prepare(),"actor",()->true).state());assertEquals(binding,version().get("scanResult",Document.class).get("remoteReview")); + } + @Test void expiredIntentCannotChangeVersion() { + var capture=reader.capture("p",0,"v");var now=System.currentTimeMillis();var id=UUID.randomUUID().toString(); + archive.retain(capture.forArchive(id,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,now-20000,now-10000)); + var prepared=new ReviewRepairPreparation.Prepared(id,capture.sha256(),now-20000,now-10000);var before=version(); + assertEquals("UNKNOWN",executor().execute(prepared,"actor",()->true).state());assertEquals(before,version());assertEquals("RESERVED",operation().get("state")); + } + @Test void duplicateVersionIntroducedAfterPreparationCannotBeIsolated() { + var prepared=prepare();projects.updateOne(new Document("_id","p"),new Document("$push",new Document("versions",version())));var before=projects.find().first(); + assertEquals("UNKNOWN",executor().execute(prepared,"actor",()->true).state());assertEquals(before,projects.find().first());assertEquals("UNKNOWN",operation().get("state")); + } + @Test void standaloneTopologyIsRejectedBeforeClaim() { + var prepared=prepare();var wrapped=spy(mongo);var db=spy(mongo.getDb());doReturn(db).when(wrapped).getDb(); + doReturn(new Document("isWritablePrimary",true)).when(db).runCommand(eq(new Document("hello",1)),eq(ReadPreference.primary())); + var before=version();assertThrows(IllegalStateException.class,()->new ReviewIsolationExecutor(wrapped,archive,reader,journal).execute(prepared,"actor",()->true));assertEquals(before,version());assertNull(operation()); + } + @Test void wrongActorCannotAccessOrClaimRepair() { + var prepared=prepare();assertThrows(SecurityException.class,()->executor().execute(prepared,"other",()->true));assertNull(operation()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"shutdown","deadline"}) + void workflowCancellationAfterVersionWriteAbortsRepair(String mode) { + var prepared=prepare();var before=version();var intercepted=spy(reader);var ticks=new java.util.concurrent.atomic.AtomicLong();var reachedWrite=new AtomicBoolean(); + var preparation=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000); + var workflow=new ReviewRepairWorkflow(preparation,new ReviewIsolationExecutor(mongo,archive,intercepted,journal),1,1000,ticks::get); + doAnswer(i->{var result=(RawReviewSnapshotReader.Captured)i.callRealMethod();if(!result.sha256().equals(prepared.sha256())) { + reachedWrite.set(true); + if(mode.equals("shutdown"))workflow.close();else ticks.addAndGet(1000000000L); + }return result;}).when(intercepted).capture(any(ClientSession.class),eq("p"),eq(0),eq("v")); + assertEquals("UNKNOWN",workflow.isolate(prepared,"actor",()->true).state());assertEquals(before,version());assertEquals("UNKNOWN",operation().get("state")); + assertTrue(reachedWrite.get()); + assertEquals(new ReviewRepairWorkflow.Status(mode.equals("shutdown")?"CLOSED":"OPEN",0),workflow.status()); + } + + @Test void completeWorkflowUsesDriverTimeoutsAndCommitsRepair() { + var prepared=prepare();var preparation=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000); + try(var workflow=new ReviewRepairWorkflow(preparation,executor(),1)) { + assertEquals("APPLIED",workflow.isolate(prepared,"actor",()->true).state());assertEquals("APPLIED",operation().get("state")); + } + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewOriginInventoryTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewOriginInventoryTest.java new file mode 100644 index 000000000..19ea2d33e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewOriginInventoryTest.java @@ -0,0 +1,95 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import org.bson.*; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static net.modtale.service.admin.review.ReviewOriginInventory.OriginState.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewOriginInventoryTest { + MongoClient client;MongoDatabase db;MongoCollection projects;ReviewOriginInventory inventory; + String request=UUID.randomUUID().toString(),job=UUID.randomUUID().toString(); + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");db=client.getDatabase("warden_origin_inventory_"+UUID.randomUUID().toString().replace("-","")); + projects=db.getCollection("projects");inventory=new ReviewOriginInventory(new MongoTemplate(client,db.getName())); + } + @AfterEach void cleanup(){db.drop();client.close();} + Document binding(){return new Document("requestId",request).append("jobId",job);} + Document origin(){return new Document("deploymentId","11111111-1111-1111-1111-111111111111").append("callerScope","a".repeat(64));} + Document version(Object id,Object binding){return new Document("_id",id).append("scanResult",new Document("remoteReview",binding));} + void insert(Object id,List versions){projects.insertOne(new Document("_id",id).append("versions",versions));} + @ParameterizedTest @ValueSource(strings={"SCANNING","FAILED","COMPLETED","CANCELLED","malformed"}) + void retainedBindingsRemainVisibleIndependentOfCurrentReviewOrScanState(String status) { + var v=version("v",binding());v.put("reviewStatus","APPROVED");v.get("scanResult",Document.class).put("status",status);insert("a",List.of(v)); + var item=inventory.page(null,64).items().getFirst();assertEquals(MISSING,item.originState());assertEquals(request,item.requestId());assertEquals(job,item.jobId());assertFalse(item.ambiguousVersion()); + } + @Test void recordedMissingAndInvalidOriginsStayDistinctWithoutClaimingAuthority() { + insert("a",List.of(version("missing",binding()),version("null",binding().append("origin",null)),version("recorded",binding().append("origin",origin())), + version("bad",binding().append("origin",new Document("deploymentId","not-a-uuid"))),version("array",List.of(binding())))); + var page=inventory.page(null,64);assertEquals(List.of(MISSING,MISSING,RECORDED,INVALID,UNREADABLE_BINDING),page.items().stream().map(ReviewOriginInventory.Item::originState).toList()); + assertNull(page.items().getLast().requestId());assertNull(page.items().getLast().jobId()); + } + @Test void oversizedMalformedPayloadsAreNotEchoedOrDeserialized() { + String payload="sensitive-payload".repeat(50000); + insert("a",List.of(version("v",binding().append("origin",new Document("deploymentId",payload).append("callerScope",List.of(payload))).append("requestId",payload).append("jobId",List.of(payload))).append("unrelated",payload))); + var page=inventory.page(null,1);assertEquals(INVALID,page.items().getFirst().originState());assertNull(page.items().getFirst().requestId());assertNull(page.items().getFirst().jobId());assertFalse(page.toString().contains("sensitive-payload")); + } + @Test void duplicateInvalidAndPhysicalPositionsArePreserved() { + insert("a",List.of(version("same",binding()),version("same",binding()),version(List.of("bad"),binding()),version("bad\n",binding()))); + var page=inventory.page(null,64);assertEquals(List.of(0,1,2,3),page.items().stream().map(ReviewOriginInventory.Item::versionIndex).toList()); + assertTrue(page.items().stream().allMatch(ReviewOriginInventory.Item::ambiguousVersion));assertNull(page.items().get(2).versionId());assertNull(page.items().get(3).versionId()); + } + @Test void emptySlicesContinueWithoutSkippingLaterBindings() { + insert("a",List.of(new Document("_id","one"),version("two",null),version("three",binding()))); + var first=inventory.page(null,1);assertTrue(first.items().isEmpty());assertEquals(1,first.examined()); + var second=inventory.page(first.next(),1);assertTrue(second.items().isEmpty());var third=inventory.page(second.next(),1);assertEquals(2,third.items().getFirst().versionIndex()); + assertNull(inventory.page(third.next(),1).next()); + } + @Test void mixedBsonProjectIdentitiesAndDeletedCursorRootContinueInOrder() { + var oid=new ObjectId("abcdefabcdefabcdefabcdef");insert("a",List.of(version("a",binding())));insert(oid,List.of(version("o",binding()))); + var first=inventory.page(null,1);assertEquals("a",first.items().getFirst().projectId());projects.deleteOne(new Document("_id","a")); + var second=inventory.page(first.next(),1);assertEquals(oid,second.items().getFirst().projectId());assertNull(inventory.page(second.next(),1).next()); + } + @Test void binaryOrderingDoesNotConflateProjectNames() { + insert("A",List.of(version("v",binding())));insert("a",List.of(version("v",binding()))); + var first=inventory.page(null,1);assertEquals("A",first.items().getFirst().projectId());assertEquals("a",inventory.page(first.next(),1).items().getFirst().projectId()); + } + @Test void malformedSiblingsAndRootsDoNotForceTypedProjectReads() { + projects.insertOne(new Document("_id",32).append("versions",List.of(version("v",binding())))); + projects.insertOne(new Document("_id","bad").append("versions","malformed")); + insert("a",List.of(42,new Document("scanResult",List.of(binding())),version("v",binding()))); + var page=inventory.page(null,64);assertEquals(3,page.examined());assertEquals(2,page.items().size());assertEquals(UNREADABLE_BINDING,page.items().getFirst().originState());assertTrue(page.items().getFirst().ambiguousVersion());assertEquals(2,page.items().getLast().versionIndex());assertEquals(MISSING,page.items().getLast().originState());assertNull(inventory.page(page.next(),64).next()); + } + @Test void inventoryPreservesExactStoredBytesAndDoesNotBackfillOrigin() { + insert("a",List.of(version("v",binding()).append("unmodeled",new Document("int",1).append("long",1L)))); + var raw=projects.withDocumentClass(RawBsonDocument.class);var before=raw.find().first();byte[] bytes=new byte[before.getByteBuffer().remaining()];before.getByteBuffer().get(bytes); + inventory.page(null,64);var after=raw.find().first();byte[] actual=new byte[after.getByteBuffer().remaining()];after.getByteBuffer().get(actual);assertArrayEquals(bytes,actual); + } + @Test void limitsRejectBeforeDatabaseWorkAndResponsesAreImmutable() { + assertThrows(IllegalArgumentException.class,()->inventory.page(null,0));assertThrows(IllegalArgumentException.class,()->inventory.page(null,65)); + insert("a",List.of(version("v",binding())));var page=inventory.page(null,1);assertThrows(UnsupportedOperationException.class,()->page.items().clear()); + } + @Test void realInventoryToHttpPreservesEmptyContinuationAndFailedVersionReference() throws Exception { + insert("a",List.of(new Document("_id","empty"),version("v",binding()).append("reviewStatus","APPROVED"))); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(new net.modtale.controller.admin.ReviewOriginController(inventory)).build(); + var first=mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/origins/page").param("limit","1")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items").isEmpty()).andReturn(); + String cursor=new com.fasterxml.jackson.databind.ObjectMapper().readTree(first.getResponse().getContentAsString()).path("nextCursor").asText(); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/origins/page").param("limit","1").param("cursor",cursor)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items[0].jobId").value(job)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items[0].position.versionIndex").value(1)) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items[0].originState").value("MISSING")); + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessIntegrationTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessIntegrationTest.java new file mode 100644 index 000000000..b342b55d5 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessIntegrationTest.java @@ -0,0 +1,126 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.time.Clock; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewRepairAccessIntegrationTest { + ReviewIsolationExecutorTest fixture;AccountService accounts;ReviewRepairWorkflow workflow;ReviewRepairAccess access; + ReviewRepairAccess.Position position=new ReviewRepairAccess.Position("STRING","p",0); + @BeforeEach void setup() { + fixture=new ReviewIsolationExecutorTest();fixture.setup();accounts=mock(AccountService.class); + var actor=ReviewRepairAccessTest.user("actor");SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(actor,null,List.of()));when(accounts.getCurrentUser(any())).thenReturn(actor); + configure(fixture.archive); + } + void configure(ReviewSnapshotArchive archive) { + var executor=new ReviewIsolationExecutor(fixture.mongo,archive,fixture.reader,fixture.journal); + workflow=new ReviewRepairWorkflow(new ReviewRepairPreparation(archive,fixture.reader,Clock.systemUTC(),1,60000),executor,1); + access=new ReviewRepairAccess(accounts,workflow,fixture.reader,executor); + } + @AfterEach void cleanup(){workflow.close();fixture.cleanup();SecurityContextHolder.clearContext();} + ReviewRepairPreparation.Prepared prepare() { + var preview=access.inspect(new ReviewRepairAccess.Inspect(position,"v"));assertTrue(preview.eligible());assertEquals("ISOLATE_LOCAL_REVIEW",preview.effect()); + return access.prepare(new ReviewRepairAccess.Prepare(UUID.randomUUID().toString(),position,"v",preview.sha256())); + } + @Test void exactPreviewPreparationExecutionAndReceiptAreComposed() { + var before=fixture.version();var prepared=prepare();assertEquals(before,fixture.version());assertNull(fixture.operation()); + var result=access.execute(prepared);assertEquals("APPLIED",result.state());var receipt=access.receipt(prepared);assertEquals(result.state(),receipt.state());assertEquals(result.afterSha256(),receipt.afterSha256());assertEquals(position,receipt.position());assertEquals("v",receipt.versionId());assertEquals(prepared.sha256(),receipt.beforeSha256()); + assertEquals("BLOCK",fixture.version().get("scanResult",org.bson.Document.class).get("verdict")); + } + @Test void anotherModeratorCannotUseAnExistingPreparedActionOrReceipt() { + var prepared=prepare();when(accounts.getCurrentUser(any())).thenReturn(ReviewRepairAccessTest.user("other")); + assertThrows(SecurityException.class,()->access.execute(prepared));assertThrows(SecurityException.class,()->access.receipt(prepared));assertNull(fixture.operation()); + } + @Test void permissionLossDuringArchivalReturnsNoPreparedAuthority() { + var archive=spy(fixture.archive);doAnswer(i->{var result=i.callRealMethod();when(accounts.getCurrentUser(any())).thenReturn(null);return result;}).when(archive).retain(any());configure(archive); + var before=fixture.version();assertThrows(SecurityException.class,this::prepare);assertEquals(before,fixture.version());assertNull(fixture.operation()); + } + @Test void stalePreviewAndTamperedPreparedIntentCannotMutateVersion() { + var preview=access.inspect(new ReviewRepairAccess.Inspect(position,"v"));fixture.set("changed",true); + assertThrows(IllegalStateException.class,()->access.prepare(new ReviewRepairAccess.Prepare(UUID.randomUUID().toString(),position,"v",preview.sha256()))); + var prepared=prepare();assertThrows(IllegalStateException.class,()->access.execute(new ReviewRepairPreparation.Prepared(prepared.id(),prepared.sha256(),prepared.createdAt(),prepared.expiresAt()+1)));assertNull(fixture.operation()); + } + @Test void storedAccountRevocationIsObservedDuringPreparation() { + var actor=ReviewRepairAccessTest.user("actor");fixture.mongo.save(actor); + var repository=mock(net.modtale.repository.user.UserRepository.class); + when(repository.findById("actor")).thenAnswer(i->Optional.ofNullable(fixture.mongo.findById("actor",net.modtale.model.user.User.class))); + var resolver=new net.modtale.service.user.account.CurrentUserResolutionService(repository); + when(accounts.getCurrentUser(any())).thenAnswer(i->resolver.resolveCurrentUser(i.getArgument(0))); + var archived=spy(fixture.archive); + doAnswer(i->{var result=i.callRealMethod();assertEquals(1,fixture.mongo.updateFirst( + org.springframework.data.mongodb.core.query.Query.query(org.springframework.data.mongodb.core.query.Criteria.where("_id").is("actor")), + new org.springframework.data.mongodb.core.query.Update().set("adminPermissions",List.of()),net.modtale.model.user.User.class).getModifiedCount());return result;}).when(archived).retain(any()); + configure(archived);assertThrows(SecurityException.class,this::prepare);assertNull(fixture.operation()); + } + + @Test void actualHttpPreviewPrepareExecuteAndReceiptReachTransactionalStorage() throws Exception { + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(new net.modtale.controller.admin.ReviewRepairController(access)).build(); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper(); + var previewResponse=mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/admin/verification/repairs/inspect") + .contentType("application/json").content(mapper.writeValueAsString(new ReviewRepairAccess.Inspect(position,"v")))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")).andReturn(); + var preview=mapper.readValue(previewResponse.getResponse().getContentAsString(),ReviewRepairAccess.Preview.class);assertTrue(preview.eligible()); + var preparedResponse=mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/admin/verification/repairs/prepare") + .contentType("application/json").content(mapper.writeValueAsString(new ReviewRepairAccess.Prepare(UUID.randomUUID().toString(),position,"v",preview.sha256())))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()).andReturn(); + String prepared=preparedResponse.getResponse().getContentAsString();assertNull(fixture.operation()); + for(String route:List.of("execute","receipt"))mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/admin/verification/repairs/"+route) + .contentType("application/json").content(prepared)).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.state").value("APPLIED")); + assertEquals("actor",fixture.operation().get("actor")); + } + + @Test void typedProjectSavePreservesIsolationAndHistoricalReceipt() { + var prepared=prepare();access.execute(prepared);var originalMarker=fixture.version().get("reviewIsolation",org.bson.Document.class); + var project=fixture.mongo.findById("p",net.modtale.model.project.Project.class); + assertNotNull(project.getVersions().getFirst().getReviewIsolation());project.getVersions().getFirst().setVersionNumber("edited");fixture.mongo.save(project); + assertEquals(originalMarker,fixture.version().get("reviewIsolation",org.bson.Document.class)); + var receipt=access.receipt(prepared);assertEquals("APPLIED",receipt.state());assertEquals(position,receipt.position());assertEquals(prepared.sha256(),receipt.beforeSha256()); + assertNotEquals(receipt.afterSha256(),fixture.reader.capture("p",0,"v").sha256()); + fixture.projects.deleteOne(new org.bson.Document("_id","p"));assertEquals(receipt,access.receipt(prepared)); + } + @Test void objectIdReceiptsRetainOriginalBsonIdentity() { + var version=fixture.version();String id="abcdefabcdefabcdefabcdef";fixture.projects.deleteMany(new org.bson.Document()); + fixture.projects.insertOne(new org.bson.Document("_id",new org.bson.types.ObjectId(id)).append("versions",List.of(version))); + position=new ReviewRepairAccess.Position("OBJECT_ID",id,0);var prepared=prepare();access.execute(prepared); + var receipt=access.receipt(prepared);assertEquals(position,receipt.position());assertEquals("v",receipt.versionId());assertEquals(prepared.sha256(),receipt.beforeSha256()); + } + + @Test void recoverByIdRestoresAuthenticatedIntentWithoutExecutingIt() { + var prepared=prepare();var before=fixture.version();var recovered=access.recover(prepared.id()); + assertEquals(prepared,recovered.prepared());assertEquals(position,recovered.receipt().position());assertEquals("UNKNOWN",recovered.receipt().state());assertEquals(before,fixture.version());assertNull(fixture.operation()); + access.execute(prepared);var applied=access.recover(prepared.id());assertEquals("APPLIED",applied.receipt().state());assertEquals(prepared,applied.prepared()); + when(accounts.getCurrentUser(any())).thenReturn(ReviewRepairAccessTest.user("other"));assertThrows(SecurityException.class,()->access.recover(prepared.id())); + } + @Test void operationDiscoveryAndRecoveryNeverReclaimAnUnknownClaim() { + var prepared=prepare();var claim=fixture.journal.claim(prepared,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,()->true);fixture.journal.markUnknown(claim,()->true); + new ReviewRepairOperationReader(fixture.mongo).initialize();var before=fixture.version();var operation=fixture.operation(); + var page=access.operations(null,25);assertEquals(List.of(new ReviewRepairOperationReader.Item(prepared.id(),"UNKNOWN")),page.items()); + assertEquals("UNKNOWN",access.recover(prepared.id()).receipt().state());assertEquals(before,fixture.version());assertEquals(operation,fixture.operation()); + } + + @Test void explicitClosureHttpActionPreservesOriginalIdentityAndDoesNotChangeVersion()throws Exception { + var capture=fixture.reader.capture("p",0,"v");long now=System.currentTimeMillis();String id=UUID.randomUUID().toString(); + fixture.archive.retain(capture.forArchive(id,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,now-20000,now-10000)); + var prepared=new ReviewRepairPreparation.Prepared(id,capture.sha256(),now-20000,now-10000); + assertNull(fixture.journal.claim(prepared,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,()->true));var before=fixture.version(); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(new net.modtale.controller.admin.ReviewRepairController(access)).build(); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/admin/verification/repairs/close-expired") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON).content(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsBytes(prepared))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.state").value("NOT_APPLIED")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.beforeSha256").value(prepared.sha256())); + assertEquals(before,fixture.version());assertEquals(position,access.receipt(prepared).position());assertEquals("NOT_APPLIED",access.recover(id).receipt().state()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessTest.java new file mode 100644 index 000000000..c9ffdb17c --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairAccessTest.java @@ -0,0 +1,53 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.user.*; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.*; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.*; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewRepairAccessTest { + AccountService accounts;ReviewRepairWorkflow workflow;RawReviewSnapshotReader reader;ReviewIsolationExecutor isolation;ReviewRepairAccess access;User actor; + ReviewRepairAccess.Position position=new ReviewRepairAccess.Position("STRING","p",0); + @BeforeEach void setup() { + accounts=mock(AccountService.class);workflow=mock(ReviewRepairWorkflow.class);reader=mock(RawReviewSnapshotReader.class);isolation=mock(ReviewIsolationExecutor.class); + access=new ReviewRepairAccess(accounts,workflow,reader,isolation);actor=user("actor"); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(actor,null,List.of())); + when(accounts.getCurrentUser(any())).thenReturn(actor); + } + @AfterEach void cleanup(){SecurityContextHolder.clearContext();} + static User user(String id){var user=new User();user.setId(id);user.setAdminPermissions(Set.of(AdminPermission.PROJECT_REVIEW_READ,AdminPermission.PROJECT_VERSION_RESCAN));return user;} + @Test void neitherReadNorRescanAloneAuthorizesAnyRepairOperation() { + var prepared=new ReviewRepairPreparation.Prepared(UUID.randomUUID().toString(),"a".repeat(64),1,2); + for(var permissions:List.of(Set.of(AdminPermission.PROJECT_REVIEW_READ),Set.of(AdminPermission.PROJECT_VERSION_RESCAN),Set.of(AdminPermission.PROJECT_REVIEW_DECIDE))) { + actor.setAdminPermissions(permissions);assertThrows(SecurityException.class,()->access.closeExpired(prepared));assertThrows(SecurityException.class,()->access.operations(null,25));assertThrows(SecurityException.class,()->access.recover(prepared.id()));assertThrows(SecurityException.class,()->access.capability()); + assertThrows(SecurityException.class,()->access.inspect(null));assertThrows(SecurityException.class,()->access.prepare(null)); + assertThrows(SecurityException.class,()->access.execute(prepared));assertThrows(SecurityException.class,()->access.receipt(prepared)); + } + verifyNoInteractions(workflow,reader,isolation); + } + @Test void preparationUsesServerActorAndRechecksFreshAccountIdentity() { + var id=UUID.randomUUID().toString(); + when(workflow.prepare(any(),any())).thenAnswer(i->{ + var input=i.getArgument(0);assertEquals("actor",input.actorId());assertEquals(ReviewSnapshotArchive.Action.ISOLATE_REVIEW,input.action()); + var allowed=i.getArgument(1);assertTrue(allowed.getAsBoolean()); + when(accounts.getCurrentUser(any())).thenReturn(user("other"));assertFalse(allowed.getAsBoolean()); + return new ReviewRepairPreparation.Prepared(id,"a".repeat(64),1,2); + }); + assertEquals(id,access.prepare(new ReviewRepairAccess.Prepare(id,position,"v","a".repeat(64))).id()); + } + @Test void deletedOrMissingAccountsCannotReachWorkflow() { + actor.setDeletedAt(java.time.LocalDateTime.now());assertThrows(SecurityException.class,()->access.inspect(null)); + when(accounts.getCurrentUser(any())).thenReturn(null);assertThrows(SecurityException.class,()->access.inspect(null));verifyNoInteractions(workflow); + } + @Test void malformedIdentityAndIntentAreRejectedBeforeWork() { + for(var p:List.of(new ReviewRepairAccess.Position("OBJECT_ID","ABCDEFABCDEFABCDEFABCDEF",0),new ReviewRepairAccess.Position("NUMBER","1",0),new ReviewRepairAccess.Position("STRING","p",-1))) + assertThrows(IllegalArgumentException.class,()->access.inspect(new ReviewRepairAccess.Inspect(p,"v"))); + assertThrows(IllegalArgumentException.class,()->access.execute(new ReviewRepairPreparation.Prepared("invalid","a".repeat(64),1,2))); + assertThrows(IllegalArgumentException.class,()->access.receipt(new ReviewRepairPreparation.Prepared(UUID.randomUUID().toString(),"a".repeat(64),2,1)));verifyNoInteractions(workflow,reader,isolation); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairClosureTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairClosureTest.java new file mode 100644 index 000000000..b3f6fb64c --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairClosureTest.java @@ -0,0 +1,118 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewRepairClosureTest { + ReviewIsolationExecutorTest fixture=new ReviewIsolationExecutorTest(); + @BeforeEach void setup(){fixture.setup();} + @AfterEach void cleanup(){fixture.cleanup();} + ReviewRepairPreparation.Prepared intent(long expires) { + var capture=fixture.reader.capture("p",0,"v");String id=UUID.randomUUID().toString();long created=expires-10000; + fixture.archive.retain(capture.forArchive(id,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,created,expires)); + return new ReviewRepairPreparation.Prepared(id,capture.sha256(),created,expires); + } + void record(ReviewRepairPreparation.Prepared p,String state) { + var d=new Document("_id",p.id()).append("token",UUID.randomUUID().toString()).append("actor","actor").append("action","ISOLATE_REVIEW") + .append("sha256",p.sha256()).append("createdAt",p.createdAt()).append("expiresAt",p.expiresAt()).append("state",state); + if(!state.equals("RESERVED"))d.append("startedAt",new Date(p.createdAt())); + if(state.equals("UNKNOWN"))d.append("uncertainAt",new Date(p.createdAt()+1)); + fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).insertOne(d); + } + void expire(ReviewRepairPreparation.Prepared p)throws InterruptedException { + long wait=p.expiresAt()-System.currentTimeMillis()+25;if(wait>0)Thread.sleep(wait); + } + @ParameterizedTest @ValueSource(strings={"RESERVED","EXECUTING","UNKNOWN"}) + void closesExpiredClaimsWithoutMutatingVersionOrRenewingIntent(String state) { + var p=intent(System.currentTimeMillis()-1000);record(p,state);var before=fixture.version(); + var receipt=fixture.executor().closeExpired(p,"actor",()->true);assertEquals("NOT_APPLIED",receipt.outcome().state());assertEquals(before,fixture.version()); + var saved=fixture.operation();var resolution=saved.get("resolution",Document.class);assertEquals("EXPIRED_CLAIM_CLOSED",resolution.get("kind"));assertEquals(state,resolution.get("previousState")); + assertEquals("actor",resolution.get("actorId"));assertInstanceOf(Date.class,resolution.get("closedAt"));assertEquals(saved.get("finishedAt"),resolution.get("closedAt")); + assertEquals(p.expiresAt(),saved.get("expiresAt"));assertEquals(receipt,fixture.executor().closeExpired(p,"actor",()->true));assertEquals(saved,fixture.operation()); + assertNull(fixture.journal.claim(p,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,()->true));assertEquals(before,fixture.version()); + } + @Test void unexpiredAbsentMalformedAndWrongActorCannotClose() { + var p=intent(System.currentTimeMillis()+60000);assertEquals("UNKNOWN",fixture.executor().closeExpired(p,"actor",()->true).outcome().state());assertNull(fixture.operation()); + record(p,"EXECUTING");var before=fixture.operation();assertEquals("UNKNOWN",fixture.executor().closeExpired(p,"actor",()->true).outcome().state());assertEquals(before,fixture.operation()); + assertThrows(SecurityException.class,()->fixture.executor().closeExpired(p,"other",()->true)); + assertThrows(SecurityException.class,()->fixture.executor().closeExpired(p,"actor",()->false));assertEquals(before,fixture.operation()); + var altered=new ReviewRepairPreparation.Prepared(p.id(),"f".repeat(64),p.createdAt(),p.expiresAt());assertThrows(IllegalStateException.class,()->fixture.executor().closeExpired(altered,"actor",()->true)); + } + @Test void malformedExpiredRecordAndNumericTypeSubstitutionStayUnknown() { + var p=intent(System.currentTimeMillis()-1000);record(p,"EXECUTING");var records=fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION); + records.updateOne(new Document("_id",p.id()),new Document("$set",new Document("extra","unexpected")));var before=fixture.operation(); + assertEquals("UNKNOWN",fixture.executor().closeExpired(p,"actor",()->true).outcome().state());assertEquals(before,fixture.operation()); + records.updateOne(new Document("_id",p.id()),new Document("$unset",new Document("extra","")).append("$set",new Document("createdAt",(double)p.createdAt())));before=fixture.operation(); + assertEquals("UNKNOWN",fixture.executor().closeExpired(p,"actor",()->true).outcome().state());assertEquals(before,fixture.operation()); + } + @ParameterizedTest @ValueSource(strings={"beforeSnapshot","afterVersionWrite"}) + void closureWinsAgainstAnOldTransactionAndPreventsItsVersionCommit(String phase)throws Exception { + var p=intent(System.currentTimeMillis()+1000);var before=fixture.version();var reader=spy(fixture.reader);var closed=new AtomicBoolean(); + doAnswer(i->{ + var value=(RawReviewSnapshotReader.Captured)i.callRealMethod(); + boolean selected=phase.equals("beforeSnapshot") || !value.sha256().equals(p.sha256()); + if(selected && closed.compareAndSet(false,true)){expire(p);assertEquals("NOT_APPLIED",fixture.executor().closeExpired(p,"actor",()->true).outcome().state());} + return value; + }).when(reader).capture(any(ClientSession.class),eq("p"),eq(0),eq("v")); + var outcome=new ReviewIsolationExecutor(fixture.mongo,fixture.archive,reader,fixture.journal).execute(p,"actor",()->true); + assertTrue(closed.get());assertEquals("NOT_APPLIED",outcome.state());assertEquals(before,fixture.version());assertEquals("EXPIRED_CLAIM_CLOSED",fixture.operation().get("resolution",Document.class).get("kind")); + } + @Test void lateExecutorStartingAfterClosureCannotExecute()throws Exception { + var p=intent(System.currentTimeMillis()+1000);var claim=fixture.journal.claim(p,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,()->true);assertNotNull(claim);expire(p); + var before=fixture.version();assertEquals("NOT_APPLIED",fixture.executor().closeExpired(p,"actor",()->true).outcome().state()); + assertEquals("NOT_APPLIED",fixture.executor().execute(p,"actor",()->true).state());assertEquals(before,fixture.version()); + } + @Test void lostClosureAcknowledgementRecoversWithoutASecondWrite() { + var p=intent(System.currentTimeMillis()-1000);record(p,"UNKNOWN");var journal=spy(fixture.journal);var writes=new AtomicInteger(); + doAnswer(i->{i.callRealMethod();writes.incrementAndGet();throw new MongoException("lost acknowledgement");}).when(journal).closeExpired(eq(p),eq("actor"),any()); + var executor=new ReviewIsolationExecutor(fixture.mongo,fixture.archive,fixture.reader,journal);var before=fixture.version(); + assertEquals("NOT_APPLIED",executor.closeExpired(p,"actor",()->true).outcome().state());assertEquals(1,writes.get());assertEquals(before,fixture.version()); + } + @Test void anAlreadyCommittedIsolationReceiptIsNeverOverwritten() { + var p=fixture.prepare();assertEquals("APPLIED",fixture.executor().execute(p,"actor",()->true).state());var before=fixture.version();var operation=fixture.operation(); + assertEquals("APPLIED",fixture.executor().closeExpired(p,"actor",()->true).outcome().state());assertEquals(operation,fixture.operation());assertEquals(before,fixture.version()); + } + @Test void committedTransactionWinsWhileClosureWaitsOnTheJournalWrite()throws Exception { + var p=intent(System.currentTimeMillis()+1000);var sent=new CountDownLatch(1); + String port=System.getenv().getOrDefault("WARDEN_REPAIR_TX_DB_PORT","27031"); + var settings=MongoClientSettings.builder().applyConnectionString(new ConnectionString("mongodb://127.0.0.1:"+port+"/?directConnection=true&serverSelectionTimeoutMS=3000&socketTimeoutMS=5000")) + .addCommandListener(new com.mongodb.event.CommandListener(){@Override public void commandStarted(com.mongodb.event.CommandStartedEvent e){ + if(e.getCommandName().equals("update") && e.getCommand().toJson().contains("EXPIRED_CLAIM_CLOSED"))sent.countDown(); + }}).build(); + try(var client=MongoClients.create(settings);var worker=Executors.newSingleThreadExecutor()) { + var mongo=new org.springframework.data.mongodb.core.MongoTemplate(client,fixture.mongo.getDb().getName()); + var closer=new ReviewIsolationExecutor(mongo,fixture.archive,fixture.reader,new ReviewRepairJournal(mongo,fixture.archive)); + var wrapped=spy(fixture.mongo);var factory=spy(fixture.mongo.getMongoDatabaseFactory());doReturn(factory).when(wrapped).getMongoDatabaseFactory(); + var future=new AtomicReference>(); + doAnswer(i->{var session=spy((ClientSession)i.callRealMethod());doAnswer(c->{ + expire(p);future.set(worker.submit(()->closer.closeExpired(p,"actor",()->true))); + assertTrue(sent.await(5,TimeUnit.SECONDS));return c.callRealMethod(); + }).when(session).commitTransaction();return session;}).when(factory).getSession(any(ClientSessionOptions.class)); + var result=new ReviewIsolationExecutor(wrapped,fixture.archive,fixture.reader,fixture.journal).execute(p,"actor",()->true); + assertEquals("APPLIED",result.state());assertEquals("APPLIED",future.get().get(10,TimeUnit.SECONDS).outcome().state()); + assertNull(fixture.operation().get("resolution"));assertEquals(result.afterSha256(),fixture.reader.capture("p",0,"v").sha256()); + } + } + @Test void permissionLossAtTheFinalJournalCheckpointPreventsClosure() { + var p=intent(System.currentTimeMillis()-1000);record(p,"UNKNOWN");var before=fixture.operation();var checks=new AtomicInteger(); + assertThrows(SecurityException.class,()->fixture.journal.closeExpired(p,"actor",()->checks.incrementAndGet()==1));assertEquals(before,fixture.operation()); + } + @Test void standaloneIsRejectedBeforeClosingAClaim() { + var p=intent(System.currentTimeMillis()-1000);record(p,"UNKNOWN");var before=fixture.operation();var mongo=spy(fixture.mongo);var db=spy(fixture.mongo.getDb());doReturn(db).when(mongo).getDb(); + doReturn(new Document("isWritablePrimary",true)).when(db).runCommand(eq(new Document("hello",1)),eq(ReadPreference.primary())); + var executor=new ReviewIsolationExecutor(mongo,fixture.archive,fixture.reader,fixture.journal); + assertThrows(IllegalStateException.class,()->executor.closeExpired(p,"actor",()->true));assertEquals(before,fixture.operation()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairConfigurationTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairConfigurationTest.java new file mode 100644 index 000000000..e4ef40840 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairConfigurationTest.java @@ -0,0 +1,36 @@ +package net.modtale.service.admin.review; + +import net.modtale.config.properties.AppReviewRepairProperties; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewRepairConfigurationTest { + String key=Base64.getEncoder().encodeToString(new byte[32]); + @Test void featureIsAbsentByDefault() { + new ApplicationContextRunner().withUserConfiguration(ReviewRepairConfiguration.class).run(context->{assertFalse(context.containsBean("reviewRepairPreparation"));assertFalse(context.containsBean("reviewRepairWorkflow"));}); + } + @Test void enabledFeatureRequiresExplicitKeys() { + new ApplicationContextRunner().withUserConfiguration(ReviewRepairConfiguration.class).withBean(MongoTemplate.class,()->mock(MongoTemplate.class)).withBean(net.modtale.service.user.account.AccountService.class,()->mock(net.modtale.service.user.account.AccountService.class)) + .withPropertyValues("app.warden.repair.enabled=true").run(context->assertNotNull(context.getStartupFailure())); + } + @Test void configuredFeatureConstructsBoundedPreparationWithoutStartingWork() { + var mongo=mock(MongoTemplate.class,RETURNS_DEEP_STUBS); + new ApplicationContextRunner().withUserConfiguration(ReviewRepairConfiguration.class).withBean(MongoTemplate.class,()->mongo).withBean(net.modtale.service.user.account.AccountService.class,()->mock(net.modtale.service.user.account.AccountService.class)) + .withPropertyValues("app.warden.repair.enabled=true","app.warden.repair.active-key=test","app.warden.repair.signing-keys.test="+key) + .run(context->{assertNull(context.getStartupFailure());assertNotNull(context.getBean(ReviewRepairPreparation.class));assertNotNull(context.getBean(ReviewSnapshotArchive.class));assertNotNull(context.getBean(ReviewRepairWorkflow.class));}); + } + @Test void invalidKeysAndLimitsFailWithoutEchoingSecretValues() { + for(String value:List.of("secret",Base64.getEncoder().encodeToString(new byte[31]),key.replace("=",""))) { + var failure=assertThrows(IllegalArgumentException.class,()->new AppReviewRepairProperties(true,"test",Map.of("test",value),1,1000)); + assertFalse(failure.getMessage().contains(value)); + } + assertThrows(IllegalArgumentException.class,()->new AppReviewRepairProperties(true,"test",Map.of("test",key),3,1000)); + assertThrows(IllegalArgumentException.class,()->new AppReviewRepairProperties(true,"test",Map.of("test",key),1,900001)); + var properties=new AppReviewRepairProperties(true,"test",Map.of("test",key),1,1000);assertFalse(properties.toString().contains(key)); + var decoded=properties.decodedKeys();decoded.get("test")[0]=7;assertEquals(0,properties.decodedKeys().get("test")[0]); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoStallTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoStallTest.java new file mode 100644 index 000000000..a019f55a4 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoStallTest.java @@ -0,0 +1,132 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import com.mongodb.event.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.net.*; +import java.io.*; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewRepairIoStallTest { + MongoClient direct,client;MongoTemplate mongo;ReplyProxy proxy;ReviewSnapshotArchive archive;String database; + AtomicBoolean stallInsert=new AtomicBoolean(),stallWrite=new AtomicBoolean();AtomicLong remaining=new AtomicLong(TimeUnit.SECONDS.toNanos(2));AtomicBoolean expireAfterInsert=new AtomicBoolean(); + @BeforeEach void setup(TestInfo info)throws Exception { + boolean smallBuffers=info.getTestMethod().orElseThrow().getName().startsWith("blockedSocketWrite"); + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + direct=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000&socketTimeoutMS=5000"); + proxy=new ReplyProxy(Integer.parseInt(port),smallBuffers);database="warden_io_"+UUID.randomUUID().toString().replace("-",""); + client=MongoClients.create(MongoClientSettings.builder().applyConnectionString(new ConnectionString("mongodb://127.0.0.1:"+proxy.port()+"/?directConnection=true&serverSelectionTimeoutMS=3000&socketTimeoutMS=0")) + .applyToSocketSettings(settings->settings.sendBufferSize(smallBuffers?1024:0)) + .addCommandListener(new CommandListener() { + @Override public void commandStarted(CommandStartedEvent event){if(event.getCommandName().equals("insert")) {if(stallInsert.get())proxy.paused.set(true);if(stallWrite.get())proxy.pausedRequests.set(true);}} + @Override public void commandSucceeded(CommandSucceededEvent event){if(event.getCommandName().equals("insert") && expireAfterInsert.get())remaining.set(0);} + }).build()); + mongo=new MongoTemplate(client,database);mongo.getDb().withTimeout(3000,TimeUnit.MILLISECONDS).runCommand(new Document("ping",1)); + archive=new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32])); + } + @AfterEach void cleanup()throws Exception { + if(proxy!=null)proxy.close();if(client!=null)client.close(); + if(direct!=null){direct.getDatabase(database).drop();direct.close();} + } + ReviewSnapshotArchive.Snapshot snapshot(int size){long now=System.currentTimeMillis();return new ReviewSnapshotArchive.Snapshot(UUID.randomUUID().toString(),"p",0,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,now,now+60000,new byte[size]);} + + @Test void stalledReadKeepsAdmissionUntilDriverActuallyReturns()throws Exception { + var reader=new RawReviewSnapshotReader(mongo);var preparation=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000);var isolation=mock(ReviewIsolationExecutor.class); + var workflow=new ReviewRepairWorkflow(preparation,isolation,1,1000,System::nanoTime); + var request=new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),"p",0,"v","a".repeat(64),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW); + proxy.paused.set(true); + try(var worker=Executors.newSingleThreadExecutor()) { + var pending=worker.submit(()->workflow.prepare(request,()->true)); + try { + assertTrue(proxy.withheld.await(5,TimeUnit.SECONDS));assertFalse(pending.isDone()); + assertEquals(new ReviewRepairWorkflow.Status("OPEN",1),workflow.status()); + assertThrows(IllegalStateException.class,()->workflow.isolate(new ReviewRepairPreparation.Prepared(request.id(),request.expectedSha256(),1,2),"actor",()->true)); + workflow.close();assertEquals(new ReviewRepairWorkflow.Status("DRAINING",1),workflow.status()); + var failure=assertThrows(ExecutionException.class,()->pending.get(10,TimeUnit.SECONDS));assertInstanceOf(MongoOperationTimeoutException.class,failure.getCause()); + assertEquals(new ReviewRepairWorkflow.Status("CLOSED",0),workflow.status());verifyNoInteractions(isolation); + } finally {proxy.close();workflow.close();} + } + assertEquals(0,direct.getDatabase(database).getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + + @Test void lostInsertReplyLeavesOnlyUnpublishedChunkAndNeverClaimsRetention() { + var snapshot=snapshot(5);stallInsert.set(true);long started=System.nanoTime(); + try(var io=ReviewRepairIo.open(()->TimeUnit.SECONDS.toNanos(1)-(System.nanoTime()-started))) { + assertThrows(RuntimeException.class,()->archive.retain(snapshot)); + } + assertTrue(proxy.paused.get());assertEquals(1,direct.getDatabase(database).getCollection(ReviewSnapshotArchive.CHUNKS).countDocuments()); + assertEquals(0,direct.getDatabase(database).getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + assertTrue(System.nanoTime()-startedarchive.retain(snapshot));} + assertEquals(0,remaining.get());assertEquals(1,direct.getDatabase(database).getCollection(ReviewSnapshotArchive.CHUNKS).countDocuments()); + assertEquals(0,direct.getDatabase(database).getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + + + @Test void blockedSocketWriteRetainsAdmissionBeyondCheckpointDeadlineUntilTransportCloses()throws Exception { + var directMongo=new MongoTemplate(direct,database); + directMongo.getCollection("projects").insertOne(new Document("_id","p").append("versions",List.of(new Document("_id","v").append("padding",new org.bson.types.Binary(new byte[300000]))))); + var expected=new RawReviewSnapshotReader(directMongo).capture("p",0,"v");var reader=new RawReviewSnapshotReader(mongo); + var preparation=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000);var isolation=mock(ReviewIsolationExecutor.class); + var workflow=new ReviewRepairWorkflow(preparation,isolation,1,1000,System::nanoTime); + var request=new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),"p",0,"v",expected.sha256(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW); + stallWrite.set(true); + try(var worker=Executors.newSingleThreadExecutor()) { + var pending=worker.submit(()->workflow.prepare(request,()->true)); + try { + assertTrue(proxy.requestWithheld.await(5,TimeUnit.SECONDS)); + // The pinned synchronous driver does not interrupt a blocked socket write at timeoutMS. + assertThrows(TimeoutException.class,()->pending.get(1500,TimeUnit.MILLISECONDS)); + assertEquals(new ReviewRepairWorkflow.Status("OPEN",1,1),workflow.status());workflow.close(); + assertEquals(new ReviewRepairWorkflow.Status("DRAINING",1,1),workflow.status()); + proxy.close();assertThrows(ExecutionException.class,()->pending.get(10,TimeUnit.SECONDS)); + assertEquals(new ReviewRepairWorkflow.Status("CLOSED",0),workflow.status());verifyNoInteractions(isolation); + } finally {proxy.close();workflow.close();} + } + assertEquals(0,direct.getDatabase(database).getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + + /** Transparent loopback proxy that can retain replies while requests still reach the real database. */ + static final class ReplyProxy implements AutoCloseable { + final ServerSocket listener;final ExecutorService workers=Executors.newVirtualThreadPerTaskExecutor();final List sockets=new CopyOnWriteArrayList<>(); + final AtomicBoolean paused=new AtomicBoolean(),pausedRequests=new AtomicBoolean(),closed=new AtomicBoolean();final CountDownLatch withheld=new CountDownLatch(1),requestWithheld=new CountDownLatch(1),release=new CountDownLatch(1); + ReplyProxy(int target)throws IOException {this(target,false);} + ReplyProxy(int target,boolean smallBuffers)throws IOException { + listener=new ServerSocket(0,16,InetAddress.getLoopbackAddress()); + workers.submit(()->{while(!closed.get())try { + Socket front=listener.accept();if(smallBuffers)front.setReceiveBufferSize(1024);sockets.add(front);Socket back=new Socket("127.0.0.1",target);sockets.add(back); + workers.submit(()->copy(front,back,false));workers.submit(()->copy(back,front,true)); + }catch(IOException failure){if(!closed.get())throw new UncheckedIOException(failure);}}); + } + int port(){return listener.getLocalPort();} + void copy(Socket source,Socket target,boolean reply) { + try {byte[] buffer=new byte[8192];int count; + while((count=source.getInputStream().read(buffer))!=-1) { + if(reply && paused.get()){withheld.countDown();release.await();} + if(!reply && pausedRequests.get()){requestWithheld.countDown();release.await();} + if(closed.get())return;target.getOutputStream().write(buffer,0,count);target.getOutputStream().flush(); + } + }catch(IOException ignored){}catch(InterruptedException interrupted){Thread.currentThread().interrupt();} + } + @Override public void close() { + if(!closed.compareAndSet(false,true))return;release.countDown();try{listener.close();}catch(IOException ignored){} + for(var socket:sockets)try{socket.close();}catch(IOException ignored){} + workers.shutdownNow(); + try{if(!workers.awaitTermination(5,TimeUnit.SECONDS))throw new IllegalStateException("Proxy did not stop");}catch(InterruptedException interrupted){Thread.currentThread().interrupt();throw new IllegalStateException(interrupted);} + } + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoTest.java new file mode 100644 index 000000000..7a4f665a1 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairIoTest.java @@ -0,0 +1,34 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.MongoCollection; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewRepairIoTest { + @Test void remainingBudgetIsAppliedAndNeverBecomesUnlimited() { + MongoCollection collection=mock(MongoCollection.class,RETURNS_SELF);var remaining=new AtomicLong(TimeUnit.SECONDS.toNanos(30)); + try(var io=ReviewRepairIo.open(remaining::get)) { + ReviewRepairIo.collection(collection);verify(collection).withTimeout(5000,TimeUnit.MILLISECONDS); + remaining.set(1);ReviewRepairIo.collection(collection);verify(collection).withTimeout(1,TimeUnit.MILLISECONDS); + remaining.set(0);assertThrows(IllegalStateException.class,()->ReviewRepairIo.collection(collection)); + } + clearInvocations(collection);assertSame(collection,ReviewRepairIo.collection(collection));verifyNoInteractions(collection); + } + @Test void cleanupHasIndependentBudgetAndRestoresExpiredScope() { + MongoCollection collection=mock(MongoCollection.class,RETURNS_SELF); + try(var expired=ReviewRepairIo.open(()->0)) { + try(var cleanup=ReviewRepairIo.cleanup()) {assertSame(collection,ReviewRepairIo.collection(collection));} + assertThrows(IllegalStateException.class,()->ReviewRepairIo.collection(collection)); + } + } + @Test void scopeDoesNotEscapeToAnotherThread()throws Exception { + MongoCollection collection=mock(MongoCollection.class); + try(var io=ReviewRepairIo.open(()->0);var worker=java.util.concurrent.Executors.newSingleThreadExecutor()) { + assertSame(collection,worker.submit(()->ReviewRepairIo.collection(collection)).get(5,TimeUnit.SECONDS));verifyNoInteractions(collection); + } + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairJournalTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairJournalTest.java new file mode 100644 index 000000000..ba8b9a00d --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairJournalTest.java @@ -0,0 +1,81 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewRepairJournalTest { + MongoClient client;MongoTemplate mongo;ReviewSnapshotArchive archive;ReviewRepairJournal journal;ReviewRepairPreparation.Prepared prepared; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");mongo=new MongoTemplate(client,"warden_repair_journal_"+UUID.randomUUID().toString().replace("-","")); + mongo.getCollection("projects").insertOne(new Document("_id","p").append("versions",List.of(new Document("_id","v").append("scanResult","malformed")))); + archive=new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32]));var reader=new RawReviewSnapshotReader(mongo); + var request=new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),"p",0,"v",reader.capture("p",0,"v").sha256(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW); + prepared=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000).prepare(request,()->true);journal=new ReviewRepairJournal(mongo,archive); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + ReviewRepairJournal.Claim claim(ReviewRepairJournal j){return j.claim(prepared,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,()->true);} + @Test void restartCannotReclaimExecutingOrUncertainOperation() { + var before=mongo.getCollection("projects").find().first();var first=claim(journal);assertNotNull(first); + assertNull(claim(new ReviewRepairJournal(mongo,archive)));assertTrue(journal.markUnknown(first,()->true));assertTrue(journal.markUnknown(first,()->true)); + assertNull(claim(journal));assertFalse(journal.markUnknown(new ReviewRepairJournal.Claim(first.id(),UUID.randomUUID().toString()),()->true)); + assertEquals(before,mongo.getCollection("projects").find().first()); + } + @Test void concurrentClaimantsHaveOnlyOneWinner()throws Exception { + try(var executor=Executors.newFixedThreadPool(2)) { + var a=executor.submit(()->claim(new ReviewRepairJournal(mongo,archive)));var b=executor.submit(()->claim(new ReviewRepairJournal(mongo,archive))); + var first=a.get(5,TimeUnit.SECONDS);var second=b.get(5,TimeUnit.SECONDS);assertNotEquals(first==null,second==null); + } + assertEquals(1,mongo.getCollection(ReviewRepairJournal.COLLECTION).countDocuments()); + } + @Test void expiredPreparedEvidenceCannotArmExecution() { + var old=archive.load(prepared.id());var expired=new ReviewSnapshotArchive.Snapshot(UUID.randomUUID().toString(),old.projectId(),old.versionIndex(),old.actorId(),old.action(),1,2,old.versionBytes());archive.retain(expired); + prepared=new ReviewRepairPreparation.Prepared(expired.id(),prepared.sha256(),1,2);assertNull(claim(journal)); + assertEquals("RESERVED",mongo.getCollection(ReviewRepairJournal.COLLECTION).find().first().getString("state"));assertNull(claim(journal)); + } + @ParameterizedTest @ValueSource(strings={"actor","action","hash","expiry"}) + void originalArchiveBindingCannotBeForged(String mutation) { + var request=mutation.equals("hash")?new ReviewRepairPreparation.Prepared(prepared.id(),"a".repeat(64),prepared.createdAt(),prepared.expiresAt()): + mutation.equals("expiry")?new ReviewRepairPreparation.Prepared(prepared.id(),prepared.sha256(),prepared.createdAt(),prepared.expiresAt()+1):prepared; + assertThrows(IllegalStateException.class,()->journal.claim(request,mutation.equals("actor")?"other":"actor",mutation.equals("action")?ReviewSnapshotArchive.Action.REPLACE_REVIEW:ReviewSnapshotArchive.Action.ISOLATE_REVIEW,()->true)); + assertEquals(0,mongo.getCollection(ReviewRepairJournal.COLLECTION).countDocuments()); + } + MongoCollection intercepted(MongoTemplate template) { + var configured=mongo.getCollection(ReviewRepairJournal.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY).withWriteConcern(WriteConcern.MAJORITY.withJournal(true)); + var spy=spy(configured);doReturn(spy).when(template).getCollection(ReviewRepairJournal.COLLECTION);doReturn(spy).when(spy).withReadPreference(any());doReturn(spy).when(spy).withReadConcern(any());doReturn(spy).when(spy).withWriteConcern(any());return spy; + } + @Test void committedReservationWithLostAcknowledgementCanArmOnlyItsOwnToken() { + var template=spy(mongo);var ops=intercepted(template);doAnswer(i->{i.callRealMethod();throw new MongoException("lost acknowledgement");}).when(ops).insertOne(any(Document.class)); + var result=claim(new ReviewRepairJournal(template,archive));assertNotNull(result);assertNull(claim(journal)); + } + @Test void committedArmingWithLostAcknowledgementRecoversOwnClaim() { + var template=spy(mongo);var ops=intercepted(template); + doAnswer(i->{i.callRealMethod();throw new MongoException("lost acknowledgement");}).when(ops).findOneAndUpdate(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.FindOneAndUpdateOptions.class)); + var result=claim(new ReviewRepairJournal(template,archive));assertNotNull(result);assertNull(claim(journal)); + } + @Test void unknownOutcomeWithLostAcknowledgementIsDurableAndNeverReclaimed() { + var own=claim(journal);assertNotNull(own);var template=spy(mongo);var ops=intercepted(template); + doAnswer(i->{i.callRealMethod();throw new MongoException("lost acknowledgement");}).when(ops).updateOne(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.UpdateOptions.class)); + assertTrue(new ReviewRepairJournal(template,archive).markUnknown(own,()->true));assertNull(claim(journal)); + assertEquals("UNKNOWN",mongo.getCollection(ReviewRepairJournal.COLLECTION).find().first().getString("state")); + } + @Test void revocationAfterReservationLeavesNoReusableExecutionAuthority() { + var allowed=new AtomicBoolean(true);var template=spy(mongo);var ops=intercepted(template); + doAnswer(i->{var result=i.callRealMethod();allowed.set(false);return result;}).when(ops).insertOne(any(Document.class)); + assertThrows(SecurityException.class,()->new ReviewRepairJournal(template,archive).claim(prepared,"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,allowed::get)); + assertEquals("RESERVED",mongo.getCollection(ReviewRepairJournal.COLLECTION).find().first().getString("state"));assertNull(claim(journal)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairOperationReaderTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairOperationReaderTest.java new file mode 100644 index 000000000..673539934 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairOperationReaderTest.java @@ -0,0 +1,49 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewRepairOperationReaderTest { + MongoClient client;MongoTemplate mongo;MongoCollection operations;ReviewRepairOperationReader reader; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000&socketTimeoutMS=5000");mongo=new MongoTemplate(client,"warden_operation_page_"+UUID.randomUUID().toString().replace("-","")); + mongo.getDb().createCollection(ReviewRepairJournal.COLLECTION,new com.mongodb.client.model.CreateCollectionOptions().collation(com.mongodb.client.model.Collation.builder().locale("en").collationStrength(com.mongodb.client.model.CollationStrength.SECONDARY).build())); + operations=mongo.getCollection(ReviewRepairJournal.COLLECTION);reader=new ReviewRepairOperationReader(mongo);reader.initialize(); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + String id(int n){return String.format("%08x-0000-0000-0000-%012x",n,n);} + Document record(int n,Object actor,Object action,Object state){return new Document("_id",id(n)).append("actor",actor).append("action",action).append("state",state);} + @Test void discoveryIsActorScopedBinaryAndScalar() { + operations.insertMany(List.of(record(1,"actor","ISOLATE_REVIEW","EXECUTING"),record(2,"Actor","ISOLATE_REVIEW","APPLIED"),record(3,List.of("actor"),"ISOLATE_REVIEW","APPLIED"),record(4,"actor",List.of("ISOLATE_REVIEW"),"APPLIED"),record(5,"actor","REPLACE_REVIEW","APPLIED"))); + var page=reader.page("actor",null,25);assertEquals(List.of(new ReviewRepairOperationReader.Item(id(1),"EXECUTING")),page.items());assertNull(page.nextCursor());assertEquals("OPERATION_ID",page.order()); + } + @Test void pagesStayBoundedAndDeletedCursorsStillAdvance() { + for(int n=1;n<=4;n++)operations.insertOne(record(n,"actor","ISOLATE_REVIEW","UNKNOWN")); + var first=reader.page("actor",null,2);assertEquals(2,first.items().size());assertEquals("r1."+id(2),first.nextCursor()); + operations.deleteOne(new Document("_id",id(2)));var second=reader.page("actor",first.nextCursor(),2);assertEquals(List.of(id(3),id(4)),second.items().stream().map(ReviewRepairOperationReader.Item::id).toList());assertNull(second.nextCursor()); + assertTrue(reader.page("other",first.nextCursor(),2).items().isEmpty()); + } + @Test void malformedStateAndPrivatePayloadsNeverExpandTheResponse() { + var large="private".repeat(100000);operations.insertOne(record(1,"actor","ISOLATE_REVIEW",large).append("payload",large).append("token",large)); + var before=operations.find().first();var page=reader.page("actor",null,1);assertEquals(List.of(new ReviewRepairOperationReader.Item(id(1),"UNKNOWN")),page.items());assertEquals(before,operations.find().first());assertFalse(page.toString().contains("private")); + } + @Test void malformedIdentityDoesNotEnterTheReferenceStream() { + operations.insertOne(new Document("_id","INVALID").append("actor","actor").append("action","ISOLATE_REVIEW")); + operations.insertOne(record(1,"actor","ISOLATE_REVIEW","APPLIED"));assertEquals(1,reader.page("actor",null,25).items().size()); + } + @Test void invalidCursorsAndLimitsAreRejected() { + for(String cursor:List.of("",id(1),"r2."+id(1),"r1."+id(1)+"extra"))assertThrows(IllegalArgumentException.class,()->reader.page("actor",cursor,1)); + assertThrows(IllegalArgumentException.class,()->reader.page("actor",null,0));assertThrows(IllegalArgumentException.class,()->reader.page("actor",null,26)); + } + @Test void missingRequiredIndexDoesNotFallBackToACollectionScan() { + operations.insertOne(record(1,"actor","ISOLATE_REVIEW","UNKNOWN"));operations.dropIndex(ReviewRepairOperationReader.INDEX); + assertThrows(com.mongodb.MongoException.class,()->reader.page("actor",null,25)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairPreparationTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairPreparationTest.java new file mode 100644 index 000000000..ee2b53001 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairPreparationTest.java @@ -0,0 +1,74 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewRepairPreparationTest { + MongoClient client;MongoTemplate mongo;RawReviewSnapshotReader reader;ReviewSnapshotArchive archive;Clock clock; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");mongo=new MongoTemplate(client,"warden_prepare_"+UUID.randomUUID().toString().replace("-","")); + mongo.getCollection("projects").insertOne(new Document("_id","p").append("versions",List.of(new Document("_id","v").append("scanResult","malformed")))); + reader=new RawReviewSnapshotReader(mongo);archive=new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32]));clock=Clock.fixed(Instant.ofEpochMilli(1000),ZoneOffset.UTC); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + ReviewRepairPreparation.Request request(){return new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),"p",0,"v",reader.capture("p",0,"v").sha256(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW);} + ReviewRepairPreparation service(ReviewSnapshotArchive a,RawReviewSnapshotReader r,Clock c){return new ReviewRepairPreparation(a,r,c,1,1000);} + @Test void retryAfterRestartUsesOriginalSnapshotAndExpiryWithoutProjectMutation() { + var req=request();var before=mongo.getCollection("projects").find().first();var result=service(archive,reader,clock).prepare(req,()->true); + var restarted=service(new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32])),new RawReviewSnapshotReader(mongo),Clock.fixed(Instant.ofEpochMilli(1500),ZoneOffset.UTC)); + assertEquals(result,restarted.prepare(req,()->true));assertEquals(2000,result.expiresAt());assertEquals(1,mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments());assertEquals(before,mongo.getCollection("projects").find().first()); + } + @ParameterizedTest @ValueSource(strings={"actor","action","project","hash"}) + void sameRequestIdentityCannotBeRebound(String field) { + var req=request();service(archive,reader,clock).prepare(req,()->true); + var replacement=new ReviewRepairPreparation.Request(req.id(),field.equals("project")?"other":req.projectId(),0,"v",field.equals("hash")?"a".repeat(64):req.expectedSha256(),field.equals("actor")?"other":req.actorId(),field.equals("action")?ReviewSnapshotArchive.Action.REPLACE_REVIEW:req.action()); + assertThrows(IllegalStateException.class,()->service(archive,reader,clock).prepare(replacement,()->true));assertEquals("actor",archive.load(req.id()).actorId()); + } + @Test void expiredIntentCannotBeRenewedByRetry() { + var req=request();service(archive,reader,clock).prepare(req,()->true); + assertThrows(IllegalStateException.class,()->service(archive,reader,Clock.fixed(Instant.ofEpochMilli(2000),ZoneOffset.UTC)).prepare(req,()->true));assertEquals(2000,archive.load(req.id()).expiresAt()); + } + @Test void staleExpectedDigestCannotArchiveNewerState() { + var req=request();mongo.getCollection("projects").updateOne(new Document(),new Document("$set",new Document("versions.0.changed",true))); + assertThrows(IllegalStateException.class,()->service(archive,reader,clock).prepare(req,()->true));assertEquals(0,mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + @Test void changeAfterArchivalRetainsEvidenceButReturnsNoPreparedResult() { + var req=request();var intercepted=spy(archive);doAnswer(i->{var result=i.callRealMethod();mongo.getCollection("projects").updateOne(new Document(),new Document("$set",new Document("versions.0.changed",true)));return result;}).when(intercepted).retain(any()); + assertThrows(IllegalStateException.class,()->service(intercepted,reader,clock).prepare(req,()->true));assertNotNull(archive.load(req.id())); + assertThrows(IllegalStateException.class,()->service(archive,reader,clock).prepare(req,()->true)); + } + @Test void permissionLossAfterArchiveCannotReturnPreparedResult() { + var req=request();var allowed=new AtomicBoolean(true);var intercepted=spy(archive); + doAnswer(i->{var result=i.callRealMethod();allowed.set(false);return result;}).when(intercepted).retain(any()); + assertThrows(SecurityException.class,()->service(intercepted,reader,clock).prepare(req,allowed::get));assertNotNull(archive.load(req.id())); + var a=mock(ReviewSnapshotArchive.class);var r=mock(RawReviewSnapshotReader.class); + assertThrows(SecurityException.class,()->service(a,r,clock).prepare(req,()->false));verifyNoInteractions(a,r); + } + @Test void unknownReturnAfterArchiveCommitRecoversSameIntent() { + var req=request();var intercepted=spy(archive);doAnswer(i->{i.callRealMethod();throw new IllegalStateException("lost response");}).when(intercepted).retain(any()); + assertThrows(IllegalStateException.class,()->service(intercepted,reader,clock).prepare(req,()->true)); + assertEquals(req.id(),service(archive,reader,clock).prepare(req,()->true).id());assertEquals(1,mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + @Test void localAdmissionHasNoUnboundedWaitQueue()throws Exception { + var req=request();var entered=new CountDownLatch(1);var release=new CountDownLatch(1);var intercepted=spy(archive); + doAnswer(i->{entered.countDown();assertTrue(release.await(5,TimeUnit.SECONDS));return i.callRealMethod();}).when(intercepted).find(req.id()); + var service=service(intercepted,reader,clock);try(var executor=Executors.newSingleThreadExecutor()) { + var running=executor.submit(()->service.prepare(req,()->true));try {assertTrue(entered.await(5,TimeUnit.SECONDS));assertThrows(IllegalStateException.class,()->service.prepare(req,()->true));}finally{release.countDown();} + assertEquals(req.id(),running.get(5,TimeUnit.SECONDS).id()); + } + assertEquals(req.id(),service.prepare(req,()->true).id()); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionContractTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionContractTest.java new file mode 100644 index 000000000..dd29e12e2 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionContractTest.java @@ -0,0 +1,67 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewRepairTransactionContractTest { + MongoClient client;MongoTemplate mongo;MongoCollection projects,operations;RawReviewSnapshotReader reader; + TransactionOptions options=TransactionOptions.builder().readConcern(ReadConcern.SNAPSHOT).writeConcern(WriteConcern.MAJORITY.withJournal(true)) + .readPreference(ReadPreference.primary()).maxCommitTime(5000L,TimeUnit.MILLISECONDS).build(); + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REPAIR_TX_DB_PORT","27031");if(!Set.of("27031","27032").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?directConnection=true&serverSelectionTimeoutMS=3000&socketTimeoutMS=10000"); + mongo=new MongoTemplate(client,"warden_repair_tx_"+UUID.randomUUID().toString().replace("-","")); + projects=mongo.getCollection("projects");operations=mongo.getCollection("review_repair_operations"); + projects.insertOne(new Document("_id","p").append("versions",List.of(new Document("_id","v").append("scanResult",new Document("status","SCANNING").append("scanAttempt",1))))); + operations.insertOne(new Document("_id","operation").append("state","EXECUTING"));reader=new RawReviewSnapshotReader(mongo); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + Document storedVersion(){return projects.find().first().getList("versions",Document.class).getFirst();} + void stage(ClientSession session) { + assertEquals(1,projects.updateOne(session,new Document("_id","p"),new Document("$set",new Document("versions.0.scanResult.status","FAILED"))).getModifiedCount()); + assertEquals(1,operations.updateOne(session,new Document("_id","operation"),new Document("$set",new Document("state","APPLIED"))).getModifiedCount()); + } + @Test void ordinaryEmbeddedEqualityDoesNotProveRawByteIdentity() { + var original=storedVersion();var before=reader.capture("p",0,"v"); + projects.updateOne(new Document("_id","p"),new Document("$set",new Document("versions.0.scanResult.scanAttempt",1L))); + assertInstanceOf(Long.class,storedVersion().get("scanResult",Document.class).get("scanAttempt")); + assertNotEquals(before.sha256(),reader.capture("p",0,"v").sha256()); + assertNotNull(projects.find(new Document("_id","p").append("versions",original)).first()); + } + @Test void concurrentTypeChangeAfterTransactionalCaptureCausesWriteConflict() { + var original=reader.capture("p",0,"v"); + try(var session=client.startSession()) { + session.startTransaction(options);assertEquals(original.sha256(),reader.capture(session,"p",0,"v").sha256()); + projects.updateOne(new Document("_id","p"),new Document("$set",new Document("versions.0.scanResult.scanAttempt",1L))); + assertEquals(original.sha256(),reader.capture(session,"p",0,"v").sha256()); + var conflict=assertThrows(MongoException.class,()->stage(session));assertTrue(conflict.hasErrorLabel("TransientTransactionError"));session.abortTransaction(); + } + assertEquals("SCANNING",storedVersion().get("scanResult",Document.class).getString("status"));assertEquals("EXECUTING",operations.find().first().getString("state")); + } + @Test void abortLeavesVersionAndOutcomeUnchanged() { + try(var session=client.startSession()) {session.startTransaction(options);reader.capture(session,"p",0,"v");stage(session);session.abortTransaction();} + assertEquals("SCANNING",storedVersion().get("scanResult",Document.class).getString("status"));assertEquals("EXECUTING",operations.find().first().getString("state")); + } + @Test void committedVersionAndOutcomeBecomeVisibleTogether() { + try(var session=client.startSession()) {session.startTransaction(options);reader.capture(session,"p",0,"v");stage(session);session.commitTransaction();} + assertEquals("FAILED",storedVersion().get("scanResult",Document.class).getString("status"));assertEquals("APPLIED",operations.find().first().getString("state")); + } + @Test void lostCommitAcknowledgementCanBeReconciledWithoutRepeatingWrites() { + try(var real=client.startSession()) { + var session=spy(real);session.startTransaction(options);reader.capture(session,"p",0,"v");stage(session); + doAnswer(i->{i.callRealMethod();var lost=new MongoException("lost commit acknowledgement");lost.addLabel("UnknownTransactionCommitResult");throw lost;}).when(session).commitTransaction(); + var failure=assertThrows(MongoException.class,session::commitTransaction);assertTrue(failure.hasErrorLabel("UnknownTransactionCommitResult")); + } + assertEquals("APPLIED",operations.withReadConcern(ReadConcern.MAJORITY).find().first().getString("state")); + assertEquals("FAILED",storedVersion().get("scanResult",Document.class).getString("status")); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionStallTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionStallTest.java new file mode 100644 index 000000000..8e78ed658 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairTransactionStallTest.java @@ -0,0 +1,85 @@ +package net.modtale.service.admin.review; + +import com.mongodb.*; +import com.mongodb.client.*; +import com.mongodb.event.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewRepairTransactionStallTest { + MongoClient direct,client;MongoTemplate mongo;ReviewRepairIoStallTest.ReplyProxy proxy;String database; + ReviewSnapshotArchive archive;RawReviewSnapshotReader reader;ReviewRepairJournal journal;ReviewRepairPreparation preparation; + AtomicReference stallCommand=new AtomicReference<>();CountDownLatch commandEntered=new CountDownLatch(1); + @BeforeEach void setup()throws Exception { + String port=System.getenv().getOrDefault("WARDEN_REPAIR_TX_DB_PORT","27031");if(!Set.of("27031","27032").contains(port))throw new IllegalArgumentException(); + direct=MongoClients.create("mongodb://127.0.0.1:"+port+"/?directConnection=true&serverSelectionTimeoutMS=3000&socketTimeoutMS=5000"); + proxy=new ReviewRepairIoStallTest.ReplyProxy(Integer.parseInt(port));database="warden_tx_stall_"+UUID.randomUUID().toString().replace("-",""); + client=MongoClients.create(MongoClientSettings.builder().applyConnectionString(new ConnectionString("mongodb://127.0.0.1:"+proxy.port()+"/?directConnection=true&serverSelectionTimeoutMS=3000&socketTimeoutMS=0")) + .addCommandListener(new CommandListener() { + @Override public void commandStarted(CommandStartedEvent event) { + if(event.getCommandName().equals(stallCommand.get())){proxy.paused.set(true);commandEntered.countDown();} + } + }).build()); + mongo=new MongoTemplate(client,database);mongo.getDb().withTimeout(3000,TimeUnit.MILLISECONDS).runCommand(new Document("ping",1)); + direct.getDatabase(database).getCollection("projects").insertOne(new Document("_id","p").append("versions",List.of(new Document("_id","v").append("reviewStatus","PENDING") + .append("scanResult",new Document("status","SCANNING").append("scanState","REMOTE_REVIEW").append("verdict","BLOCK"))))); + archive=new ReviewSnapshotArchive(mongo,"test",Map.of("test",new byte[32]));reader=new RawReviewSnapshotReader(mongo);journal=new ReviewRepairJournal(mongo,archive); + preparation=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000); + } + @AfterEach void cleanup() { + if(proxy!=null)proxy.close();if(client!=null)client.close();if(direct!=null){direct.getDatabase(database).drop();direct.close();} + } + ReviewRepairWorkflow workflow(RawReviewSnapshotReader selected) { + return new ReviewRepairWorkflow(preparation,new ReviewIsolationExecutor(mongo,archive,selected,journal),1,1000,System::nanoTime); + } + ReviewRepairPreparation.Prepared prepare() { + return preparation.prepare(new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),"p",0,"v",reader.capture("p",0,"v").sha256(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW),()->true); + } + Document project(){return direct.getDatabase(database).getCollection("projects").withReadConcern(ReadConcern.MAJORITY).find().first();} + Document operation(){return direct.getDatabase(database).getCollection(ReviewRepairJournal.COLLECTION).withReadConcern(ReadConcern.MAJORITY).find().first();} + void resume(){stallCommand.set(null);proxy.paused.set(false);proxy.release.countDown();mongo.getDb().withTimeout(3000,TimeUnit.MILLISECONDS).runCommand(new Document("ping",1));} + + @Test void actualLostCommitReplyKeepsCommittedReceiptAndNeverRepeatsVersionWrite()throws Exception { + var prepared=prepare();stallCommand.set("commitTransaction");var workflow=workflow(reader); + try(var worker=Executors.newSingleThreadExecutor()) { + var running=worker.submit(()->workflow.isolate(prepared,"actor",()->true)); + try { + assertTrue(commandEntered.await(5,TimeUnit.SECONDS));assertTrue(proxy.withheld.await(5,TimeUnit.SECONDS));assertFalse(running.isDone()); + assertEquals("APPLIED",operation().get("state"));var committed=project(); + workflow.close();assertEquals(new ReviewRepairWorkflow.Status("DRAINING",1),workflow.status()); + assertEquals("UNKNOWN",running.get(15,TimeUnit.SECONDS).state());assertEquals(new ReviewRepairWorkflow.Status("CLOSED",0),workflow.status()); + assertEquals("APPLIED",operation().get("state"));assertEquals(committed,project()); + resume();try(var restarted=workflow(reader)) { + var recovered=restarted.isolate(prepared,"actor",()->true);assertEquals("APPLIED",recovered.state()); + assertEquals(operation().get("afterSha256"),recovered.afterSha256());assertEquals(committed,project()); + } + } finally {proxy.close();workflow.close();} + } + } + + @Test void lostAbortReplyCannotTurnPermissionLossIntoAppliedRepair()throws Exception { + var prepared=prepare();var before=project();var permitted=new AtomicBoolean(true);var selected=spy(reader); + doAnswer(i->{var captured=(RawReviewSnapshotReader.Captured)i.callRealMethod();if(!captured.sha256().equals(prepared.sha256()))permitted.set(false);return captured;}) + .when(selected).capture(any(ClientSession.class),eq("p"),eq(0),eq("v")); + var workflow=workflow(selected);stallCommand.set("abortTransaction"); + try(var worker=Executors.newSingleThreadExecutor()) { + var running=worker.submit(()->workflow.isolate(prepared,"actor",permitted::get)); + try { + assertTrue(commandEntered.await(5,TimeUnit.SECONDS));assertTrue(proxy.withheld.await(5,TimeUnit.SECONDS));assertFalse(running.isDone()); + workflow.close();assertEquals(new ReviewRepairWorkflow.Status("DRAINING",1),workflow.status()); + var failure=assertThrows(ExecutionException.class,()->running.get(15,TimeUnit.SECONDS));assertInstanceOf(SecurityException.class,failure.getCause()); + assertEquals(before,project());assertEquals("EXECUTING",operation().get("state"));assertEquals(new ReviewRepairWorkflow.Status("CLOSED",0),workflow.status()); + resume();try(var restarted=workflow(reader)) {assertEquals("UNKNOWN",restarted.isolate(prepared,"actor",()->true).state());assertEquals(before,project());} + } finally {proxy.close();workflow.close();} + } + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairWorkflowTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairWorkflowTest.java new file mode 100644 index 000000000..effbbab20 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewRepairWorkflowTest.java @@ -0,0 +1,139 @@ +package net.modtale.service.admin.review; + +import org.junit.jupiter.api.Test; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReviewRepairWorkflowTest { + final ReviewRepairPreparation preparation = mock(ReviewRepairPreparation.class); + final ReviewIsolationExecutor isolation = mock(ReviewIsolationExecutor.class); + final ReviewRepairPreparation.Prepared prepared = new ReviewRepairPreparation.Prepared(UUID.randomUUID().toString(), "a".repeat(64), 1000, 2000); + final ReviewRepairPreparation.Request request = new ReviewRepairPreparation.Request(prepared.id(), "p", 0, "v", prepared.sha256(), "actor", ReviewSnapshotArchive.Action.ISOLATE_REVIEW); + + @Test void stoppedOrUnauthorizedRequestsNeverReachStorage() { + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1); + assertThrows(SecurityException.class, () -> workflow.isolate(prepared, "actor", () -> false)); + workflow.close(); + assertThrows(IllegalStateException.class, () -> workflow.prepare(request, () -> true)); + assertEquals(new ReviewRepairWorkflow.Status("CLOSED", 0), workflow.status()); + verifyNoInteractions(preparation, isolation); + } + + @Test void preparationAndIsolationShareAdmissionWithoutAWaitQueue() throws Exception { + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1); + var entered = new CountDownLatch(1);var release = new CountDownLatch(1); + when(preparation.prepare(eq(request), any())).thenAnswer(i -> { + entered.countDown();assertTrue(release.await(5, TimeUnit.SECONDS)); + assertTrue(i.getArgument(1).getAsBoolean());return prepared; + }); + try (var workers = Executors.newSingleThreadExecutor()) { + var running = workers.submit(() -> workflow.prepare(request, () -> true)); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, () -> workflow.isolate(prepared, "actor", () -> true)); + assertEquals(new ReviewRepairWorkflow.Status("OPEN", 1), workflow.status()); + verifyNoInteractions(isolation); + workflow.close(); + assertEquals(new ReviewRepairWorkflow.Status("DRAINING", 1), workflow.status()); + } finally { release.countDown(); } + assertInstanceOf(IllegalStateException.class, assertThrows(ExecutionException.class, () -> running.get(5, TimeUnit.SECONDS)).getCause()); + } + assertEquals(new ReviewRepairWorkflow.Status("CLOSED", 0), workflow.status()); + } + + @Test void twoSlotsCanRunButAThirdCannotEnter() throws Exception { + var workflow = new ReviewRepairWorkflow(preparation, isolation, 2); + var entered = new CountDownLatch(2);var release = new CountDownLatch(1); + when(isolation.execute(eq(prepared), eq("actor"), any())).thenAnswer(i -> { + entered.countDown();assertTrue(release.await(5, TimeUnit.SECONDS));return new ReviewIsolationExecutor.Result("UNKNOWN", null); + }); + try (var workers = Executors.newFixedThreadPool(2)) { + var first = workers.submit(() -> workflow.isolate(prepared, "actor", () -> true)); + var second = workers.submit(() -> workflow.isolate(prepared, "actor", () -> true)); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, () -> workflow.prepare(request, () -> true)); + assertEquals(2, workflow.status().active()); + } finally { release.countDown(); } + first.get(5, TimeUnit.SECONDS);second.get(5, TimeUnit.SECONDS); + } + assertEquals(0, workflow.status().active()); + } + + @Test void deadlineIsMonotonicAndExpiredWorkReleasesAdmission() { + var ticks = new AtomicLong(Long.MAX_VALUE - 500000000L); + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1, 1000, ticks::get); + when(isolation.execute(eq(prepared), eq("actor"), any())).thenAnswer(i -> { + assertTrue(i.getArgument(2).getAsBoolean()); + ticks.addAndGet(1000000000L); + i.getArgument(2).getAsBoolean();return null; + }); + assertThrows(IllegalStateException.class, () -> workflow.isolate(prepared, "actor", () -> true)); + assertEquals(new ReviewRepairWorkflow.Status("OPEN", 0), workflow.status()); + } + + @Test void permissionCheckCannotConsumeDeadlineAndThenAuthorizeWork() { + var ticks = new AtomicLong();var calls = new AtomicLong(); + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1, 1000, ticks::get); + when(isolation.execute(eq(prepared), eq("actor"), any())).thenAnswer(i -> { + i.getArgument(2).getAsBoolean();return null; + }); + assertThrows(IllegalStateException.class, () -> workflow.isolate(prepared, "actor", () -> { + if (calls.incrementAndGet() == 2) ticks.addAndGet(1000000000L);return true; + })); + assertEquals(0, workflow.status().active()); + } + + @Test void interruptionBeforeAdmissionDoesNotClearInterruptOrTouchStorage() throws Exception { + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1); + try (var worker = Executors.newSingleThreadExecutor()) { + worker.submit(() -> { + Thread.currentThread().interrupt(); + try { assertThrows(IllegalStateException.class, () -> workflow.isolate(prepared, "actor", () -> true));assertTrue(Thread.currentThread().isInterrupted()); } + finally { Thread.interrupted(); } + }).get(5, TimeUnit.SECONDS); + } + verifyNoInteractions(preparation, isolation); + } + + @Test void committedReceiptIsNotDiscardedByConcurrentShutdown() { + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1); + var committed = new ReviewIsolationExecutor.Result("APPLIED", "b".repeat(64)); + when(isolation.execute(eq(prepared), eq("actor"), any())).thenAnswer(i -> { workflow.close();return committed; }); + assertEquals(committed, workflow.isolate(prepared, "actor", () -> true)); + assertEquals(new ReviewRepairWorkflow.Status("CLOSED", 0), workflow.status()); + } + + @Test void exceptionsReleaseSlotsAndLimitsAreValidated() { + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1); + when(isolation.execute(eq(prepared), eq("actor"), any())).thenThrow(new IllegalStateException("storage unavailable")); + assertThrows(IllegalStateException.class, () -> workflow.isolate(prepared, "actor", () -> true)); + assertThrows(IllegalStateException.class, () -> workflow.isolate(prepared, "actor", () -> true)); + verify(isolation, times(2)).execute(eq(prepared), eq("actor"), any());assertEquals(0, workflow.status().active()); + assertThrows(IllegalArgumentException.class, () -> new ReviewRepairWorkflow(preparation, isolation, 3)); + assertThrows(IllegalArgumentException.class, () -> new ReviewRepairWorkflow(preparation, isolation, 1, 999, System::nanoTime)); + } + @Test void overdueWorkRemainsCountedUntilItActuallyReturns() throws Exception { + var ticks = new AtomicLong();var entered = new CountDownLatch(1);var release = new CountDownLatch(1); + var workflow = new ReviewRepairWorkflow(preparation, isolation, 1, 1000, ticks::get); + when(isolation.execute(eq(prepared), eq("actor"), any())).thenAnswer(i -> { + entered.countDown();assertTrue(release.await(5, TimeUnit.SECONDS));return new ReviewIsolationExecutor.Result("UNKNOWN", null); + }); + try (var worker = Executors.newSingleThreadExecutor()) { + var running = worker.submit(() -> workflow.isolate(prepared, "actor", () -> true)); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS));ticks.set(1000000000L); + assertEquals(new ReviewRepairWorkflow.Status("OPEN", 1, 1), workflow.status()); + assertThrows(IllegalStateException.class, () -> workflow.prepare(request, () -> true)); + workflow.close();assertEquals(new ReviewRepairWorkflow.Status("DRAINING", 1, 1), workflow.status()); + } finally { release.countDown(); } + running.get(5, TimeUnit.SECONDS); + } + assertEquals(new ReviewRepairWorkflow.Status("CLOSED", 0, 0), workflow.status()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewSnapshotArchiveTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewSnapshotArchiveTest.java new file mode 100644 index 000000000..969cf186e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewSnapshotArchiveTest.java @@ -0,0 +1,104 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import com.mongodb.*; +import org.bson.Document; +import org.bson.types.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewSnapshotArchiveTest { + MongoClient client;MongoTemplate mongo;String database;ReviewSnapshotArchive archive; + byte[] key=new byte[32]; + @BeforeEach void setup() { + Arrays.fill(key,(byte)19);String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");database="warden_snapshot_"+UUID.randomUUID().toString().replace("-","");mongo=new MongoTemplate(client,database); + archive=new ReviewSnapshotArchive(mongo,"one",Map.of("one",key)); + } + @AfterEach void cleanup(){mongo.getDb().drop();client.close();} + ReviewSnapshotArchive.Snapshot snapshot(Object project,int size) { + byte[] bytes=new byte[size];new Random(17).nextBytes(bytes); + return new ReviewSnapshotArchive.Snapshot(UUID.randomUUID().toString(),project,3,"operator",ReviewSnapshotArchive.Action.ISOLATE_REVIEW,1000,2000,bytes); + } + @Test void boundedChunksRetainExactOpaqueBytesAndBsonIdentityAcrossRestart() { + String id="abcdefabcdefabcdefabcdef"; + for(Object project:List.of(id,new ObjectId(id))) { + var source=snapshot(project,ReviewSnapshotArchive.CHUNK_BYTES+17);var retained=archive.retain(source); + assertArrayEquals(source.versionBytes(),retained.versionBytes());assertEquals(project,retained.projectId()); + var restarted=new ReviewSnapshotArchive(mongo,"one",Map.of("one",key));assertArrayEquals(source.versionBytes(),restarted.load(source.id()).versionBytes()); + assertArrayEquals(source.versionBytes(),restarted.retain(source).versionBytes()); + } + assertEquals(2,mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments());assertEquals(4,mongo.getCollection(ReviewSnapshotArchive.CHUNKS).countDocuments()); + } + @ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(ints={5,262144,16777216}) + void byteBoundariesRoundTripWithoutTruncation(int size) { + var source=snapshot("project",size);assertArrayEquals(source.versionBytes(),archive.retain(source).versionBytes()); + assertEquals((size+ReviewSnapshotArchive.CHUNK_BYTES-1)/ReviewSnapshotArchive.CHUNK_BYTES,mongo.getCollection(ReviewSnapshotArchive.CHUNKS).countDocuments()); + } + @Test void originalBsonIncludingUnknownFieldsAndMalformedTypedStateIsPreserved() { + var original=new Document("_id","v").append("scanResult","malformed typed state").append("unknown",new Document("date",new Date(123)) + .append("binary",new Binary((byte)0x80,new byte[]{1,2,3})).append("decimal",new Decimal128(new java.math.BigDecimal("123.4500")))); + var encoded=new org.bson.RawBsonDocument(original,new org.bson.codecs.DocumentCodec()).getByteBuffer().asNIO();byte[] bytes=new byte[encoded.remaining()];encoded.get(bytes); + var source=new ReviewSnapshotArchive.Snapshot(UUID.randomUUID().toString(),new ObjectId(),0,"operator",ReviewSnapshotArchive.Action.REPLACE_REVIEW,1000,2000,bytes); + var restored=archive.retain(source);assertArrayEquals(bytes,restored.versionBytes()); + var decoded=new org.bson.RawBsonDocument(restored.versionBytes()).decode(new org.bson.codecs.DocumentCodec());assertEquals(original,decoded); + } + @ParameterizedTest @ValueSource(strings={"actorId","projectId","versionIndex","action","expiresAt","sha256","keyId","extra"}) + void changedMetadataCannotAuthenticate(String field) { + var source=snapshot("project",17);archive.retain(source); + Object value=switch(field){case "versionIndex"->4;case "expiresAt"->3000L;case "action"->"REPLACE_REVIEW";case "projectId"->new ObjectId();default->"changed";}; + mongo.getCollection(ReviewSnapshotArchive.METADATA).updateOne(new Document("_id",source.id()),new Document("$set",new Document(field,value))); + assertThrows(IllegalStateException.class,()->archive.load(source.id())); + } + @ParameterizedTest @ValueSource(strings={"missing","changed","extra","wrongType"}) + void missingOrAlteredChunksCannotRestoreSnapshot(String mutation) { + var source=snapshot("project",ReviewSnapshotArchive.CHUNK_BYTES+7);archive.retain(source);var chunks=mongo.getCollection(ReviewSnapshotArchive.CHUNKS); + var query=new Document("_id",source.id()+":0"); + switch(mutation) { + case "missing"->chunks.deleteOne(query); + case "changed"->{byte[] bytes=new byte[ReviewSnapshotArchive.CHUNK_BYTES];chunks.updateOne(query,new Document("$set",new Document("payload",new Binary(bytes))));} + case "extra"->chunks.updateOne(query,new Document("$set",new Document("extra",1))); + default->chunks.updateOne(query,new Document("$set",new Document("payload","not bytes"))); + } + assertThrows(IllegalStateException.class,()->archive.load(source.id())); + } + @Test void reusedIdentityCannotReplaceOriginalSnapshot() { + var source=snapshot("project",17);archive.retain(source); + var different=new ReviewSnapshotArchive.Snapshot(source.id(),"other",source.versionIndex(),source.actorId(),source.action(),source.createdAt(),source.expiresAt(),source.versionBytes()); + assertThrows(IllegalStateException.class,()->archive.retain(different));assertEquals("project",archive.load(source.id()).projectId()); + } + @Test void rotationRetainsReadAccessOnlyWithOriginalVerificationKey() { + var source=snapshot("project",17);archive.retain(source);byte[] newer=new byte[32];Arrays.fill(newer,(byte)23); + var rotated=new ReviewSnapshotArchive(mongo,"two",Map.of("one",key,"two",newer));assertArrayEquals(source.versionBytes(),rotated.load(source.id()).versionBytes()); + assertArrayEquals(source.versionBytes(),rotated.retain(source).versionBytes()); + assertThrows(IllegalStateException.class,()->new ReviewSnapshotArchive(mongo,"two",Map.of("two",newer)).load(source.id())); + assertThrows(IllegalStateException.class,()->new ReviewSnapshotArchive(mongo,"one",Map.of("one",newer)).load(source.id())); + } + @ParameterizedTest @ValueSource(strings={ReviewSnapshotArchive.METADATA,ReviewSnapshotArchive.CHUNKS}) + void committedInsertWithLostAcknowledgementRecoversWithoutReplacingBytes(String collection) { + var configured=mongo.getCollection(collection).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY).withWriteConcern(WriteConcern.MAJORITY.withJournal(true)); + var intercepted=spy(configured);var template=spy(mongo);doReturn(intercepted).when(template).getCollection(collection); + doReturn(intercepted).when(intercepted).withReadPreference(any());doReturn(intercepted).when(intercepted).withReadConcern(any());doReturn(intercepted).when(intercepted).withWriteConcern(any()); + doAnswer(i->{i.callRealMethod();throw new MongoException("Lost acknowledgement");}).when(intercepted).insertOne(any(Document.class)); + var source=snapshot("project",ReviewSnapshotArchive.CHUNK_BYTES+3);var result=new ReviewSnapshotArchive(template,"one",Map.of("one",key)).retain(source); + assertArrayEquals(source.versionBytes(),result.versionBytes());assertEquals(1,mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments());assertEquals(2,mongo.getCollection(ReviewSnapshotArchive.CHUNKS).countDocuments()); + } + @Test void conflictingPartialUploadNeverCreatesAuthenticatedMetadata() { + var source=snapshot("project",17);mongo.getCollection(ReviewSnapshotArchive.CHUNKS).insertOne(new Document("_id",source.id()+":0").append("payload",new Binary(new byte[17]))); + assertThrows(IllegalStateException.class,()->archive.retain(source));assertEquals(0,mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments());assertThrows(IllegalStateException.class,()->archive.load(source.id())); + } + @Test void limitsAndDefensiveCopiesPreventMutableSnapshotOrKeyInputs() { + assertThrows(IllegalArgumentException.class,()->snapshot("project",ReviewSnapshotArchive.MAX_BYTES+1)); + assertThrows(IllegalArgumentException.class,()->new ReviewSnapshotArchive(mongo,"missing",Map.of("one",key))); + assertThrows(IllegalArgumentException.class,()->new ReviewSnapshotArchive(mongo,"one",Map.of("one",new byte[3]))); + var source=snapshot("project",17);var before=source.versionBytes();source.versionBytes()[0]++;assertArrayEquals(before,source.versionBytes()); + Arrays.fill(key,(byte)0);archive.retain(source);assertArrayEquals(before,archive.load(source.id()).versionBytes());assertFalse(source.toString().contains("operator")); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursorTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursorTest.java new file mode 100644 index 000000000..fc2893196 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticCursorTest.java @@ -0,0 +1,28 @@ +package net.modtale.service.admin.review; + +import net.modtale.service.security.scan.RemoteReviewDiscovery; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class ReviewStateDiagnosticCursorTest { + @Test void exactBsonIdentityAndPhysicalPositionRoundTrip() { + for(Object id:List.of("a",new ObjectId("abcdefabcdefabcdefabcdef"),"abcdefabcdefabcdefabcdef","\n$position","😀".repeat(128))) + for(boolean after:List.of(false,true)) { + var cursor=new RemoteReviewDiscovery.Cursor(id,after?0:16777216,after); + assertEquals(cursor,ReviewStateDiagnosticCursor.decode(ReviewStateDiagnosticCursor.encode(cursor))); + } + assertNull(ReviewStateDiagnosticCursor.decode(null));assertNull(ReviewStateDiagnosticCursor.encode(null)); + } + @Test void rejectsOtherScopesAndNoncanonicalOrUnboundedTokens() { + for(String token:List.of("","1.s.0.YQ","d1.v.2.SECURITY.s.0.YQ","d2.v.1.s.0.YQ","d1.a.1.s.1.YQ","d1.v.1.s.00.YQ", + "d1.v.1.s.0.YQ=","d1.v.1.s.0.YR","d1.v.1.s.0._w","d1.v.1.s.16777217.YQ","d1.v.1.s.0.","d1.v.1.o.0.YQ","d1.v.1.s.0.YQ.extra","x".repeat(811))) + assertThrows(IllegalArgumentException.class,()->ReviewStateDiagnosticCursor.decode(token),token); + assertThrows(IllegalArgumentException.class,()->ReviewStateDiagnosticCursor.encode(new RemoteReviewDiscovery.Cursor("\ud800",0,false))); + } + @Test void diagnosticScopeCannotBeUsedAsOrdinaryQueueCursor() { + var token=ReviewStateDiagnosticCursor.encode(new RemoteReviewDiscovery.Cursor("a",0,false)); + assertThrows(IllegalArgumentException.class,()->ModerationQueueCursor.decode(token)); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticReaderTest.java b/backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticReaderTest.java new file mode 100644 index 000000000..50c68e087 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/ReviewStateDiagnosticReaderTest.java @@ -0,0 +1,136 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.*; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static net.modtale.service.admin.review.ReviewStateDiagnosticReader.Reason.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class ReviewStateDiagnosticReaderTest { + MongoClient client;MongoDatabase db;MongoCollection projects;ReviewStateDiagnosticReader reader; + String request=UUID.randomUUID().toString(); + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");db=client.getDatabase("warden_diagnostic_"+UUID.randomUUID().toString().replace("-","")); + projects=db.getCollection("projects");reader=new ReviewStateDiagnosticReader(new MongoTemplate(client,db.getName())); + } + @AfterEach void cleanup(){db.drop();client.close();} + Document version(String id) {return new Document("_id",id).append("reviewStatus","PENDING").append("fileUrl","file.zip").append("hash","a".repeat(64)) + .append("scanResult",new Document("status","SCANNING").append("scanState","QUEUED").append("scanAttempt",1).append("scanRequestId",request));} + Document binding(String project,String id) {return new Document("projectId",project).append("versionId",id).append("requestId",request).append("attempt",1) + .append("filePath","file.zip").append("artifactSha256","a".repeat(64)).append("contextSha256","b".repeat(64)).append("policyVersion","warden-3.0.0:"+"c".repeat(64)) + .append("reviewConfigSha256","d".repeat(64)).append("manualRescan",false);} + Document scan(Document version){return version.get("scanResult",Document.class);} + void insert(Object id,List versions){projects.insertOne(new Document("_id",id).append("versions",versions));} + @Test void validWaitingRecordsAreNotDiagnosedAsBroken() { + var queued=version("q");var remote=version("r");scan(remote).put("scanState","REMOTE_REVIEW");scan(remote).put("remoteReview",binding("a","r")); + scan(remote).put("remotePoll",new Document("token",UUID.randomUUID().toString()).append("leaseUntil",new Date(Long.MAX_VALUE)).append("nextPollAt",new Date(Long.MAX_VALUE))); + insert("a",List.of(queued,remote));var before=projects.find().first();var page=reader.page(null,64); + assertEquals(2,page.examined());assertTrue(page.items().isEmpty());assertNotNull(page.next());assertNull(reader.page(page.next(),64).next());assertEquals(before,projects.find().first()); + } + @ParameterizedTest @ValueSource(strings={"missing","false"}) + void legacyDefaultManualModeAgreesWithTypedPersistence(String representation) { + var v=version("v");scan(v).put("scanState","REMOTE_REVIEW");var b=binding("a","v"); + if(!representation.equals("missing"))scan(v).put("manualRescan",false); + scan(v).put("remoteReview",b);insert("a",List.of(v)); + var mongo=new MongoTemplate(client,db.getName()); + var typed=mongo.findById("a",net.modtale.model.project.Project.class).getVersions().getFirst(); + assertFalse(typed.getScanResult().isManualRescan());assertFalse(typed.getScanResult().getRemoteReview().manualRescan()); + assertTrue(reader.page(null,1).items().isEmpty()); + } + @Test void explicitNullManualModeIsNotSilentlyTreatedAsDefault() { + var v=version("v");scan(v).put("manualRescan",null);insert("a",List.of(v)); + assertThrows(RuntimeException.class,()->new MongoTemplate(client,db.getName()).findById("a",net.modtale.model.project.Project.class)); + assertEquals(List.of(INVALID_MANUAL_MODE),reader.page(null,1).items().getFirst().reasons()); + } + @ParameterizedTest @ValueSource(strings={"null","scalar","array","missingKey","badToken","extraKey","badDate"}) + void invalidPollsSkippedByDiscoveryRemainDiagnosable(String mutation) { + var v=version("v");scan(v).put("scanState","REMOTE_REVIEW");scan(v).put("remoteReview",binding("a","v")); + var poll=new Document("token",null).append("leaseUntil",new Date()).append("nextPollAt",new Date());Object stored=poll; + switch(mutation) { + case "null" -> stored=null;case "scalar" -> stored="unexpected";case "array" -> stored=List.of(poll); + case "missingKey" -> poll.remove("token");case "badToken" -> poll.put("token","not-a-uuid"); + case "extraKey" -> poll.put("extra",1);case "badDate" -> poll.put("leaseUntil",0); + } + scan(v).put("remotePoll",stored);insert("a",List.of(v)); + assertEquals(List.of(INVALID_POLL),reader.page(null,1).items().getFirst().reasons()); + } + @ParameterizedTest @ValueSource(strings={"scalar","job","attempt","manual","missingField","longPath","missingManual","nullManual"}) + void bindingCorruptionDoesNotRequireTypedDeserialization(String mutation) { + var v=version("v");scan(v).put("scanState","REMOTE_REVIEW");var binding=binding("a","v");Object stored=binding; + switch(mutation) { + case "scalar" -> stored=42;case "job" -> binding.put("jobId",List.of("unexpected")); + case "attempt" -> binding.put("attempt",1.5);case "manual" -> binding.put("manualRescan","false"); + case "missingManual" -> binding.remove("manualRescan");case "nullManual" -> binding.put("manualRescan",null); + case "missingField" -> binding.remove("contextSha256");case "longPath" -> binding.put("filePath","p".repeat(100000)); + } + scan(v).put("remoteReview",stored);insert("a",List.of(v));assertEquals(List.of(INVALID_BINDING),reader.page(null,1).items().getFirst().reasons()); + if(mutation.equals("missingManual") || mutation.equals("nullManual"))assertThrows(RuntimeException.class,()->new MongoTemplate(client,db.getName()).findById("a",net.modtale.model.project.Project.class)); + } + @Test void missingBindingAndMismatchedIdentityAreDistinct() { + var missing=version("missing");scan(missing).put("scanState","REMOTE_REVIEW"); + var mismatch=version("mismatch");scan(mismatch).put("scanState","REMOTE_REVIEW");scan(mismatch).put("remoteReview",binding("other","mismatch")); + insert("a",List.of(missing,mismatch));var page=reader.page(null,64); + assertEquals(List.of(MISSING_BINDING),page.items().getFirst().reasons());assertEquals(List.of(BINDING_MISMATCH),page.items().get(1).reasons()); + } + @Test void badRequestArtifactAndManualModeAreAllReportedWithoutEchoingContent() { + var v=version("v").append("hash",List.of("bad")).append("fileUrl","secret".repeat(10000)); + scan(v).put("scanRequestId","secret".repeat(10000));scan(v).put("scanAttempt",Long.MAX_VALUE);scan(v).put("manualRescan","invalid");scan(v).put("scanState","unexpected"); + scan(v).put("issues",List.of(new Document("secret","do not expose")));insert("a",List.of(v)); + var item=reader.page(null,1).items().getFirst();assertEquals(List.of(INVALID_REQUEST,INVALID_ARTIFACT,INVALID_MANUAL_MODE,INVALID_SCAN_STATE),item.reasons()); + assertFalse(item.toString().contains("secret"));assertFalse(item.toString().contains("do not expose")); + } + @Test void duplicateAndMalformedVersionIdsKeepPhysicalPositions() { + insert("a",List.of(version("same"),version("same"),version("x".repeat(10000)),version("bad\n"))); + var page=reader.page(null,64);assertEquals(4,page.items().size()); + for(int i=0;i<4;i++)assertEquals(i,page.items().get(i).versionIndex()); + assertEquals(List.of(DUPLICATE_VERSION_ID),page.items().getFirst().reasons());assertNull(page.items().get(2).versionId());assertNull(page.items().get(3).versionId()); + } + @Test void boundedPhysicalPagingCrossesHealthySlotsAndMixedBsonIds() { + String hex="abcdefabcdefabcdefabcdef";var versions=new ArrayList(); + for(int i=0;i<70;i++)versions.add(i%3==0?"malformed sibling":version("v"+i)); + var invalid=version("last");scan(invalid).put("remotePoll",null);versions.add(invalid); + insert(hex,versions);var invalid2=version("other");scan(invalid2).put("scanRequestId","bad");insert(new ObjectId(hex),List.of(invalid2)); + net.modtale.service.security.scan.RemoteReviewDiscovery.Cursor cursor=null;var found=new ArrayList();int examined=0,pages=0; + do {var page=reader.page(cursor,7);assertTrue(page.examined()<=7);found.addAll(page.items());examined+=page.examined();cursor=page.next();assertTrue(++pages<20);}while(cursor!=null); + assertEquals(72,examined);assertEquals(2,found.size());assertEquals(hex,found.getFirst().projectId());assertEquals(70,found.getFirst().versionIndex()); + assertEquals(new ObjectId(hex),found.get(1).projectId());assertEquals(List.of(POLL_WITHOUT_REMOTE_STATE,INVALID_POLL),found.getFirst().reasons()); + } + @Test void deletedRootContinuesAndNonpendingRecordsAreNotDiagnosed() { + var v=version("v");scan(v).put("scanRequestId","bad");insert("a",List.of(v,v));insert("b",List.of(v)); + var page=reader.page(null,1);projects.deleteOne(new Document("_id","a"));assertEquals("b",reader.page(page.next(),1).items().getFirst().projectId()); + projects.deleteMany(new Document());v.put("reviewStatus","APPROVED");insert("a",List.of(v));assertEquals(0,reader.page(null,1).examined()); + } + @Test void httpPaginationTraversesEmptyDiagnosticPageAndMixedIdentityRoots()throws Exception { + String hex="abcdefabcdefabcdefabcdef";var bad=version("bad");scan(bad).put("remotePoll",null); + insert(hex,List.of(version("healthy"),bad));insert(new ObjectId(hex),List.of(bad)); + var controller=new net.modtale.controller.admin.ReviewStateDiagnosticController(reader); + var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build(); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper();String cursor=null;var types=new ArrayList();int calls=0,examined=0; + do { + var request=org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/v1/admin/verification/diagnostics/page").param("limit","1"); + if(cursor!=null)request.param("cursor",cursor); + var response=mvc.perform(request).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.header().string("Cache-Control","no-store")).andReturn().getResponse(); + var body=mapper.readTree(response.getContentAsString());examined+=body.get("examinedSlots").asInt(); + if(calls==0) {assertTrue(body.get("items").isEmpty());assertFalse(body.get("nextCursor").isNull());} + for(var item:body.get("items")) {types.add(item.get("position").get("projectIdType").asText());assertEquals(hex,item.get("position").get("projectId").asText());} + cursor=body.get("nextCursor").isNull()?null:body.get("nextCursor").asText();assertTrue(++calls<=4); + } while(cursor!=null); + assertEquals(4,calls);assertEquals(3,examined);assertEquals(List.of("STRING","OBJECT_ID"),types); + } + @Test void invalidLimitsFailBeforeDatabaseAccess(){assertThrows(IllegalArgumentException.class,()->reader.page(null,0));assertThrows(IllegalArgumentException.class,()->reader.page(null,65));} + @Test void malformedOriginIsDiagnosedWithoutExposingItsPayload() { + var v=version("v");scan(v).put("scanState","REMOTE_REVIEW");scan(v).put("remoteReview",binding("a","v").append("origin",new Document("deploymentId","private".repeat(100000)).append("callerScope",List.of("bad")))); + insert("a",List.of(v));var before=projects.find().first();var page=reader.page(null,1); + assertTrue(page.items().getFirst().reasons().contains(INVALID_BINDING));assertFalse(page.toString().contains("private"));assertEquals(before,projects.find().first()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/VersionReviewPersistenceIntegrationTest.java b/backend/src/test/java/net/modtale/service/admin/review/VersionReviewPersistenceIntegrationTest.java new file mode 100644 index 000000000..c0b2aa4ee --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/VersionReviewPersistenceIntegrationTest.java @@ -0,0 +1,496 @@ +package net.modtale.service.admin.review; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import net.modtale.config.db.MongoConfig; +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ScanEvidenceFixtures; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.*; +import org.springframework.data.mongodb.core.convert.*; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.core.query.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class VersionReviewPersistenceIntegrationTest { + private net.modtale.model.project.RemoteReviewBinding prepareRemote() { + var scan=new ScanResult();scan.setStatus(ScanStatus.SCANNING);scan.setScanState("SCANNING");scan.setScanAttempt(1); + scan.setScanRequestId(UUID.randomUUID().toString());version.setScanResult(scan);version.setFileUrl("original.zip"); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0",version),Project.class); + return new RemoteReviewBinding(id,version.getId(),scan.getScanRequestId(),1,version.getFileUrl(),version.getHash(), + net.modtale.service.security.scan.ArtifactReviewContext.automaticallyReviewableFingerprint(version), + "warden-3.0.0:"+"a".repeat(64),"b".repeat(64),null,false,new net.modtale.model.project.RemoteReviewOrigin("11111111-1111-1111-1111-111111111111","e".repeat(64))); + } + @Test void remoteBindingSurvivesRestartAndAttachesOnlyOneJob() { + var binding=prepareRemote();var remote=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo); + assertTrue(remote.bind(version,binding));assertTrue(remote.bind(version,binding)); + var restored=mongo.findById(id,Project.class).getVersions().getFirst();assertEquals(binding,restored.getScanResult().getRemoteReview()); + var restarted=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo);String job=UUID.randomUUID().toString(); + assertTrue(restarted.attachJob(restored,binding,job));assertTrue(restarted.attachJob(restored,binding,job)); + assertFalse(restarted.attachJob(restored,binding,UUID.randomUUID().toString()));assertFalse(restarted.bind(restored,binding)); + assertEquals(binding.withJobId(job),mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getRemoteReview()); + } + @Test void changedContextCannotBindOrAttachRemoteJob() { + var binding=prepareRemote();var remote=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.manifestVersion","changed"),Project.class); + assertFalse(remote.bind(version,binding)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.manifestVersion",version.getManifestVersion()),Project.class); + assertTrue(remote.bind(version,binding)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.hash","c".repeat(64)),Project.class); + assertFalse(remote.attachJob(version,binding,UUID.randomUUID().toString())); + } + @Test void newRequestAndCompetingConfigurationCannotReplaceBinding() { + var binding=prepareRemote();var remote=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo); + assertTrue(remote.bind(version,binding)); + var other=new RemoteReviewBinding(binding.projectId(),binding.versionId(),binding.requestId(),binding.attempt(),binding.filePath(), + binding.artifactSha256(),binding.contextSha256(),binding.policyVersion(),"c".repeat(64),null,false,new net.modtale.model.project.RemoteReviewOrigin("11111111-1111-1111-1111-111111111111","e".repeat(64))); + assertFalse(remote.bind(version,other)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.scanResult.scanRequestId",UUID.randomUUID().toString()),Project.class); + assertFalse(remote.attachJob(version,binding,UUID.randomUUID().toString()));assertFalse(remote.bind(version,binding)); + } + @Test void legacyRecoveryCannotReplaceRemoteReviewEvenWithOldSnapshot() { + var binding=prepareRemote();var observed=version.getScanResult();var remote=new net.modtale.service.security.scan.RemoteReviewPersistence(mongo); + assertTrue(remote.bind(version,binding)); + var scans=new net.modtale.service.security.scan.ScanPersistenceService(mongo, + org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class),org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class)); + assertFalse(scans.queueRetryAttempt(id,version.getId(),1,new ScanResult(),observed,1)); + assertFalse(scans.updateTimedOutScan(id,version.getId(),new ScanResult(),1,observed,1)); + var restored=mongo.findById(id,Project.class).getVersions().getFirst(); + assertFalse(scans.queueRetryAttempt(id,version.getId(),1,new ScanResult(),restored.getScanResult(),1)); + assertEquals(binding,restored.getScanResult().getRemoteReview()); + } + private net.modtale.service.security.scan.RemoteReviewPollStore pollStore() { + return new net.modtale.service.security.scan.RemoteReviewPollStore(mongo); + } + private RemoteReviewBinding boundRemote() { + var binding=prepareRemote();assertTrue(new net.modtale.service.security.scan.RemoteReviewPersistence(mongo).bind(version,binding));return binding; + } + @Test void onlyOneConcurrentPollerOwnsTheRetainedRequest()throws Exception { + var binding=boundRemote();var start=new java.util.concurrent.CountDownLatch(1); + try(var executor=java.util.concurrent.Executors.newFixedThreadPool(2)) { + var a=executor.submit(()->{start.await();return pollStore().claim(binding,30000);}); + var b=executor.submit(()->{start.await();return pollStore().claim(binding,30000);});start.countDown(); + var first=a.get();var second=b.get();assertTrue((first==null)!=(second==null)); + assertTrue(pollStore().isCurrent(first==null?second:first));assertNull(pollStore().claim(binding,30000)); + } + } + @Test void pollLeaseTakeoverFencesExpiredOwnerAndPreservesUnrelatedFields() { + var binding=boundRemote();mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update() + .set("versions.0.unmodeledField","keep").set("versions.1.rejectionReason","sibling").set("title","keep title"),Project.class); + var old=pollStore().claim(binding,30000);assertNotNull(old); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.scanResult.remotePoll.leaseUntil",new Date(0)),Project.class); + assertFalse(pollStore().isCurrent(old));assertFalse(pollStore().release(old,100));assertNull(pollStore().attachJob(old,UUID.randomUUID().toString())); + var next=pollStore().claim(binding,30000);assertNotNull(next);assertNotEquals(old.token(),next.token());assertFalse(pollStore().release(old,100)); + assertTrue(pollStore().isCurrent(next));var project=mongo.findById(id,Project.class);assertEquals("keep title",project.getTitle());assertEquals("sibling",project.getVersions().get(1).getRejectionReason()); + assertNotNull(project.getVersions().getFirst().getScanResult().getRemotePoll()); + assertEquals("keep",mongo.getCollection("projects").find().first().getList("versions",Document.class).getFirst().getString("unmodeledField")); + } + @Test void completedPollPersistsDatabaseDelayAndCannotBeReused() { + var binding=boundRemote();var before=client.getDatabase(database).runCommand(new Document("hello",1)).getDate("localTime"); + var owner=pollStore().claim(binding,30000);assertNotNull(owner); + var after=client.getDatabase(database).runCommand(new Document("hello",1)).getDate("localTime"); + var acquired=mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getRemotePoll(); + assertTrue(acquired.leaseUntil().getTime()>=before.getTime()+30000);assertTrue(acquired.leaseUntil().getTime()<=after.getTime()+30000); + assertTrue(pollStore().release(owner,30000));assertFalse(pollStore().release(owner,100));assertFalse(pollStore().isCurrent(owner));assertNull(pollStore().claim(binding,30000)); + var state=mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getRemotePoll();assertNull(state.token());assertEquals(new Date(0),state.leaseUntil()); + assertTrue(state.nextPollAt().getTime()>System.currentTimeMillis()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.scanResult.remotePoll.nextPollAt",new Date(0)),Project.class); + assertNotNull(pollStore().claim(binding,30000)); + } + @Test void onlyLivePollCanAttachJobAndMustUseUpdatedBindingAfterward() { + var binding=boundRemote();var owner=pollStore().claim(binding,30000);String job=UUID.randomUUID().toString(); + assertFalse(new net.modtale.service.security.scan.RemoteReviewPersistence(mongo).attachJob(version,binding,job)); + var attached=pollStore().attachJob(owner,job);assertNotNull(attached);assertEquals(binding.withJobId(job),attached.binding()); + assertFalse(pollStore().isCurrent(owner));assertTrue(pollStore().isCurrent(attached));assertNotNull(pollStore().attachJob(attached,job)); + assertThrows(IllegalArgumentException.class,()->pollStore().attachJob(attached,UUID.randomUUID().toString()));assertTrue(pollStore().release(attached,100)); + } + @Test void contextAndRequestChangesInvalidatePollOwnership() { + var binding=boundRemote();var owner=pollStore().claim(binding,30000); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.manifestVersion","new runtime"),Project.class); + assertFalse(pollStore().isCurrent(owner));assertFalse(pollStore().release(owner,100));assertNull(pollStore().claim(binding,30000)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.manifestVersion",version.getManifestVersion()) + .set("versions.0.scanResult.scanRequestId",UUID.randomUUID().toString()),Project.class); + assertFalse(pollStore().isCurrent(owner));assertNull(pollStore().attachJob(owner,UUID.randomUUID().toString())); + } + @Test void malformedPollAndDuplicateVersionIdentityCannotBeClaimed() { + var binding=boundRemote();mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update() + .set("versions.0.scanResult.remotePoll",new Document("leaseUntil","bad")),Project.class);assertNull(pollStore().claim(binding,30000)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().unset("versions.0.scanResult.remotePoll") + .set("versions.1._id",version.getId()),Project.class);assertNull(pollStore().claim(binding,30000)); + } + @Test void pollIntervalsAreBoundedBeforeDatabaseWork() { + var binding=boundRemote();assertThrows(IllegalArgumentException.class,()->pollStore().claim(binding,999));assertThrows(IllegalArgumentException.class,()->pollStore().claim(binding,120001)); + var owner=pollStore().claim(binding,30000);assertThrows(IllegalArgumentException.class,()->pollStore().release(owner,99));assertThrows(IllegalArgumentException.class,()->pollStore().release(owner,3600001)); + } + @Test void staleRecoveryCannotReplaceAnAttemptThatStartedAfterItsSnapshot() { + var scan = new ScanResult(); scan.setStatus(ScanStatus.SCANNING); scan.setScanState("QUEUED"); + scan.setScanAttempt(1); scan.setScanTimestamp(System.currentTimeMillis()-120_000); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update() + .set("versions.0.reviewStatus", ProjectVersion.ReviewStatus.PENDING).set("versions.0.scanResult", scan), Project.class); + var scans = new net.modtale.service.security.scan.ScanPersistenceService(mongo, + org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class), + org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class)); + assertTrue(scans.markAttemptRunning(id,"version-a",1)); + var queued = new ScanResult(); queued.setStatus(ScanStatus.SCANNING); queued.setScanState("QUEUED"); queued.setScanAttempt(2); + assertFalse(scans.queueRetryAttempt(id,"version-a",1,queued,scan,60_000)); + assertFalse(scans.updateTimedOutScan(id,"version-a",new ScanResult(),1,scan,60_000)); + assertEquals("SCANNING",mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getScanState()); + } + @Test void recoveryRequiresDatabaseExpiryAndAllowsOnlyOneStaleRetry() { + var scans = new net.modtale.service.security.scan.ScanPersistenceService(mongo, + org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class), + org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class)); + var observed = new ScanResult(); observed.setStatus(ScanStatus.SCANNING); observed.setScanState("SCANNING"); + observed.setScanAttempt(1); observed.setScanTimestamp(System.currentTimeMillis()+86_400_000); + var query = Query.query(Criteria.where("_id").is(id)); + mongo.updateFirst(query,new Update().set("versions.0.reviewStatus",ProjectVersion.ReviewStatus.PENDING) + .set("versions.0.scanResult",observed).set("futureField","preserve"),Project.class); + var queued = new ScanResult(); queued.setStatus(ScanStatus.SCANNING); queued.setScanState("QUEUED"); queued.setScanAttempt(2); + assertFalse(scans.queueRetryAttempt(id,"version-a",1,queued,observed,60_000)); + assertFalse(scans.updateTimedOutScan(id,"version-a",new ScanResult(),1,observed,60_000)); + observed.setScanTimestamp(System.currentTimeMillis()-120_000); + mongo.updateFirst(query,new Update().set("versions.0.scanResult",observed),Project.class); + // Same state/attempt but a newer timestamp must reject the stale recovery snapshot. + mongo.updateFirst(query,new Update().set("versions.0.scanResult.scanTimestamp",System.currentTimeMillis()),Project.class); + assertFalse(scans.queueRetryAttempt(id,"version-a",1,queued,observed,60_000)); + mongo.updateFirst(query,new Update().set("versions.0.scanResult",observed),Project.class); + assertTrue(scans.queueRetryAttempt(id,"version-a",1,queued,observed,60_000)); + assertFalse(scans.queueRetryAttempt(id,"version-a",1,queued,observed,60_000)); + assertEquals("preserve",mongo.getCollection("projects").find().first().getString("futureField")); + assertEquals(2,mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getScanAttempt()); + } + @Test void expiredTerminalRecoveryIsConditionalAndPreservesOtherVersions() { + var scans = new net.modtale.service.security.scan.ScanPersistenceService(mongo, + org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class), + org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class)); + var observed=new ScanResult(); observed.setStatus(ScanStatus.SCANNING); observed.setScanState("WAITING_RETRY"); + observed.setScanAttempt(3); observed.setScanTimestamp(0); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update() + .set("versions.0.reviewStatus",ProjectVersion.ReviewStatus.PENDING).set("versions.0.scanResult",observed),Project.class); + var failed=new ScanResult(); failed.setStatus(ScanStatus.FAILED); failed.setScanState("FAILED"); + assertTrue(scans.updateTimedOutScan(id,"version-a",failed,3,observed,60_000)); + assertFalse(scans.updateTimedOutScan(id,"version-a",failed,3,observed,60_000)); + assertEquals("version-b",mongo.findById(id,Project.class).getVersions().get(1).getId()); + } + @Test void oldAttemptOneCannotFailAReplacementAttemptOne() throws Exception { + var scans = new net.modtale.service.security.scan.ScanPersistenceService(mongo, + org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class), + org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class)); + var current = new ScanResult(); current.setStatus(ScanStatus.SCANNING); current.setScanState("SCANNING"); current.setScanAttempt(1); current.setScanRequestId("new-request"); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update() + .set("versions.0.reviewStatus",ProjectVersion.ReviewStatus.PENDING).set("versions.0.scanResult",current),Project.class); + var failed = new ScanResult(); failed.setStatus(ScanStatus.FAILED); + assertFalse(scans.updateFailedScan(id,"version-a",failed,1)); + assertEquals(ScanStatus.SCANNING,mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getStatus()); + assertFalse(scans.updateFailedScan(id,"version-a",failed,1,"old-request")); + // Reproduce the pre-fix predicate directly: it only distinguishes attempt/state, so it matches the replacement. + var legacy = scans.getClass().getDeclaredMethod("buildVersionAttemptQuery",String.class,String.class,int.class,String[].class); + legacy.setAccessible(true); + var query = (Query)legacy.invoke(scans,id,"version-a",1,new String[]{"SCANNING","QUEUED","WAITING_RETRY",null}); + assertEquals(1,mongo.updateFirst(query,new Update().set("versions.$.scanResult",failed),Project.class).getModifiedCount()); + + } + @Test void requestIdentityBindsClaimsOutcomesAndRecoveryEvenWhenAttemptAndTimestampMatch() { + var scans = new net.modtale.service.security.scan.ScanPersistenceService(mongo, + org.mockito.Mockito.mock(net.modtale.repository.project.ProjectRepository.class), + org.mockito.Mockito.mock(net.modtale.service.project.query.ProjectService.class)); + var current = new ScanResult(); current.setStatus(ScanStatus.SCANNING); current.setScanState("QUEUED"); + current.setScanAttempt(1); current.setScanRequestId("replacement"); current.setScanTimestamp(System.currentTimeMillis()-120_000); + var query=Query.query(Criteria.where("_id").is(id)); + mongo.updateFirst(query,new Update().set("versions.0.reviewStatus",ProjectVersion.ReviewStatus.PENDING) + .set("versions.0.scanResult",current),Project.class); + assertEquals("replacement",mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getScanRequestId()); + assertFalse(scans.markAttemptRunning(id,"version-a",1)); + assertFalse(scans.markAttemptRunning(id,"version-a",1,"obsolete")); + current.setScanRequestId("obsolete"); + assertFalse(scans.queueRetryAttempt(id,"version-a",1,new ScanResult(),current,60_000)); + assertFalse(scans.updateTimedOutScan(id,"version-a",new ScanResult(),1,current,60_000)); + assertTrue(scans.markAttemptRunning(id,"version-a",1,"replacement")); + assertFalse(scans.markAttemptRunning(id,"version-a",1,"replacement")); + mongo.updateFirst(query,new Update().set("versions.0.hash","b".repeat(64)),Project.class); + var reviewed=mongo.findById(id,Project.class).getVersions().getFirst(); + var result=ScanEvidenceFixtures.complete(false); + var routing=new net.modtale.service.security.scan.ScanRoutingService.RoutingDecision( + net.modtale.service.security.scan.ScanRoutingService.RoutingAction.REQUIRE_REVIEW,0); + assertFalse(scans.applyScanOutcome(id,"version-a",1,result,routing,reviewed)); + assertFalse(scans.applyScanOutcome(id,"version-a",1,result,routing,reviewed,"obsolete")); + assertTrue(scans.applyScanOutcome(id,"version-a",1,result,routing,reviewed,"replacement")); + assertEquals("replacement",mongo.findById(id,Project.class).getVersions().getFirst().getScanResult().getScanRequestId()); + } + @Test void metadataRepairPreservesVersionsEvidenceAndUnknownFields() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update().set("futureField", "retain"), Project.class); + var repairs = new ProjectReviewPersistence(mongo); + var project = mongo.findById(id, Project.class); + var snapshot = repairs.capture(id, ProjectReviewSnapshot.token(project)); + var originalVersions = snapshot.raw().get("versions"); + assertTrue(repairs.applyMetadataRepair(snapshot, Map.of("title", "Metadata repaired", "links", Map.of("source", "https://example.com")))); + var raw = mongo.getCollection("projects").find().first(); + assertEquals(originalVersions, raw.get("versions")); + assertEquals("retain", raw.getString("futureField")); + assertEquals("Metadata repaired", raw.getString("title")); + assertTrue(net.modtale.service.security.scan.ArtifactClearancePolicy.complete(mongo.findById(id, Project.class).getVersions().getFirst().getScanResult())); + } + @Test void metadataRepairRejectsForgedStateAndConcurrentDecisions() { + var repairs = new ProjectReviewPersistence(mongo); + var project = mongo.findById(id, Project.class); + var snapshot = repairs.capture(id, ProjectReviewSnapshot.token(project)); + assertThrows(ResponseStatusException.class, () -> repairs.applyMetadataRepair(snapshot, Map.of("status", "PUBLISHED"))); + assertEquals("original", mongo.findById(id, Project.class).getTitle()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update() + .set("versions.0.reviewStatus", ProjectVersion.ReviewStatus.REJECTED), Project.class); + assertFalse(repairs.applyMetadataRepair(snapshot, Map.of("title", "Stale title"))); + assertEquals("original", mongo.findById(id, Project.class).getTitle()); + assertEquals(ProjectVersion.ReviewStatus.REJECTED, mongo.findById(id, Project.class).getVersions().getFirst().getReviewStatus()); + } + @Test void conditionalApprovalRetainsFindingEvidenceAfterScanPruningAndReload() { + var loaded = mongo.findById(id, Project.class); + var selected = loaded.getVersions().getFirst(); + selected.setHash(selected.getScanResult().getSecurityEvidence().artifactSha256()); + var issue = new ScanResult.ScanIssue(); issue.setFilePath("Mod.class"); + issue.setType("Network"); issue.setDescription("connect"); issue.setSeverity("LOW"); + selected.getScanResult().setIssues(new ArrayList<>(List.of(issue))); + mongo.save(loaded); + selected = mongo.findById(id, Project.class).getVersions().getFirst(); + var snapshot = persistence.capture(id, selected.getId(), VersionReviewSnapshot.token(selected)); + var classification = new net.modtale.service.security.issue.SecurityIssueClassificationService( + new net.modtale.config.properties.AppSecurityProperties("test", 60, 120, 2, 12, 15, 120, 25, 2)); + new net.modtale.service.security.issue.SecurityIssueApprovalService(classification) + .markIssuesAcceptedForApprovedVersion(selected); + selected.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + assertTrue(persistence.apply(snapshot, selected)); + var restored = mongo.findById(id, Project.class); + var baseline = restored.getVersions().getFirst().getApprovedIssueBaselines().getFirst(); + assertTrue(baseline.getEvidenceIdentity().matches("ie1:[0-9a-f]{64}")); + assertNull(restored.getVersions().getFirst().getScanResult()); + var nextScan = ScanEvidenceFixtures.complete(false); + var nextIssue = new ScanResult.ScanIssue(); nextIssue.setFilePath("Mod.class"); + nextIssue.setType("Network"); nextIssue.setDescription("connect"); nextIssue.setSeverity("LOW"); + nextScan.setIssues(new ArrayList<>(List.of(nextIssue))); + classification.annotateAgainstBaselines(nextScan, classification.collectApprovedIssueBaselines(restored, null)); + assertTrue(nextIssue.isHistoricalFileEvidenceIdentical()); + assertFalse(nextIssue.isResolved()); + assertFalse(net.modtale.service.security.scan.ArtifactClearancePolicy.cleared(nextScan)); + } + @Test void duplicateVersionIdentityIsRejectedBeforeSnapshotCapture() { + var collection=mongo.getCollection("projects");var raw=collection.find().first();var duplicate=raw.getList("versions",Document.class).getFirst(); + collection.updateOne(new Document("_id",raw.get("_id")),new Document("$push",new Document("versions",duplicate))); + assertThrows(ResponseStatusException.class,()->persistence.captureForRescan(id,version.getId(),VersionReviewSnapshot.rescanToken(version))); + assertThrows(ResponseStatusException.class,()->persistence.capture(id,version.getId(),VersionReviewSnapshot.token(version))); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"rescan","decision","finding"}) + void duplicateInsertedAfterSnapshotPreventsEveryVersionDecisionWrite(String action) { + var snapshot=persistence.captureForRescan(id,version.getId(),VersionReviewSnapshot.rescanToken(version)); + var duplicate=new Document(snapshot.version());duplicate.put("versionNumber","concurrent duplicate"); + var collection=mongo.getCollection("projects");collection.updateOne(new Document("_id",snapshot.projectId()),new Document("$push",new Document("versions",duplicate))); + var before=collection.find().first();boolean applied; + switch(action) { + case "rescan" -> applied=persistence.queueRescan(snapshot,queued()); + case "finding" -> applied=persistence.appendFindingReview(snapshot,"new decision"); + default -> {version.setReviewStatus(ProjectVersion.ReviewStatus.REJECTED);applied=persistence.apply(snapshot,version);} + } + assertFalse(applied);assertEquals(before,collection.find().first()); + } + @Test void unrelatedMalformedSiblingDoesNotPreventCapturingUniqueVersion() { + var collection=mongo.getCollection("projects");var raw=collection.find().first(); + collection.updateOne(new Document("_id",raw.get("_id")),new Document("$push",new Document("versions",new Document("$each",List.of( + new Document("_id","broken").append("scanResult","invalid"))).append("$position",0)))); + var snapshot=persistence.captureForRescan(id,version.getId(),VersionReviewSnapshot.rescanToken(version)); + assertTrue(persistence.queueRescan(snapshot,queued())); + } + @Test void caseInsensitiveCollectionCannotHideAChangedSnapshot() { + var collection=mongo.getCollection("projects");var original=collection.find().first();collection.drop(); + mongo.getDb().createCollection("projects",new com.mongodb.client.model.CreateCollectionOptions().collation( + com.mongodb.client.model.Collation.builder().locale("en").collationStrength(com.mongodb.client.model.CollationStrength.SECONDARY).build())); + collection=mongo.getCollection("projects");collection.insertOne(original); + collection.updateOne(new Document("_id",original.get("_id")),new Document("$set",new Document("versions.0.versionNumber","release"))); + var current=mongo.findById(id,Project.class).getVersions().getFirst(); + var snapshot=persistence.captureForRescan(id,current.getId(),VersionReviewSnapshot.rescanToken(current)); + collection.updateOne(new Document("_id",original.get("_id")),new Document("$set",new Document("versions.0.versionNumber","RELEASE"))); + assertFalse(persistence.queueRescan(snapshot,queued())); + } + private ScanResult queued() { + var scan = new ScanResult(); scan.setStatus(ScanStatus.SCANNING); + scan.setScanState("QUEUED"); scan.setScanAttempt(2); return scan; + } + @Test void missingPriorManifestDoesNotPreventRescanning() { + mongo.getCollection(net.modtale.config.db.MongoArtifactManifestStore.COLLECTION).deleteMany(new Document()); + var current = mongo.findById(id, Project.class).getVersions().getFirst(); + var snapshot = persistence.captureForRescan(id, current.getId(), VersionReviewSnapshot.rescanToken(current)); + assertTrue(persistence.queueRescan(snapshot, queued())); + var result = mongo.findById(id, Project.class).getVersions().getFirst(); + assertEquals(ProjectVersion.ReviewStatus.PENDING, result.getReviewStatus()); + assertEquals("QUEUED", result.getScanResult().getScanState()); + assertFalse(net.modtale.service.security.scan.ArtifactClearancePolicy.cleared(result.getScanResult())); + } + @Test void rescanPreservesConcurrentSiblingAndUnknownFieldsAndOnlyOneRequestWins() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update() + .set("versions.0.legacyMarker", "retain"), Project.class); + var snapshot = persistence.captureForRescan(id, version.getId(), VersionReviewSnapshot.rescanToken(version)); + var competing = persistence.captureForRescan(id, version.getId(), VersionReviewSnapshot.rescanToken(version)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update() + .set("title", "concurrent title").set("versions.1.reviewStatus", ProjectVersion.ReviewStatus.REJECTED), Project.class); + assertTrue(persistence.queueRescan(snapshot, queued())); + assertFalse(persistence.queueRescan(competing, queued())); + var saved = mongo.findById(id, Project.class); + assertEquals("concurrent title", saved.getTitle()); + assertEquals(ProjectVersion.ReviewStatus.REJECTED, saved.getVersions().get(1).getReviewStatus()); + assertEquals("QUEUED", saved.getVersions().getFirst().getScanResult().getScanState()); + assertEquals(2, saved.getVersions().getFirst().getScanResult().getScanAttempt()); + assertEquals("retain", mongo.getCollection("projects").find().first().getList("versions", Document.class).getFirst().getString("legacyMarker")); + } + @Test void rescanRejectsChangesToArtifactContextOrReviewDecision() { + var changes = List.of(new Update().set("versions.0.hash", "f".repeat(64)), + new Update().set("versions.0.gameVersions", List.of("changed")), + new Update().set("versions.0.reviewStatus", ProjectVersion.ReviewStatus.APPROVED), + new Update().set("versions.0.reviewStatus", ProjectVersion.ReviewStatus.REJECTED)); + for (var change : changes) { + var current = mongo.findById(id, Project.class).getVersions().getFirst(); + var snapshot = persistence.captureForRescan(id, current.getId(), VersionReviewSnapshot.rescanToken(current)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), change, Project.class); + assertFalse(persistence.queueRescan(snapshot, queued())); + assertThrows(ResponseStatusException.class, () -> persistence.captureForRescan(id, current.getId(), VersionReviewSnapshot.rescanToken(current))); + assertNotEquals("QUEUED", mongo.findById(id, Project.class).getVersions().getFirst().getScanResult().getScanState()); + } + } + private MongoClient client; + private MongoTemplate mongo; + private VersionReviewPersistence persistence; + private String database; + private ProjectVersion version; + private final String id="abcdefabcdefabcdefabcdef"; + @BeforeEach void setup() throws Exception { + database="warden_review_test_"+UUID.randomUUID().toString().replace("-",""); + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27029"); + if(!Set.of("27029","27030").contains(port)) throw new IllegalArgumentException("Unexpected test database port"); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000"); + var factory=new SimpleMongoClientDatabaseFactory(client,database); + var conversions=new MongoConfig().mongoCustomConversions(new net.modtale.config.db.MongoArtifactManifestStore(factory)); + var context=new MongoMappingContext();context.setSimpleTypeHolder(conversions.getSimpleTypeHolder());context.afterPropertiesSet(); + var converter=new MappingMongoConverter(new DefaultDbRefResolver(factory),context); + converter.setCustomConversions(conversions);converter.afterPropertiesSet(); + mongo=new MongoTemplate(factory,converter);persistence=new VersionReviewPersistence(mongo); + var project=new Project();project.setId(id);project.setTitle("original"); + version=new ProjectVersion();version.setId("version-a");version.setVersionNumber("1.0"); + version.setHash("a".repeat(64));version.setScanResult(ScanEvidenceFixtures.complete(true)); + var sibling=new ProjectVersion();sibling.setId("version-b");sibling.setVersionNumber("2.0"); + project.setVersions(List.of(version,sibling));mongo.insert(project); + } + @Test void projectPublicationPreservesUnreviewedVersionsAndUnknownFields() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update() + .set("versions.0.legacyMarker", "preserve").set("unmodeledProjectField", "preserve"), Project.class); + var project = mongo.findById(id, Project.class); + var projectPersistence = new ProjectReviewPersistence(mongo); + var snapshot = projectPersistence.capture(id, ProjectReviewSnapshot.token(project)); + snapshot.project().setStatus(ProjectStatus.PUBLISHED); + snapshot.project().getVersions().getFirst().setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + assertTrue(projectPersistence.apply(snapshot, "version-a")); + var saved = mongo.findById(id, Project.class); + assertEquals(ProjectStatus.PUBLISHED, saved.getStatus()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, saved.getVersions().getFirst().getReviewStatus()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, saved.getVersions().get(1).getReviewStatus()); + var raw = mongo.getCollection("projects").find().first(); + assertEquals("preserve", raw.getString("unmodeledProjectField")); + assertEquals("preserve", raw.getList("versions", Document.class).getFirst().getString("legacyMarker")); + } + @Test void concurrentProjectChangePreventsPublication() { + var project = mongo.findById(id, Project.class); + var projectPersistence = new ProjectReviewPersistence(mongo); + var snapshot = projectPersistence.capture(id, ProjectReviewSnapshot.token(project)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update().set("title", "changed"), Project.class); + snapshot.project().setStatus(ProjectStatus.PUBLISHED); + snapshot.project().getVersions().getFirst().setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + assertFalse(projectPersistence.apply(snapshot, "version-a")); + assertEquals("changed", mongo.findById(id, Project.class).getTitle()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, mongo.findById(id, Project.class).getVersions().getFirst().getReviewStatus()); + } + @Test void changedVersionInvalidatesProjectReviewInBrowser() { + var project = mongo.findById(id, Project.class); + String token = ProjectReviewSnapshot.token(project); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update().set("versions.0.hash", "f".repeat(64)), Project.class); + assertThrows(ResponseStatusException.class, () -> new ProjectReviewPersistence(mongo).capture(id, token)); + } + @Test void referencedManifestsAreVerifiedAndMissingOrCorruptRecordsCannotClear() { + var raw = mongo.getCollection("projects").find().first(); + var evidence = raw.getList("versions", Document.class).getFirst().get("scanResult", Document.class).get("securityEvidence", Document.class); + String reference = evidence.getString("manifestRef"); + assertNotNull(reference); assertFalse(evidence.containsKey("entryHashes")); + assertTrue(net.modtale.service.security.scan.ArtifactClearancePolicy.complete(mongo.findById(id, Project.class).getVersions().getFirst().getScanResult())); + var manifests = mongo.getCollection(net.modtale.config.db.MongoArtifactManifestStore.COLLECTION); + manifests.updateOne(new Document("_id", reference), new Document("$set", new Document("entries", List.of(new Document("path", "substituted").append("sha256", "f".repeat(64)))))); + assertFalse(net.modtale.service.security.scan.ArtifactClearancePolicy.complete(mongo.findById(id, Project.class).getVersions().getFirst().getScanResult())); + manifests.deleteOne(new Document("_id", reference)); + assertFalse(net.modtale.service.security.scan.ArtifactClearancePolicy.complete(mongo.findById(id, Project.class).getVersions().getFirst().getScanResult())); + } + @Test void legacyEmbeddedEvidenceMigratesWithoutChangingTheBrowserReviewToken() { + var scan = ScanEvidenceFixtures.complete(true); + var hashes = new LinkedHashMap(); hashes.put("z/nested.class", "b".repeat(64)); hashes.put("a.$manifest", "c".repeat(64)); + var evidence = scan.getSecurityEvidence(); + var updated = new ScanResult.SecurityEvidence(evidence.policyVersion(), evidence.artifactSha256(), SecurityManifest.identity(hashes), true, true, "COMPLETED", hashes); + var legacy = new net.modtale.config.db.SecurityEvidenceConverters.Write().convert(updated); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)), new Update().set("versions.0.scanResult.securityEvidence", legacy), Project.class); + var before = mongo.findById(id, Project.class); + assertTrue(net.modtale.service.security.scan.ArtifactClearancePolicy.complete(before.getVersions().getFirst().getScanResult())); + String token = VersionReviewSnapshot.token(before.getVersions().getFirst()); + mongo.save(before); + var after = mongo.findById(id, Project.class); + assertEquals(token, VersionReviewSnapshot.token(after.getVersions().getFirst())); + assertEquals(hashes, after.getVersions().getFirst().getScanResult().getSecurityEvidence().entryHashes()); + var raw = mongo.getCollection("projects").find().first().getList("versions", Document.class).getFirst() + .get("scanResult", Document.class).get("securityEvidence", Document.class); + assertTrue(raw.containsKey("manifestRef")); assertFalse(raw.containsKey("entryHashes")); + } + @Test void sixLargeManifestsNoLongerExceedTheProjectDocumentLimit() { + var hashes = new LinkedHashMap(); + for (int i = 0; i < 20_000; i++) hashes.put(String.format("p/%05d/", i) + "a".repeat(30) + ".class", "a".repeat(64)); + String identity = SecurityManifest.identity(hashes); + var project = new Project(); project.setId("large-manifest-fixture"); + var versions = new ArrayList(); + for (int i = 0; i < 6; i++) { + var item = new ProjectVersion(); item.setId("v" + i); + var scan = ScanEvidenceFixtures.complete(true); + var prior = scan.getSecurityEvidence(); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(prior.policyVersion(), prior.artifactSha256(), identity, true, true, "COMPLETED", hashes)); + item.setScanResult(scan); versions.add(item); + } + project.setVersions(versions); mongo.insert(project); + var raw = mongo.getCollection("projects").find(new Document("_id", "large-manifest-fixture")).first(); + assertNotNull(raw); + assertTrue(new org.bson.RawBsonDocument(raw, new org.bson.codecs.DocumentCodec()).getByteBuffer().remaining() < 64_000); + assertEquals(1, mongo.getCollection(net.modtale.config.db.MongoArtifactManifestStore.COLLECTION).countDocuments(new Document("_id", identity))); + var restored = mongo.findById("large-manifest-fixture", Project.class); + for (var item : restored.getVersions()) assertTrue(net.modtale.service.security.scan.ArtifactClearancePolicy.complete(item.getScanResult())); + } + @AfterEach void cleanup() {if(client!=null) {client.getDatabase(database).drop();client.close();}} + @Test void conditionalApprovalPreservesConcurrentSiblingAndProjectChanges() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.legacyMarker","preserve"),Project.class); + var snapshot=persistence.capture(id,version.getId(),VersionReviewSnapshot.token(version)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("title","concurrent title") + .set("versions.1.rejectionReason","independent sibling edit"),Project.class); + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);version.setScanResult(null); + assertTrue(persistence.apply(snapshot,version)); + var result=mongo.findById(id,Project.class); + assertEquals("concurrent title",result.getTitle()); + assertEquals(ProjectVersion.ReviewStatus.APPROVED,result.getVersions().getFirst().getReviewStatus()); + assertEquals("independent sibling edit",result.getVersions().get(1).getRejectionReason()); + var raw=mongo.getCollection("projects").find().first(); + assertEquals("preserve",raw.getList("versions",Document.class).getFirst().getString("legacyMarker")); + } + @Test void artifactReplacementDuringDecisionPreventsApproval() { + var snapshot=persistence.capture(id,version.getId(),VersionReviewSnapshot.token(version)); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.hash","b".repeat(64)),Project.class); + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + assertFalse(persistence.apply(snapshot,version)); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(id,Project.class).getVersions().getFirst().getReviewStatus()); + } + @Test void staleBrowserEvidenceIsRejectedBeforeCapturingDecision() { + String token=VersionReviewSnapshot.token(version); + mongo.updateFirst(Query.query(Criteria.where("_id").is(id)),new Update().set("versions.0.scanResult.scanTimestamp",1234L),Project.class); + assertEquals(409,assertThrows(ResponseStatusException.class,()->persistence.capture(id,version.getId(),token)).getStatusCode().value()); + } +} diff --git a/backend/src/test/java/net/modtale/service/admin/review/VersionReviewSnapshotTest.java b/backend/src/test/java/net/modtale/service/admin/review/VersionReviewSnapshotTest.java new file mode 100644 index 000000000..4f0856e1a --- /dev/null +++ b/backend/src/test/java/net/modtale/service/admin/review/VersionReviewSnapshotTest.java @@ -0,0 +1,18 @@ +package net.modtale.service.admin.review; + +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ScanEvidenceFixtures; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class VersionReviewSnapshotTest { + @Test void tokenTracksArtifactMetadataAndScanButIgnoresDownloadCounters() { + var version=new ProjectVersion();version.setId("version");version.setHash("a".repeat(64)); + version.setScanResult(ScanEvidenceFixtures.complete(true)); + String original=VersionReviewSnapshot.token(version); + version.setDownloadCount(50);assertEquals(original,VersionReviewSnapshot.token(version)); + version.setOverrideFileUrl("other.zip");assertNotEquals(original,VersionReviewSnapshot.token(version)); + version.setOverrideFileUrl(null);version.getScanResult().setScanAttempt(2);assertNotEquals(original,VersionReviewSnapshot.token(version)); + version.getScanResult().setScanAttempt(0);version.setHash("b".repeat(64));assertNotEquals(original,VersionReviewSnapshot.token(version)); + } +} diff --git a/backend/src/test/java/net/modtale/service/auth/AuthenticationMutationServiceTest.java b/backend/src/test/java/net/modtale/service/auth/AuthenticationMutationServiceTest.java index d8dd48e2c..42888943b 100644 --- a/backend/src/test/java/net/modtale/service/auth/AuthenticationMutationServiceTest.java +++ b/backend/src/test/java/net/modtale/service/auth/AuthenticationMutationServiceTest.java @@ -153,28 +153,6 @@ void removePasswordRejectsAccountsWithoutLinkedOAuth() { verify(userRepository, never()).save(user); } - @Test - void enableMfaMarksTheAccountEnabled() { - User user = user("user-1", "Ada", "ada@example.com"); - user.setMfaSecret("someSecret"); - when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); - - service.enableMfa("user-1"); - - assertTrue(user.isMfaEnabled()); - verify(userRepository).save(user); - } - - @Test - void enableMfaWithoutSecretThrows() { - User user = user("user-1", "Ada", "ada@example.com"); - when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); - - assertThrows(net.modtale.exception.InvalidAuthenticationRequestException.class, () -> service.enableMfa("user-1")); - assertFalse(user.isMfaEnabled()); - verify(userRepository, org.mockito.Mockito.never()).save(user); - } - private static User user(String id, String username, String email) { User user = new User(); user.setId(id); diff --git a/backend/src/test/java/net/modtale/service/auth/AuthenticationServiceTest.java b/backend/src/test/java/net/modtale/service/auth/AuthenticationServiceTest.java index 42dd641cd..e6c3a6c45 100644 --- a/backend/src/test/java/net/modtale/service/auth/AuthenticationServiceTest.java +++ b/backend/src/test/java/net/modtale/service/auth/AuthenticationServiceTest.java @@ -148,7 +148,7 @@ void authenticateRejectsPasswordlessAccountsBeforePasswordValidation() { } @Test - void authenticateAutoDisablesMfaWhenSecretIsMissing() { + void authenticatePreservesMfaWhenSecretIsMissing() { User user = user("user-1", "Ada", "ada@example.com"); user.setPassword("encoded"); user.setMfaEnabled(true); @@ -157,12 +157,11 @@ void authenticateAutoDisablesMfaWhenSecretIsMissing() { when(userRepository.findByUsernameIgnoreCase("Ada")).thenReturn(Optional.of(user)); when(bannedEmailRepository.existsByEmailIgnoreCase("ada@example.com")).thenReturn(false); when(passwordEncoder.matches("secret123", "encoded")).thenReturn(true); - when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); User authenticated = authenticationService.authenticate("Ada", "secret123"); - assertFalse(authenticated.isMfaEnabled()); - verify(userRepository).save(user); + assertTrue(authenticated.isMfaEnabled()); + verify(userRepository, never()).save(any(User.class)); } @Test diff --git a/backend/src/test/java/net/modtale/service/auth/MfaEnrollmentIntegrationTest.java b/backend/src/test/java/net/modtale/service/auth/MfaEnrollmentIntegrationTest.java new file mode 100644 index 000000000..e5052b23e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/auth/MfaEnrollmentIntegrationTest.java @@ -0,0 +1,81 @@ +package net.modtale.service.auth; + +import com.mongodb.client.*; +import java.util.*; +import net.modtale.model.user.User; +import net.modtale.exception.InvalidAuthenticationRequestException; +import net.modtale.exception.ResourceNotFoundException; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST", matches="true") +class MfaEnrollmentIntegrationTest { + private MongoClient client; private MongoTemplate mongo; private String database; + private TwoFactorService factors; private MfaEnrollmentService service; + @BeforeEach void setup(){ + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030"); + if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException("Unexpected test port"); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000"); + database="warden_mfa_test_"+UUID.randomUUID().toString().replace("-", ""); + mongo=new MongoTemplate(client,database);factors=mock(TwoFactorService.class);service=new MfaEnrollmentService(mongo,factors); + var user=new User();user.setId("user");user.setLikedModIds(List.of("favorite"));mongo.insert(user); + change(new Document("unknownAuthority","retained")); + when(factors.generateNewSecret()).thenReturn("fixture-secret"); + when(factors.isOtpValid("fixture-secret","123456")).thenReturn(true); + } + @AfterEach void cleanup(){if(client!=null){client.getDatabase(database).drop();client.close();}} + private Document stored(){return mongo.getCollection("users").find(new Document("_id","user")).first();} + private void change(Document fields){mongo.getCollection("users").updateOne(new Document("_id","user"),new Document("$set",fields));} + @Test void enrollmentOnlyChangesFactorFieldsAndCannotReplaceEnabledFactor(){ + var before=stored();assertEquals("fixture-secret",service.begin("user"));service.verify("user","123456"); + var after=stored();assertEquals(true,after.remove("mfaEnabled"));assertEquals("fixture-secret",after.remove("mfaSecret")); + before.remove("mfaEnabled");before.remove("mfaSecret");assertEquals(before,after); + assertThrows(InvalidAuthenticationRequestException.class,()->service.begin("user")); + assertThrows(InvalidAuthenticationRequestException.class,()->service.verify("user","123456")); + assertEquals("fixture-secret",stored().getString("mfaSecret")); + } + @Test void verificationCannotEnableAReplacedSecret(){ + service.begin("user"); + when(factors.isOtpValid("fixture-secret","123456")).thenAnswer(invocation->{change(new Document("mfaSecret","replacement"));return true;}); + assertEquals(409,assertThrows(ResponseStatusException.class,()->service.verify("user","123456")).getStatusCode().value()); + assertEquals("replacement",stored().getString("mfaSecret"));assertEquals(false,stored().get("mfaEnabled")); + } + @Test void verificationCannotUndoConcurrentAuthorityDeletionOrFavorites(){ + for(var mutation:List.of(new Document("roles",List.of("MODERATOR")),new Document("deletedAt",new Date()), + new Document("likedModIds",List.of("new")),new Document("mfaEnabled",true))){ + var initial=stored();service.begin("user"); + doAnswer(invocation->{change(mutation);return true;}).when(factors).isOtpValid("fixture-secret","123456"); + assertThrows(ResponseStatusException.class,()->service.verify("user","123456")); + mutation.forEach((key,value)->assertEquals(value,stored().get(key))); + assertEquals(mutation.getOrDefault("mfaEnabled",false),stored().get("mfaEnabled")); + mongo.getCollection("users").replaceOne(new Document("_id","user"),initial); + } + } + @Test void delayedSetupCannotReplaceAConcurrentlyEnabledFactor(){ + when(factors.generateNewSecret()).thenAnswer(invocation->{change(new Document("mfaEnabled",true).append("mfaSecret","enabled-secret"));return "new-secret";}); + assertThrows(ResponseStatusException.class,()->service.begin("user")); + assertEquals(true,stored().get("mfaEnabled"));assertEquals("enabled-secret",stored().get("mfaSecret")); + } + @Test void invalidCodeMissingSecretAndMalformedStateFailClosed(){ + assertThrows(InvalidAuthenticationRequestException.class,()->service.verify("user","123456")); + service.begin("user");assertThrows(InvalidAuthenticationRequestException.class,()->service.verify("user","wrong")); + assertEquals(false,stored().get("mfaEnabled")); + change(new Document("mfaEnabled","false")); + assertThrows(InvalidAuthenticationRequestException.class,()->service.begin("user")); + assertThrows(InvalidAuthenticationRequestException.class,()->service.verify("user","123456")); + } + @Test void deletedMissingAndAmbiguousIdentitiesCannotEnroll(){ + assertThrows(ResourceNotFoundException.class,()->service.begin("missing")); + change(new Document("deletedAt",new Date()));assertThrows(ResourceNotFoundException.class,()->service.begin("user")); + var id=new ObjectId();mongo.getCollection("users").insertOne(new Document("_id",id)); + assertEquals("fixture-secret",service.begin(id.toHexString()));service.verify(id.toHexString(),"123456"); + mongo.getCollection("users").insertOne(new Document("_id",id.toHexString())); + assertThrows(ResourceNotFoundException.class,()->service.begin(id.toHexString())); + } +} diff --git a/backend/src/test/java/net/modtale/service/auth/TwoFactorServiceTest.java b/backend/src/test/java/net/modtale/service/auth/TwoFactorServiceTest.java new file mode 100644 index 000000000..03a47556b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/auth/TwoFactorServiceTest.java @@ -0,0 +1,23 @@ +package net.modtale.service.auth; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class TwoFactorServiceTest { + @Test void currentAuthenticatorCodeStillVerifies() throws Exception { + String secret = "JBSWY3DPEHPK3PXP"; + String code = new dev.samstevens.totp.code.DefaultCodeGenerator() + .generate(secret, System.currentTimeMillis() / 30000); + assertTrue(new TwoFactorService().isOtpValid(secret, code)); + } + + @Test void missingSecretsAndMalformedCodesCannotAuthenticate() { + var service = new TwoFactorService(); + for (String secret : new String[]{null, "", " "}) { + assertFalse(service.isOtpValid(secret, "000000")); + } + for (String code : new String[]{null, "", "12345", "1234567", "abcdef", "123456", "123456\n"}) { + assertFalse(service.isOtpValid("JBSWY3DPEHPK3PXP", code)); + } + } +} diff --git a/backend/src/test/java/net/modtale/service/communication/NotificationServiceTest.java b/backend/src/test/java/net/modtale/service/communication/NotificationServiceTest.java index ddd788c05..255a55e6c 100644 --- a/backend/src/test/java/net/modtale/service/communication/NotificationServiceTest.java +++ b/backend/src/test/java/net/modtale/service/communication/NotificationServiceTest.java @@ -111,7 +111,7 @@ void deleteNotificationVoidsOrganizationInvitesBeforeRemovingTheNotification() { URI.create("/dashboard/orgs"), "https://cdn.example/org.png", NotificationType.ORG_INVITE, - Map.of("orgId", "org-1") + Map.of("orgId", "org-1", "requestId", "invite-1") ); notification.setId("notification-1"); diff --git a/backend/src/test/java/net/modtale/service/project/lifecycle/LifecycleServiceTest.java b/backend/src/test/java/net/modtale/service/project/lifecycle/LifecycleServiceTest.java index d28bdc015..31fe3f4dd 100644 --- a/backend/src/test/java/net/modtale/service/project/lifecycle/LifecycleServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/lifecycle/LifecycleServiceTest.java @@ -38,6 +38,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.anyString; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -46,6 +48,7 @@ class LifecycleServiceTest { private LifecycleService lifecycleService; + private net.modtale.service.admin.review.ProjectReviewPersistence reviewPersistence; private ProjectRepository projectRepository; private ProjectService projectService; private ValidationService validationService; @@ -84,6 +87,12 @@ void setUp() { mock(VersionDependencyService.class), mock(ProjectDeletionService.class) ); + reviewPersistence = mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + when(reviewPersistence.capture(anyString(), anyString())).thenAnswer(invocation -> + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), + projectService.getRawProjectById(invocation.getArgument(0)))); + when(reviewPersistence.apply(any(), any())).thenReturn(true); + when(reviewPersistence.submitDraft(any())).thenReturn(true); ProjectDraftWorkflowService projectDraftWorkflowService = new ProjectDraftWorkflowService( projectRepository, projectService, @@ -94,10 +103,10 @@ void setUp() { projectAccessService, projectMutationGuard, versionMutationOrchestrationService, - new AppLimitProperties(10, 5, 10, 5, 5, 5, 20, 10) + new AppLimitProperties(10, 5, 10, 5, 5, 5, 20, 10), + reviewPersistence ); ProjectPublicationService projectPublicationService = new ProjectPublicationService( - projectRepository, projectService, projectNotificationService, webhookService, @@ -105,7 +114,8 @@ void setUp() { mock(ScoringService.class), accessControlService, projectAccessService, - securityIssueAnalysisService + securityIssueAnalysisService, + reviewPersistence ); lifecycleService = new LifecycleService( projectDraftWorkflowService, @@ -125,7 +135,7 @@ void createDraftUsesOrganizationOwnerAndSanitizesSavedFields() { when(userRepository.findById("org-1")).thenReturn(java.util.Optional.of(organization)); when(sanitizationService.sanitizePlainText("Raw Title")).thenReturn("Clean Title"); when(sanitizationService.sanitizePlainText("Raw Description")).thenReturn("Clean Description"); - when(projectRepository.save(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(projectRepository.insert(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); Project draft = lifecycleService.createDraft( "Raw Title", @@ -154,7 +164,7 @@ void createDraftUsesOrganizationOwnerAndSanitizesSavedFields() { @Test void importedDetailsAreSavedTogetherInAnUnpublishedDraft() { User creator = user("user-1", "Creator", User.AccountType.USER, true); - when(projectRepository.save(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(projectRepository.insert(any(Project.class))).thenAnswer(invocation -> invocation.getArgument(0)); String source = "https://www.curseforge.com/hytale/mods/my-mod"; String icon = "https://media.forgecdn.net/avatars/1/icon.png"; @@ -167,7 +177,7 @@ void importedDetailsAreSavedTogetherInAnUnpublishedDraft() { assertEquals(source, draft.getLinks().get("CurseForge")); assertEquals(icon, draft.getImageUrl()); assertTrue(draft.getVersions().isEmpty()); - verify(projectRepository).save(draft); + verify(projectRepository).insert(draft); } @Test @@ -201,7 +211,7 @@ void submitProjectMarksVersionsPendingAndTriggersAdminWebhookWhenNoScanIsRunning verify(validationService).validateSlug("sky-tools"); verify(validationService).validateRepositoryUrl("https://github.com/modtale/sky-tools"); - verify(projectRepository).save(project); + verify(reviewPersistence).submitDraft(any()); verify(projectService).evictProjectCache(project); verify(webhookService).triggerAdminNewProjectWebhook(project); } @@ -221,16 +231,25 @@ void submitProjectQueuesInitialScanForDraftArtifactsBeforeReview() { project.setVersions(new ArrayList<>(List.of(uploadedDraftVersion))); ScanResult queuedScan = new ScanResult(ScanStatus.SCANNING, 0, List.of()); + queuedScan.setScanRequestId("submission-request"); when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_STATUS_SUBMIT")).thenReturn(true); when(scanService.createQueuedScanResult(1, "Initial scan queued.")).thenReturn(queuedScan); + when(reviewPersistence.submitDraft(any())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> lifecycleService.submitProject("project-1", user)); + verify(scanService, never()).enqueueBackgroundScan(anyString(), anyString(), anyString(), anyString(), anyBoolean(), anyInt(), any()); + verify(webhookService, never()).triggerAdminNewProjectWebhook(any()); + project.setStatus(ProjectStatus.DRAFT); uploadedDraftVersion.setScanResult(null); + when(reviewPersistence.submitDraft(any())).thenReturn(true); + org.mockito.Mockito.clearInvocations(reviewPersistence, scanService); + lifecycleService.submitProject("project-1", user); assertEquals(ProjectStatus.PENDING, project.getStatus()); assertEquals(queuedScan, uploadedDraftVersion.getScanResult()); - verify(projectRepository).save(project); + verify(reviewPersistence).submitDraft(any()); verify(projectService).evictProjectCache(project); verify(scanService).enqueueBackgroundScan( "project-1", @@ -238,7 +257,7 @@ void submitProjectQueuesInitialScanForDraftArtifactsBeforeReview() { "/files/data/bundle.zip", "/files/data/bundle.zip", false, - 1 + 1, queuedScan.getScanRequestId() ); verify(webhookService, never()).triggerAdminNewProjectWebhook(project); } @@ -261,12 +280,12 @@ void submitProjectSkipsAdminWebhookWhileAnyVersionIsStillScanning() { lifecycleService.submitProject("project-1", user); - verify(projectRepository).save(project); + verify(reviewPersistence).submitDraft(any()); verify(webhookService, never()).triggerAdminNewProjectWebhook(project); } @Test - void publishProjectApprovesVersionsAndBroadcastsFirstPublication() { + void publishProjectApprovesOnlySelectedVersionAndBroadcastsFirstPublication() { User admin = user("user-1", "ItsNeil17", User.AccountType.USER, true); Project project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.PENDING); project.setCreatedAt(null); @@ -277,13 +296,18 @@ void publishProjectApprovesVersionsAndBroadcastsFirstPublication() { scheduled.setScheduledPublishDate("2026-06-09T12:00:00"); ProjectVersion approved = version("1.1.0"); approved.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); - project.setVersions(new ArrayList<>(List.of(scheduled, approved))); + ProjectVersion uninspected = version("1.2.0"); + uninspected.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + project.setVersions(new ArrayList<>(List.of(scheduled, approved, uninspected))); when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.canApproveProjectReviews(admin)).thenReturn(true); when(projectRepository.save(project)).thenReturn(project); - lifecycleService.publishProject("project-1", admin); + var snapshot = new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), project); + when(reviewPersistence.capture("project-1", "review-token")).thenReturn(snapshot); + when(reviewPersistence.apply(snapshot, scheduled.getId())).thenReturn(true); + lifecycleService.publishProject("project-1", admin, "review-token", scheduled.getId()); assertEquals(ProjectStatus.PUBLISHED, project.getStatus()); assertNotNull(project.getCreatedAt()); @@ -294,7 +318,9 @@ void publishProjectApprovesVersionsAndBroadcastsFirstPublication() { assertNull(scheduled.getScheduledPublishDate()); assertEquals(ProjectVersion.ReviewStatus.APPROVED, approved.getReviewStatus()); - verify(securityIssueAnalysisService).pruneApprovedScanResults(project); + assertEquals(ProjectVersion.ReviewStatus.PENDING, uninspected.getReviewStatus()); + verify(securityIssueAnalysisService).markIssuesAcceptedForApprovedVersion(scheduled); + verify(projectRepository, never()).save(project); verify(projectService).evictProjectCache(project); verify(projectNotificationService).notifyNewProject(project); verify(webhookService).triggerWebhook(project); @@ -302,6 +328,25 @@ void publishProjectApprovesVersionsAndBroadcastsFirstPublication() { verify(trackingService).logNewProject("project-1"); } + @Test + void unboundOrConcurrentPublicationDoesNotBroadcastOrSave() { + User admin = user("admin", "Moderator", User.AccountType.USER, true); + Project project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.PENDING); + ProjectVersion selected = version("1.0"); project.setVersions(List.of(selected)); + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(accessControlService.canApproveProjectReviews(admin)).thenReturn(true); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> lifecycleService.publishProject("project-1", admin)); + var snapshot = new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), project); + when(reviewPersistence.capture("project-1", "token")).thenReturn(snapshot); + when(reviewPersistence.apply(snapshot, selected.getId())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> lifecycleService.publishProject("project-1", admin, "token", selected.getId())); + verify(projectRepository, never()).save(any()); + verify(projectNotificationService, never()).notifyNewProject(any()); + verify(webhookService, never()).triggerWebhook(any()); + } + @Test void publishProjectRestorationUsesProjectPermissionWithoutBroadcastingNewProjectEvents() { User maintainer = user("user-2", "ItsNeil17", User.AccountType.USER, true); diff --git a/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java b/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java index 62428666d..aa42a6c0b 100644 --- a/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectDeletionServiceTest.java @@ -27,6 +27,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -36,6 +38,8 @@ class ProjectDeletionServiceTest { private ProjectDeletionService service; + private net.modtale.service.admin.review.ProjectReviewPersistence reviewPersistence; + private java.util.Map projects; private ProjectRepository projectRepository; private ProjectService projectService; private TrackingService trackingService; @@ -46,6 +50,12 @@ class ProjectDeletionServiceTest { @BeforeEach void setUp() { projectRepository = mock(ProjectRepository.class); + projects = new java.util.HashMap<>(); + reviewPersistence = mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + when(reviewPersistence.capture(anyString(), anyString())).thenAnswer(invocation -> + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), projects.get(invocation.getArgument(0)))); + when(reviewPersistence.applyDeletionState(any(), anyBoolean())).thenReturn(true); + when(reviewPersistence.deleteProject(any())).thenReturn(true); projectService = mock(ProjectService.class); trackingService = mock(TrackingService.class); scoringService = mock(ScoringService.class); @@ -58,7 +68,8 @@ void setUp() { trackingService, scoringService, projectArtifactDeletionService, - mongoTemplate + mongoTemplate, + reviewPersistence ); } @@ -70,7 +81,7 @@ void softDeleteMarksPublishedProjectsDeletedAndTracksRemoval() { assertEquals(ProjectStatus.DELETED, project.getStatus()); assertNotNull(project.getDeletedAt()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyDeletionState(any(), eq(false)); verify(projectService).evictProjectCache(project); verify(trackingService).logDeletedProject("project-1"); } @@ -93,7 +104,6 @@ void hardDeleteScrubsProjectsThatAreStillNeededForDependencies() { project.setDeletedAt(LocalDateTime.now()); when(projectRepository.findByDependency("project-1")).thenReturn(List.of(editableProject("dependent-1", ProjectClassification.DATA, ProjectStatus.PUBLISHED))); - when(projectRepository.save(project)).thenReturn(project); service.hardDelete(project); @@ -110,11 +120,11 @@ void hardDeleteScrubsProjectsThatAreStillNeededForDependencies() { assertTrue(project.getTags().isEmpty()); assertNull(project.getDeletedAt()); - verify(storageService).deleteFile("https://cdn.modtale.net/icon.png"); - verify(storageService).deleteFile("https://cdn.modtale.net/banner.png"); - verify(storageService).deleteFile("https://cdn.modtale.net/one.png"); - verify(storageService).deleteFile("https://cdn.modtale.net/two.png"); - verify(projectRepository).save(project); + verify(storageService).deleteOwnedProjectMedia(eq("project-1"), eq("https://cdn.modtale.net/icon.png"), any()); + verify(storageService).deleteOwnedProjectMedia(eq("project-1"), eq("https://cdn.modtale.net/banner.png"), any()); + verify(storageService).deleteOwnedProjectMedia(eq("project-1"), eq("https://cdn.modtale.net/one.png"), any()); + verify(storageService).deleteOwnedProjectMedia(eq("project-1"), eq("https://cdn.modtale.net/two.png"), any()); + verify(reviewPersistence).applyDeletionState(any(), eq(true)); verify(projectService).evictProjectCache(project); verify(projectRepository, never()).delete(project); verify(trackingService, never()).deleteProjectAnalytics("project-1"); @@ -146,21 +156,47 @@ void hardDeleteRemovesArtifactsAnalyticsAndProjectRecordsWhenNoDependentsRemain( verify(trackingService).deleteProjectAnalytics("project-1"); verify(storageService).deleteFile("files/project-1/main.jar"); verify(storageService).deleteFile("modpack-overrides/project-1/overrides.zip"); - verify(storageService).deleteFile("https://cdn.modtale.net/icon.png"); - verify(storageService).deleteFile("https://cdn.modtale.net/banner.png"); - verify(storageService).deleteFile("https://cdn.modtale.net/one.png"); + verify(storageService).deleteOwnedProjectMedia(eq("project-1"), eq("https://cdn.modtale.net/icon.png"), any()); + verify(storageService).deleteOwnedProjectMedia(eq("project-1"), eq("https://cdn.modtale.net/banner.png"), any()); + verify(storageService).deleteOwnedProjectMedia(eq("project-1"), eq("https://cdn.modtale.net/one.png"), any()); verify(mongoTemplate, times(2)).updateMulti(any(Query.class), any(Update.class), eq(net.modtale.model.user.User.class)); - verify(projectRepository).delete(project); + verify(reviewPersistence).deleteProject(argThat(snapshot -> "project-1".equals(snapshot.project().getId()))); verify(projectService).evictProjectCache(project); verify(trackingService).deleteProjectAnalytics("dep-1"); - verify(projectRepository).delete(orphan); + verify(reviewPersistence).deleteProject(argThat(snapshot -> "dep-1".equals(snapshot.project().getId()))); verify(projectService).evictProjectCache(orphan); } - private static Project editableProject(String id, ProjectClassification classification, ProjectStatus status) { + @Test void failedHardDeleteCannotTouchStorageAnalyticsOrUserRecords() { + var project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.DELETED); + project.setImageUrl("images/example.png"); + when(reviewPersistence.deleteProject(any())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.hardDelete(project)); + verifyNoInteractions(storageService, trackingService, mongoTemplate); + verify(projectService, never()).evictProjectCache(any()); + } + @Test void failedDependencyScrubCannotDeleteMediaOrEvictCurrentState() { + var project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.DELETED); + project.setImageUrl("images/example.png"); + when(projectRepository.findByDependency("project-1")).thenReturn(List.of(new Project())); + when(reviewPersistence.applyDeletionState(any(), eq(true))).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.hardDelete(project)); + verifyNoInteractions(storageService, trackingService, mongoTemplate); + verify(projectService, never()).evictProjectCache(any()); + } + @Test void failedSoftDeleteDoesNotAnnounceRemoval() { + var project = editableProject("project-1", ProjectClassification.DATA, ProjectStatus.PUBLISHED); + when(reviewPersistence.applyDeletionState(any(), eq(false))).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.softDelete(project)); + verifyNoInteractions(storageService, trackingService); + verify(projectService, never()).evictProjectCache(any()); + } + + private Project editableProject(String id, ProjectClassification classification, ProjectStatus status) { Project project = new Project(); project.setId(id); + projects.put(id, project); project.setSlug("sky-tools"); project.setTitle("Sky Tools"); project.setClassification(classification); diff --git a/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectPublicationSecurityTest.java b/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectPublicationSecurityTest.java new file mode 100644 index 000000000..e77badf5e --- /dev/null +++ b/backend/src/test/java/net/modtale/service/project/lifecycle/ProjectPublicationSecurityTest.java @@ -0,0 +1,49 @@ +package net.modtale.service.project.lifecycle; + +import net.modtale.model.project.*; +import net.modtale.model.user.User; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.analytics.*; +import net.modtale.service.communication.*; +import net.modtale.service.project.access.ProjectAccessService; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.AccessControlService; +import net.modtale.service.security.issue.SecurityIssueAnalysisService; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class ProjectPublicationSecurityTest { + @Test void restoringVisibilityNeverApprovesPendingOrScheduledVersions() { + for (ProjectStatus status : List.of(ProjectStatus.ARCHIVED,ProjectStatus.UNLISTED,ProjectStatus.PRIVATE)) { + var repository=mock(ProjectRepository.class); + var access=mock(AccessControlService.class); + var projects=mock(ProjectAccessService.class); + var analysis=mock(SecurityIssueAnalysisService.class); + var persistence=mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + var service=new ProjectPublicationService(mock(ProjectService.class),mock(ProjectNotificationService.class), + mock(WebhookService.class),mock(TrackingService.class),mock(ScoringService.class),access,projects,analysis,persistence); + var project=new Project();project.setId("project");project.setStatus(status);project.setCreatedAt("2026-01-01T00:00:00"); + var pending=new ProjectVersion();pending.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + var scheduled=new ProjectVersion();scheduled.setReviewStatus(ProjectVersion.ReviewStatus.SCHEDULED); + scheduled.setScheduledPublishDate("2999-01-01T00:00:00"); + project.setVersions(List.of(pending,scheduled)); + var owner=new User(); + when(projects.requireProject("project")).thenReturn(project); + when(access.hasProjectPermission(project,owner,"PROJECT_STATUS_PUBLISH")).thenReturn(true); + when(repository.save(project)).thenReturn(project); + when(persistence.capture(eq("project"),anyString())).thenReturn( + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(),project)); + when(persistence.apply(any(),isNull())).thenReturn(true); + service.publishProject("project",owner); + verifyNoInteractions(repository); + assertEquals(ProjectStatus.PUBLISHED,project.getStatus()); + assertEquals(ProjectVersion.ReviewStatus.PENDING,pending.getReviewStatus(),status.name()); + assertEquals(ProjectVersion.ReviewStatus.SCHEDULED,scheduled.getReviewStatus(),status.name()); + assertEquals("2999-01-01T00:00:00",scheduled.getScheduledPublishDate()); + verifyNoInteractions(analysis); + } + } +} diff --git a/backend/src/test/java/net/modtale/service/project/media/ProjectMediaServiceTest.java b/backend/src/test/java/net/modtale/service/project/media/ProjectMediaServiceTest.java index 344b436a7..7951c3c61 100644 --- a/backend/src/test/java/net/modtale/service/project/media/ProjectMediaServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/media/ProjectMediaServiceTest.java @@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -32,6 +33,7 @@ class ProjectMediaServiceTest { private ProjectMediaService service; + private net.modtale.service.admin.review.ProjectReviewPersistence reviewPersistence; private ProjectRepository projectRepository; private ProjectService projectService; private AccessControlService accessControlService; @@ -43,6 +45,11 @@ class ProjectMediaServiceTest { @BeforeEach void setUp() { projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + when(reviewPersistence.capture(anyString(), anyString())).thenAnswer(invocation -> + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), + projectService.getRawProjectById(invocation.getArgument(0)))); + when(reviewPersistence.applyPresentation(any(), anyBoolean())).thenReturn(true); projectService = mock(ProjectService.class); accessControlService = mock(AccessControlService.class); projectAccessService = new ProjectAccessService(projectService, accessControlService); @@ -51,7 +58,7 @@ void setUp() { mediaUploadService = mock(MediaUploadService.class); service = new ProjectMediaService( - projectRepository, + reviewPersistence, projectService, projectAccessService, new ProjectMutationGuard(), @@ -80,7 +87,7 @@ void addGalleryImageRejectsProjectsAtTheConfiguredLimit() throws Exception { assertEquals("This project has already reached the gallery limit of 2 items.", error.getMessage()); verify(fileValidationService, never()).validateGalleryImage(any()); - verify(mediaUploadService, never()).uploadPublicUrl(any(), eq("gallery"), any()); + verify(mediaUploadService, never()).uploadPublicUrl(any(), eq("project-media/project-1/gallery"), any()); } @Test @@ -92,13 +99,12 @@ void addGalleryVideoStoresNormalizedYouTubeWatchUrls() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_GALLERY_ADD")).thenReturn(true); - when(projectRepository.save(project)).thenReturn(project); Project updated = service.addGalleryVideo("project-1", "https://youtu.be/dQw4w9WgXcQ?si=test", user); assertEquals(project, updated); assertEquals(List.of("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), project.getGalleryImages()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -149,13 +155,12 @@ void reorderGalleryPersistsTheRequestedOrder() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_GALLERY_ADD")).thenReturn(true); - when(projectRepository.save(project)).thenReturn(project); Project updated = service.reorderGallery("project-1", List.of("c.png", "a.png", "b.png"), user); assertEquals(project, updated); assertEquals(List.of("c.png", "a.png", "b.png"), project.getGalleryImages()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -193,8 +198,8 @@ void removeGalleryImageDeletesTheStoredAssetAndEvictsProjectCache() { assertEquals(List.of("https://cdn.modtale.test/gallery/b.png"), project.getGalleryImages()); assertFalse(project.getGalleryImageCaptions().containsKey("https://cdn.modtale.test/gallery/a.png")); - verify(projectDeletionService).deleteStoredFile("https://cdn.modtale.test/gallery/a.png"); - verify(projectRepository).save(project); + verify(projectDeletionService).deleteProjectMediaFile(project, "https://cdn.modtale.test/gallery/a.png"); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -208,14 +213,13 @@ void removeGalleryImageDoesNotDeleteYoutubeVideosFromStorage() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_GALLERY_REMOVE")).thenReturn(true); - when(projectRepository.save(project)).thenReturn(project); service.removeGalleryImage("project-1", "https://www.youtube.com/watch?v=dQw4w9WgXcQ", user); assertEquals(List.of(), project.getGalleryImages()); assertFalse(project.getGalleryImageCaptions().containsKey("https://www.youtube.com/watch?v=dQw4w9WgXcQ")); - verify(projectDeletionService, never()).deleteStoredFile(any()); - verify(projectRepository).save(project); + verify(projectDeletionService, never()).deleteProjectMediaFile(any(), any()); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -229,7 +233,6 @@ void updateGalleryImageCaptionStoresTrimmedCaptionsAndEvictsProjectCache() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_GALLERY_ADD")).thenReturn(true); - when(projectRepository.save(project)).thenReturn(project); Project updated = service.updateGalleryImageCaption( "project-1", @@ -240,7 +243,7 @@ void updateGalleryImageCaptionStoresTrimmedCaptionsAndEvictsProjectCache() { assertEquals(project, updated); assertEquals("Opening shot", project.getGalleryImageCaptions().get("https://cdn.modtale.test/gallery/a.png")); - verify(projectRepository).save(project); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -254,12 +257,11 @@ void updateGalleryImageCaptionRemovesBlankCaptions() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_GALLERY_ADD")).thenReturn(true); - when(projectRepository.save(project)).thenReturn(project); service.updateGalleryImageCaption("project-1", "https://cdn.modtale.test/gallery/a.png", " ", user); assertFalse(project.getGalleryImageCaptions().containsKey("https://cdn.modtale.test/gallery/a.png")); - verify(projectRepository).save(project); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } @@ -293,20 +295,37 @@ void updateProjectImageCleansUpExistingStoredAssetsThroughTheSharedDeletionHelpe when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "PROJECT_EDIT_ICON")).thenReturn(true); - when(mediaUploadService.uploadPublicUrl(eq(file), eq("images"), any(), any())).thenAnswer(invocation -> { - Runnable cleanupExisting = invocation.getArgument(3); - cleanupExisting.run(); - return "https://cdn.modtale.test/images/new-icon.png"; - }); + when(mediaUploadService.uploadPublicUrl(eq(file), eq("project-media/project-1/images"), any())).thenReturn("https://cdn.modtale.test/images/new-icon.png"); + + when(reviewPersistence.applyPresentation(any(), eq(true))).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.updateProjectImage("project-1", file, user, false)); + verify(projectDeletionService, never()).deleteProjectMediaFile(any(), anyString()); + project.setImageUrl("https://cdn.modtale.test/images/current-icon.png"); + clearInvocations(reviewPersistence); + when(reviewPersistence.applyPresentation(any(), eq(true))).thenReturn(true); service.updateProjectImage("project-1", file, user, false); assertEquals("https://cdn.modtale.test/images/new-icon.png", project.getImageUrl()); - verify(projectDeletionService).deleteStoredFile("https://cdn.modtale.test/images/current-icon.png"); - verify(projectRepository).save(project); + verify(projectDeletionService).deleteProjectMediaFile(project, "https://cdn.modtale.test/images/current-icon.png"); + verify(reviewPersistence).applyPresentation(any(), eq(true)); verify(projectService).evictProjectCache(project); } + @Test + void galleryRemovalRequiresMembershipAndSuccessfulPersistenceBeforeDeletingStorage() { + var project = new Project(); project.setId("project-1"); + project.setGalleryImages(new ArrayList<>(List.of("owned.png"))); + var user = user("user-1"); + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(accessControlService.hasProjectPermission(project, user, "PROJECT_GALLERY_REMOVE")).thenReturn(true); + assertThrows(InvalidProjectRequestException.class, () -> service.removeGalleryImage("project-1", "foreign.png", user)); + when(reviewPersistence.applyPresentation(any(), eq(true))).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.removeGalleryImage("project-1", "owned.png", user)); + verify(projectDeletionService, never()).deleteProjectMediaFile(any(), anyString()); + } + private static User user(String id) { User user = new User(); user.setId(id); diff --git a/backend/src/test/java/net/modtale/service/project/metadata/MetadataServiceTest.java b/backend/src/test/java/net/modtale/service/project/metadata/MetadataServiceTest.java index 062dd3ad2..9f5f55a04 100644 --- a/backend/src/test/java/net/modtale/service/project/metadata/MetadataServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/metadata/MetadataServiceTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -28,6 +29,7 @@ class MetadataServiceTest { private MetadataService service; + private net.modtale.service.admin.review.ProjectReviewPersistence reviewPersistence; private ProjectRepository projectRepository; private ProjectService projectService; private ValidationService validationService; @@ -38,6 +40,11 @@ class MetadataServiceTest { @BeforeEach void setUp() { projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + when(reviewPersistence.capture(anyString(), anyString())).thenAnswer(invocation -> + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), + projectService.getRawProjectById(invocation.getArgument(0)))); + when(reviewPersistence.applyPresentation(any(), anyBoolean())).thenReturn(true); projectService = mock(ProjectService.class); validationService = mock(ValidationService.class); accessControlService = mock(AccessControlService.class); @@ -46,6 +53,7 @@ void setUp() { service = new MetadataService( projectRepository, + reviewPersistence, projectService, validationService, projectAccessService, @@ -91,7 +99,7 @@ void updateMetadataAllowsMutableClassificationChangesAndSanitizesPrimaryFields() assertEquals("MIT", existing.getLicense()); assertFalse(existing.isCustomLicenseOpenSource()); verify(validationService).validateSlug("new-slug"); - verify(projectRepository).save(existing); + verify(reviewPersistence).applyPresentation(any(), eq(false)); verify(projectService).evictProjectCache(existing); } @@ -119,7 +127,7 @@ void updateMetadataPersistsOpenSourceFlagForCustomLicenses() { assertEquals("Sky Public License", existing.getLicense()); assertTrue(existing.isCustomLicenseOpenSource()); - verify(projectRepository).save(existing); + verify(reviewPersistence).applyPresentation(any(), eq(false)); } @Test @@ -149,4 +157,15 @@ void updateMetadataRejectsMultipleGalleryCarouselMarkers() { assertEquals("Use {{gallery-carousel}} only once in the project description.", error.getMessage()); verify(projectRepository, never()).save(existing); } + @Test void metadataCannotAttachAnArbitraryStoredFileAsTheProjectImage() { + var existing = new Project(); existing.setId("project-1"); existing.setClassification(ProjectClassification.DATA); + existing.setImageUrl("owned-image.png"); + var updated = new Project(); updated.setClassification(ProjectClassification.DATA); updated.setImageUrl("another-project-file.jar"); + var user = new User(); user.setId("user-1"); + when(projectService.getRawProjectById("project-1")).thenReturn(existing); + when(accessControlService.hasProjectPermission(existing, user, "PROJECT_EDIT_METADATA")).thenReturn(true); + assertThrows(InvalidProjectRequestException.class, () -> service.updateMetadata("project-1", updated, user)); + verify(reviewPersistence, never()).applyPresentation(any(), anyBoolean()); + } + } diff --git a/backend/src/test/java/net/modtale/service/project/team/TeamServiceTest.java b/backend/src/test/java/net/modtale/service/project/team/TeamServiceTest.java index e289248bb..671d7b779 100644 --- a/backend/src/test/java/net/modtale/service/project/team/TeamServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/team/TeamServiceTest.java @@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,6 +33,7 @@ class TeamServiceTest { private TeamService service; + private net.modtale.service.admin.review.ProjectReviewPersistence reviewPersistence; private ProjectRepository projectRepository; private UserRepository userRepository; private ProjectService projectService; @@ -44,6 +46,10 @@ class TeamServiceTest { @BeforeEach void setUp() { projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + when(reviewPersistence.capture(anyString(), anyString())).thenAnswer(invocation -> + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), projectService.getRawProjectById(invocation.getArgument(0)))); + when(reviewPersistence.applyTeam(any())).thenReturn(true); userRepository = mock(UserRepository.class); projectService = mock(ProjectService.class); projectMutationGuard = new ProjectMutationGuard(); @@ -54,7 +60,7 @@ void setUp() { TeamNotificationService teamNotificationService = new TeamNotificationService(notificationService, projectService); TeamTransferService teamTransferService = new TeamTransferService( - projectRepository, + reviewPersistence, userRepository, projectService, projectAccessService, @@ -64,13 +70,13 @@ void setUp() { accessControlService ); TeamRoleService teamRoleService = new TeamRoleService( - projectRepository, + reviewPersistence, projectService, projectAccessService, apiKeyService ); TeamMembershipService teamMembershipService = new TeamMembershipService( - projectRepository, + reviewPersistence, userRepository, projectService, projectAccessService, @@ -102,7 +108,7 @@ void requestTransferSetsPendingTargetAndSendsANotification() { service.requestTransfer("project-1", "user-2", requester); assertEquals("user-2", project.getPendingTransferTo()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyTeam(any()); verify(projectService).evictProjectCache(project); verify(notificationService).sendNotifcation( eq(List.of("user-2")), @@ -111,7 +117,8 @@ void requestTransferSetsPendingTargetAndSendsANotification() { eq(java.net.URI.create("/dashboard/projects")), eq(project.getImageUrl()), eq(net.modtale.model.user.NotificationType.TRANSFER_REQUEST), - eq(java.util.Map.of("projectId", "project-1", "action", "TRANSFER_REQUEST")) + eq(java.util.Map.of("projectId", "project-1", "action", "TRANSFER_REQUEST", + "requestId", project.getPendingTransferRequestId(), "targetUserId", "user-2")) ); } @@ -129,7 +136,7 @@ void removeContributorAllowsMembersToRemoveThemselvesAndClearsApiKeyPermissions( assertTrue(project.getTeamMembers().isEmpty()); verify(apiKeyService).syncUserProjectPermissions(eq("user-2"), eq("project-1"), argThat(Set::isEmpty)); - verify(projectRepository).save(project); + verify(reviewPersistence).applyTeam(any()); verify(projectService).evictProjectCache(project); } @@ -153,7 +160,7 @@ void inviteContributorPersistsTheRoleAndSendsCanonicalProjectMetadata() { assertEquals(1, project.getTeamInvites().size()); assertEquals("role-1", project.getTeamInvites().getFirst().getRoleId()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyTeam(any()); verify(notificationService).sendNotifcation( eq(List.of("user-2")), eq("Contributor Invite"), @@ -161,7 +168,8 @@ void inviteContributorPersistsTheRoleAndSendsCanonicalProjectMetadata() { eq(java.net.URI.create("/dashboard/projects")), eq(project.getImageUrl()), eq(net.modtale.model.user.NotificationType.CONTRIBUTOR_INVITE), - eq(java.util.Map.of("projectId", "project-1", "action", "CONTRIBUTOR_INVITE")) + eq(java.util.Map.of("projectId", "project-1", "action", "CONTRIBUTOR_INVITE", + "requestId", project.getTeamInvites().getFirst().getRequestId())) ); } @@ -170,6 +178,8 @@ void resolveTransferAcceptsOwnershipTransferAndRemovesTheNewOwnerFromTeamMembers Project project = project("project-1"); project.setAuthorId("owner-1"); project.setPendingTransferTo("user-2"); + project.setPendingTransferRequestId("request-1"); project.setPendingTransferOwnerId("owner-1"); + project.setPendingTransferExpiresAt(System.currentTimeMillis() + 60000); project.setTeamMembers(new ArrayList<>(List.of(new Project.ProjectMember("user-2", "role-1")))); User responder = user("user-2", "Bea"); @@ -180,7 +190,18 @@ void resolveTransferAcceptsOwnershipTransferAndRemovesTheNewOwnerFromTeamMembers when(userRepository.findById("owner-1")).thenReturn(Optional.of(oldOwner)); when(userRepository.findById("user-2")).thenReturn(Optional.of(newOwner)); - service.resolveTransfer("project-1", true, responder); + when(reviewPersistence.resolveTransfer(any(), eq("request-1"))).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.resolveTransfer("project-1", true, "request-1", responder)); + verifyNoInteractions(apiKeyService, notificationService); + project.setAuthorId("owner-1"); project.setPendingTransferTo("user-2"); + project.setPendingTransferRequestId("request-1"); project.setPendingTransferOwnerId("owner-1"); + project.setPendingTransferExpiresAt(System.currentTimeMillis() + 60000); + project.setTeamMembers(new ArrayList<>(List.of(new Project.ProjectMember("user-2", "role-1")))); + clearInvocations(reviewPersistence); + when(reviewPersistence.resolveTransfer(any(), eq("request-1"))).thenReturn(true); + + service.resolveTransfer("project-1", true, "request-1", responder); assertEquals("user-2", project.getAuthorId()); assertNull(project.getPendingTransferTo()); @@ -216,12 +237,16 @@ void acceptInviteMovesTheInviteToTeamMembersAndNotifiesTheOwner() { when(userRepository.findById("owner-1")).thenReturn(Optional.of(owner)); when(projectService.getProjectLink(project)).thenReturn("/mod/sky-tools~project-1"); - service.acceptInvite("project-1", "user-2"); + var invitation = project.getTeamInvites().getFirst(); invitation.setRequestId("invite-1"); + invitation.setRequestExpiresAt(System.currentTimeMillis()+60000); invitation.setRequestOwnerId(project.getAuthorId()); + invitation.setRequestPermissions(Set.of(ApiKey.ApiPermission.PROJECT_EDIT_METADATA)); + when(reviewPersistence.resolveContributorInvite(any(), any(), any(), anyBoolean())).thenReturn(true); + service.acceptInvite("project-1", "user-2", "invite-1"); assertTrue(project.getTeamInvites().isEmpty()); assertEquals(1, project.getTeamMembers().size()); assertEquals("user-2", project.getTeamMembers().getFirst().getUserId()); - verify(projectRepository).save(project); + verify(reviewPersistence).resolveContributorInvite(any(), eq("user-2"), eq("invite-1"), eq(true)); verify(projectService).evictProjectCache(project); verify(apiKeyService).syncUserProjectPermissions( "user-2", @@ -248,10 +273,33 @@ void acceptInviteRejectsMissingInvitesInsteadOfReportingFalseSuccess() { assertThrows( InvalidProjectRequestException.class, - () -> service.acceptInvite("project-1", "user-2") + () -> service.acceptInvite("project-1", "user-2", "invite-1") ); } + @Test void failedRoleChangeDoesNotSynchronizeKeysOrNotify() { + var project = project("project-1"); + project.setProjectRoles(new ArrayList<>(List.of(new Project.ProjectRole("role-1", "Writer", "#fff", Set.of())))); + project.setTeamMembers(new ArrayList<>(List.of(new Project.ProjectMember("user-2", "role-1")))); + var owner = user("owner-1", "Owner"); + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(accessControlService.hasProjectPermission(project, owner, "PROJECT_MEMBER_EDIT_ROLE")).thenReturn(true); + when(reviewPersistence.applyTeam(any())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.updateProjectRole( + "project-1", "role-1", null, null, Set.of(ApiKey.ApiPermission.PROJECT_EDIT_METADATA), owner)); + verifyNoInteractions(apiKeyService, notificationService); + verify(projectService, never()).evictProjectCache(any()); + } + @Test void failedContributorRemovalDoesNotRevokeKeys() { + var project = project("project-1"); + project.setTeamMembers(new ArrayList<>(List.of(new Project.ProjectMember("user-2", "role-1")))); + var member = user("user-2", "Member"); + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(reviewPersistence.applyTeam(any())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.removeContributor("project-1", "user-2", member)); + verifyNoInteractions(apiKeyService, notificationService); + } + private static Project project(String id) { Project project = new Project(); project.setId(id); @@ -268,4 +316,18 @@ private static User user(String id, String username) { user.setUsername(username); return user; } + @Test void staleExpiredAndOwnerChangedTransferResponsesCannotResolve() { + Project project = project("project-1"); project.setAuthorId("owner-1"); project.setPendingTransferTo("user-2"); + project.setPendingTransferRequestId("new-request"); project.setPendingTransferOwnerId("owner-1"); + project.setPendingTransferExpiresAt(System.currentTimeMillis() + 60000); + when(projectService.getRawProjectById("project-1")).thenReturn(project); + User responder = user("user-2", "Bea"); + assertThrows(net.modtale.exception.InvalidProjectRequestException.class, () -> service.resolveTransfer("project-1", true, "old-request", responder)); + project.setPendingTransferExpiresAt(1); + assertThrows(net.modtale.exception.InvalidProjectRequestException.class, () -> service.resolveTransfer("project-1", true, "new-request", responder)); + project.setPendingTransferExpiresAt(System.currentTimeMillis() + 60000); project.setAuthorId("new-owner"); + assertThrows(net.modtale.exception.InvalidProjectRequestException.class, () -> service.resolveTransfer("project-1", true, "new-request", responder)); + verify(reviewPersistence, never()).resolveTransfer(any(), any()); verifyNoInteractions(apiKeyService, notificationService); + } + } diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionPublishingServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionPublishingServiceTest.java index 8b4adf566..e6b55ffcb 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionPublishingServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionPublishingServiceTest.java @@ -1,90 +1,115 @@ package net.modtale.service.project.version; -import java.util.ArrayList; -import java.util.List; -import net.modtale.model.project.Project; -import net.modtale.model.project.ProjectVersion; -import net.modtale.repository.project.ProjectRepository; +import java.util.*; +import net.modtale.model.project.*; import net.modtale.service.communication.ProjectNotificationService; -import net.modtale.service.project.lifecycle.ScheduledReleaseExecutionService; -import net.modtale.service.project.lifecycle.ScheduledReleaseQueryService; +import net.modtale.service.project.lifecycle.*; import net.modtale.service.project.query.ProjectService; import net.modtale.service.security.issue.SecurityIssueAnalysisService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import net.modtale.service.security.scan.ScanEvidenceFixtures; +import org.junit.jupiter.api.*; import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.query.Query; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import org.springframework.data.mongodb.core.query.*; +import org.mockito.ArgumentCaptor; +import com.mongodb.client.result.UpdateResult; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; class VersionPublishingServiceTest { - + private net.modtale.service.security.scan.WardenClientService warden; + private MongoTemplate mongo; + private ProjectNotificationService notifications; private VersionPublishingService service; - private MongoTemplate mongoTemplate; - private ProjectRepository projectRepository; - private ProjectService projectService; - private ProjectNotificationService projectNotificationService; - private SecurityIssueAnalysisService securityIssueAnalysisService; - - @BeforeEach - void setUp() { - mongoTemplate = mock(MongoTemplate.class); - projectRepository = mock(ProjectRepository.class); - projectService = mock(ProjectService.class); - projectNotificationService = mock(ProjectNotificationService.class); - securityIssueAnalysisService = mock(SecurityIssueAnalysisService.class); - - ScheduledReleaseQueryService queryService = new ScheduledReleaseQueryService(mongoTemplate); - ScheduledReleaseExecutionService executionService = new ScheduledReleaseExecutionService( - projectRepository, - projectService, - projectNotificationService, - securityIssueAnalysisService - ); - service = new VersionPublishingService(queryService, executionService); + @BeforeEach void setup() { + warden = mock(net.modtale.service.security.scan.WardenClientService.class); + when(warden.currentPolicyVersion()).thenReturn(ScanEvidenceFixtures.complete(true).getSecurityEvidence().policyVersion()); + mongo = mock(MongoTemplate.class); + notifications = mock(ProjectNotificationService.class); + service = new VersionPublishingService(new ScheduledReleaseQueryService(mongo), + new ScheduledReleaseExecutionService(mongo, mock(ProjectService.class), notifications, mock(SecurityIssueAnalysisService.class), warden)); + when(mongo.updateFirst(any(Query.class), any(Update.class), eq(Project.class))).thenReturn(UpdateResult.acknowledged(1, 1L, null)); } - - @Test - void processScheduledReleasesPublishesOnlyDueVersions() { - Project project = new Project(); - project.setId("project-1"); - ProjectVersion dueVersion = version("1.0.0", "2000-01-01T00:00:00"); - ProjectVersion futureVersion = version("2.0.0", "2999-01-01T00:00:00"); - project.setVersions(new ArrayList<>(List.of(dueVersion, futureVersion))); - - when(mongoTemplate.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); - + @Test void publishesOnlyDueVersionsWithVerifiedClearance() { + Project project = project(true); + var future = version("2.0", "2999-01-01T00:00:00", true); + project.setVersions(List.of(project.getVersions().getFirst(), future)); + when(mongo.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); service.processScheduledReleases(); - - assertEquals(ProjectVersion.ReviewStatus.APPROVED, dueVersion.getReviewStatus()); - assertNull(dueVersion.getScheduledPublishDate()); - assertEquals(ProjectVersion.ReviewStatus.SCHEDULED, futureVersion.getReviewStatus()); - assertEquals("2999-01-01T00:00:00", futureVersion.getScheduledPublishDate()); - assertNotNull(project.getUpdatedAt()); - - verify(securityIssueAnalysisService).pruneApprovedScanResults(project); - verify(projectRepository).save(project); - verify(projectService).evictProjectCache(project); - verify(projectNotificationService).notifyUpdates(project, "1.0.0"); - verify(projectNotificationService).notifyDependents(project, "1.0.0"); - verify(projectNotificationService, never()).notifyUpdates(project, "2.0.0"); - verify(projectNotificationService, never()).notifyDependents(project, "2.0.0"); + verify(notifications).notifyUpdates(project, "1.0"); + verify(notifications, never()).notifyUpdates(project, "2.0"); + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongo).updateFirst(query.capture(), any(Update.class), eq(Project.class)); + String filter = query.getValue().getQueryObject().toString(); + assertTrue(filter.contains("SCHEDULED")); + assertTrue(filter.contains("scanResult.scanAttempt")); + assertTrue(filter.contains("artifactSha256")); } - - private static ProjectVersion version(String versionNumber, String scheduledPublishDate) { - ProjectVersion version = new ProjectVersion(); - version.setId("version-" + versionNumber); - version.setVersionNumber(versionNumber); - version.setReviewStatus(ProjectVersion.ReviewStatus.SCHEDULED); - version.setScheduledPublishDate(scheduledPublishDate); - return version; + @Test void refusesLegacyScheduledApprovalWithoutEvidence() { + Project project = project(false); + when(mongo.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); + service.processScheduledReleases(); + verifyNoInteractions(notifications); + ArgumentCaptor update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(any(Query.class), update.capture(), eq(Project.class)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, ((org.bson.Document)update.getValue().getUpdateObject().get("$set")).get("versions.$.reviewStatus")); + } + @Test void concurrentRescanOrRejectionPreventsReleaseAndNotification() { + Project project = project(true); + when(mongo.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); + when(mongo.updateFirst(any(Query.class), any(Update.class), eq(Project.class))).thenReturn(UpdateResult.acknowledged(0, 0L, null)); + service.processScheduledReleases(); + verifyNoInteractions(notifications); + } + @Test void changedContextOrSupplementalFilesCannotInheritScheduledClearance() { + for (String scenario : List.of("override", "game", "manifest", "missing-context", "future-scan")) { + reset(mongo, notifications); + when(mongo.updateFirst(any(Query.class), any(Update.class), eq(Project.class))) + .thenReturn(UpdateResult.acknowledged(1, 1L, null)); + Project project = project(true); + var version = project.getVersions().getFirst(); + switch (scenario) { + case "override" -> version.setOverrideFileUrl("separate.zip"); + case "game" -> version.setGameVersions(List.of("changed-runtime")); + case "manifest" -> version.setManifestId("changed-entrypoint"); + case "missing-context" -> version.getScanResult().setReviewedContextSha256(null); + case "future-scan" -> version.getScanResult().setScanTimestamp(System.currentTimeMillis() + 60_000); + } + when(mongo.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); + service.processScheduledReleases(); + verifyNoInteractions(notifications); + var update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(any(Query.class), update.capture(), eq(Project.class)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, + ((org.bson.Document) update.getValue().getUpdateObject().get("$set")).get("versions.$.reviewStatus"), scenario); + } + } + @Test void unavailablePolicyDefersWithoutChangingQueueOrNotifying() { + var project = project(true); + when(mongo.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); + when(warden.currentPolicyVersion()).thenReturn(null); + service.processScheduledReleases(); + verify(mongo, never()).updateFirst(any(Query.class), any(Update.class), eq(Project.class)); + verifyNoInteractions(notifications); + } + @Test void changedPolicyInvalidatesScheduledClearance() { + var project = project(true); + when(mongo.find(any(Query.class), eq(Project.class))).thenReturn(List.of(project)); + when(warden.currentPolicyVersion()).thenReturn("warden-3.0.0:" + "c".repeat(64)); + service.processScheduledReleases(); + var update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(any(Query.class), update.capture(), eq(Project.class)); + assertEquals(ProjectVersion.ReviewStatus.PENDING, + ((org.bson.Document) update.getValue().getUpdateObject().get("$set")).get("versions.$.reviewStatus")); + verifyNoInteractions(notifications); + } + private Project project(boolean verified) { + Project p = new Project(); p.setId("project"); p.setVersions(List.of(version("1.0", "2000-01-01T00:00:00", verified))); return p; + } + private ProjectVersion version(String number, String date, boolean verified) { + ProjectVersion v = new ProjectVersion(); v.setId(number); v.setVersionNumber(number); + v.setReviewStatus(ProjectVersion.ReviewStatus.SCHEDULED); v.setScheduledPublishDate(date); + if (verified) { v.setScanResult(ScanEvidenceFixtures.complete(true)); v.setHash(v.getScanResult().getSecurityEvidence().artifactSha256()); v.getScanResult().setReviewedContextSha256(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(v)); } + return v; } } diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionServiceTest.java index 82827353f..7841cfd35 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionServiceTest.java @@ -27,6 +27,7 @@ import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.mock.web.MockMultipartFile; +import static org.mockito.Mockito.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -39,6 +40,7 @@ class VersionServiceTest { private VersionService service; + private net.modtale.service.admin.review.ProjectReviewPersistence reviewPersistence; private ProjectRepository projectRepository; private ProjectService projectService; private ProjectAccessService projectAccessService; @@ -56,6 +58,11 @@ class VersionServiceTest { @BeforeEach void setUp() { projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(net.modtale.service.admin.review.ProjectReviewPersistence.class); + when(reviewPersistence.capture(anyString(), anyString())).thenAnswer(invocation -> + new net.modtale.service.admin.review.ProjectReviewPersistence.Snapshot(new org.bson.Document(), + projectService.getRawProjectById(invocation.getArgument(0)))); + when(reviewPersistence.applyVersionEdit(any(), anyString(), anyBoolean(), anyBoolean())).thenReturn(true); projectService = mock(ProjectService.class); accessControlService = mock(AccessControlService.class); projectAccessService = new ProjectAccessService(projectService, accessControlService); @@ -63,6 +70,11 @@ void setUp() { validationService = mock(ValidationService.class); projectVersionAccessService = new ProjectVersionAccessService(validationService); scanService = mock(ScanService.class); + when(scanService.nextScanAttempt(any())).thenReturn(1); + when(scanService.createQueuedScanResult(anyInt(), anyString())).thenAnswer(invocation -> { + var queued = new ScanResult(); queued.setScanAttempt(invocation.getArgument(0)); return queued; + }); + when(reviewPersistence.applyVersionList(any())).thenReturn(true); sanitizationService = mock(SanitizationService.class); versionArtifactService = mock(VersionArtifactService.class); versionDependencyService = mock(VersionDependencyService.class); @@ -77,7 +89,7 @@ void setUp() { projectDeletionService ); VersionCreationCommandHandler versionCreationCommandHandler = new VersionCreationCommandHandler( - projectRepository, + reviewPersistence, projectService, projectAccessService, projectMutationGuard, @@ -85,7 +97,7 @@ void setUp() { new AppLimitProperties(10, 5, 10, 5, 5, 50, 20, 10) ); VersionUpdateCommandHandler versionUpdateCommandHandler = new VersionUpdateCommandHandler( - projectRepository, + reviewPersistence, projectService, projectAccessService, projectMutationGuard, @@ -94,7 +106,7 @@ void setUp() { ); service = new VersionService( - projectRepository, + reviewPersistence, projectService, projectAccessService, projectMutationGuard, @@ -128,6 +140,7 @@ void addVersionQueuesInitialScanForNonModpackArtifacts() throws Exception { when(validationService.getAllowedGameVersions()).thenReturn(List.of("1.21.0")); when(versionArtifactService.prepareVersionArtifact(project, file)) .thenReturn(new VersionArtifactService.PreparedVersionArtifact(ProjectClassification.DATA, "/files/data/bundle.zip", "sha-256")); + queuedScan.setScanRequestId("initial-request"); when(scanService.createQueuedScanResult(1, "Initial scan queued.")).thenReturn(queuedScan); DependencyReferenceRequest dependency = dependency("dep-1", "2.0.0"); when(versionDependencyService.resolveRequestedDependencies(List.of(dependency), false, false)) @@ -150,9 +163,9 @@ void addVersionQueuesInitialScanForNonModpackArtifacts() throws Exception { assertEquals("/files/data/bundle.zip", savedVersion.getFileUrl()); assertEquals("sha-256", savedVersion.getHash()); assertEquals(queuedScan, savedVersion.getScanResult()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyVersionList(any()); verify(projectService).evictProjectCache(project); - verify(scanService).enqueueBackgroundScan("project-1", savedVersion.getId(), "/files/data/bundle.zip", "bundle.zip", false, 1); + verify(scanService).enqueueBackgroundScan("project-1", savedVersion.getId(), "/files/data/bundle.zip", "bundle.zip", false, 1, queuedScan.getScanRequestId()); } @Test @@ -191,10 +204,10 @@ void addVersionDoesNotQueueScanForDraftArtifacts() throws Exception { ProjectVersion savedVersion = project.getVersions().getFirst(); assertNull(savedVersion.getScanResult()); - verify(projectRepository).save(project); + verify(reviewPersistence).applyVersionList(any()); verify(projectService).evictProjectCache(project); verify(scanService, never()).createQueuedScanResult(1, "Initial scan queued."); - verify(scanService, never()).enqueueBackgroundScan("project-1", savedVersion.getId(), "/files/data/bundle.zip", "bundle.zip", false, 1); + verify(scanService, never()).enqueueBackgroundScan("project-1", savedVersion.getId(), "/files/data/bundle.zip", "bundle.zip", false, 1, null); } @Test @@ -268,6 +281,16 @@ void addVersionReplacesDuplicateGameTargetWhenConsentedAndReturnsToPendingReview .thenReturn(new VersionArtifactService.PreparedVersionArtifact(ProjectClassification.DATA, "/files/data/replacement.zip", "sha-replacement")); when(scanService.createQueuedScanResult(1, "Initial scan queued.")).thenReturn(queuedScan); + when(reviewPersistence.applyVersionList(any())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.addVersion( + "project-1", "1.0.0", List.of("1.21.0"), file, "Replacement notes", null, null, + ProjectVersion.Channel.RELEASE, true, user)); + verify(projectDeletionService, never()).deleteVersionFile(any(ProjectVersion.class)); + verify(scanService, never()).enqueueBackgroundScan(anyString(), anyString(), anyString(), anyString(), anyBoolean(), anyInt(), any()); + project.setVersions(new ArrayList<>(List.of(existing))); + clearInvocations(reviewPersistence, scanService); + when(reviewPersistence.applyVersionList(any())).thenReturn(true); + service.addVersion( "project-1", "1.0.0", @@ -290,9 +313,9 @@ void addVersionReplacesDuplicateGameTargetWhenConsentedAndReturnsToPendingReview assertEquals(0, savedVersion.getDownloadCount()); assertEquals(queuedScan, savedVersion.getScanResult()); verify(projectDeletionService).deleteVersionFile(existing); - verify(projectRepository).save(project); + verify(reviewPersistence).applyVersionList(any()); verify(projectService).evictProjectCache(project); - verify(scanService).enqueueBackgroundScan("project-1", savedVersion.getId(), "/files/data/replacement.zip", "replacement.zip", false, 1); + verify(scanService).enqueueBackgroundScan("project-1", savedVersion.getId(), "/files/data/replacement.zip", "replacement.zip", false, 1, null); } @Test @@ -340,7 +363,8 @@ void addVersionReplacementKeepsUntouchedGameTargetsOnExistingVersion() throws Ex assertEquals(List.of("1.21.0"), project.getVersions().getFirst().getGameVersions()); assertEquals("version-old", project.getVersions().get(1).getId()); assertEquals(List.of("1.20.0"), project.getVersions().get(1).getGameVersions()); - assertEquals(ProjectVersion.ReviewStatus.APPROVED, project.getVersions().get(1).getReviewStatus()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, project.getVersions().get(1).getReviewStatus()); + verify(scanService).enqueueBackgroundScan("project-1", "version-old", "/files/data/old.zip", "/files/data/old.zip", false, 1, null); verify(projectDeletionService, never()).deleteVersionFile(existing); } @@ -370,6 +394,15 @@ void updateVersionClearsCachedModpackArchivesAndRefreshesLatestDependencyIds() { List.of("new-dep") )); + when(reviewPersistence.applyVersionEdit(any(), anyString(), anyBoolean(), anyBoolean())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.updateVersion( + "project-1", "version-1", List.of(dependency), List.of(), null, null, null, user)); + verify(projectDeletionService, never()).deleteStoredFile(anyString()); + version.setFileUrl("modpacks/sky-pack-1.0.0.zip"); + version.setDependencies(new ArrayList<>(List.of(new ProjectDependency("old-dep", "Old Dependency", "1.0.0")))); + clearInvocations(reviewPersistence); + when(reviewPersistence.applyVersionEdit(any(), anyString(), anyBoolean(), anyBoolean())).thenReturn(true); + service.updateVersion( "project-1", "version-1", @@ -385,10 +418,42 @@ void updateVersionClearsCachedModpackArchivesAndRefreshesLatestDependencyIds() { assertEquals(List.of("new-dep"), project.getChildProjectIds()); assertEquals("new-dep", version.getDependencies().getFirst().getProjectId()); verify(projectDeletionService).deleteStoredFile("modpacks/sky-pack-1.0.0.zip"); - verify(projectRepository).save(project); + verify(reviewPersistence).applyVersionEdit(any(), eq("version-1"), eq(true), eq(true)); + verify(projectRepository, never()).save(any()); verify(projectService).evictProjectCache(project); } + @Test + void contextEditQueuesOnlyAfterSuccessfulConditionalPersistence() { + var project = new Project(); project.setId("project-1"); project.setStatus(ProjectStatus.PUBLISHED); + project.setClassification(ProjectClassification.PLUGIN); + var version = new ProjectVersion(); version.setId("version-1"); version.setFileUrl("mods/mod.jar"); + version.setGameVersions(List.of("old")); version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + project.setVersions(new ArrayList<>(List.of(version))); + var user = new User(); user.setId("user-1"); + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(accessControlService.hasProjectPermission(project, user, "VERSION_EDIT")).thenReturn(true); + when(validationService.isGameVersionSupported("new")).thenReturn(true); + when(scanService.nextScanAttempt(null)).thenReturn(2); + var queued = new ScanResult(); queued.setScanAttempt(2); + when(scanService.createQueuedScanResult(eq(2), anyString())).thenReturn(queued); + when(reviewPersistence.applyVersionEdit(any(), anyString(), anyBoolean(), anyBoolean())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.updateVersion( + "project-1", "version-1", null, null, List.of("new"), null, null, user)); + verify(scanService, never()).enqueueBackgroundScan(anyString(), anyString(), anyString(), anyString(), anyBoolean(), anyInt(), any()); + verify(projectService, never()).evictProjectCache(any()); + verify(projectRepository, never()).save(any()); + clearInvocations(reviewPersistence, scanService); + // A fresh request succeeds and uses the newly prepared attempt after the database accepts it. + version.setGameVersions(List.of("old")); version.setScanResult(null); + when(reviewPersistence.applyVersionEdit(any(), anyString(), anyBoolean(), anyBoolean())).thenReturn(true); + service.updateVersion("project-1", "version-1", null, null, List.of("new"), null, null, user); + var order = inOrder(reviewPersistence, scanService); + order.verify(reviewPersistence).applyVersionEdit(any(), eq("version-1"), eq(true), eq(false)); + order.verify(scanService).enqueueBackgroundScan("project-1", "version-1", "mods/mod.jar", "mods/mod.jar", false, 2, queued.getScanRequestId()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, version.getReviewStatus()); + } + @Test void deleteVersionAllowsPrivateProjectsToRemoveTheirLastVersion() { Project project = new Project(); @@ -406,11 +471,18 @@ void deleteVersionAllowsPrivateProjectsToRemoveTheirLastVersion() { when(projectService.getRawProjectById("project-1")).thenReturn(project); when(accessControlService.hasProjectPermission(project, user, "VERSION_DELETE")).thenReturn(true); + when(reviewPersistence.applyVersionList(any())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.deleteVersion("project-1", "version-1", user)); + verify(projectDeletionService, never()).deleteVersionFile(any(ProjectVersion.class)); + project.setVersions(new ArrayList<>(List.of(version))); + clearInvocations(reviewPersistence); + when(reviewPersistence.applyVersionList(any())).thenReturn(true); + service.deleteVersion("project-1", "version-1", user); assertEquals(0, project.getVersions().size()); verify(projectDeletionService).deleteVersionFile(version); - verify(projectRepository).save(project); + verify(reviewPersistence).applyVersionList(any()); verify(projectService).evictProjectCache(project); } diff --git a/backend/src/test/java/net/modtale/service/security/issue/FindingDecisionValidityTest.java b/backend/src/test/java/net/modtale/service/security/issue/FindingDecisionValidityTest.java new file mode 100644 index 000000000..0c440beef --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/issue/FindingDecisionValidityTest.java @@ -0,0 +1,129 @@ +package net.modtale.service.security.issue; + +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.service.security.scan.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class FindingDecisionValidityTest { + private static final long NOW = 1_800_000_000_000L; + private final FindingDecisionValidity evaluator = new FindingDecisionValidity(); + private ProjectVersion version() { + var version = new ProjectVersion(); version.setId("v"); + var scan = ScanEvidenceFixtures.complete(false); version.setScanResult(scan); + version.setHash(scan.getSecurityEvidence().artifactSha256()); + scan.setReviewedContextSha256(ArtifactReviewContext.fingerprint(version)); + var issue = new ScanResult.ScanIssue(); issue.setFilePath("Mod.class"); issue.setType("Network"); + issue.setDescription("Connects to a service"); issue.setSeverity("LOW"); issue.setLineStart(9); issue.setLineEnd(11); + scan.setIssues(new ArrayList<>(List.of(issue))); return version; + } + private FindingReviewService.Event event(ProjectVersion version, String id, FindingReviewService.Disposition disposition, + String revoked, String supersedes, long created, long expires) { + var scan = version.getScanResult(); var e = scan.getSecurityEvidence(); var issue = scan.getIssues().getFirst(); + return new FindingReviewService.Event(id, "p", "v", null, 1, "reviewer", created, expires, disposition, + "Inspected the complete integration", "WHOLE_ARTIFACT", e.artifactSha256(), e.contentSha256(), e.policyVersion(), + ArtifactReviewContext.fingerprint(version), new FindingReviewService.Finding(IssueEvidenceIdentity.from(scan).identify(issue), + issue.getFilePath(), issue.getType(), issue.getDescription(), issue.getLineStart(), issue.getLineEnd()), revoked, supersedes); + } + private FindingReviewService.Event acceptance(ProjectVersion version) { + return event(version, "accepted", FindingReviewService.Disposition.ACCEPT, null, null, NOW - 1000, NOW + 1000); + } + private FindingDecisionValidity.State state(ProjectVersion version, FindingReviewService.Event event, String policy) { + return evaluator.assess("p", version, List.of(event), policy, NOW).get(event.id()).state(); + } + @Test void matchingReasoningDoesNotMutateClearanceOrResolveTheFinding() { + var version = version(); var event = acceptance(version); + assertEquals(FindingDecisionValidity.State.APPLICABLE, state(version, event, event.policyVersion())); + assertFalse(version.getScanResult().getIssues().getFirst().isResolved()); + assertFalse(ArtifactClearancePolicy.cleared(version.getScanResult())); + } + @Test void artifactCallerResourceAndDependencyChangesInvalidateWholeArtifactScope() { + for (String change : List.of("caller", "resource", "dependency", "runtime", "supplement", "unbound")) { + var version = version(); var event = acceptance(version); var scan = version.getScanResult(); + FindingDecisionValidity.State expected; + if (change.equals("caller") || change.equals("resource")) { + var e = scan.getSecurityEvidence(); + var entries = new HashMap<>(e.entryHashes()); entries.put(change + ".data", "d".repeat(64)); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), e.artifactSha256(), SecurityManifest.identity(entries), + true, false, e.reviewState(), entries)); + expected = FindingDecisionValidity.State.ARTIFACT_CHANGED; + } else if (change.equals("unbound")) { + version.setHash("d".repeat(64)); expected = FindingDecisionValidity.State.ARTIFACT_CHANGED; + } else { + if (change.equals("runtime")) version.setGameVersions(List.of("new-runtime")); + else if (change.equals("dependency")) version.setDependencies(List.of(new ProjectDependency("dependency", "Dependency", "1.0"))); + else version.setOverrideFileUrl("supplement.zip"); + expected = FindingDecisionValidity.State.CONTEXT_CHANGED; + } + assertEquals(expected, state(version, event, event.policyVersion()), change); + } + } + @Test void sameVerifiedContentIdentityAllowsPackagingChange() { + var version = version(); var event = acceptance(version); var scan = version.getScanResult(); var e = scan.getSecurityEvidence(); + version.setHash("d".repeat(64)); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), version.getHash(), e.contentSha256(), true, false, e.reviewState(), e.entryHashes())); + assertEquals(FindingDecisionValidity.State.APPLICABLE, state(version, event, event.policyVersion())); + } + @Test void newerPolicyUnavailablePolicyAndChangedOccurrenceCannotRetainAcceptance() { + var version = version(); var event = acceptance(version); + assertEquals(FindingDecisionValidity.State.POLICY_UNAVAILABLE, state(version, event, null)); + assertEquals(FindingDecisionValidity.State.POLICY_CHANGED, state(version, event, "warden-3.0.0:" + "f".repeat(64))); + version.getScanResult().getIssues().getFirst().setLineStart(10); + assertEquals(FindingDecisionValidity.State.FINDING_CHANGED, state(version, event, event.policyVersion())); + } + @Test void incompleteOrAdverseEvidenceCannotBorrowAnAcceptance() { + for (String change : List.of("unverified", "incomplete", "missing-manifest", "block", "new-evidence")) { + var version = version(); var event = acceptance(version); var scan = version.getScanResult(); var e = scan.getSecurityEvidence(); + switch (change) { + case "unverified" -> scan.setArtifactVerified(false); + case "incomplete" -> scan.setScanState("INCOMPLETE"); + case "missing-manifest" -> scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), e.artifactSha256(), e.contentSha256(), true, false, e.reviewState(), Map.of())); + case "block" -> scan.setVerdict("BLOCK"); + case "new-evidence" -> scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), e.artifactSha256(), e.contentSha256(), true, false, "NEW_SECURITY_EVIDENCE", e.entryHashes())); + } + assertEquals(change.equals("block") || change.equals("new-evidence") ? FindingDecisionValidity.State.ADVERSE_EVIDENCE + : FindingDecisionValidity.State.INCOMPLETE_EVIDENCE, state(version, event, event.policyVersion()), change); + } + } + @Test void expiryFutureDatesAndCrossVersionProvenanceAreChecked() { + var version = version(); var event = acceptance(version); + assertEquals(FindingDecisionValidity.State.EXPIRED, evaluator.assess("p", version, List.of(event), event.policyVersion(), NOW + 1000).get(event.id()).state()); + assertEquals(FindingDecisionValidity.State.INVALID_RECORD, evaluator.assess("p", version, List.of(event), event.policyVersion(), NOW - 1001).get(event.id()).state()); + assertEquals(FindingDecisionValidity.State.INVALID_RECORD, evaluator.assess("different-project", version, List.of(event), event.policyVersion(), NOW).get(event.id()).state()); + version.setId("different-version"); + assertEquals(FindingDecisionValidity.State.INVALID_RECORD, state(version, event, event.policyVersion())); + } + @Test void revokedReplacementDoesNotResurrectSupersededAcceptance() { + var version = version(); var first = acceptance(version); + var second = event(version, "replacement", FindingReviewService.Disposition.REQUIRE_REVIEW, null, first.id(), NOW - 500, 0); + var revoke = event(version, "revocation", FindingReviewService.Disposition.REVOKE, second.id(), null, NOW, 0); + var result = evaluator.assess("p", version, List.of(revoke, second, first), first.policyVersion(), NOW); + assertEquals(FindingDecisionValidity.State.SUPERSEDED, result.get(first.id()).state()); + assertEquals(FindingDecisionValidity.State.REVOKED, result.get(second.id()).state()); + assertEquals(FindingDecisionValidity.State.REVOCATION, result.get(revoke.id()).state()); + } + @Test void extendedExpiryUnknownScopeAndMisrepresentedFindingCannotApply() { + for (String change : List.of("expiry", "scope", "description")) { + var version = version(); var original = acceptance(version); + var finding = original.finding(); + if (change.equals("description")) finding = new FindingReviewService.Finding(finding.identity(), finding.path(), + finding.type(), "Different displayed evidence", finding.lineStart(), finding.lineEnd()); + var altered = new FindingReviewService.Event(original.id(), original.projectId(), original.versionId(), original.previousId(), + original.sequence(), original.actorId(), original.createdAt(), change.equals("expiry") ? NOW + 31L * 86400000 : original.expiresAt(), + original.disposition(), original.rationale(), change.equals("scope") ? "ANY_PROJECT" : original.scope(), original.artifactSha256(), + original.contentSha256(), original.policyVersion(), original.contextSha256(), finding, null, null); + var expected = switch (change) { + case "expiry" -> FindingDecisionValidity.State.INVALID_RECORD; + case "scope" -> FindingDecisionValidity.State.UNSUPPORTED_SCOPE; + default -> FindingDecisionValidity.State.FINDING_CHANGED; + }; + assertEquals(expected, state(version, altered, altered.policyVersion()), change); + } + } + @Test void reviewRequirementsNeverExpireIntoAcceptance() { + var version = version(); var hold = event(version, "hold", FindingReviewService.Disposition.REQUIRE_REVIEW, null, null, NOW - 1000, 0); + assertEquals(FindingDecisionValidity.State.REVIEW_REQUIRED, + evaluator.assess("p", version, List.of(hold), hold.policyVersion(), NOW + 100L * 86400000).get(hold.id()).state()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/issue/IssueEvidenceIdentityTest.java b/backend/src/test/java/net/modtale/service/security/issue/IssueEvidenceIdentityTest.java new file mode 100644 index 000000000..4efb796b5 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/issue/IssueEvidenceIdentityTest.java @@ -0,0 +1,152 @@ +package net.modtale.service.security.issue; + +import java.util.*; +import net.modtale.config.properties.AppSecurityProperties; +import net.modtale.model.project.*; +import net.modtale.service.security.scan.ScanEvidenceFixtures; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class IssueEvidenceIdentityTest { + private final SecurityIssueEvaluationService evaluation = new SecurityIssueEvaluationService(120); + + @Test void approvalPruningRetainsEvidenceAndNewScanRemainsUnresolved() { + var prior = approved(scan()); + assertNull(prior.getScanResult()); + assertNotNull(prior.getApprovedIssueBaselines().getFirst().getEvidenceIdentity()); + var current = scan(); + annotate(current, prior); + assertTrue(current.getIssues().getFirst().isHistoricalFileEvidenceIdentical()); + assertFalse(current.getIssues().getFirst().isResolved()); + assertEquals("REVIEW", current.getVerdict()); + } + + @Test void changedFileBehindSameWarningIsNotIdentical() { + var prior = approved(scan()); + var current = scan(); + replaceEntries(current, Map.of("Mod.class", "c".repeat(64))); + annotate(current, prior); + assertTrue(current.getIssues().getFirst().isKnownIssue()); + assertFalse(current.getIssues().getFirst().isHistoricalFileEvidenceIdentical()); + } + + @Test void changedCallerOrRuntimeCannotTurnSameFileEvidenceIntoClearance() { + var prior = approved(scan()); + var current = scan(); + replaceEntries(current, Map.of("Mod.class", "a".repeat(64), "Caller.class", "d".repeat(64))); + current.setReviewedContextSha256("e".repeat(64)); + annotate(current, prior); + assertTrue(current.getIssues().getFirst().isHistoricalFileEvidenceIdentical()); + assertFalse(current.getIssues().getFirst().isResolved()); + assertFalse(net.modtale.service.security.scan.ArtifactClearancePolicy.cleared(current)); + } + + @Test void exactOccurrencePolicyAndSemanticFieldsAreBound() { + var original = scan(); + String expected = IssueEvidenceIdentity.from(original).identify(original.getIssues().getFirst()); + for (String change : List.of("line", "end", "type", "description", "severity", "confidence", "cadence", "tactics", "policy")) { + var changed = scan(); + var issue = changed.getIssues().getFirst(); + switch (change) { + case "line" -> issue.setLineStart(10); // Same legacy eight-line bucket. + case "end" -> issue.setLineEnd(12); + case "type" -> issue.setType("Network|changed"); + case "description" -> issue.setDescription("connect|changed"); + case "severity" -> issue.setSeverity("HIGH"); + case "confidence" -> issue.setConfidence(99); + case "cadence" -> issue.setReviewCadence("ALWAYS"); + case "tactics" -> issue.setTactics(List.of("exfiltration")); + case "policy" -> { + var e = changed.getSecurityEvidence(); + changed.setSecurityEvidence(new ScanResult.SecurityEvidence("warden-3.0.0:" + "f".repeat(64), + e.artifactSha256(), e.contentSha256(), true, false, e.reviewState(), e.entryHashes())); + } + } + assertNotEquals(expected, IssueEvidenceIdentity.from(changed).identify(issue), change); + } + } + + @Test void missingUnverifiedIncompleteCorruptOrUnmappedEvidenceCannotMatch() { + for (String change : List.of("missing", "unverified", "incomplete", "corrupt", "unmapped")) { + var scan = scan(); + switch (change) { + case "missing" -> scan.setSecurityEvidence(null); + case "unverified" -> scan.setArtifactVerified(false); + case "incomplete" -> scan.setScanState("FAILED"); + case "corrupt" -> { + var e = scan.getSecurityEvidence(); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), e.artifactSha256(), + "f".repeat(64), true, false, e.reviewState(), e.entryHashes())); + } + case "unmapped" -> scan.getIssues().getFirst().setFilePath("Other.class"); + } + assertNull(IssueEvidenceIdentity.from(scan).identify(scan.getIssues().getFirst()), change); + } + } + + @Test void legacyHistoryAndUnboundApprovalCannotInventEvidence() { + var prior = approved(scan()); + prior.getApprovedIssueBaselines().getFirst().setEvidenceIdentity(null); + var current = scan(); + current.getIssues().getFirst().setHistoricalFileEvidenceIdentical(true); + annotate(current, prior); + assertFalse(current.getIssues().getFirst().isHistoricalFileEvidenceIdentical()); + var version = new ProjectVersion(); + version.setHash("f".repeat(64)); + version.setScanResult(scan()); + approval().markIssuesAcceptedForApprovedVersion(version); + assertNull(version.getApprovedIssueBaselines().getFirst().getEvidenceIdentity()); + } + + @Test void fieldBoundariesNullsAndMalformedUnicodeCannotAlias() { + var scan = scan(); var issue = scan.getIssues().getFirst(); + var identities = IssueEvidenceIdentity.from(scan); + Set hashes = new HashSet<>(); + for (String value : Arrays.asList(null, "", "null", "?", String.valueOf((char) 0xd800), + String.valueOf((char) 0xd801), "a|b")) { + issue.setDescription(value); + assertTrue(hashes.add(identities.identify(issue))); + } + issue.setType("a|b"); issue.setCategory("c"); + String before = identities.identify(issue); + issue.setType("a"); issue.setCategory("b|c"); + assertNotEquals(before, identities.identify(issue)); + } + + @Test void mergedHistoryKeepsIdentityFromTheDisplayedVersion() { + var a = new SecurityIssueAnalysisService.IssueBaseline("si", "sl", "LOW", 0, 0, "a", "1", 1, 1, "first"); + var b = new SecurityIssueAnalysisService.IssueBaseline("si", "sl", "HIGH", 2, 8, "b", "2", 2, 1, "second"); + assertEquals("second", a.mergeWith(b).evidenceIdentity()); + assertEquals("b", a.mergeWith(b).versionId()); + assertEquals("second", b.mergeWith(a).evidenceIdentity()); + } + + private void annotate(ScanResult current, ProjectVersion prior) { + var project = new Project(); project.setVersions(List.of(prior)); + evaluation.annotateAgainstBaselines(current, + new SecurityIssueBaselineService().collectApprovedIssueBaselines(project, null, evaluation)); + } + private SecurityIssueApprovalService approval() { + return new SecurityIssueApprovalService(new SecurityIssueClassificationService(new AppSecurityProperties("test", 60, 120, 2, 12, 15, 120, 25, 2))); + } + private ProjectVersion approved(ScanResult scan) { + var version = new ProjectVersion(); version.setId("prior"); version.setVersionNumber("1.0"); + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + version.setHash(scan.getSecurityEvidence().artifactSha256()); version.setScanResult(scan); + approval().markIssuesAcceptedForApprovedVersion(version); + return version; + } + private ScanResult scan() { + var scan = ScanEvidenceFixtures.complete(false); + var issue = new ScanResult.ScanIssue(); issue.setType("Network"); issue.setCategory("NETWORK"); + issue.setDescription("connect"); issue.setFilePath("Mod.class"); issue.setSeverity("LOW"); + issue.setLineStart(9); issue.setLineEnd(11); + scan.setIssues(new ArrayList<>(List.of(issue))); + return scan; + } + private void replaceEntries(ScanResult scan, Map entries) { + var e = scan.getSecurityEvidence(); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(), "c".repeat(64), + SecurityManifest.identity(entries), true, false, e.reviewState(), entries)); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/issue/PriorFindingReasoningServiceTest.java b/backend/src/test/java/net/modtale/service/security/issue/PriorFindingReasoningServiceTest.java new file mode 100644 index 000000000..86b0186de --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/issue/PriorFindingReasoningServiceTest.java @@ -0,0 +1,92 @@ +package net.modtale.service.security.issue; + +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.service.admin.review.ProjectReviewSnapshot; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.scan.*; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PriorFindingReasoningServiceTest { + static class Fixture { + final ProjectService projects=mock(ProjectService.class); + final MongoTemplate mongo=mock(MongoTemplate.class); + final PriorFindingReasoningService service=new PriorFindingReasoningService(projects,mongo); + final Project project=new Project();final ProjectVersion source=new ProjectVersion(),target=new ProjectVersion(); + final FindingReviewService.Event event; + Fixture() { + project.setId("project");source.setId("source");source.setVersionNumber("1");target.setId("target");target.setVersionNumber("2"); + var scan=ScanEvidenceFixtures.complete(false);target.setScanResult(scan);target.setHash(scan.getSecurityEvidence().artifactSha256()); + var issue=new ScanResult.ScanIssue();issue.setFilePath("Mod.class");issue.setType("Network");issue.setDescription("Connects to documented service");issue.setSeverity("LOW");issue.setLineStart(9);issue.setLineEnd(11);scan.setIssues(new ArrayList<>(List.of(issue))); + scan.setReviewedContextSha256(ArtifactReviewContext.fingerprint(target)); + source.setHash(target.getHash());source.setApprovedSecurityEvidence(scan.getSecurityEvidence()); + source.setApprovedSecurityContextSha256(ArtifactReviewContext.fingerprint(source)); + source.setSecurityApprovedAt(System.currentTimeMillis()-1000);source.setSecurityApprovalProjectId("project"); + source.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED);source.setApprovedReviewOrigins(Map.of()); + source.setFindingReviewHead("event");source.setApprovedFindingReviewHead("event"); + String identity=IssueEvidenceIdentity.from(scan).identify(issue);var baseline=new ProjectVersion.ApprovedIssueBaseline();baseline.setEvidenceIdentity(identity);source.setApprovedIssueBaselines(List.of(baseline)); + var evidence=scan.getSecurityEvidence(); + event=new FindingReviewService.Event("event","project","source",null,1,"reviewer",source.getSecurityApprovedAt()-1000,System.currentTimeMillis()+86400000, + FindingReviewService.Disposition.ACCEPT,"Verified the documented service integration","WHOLE_ARTIFACT",evidence.artifactSha256(),evidence.contentSha256(),evidence.policyVersion(),source.getApprovedSecurityContextSha256(), + new FindingReviewService.Finding(identity,issue.getFilePath(),issue.getType(),issue.getDescription(),9,11),null,null); + project.setVersions(List.of(source,target));when(projects.getRawProjectById("project")).thenReturn(project); + when(mongo.findById("event",FindingReviewService.Event.class,FindingReviewService.COLLECTION)).thenReturn(event); + } + PriorFindingReasoningService.Reasoning read(){return service.read("project","target","source",0,ProjectReviewSnapshot.token(project));} + } + @Test void returnsExactApprovedReasoningAcrossChangedCallerAndRuntimeWithoutResolving() { + var f=new Fixture();var scan=f.target.getScanResult();var e=scan.getSecurityEvidence();var entries=new HashMap<>(e.entryHashes());entries.put("Caller.class","c".repeat(64)); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(),e.artifactSha256(),SecurityManifest.identity(entries),true,false,e.reviewState(),entries)); + f.target.setGameVersions(List.of("new-runtime"));scan.setReviewedContextSha256(ArtifactReviewContext.fingerprint(f.target)); + var result=f.read();assertEquals(List.of(f.event),result.decisions()); + assertTrue(result.reviewReasons().stream().anyMatch(v->v.contains("Artifact contents changed"))); + assertTrue(result.reviewReasons().stream().anyMatch(v->v.contains("context changed"))); + assertFalse(scan.getIssues().getFirst().isResolved());assertFalse(ArtifactClearancePolicy.cleared(scan)); + verify(f.mongo,never()).insert(any(),anyString()); + } + @Test void changedFindingFileOrDescriptionCannotBorrowIdenticalLookingReasoning() { + for(boolean content:new boolean[]{true,false}) { + var f=new Fixture();var scan=f.target.getScanResult();var e=scan.getSecurityEvidence(); + if(content){var entries=Map.of("Mod.class","c".repeat(64));scan.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(),e.artifactSha256(),SecurityManifest.identity(entries),true,false,e.reviewState(),entries));} + else scan.getIssues().getFirst().setDescription("Changed endpoint reasoning"); + assertTrue(f.read().decisions().isEmpty()); + } + } + @Test void staleOrIncompleteCurrentEvidenceCannotReadAsVerifiedMatch() { + var f=new Fixture();assertThrows(ResponseStatusException.class,()->f.service.read("project","target","source",0,"old"));verifyNoInteractions(f.mongo); + f.target.getScanResult().setArtifactVerified(false);assertThrows(ResponseStatusException.class,f::read);verifyNoInteractions(f.mongo); + } + @Test void unapprovedCopiedExpiredOrEditedSourceCannotSupplyVerifiedReasoning() { + for(int scenario=0;scenario<5;scenario++) { + var f=new Fixture();switch(scenario){case 0->f.source.setReviewStatus(ProjectVersion.ReviewStatus.REJECTED);case 1->f.source.setSecurityApprovalProjectId("another");case 2->f.source.setSecurityApprovedAt(1);case 3->f.source.setFindingReviewHead("changed");case 4->f.source.setGameVersions(List.of("edited"));} + assertThrows(ResponseStatusException.class,f::read);verifyNoInteractions(f.mongo); + } + } + @Test void historyChangesDuringReadRejectTheWholeResponse() { + var f=new Fixture();doAnswer(call->{f.source.setFindingReviewHead("revoked");return f.event;}) + .when(f.mongo).findById("event",FindingReviewService.Event.class,FindingReviewService.COLLECTION); + assertThrows(ResponseStatusException.class,f::read); + } + @Test void missingAndCrossVersionHistoryFailExplicitly() { + var f=new Fixture();when(f.mongo.findById("event",FindingReviewService.Event.class,FindingReviewService.COLLECTION)).thenReturn(null); + assertThrows(ResponseStatusException.class,f::read); + when(f.mongo.findById("event",FindingReviewService.Event.class,FindingReviewService.COLLECTION)).thenReturn(new FindingReviewService.Event("event","other","source",null,1,"reviewer",1,2,FindingReviewService.Disposition.ACCEPT,"Reasoning for another project","WHOLE_ARTIFACT",null,null,null,null,f.event.finding(),null,null)); + assertThrows(ResponseStatusException.class,f::read); + } + @Test void revokedAndSupersededAcceptancesNeverReappear() { + for(boolean revoke:new boolean[]{true,false}) { + var f=new Fixture();var e=f.event; + var next=new FindingReviewService.Event("next","project","source","event",2,e.actorId(),e.createdAt()+1,e.expiresAt(), + revoke?FindingReviewService.Disposition.REVOKE:FindingReviewService.Disposition.ACCEPT,"Corrected earlier review conclusion",e.scope(),e.artifactSha256(),e.contentSha256(),e.policyVersion(),e.contextSha256(),e.finding(),revoke?e.id():null,revoke?null:e.id()); + f.source.setFindingReviewHead("next");f.source.setApprovedFindingReviewHead("next");when(f.mongo.findById("next",FindingReviewService.Event.class,FindingReviewService.COLLECTION)).thenReturn(next); + var result=f.read();assertFalse(result.decisions().contains(e));assertEquals(revoke?List.of():List.of(next),result.decisions()); + } + } + @Test void absentApprovedOccurrenceNeverManufacturesRecordedApproval() { + var f=new Fixture();f.source.setApprovedIssueBaselines(List.of());assertTrue(f.read().decisions().isEmpty()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueApprovalServiceTest.java b/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueApprovalServiceTest.java index 9c4a6124b..67d417d41 100644 --- a/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueApprovalServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueApprovalServiceTest.java @@ -54,6 +54,44 @@ void approvalKeepsCompactBaselinesAndClearsHeavyScanResult() { assertFalse(issue.isEscalated()); } + @Test + void approvalRetainsDigestAndContextWithoutDuplicatingArchiveManifest() { + ProjectVersion version = approvedVersion("version", "1.0"); + ScanResult scan = net.modtale.service.security.scan.ScanEvidenceFixtures.complete(false); + scan.setIssues(new ArrayList<>()); + version.setHash(scan.getSecurityEvidence().artifactSha256()); + scan.setReviewedContextSha256(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(version)); + String contentHash = scan.getSecurityEvidence().contentSha256(); + version.setScanResult(scan); + approvalService.markIssuesAcceptedForApprovedVersion(version); + assertEquals(contentHash, version.getApprovedSecurityEvidence().contentSha256()); + assertTrue(version.getApprovedSecurityEvidence().entryHashes().isEmpty()); + assertEquals(64, version.getApprovedSecurityContextSha256().length()); + } + + @Test + void missingOrUnverifiedEvidenceCannotBecomeReusableApproval() { + for (String scenario : List.of("missing", "unverified", "wrong-artifact", "wrong-context")) { + ProjectVersion version = approvedVersion("version", "1.0"); + ScanResult scan = net.modtale.service.security.scan.ScanEvidenceFixtures.complete(false); + version.setHash(scan.getSecurityEvidence().artifactSha256()); + scan.setReviewedContextSha256(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(version)); + version.setApprovedSecurityEvidence(scan.getSecurityEvidence()); + version.setApprovedSecurityContextSha256("old"); + version.setSecurityApprovedAt(123); + switch (scenario) { + case "unverified" -> scan.setArtifactVerified(false); + case "wrong-artifact" -> version.setHash("c".repeat(64)); + case "wrong-context" -> scan.setReviewedContextSha256("d".repeat(64)); + } + version.setScanResult(scenario.equals("missing") ? null : scan); + approvalService.markIssuesAcceptedForApprovedVersion(version); + assertNull(version.getApprovedSecurityEvidence(), scenario); + assertNull(version.getApprovedSecurityContextSha256(), scenario); + assertEquals(0, version.getSecurityApprovedAt(), scenario); + } + } + @Test void storedBaselinesAreUsedAfterScanResultIsRemoved() { ProjectVersion version = approvedVersion("version-1", "1.0.0"); diff --git a/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueBaselineServiceTest.java b/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueBaselineServiceTest.java index 4b73eade9..d7971c295 100644 --- a/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueBaselineServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueBaselineServiceTest.java @@ -69,9 +69,9 @@ void collectApprovedIssueBaselinesUsesStoredBaselinesAndSkipsExcludedOrUnapprove } @Test - void collectApprovedIssueBaselinesBuildsAndMergesBaselinesFromScanResults() { + void collectApprovedIssueBaselinesMergesIdenticalEvidenceFromScanResults() { ScanResult.ScanIssue olderLow = issue("Runtime", "Reflection", "Danger 1", "Foo.class", 1, "LOW", 3, 20); - ScanResult.ScanIssue newerHigh = issue("Runtime", "Reflection", "Danger 2", "Foo.class", 1, "HIGH", 8, 70); + ScanResult.ScanIssue newerHigh = issue("Runtime", "Reflection", "Danger 1", "Foo.class", 1, "HIGH", 8, 70); ProjectVersion older = approvedVersion("version-1", "1.0.0"); older.setReleaseDate("2026-01-01T00:00:00Z"); older.setScanResult(scan(olderLow)); diff --git a/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueEvaluationServiceTest.java b/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueEvaluationServiceTest.java index 98bd5d2fe..c2c07fa12 100644 --- a/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueEvaluationServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/issue/SecurityIssueEvaluationServiceTest.java @@ -24,7 +24,7 @@ void setUp() { } @Test - void fingerprintNormalizesCasePathsLineBucketsAndVolatileDescriptionValues() { + void fingerprintPreservesChangedPathsAndSecurityRelevantDescriptionValues() { ScanResult.ScanIssue first = issue( "Dangerous Call", "Runtime/Reflection", @@ -46,8 +46,8 @@ void fingerprintNormalizesCasePathsLineBucketsAndVolatileDescriptionValues() { 70 ); - assertEquals(service.fingerprint(first), service.fingerprint(second)); - assertEquals(service.looseFingerprint(first), service.looseFingerprint(second)); + assertFalse(service.fingerprint(first).equals(service.fingerprint(second))); + assertFalse(service.looseFingerprint(first).equals(service.looseFingerprint(second))); assertTrue(service.fingerprint(first).startsWith("si:")); assertTrue(service.looseFingerprint(first).startsWith("sl:")); } @@ -107,7 +107,7 @@ void annotateAgainstBaselinesResolvesKnownExactMatchesWithoutEscalation() { assertEquals(0, stats.escalatedIssueCount()); assertTrue(stats.knownOnly()); assertTrue(knownIssue.isKnownIssue()); - assertTrue(knownIssue.isResolved()); + assertFalse(knownIssue.isResolved()); assertFalse(knownIssue.isEscalated()); assertEquals("1.0.0", knownIssue.getBaselineVersion()); assertEquals(5, knownIssue.getBaselineScoreImpact()); diff --git a/backend/src/test/java/net/modtale/service/security/scan/ArtifactManifestReferencesTest.java b/backend/src/test/java/net/modtale/service/security/scan/ArtifactManifestReferencesTest.java new file mode 100644 index 000000000..7cc835fcf --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ArtifactManifestReferencesTest.java @@ -0,0 +1,62 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.modtale.config.db.*; +import net.modtale.model.project.*; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ArtifactManifestReferencesTest { + private static class MemoryStore implements ArtifactManifestStore { + final Map> records = new HashMap<>(); int reads, writes; + public String put(Map entries) { writes++; String id = SecurityManifest.identity(entries); records.putIfAbsent(id, Map.copyOf(entries)); return id; } + public Map get(String id) { reads++; return records.get(id); } + } + @Test void referencesPreserveFullEvidenceAndPublicJsonWithoutDuplicatingProjectPayloads() throws Exception { + var store = new MemoryStore(); var original = ScanEvidenceFixtures.complete(true); + var writer = new SecurityEvidenceConverters.Write(store); var reader = new SecurityEvidenceConverters.Read(store); + Document stored = writer.convert(original.getSecurityEvidence()); + assertFalse(stored.containsKey("entryHashes")); assertTrue(stored.containsKey("manifestRef")); + var restored = reader.convert(stored); assertEquals(0, store.reads); + assertEquals(stored, writer.convert(restored)); assertEquals(0, store.reads); assertEquals(1, store.writes); + original.setSecurityEvidence(restored); + assertTrue(ArtifactClearancePolicy.complete(original)); assertEquals(1, store.reads); + String json = new ObjectMapper().writeValueAsString(restored); + assertTrue(json.contains("entryHashes")); assertFalse(json.contains("manifestRef")); + assertEquals(1, store.reads); + assertThrows(UnsupportedOperationException.class, () -> restored.entryHashes().put("other", "a".repeat(64))); + } + @Test void missingOrSubstitutedRecordsCannotGrantClearance() { + for (boolean missing : List.of(true, false)) { + var store = new MemoryStore(); var result = ScanEvidenceFixtures.complete(true); + Document stored = new SecurityEvidenceConverters.Write(store).convert(result.getSecurityEvidence()); + String id = stored.getString("manifestRef"); + if (missing) store.records.clear(); else store.records.put(id, Map.of("different.class", "f".repeat(64))); + result.setSecurityEvidence(new SecurityEvidenceConverters.Read(store).convert(stored)); + assertFalse(ArtifactClearancePolicy.complete(result)); assertFalse(ArtifactClearancePolicy.cleared(result)); + } + } + @Test void legacyEvidenceMigratesWhenWrittenAndAmbiguousReferencePayloadsAreRejected() { + var store = new MemoryStore(); var evidence = ScanEvidenceFixtures.complete(true).getSecurityEvidence(); + Document legacy = new SecurityEvidenceConverters.Write().convert(evidence); + var restored = new SecurityEvidenceConverters.Read(store).convert(legacy); + assertEquals(evidence.entryHashes(), restored.entryHashes()); + Document migrated = new SecurityEvidenceConverters.Write(store).convert(restored); + assertTrue(migrated.containsKey("manifestRef")); + migrated.put("entryHashes", List.of()); + assertThrows(Exception.class, () -> new SecurityEvidenceConverters.Read(store).convert(migrated)); + } + @Test void productionConfigurationCanConstructStoreAwareConvertersWithoutDatabaseMappingCycles() { + try (var context = new AnnotationConfigApplicationContext()) { + context.registerBean(MongoDatabaseFactory.class, () -> mock(MongoDatabaseFactory.class)); + context.register(MongoConfig.class); context.refresh(); + assertInstanceOf(MongoArtifactManifestStore.class, context.getBean(ArtifactManifestStore.class)); + assertNotNull(context.getBean(org.springframework.data.mongodb.core.convert.MongoCustomConversions.class)); + } + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewLineageTest.java b/backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewLineageTest.java new file mode 100644 index 000000000..56062368f --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewLineageTest.java @@ -0,0 +1,82 @@ +package net.modtale.service.security.scan; + +import java.util.*; +import net.modtale.model.project.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ArtifactReviewLineageTest { + private ProjectVersion source(String id) { + var v = new ProjectVersion(); v.setId(id); v.setVersionNumber(id); + v.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); v.setSecurityApprovalProjectId("p"); + var evidence = ScanEvidenceFixtures.complete(false).getSecurityEvidence(); + v.setHash(evidence.artifactSha256()); v.setApprovedSecurityEvidence(evidence); + v.setApprovedSecurityContextSha256(ArtifactReviewContext.fingerprint(v)); + v.setSecurityApprovedAt(System.currentTimeMillis() - 1000); v.setApprovedReviewOrigins(Map.of()); return v; + } + @Test void copiedApprovalRetainsEveryOriginalSource() { + var root = source("root"); var copy = source("copy"); var project = new Project(); project.setId("p"); project.setVersions(List.of(root, copy)); + copy.setApprovedReviewOrigins(ArtifactReviewLineage.extend(project, root)); + var lineage = ArtifactReviewLineage.extend(project, copy); + assertEquals(Set.of("root", "copy"), lineage.keySet()); assertTrue(ArtifactReviewLineage.valid(project, lineage)); + root.setReviewStatus(ProjectVersion.ReviewStatus.REJECTED); + assertFalse(ArtifactReviewLineage.valid(project, lineage)); assertNull(ArtifactReviewLineage.extend(project, copy)); + } + @Test void removingAnAncestorCannotLaunderAStoredProof() { + var root = source("root"); var copy = source("copy"); var project = new Project(); project.setId("p"); project.setVersions(List.of(root, copy)); + copy.setApprovedReviewOrigins(ArtifactReviewLineage.extend(project, root)); + var lineage = ArtifactReviewLineage.extend(project, copy); + assertFalse(ArtifactReviewLineage.valid(project, Map.of("copy", lineage.get("copy")))); + copy.setApprovedReviewOrigins(Map.of()); + assertFalse(ArtifactReviewLineage.valid(project, lineage)); + } + @Test void changedRevokedExpiredOrLegacySourceCannotBeReused() { + for (String change : List.of("hash", "context", "decision", "expiry", "legacy", "missing")) { + var root = source("root"); var project = new Project(); project.setId("p"); project.setVersions(List.of(root)); + var lineage = ArtifactReviewLineage.extend(project, root); + switch (change) { + case "hash" -> root.setHash("f".repeat(64)); + case "context" -> root.setGameVersions(List.of("changed")); + case "decision" -> root.setFindingReviewHead("revocation"); + case "expiry" -> root.setSecurityApprovedAt(System.currentTimeMillis() - 31L * 86400000); + case "legacy" -> root.setApprovedReviewOrigins(null); + case "missing" -> project.setVersions(List.of()); + } + assertFalse(ArtifactReviewLineage.valid(project, lineage), change); + } + } + @Test void copyingVersionsToAnotherProjectDoesNotCopyApprovalAuthority() { + var root = source("root"); var project = new Project(); project.setId("p"); project.setVersions(List.of(root)); + var origins = ArtifactReviewLineage.extend(project, root); + project.setId("copied-project"); + assertFalse(ArtifactReviewLineage.valid(project, origins)); + assertNull(ArtifactReviewLineage.extend(project, root)); + } + @Test void networkPayloadCannotInjectOriginsAndSnapshotsBindServerOrigins() throws Exception { + var json=new com.fasterxml.jackson.databind.ObjectMapper(); + var result=json.readValue("{\"reusedReviewOrigins\":{\"source\":\""+"a".repeat(64)+"\"}}",ScanResult.class); + assertNull(result.getReusedReviewOrigins()); + var current=new ProjectVersion();current.setScanResult(result); + String before=net.modtale.service.admin.review.VersionReviewSnapshot.token(current); + result.setReusedReviewOrigins(Map.of("source","a".repeat(64))); + assertNotEquals(before,net.modtale.service.admin.review.VersionReviewSnapshot.token(current)); + assertFalse(json.writeValueAsString(result).contains("reusedReviewOrigins")); + } + @Test void lineageIsBoundedAndRejectsUnsafeStorageKeys() { + var entries=new HashMap(); + for(int i=0;i<32;i++) entries.put("version-"+i,"a".repeat(64)); + assertTrue(ArtifactReviewLineage.wellFormed(entries)); + entries.put("overflow","a".repeat(64)); assertFalse(ArtifactReviewLineage.wellFormed(entries)); + for(String id:List.of("version.with.dot","$operator","", "a".repeat(129))) + assertFalse(ArtifactReviewLineage.wellFormed(Map.of(id,"a".repeat(64)))); + assertFalse(ArtifactReviewLineage.wellFormed(null)); + } + @Test void freshIndependentClearanceDoesNotAcquireHistoricalDependencies() { + var root = source("root"); var current = new ProjectVersion(); current.setId("current"); + var project = new Project(); project.setId("p"); project.setVersions(List.of(root,current)); + var result = ScanEvidenceFixtures.complete(true); + new ArtifactReviewReuseService().annotate(project, "current", result); + assertNull(result.getReusedReviewVersion()); assertNull(result.getReusedReviewOrigins()); + assertTrue(ArtifactClearancePolicy.cleared(result)); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewReuseServiceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewReuseServiceTest.java new file mode 100644 index 000000000..2719c52d1 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ArtifactReviewReuseServiceTest.java @@ -0,0 +1,102 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import org.junit.jupiter.api.Test; +import java.time.Duration; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class ArtifactReviewReuseServiceTest { + private final ArtifactReviewReuseService service = new ArtifactReviewReuseService(); + private Project project(ScanResult result) { + ProjectVersion prior = new ProjectVersion(); + prior.setId("prior"); prior.setVersionNumber("1.0"); + prior.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + prior.setApprovedSecurityEvidence(result.getSecurityEvidence()); + prior.setHash(result.getSecurityEvidence().artifactSha256()); + prior.setApprovedReviewOrigins(Map.of()); + prior.setSecurityApprovalProjectId("p"); + prior.setSecurityApprovedAt(System.currentTimeMillis() - 1000); + prior.setApprovedSecurityContextSha256(ArtifactReviewContext.fingerprint(prior)); + ProjectVersion current = new ProjectVersion(); current.setId("new"); current.setVersionNumber("1.1"); + Project project = new Project(); project.setId("p"); project.setVersions(List.of(prior, current)); return project; + } + @Test void freshAdverseEvidenceCannotBeHiddenByAnIdenticalHistoricalApproval() { + var result=ScanEvidenceFixtures.complete(false); + var project=project(result); + var evidence=result.getSecurityEvidence(); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(evidence.policyVersion(),evidence.artifactSha256(), + evidence.contentSha256(),true,false,"NEW_SECURITY_EVIDENCE",evidence.entryHashes())); + service.annotate(project,"new",result); + assertNull(result.getReusedReviewVersion()); + result.setReusedReviewVersion("forged-stale-reuse"); + assertFalse(ArtifactClearancePolicy.cleared(result)); + } + @Test void recordedDecisionsPreventImplicitReuseUntilExplicitlyEvaluated() { + for (int index : List.of(0, 1)) { + var result = ScanEvidenceFixtures.complete(false); + var project = project(result); + project.getVersions().get(index).setFindingReviewHead("decision-history"); + service.annotate(project, "new", result); + assertNull(result.getReusedReviewVersion()); + } + } + @Test void unchangedFullyInspectedArtifactReusesReview() { + ScanResult result = ScanEvidenceFixtures.complete(false); + Project project = project(result); + service.annotate(project, "new", result); + assertEquals("1.0", result.getReusedReviewVersion()); + assertEquals(project.getVersions().getFirst().getSecurityApprovedAt(), result.getReusedReviewApprovedAt()); + } + @Test void staleRejectedUnverifiedOrChangedArtifactsCannotReuse() { + for (String scenario : List.of("stale", "rejected", "unverified", "changed", "policy", "blocked", "incomplete")) { + ScanResult result = ScanEvidenceFixtures.complete(false); + Project project = project(result); + ProjectVersion prior = project.getVersions().getFirst(); + var evidence = result.getSecurityEvidence(); + switch (scenario) { + case "stale" -> prior.setSecurityApprovedAt(System.currentTimeMillis() - Duration.ofDays(31).toMillis()); + case "rejected" -> prior.setReviewStatus(ProjectVersion.ReviewStatus.REJECTED); + case "unverified" -> result.setArtifactVerified(false); + case "changed" -> prior.setApprovedSecurityEvidence(new ScanResult.SecurityEvidence(evidence.policyVersion(), evidence.artifactSha256(), "c".repeat(64), true, false, "COMPLETED", Map.of("Mod.class", "d".repeat(64)))); + case "policy" -> prior.setApprovedSecurityEvidence(new ScanResult.SecurityEvidence("older", evidence.artifactSha256(), evidence.contentSha256(), true, false, "COMPLETED", evidence.entryHashes())); + case "blocked" -> result.setVerdict("BLOCK"); + case "incomplete" -> result.getSummary().setRecoverableErrors(1); + } + service.annotate(project, "new", result); + assertNull(result.getReusedReviewVersion(), scenario); + } + } + @Test void changedContextOrSupplementalContentCannotReuse() { + for (String scenario : List.of("games", "dependency", "override", "missing-snapshot", "edited-prior")) { + ScanResult result = ScanEvidenceFixtures.complete(false); + Project project = project(result); + ProjectVersion current = project.getVersions().get(1); + switch (scenario) { + case "games" -> current.setGameVersions(List.of("changed-runtime")); + case "dependency" -> current.setDependencies(List.of(new ProjectDependency("dependency", "Dependency", "1.0"))); + case "override" -> current.setOverrideFileUrl("storage/supplement.zip"); + case "missing-snapshot" -> project.getVersions().getFirst().setApprovedSecurityContextSha256(null); + case "edited-prior" -> project.getVersions().getFirst().setGameVersions(List.of("edited-after-approval")); + } + service.annotate(project, "new", result); + assertNull(result.getReusedReviewVersion(), scenario); + } + } + @Test void compactContentDigestIsSufficientWithoutRetainingEveryPriorEntry() { + ScanResult result = ScanEvidenceFixtures.complete(false); + Project project = project(result); + var evidence = result.getSecurityEvidence(); + project.getVersions().getFirst().setApprovedSecurityEvidence(new ScanResult.SecurityEvidence( + evidence.policyVersion(), evidence.artifactSha256(), evidence.contentSha256(), true, false, "COMPLETED", Map.of())); + service.annotate(project, "new", result); + assertEquals("1.0", result.getReusedReviewVersion()); + } + @Test void v2ApprovalDoesNotSupplyV3Evidence() { + ScanResult result = ScanEvidenceFixtures.complete(false); + Project project = project(result); + project.getVersions().getFirst().setApprovedSecurityEvidence(null); + service.annotate(project, "new", result); + assertNull(result.getReusedReviewVersion()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewClientTest.java b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewClientTest.java new file mode 100644 index 000000000..88bd82524 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewClientTest.java @@ -0,0 +1,140 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.*; +import net.modtale.config.properties.AppWardenProperties; +import net.modtale.model.project.*; +import org.junit.jupiter.api.*; +import java.net.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; + +class RemoteReviewClientTest { + HttpServer server;RemoteReviewClient client;ObjectMapper mapper=new ObjectMapper(); + byte[] bytes="inert original".getBytes(java.nio.charset.StandardCharsets.UTF_8); + RemoteReviewOrigin origin=new RemoteReviewOrigin("11111111-1111-1111-1111-111111111111","e".repeat(64)); + String expectedApiKey="fixture-key"; + String request=UUID.randomUUID().toString(),job=UUID.randomUUID().toString();RemoteReviewBinding binding; + AtomicInteger calls=new AtomicInteger(),posts=new AtomicInteger(); + interface Handler { void handle(HttpExchange exchange)throws Exception; } + void route(Handler handler) { + server.createContext("/api/v1/review-jobs",exchange->{calls.incrementAndGet();if(exchange.getRequestMethod().equals("POST"))posts.incrementAndGet(); + try {assertEquals(expectedApiKey,exchange.getRequestHeaders().getFirst("X-Warden-Api-Key")); + if(exchange.getRequestURI().getPath().endsWith("/identity")){reply(exchange,200,origin);return;} + assertEquals(origin.deploymentId(),exchange.getRequestHeaders().getFirst("X-Warden-Deployment-Id"));assertEquals(origin.callerScope(),exchange.getRequestHeaders().getFirst("X-Warden-Caller-Scope"));handler.handle(exchange);} + catch(Exception failure){throw new RuntimeException(failure);}finally{exchange.close();}}); + } + void reply(HttpExchange exchange,int code,Object value)throws Exception { + byte[] body=mapper.writeValueAsBytes(value);exchange.getResponseHeaders().set("Content-Type","application/json"); + exchange.sendResponseHeaders(code,body.length);exchange.getResponseBody().write(body); + } + Map status(String state) { + var result=new LinkedHashMap();result.put("jobId",job);result.put("requestId",request);result.put("binding",wireBinding()); + result.put("state",state);result.put("artifactRetained",!state.equals("AWAITING_UPLOAD"));result.put("createdAt",1000L);result.put("expiresAt",2000L);result.put("workState",null);return result; + } + Map wireBinding(){return Map.of("artifactSha256",binding.artifactSha256(),"contextSha256",binding.contextSha256(),"policyVersion",binding.policyVersion(),"reviewConfigSha256",binding.reviewConfigSha256());} + @BeforeEach void setup()throws Exception { + server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0);server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());server.start(); + client=new RemoteReviewClient(new AppWardenProperties("http://127.0.0.1:"+server.getAddress().getPort(),"fixture-key",true,9,600),Duration.ofSeconds(2)); + binding=new RemoteReviewBinding("p","v",request,1,"original.zip",HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)), + "b".repeat(64),"warden-3.0.0:"+"c".repeat(64),"d".repeat(64),null,false,origin); + } + @AfterEach void cleanup(){client.close();server.stop(0);} + @Test void lostSubmissionResponseIsRecoveredBySameRequestWithoutAnotherPost() { + var accepted=new AtomicBoolean();route(e->{ + if(e.getRequestMethod().equals("POST")){assertTrue(new String(e.getRequestBody().readAllBytes()).contains(request));accepted.set(true);return;} + assertTrue(e.getRequestURI().getPath().endsWith(request));assertTrue(e.getRequestURI().getQuery().contains(binding.contextSha256())); + reply(e,accepted.get()?200:404,accepted.get()?status("QUEUED"):Map.of()); + }); + assertThrows(RemoteReviewClient.Unavailable.class,()->client.submitOrFind(binding,bytes)); + assertEquals(job,client.submitOrFind(binding,bytes).jobId());assertEquals(1,posts.get());assertEquals(3,calls.get()); + } + @Test void awaitingUploadResubmitsOnlyOriginalRequestAndRequiresSameJob() { + route(e->{if(e.getRequestMethod().equals("POST")){e.getRequestBody().readAllBytes();var changed=status("QUEUED");changed.put("jobId",UUID.randomUUID().toString());reply(e,202,changed);}else reply(e,200,status("AWAITING_UPLOAD"));}); + assertThrows(RemoteReviewClient.Unavailable.class,()->client.submitOrFind(binding,bytes));assertEquals(1,posts.get()); + } + @Test void submissionRejectsWrongOriginalBeforeAnyHttp() { + route(e->reply(e,200,status("QUEUED")));assertThrows(IllegalArgumentException.class,()->client.submitOrFind(binding,new byte[]{1}));assertEquals(0,calls.get()); + } + @Test void mismatchedIdentityAndUnknownStateNeverBecomeUsableStatus() { + var response=new AtomicReference<>(status("QUEUED"));route(e->reply(e,200,response.get())); + response.get().put("requestId",UUID.randomUUID().toString());assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding)); + response.set(status("NEW_UNKNOWN_STATE"));assertThrows(RemoteReviewClient.Unavailable.class,()->client.status(binding.withJobId(job))); + response.set(status("QUEUED"));response.get().put("binding",Map.of());assertThrows(RemoteReviewClient.Unavailable.class,()->client.status(binding.withJobId(job))); + } + @Test void redirectsAndTemporaryFailuresAreNotRetriedOrTurnedIntoFindings() { + route(e->{e.getResponseHeaders().set("Location","/leaked");reply(e,307,Map.of());}); + assertEquals(307,assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding)).status());assertEquals(1,calls.get()); + } + @Test void onlyNotFoundPermitsSubmission() { + route(e->reply(e,503,Map.of()));assertEquals(503,assertThrows(RemoteReviewClient.Unavailable.class,()->client.submitOrFind(binding,bytes)).status());assertEquals(0,posts.get());assertEquals(1,calls.get()); + } + @Test void boundResultRetainsOriginalContextAndRejectsWrongJob() { + var response=new LinkedHashMap();response.put("jobId",job);response.put("requestId",request);response.put("binding",wireBinding());response.put("completedAt",1500L); + var scan=ScanEvidenceFixtures.complete(true);var old=scan.getSecurityEvidence(); + scan.setSecurityEvidence(new ScanResult.SecurityEvidence(binding.policyVersion(),binding.artifactSha256(),old.contentSha256(),true,true,old.reviewState(),old.entryHashes()));response.put("scan",scan); + route(e->reply(e,200,response));var result=client.result(binding.withJobId(job)); + assertTrue(result.isArtifactVerified());assertEquals(binding.contextSha256(),result.getReviewedContextSha256());assertEquals(request,result.getScanRequestId()); + response.put("jobId",UUID.randomUUID().toString());assertThrows(RemoteReviewClient.Unavailable.class,()->client.result(binding.withJobId(job))); + } + @Test void duplicateJsonKeysAndOversizedStatusAreRejected() { + var raw=new AtomicReference<>("{\"policyVersion\":\"a\",\"policyVersion\":\"b\"}");route(e->{byte[] body=raw.get().getBytes();e.sendResponseHeaders(200,body.length);e.getResponseBody().write(body);}); + assertThrows(RemoteReviewClient.Unavailable.class,()->client.configuration());raw.set(" ".repeat(65537));assertThrows(RemoteReviewClient.Unavailable.class,()->client.configuration()); + } + @Test void closeCancelsActiveRequestsAndRejectsFurtherCalls()throws Exception { + var entered=new CountDownLatch(1);var release=new CountDownLatch(1);route(e->{entered.countDown();release.await(5,TimeUnit.SECONDS);}); + try(var executor=Executors.newSingleThreadExecutor()) { + var pending=executor.submit(()->assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding))); + try {assertTrue(entered.await(2,TimeUnit.SECONDS));client.close();pending.get(1,TimeUnit.SECONDS);assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding));} + finally{release.countDown();} + } + assertEquals(1,calls.get()); + } + @Test void thirdConcurrentRequestIsRejectedWithoutHttpAndCloseDrainsBoth()throws Exception { + var entered=new CountDownLatch(2);var release=new CountDownLatch(1);route(e->{entered.countDown();release.await(5,TimeUnit.SECONDS);}); + try(var executor=Executors.newFixedThreadPool(2)) { + var first=executor.submit(()->assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding))); + var second=executor.submit(()->assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding))); + try {assertTrue(entered.await(2,TimeUnit.SECONDS));assertEquals(503,assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding)).status()); + assertEquals(2,calls.get());client.close();first.get(1,TimeUnit.SECONDS);second.get(1,TimeUnit.SECONDS);} + finally{release.countDown();} + } + } + @Test void deadlineCancelsSlowResponseWithoutRetry() { + client.close();client=new RemoteReviewClient(new AppWardenProperties("http://127.0.0.1:"+server.getAddress().getPort(),"fixture-key",true,9,600),Duration.ofMillis(150)); + var release=new CountDownLatch(1);route(e->release.await(3,TimeUnit.SECONDS)); + long start=System.nanoTime(); + try {assertThrows(RemoteReviewClient.Unavailable.class,()->client.find(binding));assertTrue(System.nanoTime()-start{if(e.getRequestURI().getPath().endsWith("configuration"))reply(e,200,Map.of("policyVersion",binding.policyVersion(),"reviewConfigSha256",binding.reviewConfigSha256())); + else {assertEquals("DELETE",e.getRequestMethod());assertTrue(e.getRequestURI().getQuery().contains(request));reply(e,200,status("CANCELLED"));}}); + var configuration=client.configuration();assertEquals(origin,configuration.origin());assertEquals(binding.reviewConfigSha256(),configuration.reviewConfigSha256());assertEquals("CANCELLED",client.cancel(binding.withJobId(job)).state()); + } + @Test void legacyOriginsNeverTriggerDiscoveryUploadStatusResultOrCancellation() { + var legacy=new RemoteReviewBinding(binding.projectId(),binding.versionId(),binding.requestId(),binding.attempt(),binding.filePath(),binding.artifactSha256(),binding.contextSha256(),binding.policyVersion(),binding.reviewConfigSha256(),null); + route(e->reply(e,200,status("QUEUED")));var downloads=new AtomicInteger(); + assertThrows(RemoteReviewClient.MissingOrigin.class,()->client.find(legacy));assertThrows(RemoteReviewClient.MissingOrigin.class,()->client.status(legacy.withJobId(job))); + assertThrows(RemoteReviewClient.MissingOrigin.class,()->client.cancel(legacy.withJobId(job)));assertThrows(RemoteReviewClient.MissingOrigin.class,()->client.result(legacy.withJobId(job))); + assertThrows(RemoteReviewClient.MissingOrigin.class,()->client.submitOrFind(legacy,()->{downloads.incrementAndGet();return bytes;},()->true));assertEquals(0,calls.get());assertEquals(0,downloads.get()); + } + @Test void storedOriginSurvivesClientRecreationAndCredentialRotationWithoutRediscovery() { + route(e->reply(e,200,status("QUEUED")));var attached=binding.withJobId(job);assertEquals(origin,attached.origin());client.status(attached);client.close();expectedApiKey="rotated-fixture-key"; + client=new RemoteReviewClient(new AppWardenProperties("http://127.0.0.1:"+server.getAddress().getPort(),expectedApiKey,true,1,600),Duration.ofSeconds(2)); + client.status(attached);assertEquals(2,calls.get()); + } + @Test void malformedIdentityResponsesCannotSupplyConfiguration() { + var value=new AtomicReference(Map.of("deploymentId","invalid","callerScope",origin.callerScope())); + server.createContext("/api/v1/review-jobs/identity",e->{try{reply(e,200,value.get());}catch(Exception invalid){throw new RuntimeException(invalid);}finally{e.close();}}); + assertThrows(RemoteReviewClient.Unavailable.class,()->client.configuration());value.set(Map.of("deploymentId",origin.deploymentId(),"callerScope",origin.callerScope(),"extra",true)); + assertThrows(RemoteReviewClient.Unavailable.class,()->client.configuration());assertEquals(0,calls.get()); + } + @Test void changedDeploymentBetweenIdentityAndConfigurationIsNotRetriedOrRebound() { + route(e->reply(e,409,Map.of()));assertEquals(409,assertThrows(RemoteReviewClient.Unavailable.class,()->client.configuration()).status());assertEquals(2,calls.get());assertEquals(0,posts.get()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewDiscoveryTest.java b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewDiscoveryTest.java new file mode 100644 index 000000000..86644cb39 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewDiscoveryTest.java @@ -0,0 +1,71 @@ +package net.modtale.service.security.scan; + +import com.mongodb.client.*; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class RemoteReviewDiscoveryTest { + MongoClient client;MongoDatabase db;MongoCollection projects;RemoteReviewDiscovery discovery; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030");if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException(); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000");db=client.getDatabase("warden_discovery_"+UUID.randomUUID().toString().replace("-",""));projects=db.getCollection("projects"); + discovery=new RemoteReviewDiscovery(new MongoTemplate(client,db.getName())); + } + @AfterEach void cleanup(){db.drop();client.close();} + Document version(String id) {return new Document("_id",id).append("reviewStatus","PENDING").append("scanResult",new Document("status","SCANNING").append("scanState","QUEUED").append("scanAttempt",1).append("scanRequestId",UUID.randomUUID().toString()));} + void insert(Object id,List versions){projects.insertOne(new Document("_id",id).append("versions",versions));} + List walk(int size) { + var found=new ArrayList();RemoteReviewDiscovery.Cursor cursor=null; + for(int i=0;i<100;i++){var page=discovery.page(cursor,size);assertTrue(page.examined()<=size);found.addAll(page.candidates());cursor=page.next();if(cursor==null)return found;} + fail("Discovery cursor did not terminate");return found; + } + @Test void everyVersionIsVisitedAcrossBoundedPagesAndMixedIdTypes() { + var versions=new ArrayList();for(int i=0;i<71;i++)versions.add(version("v"+i));insert("string-project",versions);var objectId=new ObjectId();insert(objectId,List.of(version("last"))); + var first=discovery.page(null,16);assertEquals(16,first.examined());assertEquals(16,first.candidates().size());assertFalse(first.next().afterProject());assertEquals(16,first.next().offset()); + var all=walk(16);assertEquals(72,all.size());assertEquals(72,all.stream().map(c->c.projectId()+":"+c.versionId()).distinct().count());assertEquals(objectId.toHexString(),all.getLast().projectId()); + } + @Test void activeLeasesAndFutureRetryDatesAreSkippedUsingDatabaseTime() { + var now=db.runCommand(new Document("hello",1)).getDate("localTime").getTime();var versions=new ArrayList(); + for(int i=0;i<4;i++){var v=version("v"+i);var scan=v.get("scanResult",Document.class);scan.put("scanState","REMOTE_REVIEW"); + scan.put("remotePoll",new Document("token",i==2?null:UUID.randomUUID().toString()).append("leaseUntil",new Date(i==0?now+60000:0)).append("nextPollAt",new Date(i==1?now+60000:0)));versions.add(v);} + insert("p",versions);var found=walk(2);assertEquals(List.of("v2","v3"),found.stream().map(RemoteReviewDiscovery.Candidate::versionId).toList()); + } + @Test void emptyCandidatePageStillAdvancesPastBusyVersions() { + var busy=version("busy");busy.get("scanResult",Document.class).put("scanState","REMOTE_REVIEW");busy.get("scanResult",Document.class).put("remotePoll",new Document("token",null).append("leaseUntil",new Date(Long.MAX_VALUE)).append("nextPollAt",new Date(0))); + insert("p",List.of(busy,version("ready")));var first=discovery.page(null,1);assertTrue(first.candidates().isEmpty());assertEquals(1,first.next().offset()); + assertEquals("ready",discovery.page(first.next(),1).candidates().getFirst().versionId()); + } + @Test void deletedCursorProjectDoesNotApplyItsOffsetToTheNextProject() { + insert("a",List.of(version("one"),version("two")));insert("b",List.of(version("next")));var first=discovery.page(null,1); + projects.deleteOne(new Document("_id","a"));var next=discovery.page(first.next(),1);assertEquals("next",next.candidates().getFirst().versionId());assertTrue(next.next().afterProject()); + } + @Test void malformedMetadataAndPollsCannotBecomeCandidates() { + var badRequest=version("bad-request");badRequest.get("scanResult",Document.class).put("scanRequestId","x".repeat(100000)); + var badAttempt=version("bad-attempt");badAttempt.get("scanResult",Document.class).put("scanAttempt",Long.MAX_VALUE); + var malformed=version("bad-poll");malformed.get("scanResult",Document.class).put("remotePoll",List.of("invalid")); + var nullPoll=version("null-poll");nullPoll.get("scanResult",Document.class).put("remotePoll",null); + var objectPoll=version("bad-dates");objectPoll.get("scanResult",Document.class).put("scanState","REMOTE_REVIEW");objectPoll.get("scanResult",Document.class).put("remotePoll",new Document("token",null).append("leaseUntil","bad").append("nextPollAt",new Date(0))); + insert("p",List.of(badRequest,badAttempt,malformed,nullPoll,objectPoll,version("ready")));assertEquals(List.of("ready"),walk(2).stream().map(RemoteReviewDiscovery.Candidate::versionId).toList()); + } + @Test void nonScanningProjectsAndUnsupportedRootIdsCannotStarveValidProjects() { + insert(1,List.of(version("numeric")));insert("x".repeat(129),List.of(version("long-root"))); + var finished=version("done");finished.get("scanResult",Document.class).put("status","CLEAN");insert("a",List.of(finished));insert("b",List.of(version("ready"))); + assertEquals(List.of("ready"),walk(1).stream().map(RemoteReviewDiscovery.Candidate::versionId).toList()); + } + @Test void shrinkingVersionArrayAdvancesRatherThanRepeatingItsLastPage() { + insert("a",List.of(version("one"),version("two")));insert("b",List.of(version("next")));var first=discovery.page(null,1); + projects.updateOne(new Document("_id","a"),new Document("$set",new Document("versions",List.of(version("one"))))); + var exhausted=discovery.page(first.next(),1);assertEquals(0,exhausted.examined());assertTrue(exhausted.next().afterProject());assertEquals("next",discovery.page(exhausted.next(),1).candidates().getFirst().versionId()); + } + @Test void pageAndCursorLimitsAreValidated() { + assertThrows(IllegalArgumentException.class,()->discovery.page(null,0));assertThrows(IllegalArgumentException.class,()->discovery.page(null,65)); + assertThrows(IllegalArgumentException.class,()->new RemoteReviewDiscovery.Cursor("p",-1,false));assertThrows(IllegalArgumentException.class,()->new RemoteReviewDiscovery.Cursor("p",1,true)); + assertThrows(IllegalArgumentException.class,()->new RemoteReviewDiscovery.Cursor(1,0,true));assertNull(discovery.page(null,1).next()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewIsolationIntegrationTest.java b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewIsolationIntegrationTest.java new file mode 100644 index 000000000..b48430e51 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewIsolationIntegrationTest.java @@ -0,0 +1,67 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import net.modtale.model.project.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import java.time.Clock; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class RemoteReviewIsolationIntegrationTest { + RemoteReviewStepIntegrationTest fixture=new RemoteReviewStepIntegrationTest(); + ReviewSnapshotArchive archive;RawReviewSnapshotReader reader;ReviewIsolationExecutor isolation; + ReviewRepairPreparation.Prepared prepared; + @BeforeEach void setup()throws Exception { + fixture.setup(System.getenv().getOrDefault("WARDEN_REPAIR_TX_DB_PORT","27031"),true); + reader=new RawReviewSnapshotReader(fixture.mongo);archive=new ReviewSnapshotArchive(fixture.mongo,"test",Map.of("test",new byte[32])); + isolation=new ReviewIsolationExecutor(fixture.mongo,archive,reader,new ReviewRepairJournal(fixture.mongo,archive)); + } + @AfterEach void cleanup(){fixture.cleanup();} + Document raw(){return fixture.mongo.getCollection("projects").find().first();} + void isolateExpiredBrokenPoll() { + fixture.change("scanResult.remotePoll.leaseUntil",new Date(0)); + fixture.change("scanResult.remotePoll.extra","invalid poll shape"); + Object rootId=raw().get("_id");var capture=reader.capture(rootId,0,"v"); + prepared=new ReviewRepairPreparation(archive,reader,Clock.systemUTC(),1,60000).prepare( + new ReviewRepairPreparation.Request(UUID.randomUUID().toString(),rootId,0,"v",capture.sha256(),"actor",ReviewSnapshotArchive.Action.ISOLATE_REVIEW),()->true); + assertEquals("APPLIED",isolation.execute(prepared,"actor",()->true).state()); + assertArrayEquals(capture.versionBytes(),archive.load(prepared.id()).versionBytes()); + } + void assertIsolated() { + var version=fixture.mongo.findById(fixture.project,Project.class).getVersions().getFirst(); + assertEquals(ProjectVersion.ReviewStatus.PENDING,version.getReviewStatus());assertEquals("REMOTE_ISOLATED",version.getScanResult().getScanState()); + assertEquals(ScanStatus.FAILED,version.getScanResult().getStatus());assertFalse(version.getScanResult().isArtifactVerified()); + assertNull(version.getScheduledPublishDate());assertEquals(0,version.getSecurityApprovedAt()); + assertEquals(fixture.job,version.getScanResult().getRemoteReview().jobId()); + assertEquals(prepared.id(),version.getReviewIsolation().operationId()); + } + @Test void isolatedVersionRejectsStaleCompletionAndEveryPollMutation() { + var claim=fixture.attached(30000);fixture.change("scanResult.verdict","BLOCK");isolateExpiredBrokenPoll();var before=raw(); + assertFalse(fixture.completion.handleRemoteCompletedScan(claim,fixture.cleanResult())); + assertFalse(fixture.polls.isCurrent(claim));assertFalse(fixture.polls.release(claim,100));assertNull(fixture.polls.claim(claim.binding(),1000)); + assertNull(fixture.polls.attachJob(claim,fixture.job)); + var status=new RemoteReviewClient.Status(fixture.job,"QUEUED",true,1000,2000,null); + assertFalse(fixture.polls.recordStatusAndRelease(claim,status,100)); + assertFalse(fixture.polls.finishUnavailable(claim,new RemoteReviewClient.Status(fixture.job,"CANCELLED",true,1000,2000,null))); + assertEquals("NO_WORK",fixture.step.advance(claim.binding()).state());assertTrue(new RemoteReviewDiscovery(fixture.mongo).page(null,64).candidates().isEmpty()); + assertEquals(before,raw());assertIsolated();assertEquals("BLOCK",fixture.saved().getVerdict());assertEquals(0,fixture.posts.get());assertEquals(0,fixture.gets.get()); + verifyNoInteractions(fixture.storage); + } + @Test void cleanHttpResultArrivingAfterIsolationCannotRestoreClearance() { + var reached=new AtomicBoolean(); + fixture.route(e->{if(e.getRequestURI().getPath().endsWith("/result")){isolateExpiredBrokenPoll();reached.set(true);}fixture.reply(e,200,"COMPLETED");}); + assertEquals("SUPERSEDED",fixture.step.advance(fixture.binding).state());assertTrue(reached.get());assertIsolated(); + assertEquals(0,fixture.posts.get());assertEquals(2,fixture.gets.get());verifyNoInteractions(fixture.storage); + } + @Test void isolationAfterCompletionSnapshotRejectsFinalPublicationWrite() { + var claim=fixture.attached(30000);var reached=new AtomicBoolean(); + when(fixture.policy.currentPolicyVersion()).thenAnswer(i->{isolateExpiredBrokenPoll();reached.set(true);return fixture.binding.policyVersion();}); + assertFalse(fixture.completion.handleRemoteCompletedScan(claim,fixture.cleanResult()));assertTrue(reached.get());assertIsolated(); + assertEquals("APPLIED",isolation.recover(prepared.id(),"actor").receipt().outcome().state()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewSchedulerTest.java b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewSchedulerTest.java new file mode 100644 index 000000000..ef4ca6a05 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewSchedulerTest.java @@ -0,0 +1,70 @@ +package net.modtale.service.security.scan; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RemoteReviewSchedulerTest { + RemoteReviewDiscovery discovery=mock(RemoteReviewDiscovery.class); + RemoteReviewBootstrap bootstrap=mock(RemoteReviewBootstrap.class); + RemoteReviewScheduler scheduler(int workers,long drain){return new RemoteReviewScheduler(discovery,bootstrap,new RemoteReviewScheduler.Settings(workers,4,100,drain));} + RemoteReviewDiscovery.Candidate candidate(String id){return new RemoteReviewDiscovery.Candidate("p",id,1,UUID.randomUUID().toString());} + void until(BooleanSupplier ready)throws Exception {long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(3);while(!ready.getAsBoolean()&&System.nanoTime()newKeySet(); + when(discovery.page(any(),anyInt())).thenReturn(new RemoteReviewDiscovery.Page(List.of(a,b,c),new RemoteReviewDiscovery.Cursor("p",0,true),3),new RemoteReviewDiscovery.Page(List.of(),null,0)); + when(bootstrap.advance(anyString(),anyString(),anyInt(),anyString(),any())).thenAnswer(i->{invoked.add(i.getArgument(1));entered.countDown();release.await(3,TimeUnit.SECONDS);return new RemoteReviewStep.Outcome("RECORDED",null);}); + try(var scheduler=scheduler(2,1000)) { + scheduler.start();scheduler.start();try{assertTrue(entered.await(2,TimeUnit.SECONDS));assertEquals(2,scheduler.status().active());assertEquals(1,scheduler.status().buffered());verify(discovery,times(1)).page(any(),anyInt());} + finally{release.countDown();} + until(()->invoked.size()==3);assertEquals(Set.of("a","b","c"),invoked); + } + } + @Test void duplicateInFlightCandidateIsNotDispatchedTwice()throws Exception { + var candidate=candidate("v");var entered=new CountDownLatch(1);var release=new CountDownLatch(1); + when(discovery.page(any(),anyInt())).thenReturn(new RemoteReviewDiscovery.Page(List.of(candidate,candidate),null,2),new RemoteReviewDiscovery.Page(List.of(),null,0)); + when(bootstrap.advance(anyString(),anyString(),anyInt(),anyString(),any())).thenAnswer(i->{entered.countDown();release.await(3,TimeUnit.SECONDS);return new RemoteReviewStep.Outcome("RECORDED",null);}); + try(var scheduler=scheduler(2,1000)){scheduler.start();try{assertTrue(entered.await(2,TimeUnit.SECONDS));Thread.sleep(250);verify(bootstrap,times(1)).advance(anyString(),anyString(),anyInt(),anyString(),any());}finally{release.countDown();}} + } + @Test void discoveryFailureBacksOffInsteadOfSpinning()throws Exception { + when(discovery.page(any(),anyInt())).thenThrow(new IllegalStateException("fixture unavailable")); + try(var scheduler=scheduler(1,1000)){scheduler.start();until(()->scheduler.status().failures()==1);Thread.sleep(250);verify(discovery,times(1)).page(any(),anyInt());assertEquals("POLL_ERROR",scheduler.status().lastOutcome());} + } + @Test void timeoutNeverRunsStopCallbacksBeforeUncooperativeWorkEnds()throws Exception { + var candidate=candidate("v");var entered=new CountDownLatch(1);var release=new CountDownLatch(1);var done=new CountDownLatch(2);var guard=new AtomicReference(); + when(discovery.page(any(),anyInt())).thenReturn(new RemoteReviewDiscovery.Page(List.of(candidate),null,1),new RemoteReviewDiscovery.Page(List.of(),null,0)); + when(bootstrap.advance(anyString(),anyString(),anyInt(),anyString(),any())).thenAnswer(i->{guard.set(i.getArgument(4));entered.countDown();long limit=System.nanoTime()+TimeUnit.SECONDS.toNanos(5); + while(release.getCount()>0&&System.nanoTime()"DRAIN_TIMEOUT".equals(scheduler.status().state())); + assertTrue(scheduler.isRunning());assertEquals(2,done.getCount());assertFalse(guard.get().getAsBoolean()); + assertThrows(IllegalStateException.class,scheduler::close); + }finally{release.countDown();} + assertTrue(done.await(2,TimeUnit.SECONDS));scheduler.close();assertEquals("STOPPED",scheduler.status().state()); + } + @Test void invalidLimitsAreRejected() { + assertThrows(IllegalArgumentException.class,()->new RemoteReviewScheduler.Settings(3,4,100,100));assertThrows(IllegalArgumentException.class,()->new RemoteReviewScheduler.Settings(1,65,100,100)); + assertThrows(IllegalArgumentException.class,()->new RemoteReviewScheduler.Settings(1,4,99,100));assertThrows(IllegalArgumentException.class,()->new RemoteReviewScheduler.Settings(1,4,100,30001)); + } + @Test void springSchedulerIsAbsentByDefault() { + new ApplicationContextRunner().withUserConfiguration(RemoteReviewSchedulerConfig.class).run(context->assertFalse(context.containsBean("remoteReviewScheduler"))); + } + @Test void springSchedulerRequiresRemoteModeAndStopsWithContext() { + empty();var runner=new ApplicationContextRunner().withUserConfiguration(RemoteReviewSchedulerConfig.class).withBean(RemoteReviewDiscovery.class,()->discovery).withBean(RemoteReviewBootstrap.class,()->bootstrap) + .withPropertyValues("app.warden.jobs.scheduler.enabled=true"); + runner.run(context->assertNotNull(context.getStartupFailure())); + var reference=new AtomicReference();runner.withPropertyValues("app.warden.jobs.enabled=true").run(context->{assertNull(context.getStartupFailure());reference.set(context.getBean(RemoteReviewScheduler.class));assertTrue(reference.get().isRunning());}); + assertEquals("STOPPED",reference.get().status().state()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewStepIntegrationTest.java b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewStepIntegrationTest.java new file mode 100644 index 000000000..186d48805 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/RemoteReviewStepIntegrationTest.java @@ -0,0 +1,452 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mongodb.client.*; +import com.sun.net.httpserver.*; +import net.modtale.config.properties.AppWardenProperties; +import net.modtale.model.project.*; +import net.modtale.service.storage.StorageService; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.*; +import java.net.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST",matches="true") +class RemoteReviewStepIntegrationTest { + MongoClient db;MongoTemplate mongo;String database;HttpServer server;RemoteReviewClient client; + RemoteReviewPollStore polls;StorageService storage;RemoteReviewStep step;RemoteReviewBinding binding; + ScanCompletionService completion;net.modtale.service.security.issue.SecurityIssueAnalysisService analysis; + WardenClientService policy; + byte[] bytes="inert original".getBytes();String project="abcdefabcdefabcdefabcdef",job=UUID.randomUUID().toString(); + RemoteReviewOrigin origin=new RemoteReviewOrigin("11111111-1111-1111-1111-111111111111","e".repeat(64)); + AtomicInteger identityGets=new AtomicInteger(),posts=new AtomicInteger(),gets=new AtomicInteger();ObjectMapper mapper=new ObjectMapper(); + interface Handler {void handle(HttpExchange e)throws Exception;} + void route(Handler handler){server.createContext("/api/v1/review-jobs",e->{try{if(e.getRequestURI().getPath().endsWith("/identity")){identityGets.incrementAndGet();byte[] body=mapper.writeValueAsBytes(origin);e.sendResponseHeaders(200,body.length);e.getResponseBody().write(body);return;} + assertEquals(origin.deploymentId(),e.getRequestHeaders().getFirst("X-Warden-Deployment-Id"));assertEquals(origin.callerScope(),e.getRequestHeaders().getFirst("X-Warden-Caller-Scope"));if(e.getRequestMethod().equals("POST")){posts.incrementAndGet();e.getRequestBody().readAllBytes();}else gets.incrementAndGet();handler.handle(e);}catch(Exception failure){throw new RuntimeException(failure);}finally{e.close();}});} + void reply(HttpExchange e,int code,String state)throws Exception { + var body=new LinkedHashMap();body.put("jobId",job);body.put("requestId",binding.requestId()); + body.put("binding",Map.of("artifactSha256",binding.artifactSha256(),"contextSha256",binding.contextSha256(),"policyVersion",binding.policyVersion(),"reviewConfigSha256",binding.reviewConfigSha256())); + body.put("state",state);body.put("artifactRetained",!Set.of("UPLOADING","AWAITING_UPLOAD").contains(state));body.put("createdAt",1000L);body.put("expiresAt",2000L);body.put("workState",null); + if(e.getRequestURI().getPath().endsWith("/result")) { + body.remove("state");body.remove("artifactRetained");body.remove("createdAt");body.remove("expiresAt");body.remove("workState"); + body.put("completedAt",1500L);body.put("scan",cleanResult()); + } + byte[] data=mapper.writeValueAsBytes(body);e.sendResponseHeaders(code,data.length);e.getResponseBody().write(data); + } + @BeforeEach void setup()throws Exception { + setup(System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030"),false); + } + void setup(String port,boolean replica) throws Exception { + if(!(replica?Set.of("27031","27032"):Set.of("27029","27030")).contains(port))throw new IllegalArgumentException(); + db=MongoClients.create("mongodb://127.0.0.1:"+port+"/?directConnection=true&serverSelectionTimeoutMS=3000");database="warden_remote_step_"+UUID.randomUUID().toString().replace("-",""); + var factory=new org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory(db,database); + var conversions=new net.modtale.config.db.MongoConfig().mongoCustomConversions(new net.modtale.config.db.MongoArtifactManifestStore(factory)); + var context=new org.springframework.data.mongodb.core.mapping.MongoMappingContext();context.setSimpleTypeHolder(conversions.getSimpleTypeHolder());context.afterPropertiesSet(); + var converter=new org.springframework.data.mongodb.core.convert.MappingMongoConverter(new org.springframework.data.mongodb.core.convert.DefaultDbRefResolver(factory),context); + converter.setCustomConversions(conversions);converter.afterPropertiesSet();mongo=new MongoTemplate(factory,converter); + var version=new ProjectVersion();version.setId("v");version.setFileUrl("original.zip");version.setHash(HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes))); + var scan=new ScanResult();scan.setStatus(ScanStatus.SCANNING);scan.setScanState("SCANNING");scan.setScanAttempt(1);scan.setScanRequestId(UUID.randomUUID().toString());version.setScanResult(scan); + var root=new Project();root.setId(project);root.setVersions(List.of(version));mongo.insert(root); + binding=new RemoteReviewBinding(project,"v",scan.getScanRequestId(),1,version.getFileUrl(),version.getHash(),ArtifactReviewContext.automaticallyReviewableFingerprint(version),"warden-3.0.0:"+"b".repeat(64),"c".repeat(64),null,false,origin); + assertTrue(new RemoteReviewPersistence(mongo).bind(version,binding));polls=new RemoteReviewPollStore(mongo);storage=mock(StorageService.class);when(storage.downloadBounded(binding.filePath(),100*1024*1024)).thenReturn(bytes); + server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0);server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());server.start(); + client=new RemoteReviewClient(new AppWardenProperties("http://127.0.0.1:"+server.getAddress().getPort(),"fixture-key",true,1,600),Duration.ofSeconds(2)); + var repo=mock(net.modtale.repository.project.ProjectRepository.class);when(repo.findById(project)).thenAnswer(i->Optional.ofNullable(mongo.findById(project,Project.class))); + var projects=mock(net.modtale.service.project.query.ProjectService.class);analysis=mock(net.modtale.service.security.issue.SecurityIssueAnalysisService.class); + doAnswer(i->{new net.modtale.service.security.issue.SecurityIssueApprovalService(new net.modtale.service.security.issue.SecurityIssueClassificationService( + new net.modtale.config.properties.AppSecurityProperties("fixture",60,120,2,4,15,20,25,2))).markIssuesAcceptedForApprovedVersion(i.getArgument(0));return null;}) + .when(analysis).markIssuesAcceptedForApprovedVersion(any()); + when(analysis.annotateAgainstBaselines(any(),any())).thenReturn(new net.modtale.service.security.issue.SecurityIssueAnalysisService.ClassificationStats(0,0,0,false)); + policy=mock(WardenClientService.class);when(policy.currentPolicyVersion()).thenReturn(binding.policyVersion()); + completion=new ScanCompletionService(repo,projects,mock(net.modtale.service.communication.ProjectNotificationService.class),mock(net.modtale.service.communication.WebhookService.class),analysis, + new ScanRoutingService(new net.modtale.config.properties.AppSecurityProperties("fixture",60,120,2,4,15,20,25,2)), + new ScanPersistenceService(mongo,repo,projects),new net.modtale.service.project.access.ProjectVersionAccessService(null),policy); + completion=spy(completion); + doAnswer(invocation->{try{return invocation.callRealMethod();}catch(RuntimeException failure){throw new AssertionError("Completion failed",failure);}}) + .when(completion).handleRemoteCompletedScan(any(),any()); + step=new RemoteReviewStep(polls,client,storage,completion); + } + @AfterEach void cleanup(){if(client!=null)client.close();if(server!=null)server.stop(0);if(db!=null){db.getDatabase(database).drop();db.close();}} + ScanResult cleanResult() { + var result=ScanEvidenceFixtures.complete(true);var old=result.getSecurityEvidence(); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(binding.policyVersion(),binding.artifactSha256(),old.contentSha256(),true,true,old.reviewState(),old.entryHashes())); + result.setReviewedContextSha256(binding.contextSha256());result.setScanRequestId(binding.requestId());return result; + } + ScanResult saved(){return mongo.findById(project,Project.class).getVersions().getFirst().getScanResult();} + void change(String field,Object value){mongo.updateFirst(Query.query(Criteria.where("_id").is(project)),new Update().set("versions.0."+field,value),Project.class);} + void ready(){change("scanResult.remotePoll.nextPollAt",new Date(0));} + @Test void submitThenPersistAndPollExistingJobWithoutRedownload() { + var found=new AtomicBoolean();route(e->{if(e.getRequestMethod().equals("POST")){found.set(true);reply(e,202,"QUEUED");}else reply(e,found.get()?200:404,found.get()?"COMPLETED":"AWAITING_UPLOAD");}); + assertEquals("RECORDED",step.advance(binding).state());assertEquals(job,saved().getRemoteReview().jobId());assertEquals("QUEUED",saved().getRemoteStatus().state());assertNull(saved().getRemotePoll().token()); + assertEquals("NO_WORK",step.advance(saved().getRemoteReview()).state());ready(); + var outcome=new RemoteReviewStep(new RemoteReviewPollStore(mongo),client,storage,completion).advance(saved().getRemoteReview());assertEquals("COMPLETED",outcome.remoteState(),outcome.toString()); + assertEquals("APPLIED",outcome.state());assertEquals("COMPLETED",saved().getScanState());assertTrue(saved().isArtifactVerified()); + assertEquals(ProjectVersion.ReviewStatus.SCHEDULED,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus());assertEquals(binding.contextSha256(),saved().getReviewedContextSha256());assertEquals(1,posts.get());verify(storage,times(1)).downloadBounded(anyString(),anyInt()); + } + RemoteReviewPollStore.Claim attached(long lease) { + var claim=polls.claim(binding,lease);assertNotNull(claim);var attached=polls.attachJob(claim,job);assertNotNull(attached);return attached; + } + @Test void contextChangeDuringResultReadCannotScheduleVersion() { + route(e->{if(e.getRequestURI().getPath().endsWith("/result"))change("manifestVersion","changed");reply(e,200,"COMPLETED");}); + assertEquals("SUPERSEDED",step.advance(binding).state());assertEquals("REMOTE_REVIEW",saved().getScanState()); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()); + } + @Test void contextChangeAfterCompletionSnapshotStillFailsPublicationWrite() { + var claim=attached(30000);when(policy.currentPolicyVersion()).thenAnswer(i->{change("manifestVersion","changed at commit");return binding.policyVersion();}); + assertFalse(completion.handleRemoteCompletedScan(claim,cleanResult()));assertEquals("REMOTE_REVIEW",saved().getScanState()); + } + @Test void databaseLeaseExpiryDuringPolicyCheckRejectsResultWithoutChangingStoredPoll()throws Exception { + var claim=attached(1000);when(policy.currentPolicyVersion()).thenAnswer(i->{Thread.sleep(1100);return binding.policyVersion();}); + assertFalse(completion.handleRemoteCompletedScan(claim,cleanResult()));assertEquals(claim.token(),saved().getRemotePoll().token());assertEquals("REMOTE_REVIEW",saved().getScanState()); + } + @Test void wrongOriginalResultContextAndRequestAreRejected() { + var claim=attached(30000);var result=cleanResult();result.setReviewedContextSha256("d".repeat(64));assertFalse(completion.handleRemoteCompletedScan(claim,result)); + result=cleanResult();result.setScanRequestId(UUID.randomUUID().toString());assertFalse(completion.handleRemoteCompletedScan(claim,result)); + assertEquals("REMOTE_REVIEW",saved().getScanState()); + } + @Test void missingCurrentPolicyRequiresReviewAndCannotSchedule() { + var claim=attached(30000);when(policy.currentPolicyVersion()).thenReturn(null); + assertTrue(completion.handleRemoteCompletedScan(claim,cleanResult()));assertEquals("REVIEW",saved().getVerdict()); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()); + assertFalse(completion.handleRemoteCompletedScan(claim,cleanResult())); + } + @Test void terminalRemoteFailureDoesNotCreateAnotherLocalScanAttempt() { + var claim=attached(30000);var result=cleanResult();var e=result.getSecurityEvidence(); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(),e.artifactSha256(),e.contentSha256(),true,false,"RATE_LIMITED",e.entryHashes())); + result.setVerdict("REVIEW");result.setStatus(ScanStatus.SUSPICIOUS); + assertTrue(completion.handleRemoteCompletedScan(claim,result));assertEquals("COMPLETED",saved().getScanState()); + assertEquals(binding.requestId(),saved().getScanRequestId());assertEquals(job,saved().getRemoteReview().jobId()); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()); + } + @Test void incompleteRemoteEvidenceRemainsManualReview() { + var claim=attached(30000);var result=cleanResult();var e=result.getSecurityEvidence(); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(e.policyVersion(),e.artifactSha256(),e.contentSha256(),false,false,"INCOMPLETE",e.entryHashes()));result.setArtifactVerified(false);result.setVerdict("REVIEW");result.setStatus(ScanStatus.SUSPICIOUS); + assertTrue(completion.handleRemoteCompletedScan(claim,result));assertEquals("REVIEW",saved().getVerdict());assertFalse(ArtifactClearancePolicy.cleared(saved())); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()); + } + void queuedAgain() { + mongo.updateFirst(Query.query(Criteria.where("_id").is(project)),new Update().unset("versions.0.scanResult.remoteReview").set("versions.0.scanResult.scanState","QUEUED"),Project.class); + } + void configuration(HttpExchange e)throws Exception { + byte[] data=mapper.writeValueAsBytes(Map.of("policyVersion",binding.policyVersion(),"reviewConfigSha256",binding.reviewConfigSha256())); + e.sendResponseHeaders(200,data.length);e.getResponseBody().write(data); + } + RemoteReviewBootstrap bootstrap(RemoteReviewPersistence persistence) {return new RemoteReviewBootstrap(persistence,client,step);} + @Test void bootstrapBindsQueuedRequestBeforeUploadAndReusesRetainedConfiguration() { + queuedAgain();var configurations=new AtomicInteger(); + route(e->{if(e.getRequestURI().getPath().endsWith("configuration")){configurations.incrementAndGet();configuration(e);} + else if(e.getRequestMethod().equals("POST"))reply(e,202,"QUEUED");else reply(e,404,"AWAITING_UPLOAD");}); + var candidate=new RemoteReviewDiscovery(mongo).page(null,16).candidates().getFirst(); + var boot=bootstrap(new RemoteReviewPersistence(mongo));assertEquals("RECORDED",boot.advance(candidate.projectId(),candidate.versionId(),candidate.attempt(),candidate.requestId()).state()); + assertEquals(job,saved().getRemoteReview().jobId());assertEquals("READY",boot.prepare(project,"v",1,binding.requestId()).state()); + assertEquals(origin,saved().getRemoteReview().origin());assertEquals(1,identityGets.get());assertEquals(1,configurations.get());assertEquals(1,posts.get()); + } + @Test void bootstrapRecoversCommittedBindingAcknowledgementLoss() { + queuedAgain();route(this::configuration);var persistence=spy(new RemoteReviewPersistence(mongo)); + doAnswer(i->{assertTrue((Boolean)i.callRealMethod());throw new IllegalStateException("Lost acknowledgement after committed binding");}).when(persistence).bind(any(),any()); + var prepared=bootstrap(persistence).prepare(project,"v",1,binding.requestId());assertEquals("READY",prepared.state());assertEquals(binding,prepared.binding()); + assertEquals(prepared.binding(),saved().getRemoteReview());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @Test void changedContextDuringConfigurationCannotCreateBindingOrUpload() { + queuedAgain();route(e->{change("manifestVersion","changed");configuration(e);}); + assertEquals("NO_WORK",bootstrap(new RemoteReviewPersistence(mongo)).advance(project,"v",1,binding.requestId()).state()); + assertNull(saved().getRemoteReview());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @Test void missingBindingStopsDiscoveryWithoutRemoteWork() { + change("scanResult.remoteReview",null); + var boot=bootstrap(new RemoteReviewPersistence(mongo)); + assertEquals("UNAVAILABLE",boot.advance(project,"v",1,binding.requestId()).state()); + assertEquals(ScanStatus.FAILED,saved().getStatus());assertEquals("REMOTE_BINDING_MISSING",saved().getScanState()); + assertEquals(binding.requestId(),saved().getScanRequestId());assertEquals(1,saved().getScanAttempt()); + assertFalse(ArtifactClearancePolicy.cleared(saved())); + assertTrue(new RemoteReviewDiscovery(mongo).page(null,16).candidates().isEmpty()); + assertEquals("NO_WORK",boot.advance(project,"v",1,binding.requestId()).state()); + assertEquals(0,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @Test void mismatchedAttachedBindingPreservesBlockAndRejectsOldCompletion() { + var claim=attached(30000);change("manifestVersion","changed");change("scanResult.verdict","BLOCK"); + change("scanResult.riskScore",87); + assertEquals("UNAVAILABLE",bootstrap(new RemoteReviewPersistence(mongo)).advance(project,"v",1,binding.requestId()).state()); + assertEquals("REMOTE_BINDING_MISMATCH",saved().getScanState());assertEquals(ScanStatus.FAILED,saved().getStatus()); + assertEquals("BLOCK",saved().getVerdict());assertEquals(87,saved().getRiskScore()); + assertEquals(job,saved().getRemoteReview().jobId());assertNull(saved().getRemotePoll()); + assertFalse(polls.isCurrent(claim));assertFalse(completion.handleRemoteCompletedScan(claim,cleanResult())); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()); + assertEquals(0,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @Test void validOrSupersededBindingCannotBeFailed() { + var persistence=new RemoteReviewPersistence(mongo); + assertNull(persistence.finishBrokenBinding(project,"v",1,binding.requestId())); + change("manifestVersion","changed"); + assertNull(persistence.finishBrokenBinding(project,"v",1,UUID.randomUUID().toString())); + assertEquals("REMOTE_REVIEW",saved().getScanState()); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"repair","request","metadata","duplicate"}) + void concurrentChangeBetweenSnapshotAndFailureWriteIsNotOverwritten(String mutation) { + change("manifestVersion","changed");var intercepted=new AtomicBoolean(); + var settings=com.mongodb.MongoClientSettings.builder().applyConnectionString(new com.mongodb.ConnectionString( + "mongodb://127.0.0.1:"+System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030")+"/?serverSelectionTimeoutMS=3000")) + .addCommandListener(new com.mongodb.event.CommandListener() { + @Override public void commandStarted(com.mongodb.event.CommandStartedEvent event) { + if(!event.getCommandName().equals("update") || !intercepted.compareAndSet(false,true))return; + switch(mutation) { + case "repair" -> change("manifestVersion",null); + case "request" -> change("scanResult.scanRequestId",UUID.randomUUID().toString()); + case "metadata" -> change("unrecognizedMetadata","preserve this concurrent field"); + case "duplicate" -> { + var collection=mongo.getCollection("projects");var root=collection.find().first(); + collection.updateOne(new Document("_id",root.get("_id")),new Document("$push",new Document("versions",root.getList("versions",Document.class).getFirst()))); + } + } + } + }).build(); + try(var raceClient=MongoClients.create(settings)) { + assertNull(new RemoteReviewPersistence(new MongoTemplate(raceClient,database)).finishBrokenBinding(project,"v",1,binding.requestId())); + } + assertTrue(intercepted.get());assertEquals("REMOTE_REVIEW",saved().getScanState());assertEquals(ScanStatus.SCANNING,saved().getStatus()); + if(mutation.equals("repair"))assertNotNull(new RemoteReviewPersistence(mongo).retained(project,"v",1,binding.requestId())); + if(mutation.equals("metadata"))assertEquals("preserve this concurrent field",mongo.getCollection("projects").find().first().getList("versions",Document.class).getFirst().getString("unrecognizedMetadata")); + if(mutation.equals("duplicate"))assertEquals(2,mongo.findById(project,Project.class).getVersions().size()); + } + @Test void retainedContextMismatchNeverFetchesAnotherConfiguration() { + change("manifestVersion","changed");route(this::configuration); + assertEquals("UNAVAILABLE",bootstrap(new RemoteReviewPersistence(mongo)).prepare(project,"v",1,binding.requestId()).state());assertEquals(0,gets.get()); + } + @Test void unsupportedContextAndMissingRequestRemainExplicitWithoutHttp() { + queuedAgain();change("overrideFileUrl","unreviewed.zip");route(this::configuration);var boot=bootstrap(new RemoteReviewPersistence(mongo)); + assertEquals("UNAVAILABLE",boot.prepare(project,"v",1,binding.requestId()).state());assertEquals("NO_WORK",boot.prepare(project,"v",1,null).state());assertEquals(0,gets.get()); + } + @Test void unsupportedSetupPreservesRequestAndFindingsAndStopsAutomaticPolling() { + queuedAgain();change("overrideFileUrl","unreviewed.zip");change("scanResult.manualRescan",true); + change("scanResult.verdict","BLOCK");change("scanResult.riskScore",42); + change("scanResult.issues",List.of(new org.bson.Document("type","RetainedFinding"))); + route(this::configuration);var boot=bootstrap(new RemoteReviewPersistence(mongo)); + assertEquals("UNAVAILABLE",boot.advance(project,"v",1,binding.requestId()).state()); + var result=saved();assertEquals(ScanStatus.FAILED,result.getStatus());assertEquals("REMOTE_UNSUPPORTED_CONTEXT",result.getScanState()); + assertEquals(binding.requestId(),result.getScanRequestId());assertEquals(1,result.getScanAttempt());assertTrue(result.isManualRescan()); + assertEquals("BLOCK",result.getVerdict());assertEquals(42,result.getRiskScore());assertEquals("RetainedFinding",result.getIssues().getFirst().getType()); + assertFalse(ArtifactClearancePolicy.cleared(result));assertNull(result.getRemoteReview()); + assertTrue(new RemoteReviewDiscovery(mongo).page(null,16).candidates().isEmpty()); + assertEquals("NO_WORK",bootstrap(new RemoteReviewPersistence(mongo)).advance(project,"v",1,binding.requestId()).state()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(project)),new Update().set("status",ProjectStatus.PUBLISHED),Project.class); + var failures=new net.modtale.service.admin.review.ModerationQueuePageReader(mongo).page(null,25,net.modtale.service.admin.review.ModerationQueuePageReader.Filter.OPERATIONS); + assertEquals(1,failures.items().size());assertEquals("REMOTE_UNSUPPORTED_CONTEXT",failures.items().getFirst().pendingVersion().scan().scanState()); + assertEquals(0,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"context","request","verdict","binding","duplicate"}) + void unsupportedSetupRejectsConcurrentChange(String mutation) { + queuedAgain();change("overrideFileUrl","unreviewed.zip");var persistence=new RemoteReviewPersistence(mongo); + var observed=persistence.current(project,"v",1,binding.requestId());assertNotNull(observed); + switch(mutation) { + case "context" -> change("overrideFileUrl",null); + case "request" -> change("scanResult.scanRequestId",UUID.randomUUID().toString()); + case "verdict" -> change("scanResult.verdict","BLOCK"); + case "binding" -> change("scanResult.remoteReview",binding); + case "duplicate" -> mongo.updateFirst(Query.query(Criteria.where("_id").is(project)),new Update().push("versions",observed),Project.class); + } + assertFalse(persistence.finishUnsupportedContext(project,observed));assertEquals(ScanStatus.SCANNING,saved().getStatus()); + } + @Test void supportedOrAlreadyBoundContextCannotBeFinishedAsUnsupported() { + var persistence=new RemoteReviewPersistence(mongo); + assertFalse(persistence.finishUnsupportedContext(project,persistence.current(project,"v",1,binding.requestId()))); + queuedAgain();assertFalse(persistence.finishUnsupportedContext(project,persistence.current(project,"v",1,binding.requestId()))); + assertEquals("QUEUED",saved().getScanState()); + } + @Test void manualRescanModeSurvivesBootstrapAttachmentAndCompletesImmediately() { + queuedAgain();change("scanResult.manualRescan",true); + route(e->{if(e.getRequestURI().getPath().endsWith("configuration"))configuration(e);else reply(e,200,"COMPLETED");}); + var outcome=bootstrap(new RemoteReviewPersistence(mongo)).advance(project,"v",1,binding.requestId());assertEquals("APPLIED",outcome.state()); + var version=mongo.findById(project,Project.class).getVersions().getFirst();assertEquals(ProjectVersion.ReviewStatus.APPROVED,version.getReviewStatus()); + assertNotNull(version.getApprovedSecurityEvidence());assertNull(version.getScanResult());assertEquals(0,posts.get()); + } + @Test void requestModeChangeDuringConfigurationRejectsStaleBinding() { + queuedAgain();route(e->{change("scanResult.manualRescan",true);configuration(e);}); + assertEquals("NO_WORK",bootstrap(new RemoteReviewPersistence(mongo)).prepare(project,"v",1,binding.requestId()).state());assertNull(saved().getRemoteReview()); + } + @Test void schedulerDiscoversBootstrapsUploadsAndSchedulesCompletedReview()throws Exception { + queuedAgain();route(e->{if(e.getRequestURI().getPath().endsWith("configuration"))configuration(e); + else if(e.getRequestMethod().equals("POST"))reply(e,202,"COMPLETED");else reply(e,e.getRequestURI().getPath().endsWith("/result")?200:404,"COMPLETED");}); + when(policy.remoteJobsEnabled()).thenReturn(true); + var recovery=mock(ScanRecoveryService.class);var legacyPersistence=mock(ScanPersistenceService.class); + var execution=new ScanExecutionService(policy,storage,r->{throw new AssertionError("Legacy dispatch");},legacyPersistence,completion,recovery); + execution.enqueueBackgroundScan(project,"v",binding.filePath(),"original.zip",false,1,binding.requestId()); + execution.recoverStaleScanningVersions(); + assertEquals("QUEUED",saved().getScanState());assertEquals(binding.requestId(),saved().getScanRequestId()); + verifyNoInteractions(legacyPersistence,recovery);verify(storage,never()).download(any()); + try(var scheduler=new RemoteReviewScheduler(new RemoteReviewDiscovery(mongo),bootstrap(new RemoteReviewPersistence(mongo)),new RemoteReviewScheduler.Settings(2,4,100,2000))) { + scheduler.start();long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(10); + while(mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()!=ProjectVersion.ReviewStatus.SCHEDULED && System.nanoTime(){if(e.getRequestURI().getPath().endsWith("configuration"))configuration(e);else reply(e,404,"AWAITING_UPLOAD");}); + when(storage.downloadBounded(anyString(),anyInt())).thenAnswer(i->{entered.countDown();long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(5); + while(release.getCount()>0&&System.nanoTime()reply(e,200,state)); + assertEquals("UNAVAILABLE",step.advance(binding).state()); + var result=saved();assertEquals(ScanStatus.FAILED,result.getStatus());assertEquals("REMOTE_"+state,result.getScanState()); + assertEquals("REVIEW",result.getVerdict());assertEquals(binding.requestId(),result.getScanRequestId()); + assertEquals(job,result.getRemoteReview().jobId());assertEquals(state,result.getRemoteStatus().state()); + assertNull(result.getRemotePoll());assertNull(result.getSecurityEvidence());assertFalse(ArtifactClearancePolicy.cleared(result)); + assertEquals(0,result.getRiskScore());assertTrue(result.getIssues().isEmpty());assertTrue(result.getScanTimestamp()>0); + assertEquals(ProjectVersion.ReviewStatus.PENDING,mongo.findById(project,Project.class).getVersions().getFirst().getReviewStatus()); + assertTrue(new RemoteReviewDiscovery(mongo).page(null,16).candidates().isEmpty()); + assertEquals("NO_WORK",new RemoteReviewStep(new RemoteReviewPollStore(mongo),client,storage,completion).advance(result.getRemoteReview()).state()); + assertEquals(1,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage);verify(completion,never()).handleRemoteCompletedScan(any(),any()); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"HELD","CANCELLED","EXPIRED"}) + void terminalServiceOutcomePreservesSecurityBlockAndFindings(String state) { + change("scanResult.verdict","BLOCK");change("scanResult.riskScore",87); + change("scanResult.issues",List.of(new Document("type","RetainedFinding"))); + route(e->reply(e,200,state));assertEquals("UNAVAILABLE",step.advance(binding).state()); + assertEquals("BLOCK",saved().getVerdict());assertEquals(87,saved().getRiskScore()); + assertEquals("RetainedFinding",saved().getIssues().getFirst().getType());assertEquals(ScanStatus.FAILED,saved().getStatus()); + assertEquals("REMOTE_"+state,saved().getScanState());assertFalse(ArtifactClearancePolicy.cleared(saved())); + assertEquals(binding.requestId(),saved().getScanRequestId());assertEquals(job,saved().getRemoteReview().jobId()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(project)),new Update().set("status",ProjectStatus.PUBLISHED),Project.class); + var reader=new net.modtale.service.admin.review.ModerationQueuePageReader(mongo); + for(var filter:List.of(net.modtale.service.admin.review.ModerationQueuePageReader.Filter.SECURITY,net.modtale.service.admin.review.ModerationQueuePageReader.Filter.OPERATIONS)) { + var rows=reader.page(null,25,filter).items();assertEquals(1,rows.size());assertEquals("BLOCK",rows.getFirst().pendingVersion().scan().verdict()); + } + assertEquals(1,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"verdict","context","lease"}) + void terminalFailureWriteRejectsChangesAfterLiveSnapshot(String mutation) { + var claim=attached(30000);var intercepted=new AtomicBoolean(); + var settings=com.mongodb.MongoClientSettings.builder().applyConnectionString(new com.mongodb.ConnectionString( + "mongodb://127.0.0.1:"+System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030")+"/?serverSelectionTimeoutMS=3000")) + .addCommandListener(new com.mongodb.event.CommandListener() { + @Override public void commandStarted(com.mongodb.event.CommandStartedEvent event) { + if(!event.getCommandName().equals("update") || !intercepted.compareAndSet(false,true))return; + switch(mutation) { + case "verdict" -> change("scanResult.verdict","BLOCK"); + case "context" -> change("manifestVersion","changed during write"); + case "lease" -> change("scanResult.remotePoll.token",UUID.randomUUID().toString()); + } + } + }).build(); + try(var raceClient=MongoClients.create(settings)) { + assertFalse(new RemoteReviewPollStore(new MongoTemplate(raceClient,database)).finishUnavailable(claim, + new RemoteReviewClient.Status(job,"HELD",true,1,2,"HELD"))); + } + assertTrue(intercepted.get());assertEquals("REMOTE_REVIEW",saved().getScanState());assertEquals(ScanStatus.SCANNING,saved().getStatus()); + if(mutation.equals("verdict")) { + assertEquals("BLOCK",saved().getVerdict()); + assertTrue(polls.finishUnavailable(claim,new RemoteReviewClient.Status(job,"HELD",true,1,2,"HELD"))); + assertEquals("BLOCK",saved().getVerdict()); + } + } + @Test void databaseQueueKeepsFailedVersionVisibleBesideScanningSibling() { + route(e->reply(e,200,"HELD"));assertEquals("UNAVAILABLE",step.advance(binding).state()); + var sibling=new ProjectVersion();sibling.setId("scanning-sibling");sibling.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); + var scan=new ScanResult();scan.setStatus(ScanStatus.SCANNING);sibling.setScanResult(scan); + mongo.updateFirst(Query.query(Criteria.where("_id").is(project)),new Update().set("status",ProjectStatus.PUBLISHED).push("versions",sibling),Project.class); + var rows=new net.modtale.service.admin.review.ModerationQueuePageReader(mongo).page(null,25).items();assertEquals(1,rows.size());assertEquals("v",rows.getFirst().pendingVersion().id()); + assertEquals("REMOTE_HELD",rows.getFirst().pendingVersion().scan().scanState()); + assertEquals(ScanStatus.FAILED,rows.getFirst().pendingVersion().scan().status()); + } + @Test void expiredTerminalClaimCannotOverwriteOrConsumeAnotherAttempt() { + var claim=attached(1000); + change("scanResult.remotePoll.leaseUntil",new Date(0)); + var status=new RemoteReviewClient.Status(job,"HELD",true,1,2,"HELD"); + assertFalse(polls.finishUnavailable(claim,status));assertEquals("REMOTE_REVIEW",saved().getScanState()); + var replacement=polls.claim(claim.binding(),30000);assertNotNull(replacement); + assertFalse(polls.finishUnavailable(claim,status));assertTrue(polls.isCurrent(replacement)); + assertTrue(polls.finishUnavailable(replacement,status));assertFalse(polls.finishUnavailable(replacement,status)); + } + @Test void terminalOutcomeRequiresExactJobAndUnchangedContext() { + var claim=attached(30000); + assertFalse(polls.finishUnavailable(claim,new RemoteReviewClient.Status(UUID.randomUUID().toString(),"HELD",true,1,2,"HELD"))); + assertFalse(polls.finishUnavailable(claim,new RemoteReviewClient.Status(job,"COMPLETED",true,1,2,"COMPLETED"))); + assertFalse(polls.finishUnavailable(claim,new RemoteReviewClient.Status(job,"HELD",false,1,2,"HELD"))); + assertFalse(polls.recordStatusAndRelease(claim,new RemoteReviewClient.Status(job,"HELD",true,1,2,"HELD"),10000)); + change("manifestVersion","changed"); + assertFalse(polls.finishUnavailable(claim,new RemoteReviewClient.Status(job,"HELD",true,1,2,"HELD"))); + assertEquals("REMOTE_REVIEW",saved().getScanState()); + } + @Test void lostPostResponseRecoversSameRequestAndRecordsJobWithoutAnotherUpload() { + var accepted=new AtomicBoolean();route(e->{if(e.getRequestMethod().equals("POST")){accepted.set(true);return;}reply(e,accepted.get()?200:404,accepted.get()?"QUEUED":"AWAITING_UPLOAD");}); + assertEquals("RETRY",step.advance(binding).state());assertNull(saved().getRemoteReview().jobId());ready(); + assertEquals("RECORDED",step.advance(saved().getRemoteReview()).state());assertEquals(job,saved().getRemoteReview().jobId());assertEquals(1,posts.get());verify(storage,times(1)).downloadBounded(anyString(),anyInt()); + } + @Test void contextChangeDuringLookupPreventsDownloadAndPost() { + route(e->{change("manifestVersion","changed");reply(e,404,"AWAITING_UPLOAD");}); + assertEquals("SUPERSEDED",step.advance(binding).state());verifyNoInteractions(storage);assertEquals(0,posts.get());assertNull(saved().getRemoteStatus()); + } + @Test void leaseExpiryDuringDownloadPreventsPost() { + route(e->reply(e,404,"AWAITING_UPLOAD"));when(storage.downloadBounded(anyString(),anyInt())).thenAnswer(i->{change("scanResult.remotePoll.leaseUntil",new Date(0));return bytes;}); + assertEquals("SUPERSEDED",step.advance(binding).state());assertEquals(0,posts.get());assertNull(saved().getRemoteReview().jobId()); + } + @Test void changedContextDuringPostCannotAttachRemoteJob() { + route(e->{if(e.getRequestMethod().equals("POST")){change("manifestVersion","changed");reply(e,202,"QUEUED");}else reply(e,404,"AWAITING_UPLOAD");}); + assertEquals("SUPERSEDED",step.advance(binding).state());assertEquals(1,posts.get());assertNull(saved().getRemoteReview().jobId());assertNull(saved().getRemoteStatus()); + } + @Test void knownUploadingJobCanResumeAbandonedUploadWithSameIdentity() { + var state=new AtomicReference<>("UPLOADING");route(e->{if(e.getRequestMethod().equals("POST"))reply(e,202,"QUEUED");else reply(e,200,state.get());}); + assertEquals("UPLOADING",step.advance(binding).remoteState());verifyNoInteractions(storage);ready();state.set("AWAITING_UPLOAD"); + assertEquals("QUEUED",step.advance(saved().getRemoteReview()).remoteState());assertEquals(job,saved().getRemoteReview().jobId());assertEquals(1,posts.get()); + } + @Test void competingStepCannotDispatchUnderExistingLiveClaim() { + assertNotNull(polls.claim(binding,120000));route(e->reply(e,404,"AWAITING_UPLOAD"));assertEquals("NO_WORK",step.advance(binding).state());assertEquals(0,gets.get());verifyNoInteractions(storage); + } + @Test void legacyOriginRoutesOnceToOperationsWithoutNetworkOrBackfill() { + change("scanResult.remoteReview.origin",null);change("scanResult.verdict","BLOCK");var old=saved().getRemoteReview(); + route(e->reply(e,200,"COMPLETED"));var boot=bootstrap(new RemoteReviewPersistence(mongo)); + assertEquals("UNAVAILABLE",boot.advance(project,"v",1,binding.requestId()).state());assertEquals("REMOTE_ORIGIN_UNVERIFIED",saved().getScanState());assertEquals(ScanStatus.FAILED,saved().getStatus()); + assertEquals("BLOCK",saved().getVerdict());assertEquals(old,saved().getRemoteReview());assertNull(saved().getRemoteReview().origin());assertFalse(saved().isArtifactVerified()); + assertEquals("NO_WORK",boot.advance(project,"v",1,binding.requestId()).state());assertEquals(0,gets.get());assertEquals(0,posts.get());assertEquals(0,identityGets.get());verifyNoInteractions(storage); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) + void legacyWriterWinningInitialBindingRoutesImmediatelyEvenAfterLostAcknowledgement(boolean lostAcknowledgement) { + queuedAgain();change("scanResult.verdict","BLOCK");route(this::configuration); + var persistence=spy(new RemoteReviewPersistence(mongo)); + var legacy=new RemoteReviewBinding(binding.projectId(),binding.versionId(),binding.requestId(),binding.attempt(),binding.filePath(), + binding.artifactSha256(),binding.contextSha256(),binding.policyVersion(),binding.reviewConfigSha256(),job,binding.manualRescan()); + doAnswer(i->{change("scanResult.remoteReview",legacy);change("scanResult.scanState","REMOTE_REVIEW"); + if(lostAcknowledgement)throw new IllegalStateException("Unknown binding acknowledgement");return i.callRealMethod();}).when(persistence).bind(any(),any()); + var boot=bootstrap(persistence);var prepared=boot.prepare(project,"v",1,binding.requestId()); + assertEquals("UNAVAILABLE",prepared.state());assertNull(prepared.binding()); + assertEquals(ScanStatus.FAILED,saved().getStatus());assertEquals("REMOTE_ORIGIN_UNVERIFIED",saved().getScanState()); + assertEquals(legacy,saved().getRemoteReview());assertEquals("BLOCK",saved().getVerdict());assertFalse(saved().isArtifactVerified()); + assertEquals("NO_WORK",boot.advance(project,"v",1,binding.requestId()).state()); + assertEquals(1,identityGets.get());assertEquals(1,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage); + assertTrue(new RemoteReviewDiscovery(mongo).page(null,16).candidates().isEmpty()); + } + @Test void remoteContextConflictPreservesBindingAndBlockWithoutPollingAgain() { + change("scanResult.verdict","BLOCK");route(e->reply(e,409,"QUEUED"));var before=saved().getRemoteReview(); + assertEquals("UNAVAILABLE",step.advance(binding).state());assertEquals("REMOTE_CONTEXT_CONFLICT",saved().getScanState());assertEquals(before,saved().getRemoteReview());assertEquals("BLOCK",saved().getVerdict()); + assertEquals("NO_WORK",step.advance(binding).state());assertEquals(1,gets.get());assertEquals(0,posts.get());verifyNoInteractions(storage); + } + @Test void aConflictArrivingAfterLocalContextChangeCannotOverwriteNewState() { + route(e->{change("manifestVersion","concurrent");reply(e,409,"QUEUED");});assertEquals("SUPERSEDED",step.advance(binding).state());assertEquals("REMOTE_REVIEW",saved().getScanState()); + } + @Test void changingOriginAfterCompletionSnapshotRejectsTheFinalWrite() { + var claim=attached(30000);when(policy.currentPolicyVersion()).thenAnswer(i->{change("scanResult.remoteReview.origin.deploymentId","22222222-2222-2222-2222-222222222222");return binding.policyVersion();}); + assertFalse(completion.handleRemoteCompletedScan(claim,cleanResult()));assertEquals("REMOTE_REVIEW",saved().getScanState());assertFalse(saved().isArtifactVerified()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationApiIntegrationTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationApiIntegrationTest.java new file mode 100644 index 000000000..2c47f3948 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationApiIntegrationTest.java @@ -0,0 +1,103 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.*; +import net.modtale.controller.admin.ReviewCancellationController; +import net.modtale.service.admin.review.*; +import net.modtale.service.user.account.AccountService; +import net.modtale.model.user.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.context.annotation.*; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.*; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import java.time.*; +import java.util.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewCancellationApiIntegrationTest { + @Configuration @EnableMethodSecurity static class MethodSecurity {} + public static class Permissions { + public boolean hasAdminPermission(String permission,Authentication auth){return auth!=null&&auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority()));} + } + ReviewOrphanCancellationJournalTest base=new ReviewOrphanCancellationJournalTest(); + AnnotationConfigApplicationContext context;MockMvc mvc;ObjectMapper mapper=new ObjectMapper(); + AtomicReference account=new AtomicReference<>();AtomicInteger cancellations=new AtomicInteger(),reads=new AtomicInteger(); + String prefix="/api/v1/admin/verification/cancellations"; + @BeforeEach void setup()throws Exception { + base.setup();account.set(user()); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("actor",null,List.of(new SimpleGrantedAuthority("PROJECT_REVIEW_READ"),new SimpleGrantedAuthority("PROJECT_VERSION_RESCAN")))); + open(); + } + User user(){var u=new User();u.setId("actor");u.setAdminPermissions(Set.of(AdminPermission.PROJECT_REVIEW_READ,AdminPermission.PROJECT_VERSION_RESCAN));return u;} + void open() { + var f=base.fixture.fixture;var accounts=mock(AccountService.class);when(accounts.getCurrentUser(any())).thenAnswer(i->account.get()); + var journal=base.create(f.mongo,Clock.systemUTC()); + var preparation=new ReviewRepairPreparation(base.fixture.archive,base.fixture.reader,Clock.systemUTC(),1,60000); + var workflow=new ReviewRepairWorkflow(preparation,base.fixture.isolation,1); + var executor=new ReviewOrphanCancellationExecutor(journal,f.client,1,Duration.ofSeconds(5)); + var reconciler=new ReviewCancellationReconciler(f.mongo,journal,f.client,1,Duration.ofSeconds(5)); + context=new AnnotationConfigApplicationContext();context.getEnvironment().getPropertySources().addFirst(new org.springframework.core.env.MapPropertySource("test",Map.of("app.warden.repair.cancellation.enabled","true")));context.register(MethodSecurity.class); + context.registerBean("apiSecurity",Permissions.class,Permissions::new); + context.registerBean(ReviewRepairWorkflow.class,()->workflow);context.registerBean(ReviewOrphanCancellationExecutor.class,()->executor);context.registerBean(ReviewCancellationReconciler.class,()->reconciler); + context.registerBean(ReviewCancellationAccess.class,()->new ReviewCancellationAccess(accounts,workflow,journal,executor,reconciler)); + context.registerBean(ReviewCancellationController.class,()->new ReviewCancellationController(context.getBean(ReviewCancellationAccess.class))); + context.refresh();mvc=MockMvcBuilders.standaloneSetup(context.getBean(ReviewCancellationController.class)).build(); + } + @AfterEach void cleanup(){if(context!=null)context.close();SecurityContextHolder.clearContext();base.cleanup();} + JsonNode postJson(String route,Object body)throws Exception { + var response=mvc.perform(post(prefix+route).contentType("application/json").content(mapper.writeValueAsBytes(body))) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andReturn(); + return mapper.readTree(response.getResponse().getContentAsByteArray()); + } + JsonNode getJson(String route)throws Exception { + return mapper.readTree(mvc.perform(get(prefix+route)).andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andReturn().getResponse().getContentAsByteArray()); + } + JsonNode prepare()throws Exception{return postJson("/prepare",Map.of("isolationId",base.fixture.prepared.id()));} + @Test void duplicateHttpRequestsAndRestartRecoverOriginalAndLaterOutcomesWithoutRemoteRedispatch()throws Exception { + var f=base.fixture.fixture;var before=base.fixture.raw(); + f.route(e->{if(e.getRequestMethod().equals("DELETE")){cancellations.incrementAndGet();e.close();}else{assertEquals("GET",e.getRequestMethod());f.reply(e,200,reads.incrementAndGet()==1?"CANCELLED":"COMPLETED");}}); + var preview=getJson("/targets/"+base.fixture.prepared.id());assertEquals(f.job,preview.path("jobId").asText()); + var original=prepare();assertEquals(preview.path("targetSha256"),original.path("targetSha256")); + var executed=postJson("/execute",original);assertEquals("UNKNOWN",executed.path("state").asText()); + assertEquals(executed,postJson("/execute",original));assertEquals(1,cancellations.get()); + var initial=getJson("/operations/"+base.fixture.prepared.id());assertEquals(executed.path("receipt"),initial); + String id=UUID.randomUUID().toString();var check=Map.of("id",id,"original",original); + var observed=postJson("/checks",check);assertEquals("CANCELLED",observed.path("observation").path("status").path("state").asText()); + assertEquals(observed,postJson("/checks",check));assertEquals(observed,postJson("/checks/receipt",check));assertEquals(1,reads.get()); + context.close();open();assertEquals(initial,getJson("/operations/"+base.fixture.prepared.id()));assertEquals(observed,postJson("/checks/receipt",check)); + assertEquals(executed,postJson("/execute",original));assertEquals(observed,postJson("/checks",check));assertEquals(1,cancellations.get());assertEquals(1,reads.get()); + var second=postJson("/checks",Map.of("id",UUID.randomUUID().toString(),"original",original));assertEquals("COMPLETED",second.path("observation").path("status").path("state").asText()); + assertEquals(initial,getJson("/operations/"+base.fixture.prepared.id()));assertEquals(observed,postJson("/checks/receipt",check)); + assertEquals(2,f.mongo.getCollection(ReviewCancellationReconciler.COLLECTION).countDocuments());assertEquals(before,base.fixture.raw()); + assertEquals("BLOCK",f.saved().getVerdict());assertEquals(0,f.identityGets.get());verifyNoInteractions(f.storage); + } + @Test void storedAccountRevocationAfterDispatchCannotReturnAuthorityOrCauseAnotherCancellation()throws Exception { + var f=base.fixture.fixture;var original=prepare(); + f.route(e->{assertEquals("DELETE",e.getRequestMethod());cancellations.incrementAndGet();account.set(null);f.reply(e,200,"CANCELLED");}); + mvc.perform(post(prefix+"/execute").contentType("application/json").content(mapper.writeValueAsBytes(original))) + .andExpect(status().isForbidden()).andExpect(content().string(""));assertEquals(1,cancellations.get()); + account.set(user());var recovered=getJson("/operations/"+base.fixture.prepared.id());assertEquals("DISPATCHING",recovered.path("state").asText()); + assertEquals("DISPATCHING",postJson("/execute",original).path("state").asText());assertEquals(1,cancellations.get()); + } + @Test void forgedIntentAndFreshAccountDenialCannotCreateRemoteWork()throws Exception { + var f=base.fixture.fixture;f.route(e->{assertEquals("DELETE",e.getRequestMethod());cancellations.incrementAndGet();f.reply(e,200,"CANCELLED");});var original=prepare(); + var forged=original.deepCopy();((com.fasterxml.jackson.databind.node.ObjectNode)forged).put("targetSha256","f".repeat(64)); + // Unknown execution response carries no claim; the retained intent still cannot be changed. + assertEquals("UNKNOWN",postJson("/execute",forged).path("state").asText());assertEquals(0,cancellations.get()); + var denied=user();denied.setAdminPermissions(Set.of(AdminPermission.PROJECT_REVIEW_READ));account.set(denied); + mvc.perform(get(prefix+"/targets/"+base.fixture.prepared.id())).andExpect(status().isForbidden()); + mvc.perform(post(prefix+"/execute").contentType("application/json").content(mapper.writeValueAsBytes(original))).andExpect(status().isForbidden()); + assertEquals(0,cancellations.get());assertEquals("PREPARED",base.operations().find().first().getString("state"));assertEquals(0,f.mongo.getCollection(ReviewCancellationReconciler.COLLECTION).countDocuments()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationReconcilerTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationReconcilerTest.java new file mode 100644 index 000000000..ab22e82fa --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewCancellationReconcilerTest.java @@ -0,0 +1,141 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import com.mongodb.*; +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewCancellationReconcilerTest { + ReviewOrphanCancellationJournalTest base=new ReviewOrphanCancellationJournalTest();RemoteReviewStepIntegrationTest f; + ReviewOrphanCancellationJournal.Prepared original;ReviewCancellationReconciler reconciler;String id=UUID.randomUUID().toString();AtomicInteger reads=new AtomicInteger(); + @BeforeEach void setup()throws Exception { + base.setup();f=base.fixture.fixture;original=base.prepare();var claim=base.journal.claim(original,"actor",()->true);assertTrue(base.journal.beginDispatch(claim,"actor",()->true)>0); + assertEquals("UNKNOWN",base.journal.record(claim,"actor",new ReviewOrphanCancellationJournal.Observation("UNKNOWN",null,503),()->true).state()); + reconciler=create(f.mongo,Duration.ofSeconds(5)); + } + @AfterEach void cleanup(){reconciler.close();base.cleanup();} + ReviewCancellationReconciler create(MongoTemplate mongo,Duration budget){return new ReviewCancellationReconciler(mongo,base.journal,f.client,1,budget);} + MongoCollection observations(){return f.mongo.getCollection(ReviewCancellationReconciler.COLLECTION);} + ReviewCancellationReconciler.Receipt check(){return reconciler.check(id,original,"actor",()->true);} + void route(int code,String state){f.route(e->{assertEquals("GET",e.getRequestMethod());reads.incrementAndGet();f.reply(e,code,state);});} + @Test void aLaterStatusPreservesTheOriginalUnknownOutcomeAndSurvivesRecreation() { + route(200,"CANCELLED");var before=base.operations().find().first();var project=base.fixture.raw();var result=check(); + assertEquals("OBSERVED",result.state());assertEquals("CANCELLED",result.observation().status().state());assertNotNull(result.receivedAt()); + assertEquals(result,create(f.mongo,Duration.ofSeconds(5)).check(id,original,"actor",()->true));assertEquals(result,reconciler.receipt(id,original,"actor",()->true)); + assertEquals(1,reads.get());assertEquals(0,f.posts.get());assertEquals(0,f.identityGets.get());assertEquals(before,base.operations().find().first());assertEquals(project,base.fixture.raw());verifyNoInteractions(f.storage); + assertNull(base.journal.claim(original,"actor",()->true)); + } + @Test void currentVersionEditsOrDeletionCannotRetargetTheOriginalStatusRequest() { + f.change("scanResult.remoteReview.jobId",UUID.randomUUID().toString());f.mongo.getCollection("projects").deleteMany(new Document()); + route(200,"COMPLETED");assertEquals(f.job,check().observation().status().jobId());assertEquals(1,reads.get()); + } + @Test void distinctExplicitChecksKeepBothObservationsWithoutOverwritingHistory() { + f.route(e->{assertEquals("GET",e.getRequestMethod());f.reply(e,200,reads.incrementAndGet()==1?"QUEUED":"CANCELLED");}); + var first=check();var second=reconciler.check(UUID.randomUUID().toString(),original,"actor",()->true); + assertEquals("QUEUED",first.observation().status().state());assertEquals("CANCELLED",second.observation().status().state());assertEquals(first,reconciler.receipt(id,original,"actor",()->true)); + assertEquals(2,observations().countDocuments());assertEquals("UNKNOWN",base.operations().find().first().getString("state")); + } + @ParameterizedTest @ValueSource(ints={404,409,401,403,429,503}) + void statusFailuresAreRetainedWithoutCancellingOrRepeatingTheRead(int code) { + route(code,"QUEUED");var result=check();String kind=code==404?"NOT_FOUND":code==409?"CONTEXT_CONFLICT":code==503?"UNKNOWN":"UNAVAILABLE"; + assertEquals(kind,result.observation().kind());assertEquals(code,result.observation().httpStatus());assertEquals(result,check());assertEquals(1,reads.get()); + assertEquals("UNKNOWN",base.operations().find().first().getString("state")); + } + @Test void permissionAndOriginalIntentValidationPrecedeReservation() { + assertThrows(SecurityException.class,()->reconciler.check(id,original,"actor",()->false));assertThrows(SecurityException.class,()->reconciler.check(id,original,"other",()->true)); + var forged=new ReviewOrphanCancellationJournal.Prepared(original.isolationId(),"f".repeat(64),original.createdAt(),original.expiresAt()); + assertThrows(IllegalStateException.class,()->reconciler.check(id,forged,"actor",()->true));assertEquals(0,observations().countDocuments());assertEquals(0,reads.get()); + } + @Test void unstartedCancellationCannotCreateAnObservation() { + var record=base.operations().find().first();base.operations().replaceOne(new Document("_id",original.isolationId()),new Document("_id",original.isolationId()).append("intent",record.get("intent")).append("state","PREPARED")); + assertThrows(IllegalStateException.class,this::check);assertEquals(0,observations().countDocuments()); + } + @Test void competingChecksAndLocalAdmissionDoNotDuplicateARead()throws Exception { + var arrived=new CountDownLatch(1);var release=new CountDownLatch(1);f.route(e->{assertEquals("GET",e.getRequestMethod());reads.incrementAndGet();arrived.countDown();release.await(5,TimeUnit.SECONDS);f.reply(e,200,"CANCELLED");}); + try(var pool=Executors.newSingleThreadExecutor();var replica=create(f.mongo,Duration.ofSeconds(5))) { + var first=pool.submit(this::check);assertTrue(arrived.await(3,TimeUnit.SECONDS));assertThrows(IllegalStateException.class,()->reconciler.check(UUID.randomUUID().toString(),original,"actor",()->true)); + assertEquals("READING",replica.check(id,original,"actor",()->true).state());release.countDown();assertEquals("OBSERVED",first.get(4,TimeUnit.SECONDS).state());assertEquals(1,reads.get()); + }finally{release.countDown();} + } + MongoCollection intercepted(MongoTemplate template) { + var ops=spy(observations().withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY).withWriteConcern(WriteConcern.MAJORITY.withJournal(true))); + doReturn(ops).when(template).getCollection(ReviewCancellationReconciler.COLLECTION);doReturn(ops).when(ops).withReadPreference(any());doReturn(ops).when(ops).withReadConcern(any());doReturn(ops).when(ops).withWriteConcern(any());doReturn(ops).when(ops).withTimeout(anyLong(),any());return ops; + } + @Test void lostReservationAcknowledgementRecoversOnlyTheSameReadToken() { + var mongo=spy(f.mongo);var ops=intercepted(mongo);doAnswer(i->{i.callRealMethod();throw new MongoException("Lost reservation acknowledgement");}).when(ops).insertOne(any(Document.class)); + route(200,"CANCELLED");var result=create(mongo,Duration.ofSeconds(5)).check(id,original,"actor",()->true);assertEquals("OBSERVED",result.state());assertEquals(result,check());assertEquals(1,reads.get()); + } + @Test void lostReadGateAcknowledgementCannotStartAnHttpRequest() { + var mongo=spy(f.mongo);var ops=intercepted(mongo);doAnswer(i->{i.callRealMethod();throw new MongoException("Lost read gate acknowledgement");}).when(ops).findOneAndUpdate(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.FindOneAndUpdateOptions.class)); + route(200,"CANCELLED");var result=create(mongo,Duration.ofSeconds(5)).check(id,original,"actor",()->true);assertEquals("UNKNOWN",result.state());assertEquals(result,check());assertEquals(0,reads.get()); + } + @Test void lostObservationAcknowledgementReadsBackWithoutAnotherStatusCall() { + var mongo=spy(f.mongo);var ops=intercepted(mongo);doAnswer(i->{i.callRealMethod();throw new MongoException("Lost observation acknowledgement");}).when(ops).updateOne(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.UpdateOptions.class)); + route(200,"CANCELLED");var result=create(mongo,Duration.ofSeconds(5)).check(id,original,"actor",()->true);assertEquals("OBSERVED",result.state());assertEquals(result,check());assertEquals(1,reads.get()); + } + @Test void droppedReplyProducesOneReadAndUnknownObservation() { + f.route(e->{assertEquals("GET",e.getRequestMethod());reads.incrementAndGet();e.close();});var result=check();assertEquals("UNKNOWN",result.state());assertEquals(result,check());assertEquals(1,reads.get()); + } + @Test void tamperedObservationCannotBeReadOrOverwrittenAsAValidResult() { + route(200,"CANCELLED");check();observations().updateOne(new Document("_id",id),new Document("$set",new Document("observation.status.jobId",UUID.randomUUID().toString()))); + var stored=observations().find().first();assertThrows(IllegalStateException.class,()->reconciler.receipt(id,original,"actor",()->true));assertThrows(IllegalStateException.class,this::check); + assertEquals(stored,observations().find().first());assertEquals(1,reads.get()); + } + @Test void permissionRevocationAfterReplyLeavesAnUnresolvedReadWithoutReplay() { + var allowed=new AtomicBoolean(true);f.route(e->{assertEquals("GET",e.getRequestMethod());reads.incrementAndGet();allowed.set(false);f.reply(e,200,"CANCELLED");}); + assertThrows(SecurityException.class,()->reconciler.check(id,original,"actor",allowed::get));assertEquals("READING",check().state());assertEquals(1,reads.get()); + } + @Test void shutdownAndInvalidIdsDoNotAllocateObservationRecords() { + assertThrows(IllegalArgumentException.class,()->reconciler.check("bad",original,"actor",()->true));reconciler.close();assertThrows(IllegalStateException.class,this::check); + assertThrows(SecurityException.class,()->reconciler.check(id,original,"actor",()->false));assertEquals(0,observations().countDocuments()); + } + @Test void remainingReadBudgetBoundsWaitingWithoutRepeatingTheGet()throws Exception { + var arrived=new CountDownLatch(1);var release=new CountDownLatch(1);var done=new CountDownLatch(1); + f.route(e->{assertEquals("GET",e.getRequestMethod());reads.incrementAndGet();arrived.countDown();try{release.await(5,TimeUnit.SECONDS);f.reply(e,200,"CANCELLED");}finally{done.countDown();}}); + try(var bounded=create(f.mongo,Duration.ofSeconds(1));var pool=Executors.newSingleThreadExecutor()) { + var request=pool.submit(()->bounded.check(id,original,"actor",()->true));assertTrue(arrived.await(3,TimeUnit.SECONDS)); + var result=request.get(4,TimeUnit.SECONDS);assertEquals("UNKNOWN",result.state());assertEquals(result,check());assertEquals(1,reads.get()); + }finally{release.countDown();assertTrue(done.await(3,TimeUnit.SECONDS));} + } + @Test void wrongRemoteJobCannotBecomeAValidReconciliationObservation() { + f.route(e->{assertEquals("GET",e.getRequestMethod());reads.incrementAndGet();f.job=UUID.randomUUID().toString();f.reply(e,200,"CANCELLED");}); + var result=check();assertEquals("UNKNOWN",result.state());assertEquals(502,result.observation().httpStatus());assertNull(result.observation().status());assertEquals(result,check());assertEquals(1,reads.get()); + } + @Test void sourceTamperingAfterReservationCannotReachTheRemoteService() { + var journal=spy(base.journal);var count=new AtomicInteger(); + doAnswer(i->{var target=i.callRealMethod();if(count.incrementAndGet()==1)f.mongo.getCollection(ReviewSnapshotArchive.METADATA) + .updateOne(new Document("_id",original.isolationId()),new Document("$set",new Document("expiresAt",1L)));return target; + }).when(journal).reconciliationTarget(any(),anyString(),any()); + route(200,"CANCELLED");try(var checked=new ReviewCancellationReconciler(f.mongo,journal,f.client,1,Duration.ofSeconds(5))) { + assertThrows(IllegalStateException.class,()->checked.check(id,original,"actor",()->true)); + } + assertEquals(0,reads.get());assertEquals("RESERVED",observations().find().first().getString("state")); + } + @Test void statusCanBeObservedAfterTheOriginalCancellationWindowExpires()throws Exception { + var other=new ReviewOrphanCancellationJournalTest();other.setup(); + try { + var remote=other.fixture.fixture;var journal=new ReviewOrphanCancellationJournal(remote.mongo,other.resolver,other.fixture.archive,java.time.Clock.systemUTC(),1000); + var prepared=journal.prepare(other.fixture.prepared.id(),"actor",()->true);var claim=journal.claim(prepared,"actor",()->true);assertNotNull(claim); + assertTrue(journal.beginDispatch(claim,"actor",()->true)>0);journal.record(claim,"actor",new ReviewOrphanCancellationJournal.Observation("UNKNOWN",null,503),()->true); + Thread.sleep(1100);assertTrue(System.currentTimeMillis()>=prepared.expiresAt()); + remote.route(e->{assertEquals("GET",e.getRequestMethod());remote.reply(e,200,"CANCELLED");}); + try(var later=new ReviewCancellationReconciler(remote.mongo,journal,remote.client,1,Duration.ofSeconds(5))) { + var receipt=later.check(UUID.randomUUID().toString(),prepared,"actor",()->true);assertEquals("CANCELLED",receipt.observation().status().state()); + assertEquals("UNKNOWN",journal.receipt(prepared,"actor",()->true).state());assertNull(journal.claim(prepared,"actor",()->true)); + } + }finally{other.cleanup();} + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationExecutorTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationExecutorTest.java new file mode 100644 index 000000000..c7e3e5f58 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationExecutorTest.java @@ -0,0 +1,112 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import com.mongodb.MongoException; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewOrphanCancellationExecutorTest { + ReviewOrphanCancellationJournalTest base=new ReviewOrphanCancellationJournalTest(); + RemoteReviewStepIntegrationTest f;ReviewOrphanCancellationJournal.Prepared prepared;ReviewOrphanCancellationExecutor executor; + AtomicInteger deletes=new AtomicInteger(); + @BeforeEach void setup()throws Exception {base.setup();f=base.fixture.fixture;prepared=base.prepare();executor=create(base.journal,Duration.ofSeconds(5));} + @AfterEach void cleanup(){executor.close();base.cleanup();} + ReviewOrphanCancellationExecutor create(ReviewOrphanCancellationJournal journal,Duration budget){return new ReviewOrphanCancellationExecutor(journal,f.client,1,budget);} + ReviewOrphanCancellationExecutor.Execution execute(){return executor.execute(prepared,"actor",()->true);} + void route(int code,String state){f.route(e->{assertEquals("DELETE",e.getRequestMethod());deletes.incrementAndGet();f.reply(e,code,state);});} + @ParameterizedTest @ValueSource(strings={"CANCELLED","COMPLETED","HELD","EXPIRED","QUEUED","RUNNING"}) + void recordsActualRemoteStateWithoutGrantingClearanceOrRepeatingDispatch(String state) { + route(200,state);var before=base.fixture.raw();var result=execute();assertEquals("OBSERVED",result.state());assertEquals("REMOTE_STATUS",result.receipt().observation().kind()); + assertEquals(state,result.receipt().observation().status().state());assertNotNull(result.receipt().observedAt()); + assertEquals(result,create(base.journal,Duration.ofSeconds(5)).execute(prepared,"actor",()->true));assertEquals(1,deletes.get());assertEquals(0,f.posts.get());assertEquals(0,f.identityGets.get()); + assertEquals(before,base.fixture.raw());base.fixture.assertIsolated();assertEquals("BLOCK",f.saved().getVerdict());verifyNoInteractions(f.storage); + } + @ParameterizedTest @ValueSource(ints={404,409,401,403,429,503}) + void rejectedAndUncertainResponsesAreDistinctAndCannotRetry(int code) { + route(code,"QUEUED");var result=execute();String kind=code==404?"NOT_FOUND":code==409?"CONTEXT_CONFLICT":code==503?"UNKNOWN":"UNAVAILABLE"; + assertEquals(kind,result.receipt().observation().kind());assertEquals(code,result.receipt().observation().httpStatus());assertNull(result.receipt().observation().status()); + assertEquals(result,execute());assertEquals(1,deletes.get()); + } + @Test void mismatchedRemoteJobIsUnknownAndNeverCleared() { + f.route(e->{deletes.incrementAndGet();f.job=UUID.randomUUID().toString();f.reply(e,200,"CANCELLED");}); + var result=execute();assertEquals("UNKNOWN",result.state());assertNull(result.receipt().observation().status());assertEquals(502,result.receipt().observation().httpStatus());assertEquals(result,execute());assertEquals(1,deletes.get()); + } + @Test void droppedResponseHasOneHttpAttemptAndDurableUnknownOutcome() { + f.route(e->{deletes.incrementAndGet();e.close();});var result=execute();assertEquals("UNKNOWN",result.state());assertEquals(result,execute());assertEquals(1,deletes.get()); + } + @Test void remainingBudgetBoundsHttpWaitAndLeavesNoRetryAuthority()throws Exception { + var arrived=new CountDownLatch(1);var release=new CountDownLatch(1);var done=new CountDownLatch(1); + f.route(e->{deletes.incrementAndGet();arrived.countDown();try{release.await(5,TimeUnit.SECONDS);f.reply(e,200,"CANCELLED");}finally{done.countDown();}}); + try(var bounded=create(base.journal,Duration.ofSeconds(1));var pool=Executors.newSingleThreadExecutor()) { + var result=pool.submit(()->bounded.execute(prepared,"actor",()->true));assertTrue(arrived.await(3,TimeUnit.SECONDS)); + var observed=result.get(4,TimeUnit.SECONDS);assertEquals("UNKNOWN",observed.state());assertEquals(observed,execute());assertEquals(1,deletes.get()); + }finally{release.countDown();assertTrue(done.await(3,TimeUnit.SECONDS));} + } + @Test void localAdmissionAndAnotherReplicaCannotDuplicateAnInFlightDispatch()throws Exception { + var arrived=new CountDownLatch(1);var release=new CountDownLatch(1); + f.route(e->{deletes.incrementAndGet();arrived.countDown();release.await(5,TimeUnit.SECONDS);f.reply(e,200,"CANCELLED");}); + try(var pool=Executors.newSingleThreadExecutor();var replica=create(base.journal,Duration.ofSeconds(5))) { + var first=pool.submit(this::execute);assertTrue(arrived.await(3,TimeUnit.SECONDS));assertEquals("BUSY",execute().state()); + assertEquals("DISPATCHING",replica.execute(prepared,"actor",()->true).state());release.countDown();assertEquals("OBSERVED",first.get(4,TimeUnit.SECONDS).state());assertEquals(1,deletes.get()); + }finally{release.countDown();} + } + @Test void lostDispatchAcknowledgementNeverRecoversIntoAnHttpRequest() { + var journal=spy(base.journal);doAnswer(i->{i.callRealMethod();throw new MongoException("Unknown dispatch acknowledgement");}).when(journal).beginDispatch(any(),anyString(),any()); + route(200,"CANCELLED");var result=create(journal,Duration.ofSeconds(5)).execute(prepared,"actor",()->true); + assertEquals("UNKNOWN",result.state());assertEquals(0,deletes.get());assertEquals(result,execute()); + } + @Test void lostObservationAcknowledgementReadsBackTheCommittedReceipt() { + var mongo=spy(f.mongo);var ops=base.intercepted(mongo); + doAnswer(i->{i.callRealMethod();throw new MongoException("Lost outcome acknowledgement");}).when(ops).updateOne(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.UpdateOptions.class)); + route(200,"CANCELLED");var result=create(base.create(mongo,Clock.systemUTC()),Duration.ofSeconds(5)).execute(prepared,"actor",()->true); + assertEquals("OBSERVED",result.state());assertEquals(result,execute());assertEquals(1,deletes.get()); + } + @Test void aTerminalObservationCannotBeOverwrittenByLaterBookkeeping() { + route(200,"COMPLETED");var first=execute();var record=base.operations().find().first(); + var claim=new ReviewOrphanCancellationJournal.Claim(prepared,record.getString("token"),base.resolver.resolve(prepared.isolationId(),"actor")); + var later=new ReviewOrphanCancellationJournal.Observation("REMOTE_STATUS",new RemoteReviewClient.Status(f.job,"CANCELLED",true,1000,2000,null),null); + assertEquals(first.receipt(),base.journal.record(claim,"actor",later,()->true));assertEquals(record,base.operations().find().first()); + } + @Test void revokedPermissionAfterRemoteReplyCannotPublishAnUnauthorizedReceiptOrRetry() { + var allowed=new AtomicBoolean(true);f.route(e->{deletes.incrementAndGet();allowed.set(false);f.reply(e,200,"CANCELLED");}); + assertThrows(SecurityException.class,()->executor.execute(prepared,"actor",allowed::get));assertEquals("DISPATCHING",execute().state());assertEquals(1,deletes.get()); + } + @Test void shutdownRefusesNewClaimsAndPermissionIsCheckedFirst() { + executor.close();assertEquals("SHUTDOWN",execute().state());assertEquals("PREPARED",base.operations().find().first().getString("state")); + assertThrows(SecurityException.class,()->executor.execute(prepared,"actor",()->false));assertEquals(0,deletes.get()); + } + @Test void forgedObservationForAnotherJobCannotBeRetained() { + var claim=base.journal.claim(prepared,"actor",()->true);assertTrue(base.journal.beginDispatch(claim,"actor",()->true)>0); + var observation=new ReviewOrphanCancellationJournal.Observation("REMOTE_STATUS",new RemoteReviewClient.Status(UUID.randomUUID().toString(),"CANCELLED",true,1000,2000,null),null); + assertThrows(IllegalStateException.class,()->base.journal.record(claim,"actor",observation,()->true));assertEquals("DISPATCHING",base.operations().find().first().getString("state")); + } + @Test void theSameClaimCannotCrossTheDispatchBoundaryTwice() { + var claim=base.journal.claim(prepared,"actor",()->true);assertTrue(base.journal.beginDispatch(claim,"actor",()->true)>0); + assertEquals(0,base.journal.beginDispatch(claim,"actor",()->true));assertEquals(0,deletes.get()); + var forged=new ReviewOrphanCancellationJournal.Claim(prepared,UUID.randomUUID().toString(),claim.target()); + assertThrows(IllegalStateException.class,()->base.journal.beginDispatch(forged,"actor",()->true)); + } + @Test void revocationImmediatelyAfterDispatchGatePreventsHttpSubscription() { + var allowed=new AtomicBoolean(true);var journal=spy(base.journal); + doAnswer(i->{var result=i.callRealMethod();allowed.set(false);return result;}).when(journal).beginDispatch(any(),anyString(),any()); + route(200,"CANCELLED");assertThrows(SecurityException.class,()->create(journal,Duration.ofSeconds(5)).execute(prepared,"actor",allowed::get)); + assertEquals("DISPATCHING",base.operations().find().first().getString("state"));assertEquals(0,deletes.get());assertEquals("DISPATCHING",execute().state()); + } + + @Test void deadlineBeforeArmingIsOperationalStateRatherThanPermissionDenial() { + var journal=spy(base.journal);doAnswer(i->{Thread.sleep(1100);return i.callRealMethod();}).when(journal).claim(any(),anyString(),any()); + route(200,"CANCELLED");var result=create(journal,Duration.ofSeconds(1)).execute(prepared,"actor",()->true); + assertEquals("PREPARED",result.state());assertEquals(0,deletes.get());assertFalse(base.operations().find().first().containsKey("token")); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationJournalTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationJournalTest.java new file mode 100644 index 000000000..d26551421 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanCancellationJournalTest.java @@ -0,0 +1,173 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import com.mongodb.*; +import com.mongodb.client.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mongodb.core.MongoTemplate; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewOrphanCancellationJournalTest { + RemoteReviewIsolationIntegrationTest fixture=new RemoteReviewIsolationIntegrationTest(); + ReviewOrphanTargetResolver resolver;ReviewOrphanCancellationJournal journal; + @BeforeEach void setup()throws Exception { + fixture.setup();fixture.fixture.attached(30000);fixture.fixture.change("scanResult.verdict","BLOCK");fixture.isolateExpiredBrokenPoll(); + resolver=new ReviewOrphanTargetResolver(fixture.fixture.mongo,fixture.archive,fixture.isolation);journal=create(fixture.fixture.mongo,Clock.systemUTC()); + } + @AfterEach void cleanup(){fixture.cleanup();} + ReviewOrphanCancellationJournal create(MongoTemplate mongo,Clock clock){return new ReviewOrphanCancellationJournal(mongo,resolver,fixture.archive,clock,60000);} + ReviewOrphanCancellationJournal.Prepared prepare(){return journal.prepare(fixture.prepared.id(),"actor",()->true);} + MongoCollection operations(){return fixture.fixture.mongo.getCollection(ReviewOrphanCancellationJournal.COLLECTION);} + @Test void preparationRetainsOriginalTargetAndExpiryAcrossRestartWithoutRemoteCalls() { + var before=fixture.raw();var prepared=prepare();var record=operations().find().first(); + assertEquals(prepared,create(fixture.fixture.mongo,Clock.systemUTC()).prepare(fixture.prepared.id(),"actor",()->true));assertEquals(record,operations().find().first()); + assertEquals(fixture.prepared.id(),prepared.isolationId());assertTrue(prepared.targetSha256().matches("[0-9a-f]{64}")); + fixture.fixture.change("scanResult.remoteReview.jobId",UUID.randomUUID().toString());assertEquals(prepared,prepare()); + assertEquals("BLOCK",fixture.fixture.saved().getVerdict());assertEquals(before.get("reviewStatus"),fixture.raw().get("reviewStatus")); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get());verifyNoInteractions(fixture.fixture.storage); + } + @Test void previewUsesAuthenticatedOriginalTargetAndMatchesPreparedDigestWithoutCreatingWork() { + var before=fixture.raw();long archives=fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments(); + var preview=journal.preview(fixture.prepared.id(),"actor",()->true); + assertEquals(fixture.prepared.id(),preview.isolationId());assertEquals(fixture.prepared.sha256(),preview.beforeSha256()); + assertEquals(fixture.fixture.job,preview.jobId());assertEquals(0,preview.versionIndex()); + assertEquals(0,operations().countDocuments());assertEquals(archives,fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments());assertEquals(before,fixture.raw()); + fixture.fixture.change("scanResult.remoteReview.jobId",UUID.randomUUID().toString()); + assertEquals(preview,journal.preview(fixture.prepared.id(),"actor",()->true)); + fixture.fixture.mongo.getCollection("projects").deleteMany(new Document()); + assertEquals(preview,journal.preview(fixture.prepared.id(),"actor",()->true)); + assertEquals(preview.targetSha256(),prepare().targetSha256()); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get());verifyNoInteractions(fixture.fixture.storage); + } + @Test void previewRejectsUnconfirmedIsolationRevokedPermissionAndOtherActors() { + assertThrows(SecurityException.class,()->journal.preview(fixture.prepared.id(),"other",()->true)); + assertThrows(SecurityException.class,()->journal.preview("invalid","actor",()->false)); + var calls=new java.util.concurrent.atomic.AtomicInteger();assertThrows(SecurityException.class,()->journal.preview(fixture.prepared.id(),"actor",()->calls.incrementAndGet()<3)); + fixture.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).updateOne(new Document("_id",fixture.prepared.id()),new Document("$set",new Document("state","UNKNOWN"))); + assertThrows(IllegalStateException.class,()->journal.preview(fixture.prepared.id(),"actor",()->true));assertEquals(0,operations().countDocuments()); + } + @Test void expiredIntentRecoveryIsReadOnlyAndNeverRenewsCancellationAuthority() { + var old=create(fixture.fixture.mongo,Clock.fixed(Instant.now().minusSeconds(3600),ZoneOffset.UTC)); + var prepared=old.prepare(fixture.prepared.id(),"actor",()->true);var before=operations().find().first();var version=fixture.raw(); + var recovered=create(fixture.fixture.mongo,Clock.systemUTC()).recover(prepared.isolationId(),"actor",()->true); + assertEquals(prepared,recovered.prepared());assertEquals("PREPARED",recovered.state());assertNull(recovered.observation()); + assertThrows(IllegalStateException.class,()->prepare());assertNull(journal.claim(recovered.prepared(),"actor",()->true)); + assertEquals(before,operations().find().first());assertEquals(version,fixture.raw()); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get());verifyNoInteractions(fixture.fixture.storage); + } + @Test void unknownOutcomeRecoveryAfterRestartRetainsOriginalObservationAndNoToken() { + var prepared=prepare();var claim=journal.claim(prepared,"actor",()->true);assertTrue(journal.beginDispatch(claim,"actor",()->true)>0); + var receipt=journal.record(claim,"actor",new ReviewOrphanCancellationJournal.Observation("UNKNOWN",null,503),()->true); + var before=operations().find().first(); + assertEquals(receipt,create(fixture.fixture.mongo,Clock.systemUTC()).recover(prepared.isolationId(),"actor",()->true)); + assertEquals(before,operations().find().first());assertNull(journal.claim(prepared,"actor",()->true)); + assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get()); + } + @Test void missingCancellationRecoveryDoesNotPrepareOrCreateAnArchive() { + var before=fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments(); + assertThrows(IllegalStateException.class,()->journal.recover(fixture.prepared.id(),"actor",()->true)); + assertEquals(0,operations().countDocuments());assertEquals(before,fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).countDocuments()); + } + @Test void recoveryRequiresOriginalActorAndCurrentPermissionBeforeReturningEvidence() { + var prepared=prepare();var before=operations().find().first(); + assertThrows(SecurityException.class,()->journal.recover(prepared.isolationId(),"other",()->true)); + assertThrows(SecurityException.class,()->journal.recover("invalid","actor",()->false)); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + assertThrows(SecurityException.class,()->journal.recover(prepared.isolationId(),"actor",()->calls.incrementAndGet()<3)); + assertEquals(before,operations().find().first()); + } + @Test void concurrentCallersCanObtainOnlyOneExecutionToken()throws Exception { + var prepared=prepare();var start=new CountDownLatch(1); + try(var pool=Executors.newFixedThreadPool(2)) { + var one=pool.submit(()->{start.await();return journal.claim(prepared,"actor",()->true);}); + var two=pool.submit(()->{start.await();return create(fixture.fixture.mongo,Clock.systemUTC()).claim(prepared,"actor",()->true);});start.countDown(); + var a=one.get(10,TimeUnit.SECONDS);var b=two.get(10,TimeUnit.SECONDS);assertNotEquals(a==null,b==null); + var winner=a==null?b:a;assertEquals(fixture.fixture.job,winner.target().binding().jobId());assertNotNull(winner.target().binding().origin()); + } + assertNull(journal.claim(prepared,"actor",()->true));assertEquals(1,operations().countDocuments());assertEquals("EXECUTING",operations().find().first().getString("state")); + } + @Test void databaseTimeRejectsExpiredOrFutureIntentEvenWithSkewedClientClock() { + var old=create(fixture.fixture.mongo,Clock.fixed(Instant.now().minusSeconds(3600),ZoneOffset.UTC));var prepared=old.prepare(fixture.prepared.id(),"actor",()->true); + assertNull(old.claim(prepared,"actor",()->true));assertEquals("PREPARED",operations().find().first().getString("state")); + assertThrows(IllegalStateException.class,()->prepare()); + } + @Test void futureIntentCannotExecute() { + var future=create(fixture.fixture.mongo,Clock.fixed(Instant.now().plusSeconds(3600),ZoneOffset.UTC));var prepared=future.prepare(fixture.prepared.id(),"actor",()->true); + assertNull(future.claim(prepared,"actor",()->true)); + } + @ParameterizedTest @ValueSource(strings={"actor","target","expiry","extra","state","numericType"}) + void alteredStoredIntentOrStateCannotAcquireAuthority(String field) { + var prepared=prepare();var change=switch(field) { + case "actor"->new Document("intent.actor","other");case "target"->new Document("intent.target.binding.origin.deploymentId","22222222-2222-2222-2222-222222222222"); + case "expiry"->new Document("intent.expiresAt",prepared.expiresAt()+1);case "extra"->new Document("extra",true);case "numericType"->new Document("intent.target.binding.attempt",1L); + default->new Document("state",List.of("PREPARED"));}; + operations().updateOne(new Document("_id",prepared.isolationId()),new Document("$set",change));var before=operations().find().first(); + assertThrows(IllegalStateException.class,()->journal.claim(prepared,"actor",()->true));assertThrows(IllegalStateException.class,()->journal.recover(prepared.isolationId(),"actor",()->true));assertEquals(before,operations().find().first()); + } + @Test void permissionAndArchiveAuthorityAreRequiredBeforeCreatingIntent() { + assertThrows(SecurityException.class,()->journal.prepare(fixture.prepared.id(),"actor",()->false));assertThrows(SecurityException.class,()->journal.prepare(fixture.prepared.id(),"other",()->true)); + fixture.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).updateOne(new Document("_id",fixture.prepared.id()),new Document("$set",new Document("state","UNKNOWN"))); + assertThrows(IllegalStateException.class,()->prepare());assertEquals(0,operations().countDocuments()); + } + MongoCollection intercepted(MongoTemplate template) { + var configured=operations().withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY).withWriteConcern(WriteConcern.MAJORITY.withJournal(true)); + var spy=spy(configured);doReturn(spy).when(template).getCollection(ReviewOrphanCancellationJournal.COLLECTION); + doReturn(spy).when(spy).withReadPreference(any());doReturn(spy).when(spy).withReadConcern(any());doReturn(spy).when(spy).withWriteConcern(any());doReturn(spy).when(spy).withTimeout(anyLong(),any());return spy; + } + @Test void lostPreparationAcknowledgementRetainsOneOriginalIntent() { + var mongo=spy(fixture.fixture.mongo);var ops=intercepted(mongo);doAnswer(i->{i.callRealMethod();throw new MongoException("Lost preparation acknowledgement");}).when(ops).insertOne(any(Document.class)); + var prepared=create(mongo,Clock.systemUTC()).prepare(fixture.prepared.id(),"actor",()->true);assertEquals(prepared,prepare());assertEquals(1,operations().countDocuments()); + } + @Test void lostClaimAcknowledgementRecoversOnlyThisInvocationsToken() { + var prepared=prepare();var mongo=spy(fixture.fixture.mongo);var ops=intercepted(mongo); + doAnswer(i->{i.callRealMethod();throw new MongoException("Lost claim acknowledgement");}).when(ops).findOneAndUpdate(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.FindOneAndUpdateOptions.class)); + var claim=create(mongo,Clock.systemUTC()).claim(prepared,"actor",()->true);assertNotNull(claim);assertEquals(claim.token(),operations().find().first().getString("token"));assertNull(journal.claim(prepared,"actor",()->true)); + } + @Test void revokedPermissionAfterArmingCannotExposeOrReclaimToken() { + var prepared=prepare();var permitted=new AtomicBoolean(true);var mongo=spy(fixture.fixture.mongo);var ops=intercepted(mongo); + doAnswer(i->{var result=i.callRealMethod();permitted.set(false);return result;}).when(ops).findOneAndUpdate(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.FindOneAndUpdateOptions.class)); + assertThrows(SecurityException.class,()->create(mongo,Clock.systemUTC()).claim(prepared,"actor",permitted::get));assertEquals("EXECUTING",operations().find().first().getString("state"));assertNull(journal.claim(prepared,"actor",()->true)); + } + @Test void anUncommittedFailedClaimNeverReturnsExecutionAuthority() { + var prepared=prepare();var mongo=spy(fixture.fixture.mongo);var ops=intercepted(mongo); + doThrow(new MongoException("Claim unavailable")).when(ops).findOneAndUpdate(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.FindOneAndUpdateOptions.class)); + assertThrows(IllegalStateException.class,()->create(mongo,Clock.systemUTC()).claim(prepared,"actor",()->true));assertEquals("PREPARED",operations().find().first().getString("state")); + } + @ParameterizedTest @ValueSource(strings={"attempt","versionIndex","state"}) + void aTypeSubstitutionAfterValidationCannotWinTheClaim(String field) { + var prepared=prepare();var mongo=spy(fixture.fixture.mongo);var ops=intercepted(mongo); + doAnswer(i->{ + var patch=switch(field){case "attempt"->new Document("intent.target.binding.attempt",1L);case "versionIndex"->new Document("intent.target.versionIndex",0L);default->new Document("state",List.of("PREPARED"));}; + operations().updateOne(new Document("_id",prepared.isolationId()),new Document("$set",patch));return i.callRealMethod(); + }).when(ops).findOneAndUpdate(any(org.bson.conversions.Bson.class),anyList(),any(com.mongodb.client.model.FindOneAndUpdateOptions.class)); + assertNull(create(mongo,Clock.systemUTC()).claim(prepared,"actor",()->true));assertFalse(operations().find().first().containsKey("token")); + } + + @Test void alteredJournalTimesCannotRenewPreparationAgainstTheSignedArchive() { + var prepared=prepare();operations().updateOne(new Document("_id",prepared.isolationId()),new Document("$set",new Document("intent.createdAt",prepared.createdAt()+1).append("intent.expiresAt",prepared.expiresAt()+1))); + assertThrows(IllegalStateException.class,()->prepare()); + var forged=new ReviewOrphanCancellationJournal.Prepared(prepared.isolationId(),prepared.targetSha256(),prepared.createdAt()+1,prepared.expiresAt()+1); + assertThrows(IllegalStateException.class,()->journal.claim(forged,"actor",()->true));assertFalse(operations().find().first().containsKey("token")); + } + @Test void aTamperedCancellationArchiveCannotAuthorizeAClaim() { + var prepared=prepare();fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).updateOne(new Document("action","CANCEL_REMOTE_REVIEW"),new Document("$set",new Document("expiresAt",prepared.expiresAt()+1))); + assertThrows(IllegalStateException.class,()->journal.claim(prepared,"actor",()->true));assertFalse(operations().find().first().containsKey("token")); + } + @Test void archivedIntentSurvivesJournalInsertFailureWithoutRenewingExpiry() { + var mongo=spy(fixture.fixture.mongo);var ops=intercepted(mongo);doThrow(new MongoException("No journal acknowledgement")).when(ops).insertOne(any(Document.class)); + assertThrows(IllegalStateException.class,()->create(mongo,Clock.systemUTC()).prepare(fixture.prepared.id(),"actor",()->true)); + var retained=fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).find(new Document("action","CANCEL_REMOTE_REVIEW")).first();assertNotNull(retained); + var prepared=prepare();assertEquals(retained.getLong("createdAt"),prepared.createdAt());assertEquals(retained.getLong("expiresAt"),prepared.expiresAt()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanTargetResolverTest.java b/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanTargetResolverTest.java new file mode 100644 index 000000000..eee43e01d --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ReviewOrphanTargetResolverTest.java @@ -0,0 +1,74 @@ +package net.modtale.service.security.scan; + +import net.modtale.service.admin.review.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REPAIR_TX_DB_TEST",matches="true") +class ReviewOrphanTargetResolverTest { + RemoteReviewIsolationIntegrationTest fixture=new RemoteReviewIsolationIntegrationTest(); + ReviewOrphanTargetResolver resolver; + @BeforeEach void setup()throws Exception { + fixture.setup();resolver=new ReviewOrphanTargetResolver(fixture.fixture.mongo,fixture.archive,fixture.isolation); + } + @AfterEach void cleanup(){fixture.cleanup();} + @Test void usesOriginalAuthenticatedBindingEvenAfterCurrentVersionIsEditedOrDeleted() { + var claim=fixture.fixture.attached(30000);fixture.isolateExpiredBrokenPoll();var before=fixture.raw(); + var target=resolver.resolve(fixture.prepared.id(),"actor");assertEquals(claim.binding(),target.binding()); + assertEquals(before.get("_id"),target.projectId());assertEquals(0,target.versionIndex());assertEquals(fixture.prepared.sha256(),target.beforeSha256()); + assertEquals(before,fixture.raw());fixture.fixture.change("scanResult.remoteReview.jobId",UUID.randomUUID().toString()); + assertEquals(target,resolver.resolve(fixture.prepared.id(),"actor"));fixture.fixture.mongo.getCollection("projects").deleteMany(new Document()); + assertEquals(target,resolver.resolve(fixture.prepared.id(),"actor"));assertEquals(0,fixture.fixture.gets.get());assertEquals(0,fixture.fixture.posts.get());verifyNoInteractions(fixture.fixture.storage); + } + @ParameterizedTest @ValueSource(strings={"projectId","versionId","requestId","attempt","filePath","artifactSha256","contextSha256","manualRescan"}) + void refusesAMismatchedBindingThatIsolationCorrectlyPreserved(String field) { + fixture.fixture.attached(30000); + Object value=switch(field){case "attempt"->2;case "manualRescan"->true;case "requestId"->UUID.randomUUID().toString();case "artifactSha256","contextSha256"->"d".repeat(64);default->"different";}; + fixture.fixture.change("scanResult.remoteReview."+field,value);fixture.isolateExpiredBrokenPoll();var before=fixture.raw(); + assertThrows(IllegalStateException.class,()->resolver.resolve(fixture.prepared.id(),"actor"));assertEquals(before,fixture.raw()); + } + @Test void refusesMissingJobWithoutRemoteDiscovery() { + fixture.fixture.attached(30000);fixture.fixture.change("scanResult.remoteReview.jobId",null);fixture.isolateExpiredBrokenPoll(); + assertThrows(IllegalStateException.class,()->resolver.resolve(fixture.prepared.id(),"actor"));assertEquals(0,fixture.fixture.gets.get()); + } + @Test void anotherActorOrAnUnconfirmedIsolationCannotSupplyACancellationTarget() { + fixture.fixture.attached(30000);fixture.isolateExpiredBrokenPoll();assertThrows(SecurityException.class,()->resolver.resolve(fixture.prepared.id(),"other")); + fixture.fixture.mongo.getCollection(ReviewRepairJournal.COLLECTION).updateOne(new Document("_id",fixture.prepared.id()),new Document("$set",new Document("state","UNKNOWN"))); + assertThrows(IllegalStateException.class,()->resolver.resolve(fixture.prepared.id(),"actor")); + } + @Test void tamperedArchiveCannotSupplyATarget() { + fixture.fixture.attached(30000);fixture.isolateExpiredBrokenPoll(); + fixture.fixture.mongo.getCollection(ReviewSnapshotArchive.METADATA).updateOne(new Document("_id",fixture.prepared.id()),new Document("$set",new Document("actorId","other"))); + assertThrows(IllegalStateException.class,()->resolver.resolve(fixture.prepared.id(),"actor")); + } + @Test void malformedArchivedBindingIsNotReconstructedFromTheCurrentVersion() { + fixture.fixture.attached(30000);fixture.fixture.change("scanResult.remoteReview",new Document("jobId","invalid"));fixture.isolateExpiredBrokenPoll(); + assertNull(fixture.fixture.saved().getRemoteReview());assertThrows(IllegalStateException.class,()->resolver.resolve(fixture.prepared.id(),"actor")); + } + @Test void brokenPollPayloadDoesNotPreventResolvingTheIntactOriginalBinding() { + var claim=fixture.fixture.attached(30000);fixture.fixture.change("scanResult.remotePoll.token",17);fixture.isolateExpiredBrokenPoll(); + assertEquals(claim.binding(),resolver.resolve(fixture.prepared.id(),"actor").binding()); + } + + @ParameterizedTest @ValueSource(strings={"position","bytes"}) + void archiveReadMustMatchTheSameReceiptEvenIfStorageChangesBetweenReads(String field) { + fixture.fixture.attached(30000);fixture.isolateExpiredBrokenPoll();var source=fixture.archive.load(fixture.prepared.id());var archive=spy(fixture.archive); + byte[] bytes=source.versionBytes();if(field.equals("bytes")){var doc=new org.bson.RawBsonDocument(bytes).decode(new org.bson.codecs.DocumentCodec());doc.put("manifestVersion","different");var buffer=new org.bson.RawBsonDocument(doc,new org.bson.codecs.DocumentCodec()).getByteBuffer().asNIO();bytes=new byte[buffer.remaining()];buffer.get(bytes);} + var changed=new ReviewSnapshotArchive.Snapshot(source.id(),source.projectId(),field.equals("position")?1:source.versionIndex(),source.actorId(),source.action(),source.createdAt(),source.expiresAt(),bytes); + doReturn(changed).when(archive).load(source.id()); + var resolver=new ReviewOrphanTargetResolver(fixture.fixture.mongo,archive,fixture.isolation); + assertThrows(IllegalStateException.class,()->resolver.resolve(source.id(),"actor")); + } + + @Test void legacyBindingWithoutOriginCannotBecomeACancellationTarget() { + fixture.fixture.attached(30000);fixture.fixture.change("scanResult.remoteReview.origin",null);fixture.isolateExpiredBrokenPoll(); + assertThrows(IllegalStateException.class,()->resolver.resolve(fixture.prepared.id(),"actor")); + } + +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanEvidenceFixtures.java b/backend/src/test/java/net/modtale/service/security/scan/ScanEvidenceFixtures.java new file mode 100644 index 000000000..abfa79f7c --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanEvidenceFixtures.java @@ -0,0 +1,28 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +public final class ScanEvidenceFixtures { + public static ScanResult complete(boolean granted) { + ScanResult result = new ScanResult(); + result.setStatus(granted ? ScanStatus.CLEAN : ScanStatus.SUSPICIOUS); + result.setVerdict(granted ? "AUTO_APPROVE" : "REVIEW"); + result.setScanState("COMPLETED"); + result.setArtifactVerified(true); + result.setScanTimestamp(System.currentTimeMillis()); + ScanResult.ScanSummary summary = new ScanResult.ScanSummary(); + summary.setFilesScanned(1); + result.setSummary(summary); + String hash = "a".repeat(64); + result.setSecurityEvidence(new ScanResult.SecurityEvidence("warden-3.0.0:" + "a".repeat(64), "b".repeat(64), + digest("9:Mod.class:" + hash + "\n"), true, granted, "COMPLETED", Map.of("Mod.class", hash))); + return result; + } + static String digest(String text) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); } + catch (Exception e) { throw new IllegalStateException(e); } + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanEvidencePersistenceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanEvidencePersistenceTest.java new file mode 100644 index 000000000..25703ecc0 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanEvidencePersistenceTest.java @@ -0,0 +1,52 @@ +package net.modtale.service.security.scan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.modtale.model.project.ScanResult; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import static org.junit.jupiter.api.Assertions.*; + +class ScanEvidencePersistenceTest { + @Test void internalTrustSurvivesDatabaseRoundTripButCannotBeSuppliedOverJson() throws Exception { + var context = new MongoMappingContext(); + context.afterPropertiesSet(); + var converter = new MappingMongoConverter(NoOpDbRefResolver.INSTANCE, context); + converter.setCustomConversions(new net.modtale.config.db.MongoConfig().mongoCustomConversions()); + converter.afterPropertiesSet(); + var original = ScanEvidenceFixtures.complete(true); + var remote=new net.modtale.model.project.RemoteReviewBinding("project","version",java.util.UUID.randomUUID().toString(),1, + "original.zip","a".repeat(64),"b".repeat(64),"warden-3.0.0:"+"c".repeat(64),"d".repeat(64),null,false,new net.modtale.model.project.RemoteReviewOrigin("11111111-1111-1111-1111-111111111111","e".repeat(64))); + original.setManualRescan(true); + original.setRemoteReview(remote); + original.setRemoteStatus(new ScanResult.RemoteReviewStatus(java.util.UUID.randomUUID().toString(),"QUEUED",true,1000,2000,null)); + original.setRemotePoll(new ScanResult.RemoteReviewPoll(java.util.UUID.randomUUID().toString(),new java.util.Date(1000),new java.util.Date(2000))); + original.setReviewedContextSha256("c".repeat(64)); + var stored = new Document(); + converter.write(original, stored); + var restored = converter.read(ScanResult.class, stored); + assertTrue(ArtifactClearancePolicy.complete(restored)); + assertEquals(remote,restored.getRemoteReview());assertEquals(remote.origin(),restored.getRemoteReview().origin()); + assertTrue(restored.isManualRescan()); + assertEquals(original.getRemoteStatus(),restored.getRemoteStatus()); + assertEquals(original.getRemotePoll(),restored.getRemotePoll()); + assertEquals(original.getReviewedContextSha256(), restored.getReviewedContextSha256()); + var mapper = new ObjectMapper(); + var forged = mapper.readValue("{\"artifactVerified\":true,\"reviewedContextSha256\":\"forged\"}", ScanResult.class); + assertFalse(forged.isArtifactVerified()); + assertNull(forged.getReviewedContextSha256()); + String json = mapper.writeValueAsString(original); + assertFalse(json.contains("remoteReview")); + assertFalse(json.contains("remotePoll")); + assertFalse(json.contains("remoteStatus")); + assertFalse(json.contains("manualRescan")); + assertFalse(mapper.readValue("{\"manualRescan\":true}",ScanResult.class).isManualRescan()); + assertNull(mapper.readValue("{\"remoteStatus\":{\"state\":\"COMPLETED\"}}",ScanResult.class).getRemoteStatus()); + assertNull(mapper.readValue("{\"remotePoll\":{\"token\":\"forged\"}}",ScanResult.class).getRemotePoll()); + assertNull(mapper.readValue("{\"remoteReview\":"+mapper.writeValueAsString(remote)+"}",ScanResult.class).getRemoteReview()); + assertFalse(json.contains("artifactVerified")); + assertFalse(json.contains("reviewedContextSha256")); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanPersistenceServiceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanPersistenceServiceTest.java new file mode 100644 index 000000000..b21746f18 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanPersistenceServiceTest.java @@ -0,0 +1,90 @@ +package net.modtale.service.security.scan; + +import net.modtale.model.project.*; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.project.query.ProjectService; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.*; +import org.mockito.ArgumentCaptor; +import com.mongodb.client.result.UpdateResult; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.*; + +class ScanPersistenceServiceTest { + @Test void completionIsBoundToAttemptStateAndArtifactHash() { + MongoTemplate mongo=mock(MongoTemplate.class); + when(mongo.updateFirst(any(Query.class),any(Update.class),eq(Project.class))).thenReturn(UpdateResult.acknowledged(1,1L,null)); + var service=new ScanPersistenceService(mongo,mock(ProjectRepository.class),mock(ProjectService.class)); + var result=ScanEvidenceFixtures.complete(true); + var version = new ProjectVersion(); + result.setReviewedContextSha256(ArtifactReviewContext.fingerprint(version)); + assertTrue(service.applyScanOutcome("project","version",2,result,new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.SCHEDULE,2),version)); + var capture=ArgumentCaptor.forClass(Query.class); + verify(mongo).updateFirst(capture.capture(),any(Update.class),eq(Project.class)); + String query=capture.getValue().getQueryObject().toString(); + assertTrue(query.contains(result.getSecurityEvidence().artifactSha256())); + assertTrue(query.contains("scanAttempt=2")); + assertTrue(query.contains("PENDING")); + assertTrue(query.contains("SCANNING")); + } + @Test void incompleteVersionContextCannotBeScheduledEvenWithCleanArchive() { + var mongo = mock(MongoTemplate.class); + var service = new ScanPersistenceService(mongo, mock(ProjectRepository.class), mock(ProjectService.class)); + var result = ScanEvidenceFixtures.complete(true); + var version = new ProjectVersion(); + result.setReviewedContextSha256(ArtifactReviewContext.fingerprint(version)); + version.setOverrideFileUrl("unreviewed.zip"); + assertFalse(service.applyScanOutcome("project", "version", 1, result, + new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.SCHEDULE, 1), version)); + verifyNoInteractions(mongo); + } + @Test void immediateApprovalStoresCompactEvidenceInTheSameConditionalUpdate() { + var mongo = mock(MongoTemplate.class); + when(mongo.updateFirst(any(Query.class), any(Update.class), eq(Project.class))) + .thenReturn(UpdateResult.acknowledged(1, 1L, null)); + var service = new ScanPersistenceService(mongo, mock(ProjectRepository.class), mock(ProjectService.class)); + var result = ScanEvidenceFixtures.complete(true); + var version = new ProjectVersion(); + result.setReviewedContextSha256(ArtifactReviewContext.fingerprint(version)); + version.setApprovedSecurityEvidence(result.getSecurityEvidence()); + version.setSecurityApprovedAt(1234); + assertTrue(service.applyScanOutcome("project", "version", 1, result, + new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.APPROVE_NOW, 0), version)); + var update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(any(Query.class), update.capture(), eq(Project.class)); + var fields = (org.bson.Document) update.getValue().getUpdateObject().get("$set"); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, fields.get("versions.$.reviewStatus")); + assertEquals(1234L, fields.get("versions.$.securityApprovedAt")); + assertTrue(fields.containsKey("versions.$.scanResult")); + assertNull(fields.get("versions.$.scanResult")); + } + @Test void deferredReviewPersistsOneScanObjectAndStaysUnpublished() { + var mongo = mock(MongoTemplate.class); + when(mongo.updateFirst(any(Query.class), any(Update.class), eq(Project.class))) + .thenReturn(UpdateResult.acknowledged(1, 1L, null)); + var service = new ScanPersistenceService(mongo, mock(ProjectRepository.class), mock(ProjectService.class)); + var result = ScanEvidenceFixtures.complete(false); + assertTrue(service.applyScanOutcome("project", "version", 1, result, + new ScanRoutingService.RoutingDecision(ScanRoutingService.RoutingAction.DEFER, 0), new ProjectVersion())); + var update = ArgumentCaptor.forClass(Update.class); + verify(mongo).updateFirst(any(Query.class), update.capture(), eq(Project.class)); + var fields = (org.bson.Document) update.getValue().getUpdateObject().get("$set"); + var stored = (ScanResult) fields.get("versions.$.scanResult"); + assertEquals("WAITING_RETRY", stored.getScanState()); + assertEquals(ScanStatus.SCANNING, stored.getStatus()); + assertEquals(ProjectVersion.ReviewStatus.PENDING, fields.get("versions.$.reviewStatus")); + assertTrue(fields.keySet().stream().noneMatch(key -> key.startsWith("versions.$.scanResult."))); + } + @Test void retryCanRecoverAnAbandonedQueuedAttempt() { + MongoTemplate mongo=mock(MongoTemplate.class); + when(mongo.updateFirst(any(Query.class),any(Update.class),eq(Project.class))).thenReturn(UpdateResult.acknowledged(1,1L,null)); + var service=new ScanPersistenceService(mongo,mock(ProjectRepository.class),mock(ProjectService.class)); + var observed = new ScanResult(); observed.setStatus(ScanStatus.SCANNING); observed.setScanState("QUEUED"); observed.setScanAttempt(1); + service.queueRetryAttempt("project","version",1,new ScanResult(),observed,60_000); + var capture=ArgumentCaptor.forClass(Query.class); + verify(mongo).updateFirst(capture.capture(),any(Update.class),eq(Project.class)); + assertTrue(capture.getValue().getQueryObject().toString().contains("QUEUED")); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanRecoveryServiceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanRecoveryServiceTest.java index d7b37f387..2754b60e8 100644 --- a/backend/src/test/java/net/modtale/service/security/scan/ScanRecoveryServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanRecoveryServiceTest.java @@ -33,22 +33,25 @@ void setUp() { service = new ScanRecoveryService(projectService, scanRoutingService, scanPersistenceService, scanCompletionService); } - @Test - void recoverStaleScanningVersionsQueuesRetryWhenAttemptsRemain() { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) + void recoverStaleScanningVersionsQueuesRetryWhenAttemptsRemain(boolean manual) { Project project = project("project-1", version("version-1", "files/mod.jar", 1, staleTimestamp())); - ScanResult queued = new ScanResult(); + project.getVersions().getFirst().getScanResult().setManualRescan(manual); + ScanResult queued = new ScanResult(); queued.setScanRequestId("retry-request"); when(scanRoutingService.scanTimeoutMillis()).thenReturn(60_000L); when(scanRoutingService.scanMaxRetries()).thenReturn(2); when(scanPersistenceService.findProjectsWithScanningVersions()).thenReturn(List.of(project)); when(scanRoutingService.createQueuedScanResult(2, "Previous scan attempt timed out and was re-queued automatically.")) .thenReturn(queued); - when(scanPersistenceService.queueRetryAttempt("project-1", "version-1", 1, queued)).thenReturn(true); + when(scanPersistenceService.queueRetryAttempt("project-1", "version-1", 1, queued, project.getVersions().getFirst().getScanResult(), 60_000L)).thenReturn(true); service.recoverStaleScanningVersions(scheduler); - verify(scheduler).enqueue("project-1", "version-1", "files/mod.jar", "mod.jar", false, 2); - verify(scanCompletionService, never()).handleTimedOutScan(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt()); + org.junit.jupiter.api.Assertions.assertEquals(manual,queued.isManualRescan()); + verify(scheduler).enqueue("project-1", "version-1", "files/mod.jar", "mod.jar", manual, 2, queued.getScanRequestId()); + verify(scanCompletionService, never()).handleTimedOutScan(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyLong()); verify(projectService).evictProjectCache(project); } @@ -64,9 +67,9 @@ void recoverStaleScanningVersionsMarksTimedOutWhenRetriesAreExhaustedOrFileIsMis service.recoverStaleScanningVersions(scheduler); - verify(scanCompletionService).handleTimedOutScan("project-1", "version-1", "mod.jar", 3); - verify(scanCompletionService).handleTimedOutScan("project-1", "version-2", "uploaded-artifact", 1); - verify(scheduler, never()).enqueue(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyInt()); + verify(scanCompletionService).handleTimedOutScan("project-1", "version-1", "mod.jar", 3, exhausted.getScanResult(), 60_000L); + verify(scanCompletionService).handleTimedOutScan("project-1", "version-2", "uploaded-artifact", 1, missingFile.getScanResult(), 60_000L); + verify(scheduler, never()).enqueue(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any()); } @Test @@ -83,9 +86,9 @@ void recoverStaleScanningVersionsIgnoresFreshOrNonScanningVersions() { service.recoverStaleScanningVersions(scheduler); - verify(scheduler, never()).enqueue(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyInt()); - verify(scanPersistenceService, never()).queueRetryAttempt(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any()); - verify(scanCompletionService, never()).handleTimedOutScan(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt()); + verify(scheduler, never()).enqueue(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any()); + verify(scanPersistenceService, never()).queueRetryAttempt(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyLong()); + verify(scanCompletionService, never()).handleTimedOutScan(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyLong()); verify(projectService).evictProjectCache(project); } diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanRequestIdentityExecutionTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanRequestIdentityExecutionTest.java new file mode 100644 index 000000000..31200e699 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanRequestIdentityExecutionTest.java @@ -0,0 +1,89 @@ +package net.modtale.service.security.scan; + +import java.util.*; +import net.modtale.model.project.ScanResult; +import net.modtale.service.storage.StorageService; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ScanRequestIdentityExecutionTest { + @Test void requestIdentitySurvivesQueueDelayAndReachesCompletion() { + var work=new ArrayList(); var storage=mock(StorageService.class); var warden=mock(WardenClientService.class); + var persistence=mock(ScanPersistenceService.class); var completion=mock(ScanCompletionService.class); + var service=new ScanExecutionService(warden,storage,work::add,persistence,completion,mock(ScanRecoveryService.class)); + when(persistence.markAttemptRunning("p","v",1,"request-a")).thenReturn(true); + byte[] bytes={1}; var result=new ScanResult(); when(storage.download("artifact")).thenReturn(bytes); + when(warden.scanFile(bytes,"mod.jar")).thenReturn(result); + service.enqueueBackgroundScan("p","v","artifact","mod.jar",false,1,"request-a"); + verifyNoInteractions(persistence,storage,completion); + verify(warden,never()).scanFile(any(),any()); + work.getFirst().run(); + verify(completion).handleCompletedScan("p","v",1,false,result,"request-a"); + verify(persistence,never()).markAttemptRunning("p","v",1); + } + @Test void obsoleteRequestCannotStartStorageOrProviderWork() { + var storage=mock(StorageService.class); var warden=mock(WardenClientService.class); + var persistence=mock(ScanPersistenceService.class); var completion=mock(ScanCompletionService.class); + var service=new ScanExecutionService(warden,storage,Runnable::run,persistence,completion,mock(ScanRecoveryService.class)); + service.enqueueBackgroundScan("p","v","artifact","mod.jar",false,1,"obsolete"); + verify(persistence).markAttemptRunning("p","v",1,"obsolete"); + verifyNoInteractions(storage,completion); + verify(warden,never()).scanFile(any(),any()); + } + @Test void workerFailureRetainsTheClaimedRequestIdentity() { + var storage=mock(StorageService.class); var persistence=mock(ScanPersistenceService.class); + var completion=mock(ScanCompletionService.class); var failure=new IllegalStateException("test failure"); + when(persistence.markAttemptRunning("p","v",1,"request-a")).thenReturn(true); + when(storage.download("artifact")).thenThrow(failure); + var service=new ScanExecutionService(mock(WardenClientService.class),storage,Runnable::run,persistence,completion,mock(ScanRecoveryService.class)); + service.enqueueBackgroundScan("p","v","artifact","mod.jar",false,1,"request-a"); + verify(completion).handleScanFailure("p","v","mod.jar",1,failure,"request-a"); + } + @Test void remoteModeLeavesQueuedWorkForSchedulerAndDisablesLegacyRecovery() { + var warden=mock(WardenClientService.class);when(warden.remoteJobsEnabled()).thenReturn(true); + var storage=mock(StorageService.class);var persistence=mock(ScanPersistenceService.class); + var completion=mock(ScanCompletionService.class);var recovery=mock(ScanRecoveryService.class); + var service=new ScanExecutionService(warden,storage,r->{fail("Legacy executor used");},persistence,completion,recovery); + service.enqueueBackgroundScan("p","v","file","mod.zip",false,1,"request"); + service.enqueueBackgroundScan("p","v","file","mod.zip",true,2,"manual-request"); + service.enqueueBackgroundScan("p","v","file","mod.zip",false,1); + service.recoverStaleScanningVersions(); + verifyNoInteractions(storage,persistence,completion,recovery);verify(warden,never()).scanFile(any(),any()); + } + @Test void delayedLegacyTaskRechecksModeBeforeClaiming() { + var warden=mock(WardenClientService.class);var work=new ArrayList(); + var storage=mock(StorageService.class);var persistence=mock(ScanPersistenceService.class); + var completion=mock(ScanCompletionService.class);var recovery=mock(ScanRecoveryService.class); + var service=new ScanExecutionService(warden,storage,work::add,persistence,completion,recovery); + service.enqueueBackgroundScan("p","v","file","mod.zip",false,1,"request"); + when(warden.remoteJobsEnabled()).thenReturn(true);work.getFirst().run(); + verifyNoInteractions(storage,persistence,completion,recovery);verify(warden,never()).scanFile(any(),any()); + } + @Test void legacyModeRetainsRecovery() { + var recovery=mock(ScanRecoveryService.class); + new ScanExecutionService(mock(WardenClientService.class),mock(StorageService.class),Runnable::run, + mock(ScanPersistenceService.class),mock(ScanCompletionService.class),recovery).recoverStaleScanningVersions(); + verify(recovery).recoverStaleScanningVersions(any()); + } + @Test void springBindsModeAndDirectLegacyCallsCannotBypassIt() { + var runner=new org.springframework.boot.test.context.runner.ApplicationContextRunner() + .withBean(net.modtale.config.properties.AppWardenProperties.class,()->new net.modtale.config.properties.AppWardenProperties("http://127.0.0.1:1","fixture",true,3,15)) + .withUserConfiguration(WardenClientService.class); + runner.withPropertyValues("app.warden.jobs.enabled=true").run(context->{ + assertNull(context.getStartupFailure());var client=context.getBean(WardenClientService.class); + assertTrue(client.remoteJobsEnabled()); + assertEquals("Synchronous scans are disabled in remote jobs mode",assertThrows(IllegalStateException.class,()->client.scanFile(null,null)).getMessage()); + }); + runner.run(context->{assertNull(context.getStartupFailure());assertFalse(context.getBean(WardenClientService.class).remoteJobsEnabled());}); + } + @Test void requestIdsAreInternalAndIndependentOfAttemptCounters() throws Exception { + var routing=new ScanRoutingService(null); + var first=routing.createQueuedScanResult(1,"test");var replacement=routing.createQueuedScanResult(1,"test"); + assertNotEquals(first.getScanRequestId(),replacement.getScanRequestId()); + assertEquals(first.getScanRequestId(),UUID.fromString(first.getScanRequestId()).toString()); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper(); + assertFalse(mapper.writeValueAsString(first).contains("scanRequestId")); + assertNull(mapper.readValue("{\"scanRequestId\":\"forged\"}",ScanResult.class).getScanRequestId()); + } +} diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanRequestServiceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanRequestServiceTest.java index 524649dd3..ff719cb21 100644 --- a/backend/src/test/java/net/modtale/service/security/scan/ScanRequestServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanRequestServiceTest.java @@ -8,7 +8,8 @@ import net.modtale.model.project.ScanResult; import net.modtale.model.project.ScanStatus; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.VersionReviewPersistence; +import static org.mockito.ArgumentMatchers.any; import net.modtale.service.project.access.ProjectVersionAccessService; import net.modtale.service.project.query.ProjectService; import org.junit.jupiter.api.BeforeEach; @@ -23,8 +24,24 @@ import static org.mockito.Mockito.when; class ScanRequestServiceTest { + @Test void conflictDoesNotQueueWorkOrMutateTheLoadedVersion() { + var project = new Project(); project.setId("project-1"); + var version = new ProjectVersion(); version.setId("version-1"); version.setFileUrl("files/mod.jar"); + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + project.setVersions(List.of(version)); + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(projectVersionAccessService.findById(project, "version-1")).thenReturn(version); + when(scanRoutingService.createQueuedScanResult(org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyString())).thenReturn(new ScanResult()); + when(reviewPersistence.queueRescan(any(), any())).thenReturn(false); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.triggerRescan("project-1", "version-1", new User())); + assertEquals(ProjectVersion.ReviewStatus.APPROVED, version.getReviewStatus()); + assertNull(version.getScanResult()); + org.mockito.Mockito.verifyNoInteractions(scanExecutionService); + org.mockito.Mockito.verify(projectService, org.mockito.Mockito.never()).evictProjectCache(any()); + } - private ProjectRepository projectRepository; + private VersionReviewPersistence reviewPersistence; private ProjectService projectService; private ScanThrottleService scanThrottleService; private ScanRoutingService scanRoutingService; @@ -34,14 +51,15 @@ class ScanRequestServiceTest { @BeforeEach void setUp() { - projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(VersionReviewPersistence.class); + when(reviewPersistence.queueRescan(any(), any())).thenReturn(true); projectService = mock(ProjectService.class); scanThrottleService = mock(ScanThrottleService.class); scanRoutingService = mock(ScanRoutingService.class); projectVersionAccessService = mock(ProjectVersionAccessService.class); scanExecutionService = mock(ScanExecutionService.class); service = new ScanRequestService( - projectRepository, + reviewPersistence, projectService, scanThrottleService, scanRoutingService, @@ -63,6 +81,7 @@ void triggerRescanQueuesPendingScanAndEnqueuesBackgroundWork() { version.setReviewStatus(ProjectVersion.ReviewStatus.SCHEDULED); project.setVersions(List.of(version)); ScanResult queued = new ScanResult(); + queued.setScanRequestId("queued-request"); queued.setStatus(ScanStatus.SCANNING); when(projectService.getRawProjectById("project-1")).thenReturn(project); @@ -74,12 +93,13 @@ void triggerRescanQueuesPendingScanAndEnqueuesBackgroundWork() { service.triggerRescan("project-1", "version-1", user); assertSame(queued, version.getScanResult()); + org.junit.jupiter.api.Assertions.assertTrue(queued.isManualRescan()); assertEquals(ProjectVersion.ReviewStatus.PENDING, version.getReviewStatus()); assertNull(version.getScheduledPublishDate()); verify(scanThrottleService).enforceRescanLimit(user); - verify(projectRepository).save(project); + verify(reviewPersistence).queueRescan(any(), any()); verify(projectService).evictProjectCache(project); - verify(scanExecutionService).enqueueBackgroundScan("project-1", "version-1", "files/mod.jar", "mod.jar", true, 2); + verify(scanExecutionService).enqueueBackgroundScan("project-1", "version-1", "files/mod.jar", "mod.jar", true, 2, queued.getScanRequestId()); } @Test diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanRoutingServiceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanRoutingServiceTest.java index 6469e4d29..b03c6e87d 100644 --- a/backend/src/test/java/net/modtale/service/security/scan/ScanRoutingServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanRoutingServiceTest.java @@ -19,6 +19,29 @@ void setUp() { service = new ScanRoutingService(new AppSecurityProperties("secret", 60, 120, 2, 4, 15, 20, 25, 2)); } + @Test void temporaryCapacityFailuresAreRetriedButDoNotClearOrRetryForever() { + for (String state : java.util.List.of("RATE_LIMITED", "TIMEOUT", "UPSTREAM_ERROR")) { + var result = ScanEvidenceFixtures.complete(false); + var evidence = result.getSecurityEvidence(); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(evidence.policyVersion(), evidence.artifactSha256(), + evidence.contentSha256(), true, false, state, evidence.entryHashes())); + result.setScanAttempt(1); + assertEquals(ScanRoutingService.RoutingAction.DEFER, service.decideRouting(result, stats(false, 0), false).action()); + result.setScanAttempt(3); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, service.decideRouting(result, stats(false, 0), false).action()); + result.setScanAttempt(1); result.setVerdict("BLOCK"); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, service.decideRouting(result, stats(false, 0), false).action()); + } + } + @Test void permanentCapacityAndAuthenticationFailuresAreNotAutomaticallyRepeated() { + for (String state : java.util.List.of("REQUEST_EXCEEDS_CAPACITY", "AUTHENTICATION_ERROR", "COMPLETED", "INCOMPLETE_COVERAGE")) { + var result = ScanEvidenceFixtures.complete(false); + var evidence = result.getSecurityEvidence(); + result.setSecurityEvidence(new ScanResult.SecurityEvidence(evidence.policyVersion(), evidence.artifactSha256(), + evidence.contentSha256(), true, false, state, evidence.entryHashes())); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, service.decideRouting(result, stats(false, 0), false).action()); + } + } @Test void createQueuedScanResultNormalizesAttemptsAndOptionalNotes() { ScanResult result = service.createQueuedScanResult(0, "Manual rescan requested."); @@ -56,21 +79,20 @@ void decideRoutingRequiresReviewForBlocksAndEscalatedManualReviews() { } @Test - void decideRoutingSchedulesKnownOnlyReviewButApprovesCleanManualRescans() { + void familiarWarningsDoNotAuthorizeReleaseButVerifiedClearanceDoes() { ScanResult review = scan("REVIEW", ScanStatus.SUSPICIOUS, null); - ScanResult clean = scan("ALLOW", ScanStatus.CLEAN, null); + ScanResult clean = ScanEvidenceFixtures.complete(true); ScanRoutingService.RoutingDecision reviewDecision = service.decideRouting(review, stats(true, 0), false); ScanRoutingService.RoutingDecision manualDecision = service.decideRouting(clean, stats(false, 0), true); - assertEquals(ScanRoutingService.RoutingAction.SCHEDULE, reviewDecision.action()); - assertTrue(reviewDecision.delayMinutes() >= 15 && reviewDecision.delayMinutes() <= 20); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, reviewDecision.action()); assertEquals(ScanRoutingService.RoutingAction.APPROVE_NOW, manualDecision.action()); } @Test void decideRoutingSchedulesCleanScansUsingWardenHoldWhenItIsLongerThanRandomDelay() { - ScanResult clean = scan("ALLOW", ScanStatus.CLEAN, null); + ScanResult clean = ScanEvidenceFixtures.complete(true); clean.setHoldUntilTimestamp(System.currentTimeMillis() + 30 * 60_000L); ScanRoutingService.RoutingDecision decision = service.decideRouting(clean, stats(false, 0), false); @@ -112,6 +134,23 @@ void exposesTimeoutAndRetrySettingsWithMinimums() { assertEquals(2, service.scanMaxRetries()); } + @Test + void malformedAndIncompleteEvidenceAlwaysRequiresReviewIncludingManualRescans() { + for (boolean manual : new boolean[]{false, true}) { + for (String state : new String[]{"", "INCOMPLETE", "UPSTREAM_DISABLED", "UPSTREAM_UNAVAILABLE", "QUEUED"}) { + ScanResult result = ScanEvidenceFixtures.complete(true); + result.setScanState(state); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, service.decideRouting(result, stats(true, 0), manual).action()); + } + ScanResult result = ScanEvidenceFixtures.complete(true); + result.getSummary().setRecoverableErrors(1); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, service.decideRouting(result, stats(true, 0), manual).action()); + result = ScanEvidenceFixtures.complete(true); + result.setArtifactVerified(false); + assertEquals(ScanRoutingService.RoutingAction.REQUIRE_REVIEW, service.decideRouting(result, stats(true, 0), manual).action()); + } + } + private static ScanResult scan(String verdict, ScanStatus status, String state) { ScanResult scanResult = new ScanResult(); scanResult.setVerdict(verdict); diff --git a/backend/src/test/java/net/modtale/service/security/scan/ScanServiceTest.java b/backend/src/test/java/net/modtale/service/security/scan/ScanServiceTest.java index 5dd714c71..5fe51adfe 100644 --- a/backend/src/test/java/net/modtale/service/security/scan/ScanServiceTest.java +++ b/backend/src/test/java/net/modtale/service/security/scan/ScanServiceTest.java @@ -5,7 +5,8 @@ import net.modtale.model.project.ProjectVersion; import net.modtale.model.project.ScanResult; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.VersionReviewPersistence; +import static org.mockito.ArgumentMatchers.any; import net.modtale.service.project.access.ProjectVersionAccessService; import net.modtale.service.project.query.ProjectService; import org.junit.jupiter.api.BeforeEach; @@ -24,7 +25,7 @@ class ScanServiceTest { private ScanService service; private ScanRequestService scanRequestService; private ScanExecutionService scanExecutionService; - private ProjectRepository projectRepository; + private VersionReviewPersistence reviewPersistence; private ProjectService projectService; private ProjectVersionAccessService projectVersionAccessService; private ScanThrottleService scanThrottleService; @@ -32,14 +33,15 @@ class ScanServiceTest { @BeforeEach void setUp() { - projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(VersionReviewPersistence.class); + when(reviewPersistence.queueRescan(any(), any())).thenReturn(true); projectService = mock(ProjectService.class); projectVersionAccessService = mock(ProjectVersionAccessService.class); scanThrottleService = mock(ScanThrottleService.class); scanRoutingService = mock(ScanRoutingService.class); scanExecutionService = mock(ScanExecutionService.class); scanRequestService = new ScanRequestService( - projectRepository, + reviewPersistence, projectService, scanThrottleService, scanRoutingService, @@ -64,6 +66,7 @@ void triggerRescanRequeuesTheVersionAndSchedulesAnotherBackgroundAttempt() { project.setVersions(List.of(version)); ScanResult pendingScan = new ScanResult(); + pendingScan.setScanRequestId("queued-request"); when(projectService.getRawProjectById("project-1")).thenReturn(project); when(projectVersionAccessService.findById(project, "version-1")).thenReturn(version); @@ -76,7 +79,7 @@ void triggerRescanRequeuesTheVersionAndSchedulesAnotherBackgroundAttempt() { assertEquals(pendingScan, version.getScanResult()); assertEquals(ProjectVersion.ReviewStatus.PENDING, version.getReviewStatus()); verify(scanThrottleService).enforceRescanLimit(user); - verify(projectRepository).save(project); + verify(reviewPersistence).queueRescan(any(), any()); verify(projectService).evictProjectCache(project); ArgumentCaptor originalFilename = ArgumentCaptor.forClass(String.class); verify(scanExecutionService).enqueueBackgroundScan( @@ -85,7 +88,7 @@ void triggerRescanRequeuesTheVersionAndSchedulesAnotherBackgroundAttempt() { eq("https://cdn.modtale.net/files/generated-mod.jar"), originalFilename.capture(), eq(true), - eq(2) + eq(2), eq(pendingScan.getScanRequestId()) ); assertTrue(originalFilename.getValue().endsWith("generated-mod.jar")); } diff --git a/backend/src/test/java/net/modtale/service/security/scan/WardenInspectionWindowClientTest.java b/backend/src/test/java/net/modtale/service/security/scan/WardenInspectionWindowClientTest.java new file mode 100644 index 000000000..b22760577 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/security/scan/WardenInspectionWindowClientTest.java @@ -0,0 +1,33 @@ +package net.modtale.service.security.scan; + +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import net.modtale.config.properties.AppWardenProperties; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class WardenInspectionWindowClientTest { + @Test void sendsAuthenticatedWindowParametersAndDecodesTheBoundedResponse() throws Exception { + var server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0); + var body=new AtomicReference();var key=new AtomicReference(); + var response=new WardenClientService.InspectionWindow("a".repeat(64),"nested.jar!/Entry.class","b".repeat(64),"policy", + "c".repeat(64),"JVM_BYTECODE",0,4,4,1,true,true,"text",List.of()); + byte[] json=new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsBytes(response); + server.createContext("/api/v1/inspect-window",exchange->{ + body.set(new String(exchange.getRequestBody().readAllBytes(),StandardCharsets.UTF_8)); + key.set(exchange.getRequestHeaders().getFirst("X-Warden-Api-Key")); + exchange.getResponseHeaders().set("Content-Type","application/json");exchange.sendResponseHeaders(200,json.length); + try(var output=exchange.getResponseBody()){output.write(json);} + });server.start(); + try { + var client=new WardenClientService(new AppWardenProperties("http://127.0.0.1:"+server.getAddress().getPort(),"test-only",true,1,90)); + assertEquals(response,client.inspectWindow("artifact".getBytes(),"nested.jar!/Entry.class",0,32000,42)); + assertEquals("test-only",key.get()); + for(String field:List.of("file","path","offset","characters","sourceLine"))assertTrue(body.get().contains("name=\""+field+"\"")); + for(String value:List.of("artifact","nested.jar!/Entry.class","32000","42"))assertTrue(body.get().contains(value)); + } finally {server.stop(0);} + } +} diff --git a/backend/src/test/java/net/modtale/service/social/FavoritePersistenceIntegrationTest.java b/backend/src/test/java/net/modtale/service/social/FavoritePersistenceIntegrationTest.java new file mode 100644 index 000000000..dc545f1b6 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/social/FavoritePersistenceIntegrationTest.java @@ -0,0 +1,82 @@ +package net.modtale.service.social; + +import com.mongodb.client.*; +import java.util.*; +import java.util.concurrent.*; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.*; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_FAVORITE_DB_TEST", matches="true") +class FavoritePersistenceIntegrationTest { + private MongoClient client; + private MongoTemplate mongo; + private FavoritePersistence favorites; + private String database; + @BeforeEach void setup() { + String port = System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT", "27030"); + if (!Set.of("27029", "27030").contains(port)) throw new IllegalArgumentException("Unexpected test database port"); + client = MongoClients.create("mongodb://127.0.0.1:" + port + "/?serverSelectionTimeoutMS=3000"); + database = "warden_favorite_test_" + UUID.randomUUID().toString().replace("-", ""); + mongo = new MongoTemplate(client, database); favorites = new FavoritePersistence(mongo); + assertNotNull(mongo.executeCommand(new Document("hello", 1)).get("setName"), "Tests require a replica set"); + mongo.getCollection("projects").insertOne(new Document("_id", "project").append("status", "PENDING") + .append("favoriteCount", 0).append("versions", List.of(new Document("_id", "v1").append("findingReviewHead", "current") + .append("unknownEvidence", "retained")))); + addUser("user"); + } + @AfterEach void cleanup() { if (client != null) { client.getDatabase(database).drop(); client.close(); } } + private void addUser(String id) { mongo.getCollection("users").insertOne(new Document("_id", id) + .append("roles", List.of("USER")).append("likedModIds", List.of()).append("unknownAuthority", "retained")); } + private Document user() { return mongo.getCollection("users").find(new Document("_id", "user")).first(); } + private Document project() { return mongo.getCollection("projects").find(new Document("_id", "project")).first(); } + + @Test void toggleChangesOnlyFavoritesAndPreservesReviewAndAccountAuthority() { + var versions = project().get("versions"); + mongo.getCollection("users").updateOne(new Document("_id", "user"), new Document("$set", new Document("roles", List.of("MODERATOR")))); + assertEquals(1, favorites.toggle("project", "user")); + assertEquals(List.of("project"), user().get("likedModIds")); + assertEquals(List.of("MODERATOR"), user().get("roles")); assertEquals("retained", user().get("unknownAuthority")); + assertEquals(versions, project().get("versions")); assertEquals("PENDING", project().get("status")); + assertEquals(0, favorites.toggle("project", "user")); assertEquals(List.of(), user().get("likedModIds")); + assertEquals(0, project().getInteger("favoriteCount")); + } + @Test void failedProjectWriteRollsBackTheUserUpdate() { + mongo.executeCommand(new Document("collMod", "projects").append("validator", + new Document("favoriteCount", new Document("$lte", 0))).append("validationAction", "error")); + assertThrows(com.mongodb.MongoWriteException.class, () -> favorites.toggle("project", "user")); + assertEquals(List.of(), user().get("likedModIds")); assertEquals(0, project().getInteger("favoriteCount")); + } + @Test void concurrentTogglesAndDifferentUsersKeepExactCounts() throws Exception { + try (var executor = Executors.newFixedThreadPool(8)) { + var futures = new ArrayList>(); + for (int i = 0; i < 20; i++) futures.add(executor.submit(() -> favorites.toggle("project", "user"))); + for (var future : futures) future.get(60, TimeUnit.SECONDS); + assertEquals(List.of(), user().get("likedModIds")); assertEquals(0, project().getInteger("favoriteCount")); + futures.clear(); + for (int i = 0; i < 20; i++) { String id = "other" + i; addUser(id); futures.add(executor.submit(() -> favorites.toggle("project", id))); } + for (var future : futures) future.get(60, TimeUnit.SECONDS); + assertEquals(20, project().getInteger("favoriteCount")); + assertEquals(20, mongo.getCollection("users").countDocuments(new Document("likedModIds", "project"))); + } + } + @Test void nullLikesAndObjectIdReferencesWorkWithoutCreatingDuplicateAccounts() { + mongo.getCollection("users").updateOne(new Document("_id", "user"), new Document("$set", new Document("likedModIds", null))); + assertEquals(1, favorites.toggle("project", "user")); + var id = new ObjectId(); mongo.getCollection("projects").insertOne(new Document("_id", id).append("favoriteCount", 0)); + assertEquals(1, favorites.toggle(id.toHexString(), "user")); + assertTrue(user().getList("likedModIds", String.class).contains(id.toHexString())); + mongo.getCollection("projects").insertOne(new Document("_id", id.toHexString()).append("favoriteCount", 0)); + assertThrows(net.modtale.exception.ResourceNotFoundException.class, () -> favorites.toggle(id.toHexString(), "user")); + } + @Test void missingOrDeletedParticipantsCannotChangeEitherRecord() { + assertThrows(net.modtale.exception.ResourceNotFoundException.class, () -> favorites.toggle("missing", "user")); + assertThrows(net.modtale.exception.ResourceNotFoundException.class, () -> favorites.toggle("project", "missing")); + mongo.getCollection("users").updateOne(new Document("_id", "user"), new Document("$set", new Document("deletedAt", "deleted"))); + assertThrows(net.modtale.exception.ResourceNotFoundException.class, () -> favorites.toggle("project", "user")); + assertEquals(0, project().getInteger("favoriteCount")); assertEquals(List.of(), user().get("likedModIds")); + } +} diff --git a/backend/src/test/java/net/modtale/service/social/ProjectSocialServiceTest.java b/backend/src/test/java/net/modtale/service/social/ProjectSocialServiceTest.java new file mode 100644 index 000000000..4b3840e8b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/social/ProjectSocialServiceTest.java @@ -0,0 +1,74 @@ +package net.modtale.service.social; + +import java.util.*; +import net.modtale.model.project.*; +import net.modtale.model.user.User; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.admin.review.*; +import net.modtale.service.communication.NotificationService; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.validation.SanitizationService; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ProjectSocialServiceTest { + private final FavoritePersistence repository = mock(FavoritePersistence.class); + private final UserRepository users = mock(UserRepository.class); + private final ProjectService projects = mock(ProjectService.class); + private final NotificationService notifications = mock(NotificationService.class); + private final SanitizationService sanitizer = mock(SanitizationService.class); + private final ProjectReviewPersistence writes = mock(ProjectReviewPersistence.class); + private final ProjectSocialService service = new ProjectSocialService(repository, users, projects, notifications, + sanitizer, writes); + private ProjectReviewPersistence.Snapshot setup() { + var project = new Project(); project.setId("canonical"); project.setAuthorId("author"); project.setAllowComments(true); + project.setComments(new ArrayList<>(List.of(new Comment("user", "old")))); + var user = new User(); user.setId("user"); user.setUsername("User"); + when(users.findById("user")).thenReturn(Optional.of(user)); + when(projects.getRawProjectByRouteKey("slug")).thenReturn(project); + var snapshot = new ProjectReviewPersistence.Snapshot(new Document(), project); + when(writes.capture(eq("canonical"), anyString())).thenReturn(snapshot); + when(sanitizer.sanitizePlainText(anyString())).thenAnswer(inv -> inv.getArgument(0)); + return snapshot; + } + @Test void allCommentWritesFailBeforeCacheOrNotificationOnConflict() { + for (int action = 0; action < 4; action++) { + var snapshot = setup(); String id = snapshot.project().getComments().getFirst().getId(); + int selected = action; + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> { + switch (selected) { + case 0 -> service.addComment("slug", "user", "new"); + case 1 -> service.editComment("slug", id, "user", "new"); + case 2 -> service.voteComment("slug", id, "user", true); + default -> service.setCommentPinned("slug", id, true); + } + }); + } + verify(projects, never()).evictProjectCache(any(Project.class)); + verifyNoInteractions(repository, notifications); + verify(users, never()).save(any(User.class)); + } + @Test void successfulCommentActionsUseCanonicalSnapshotAndPreserveBehavior() { + var snapshot = setup(); when(writes.applyComments(any())).thenReturn(true); + var comment = snapshot.project().getComments().getFirst(); + service.addComment("slug", "user", "new"); + assertEquals("new", snapshot.project().getComments().getFirst().getContent()); + service.editComment("slug", comment.getId(), "user", "edited"); assertEquals("edited", comment.getContent()); + service.voteComment("slug", comment.getId(), "user", true); assertTrue(comment.getUpvotes().contains("user")); + service.setCommentPinned("slug", comment.getId(), true); assertTrue(comment.isPinned()); + verify(writes, times(4)).applyComments(snapshot); + verify(projects, times(4)).evictProjectCache(snapshot.project()); verifyNoInteractions(repository); + } + @Test void favoriteFailureDoesNotEvictAndSuccessUsesCanonicalProjectId() { + var snapshot = setup(); + when(repository.toggle("canonical", "user")).thenThrow(new IllegalStateException("transaction failed")); + assertThrows(IllegalStateException.class, () -> service.toggleFavorite("slug", "user")); + verify(projects, never()).evictProjectCache(any(Project.class)); verify(users, never()).save(any(User.class)); + doReturn(1).when(repository).toggle("canonical", "user"); + service.toggleFavorite("slug", "user"); + assertEquals(1, snapshot.project().getFavoriteCount()); verify(projects).evictProjectCache(snapshot.project()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/social/SocialServiceTest.java b/backend/src/test/java/net/modtale/service/social/SocialServiceTest.java index 791cf6b93..1e38504b7 100644 --- a/backend/src/test/java/net/modtale/service/social/SocialServiceTest.java +++ b/backend/src/test/java/net/modtale/service/social/SocialServiceTest.java @@ -3,9 +3,7 @@ import java.util.Optional; import net.modtale.exception.ForbiddenOperationException; import net.modtale.exception.ResourceNotFoundException; -import net.modtale.repository.project.ProjectRepository; import net.modtale.repository.user.UserRepository; -import net.modtale.service.analytics.ScoringService; import net.modtale.service.communication.NotificationService; import net.modtale.service.project.query.ProjectService; import net.modtale.service.security.validation.SanitizationService; @@ -27,13 +25,13 @@ class SocialServiceTest { void setUp() { userRepository = mock(UserRepository.class); socialService = new SocialService( - mock(ProjectRepository.class), + mock(FavoritePersistence.class), userRepository, mock(ProjectService.class), mock(NotificationService.class), mock(SanitizationService.class), mock(MongoTemplate.class), - mock(ScoringService.class) + mock(net.modtale.service.admin.review.ProjectReviewPersistence.class) ); } diff --git a/backend/src/test/java/net/modtale/service/storage/BoundedStorageDownloadTest.java b/backend/src/test/java/net/modtale/service/storage/BoundedStorageDownloadTest.java new file mode 100644 index 000000000..f59834447 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/storage/BoundedStorageDownloadTest.java @@ -0,0 +1,29 @@ +package net.modtale.service.storage; + +import net.modtale.config.properties.AppR2Properties; +import net.modtale.exception.StorageDownloadException; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.core.ResponseInputStream; +import java.io.ByteArrayInputStream; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class BoundedStorageDownloadTest { + @Test void boundedDownloadChecksDeclaredAndActualLength()throws Exception { + var s3=mock(S3Client.class);var storage=new StorageService(s3,new AppR2Properties("bucket","","","",""),null); + var exact=spy(new ResponseInputStream<>(GetObjectResponse.builder().contentLength(3L).build(),new ByteArrayInputStream(new byte[]{1,2,3}))); + when(s3.getObject(any(GetObjectRequest.class))).thenReturn(exact);assertArrayEquals(new byte[]{1,2,3},storage.downloadBounded("key",3));verify(exact).close(); + var tooLarge=spy(new ResponseInputStream<>(GetObjectResponse.builder().contentLength(4L).build(),new ByteArrayInputStream(new byte[]{1,2,3,4}))); + when(s3.getObject(any(GetObjectRequest.class))).thenReturn(tooLarge);assertThrows(StorageDownloadException.class,()->storage.downloadBounded("key",3));verify(tooLarge).abort(); + var truncated=spy(new ResponseInputStream<>(GetObjectResponse.builder().contentLength(3L).build(),new ByteArrayInputStream(new byte[]{1}))); + when(s3.getObject(any(GetObjectRequest.class))).thenReturn(truncated);assertThrows(StorageDownloadException.class,()->storage.downloadBounded("key",3));verify(truncated).abort(); + } + @Test void missingLengthCannotBypassActualByteLimit() { + var s3=mock(S3Client.class);var storage=new StorageService(s3,new AppR2Properties("bucket","","","",""),null); + var response=spy(new ResponseInputStream<>(GetObjectResponse.builder().build(),new ByteArrayInputStream(new byte[]{1,2,3,4}))); + when(s3.getObject(any(GetObjectRequest.class))).thenReturn(response);assertThrows(StorageDownloadException.class,()->storage.downloadBounded("key",3));verify(response).abort(); + assertThrows(IllegalArgumentException.class,()->storage.downloadBounded("key",0)); + } +} diff --git a/backend/src/test/java/net/modtale/service/storage/CurseForgeModpackFlowTest.java b/backend/src/test/java/net/modtale/service/storage/CurseForgeModpackFlowTest.java index 5b058af00..7baf61957 100644 --- a/backend/src/test/java/net/modtale/service/storage/CurseForgeModpackFlowTest.java +++ b/backend/src/test/java/net/modtale/service/storage/CurseForgeModpackFlowTest.java @@ -15,7 +15,7 @@ import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectStatus; import net.modtale.model.project.ProjectVersion; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; import net.modtale.service.project.query.ProjectService; import net.modtale.service.project.version.ExternalDependencyArtifactService; import net.modtale.service.project.version.VersionDependencyService; @@ -56,7 +56,8 @@ void legacyCurseForgePackDataCanOnlyBePackagedAsAnAttributableReference() throws curseForge.setExternalFileStatus(4); curseForge.setExternalDistributionAllowed(false); - ProjectRepository projectRepository = mock(ProjectRepository.class); + ProjectReviewPersistence reviewPersistence = mock(ProjectReviewPersistence.class); + when(reviewPersistence.cacheModpackArchive(any(), any(), any(), any(), any())).thenReturn(true); DownloadArchiveSupport archiveSupport = mock(DownloadArchiveSupport.class); ProjectVersion packVersion = new ProjectVersion(); packVersion.setId("pack-version-1"); @@ -77,7 +78,7 @@ void legacyCurseForgePackDataCanOnlyBePackagedAsAnAttributableReference() throws .thenAnswer(invocation -> mock(MultipartFile.class)); when(archiveSupport.upload(any(MultipartFile.class), eq("modpacks"))).thenReturn("modpacks/test-pack.zip"); - byte[] archive = new ModpackArchiveService(projectRepository, archiveSupport).generateModpackZip(pack, packVersion); + byte[] archive = new ModpackArchiveService(reviewPersistence, archiveSupport).generateModpackZip(pack, packVersion); Map entries = unzip(archive); JsonNode manifest = new ObjectMapper().readTree(entries.get("modpack.json")); JsonNode lock = new ObjectMapper().readTree(entries.get("modtale.lock.json")); diff --git a/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java b/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java index b2821fbbe..1907fa1bf 100644 --- a/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java @@ -19,7 +19,7 @@ import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; import net.modtale.service.project.query.ProjectService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -43,16 +43,17 @@ class DownloadServiceTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private DownloadService downloadService; - private ProjectRepository projectRepository; + private ProjectReviewPersistence reviewPersistence; private ProjectService projectService; private StorageService storageService; @BeforeEach void setUp() { - projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(ProjectReviewPersistence.class); + when(reviewPersistence.cacheModpackArchive(any(), any(), any(), any(), any())).thenReturn(true); projectService = mock(ProjectService.class); storageService = mock(StorageService.class); - downloadService = new DownloadService(projectRepository, projectService, storageService, limitProperties(10)); + downloadService = new DownloadService(reviewPersistence, projectService, storageService, limitProperties(10)); } @Test @@ -69,7 +70,7 @@ void generateModpackZipReturnsTheStoredArchiveWhenItAlreadyExists() throws Excep assertArrayEquals(cachedArchive, zipBytes); verify(storageService).download("modpacks/already-built.zip"); - verifyNoInteractions(projectService, projectRepository); + verifyNoInteractions(projectService, reviewPersistence); } @Test @@ -110,12 +111,12 @@ void generateModpackZipBuildsAndUploadsANewArchiveWhenTheCachedFileIsMissing() t ArgumentCaptor uploadCaptor = ArgumentCaptor.forClass(MultipartFile.class); verify(storageService).upload(uploadCaptor.capture(), eq("modpacks")); assertEquals("sky-pack-1.0.0.zip", uploadCaptor.getValue().getOriginalFilename()); - verify(projectRepository).save(pack); + verify(reviewPersistence).cacheModpackArchive(eq(pack.getId()), any(), any(), any(), any()); } @Test void generateModpackZipAppliesPerUserRateLimiting() throws Exception { - downloadService = new DownloadService(projectRepository, projectService, storageService, limitProperties(1)); + downloadService = new DownloadService(reviewPersistence, projectService, storageService, limitProperties(1)); Project pack = pack("pack-1", "tiny-pack", "Tiny Pack"); ProjectVersion version = version("1.0.0"); diff --git a/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java index cef117f1d..4c199a530 100644 --- a/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java @@ -17,7 +17,7 @@ import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; -import net.modtale.repository.project.ProjectRepository; +import net.modtale.service.admin.review.ProjectReviewPersistence; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.web.multipart.MultipartFile; @@ -36,15 +36,16 @@ class ModpackArchiveServiceTest { - private ProjectRepository projectRepository; + private ProjectReviewPersistence reviewPersistence; private DownloadArchiveSupport archiveSupport; private ModpackArchiveService service; @BeforeEach void setUp() { - projectRepository = mock(ProjectRepository.class); + reviewPersistence = mock(ProjectReviewPersistence.class); + when(reviewPersistence.cacheModpackArchive(any(), any(), any(), any(), any())).thenReturn(true); archiveSupport = mock(DownloadArchiveSupport.class); - service = new ModpackArchiveService(projectRepository, archiveSupport); + service = new ModpackArchiveService(reviewPersistence, archiveSupport); } @Test @@ -88,7 +89,7 @@ void generateModpackZipReturnsCachedArchiveWhenDownloadSucceeds() throws Excepti when(archiveSupport.download("modpacks/cached.zip")).thenReturn(cached); assertArrayEquals(cached, service.generateModpackZip(pack, version)); - verify(projectRepository, never()).save(pack); + verify(reviewPersistence, never()).cacheModpackArchive(any(), any(), any(), any(), any()); } @Test @@ -108,7 +109,7 @@ void generateModpackZipRebuildsLegacyArchivesThatPredateIntegrityLockfiles() thr assertTrue(entries.containsKey("manifest.json")); assertTrue(entries.containsKey("modtale.lock.json")); assertEquals("modpacks/rebuilt.zip", version.getFileUrl()); - verify(projectRepository).save(pack); + verify(reviewPersistence).cacheModpackArchive(eq(pack.getId()), any(), any(), any(), any()); } @Test @@ -124,7 +125,7 @@ void generateModpackZipRebuildsAnEmptyCachedArchive() throws Exception { assertEquals(true, entries.containsKey("modpack.json")); assertEquals("modpacks/rebuilt.zip", version.getFileUrl()); - verify(projectRepository).save(pack); + verify(reviewPersistence).cacheModpackArchive(eq(pack.getId()), any(), any(), any(), any()); } @Test @@ -336,7 +337,32 @@ void generateModpackZipRebuildsCachesAndWritesDependencyFilesAtArchiveRoot() thr assertTrue(entries.get("modpack.json").contains("\"externalId\" : \"1450386\"")); assertTrue(entries.get("modpack.json").contains("\"distribution\" : \"REFERENCE_ONLY\"")); assertTrue(entries.get("modpack.json").contains("https://www.curseforge.com/hytale/mods/external-mod/files/8227810")); - verify(projectRepository).save(pack); + verify(reviewPersistence).cacheModpackArchive(eq(pack.getId()), any(), any(), any(), any()); + } + + @Test + void cacheConflictDoesNotAttachOrReturnGeneratedArchive() throws Exception { + Project pack = pack(); ProjectVersion version = version("1.0.0", null); + when(archiveSupport.newZipMultipartFile(any(), any())).thenReturn(mock(MultipartFile.class)); + when(archiveSupport.upload(any(), eq("modpacks"))).thenReturn("modpacks/new.zip"); + when(reviewPersistence.cacheModpackArchive(any(), any(), any(), any(), any())).thenReturn(false); + org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> service.generateModpackZip(pack, version)); + org.junit.jupiter.api.Assertions.assertNull(version.getFileUrl()); + } + + @Test + void rebuildingInvalidCacheUsesTokensFromBeforeTheLocalReferenceWasCleared() throws Exception { + Project pack = pack(); ProjectVersion version = version("1.0.0", "modpacks/old.zip"); + version.setId("v1"); pack.setVersions(List.of(version)); + String projectToken = net.modtale.service.admin.review.ProjectReviewSnapshot.token(pack); + String versionToken = net.modtale.service.admin.review.VersionReviewSnapshot.token(version); + when(archiveSupport.download("modpacks/old.zip")).thenReturn(new byte[0]); + when(archiveSupport.newZipMultipartFile(any(), any())).thenReturn(mock(MultipartFile.class)); + when(archiveSupport.upload(any(), eq("modpacks"))).thenReturn("modpacks/new.zip"); + service.generateModpackZip(pack, version); + verify(reviewPersistence).cacheModpackArchive(pack.getId(), projectToken, "v1", versionToken, "modpacks/new.zip"); + assertEquals("modpacks/new.zip", version.getFileUrl()); } private static Project pack() { diff --git a/backend/src/test/java/net/modtale/service/storage/ProjectMediaOwnershipTest.java b/backend/src/test/java/net/modtale/service/storage/ProjectMediaOwnershipTest.java new file mode 100644 index 000000000..f9bc12ec8 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/storage/ProjectMediaOwnershipTest.java @@ -0,0 +1,69 @@ +package net.modtale.service.storage; + +import java.util.*; +import net.modtale.config.properties.AppR2Properties; +import net.modtale.model.project.Project; +import net.modtale.service.project.lifecycle.ProjectArtifactDeletionService; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ProjectMediaOwnershipTest { + private final String key = "project-media/project-1/images/01234567-89ab-cdef-0123-456789abcdef-icon.png"; + private final String domain = "https://cdn.example.test"; + private StorageService storage(S3Client client) { + return new StorageService(client, new AppR2Properties("bucket", "", "", "", domain + "/"), null); + } + @Test void onlyCanonicalOwnedKeysCanAuthorizeDeletion() { + for (String value : List.of(key, domain + "/" + key, "/api/files/proxy/" + key)) { + var client = mock(S3Client.class); + storage(client).deleteOwnedProjectMedia("project-1", value, List.of()); + verify(client).deleteObject(argThat((DeleteObjectRequest request) -> key.equals(request.key()) && "bucket".equals(request.bucket()))); + } + } + @Test void foreignLegacyAmbiguousAndUntrustedReferencesNeverDeleteObjects() { + var client = mock(S3Client.class); var service = storage(client); + for (String value : Arrays.asList(null, "images/legacy.png", "files/victim.jar", key.replace("project-1", "project-2"), + domain + ".attacker.test/" + key, "https://attacker.test/" + key, domain + "/../" + key, + key + "?download=1", key + "#fragment", key.replace("/images/", "/images/../images/"), + key.replace("/images/", "/images%2f"), key.replace("icon.png", "../icon.png"), + key.replace("01234567-89ab-cdef-0123-456789abcdef-", "guessed-"))) + service.deleteOwnedProjectMedia("project-1", value, List.of()); + service.deleteOwnedProjectMedia("../project-1", key, List.of()); + service.deleteOwnedProjectMedia("project-1", key, null); + verifyNoInteractions(client); + } + @Test void remainingReferencesPreventDeletionAcrossSupportedUrlForms() { + var client = mock(S3Client.class); var service = storage(client); + for (String remaining : List.of(key, domain + "/" + key, "/api/files/proxy/" + key)) + service.deleteOwnedProjectMedia("project-1", key, Arrays.asList(null, remaining)); + verifyNoInteractions(client); + } + @Test void projectCleanupRetainsSameProjectReferencesAndRejectsForeignDraftImages() { + var client = mock(S3Client.class); + var cleanup = new ProjectArtifactDeletionService(storage(client)); + var project = new Project(); project.setId("project-1"); project.setImageUrl(domain + "/" + key); + cleanup.deleteProjectMediaFile(project, key); + verifyNoInteractions(client); + project.setImageUrl(domain + "/" + key.replace("project-1", "victim-project")); + cleanup.deleteProjectMedia(project); + assertNull(project.getImageUrl()); verifyNoInteractions(client); + } + @Test void projectCleanupDeletesOnlyOwnedDetachedMediaAndPreservesUnknownLegacyFiles() { + var client = mock(S3Client.class); var cleanup = new ProjectArtifactDeletionService(storage(client)); + var project = new Project(); project.setId("project-1"); project.setImageUrl(domain + "/" + key); + project.setBannerUrl("images/legacy.png"); project.setGalleryImages(List.of("https://elsewhere.test/external.png")); + cleanup.deleteProjectMedia(project); + verify(client).deleteObject(argThat((DeleteObjectRequest request) -> key.equals(request.key()))); + verifyNoMoreInteractions(client); + assertNull(project.getImageUrl()); assertTrue(project.getGalleryImages().isEmpty()); + } + @Test void uploadNamespacesRejectUntrustedProjectIdsAndKinds() { + assertEquals("project-media/project-1/gallery", ProjectMediaKeys.prefix("project-1", "gallery")); + for (String id : Arrays.asList(null, "", "../victim", "a/b", "a%2fb")) + assertThrows(IllegalArgumentException.class, () -> ProjectMediaKeys.prefix(id, "images")); + assertThrows(IllegalArgumentException.class, () -> ProjectMediaKeys.prefix("project-1", "files")); + } +} diff --git a/backend/src/test/java/net/modtale/service/user/account/AccountPreferencesPersistenceIntegrationTest.java b/backend/src/test/java/net/modtale/service/user/account/AccountPreferencesPersistenceIntegrationTest.java new file mode 100644 index 000000000..578946974 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/user/account/AccountPreferencesPersistenceIntegrationTest.java @@ -0,0 +1,114 @@ +package net.modtale.service.user.account; + +import com.mongodb.client.*; +import java.util.*; +import net.modtale.model.user.*; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST", matches="true") +class AccountPreferencesPersistenceIntegrationTest { + private MongoClient client; + private MongoTemplate mongo; + private String database; + private AccountPreferencesPersistence writes; + + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030"); + if(!Set.of("27029","27030").contains(port)) throw new IllegalArgumentException("Unexpected test port"); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000"); + database="warden_preferences_test_"+UUID.randomUUID().toString().replace("-", ""); + mongo=new MongoTemplate(client,database); + writes=spy(new AccountPreferencesPersistence(mongo)); + var user=new User();user.setId("user");user.setUsername("Original"); + user.setGithubAccessToken("fixture-token");user.setLikedModIds(List.of("favorite")); + mongo.insert(user); + mongo.getCollection("users").updateOne(new Document("_id","user"), + new Document("$set",new Document("unknownAuthority","retained"))); + } + @AfterEach void cleanup(){if(client!=null){client.getDatabase(database).drop();client.close();}} + private Document stored(){return mongo.getCollection("users").find(new Document("_id","user")).first();} + private AccountService service(){ + return new AccountService(mock(net.modtale.repository.user.UserRepository.class),mongo, + mock(net.modtale.service.security.validation.SanitizationService.class), + mock(CurrentUserResolutionService.class),mock(OAuthAvatarHealingService.class), + mock(AccountLifecycleService.class), + mock(net.modtale.service.user.connection.ConnectedAccountMutationService.class),writes); + } + @Test void eachFieldUpdatePreservesAuthorityFavoritesAndUnknownData(){ + for(var field:AccountPreferencesPersistence.Field.values()){ + var before=stored();var state=writes.capture("user"); + state.user().setRoles(List.of("FULL_ADMIN"));state.user().setLikedModIds(List.of()); + state.user().setGithubAccessToken("wrong"); + state.user().setAvatarUrl("new-avatar");state.user().setBannerUrl("new-banner"); + state.user().setNotificationPreferences(new User.NotificationPreferences()); + state.user().setLauncherSettings(new LauncherSettingsSnapshot()); + assertTrue(writes.update(state,field)); + var after=stored(); + String key=switch(field){case AVATAR->"avatarUrl";case BANNER->"bannerUrl"; + case NOTIFICATIONS->"notificationPreferences";case LAUNCHER->"launcherSettings";}; + assertNotNull(after.get(key));before.remove(key);after.remove(key);assertEquals(before,after); + } + } + @Test void staleSettingsCannotUndoAnyConcurrentAccountWrite(){ + for(var change:List.of(new Document("roles",List.of("MODERATOR")), + new Document("likedModIds",List.of("new-favorite")),new Document("password","fixture-password"), + new Document("deletedAt",new Date()),new Document("launcherSettings",new Document("settingsHash","new")))){ + var initial=stored();var state=writes.capture("user");state.user().setBannerUrl("attempted"); + mongo.getCollection("users").updateOne(new Document("_id","user"),new Document("$set",change)); + var expected=stored(); + for(var field:AccountPreferencesPersistence.Field.values())assertFalse(writes.update(state,field)); + assertEquals(expected,stored()); + mongo.getCollection("users").replaceOne(new Document("_id","user"),initial); + } + } + @Test void everySettingsEndpointReportsConcurrentModificationInsteadOfSuccess(){ + var service=service(); + doAnswer(invocation->{ + mongo.getCollection("users").updateOne(new Document("_id","user"), + new Document("$set",new Document("concurrentWrite",UUID.randomUUID().toString()))); + return invocation.callRealMethod(); + }).when(writes).update(any(),any()); + List actions=List.of(()->service.updateUserAvatar("user","avatar"), + ()->service.updateUserBanner("user","banner"), + ()->service.updateNotificationPreferences("user",new User.NotificationPreferences()), + ()->service.updateLauncherSettings("user",new LauncherSettingsSnapshot()), + ()->service.updateLauncherSettingsPreferences("user",new LauncherSettingsSnapshot())); + for(var action:actions){ + var ex=assertThrows(ResponseStatusException.class,action::run);assertEquals(409,ex.getStatusCode().value()); + } + assertNull(stored().get("avatarUrl"));assertNull(stored().get("launcherSettings")); + assertEquals(List.of("USER"),stored().get("roles")); + assertEquals(List.of("favorite"),stored().get("likedModIds")); + } + @Test void realSettingsFlowPreservesInstalledProjectsAndSupportsIdempotentClearing(){ + var service=service();service.updateUserAvatar("user","avatar");service.updateUserBanner("user","banner"); + service.updateUserAvatar("user",null);service.updateUserAvatar("user",null); + service.updateNotificationPreferences("user",new User.NotificationPreferences()); + var settings=new LauncherSettingsSnapshot();var project=new LauncherSettingsSnapshot.InstalledProject(); + project.setProjectId("project");settings.setInstalledProjects(List.of(project)); + service.updateLauncherSettings("user",settings); + var update=new LauncherSettingsSnapshot();update.setSettingsHash(" new "); + service.updateLauncherSettingsPreferences("user",update); + var current=mongo.findById("user",User.class); + assertNull(current.getAvatarUrl());assertEquals("banner",current.getBannerUrl()); + assertEquals("new",current.getLauncherSettings().getSettingsHash()); + assertEquals("project",current.getLauncherSettings().getInstalledProjects().getFirst().getProjectId()); + assertEquals(List.of("favorite"),current.getLikedModIds()); + } + @Test void missingDeletedOrAmbiguousAccountsCannotBeChanged(){ + assertNull(writes.capture("missing")); + mongo.getCollection("users").updateOne(new Document("_id","user"),new Document("$set",new Document("deletedAt",new Date()))); + assertNull(writes.capture("user")); + assertThrows(net.modtale.exception.ResourceNotFoundException.class,()->service().updateUserAvatar("user","avatar")); + var id=new ObjectId();mongo.getCollection("users").insertOne(new Document("_id",id)); + var state=writes.capture(id.toHexString());state.user().setAvatarUrl("avatar");assertTrue(writes.update(state,AccountPreferencesPersistence.Field.AVATAR)); + mongo.getCollection("users").insertOne(new Document("_id",id.toHexString()));assertNull(writes.capture(id.toHexString())); + } +} diff --git a/backend/src/test/java/net/modtale/service/user/account/AccountServiceTest.java b/backend/src/test/java/net/modtale/service/user/account/AccountServiceTest.java index 97ad45e22..bb9f2822d 100644 --- a/backend/src/test/java/net/modtale/service/user/account/AccountServiceTest.java +++ b/backend/src/test/java/net/modtale/service/user/account/AccountServiceTest.java @@ -23,6 +23,7 @@ class AccountServiceTest { private UserRepository userRepository; + private AccountPreferencesPersistence preferencesPersistence; private OAuthAvatarHealingService oauthAvatarHealingService; private AccountService accountService; @@ -30,6 +31,11 @@ class AccountServiceTest { void setUp() { userRepository = mock(UserRepository.class); oauthAvatarHealingService = mock(OAuthAvatarHealingService.class); + preferencesPersistence = mock(AccountPreferencesPersistence.class); + when(preferencesPersistence.capture(any())).thenAnswer(invocation -> + userRepository.findById(invocation.getArgument(0)).map(user -> + new AccountPreferencesPersistence.Snapshot(new org.bson.Document(), user)).orElse(null)); + when(preferencesPersistence.update(any(), any())).thenReturn(true); accountService = new AccountService( userRepository, mock(org.springframework.data.mongodb.core.MongoTemplate.class), @@ -37,7 +43,8 @@ void setUp() { mock(CurrentUserResolutionService.class), oauthAvatarHealingService, mock(AccountLifecycleService.class), - mock(ConnectedAccountMutationService.class) + mock(ConnectedAccountMutationService.class), + preferencesPersistence ); } @@ -151,7 +158,8 @@ void launcherSettingsAreNormalizedBeforeSaving() { assertEquals("external-one", saved.getInstalledProjects().getFirst().getBundledProjects().getFirst().getExternalId()); assertNotNull(saved.getUpdatedAt()); - verify(userRepository).save(argThat(savedUser -> savedUser.getLauncherSettings() == saved)); + verify(preferencesPersistence).update(argThat(state -> state.user().getLauncherSettings() == saved), + org.mockito.ArgumentMatchers.eq(AccountPreferencesPersistence.Field.LAUNCHER)); } @Test @@ -180,7 +188,8 @@ void launcherPreferenceUpdatePreservesStoredInstalledProjects() { assertEquals("project-1", saved.getInstalledProjects().getFirst().getProjectId()); assertEquals("2.0", saved.getInstalledProjects().getFirst().getInstalledVersion()); assertNotNull(saved.getUpdatedAt()); - verify(userRepository).save(argThat(savedUser -> savedUser.getLauncherSettings() == saved)); + verify(preferencesPersistence).update(argThat(state -> state.user().getLauncherSettings() == saved), + org.mockito.ArgumentMatchers.eq(AccountPreferencesPersistence.Field.LAUNCHER)); } @Test diff --git a/backend/src/test/java/net/modtale/service/user/account/UserAvatarPersistenceIntegrationTest.java b/backend/src/test/java/net/modtale/service/user/account/UserAvatarPersistenceIntegrationTest.java new file mode 100644 index 000000000..835e4d7f6 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/user/account/UserAvatarPersistenceIntegrationTest.java @@ -0,0 +1,90 @@ +package net.modtale.service.user.account; + +import com.mongodb.client.*; +import java.util.*; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import net.modtale.model.user.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST", matches="true") +class UserAvatarPersistenceIntegrationTest { + private MongoClient client; private MongoTemplate mongo; private String database; private UserAvatarPersistence writes; + private static final String OLD="https://avatars.githubusercontent.com/old", NEW="https://avatars.githubusercontent.com/new"; + @BeforeEach void setup(){ + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030"); + if(!Set.of("27029","27030").contains(port))throw new IllegalArgumentException("Unexpected test port"); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000"); + database="warden_avatar_test_"+UUID.randomUUID().toString().replace("-", ""); + mongo=new MongoTemplate(client,database);writes=new UserAvatarPersistence(mongo); + var user=new User();user.setId("user");user.setUsername("Original");user.setAvatarUrl(OLD); + user.setGithubAccessToken("fixture-token");user.setLikedModIds(List.of("favorite")); + user.setConnectedAccounts(List.of(new User.ConnectedAccount(OAuthProvider.GITHUB,"provider-id","name",null,true)));mongo.insert(user); + mongo.getCollection("users").updateOne(new Document("_id","user"),new Document("$set",new Document("unknownAuthority","retained"))); + } + @AfterEach void cleanup(){if(client!=null){client.getDatabase(database).drop();client.close();}} + private Document stored(){return mongo.getCollection("users").find(new Document("_id","user")).first();} + private User user(){return mongo.findById("user",User.class);} + private OAuthAvatarHealingService service(){ + var service=spy(new OAuthAvatarHealingService(writes)); + doReturn(false).when(service).isImageUrlReachable(OLD);doReturn(true).when(service).isImageUrlReachable(NEW); + return service; + } + @Test void persistenceCanOnlyChangeAvatarAndPreservesUnknownAccountData(){ + var snapshot=writes.capture("user");snapshot.user().setRoles(List.of("FULL_ADMIN"));snapshot.user().setLikedModIds(List.of()); + assertTrue(writes.update(snapshot,NEW));assertEquals(NEW,stored().getString("avatarUrl")); + assertEquals(List.of("USER"),stored().get("roles"));assertEquals(List.of("favorite"),stored().get("likedModIds")); + assertEquals("fixture-token",stored().get("githubAccessToken"));assertEquals("retained",stored().get("unknownAuthority")); + assertFalse(writes.update(snapshot,OLD)); + } + @Test void delayedRefreshCannotUndoConcurrentRoleChanges(){ + var caller=user();var service=service(); + doAnswer(invocation->{mongo.getCollection("users").updateOne(new Document("_id","user"), + new Document("$set",new Document("roles",List.of("MODERATOR"))));return NEW;}).when(service).refreshAvatarFromLinkedProvider(any()); + service.maybeHealOAuthAvatar(caller); + assertEquals(List.of("MODERATOR"),stored().get("roles"));assertEquals(OLD,stored().get("avatarUrl"));assertEquals(OLD,caller.getAvatarUrl()); + } + @Test void delayedFallbackCannotUndoDeletionCredentialsFavoritesDisconnectOrAvatarReplacement(){ + var initial=stored(); + var changes=List.of(new Document("deletedAt",new Date()),new Document("password","new-fixture-password"), + new Document("likedModIds",List.of("new-favorite")),new Document("connectedAccounts",List.of()),new Document("avatarUrl","custom-avatar")); + for(var change:changes){ + mongo.getCollection("users").replaceOne(new Document("_id","user"),initial); + var caller=user();var service=service(); + doAnswer(invocation->{mongo.getCollection("users").updateOne(new Document("_id","user"),new Document("$set",change));return null;}) + .when(service).refreshAvatarFromLinkedProvider(any()); + service.maybeHealOAuthAvatar(caller); + for(var field:change.keySet())assertEquals(change.get(field),stored().get(field)); + assertEquals(change.getOrDefault("avatarUrl",OLD),stored().get("avatarUrl"));assertEquals(OLD,caller.getAvatarUrl()); + } + } + @Test void captureUsesCurrentProviderContextAndEncodesFallbackUsername(){ + var caller=user(); + mongo.getCollection("users").updateOne(new Document("_id","user"),new Document("$set",new Document("username","Fresh & Person").append("githubAccessToken","current-fixture-token"))); + var service=service(); + doAnswer(invocation->{User current=invocation.getArgument(0);assertEquals("Fresh & Person",current.getUsername()); + assertEquals("current-fixture-token",current.getGithubAccessToken());return null;}).when(service).refreshAvatarFromLinkedProvider(any()); + service.maybeHealOAuthAvatar(caller); + assertEquals("https://ui-avatars.com/api/?name=Fresh+%26+Person&background=random",stored().getString("avatarUrl")); + assertEquals(stored().get("avatarUrl"),caller.getAvatarUrl());assertEquals("current-fixture-token",stored().get("githubAccessToken")); + } + @Test void successfulProviderRefreshUpdatesOnlyTheReturnedAvatar(){ + var caller=user();var service=service();doReturn(NEW).when(service).refreshAvatarFromLinkedProvider(any()); + service.maybeHealOAuthAvatar(caller);assertEquals(NEW,stored().getString("avatarUrl"));assertEquals(NEW,caller.getAvatarUrl()); + assertEquals(List.of("favorite"),stored().get("likedModIds")); + } + @Test void disconnectedDeletedAndAmbiguousAccountsAreNotHealed(){ + var caller=user();var service=service(); + mongo.getCollection("users").updateOne(new Document("_id","user"),new Document("$set",new Document("connectedAccounts",List.of()))); + service.maybeHealOAuthAvatar(caller);verify(service,never()).isImageUrlReachable(anyString());verify(service,never()).refreshAvatarFromLinkedProvider(any()); + mongo.getCollection("users").updateOne(new Document("_id","user"),new Document("$set",new Document("deletedAt",new Date()))); + assertNull(writes.capture("user"));assertNull(writes.capture("missing")); + var id=new ObjectId();mongo.getCollection("users").insertOne(new Document("_id",id)); + assertNotNull(writes.capture(id.toHexString()));mongo.getCollection("users").insertOne(new Document("_id",id.toHexString())); + assertNull(writes.capture(id.toHexString())); + } +} diff --git a/backend/src/test/java/net/modtale/service/user/organization/OrganizationInvitePersistenceIntegrationTest.java b/backend/src/test/java/net/modtale/service/user/organization/OrganizationInvitePersistenceIntegrationTest.java new file mode 100644 index 000000000..91ef21581 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/user/organization/OrganizationInvitePersistenceIntegrationTest.java @@ -0,0 +1,100 @@ +package net.modtale.service.user.organization; +import com.mongodb.client.*; +import java.util.*; +import org.bson.Document; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import net.modtale.model.user.*; +import net.modtale.repository.user.*; +import net.modtale.service.communication.*; +import net.modtale.service.security.access.AccessControlService; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named="WARDEN_REVIEW_DB_TEST", matches="true") +class OrganizationInvitePersistenceIntegrationTest { + private MongoClient client; private MongoTemplate mongo; private String database; + private OrganizationInvitePersistence writes; + @BeforeEach void setup() { + String port=System.getenv().getOrDefault("WARDEN_REVIEW_DB_PORT","27030"); + if (!Set.of("27029","27030").contains(port)) throw new IllegalArgumentException("Unexpected test port"); + client=MongoClients.create("mongodb://127.0.0.1:"+port+"/?serverSelectionTimeoutMS=3000"); + database="warden_org_invite_"+UUID.randomUUID().toString().replace("-", ""); + mongo=new MongoTemplate(client,database); writes=new OrganizationInvitePersistence(mongo); + var org=new User(); org.setId("org"); org.setAccountType(User.AccountType.ORGANIZATION); + var owner=new User.OrganizationRole("owner-role","Owner","#fff",Set.of()); owner.setOwner(true); + org.setOrganizationRoles(new ArrayList<>(List.of(owner,new User.OrganizationRole("role","Member","#fff",Set.of(ApiKey.ApiPermission.PROJECT_CREATE))))); + org.setOrganizationMembers(new ArrayList<>(List.of(new User.OrganizationMember("owner","owner-role")))); + org.setLikedModIds(List.of("favorite")); mongo.insert(org); + mongo.getCollection("users").updateOne(new Document("_id","org"),new Document("$set",new Document("unknownAuthority","retained") + .append("organizationMembers.0.futurePermissionField","retained"))); + } + @AfterEach void cleanup(){if(client!=null){client.getDatabase(database).drop();client.close();}} + private User.OrganizationMember invitation(String id,long expiry){ + var invite=new User.OrganizationMember("recipient","role"); invite.setRequestId(id); invite.setRequestExpiresAt(expiry); + invite.setRequestPermissions(Set.of(ApiKey.ApiPermission.PROJECT_CREATE)); invite.setRequestOwnerIds(Set.of("owner")); return invite; + } + private void issue(String id,long expiry){assertTrue(writes.create(writes.capture("org"),invitation(id,expiry)));} + private Document stored(){return mongo.getCollection("users").find(new Document("_id","org")).first();} + @Test void createAndAcceptPreserveAccountFieldsAndUnknownMembershipFields(){ + var snapshot=writes.capture("org"); snapshot.organization().setRoles(List.of("FULL_ADMIN")); snapshot.organization().setLikedModIds(List.of()); + assertTrue(writes.create(snapshot,invitation("invite",System.currentTimeMillis()+60000))); + assertEquals(List.of("USER"),stored().get("roles")); assertEquals(List.of("favorite"),stored().get("likedModIds")); + assertTrue(writes.resolve(writes.capture("org"),"recipient","invite",true)); + assertEquals(2,stored().getList("organizationMembers",Document.class).size()); + assertEquals("retained",stored().getList("organizationMembers",Document.class).getFirst().get("futurePermissionField")); + assertEquals("retained",stored().get("unknownAuthority")); assertEquals(List.of(),stored().get("pendingOrgInvites")); + } + @Test void concurrentAccountChangePreventsCreationAndStaleCancellation(){ + var snapshot=writes.capture("org"); + mongo.getCollection("users").updateOne(new Document("_id","org"),new Document("$set",new Document("roles",List.of("MODERATOR")))); + assertFalse(writes.create(snapshot,invitation("invite",System.currentTimeMillis()+60000))); + issue("old",System.currentTimeMillis()+60000); var old=writes.capture("org"); + mongo.getCollection("users").updateOne(new Document("_id","org"),new Document("$set",new Document("pendingOrgInvites.0.requestId","new"))); + assertFalse(writes.resolve(old,"recipient","old",false)); + assertThrows(net.modtale.exception.InvalidOrganizationRequestException.class,()->writes.resolve(writes.capture("org"),"recipient","legacy",false)); + } + @Test void changedPermissionsOwnershipAndExpiryCannotGrantMembership(){ + issue("invite",1); + assertThrows(net.modtale.exception.InvalidOrganizationRequestException.class,()->writes.resolve(writes.capture("org"),"recipient","invite",true)); + mongo.getCollection("users").updateOne(new Document("_id","org"),new Document("$set",new Document("pendingOrgInvites.0.requestExpiresAt",System.currentTimeMillis()+60000) + .append("organizationRoles.1.permissions",List.of()))); + assertThrows(net.modtale.exception.InvalidOrganizationRequestException.class,()->writes.resolve(writes.capture("org"),"recipient","invite",true)); + mongo.getCollection("users").updateOne(new Document("_id","org"),new Document("$set",new Document("organizationRoles.1.permissions",List.of("PROJECT_CREATE")) + .append("organizationMembers.0.userId","different-owner"))); + assertThrows(net.modtale.exception.InvalidOrganizationRequestException.class,()->writes.resolve(writes.capture("org"),"recipient","invite",true)); + assertEquals(1,stored().getList("organizationMembers",Document.class).size()); + mongo.getCollection("users").updateOne(new Document("_id","org"),new Document("$set",new Document("organizationMembers.0.userId","owner") + .append("organizationMembers.0.roleId","role").append("organizationMembers.0.role","OWNER"))); + assertTrue(OrganizationInvitationPolicy.owners(writes.capture("org").organization()).isEmpty()); + assertThrows(net.modtale.exception.InvalidOrganizationRequestException.class,()->writes.resolve(writes.capture("org"),"recipient","invite",true)); + } + @Test void oldNotificationsAndExpiryCannotRemoveReplacementInvites(){ + issue("new",System.currentTimeMillis()+60000); + var repository=mock(NotificationRepository.class); + var notifications=new NotificationService(repository,mock(UserRepository.class),mongo,mock(NotificationDeliveryService.class)); + var old=new Notification("recipient","Invite","Invite",java.net.URI.create("/dashboard"),null,NotificationType.ORG_INVITE, + Map.of("orgId","org","requestId","old")); old.setId("old"); when(repository.findById("old")).thenReturn(Optional.of(old)); + notifications.deleteNotification("old","recipient"); notifications.cleanupExpiredOrganizationInvites(); + assertEquals("new",stored().getList("pendingOrgInvites",Document.class).getFirst().getString("requestId")); + mongo.getCollection("users").updateOne(new Document("_id","org"),new Document("$set",new Document("pendingOrgInvites.0.requestExpiresAt",1L))); + notifications.cleanupExpiredOrganizationInvites(); assertEquals(List.of(),stored().get("pendingOrgInvites")); + } + @Test void deletedOrganizationsAndUnknownIdentitiesFailClosed(){ + issue(null,0); assertTrue(writes.resolve(writes.capture("org"),"recipient","legacy",false)); + assertThrows(net.modtale.exception.InvalidOrganizationRequestException.class,()->writes.capture("missing")); + mongo.getCollection("users").updateOne(new Document("_id","org"),new Document("$set",new Document("deletedAt",new java.util.Date()))); + assertThrows(net.modtale.exception.InvalidOrganizationRequestException.class,()->writes.capture("org")); + } + @Test void failedInviteCreationDoesNotNotifyOrSaveTheWholeAccount(){ + var repository=mock(UserRepository.class); var notifications=mock(NotificationService.class); var access=mock(AccessControlService.class); + var spyWrites=spy(writes); doReturn(false).when(spyWrites).create(any(),any()); + var service=new OrganizationInviteService(repository,new OrganizationAccessService(repository,access),notifications,spyWrites); + var requester=new User();requester.setId("owner");var target=new User();target.setId("recipient"); + when(repository.findById("recipient")).thenReturn(Optional.of(target)); + when(access.hasOrgPermission(any(),eq("owner"),eq(ApiKey.ApiPermission.ORG_MEMBER_INVITE))).thenReturn(true); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.inviteOrganizationMember("org","recipient","role",requester)); + verifyNoInteractions(notifications);verify(repository,never()).save(any(User.class));assertEquals(List.of(),stored().get("pendingOrgInvites")); + } +} diff --git a/frontend/src/modules/admin/api/adminClient.ts b/frontend/src/modules/admin/api/adminClient.ts index 94bb75c0e..c7d1bc750 100644 --- a/frontend/src/modules/admin/api/adminClient.ts +++ b/frontend/src/modules/admin/api/adminClient.ts @@ -1,22 +1,30 @@ import { api } from '@/utils/api'; -import type { AdminVerificationQueueItem } from '@/types'; + +export interface InspectionWindow { + identity: string; content: string; format: string; start: number; end: number; totalCharacters: number; + firstLine: number; lineMatched: boolean; representationComplete: boolean; gaps: string[]; +} export const adminClient = { getProjectMeta: async (projectId: string) => (await api.get(`/projects/${projectId}/meta`)).data, - getStructure: async (projectId: string, version: string) => (await api.get(`/admin/projects/${projectId}/versions/${version}/structure`)).data, - getFileContent: async (projectId: string, version: string, path: string) => (await api.get(`/admin/projects/${projectId}/versions/${version}/file`, { params: { path } })).data, + getArtifactChanges: async (projectId: string, version: string, reviewToken: string): Promise => (await api.get(`/admin/projects/${encodeURIComponent(projectId)}/versions/${encodeURIComponent(version)}/changes`, { headers: { 'If-Match': reviewToken } })).data, + getStructure: async (projectId: string, version: string, reviewToken: string) => (await api.get(`/admin/projects/${encodeURIComponent(projectId)}/versions/${encodeURIComponent(version)}/structure`, { headers: { 'If-Match': reviewToken } })).data, + getFileContent: async (projectId: string, version: string, path: string, reviewToken: string) => (await api.get(`/admin/projects/${encodeURIComponent(projectId)}/versions/${encodeURIComponent(version)}/file`, { params: { path }, headers: { 'If-Match': reviewToken } })).data, + getFileWindow: async (projectId: string, version: string, path: string, reviewToken: string, offset = 0, identity?: string, sourceLine = 0): Promise => + (await api.get(`/admin/projects/${encodeURIComponent(projectId)}/versions/${encodeURIComponent(version)}/file-window`, + { params: { path, offset, characters: 32000, sourceLine, identity }, headers: { 'If-Match': reviewToken } })).data, scanVersion: async (projectId: string, versionId: string) => (await api.post(`/admin/projects/${projectId}/versions/${versionId}/scan`)).data, - publishProject: async (projectId: string) => (await api.post(`/admin/projects/${projectId}/publish`)).data, - rejectProject: async (projectId: string, reason: string) => (await api.post(`/admin/projects/${projectId}/reject`, { reason })).data, - approveVersion: async (projectId: string, versionId: string) => (await api.post(`/admin/projects/${projectId}/versions/${versionId}/approve`)).data, - rejectVersion: async (projectId: string, versionId: string, reason: string) => (await api.post(`/admin/projects/${projectId}/versions/${versionId}/reject`, { reason })).data, + publishProject: async (projectId: string, reviewToken?: string, versionId?: string) => (await api.post(`/admin/projects/${projectId}/publish`, null, { headers: { 'If-Match': reviewToken }, params: { versionId } })).data, + rejectProject: async (projectId: string, reason: string, reviewToken?: string) => (await api.post(`/admin/projects/${projectId}/reject`, { reason }, { headers: { 'If-Match': reviewToken } })).data, + approveVersion: async (projectId: string, versionId: string, reviewToken?: string) => (await api.post(`/admin/projects/${projectId}/versions/${versionId}/approve`, null, { headers: { 'If-Match': reviewToken } })).data, + rejectVersion: async (projectId: string, versionId: string, reason: string, reviewToken?: string) => (await api.post(`/admin/projects/${projectId}/versions/${versionId}/reject`, { reason }, { headers: { 'If-Match': reviewToken } })).data, getLogs: async (params: any) => (await api.get('/admin/logs', { params })).data, getPlatformAnalytics: async (range: string) => (await api.get(`/analytics/platform/full?range=${range}`)).data, searchProjects: async (params: any) => (await api.get('/admin/projects/search', { params })).data, getProjectById: async (id: string) => (await api.get(`/admin/projects/${id}`)).data, - updateProjectRaw: async (id: string, data: any) => (await api.put(`/admin/projects/${id}/raw`, data)).data, + updateProjectRaw: async (id: string, data: Record, token: string) => (await api.put(`/admin/projects/${id}/raw`, data, { headers: { 'If-Match': token } })).data, deleteProject: async (id: string, reason: string) => (await api.delete(`/admin/projects/${id}`, { params: { reason } })).data, hardDeleteProject: async (id: string, reason: string) => (await api.delete(`/admin/projects/${id}/hard`, { params: { reason } })).data, restoreProject: async (id: string, status: string) => (await api.post(`/admin/projects/${id}/restore`, null, { params: { status } })).data, @@ -41,7 +49,6 @@ export const adminClient = { getUserRaw: async (userId: string) => (await api.get(`/admin/users/${userId}/raw`)).data, updateUserRaw: async (userId: string, data: any) => (await api.put(`/admin/users/${userId}/raw`, data)).data, - getVerificationQueue: async (): Promise => (await api.get('/admin/verification/queue')).data, getReviewDetails: async (id: string) => (await api.get(`/admin/projects/${id}/review-details`)).data, getCurrentAdmin: async () => (await api.get('/user/me')).data, diff --git a/frontend/src/modules/admin/api/findingReviews.ts b/frontend/src/modules/admin/api/findingReviews.ts new file mode 100644 index 000000000..7df933e15 --- /dev/null +++ b/frontend/src/modules/admin/api/findingReviews.ts @@ -0,0 +1,36 @@ +import { api } from '@/utils/api'; + +export interface FindingDecision { + id: string; + actorId: string; + createdAt: number; + expiresAt: number; + disposition: 'ACCEPT' | 'REQUIRE_REVIEW' | 'REVOKE'; + rationale: string; + scope: string; + finding: { path: string; description: string; lineStart: number }; + revokedDecisionId: string | null; + supersedesDecisionId?: string | null; +} +export interface DecisionAssessment { state: string; explanation: string } +export interface FindingHistory { + events: FindingDecision[]; nextOffset: number | null; + assessments?: Record; assessedAt?: number; +} +const path = (project: string, version: string) => `/admin/projects/${encodeURIComponent(project)}/versions/${encodeURIComponent(version)}/finding-decisions`; +export const findingReviews = { + history: async (project: string, version: string, token: string, offset = 0): Promise => + (await api.get(path(project, version), { headers: { 'If-Match': token }, params: { offset } })).data, + record: async (project: string, version: string, token: string, issueIndex: number, disposition: 'ACCEPT' | 'REQUIRE_REVIEW', rationale: string): Promise => + (await api.post(path(project, version), { issueIndex, disposition, rationale }, { headers: { 'If-Match': token } })).data, + revoke: async (project: string, version: string, token: string, decision: string, rationale: string): Promise => + (await api.post(`${path(project, version)}/${encodeURIComponent(decision)}/revoke`, { rationale }, { headers: { 'If-Match': token } })).data, +}; + +export interface PriorFindingReasoning { + reviewToken: string; sourceVersionId: string; sourceVersion: string; assessedAt: number; + reviewReasons: string[]; decisions: FindingDecision[]; omitted: number; +} +export const loadPriorFindingReasoning = async (project: string, version: string, sourceVersionId: string, issueIndex: number, token: string): Promise => + (await api.get(`/admin/projects/${encodeURIComponent(project)}/versions/${encodeURIComponent(version)}/prior-finding-reasoning`, + { headers: { 'If-Match': token }, params: { sourceVersionId, issueIndex } })).data; diff --git a/frontend/src/modules/admin/api/moderationQueue.ts b/frontend/src/modules/admin/api/moderationQueue.ts new file mode 100644 index 000000000..f4aa61b88 --- /dev/null +++ b/frontend/src/modules/admin/api/moderationQueue.ts @@ -0,0 +1,40 @@ +import { api } from '@/utils/api'; +import type { AdminVerificationQueueItem } from '@/types'; +export type QueueFilter = 'ALL' | 'SECURITY' | 'OPERATIONS'; +export interface QueuePage { filter?: QueueFilter; items: AdminVerificationQueueItem[]; nextCursor: string | null; unavailableItems: number; order: 'PROJECT_VERSION'; } +const cursorValid = (value: unknown): value is string => typeof value === 'string' && value.length <= 800 && /^(1|2\.(SECURITY|OPERATIONS))\.[os]\.(0|[1-9][0-9]{0,7})\.[A-Za-z0-9_-]{1,684}$/.test(value); +const cursorMatchesFilter = (cursor: string, filter: QueueFilter) => filter === 'ALL' ? cursor.startsWith('1.') : cursor.startsWith(`2.${filter}.`); +export function validateQueuePage(value: unknown, filter: QueueFilter = 'ALL'): QueuePage { + const page = value as QueuePage; + if (!page || (page.filter ?? 'ALL') !== filter || !Array.isArray(page.items) || page.items.length > 25 || page.order !== 'PROJECT_VERSION' + || (page.nextCursor !== null && (!cursorValid(page.nextCursor) || !cursorMatchesFilter(page.nextCursor, filter))) || !Number.isInteger(page.unavailableItems) + || page.unavailableItems < 0 || page.unavailableItems + page.items.length > 25) throw new Error('Invalid moderation queue response.'); + const keys = new Set(); + for (const row of page.items) { + if (!row || typeof row.id !== 'string' || !row.id || (row.pendingVersion != null + && (typeof row.pendingVersion.id !== 'string' || !row.pendingVersion.id || row.pendingVersion.reviewStatus !== 'PENDING'))) throw new Error('Invalid moderation queue entry.'); + for (const [field, max] of [['title',256],['description',1024],['author',128],['imageUrl',2048],['classification',32],['status',32],['updatedAt',64]] as const) { + const text = row[field]; if (text != null && (typeof text !== 'string' || [...text].length > max)) throw new Error('Invalid moderation queue display data.'); + } + if (row.pendingVersion) { + const version = row.pendingVersion; + if (version.versionNumber != null && (typeof version.versionNumber !== 'string' || [...version.versionNumber].length > 128) + || version.changelog != null && (typeof version.changelog !== 'string' || [...version.changelog].length > 1024)) throw new Error('Invalid moderation queue version.'); + if (version.scan != null) { + for (const field of ['status','verdict','scanState'] as const) if (version.scan[field] != null && typeof version.scan[field] !== 'string') throw new Error('Invalid moderation queue status.'); + for (const field of ['riskScore','knownIssueCount','newIssueCount','escalatedIssueCount'] as const) if (!Number.isInteger(version.scan[field])) throw new Error('Invalid moderation queue findings.'); + } + } + const key = JSON.stringify([row.id, row.pendingVersion?.id]); + if (keys.has(key)) throw new Error('Duplicate moderation queue entry.'); + keys.add(key); + } + return page; +} +export async function getModerationQueuePage(cursor: string | null, signal: AbortSignal, filter: QueueFilter = 'ALL'): Promise { + if (cursor !== null && (!cursorValid(cursor) || !cursorMatchesFilter(cursor, filter))) throw new Error('Invalid moderation queue cursor.'); + const response = await api.get('/admin/verification/queue/page', { params: { cursor: cursor ?? undefined, limit: 25, filter }, signal }); + const page = validateQueuePage(response.data, filter); + if (cursor !== null && page.nextCursor === cursor) throw new Error('The moderation queue did not advance. Refresh the queue.'); + return page; +} diff --git a/frontend/src/modules/admin/api/reviewCancellation.ts b/frontend/src/modules/admin/api/reviewCancellation.ts new file mode 100644 index 000000000..f7f4c2728 --- /dev/null +++ b/frontend/src/modules/admin/api/reviewCancellation.ts @@ -0,0 +1,117 @@ +import { api } from '@/utils/api'; + +export type CancellationPreview = { isolationId: string; projectIdType: 'STRING' | 'OBJECT_ID'; projectId: string; versionIndex: number; versionId: string; requestId: string; jobId: string; beforeSha256: string; targetSha256: string; artifactSha256: string }; +export type CancellationIntent = { isolationId: string; targetSha256: string; createdAt: number; expiresAt: number }; +export type RemoteStatus = { jobId: string; state: string; artifactRetained: boolean; createdAt: number; expiresAt: number; workState: string | null }; +export type CancellationObservation = { kind: 'REMOTE_STATUS' | 'NOT_FOUND' | 'CONTEXT_CONFLICT' | 'UNAVAILABLE' | 'UNKNOWN'; status: RemoteStatus | null; httpStatus: number | null }; +export type CancellationReceipt = { prepared: CancellationIntent; state: 'PREPARED' | 'EXECUTING' | 'DISPATCHING' | 'OBSERVED' | 'UNKNOWN'; observation: CancellationObservation | null; observedAt: number | null }; +export type CancellationCheck = { id: string; original: CancellationIntent }; +export type CheckReceipt = { id: string; original: CancellationIntent; state: 'RESERVED' | 'READING' | 'OBSERVED' | 'UNKNOWN'; observation: CancellationObservation | null; receivedAt: number | null }; +export type CancellationExecution = { state: CancellationReceipt['state'] | 'BUSY' | 'SHUTDOWN'; receipt: CancellationReceipt | null }; +export type SavedCancellation = { preview: CancellationPreview; original: CancellationIntent; checkId: string | null }; +const base = '/admin/verification/cancellations'; +const noReplay = (signal: AbortSignal) => ({ signal, skipCsrfRetry: true }); +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const digest = /^[0-9a-f]{64}$/; +const invalid = () => new Error('Invalid cancellation response.'); +function object(value: unknown, keys: string[]): Record { + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || keys.some(k => !Object.hasOwn(value, k))) throw invalid(); + return value as Record; +} +function text(value: unknown, pattern: RegExp): string { if (typeof value !== 'string' || !pattern.test(value)) throw invalid(); return value; } +function positive(value: unknown): number { if (!Number.isSafeInteger(value) || (value as number) <= 0) throw invalid(); return value as number; } +function same(a: CancellationIntent, b: CancellationIntent) { if (a.isolationId !== b.isolationId || a.targetSha256 !== b.targetSha256 || a.createdAt !== b.createdAt || a.expiresAt !== b.expiresAt) throw invalid(); } +export function validateCancellationPreview(value: unknown, isolationId: string): CancellationPreview { + const p = object(value, ['isolationId', 'projectIdType', 'projectId', 'versionIndex', 'versionId', 'requestId', 'jobId', 'beforeSha256', 'targetSha256', 'artifactSha256']); + if (text(p.isolationId, uuid) !== text(isolationId, uuid) || !['STRING', 'OBJECT_ID'].includes(p.projectIdType as string) + || typeof p.projectId !== 'string' || !p.projectId || [...p.projectId].length > 128 || /[\uD800-\uDFFF]/u.test(p.projectId) + || (p.projectIdType === 'OBJECT_ID' && !/^[0-9a-f]{24}$/.test(p.projectId)) + || !Number.isSafeInteger(p.versionIndex) || (p.versionIndex as number) < 0 || (p.versionIndex as number) > 16777216 + || typeof p.versionId !== 'string' || !p.versionId.trim() || p.versionId.length > 128 || /[\x00-\x1f\x7f-\x9f]/.test(p.versionId)) throw invalid(); + text(p.requestId, uuid); text(p.jobId, uuid); text(p.beforeSha256, digest); text(p.targetSha256, digest); text(p.artifactSha256, digest); + return { ...p } as CancellationPreview; +} +export function validateCancellationIntent(value: unknown, preview: CancellationPreview): CancellationIntent { + const p = object(value, ['isolationId', 'targetSha256', 'createdAt', 'expiresAt']); + if (text(p.isolationId, uuid) !== preview.isolationId || text(p.targetSha256, digest) !== preview.targetSha256) throw invalid(); + const createdAt = positive(p.createdAt), expiresAt = positive(p.expiresAt); + if (expiresAt <= createdAt || expiresAt - createdAt > 120000) throw invalid(); + return { isolationId: p.isolationId as string, targetSha256: p.targetSha256 as string, createdAt, expiresAt }; +} +function observation(value: unknown, jobId: string): CancellationObservation { + const o = object(value, ['kind', 'status', 'httpStatus']); + if (!['REMOTE_STATUS', 'NOT_FOUND', 'CONTEXT_CONFLICT', 'UNAVAILABLE', 'UNKNOWN'].includes(o.kind as string)) throw invalid(); + if (o.kind === 'REMOTE_STATUS') { + const s = object(o.status, ['jobId', 'state', 'artifactRetained', 'createdAt', 'expiresAt', 'workState']); + if (o.httpStatus !== null || text(s.jobId, uuid) !== jobId || !['QUEUED', 'RUNNING', 'COMPLETED', 'CANCELLED', 'EXPIRED', 'HELD', 'UPLOADING', 'AWAITING_UPLOAD'].includes(s.state as string) + || typeof s.artifactRetained !== 'boolean' || positive(s.expiresAt) <= positive(s.createdAt) + || (s.workState !== null && (typeof s.workState !== 'string' || s.workState.length > 128))) throw invalid(); + return { kind: 'REMOTE_STATUS', status: { ...s } as RemoteStatus, httpStatus: null }; + } + if (o.status !== null || (o.httpStatus !== null && (!Number.isInteger(o.httpStatus) || (o.httpStatus as number) < 100 || (o.httpStatus as number) > 599)) + || (o.kind === 'NOT_FOUND' && o.httpStatus !== 404) || (o.kind === 'CONTEXT_CONFLICT' && o.httpStatus !== 409) + || (o.kind === 'UNAVAILABLE' && ![401, 403, 429].includes(o.httpStatus as number))) throw invalid(); + return { ...o } as CancellationObservation; +} +function outcome(state: unknown, value: unknown, time: unknown, preview: CancellationPreview, pending: string[]) { + if (pending.includes(state as string)) { if (value !== null || time !== null) throw invalid(); return null; } + if (state !== 'OBSERVED' && state !== 'UNKNOWN') throw invalid(); + positive(time); const result = observation(value, preview.jobId); + if ((state === 'UNKNOWN') !== (result.kind === 'UNKNOWN')) throw invalid(); return result; +} +export function validateCancellationReceipt(value: unknown, preview: CancellationPreview, expected?: CancellationIntent): CancellationReceipt { + const r = object(value, ['prepared', 'state', 'observation', 'observedAt']); + const prepared = validateCancellationIntent(r.prepared, preview); if (expected) same(prepared, expected); + const observed = outcome(r.state, r.observation, r.observedAt, preview, ['PREPARED', 'EXECUTING', 'DISPATCHING']); + if (r.observedAt !== null && (r.observedAt as number) < prepared.createdAt) throw invalid(); + return { prepared, state: r.state as CancellationReceipt['state'], observation: observed, observedAt: r.observedAt as number | null }; +} +export function validateCheckReceipt(value: unknown, preview: CancellationPreview, expected: CancellationCheck): CheckReceipt { + const r = object(value, ['id', 'original', 'state', 'observation', 'receivedAt']); + if (text(r.id, uuid) !== text(expected.id, uuid)) throw invalid(); + const original = validateCancellationIntent(r.original, preview); same(original, expected.original); + const observed = outcome(r.state, r.observation, r.receivedAt, preview, ['RESERVED', 'READING']); + return { id: r.id as string, original, state: r.state as CheckReceipt['state'], observation: observed, receivedAt: r.receivedAt as number | null }; +} +export function restoreCancellation(raw: string): SavedCancellation { + if (raw.length > 8192) throw invalid(); const s = object(JSON.parse(raw), ['preview', 'original', 'checkId']); + const id = (s.preview as CancellationPreview)?.isolationId; + const preview = validateCancellationPreview(s.preview, id), original = validateCancellationIntent(s.original, preview); + if (s.checkId !== null) text(s.checkId, uuid); + return { preview, original, checkId: s.checkId as string | null }; +} +export function saveCancellation(storage: Pick, subject: string, value: SavedCancellation): void { + if (!subject.trim()) throw invalid(); const raw = JSON.stringify(restoreCancellation(JSON.stringify(value))); + const key = cancellationStorageKey(subject); storage.setItem(key, raw); if (storage.getItem(key) !== raw) throw new Error('Cancellation reference could not be saved.'); +} +export function cancellationStorageKey(subject: string) { return `review-cancellation:${encodeURIComponent(subject)}`; } +export async function cancellationAvailable(signal: AbortSignal): Promise { + try { const { data } = await api.get(`${base}/capabilities`, { signal }); if (object(data, ['action']).action !== 'CANCEL_ORIGINAL_REVIEW') throw invalid(); return true; } + catch (e) { if ((e as { response?: { status?: number } })?.response?.status === 404) return false; throw e; } +} +export async function previewCancellation(id: string, signal: AbortSignal) { text(id, uuid); return validateCancellationPreview((await api.get(`${base}/targets/${id}`, { signal })).data, id); } +export async function prepareCancellation(preview: CancellationPreview, signal: AbortSignal) { + const p = validateCancellationPreview(preview, preview.isolationId); + return validateCancellationIntent((await api.post(`${base}/prepare`, { isolationId: p.isolationId }, noReplay(signal))).data, p); +} +export async function recoverCancellation(preview: CancellationPreview, signal: AbortSignal) { + const p = validateCancellationPreview(preview, preview.isolationId); + return validateCancellationReceipt((await api.get(`${base}/operations/${p.isolationId}`, { signal })).data, p); +} +export async function cancellationReceipt(preview: CancellationPreview, original: CancellationIntent, signal: AbortSignal) { + const p = validateCancellationPreview(preview, preview.isolationId), intent = validateCancellationIntent(original, p); + return validateCancellationReceipt((await api.post(`${base}/receipt`, intent, { signal })).data, p, intent); +} +export async function executeCancellation(preview: CancellationPreview, original: CancellationIntent, signal: AbortSignal): Promise { + const p = validateCancellationPreview(preview, preview.isolationId), intent = validateCancellationIntent(original, p); + const r = object((await api.post(`${base}/execute`, intent, noReplay(signal))).data, ['state', 'receipt']); + if (r.receipt === null) { if (!['BUSY', 'SHUTDOWN', 'UNKNOWN'].includes(r.state as string)) throw invalid(); return { state: r.state as CancellationExecution['state'], receipt: null }; } + const receipt = validateCancellationReceipt(r.receipt, p, intent); if (receipt.state !== r.state) throw invalid(); + return { state: receipt.state, receipt }; +} +async function checkRequest(path: string, preview: CancellationPreview, check: CancellationCheck, signal: AbortSignal) { + const p = validateCancellationPreview(preview, preview.isolationId), request = { id: text(check.id, uuid), original: validateCancellationIntent(check.original, p) }; + return validateCheckReceipt((await api.post(`${base}/${path}`, request, noReplay(signal))).data, p, request); +} +export const checkCancellation = (preview: CancellationPreview, check: CancellationCheck, signal: AbortSignal) => checkRequest('checks', preview, check, signal); +export const cancellationCheckReceipt = (preview: CancellationPreview, check: CancellationCheck, signal: AbortSignal) => checkRequest('checks/receipt', preview, check, signal); diff --git a/frontend/src/modules/admin/api/reviewDiagnostics.ts b/frontend/src/modules/admin/api/reviewDiagnostics.ts new file mode 100644 index 000000000..c4b1f1386 --- /dev/null +++ b/frontend/src/modules/admin/api/reviewDiagnostics.ts @@ -0,0 +1,57 @@ +import { api } from '@/utils/api'; +export const diagnosticReasons = { + INVALID_PROJECT_ID: 'Project identifier needs repair.', + INVALID_VERSION_ID: 'Version identifier needs repair.', + DUPLICATE_VERSION_ID: 'Multiple versions share this identifier. Resolve the ambiguity before taking action.', + INVALID_REQUEST: 'The stored review request or attempt is invalid.', + INVALID_ARTIFACT: 'The stored file path or file hash is invalid.', + INVALID_MANUAL_MODE: 'The stored rescan setting is invalid.', + INVALID_SCAN_STATE: 'The stored scan state is not recognized.', + MISSING_BINDING: 'The remote review reference is missing.', + INVALID_BINDING: 'The stored review reference cannot be read.', + BINDING_MISMATCH: 'The stored review reference does not match this version.', + INVALID_POLL: 'The stored polling state is invalid.', + POLL_WITHOUT_REMOTE_STATE: 'Polling state is attached to a version outside remote review.', +} as const; +export type DiagnosticReason = keyof typeof diagnosticReasons; +export interface DiagnosticItem { position: { projectIdType: 'STRING' | 'OBJECT_ID'; projectId: string; versionIndex: number }; versionId: string | null; reasons: DiagnosticReason[]; } +export interface DiagnosticPage { items: DiagnosticItem[]; nextCursor: string | null; examinedSlots: number; scope: 'PENDING_SCAN_STRUCTURE'; } +const boundedIndex = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 0 && (n as number) <= 16777216; +export function validDiagnosticCursor(value: unknown): value is string { + if (typeof value !== 'string' || value.length > 810) return false; + const match = /^d1\.([av])\.1\.([so])\.(0|[1-9][0-9]{0,7})\.([A-Za-z0-9_-]{1,684})$/.exec(value); + if (!match || !boundedIndex(Number(match[3])) || (match[1] === 'a' && match[3] !== '0')) return false; + try { + const raw = atob(match[4].replace(/-/g, '+').replace(/_/g, '/')); + const decoded = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Uint8Array.from(raw, c => c.charCodeAt(0))); + const canonical = btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return canonical === match[4] && (match[2] === 'o' ? /^[0-9a-f]{24}$/.test(decoded) : decoded.length > 0 && [...decoded].length <= 128); + } catch { return false; } +} +export function validateDiagnosticPage(value: unknown): DiagnosticPage { + const page = value as DiagnosticPage; + if (!page || page.scope !== 'PENDING_SCAN_STRUCTURE' || !Array.isArray(page.items) || page.items.length > 25 + || !Number.isInteger(page.examinedSlots) || page.examinedSlots < page.items.length || page.examinedSlots > 25 + || (page.nextCursor !== null && !validDiagnosticCursor(page.nextCursor))) throw new Error('Invalid review diagnostic response.'); + const keys = new Set(); + for (const item of page.items) { + const p = item?.position; + if (!p || !['STRING', 'OBJECT_ID'].includes(p.projectIdType) || typeof p.projectId !== 'string' || !p.projectId || [...p.projectId].length > 128 + || (p.projectIdType === 'OBJECT_ID' && !/^[0-9a-f]{24}$/.test(p.projectId)) || !boundedIndex(p.versionIndex) + || (item.versionId !== null && (typeof item.versionId !== 'string' || !item.versionId.trim() || item.versionId.length > 128 || /[\x00-\x1f\x7f-\x9f]/.test(item.versionId))) + || !Array.isArray(item.reasons) || item.reasons.length < 1 || item.reasons.length > 12 + || item.reasons.some(reason => typeof reason !== 'string' || !Object.hasOwn(diagnosticReasons, reason)) || new Set(item.reasons).size !== item.reasons.length) + throw new Error('Invalid review diagnostic entry.'); + const key = JSON.stringify([p.projectIdType, p.projectId, p.versionIndex]); + if (keys.has(key)) throw new Error('Duplicate review diagnostic position.'); + keys.add(key); + } + return page; +} +export async function getDiagnosticPage(cursor: string | null, signal: AbortSignal): Promise { + if (cursor !== null && !validDiagnosticCursor(cursor)) throw new Error('Invalid review diagnostic cursor.'); + const response = await api.get('/admin/verification/diagnostics/page', { params: { cursor: cursor ?? undefined, limit: 25 }, signal }); + const page = validateDiagnosticPage(response.data); + if (cursor !== null && page.nextCursor === cursor) throw new Error('Diagnostics did not advance. Refresh from start.'); + return page; +} diff --git a/frontend/src/modules/admin/api/reviewOrigins.ts b/frontend/src/modules/admin/api/reviewOrigins.ts new file mode 100644 index 000000000..f54e3d558 --- /dev/null +++ b/frontend/src/modules/admin/api/reviewOrigins.ts @@ -0,0 +1,44 @@ +import { api } from '@/utils/api'; +import { validDiagnosticCursor } from './reviewDiagnostics'; +export const originLabels = { + MISSING: 'Original service not recorded', + INVALID: 'Original service record malformed', + RECORDED: 'Service identity recorded', + UNREADABLE_BINDING: 'Review reference unreadable', +} as const; +export interface OriginItem { + position: { projectIdType: 'STRING' | 'OBJECT_ID'; projectId: string; versionIndex: number }; + versionId: string | null; ambiguousVersion: boolean; requestId: string | null; jobId: string | null; + originState: keyof typeof originLabels; +} +export interface OriginPage { items: OriginItem[]; nextCursor: string | null; examinedSlots: number; scope: 'RETAINED_REVIEW_ORIGINS'; } +export function validOriginCursor(value: unknown): value is string { + return typeof value === 'string' && value.startsWith('o1.') && validDiagnosticCursor(`d1.${value.slice(3)}`); +} +const uuid = (value: unknown) => value === null || typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value); +export function validateOriginPage(value: unknown): OriginPage { + const page = value as OriginPage; + if (!page || page.scope !== 'RETAINED_REVIEW_ORIGINS' || !Array.isArray(page.items) || page.items.length > 25 + || !Number.isInteger(page.examinedSlots) || page.examinedSlots < page.items.length || page.examinedSlots > 25 + || (page.nextCursor !== null && !validOriginCursor(page.nextCursor))) throw new Error('Invalid origin inventory response.'); + const seen = new Set(); + for (const item of page.items) { + const p = item?.position; + if (!p || !['STRING', 'OBJECT_ID'].includes(p.projectIdType) || typeof p.projectId !== 'string' || !p.projectId || [...p.projectId].length > 128 + || (p.projectIdType === 'OBJECT_ID' && !/^[0-9a-f]{24}$/.test(p.projectId)) || !Number.isInteger(p.versionIndex) || p.versionIndex < 0 || p.versionIndex > 16777216 + || (item.versionId !== null && (typeof item.versionId !== 'string' || !item.versionId.trim() || item.versionId.length > 128 || /[\x00-\x1f\x7f-\x9f]/.test(item.versionId))) + || typeof item.ambiguousVersion !== 'boolean' || (item.versionId === null && !item.ambiguousVersion) || !uuid(item.requestId) || !uuid(item.jobId) + || typeof item.originState !== 'string' || !Object.hasOwn(originLabels, item.originState)) throw new Error('Invalid origin inventory record.'); + const key = JSON.stringify([p.projectIdType, p.projectId, p.versionIndex]); + if (seen.has(key)) throw new Error('Duplicate origin inventory position.'); + seen.add(key); + } + return page; +} +export async function getOriginPage(cursor: string | null, signal: AbortSignal): Promise { + if (cursor !== null && !validOriginCursor(cursor)) throw new Error('Invalid origin inventory cursor.'); + const response = await api.get('/admin/verification/origins/page', { params: { cursor: cursor ?? undefined, limit: 25 }, signal }); + const page = validateOriginPage(response.data); + if (cursor !== null && cursor === page.nextCursor) throw new Error('Origin inventory did not advance. Refresh from start.'); + return page; +} diff --git a/frontend/src/modules/admin/api/reviewRepair.ts b/frontend/src/modules/admin/api/reviewRepair.ts new file mode 100644 index 000000000..365b10cfc --- /dev/null +++ b/frontend/src/modules/admin/api/reviewRepair.ts @@ -0,0 +1,102 @@ +import { api } from '@/utils/api'; +import type { DiagnosticItem } from './reviewDiagnostics'; +export type RepairTarget = { position: DiagnosticItem['position']; versionId: string }; +export type RepairPreview = RepairTarget & { sha256: string; eligible: boolean; effect: 'ISOLATE_LOCAL_REVIEW' }; +export type PreparedRepair = { id: string; sha256: string; createdAt: number; expiresAt: number }; +export type RepairResult = { state: 'APPLIED' | 'NOT_APPLIED' | 'INELIGIBLE' | 'UNKNOWN'; afterSha256: string | null }; +export type PendingRepair = { target: RepairTarget; prepared: PreparedRepair }; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const digest = /^[0-9a-f]{64}$/; +const invalid = () => new Error('Invalid repair response.'); +export function validateTarget(value: unknown): RepairTarget { + const t = value as RepairTarget; const p = t?.position; + if (!p || !['STRING', 'OBJECT_ID'].includes(p.projectIdType) || typeof p.projectId !== 'string' || !p.projectId || p.projectId.length > 128 + || (p.projectIdType === 'OBJECT_ID' && !/^[0-9a-f]{24}$/.test(p.projectId)) || !Number.isInteger(p.versionIndex) || p.versionIndex < 0 || p.versionIndex > 16777216 + || typeof t.versionId !== 'string' || !t.versionId.trim() || t.versionId.length > 128 || /[\x00-\x1f\x7f-\x9f]/.test(t.versionId)) throw invalid(); + return { position: { projectIdType: p.projectIdType, projectId: p.projectId, versionIndex: p.versionIndex }, versionId: t.versionId }; +} +export function validatePrepared(value: unknown): PreparedRepair { + const p = value as PreparedRepair; + if (!p || typeof p.id !== 'string' || !uuid.test(p.id) || typeof p.sha256 !== 'string' || !digest.test(p.sha256) + || !Number.isSafeInteger(p.createdAt) || p.createdAt <= 0 || !Number.isSafeInteger(p.expiresAt) || p.expiresAt <= p.createdAt || p.expiresAt - p.createdAt > 900000) throw invalid(); + return { id: p.id, sha256: p.sha256, createdAt: p.createdAt, expiresAt: p.expiresAt }; +} +export function validateResult(value: unknown): RepairResult { + const r = value as RepairResult; + if (!r || !['APPLIED', 'NOT_APPLIED', 'INELIGIBLE', 'UNKNOWN'].includes(r.state) + || (r.state === 'APPLIED' ? typeof r.afterSha256 !== 'string' || !digest.test(r.afterSha256) : r.afterSha256 !== null)) throw invalid(); + return { state: r.state, afterSha256: r.afterSha256 }; +} +export function restorePending(raw: string): PendingRepair { + if (raw.length > 4096) throw invalid(); + const parsed = JSON.parse(raw); + return { target: validateTarget(parsed?.target), prepared: validatePrepared(parsed?.prepared) }; +} +export async function repairAvailable(signal: AbortSignal): Promise { + try { + const response = await api.get('/admin/verification/repairs/capabilities', { signal }); + if (response.data?.action !== 'ISOLATE_LOCAL_REVIEW') throw invalid(); + return true; + } catch (error) { + if ((error as { response?: { status?: number } })?.response?.status === 404) return false; + throw error; + } +} +export async function inspectRepair(target: RepairTarget, signal: AbortSignal): Promise { + const expected = validateTarget(target); + const { data } = await api.post('/admin/verification/repairs/inspect', expected, { signal }); + const returned = validateTarget(data); + if (JSON.stringify(returned) !== JSON.stringify(expected) || typeof data.sha256 !== 'string' || !digest.test(data.sha256) + || typeof data.eligible !== 'boolean' || data.effect !== 'ISOLATE_LOCAL_REVIEW') throw invalid(); + return { ...returned, sha256: data.sha256, eligible: data.eligible, effect: data.effect }; +} +export async function prepareRepair(preview: RepairPreview, id: string, signal: AbortSignal): Promise { + if (!uuid.test(id) || !preview.eligible || !digest.test(preview.sha256)) throw invalid(); + const { data } = await api.post('/admin/verification/repairs/prepare', { ...validateTarget(preview), id, expectedSha256: preview.sha256 }, { signal }); + const prepared = validatePrepared(data); + if (prepared.id !== id || prepared.sha256 !== preview.sha256) throw invalid(); + return prepared; +} +export async function executeRepair(prepared: PreparedRepair, signal: AbortSignal): Promise { + const config = { signal, skipCsrfRetry: true }; + return validateResult((await api.post('/admin/verification/repairs/execute', validatePrepared(prepared), config)).data); +} +export async function repairReceipt(prepared: PreparedRepair, target: RepairTarget, signal: AbortSignal): Promise { + const expected = validateTarget(target); + const { data } = await api.post('/admin/verification/repairs/receipt', validatePrepared(prepared), { signal }); + if (JSON.stringify(validateTarget(data)) !== JSON.stringify(expected) || data.beforeSha256 !== prepared.sha256) throw invalid(); + return validateResult(data); +} + +export type RepairOperation = { id: string; recordedState: 'RESERVED' | 'EXECUTING' | 'UNKNOWN' | 'APPLIED' | 'NOT_APPLIED' }; +export type RepairOperationPage = { items: RepairOperation[]; nextCursor: string | null; order: 'OPERATION_ID' }; +export type RecoveredRepair = PendingRepair & { result: RepairResult }; +export async function repairOperations(cursor: string | null, signal: AbortSignal): Promise { + if (cursor !== null && !/^r1\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(cursor)) throw invalid(); + const { data } = await api.get('/admin/verification/repairs/operations', { params: { ...(cursor === null ? {} : { cursor }), limit: 25 }, signal }); + if (!data || data.order !== 'OPERATION_ID' || !Array.isArray(data.items) || data.items.length > 25) throw invalid(); + let previous = cursor?.slice(3) ?? ''; + const items: RepairOperation[] = data.items.map((item: RepairOperation) => { + if (!item || typeof item.id !== 'string' || !uuid.test(item.id) || item.id <= previous || !['RESERVED', 'EXECUTING', 'UNKNOWN', 'APPLIED', 'NOT_APPLIED'].includes(item.recordedState)) throw invalid(); + previous = item.id; + return { id: item.id, recordedState: item.recordedState }; + }); + if (data.nextCursor !== null && (items.length !== 25 || data.nextCursor !== `r1.${previous}`)) throw invalid(); + return { items, nextCursor: data.nextCursor, order: 'OPERATION_ID' }; +} +export async function recoverRepair(id: string, signal: AbortSignal): Promise { + if (!uuid.test(id)) throw invalid(); + const { data } = await api.get(`/admin/verification/repairs/operations/${id}`, { signal }); + const prepared = validatePrepared(data?.prepared); const target = validateTarget(data?.receipt); const result = validateResult(data?.receipt); + if (prepared.id !== id || data.receipt.beforeSha256 !== prepared.sha256 || result.state === 'INELIGIBLE') throw invalid(); + return { prepared, target, result }; +} + +export async function closeExpiredRepair(pending: PendingRepair, signal: AbortSignal): Promise { + const prepared = validatePrepared(pending.prepared); const target = validateTarget(pending.target); + const config = { signal, skipCsrfRetry: true }; + const { data } = await api.post('/admin/verification/repairs/close-expired', prepared, config); + if (JSON.stringify(validateTarget(data)) !== JSON.stringify(target) || data.beforeSha256 !== prepared.sha256) throw invalid(); + const result = validateResult(data); if (result.state === 'INELIGIBLE') throw invalid(); + return result; +} diff --git a/frontend/src/modules/admin/components/ProjectManagement.tsx b/frontend/src/modules/admin/components/ProjectManagement.tsx index 537c352c6..cd650707e 100644 --- a/frontend/src/modules/admin/components/ProjectManagement.tsx +++ b/frontend/src/modules/admin/components/ProjectManagement.tsx @@ -2,6 +2,7 @@ import { SkeletonSurface } from '@/components/ui/Skeleton'; import React, { useState, useEffect, useRef } from 'react'; import { Package, Search, Trash2, EyeOff, Clock, AlertTriangle, ArrowRight, Hash, Terminal, Download, RotateCcw, Code, X, FileJson, Lock } from 'lucide-react'; import { adminClient } from '../api/adminClient'; +import { projectMetadataForRepair } from '../utils/projectMetadataRepair'; import { API_BASE_URL, extractApiErrorMessage } from '@/utils/api'; import { AdminPermission, hasAdminPermission } from '../utils/access'; import type { Project, ScanIssue } from '@/types'; @@ -31,6 +32,7 @@ export function ProjectManagement({ setStatus, currentAdmin: initialAdmin }: { s const [showRawModal, setShowRawModal] = useState(false); const [rawJsonStr, setRawJsonStr] = useState(''); + const [rawReviewToken, setRawReviewToken] = useState(null); const [jsonError, setJsonError] = useState(null); useEffect(() => { @@ -138,7 +140,12 @@ export function ProjectManagement({ setStatus, currentAdmin: initialAdmin }: { s const openRawEdit = () => { if (!canEditRaw) return; - setRawJsonStr(JSON.stringify(foundProject, null, 2)); + if (!foundProject?.reviewToken) { + setStatus({ type: 'error', title: 'Refresh required', msg: 'Reload this project before editing its metadata.' }); + return; + } + setRawReviewToken(foundProject.reviewToken); + setRawJsonStr(JSON.stringify(projectMetadataForRepair(foundProject), null, 2)); setJsonError(null); setShowRawModal(true); }; @@ -178,14 +185,15 @@ export function ProjectManagement({ setStatus, currentAdmin: initialAdmin }: { s const saveRawEdit = async () => { if (!foundProject) return; - if (!canEditRaw) return; + if (!canEditRaw || !rawReviewToken) return; setLoading(true); try { const parsed = JSON.parse(rawJsonStr); - await adminClient.updateProjectRaw(foundProject.id, parsed); + await adminClient.updateProjectRaw(foundProject.id, parsed, rawReviewToken); setStatus({ type: 'success', title: 'Saved', msg: 'Raw project metadata updated successfully.' }); setShowRawModal(false); - setFoundProject(parsed); + setFoundProject(null); + setRawReviewToken(null); } catch (e: any) { setStatus({ type: 'error', @@ -275,7 +283,7 @@ export function ProjectManagement({ setStatus, currentAdmin: initialAdmin }: { s
{foundProject.status === 'DELETED' ? ( @@ -334,7 +342,7 @@ export function ProjectManagement({ setStatus, currentAdmin: initialAdmin }: { s
-

JSON Editor

Editing project: {foundProject.id}

+

Metadata Editor

Review and release changes use their dedicated actions. Project: {foundProject.id}

diff --git a/frontend/src/modules/admin/components/ReviewCancellationPanel.tsx b/frontend/src/modules/admin/components/ReviewCancellationPanel.tsx new file mode 100644 index 000000000..b8b510bdb --- /dev/null +++ b/frontend/src/modules/admin/components/ReviewCancellationPanel.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from 'react'; +import { cancellationAvailable, previewCancellation, prepareCancellation, executeCancellation, recoverCancellation, checkCancellation, cancellationCheckReceipt, restoreCancellation, saveCancellation, cancellationStorageKey, type CancellationPreview, type CancellationIntent, type CancellationReceipt, type CheckReceipt, type CancellationObservation } from '../api/reviewCancellation'; + +type Props = { subject: string; isolationId: string }; +export function ReviewCancellationPanel(props: Props) { return ; } +function Panel({ subject, isolationId }: Props) { + const owner = JSON.stringify([subject, isolationId]); + const [available, setAvailable] = useState(null); + const [preview, setPreview] = useState(null); + const [original, setOriginal] = useState(null); + const [receipt, setReceipt] = useState(null); + const [later, setLater] = useState([]); + const [checkId, setCheckId] = useState(null); + const [confirmed, setConfirmed] = useState(false); + const [corrupt, setCorrupt] = useState(false); + const [message, setMessage] = useState(''); + const [working, setWorking] = useState(false); + const [revision, setRevision] = useState(0); + const request = useRef(null); + const heading = useRef(null); + useEffect(() => { + const controller = new AbortController(); + cancellationAvailable(controller.signal).then(value => { if (!controller.signal.aborted) setAvailable(value); }).catch(() => { if (!controller.signal.aborted) setMessage('Cancellation availability could not be checked. Reopen this operation to retry.'); }); + try { + const raw = sessionStorage.getItem(cancellationStorageKey(owner)); + if (raw) { + const saved = restoreCancellation(raw); if (saved.original.isolationId !== isolationId) throw new Error('Mismatched reference'); + setPreview(saved.preview); setOriginal(saved.original); setCheckId(saved.checkId); + setMessage('A saved reference was found. Recover its original receipt before taking further action.'); + } + } catch { setCorrupt(true); setMessage('The saved cancellation reference could not be verified. Use original receipt recovery; no remote action is enabled.'); } + return () => { controller.abort(); request.current?.abort(); }; + }, [owner, isolationId]); + useEffect(() => { if (revision) heading.current?.focus(); }, [revision]); + async function run(task: (signal: AbortSignal) => Promise) { + if (request.current || available !== true) return; + const controller = new AbortController(); request.current = controller; setWorking(true); setMessage(''); + try { await task(controller.signal); } + catch { if (!controller.signal.aborted) { setConfirmed(false); setMessage('The response could not be verified. Recover the retained receipt; do not repeat the request.'); } } + finally { if (!controller.signal.aborted) { request.current = null; setWorking(false); setRevision(v => v + 1); } } + } + function persist(p: CancellationPreview, intent: CancellationIntent, id: string | null) { saveCancellation(sessionStorage, owner, { preview: p, original: intent, checkId: id }); } + function inspect() { + void run(async signal => { const value = await previewCancellation(isolationId, signal); if (!signal.aborted) { setPreview(value); setMessage('Review the original job before preparing cancellation.'); } }); + } + function prepare() { + if (!preview || original || corrupt) return; + void run(async signal => { + const value = await prepareCancellation(preview, signal); if (signal.aborted) return; + setOriginal(value); persist(preview, value, null); setConfirmed(true); + }); + } + function execute() { + if (!preview || !original || !confirmed || corrupt) return; + setConfirmed(false); + void run(async signal => { + persist(preview, original, checkId); + const value = await executeCancellation(preview, original, signal); if (signal.aborted) return; + setReceipt(value.receipt); setMessage(value.receipt ? '' : `Cancellation outcome: ${value.state}. Recover the original receipt before another action.`); + }); + } + function recover() { + setConfirmed(false); + void run(async signal => { + const fresh = await previewCancellation(isolationId, signal); if (signal.aborted) return; + const value = await recoverCancellation(fresh, signal); if (signal.aborted) return; + if (original && (value.prepared.createdAt !== original.createdAt || value.prepared.expiresAt !== original.expiresAt || value.prepared.targetSha256 !== original.targetSha256)) throw new Error('Changed intent'); + setPreview(fresh); setOriginal(value.prepared); setReceipt(value); + // A corrupt check reference cannot be silently replaced; original recovery remains read-only. + if (!corrupt) persist(fresh, value.prepared, checkId); + }); + } + function retainLater(value: CheckReceipt) { setLater(items => [...items.filter(item => item.id !== value.id), value].slice(-20)); } + function newCheck() { + if (!preview || !original || !receipt || receipt.state === 'PREPARED' || corrupt || checkId || later.length >= 20) return; + void run(async signal => { + const id = crypto.randomUUID(); setCheckId(id); persist(preview, original, id); + const value = await checkCancellation(preview, { id, original }, signal); if (!signal.aborted) retainLater(value); + }); + } + function recoverCheck() { + if (!preview || !original || !checkId || corrupt) return; + void run(async signal => { + const value = await cancellationCheckReceipt(preview, { id: checkId, original }, signal); if (!signal.aborted) retainLater(value); + }); + } + function finishCheck() { + if (!preview || !original || !checkId || !later.some(v => v.id === checkId && ['OBSERVED', 'UNKNOWN'].includes(v.state))) return; + try { persist(preview, original, null); setCheckId(null); setRevision(v => v + 1); } catch { setMessage('The reference could not be saved. Keep using receipt recovery.'); } + } + if (available === false) return null; + const disabled = working || available !== true; + return
+

Original remote review

+

Cancellation concerns the original remote job. It does not approve this mod, replace its review, or prove that work already in flight stopped.

+ {message &&

{message}

}{working &&

Checking original review…

} + {preview &&
+
Original project and version
{JSON.stringify(preview.projectId)} ({preview.projectIdType}), position {preview.versionIndex}, version {preview.versionId}
+
Original job
{preview.jobId}
Original request
{preview.requestId}
+
Artifact SHA-256
{preview.artifactSha256}
Target SHA-256
{preview.targetSha256}
+
} + {!preview && !corrupt && } + {preview && !original && !corrupt && } + {receipt?.state === 'PREPARED' && !confirmed && !corrupt && } + {confirmed &&

Confirm cancellation of the original job shown above. The server checks the signed target and original expiry.

} + + {receipt &&
Original cancellation outcome

{receipt.state}

{receipt.observedAt !== null &&

Receipt retained: {receipt.observedAt} milliseconds since Unix epoch

}
} + {receipt && receipt.state !== 'PREPARED' && !corrupt && !checkId && } + {checkId &&

Saved observation reference: {checkId}

+ {later.some(v => v.id === checkId && ['OBSERVED', 'UNKNOWN'].includes(v.state)) && }
} + {later.length > 0 &&
Later status observations

These separate responses do not replace the original outcome or prove which request caused a state change. Up to 20 observations are shown in this view; server history discovery is not available here.

+
    {later.map(item =>
  • {item.id}: {item.state}{item.receivedAt !== null &&

    Receipt retained: {item.receivedAt} milliseconds since Unix epoch

    }
  • )}
} +
; +} +function Observation({ value }: { value: CancellationObservation | null }) { + if (!value) return

No verified remote observation is retained in this receipt.

; + if (value.kind === 'REMOTE_STATUS') return

Remote job state: {value.status!.state}. Artifact retained: {value.status!.artifactRetained ? 'yes' : 'no'}.

; + return

Observation: {value.kind}{value.httpStatus === null ? '' : ` (HTTP ${value.httpStatus})`}. This does not establish that the job stopped.

; +} diff --git a/frontend/src/modules/admin/components/ReviewOrigins.tsx b/frontend/src/modules/admin/components/ReviewOrigins.tsx new file mode 100644 index 000000000..72b9d6fc7 --- /dev/null +++ b/frontend/src/modules/admin/components/ReviewOrigins.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from 'react'; +import { getOriginPage, originLabels, type OriginPage } from '../api/reviewOrigins'; + +export function ReviewOrigins({ subject }: { subject: string }) { return ; } +function OriginPanel() { + const [open, setOpen] = useState(false); + return
+ + {open && } +
; +} +function OriginContents() { + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const request = useRef(null); + const generation = useRef(0); + const attempted = useRef(null); + const heading = useRef(null); + const failure = useRef(null); + const [navigation, setNavigation] = useState({ revision: 0, failed: false }); + async function load(cursor: string | null, focus = true) { + request.current?.abort(); const controller = new AbortController(); request.current = controller; + const epoch = ++generation.current; attempted.current = cursor; setLoading(true); + try { + const result = await getOriginPage(cursor, controller.signal); + if (generation.current !== epoch) return; + setPage(result); setError(false); + if (focus) setNavigation(n => ({ revision: n.revision + 1, failed: false })); + } catch { + if (generation.current !== epoch || controller.signal.aborted) return; + setError(true); + if (focus) setNavigation(n => ({ revision: n.revision + 1, failed: true })); + } finally { if (generation.current === epoch) { request.current = null; setLoading(false); } } + } + useEffect(() => { void load(null, false); return () => { generation.current++; request.current?.abort(); }; }, []); + useEffect(() => { if (navigation.revision) (navigation.failed ? failure.current : heading.current)?.focus(); }, [navigation]); + return
+

Historical review origins

+

Inspect retained review references, including failed and completed versions. A recorded service identity does not establish that a mod is safe or that the original job has stopped. Missing origins need operator investigation; the current service cannot establish a historical origin.

+

This view does not change review state. Deleted versions and references retained only in repair archives are outside this inventory; use repair operation history for known isolation records.

+ {error &&
+

We could not load this origin page. Any entries below are from the last successful load.

+ +
} +
+ + + {loading ? 'Loading review origins…' : page ? `${page.items.length} retained references · ${page.examinedSlots} version slots examined on this page` : 'Origin inventory unavailable'} +
+ {page && !page.items.length &&

{page.nextCursor ? 'No retained references in these slots. Continue to the next page.' : 'No retained references on this page. Refresh from start to check for changes.'}

} + {page && page.items.length > 0 &&
    + {page.items.map(item =>
  • +

    {originLabels[item.originState]}

    +

    Project record: {JSON.stringify(item.position.projectId)} ({item.position.projectIdType === 'STRING' ? 'string ID' : 'ObjectId'})

    +

    Stored version position: {item.position.versionIndex} (zero-based)

    +

    Version: {item.versionId ?? 'Identifier unavailable'}

    + {item.ambiguousVersion &&

    Version identity is ambiguous. An operator must inspect the stored record before any action.

    } +

    Original request: {item.requestId ?? 'Unavailable'}

    +

    Original job: {item.jobId ?? 'Unavailable'}

    +
  • )} +
} +
; +} diff --git a/frontend/src/modules/admin/components/ReviewRepairHistory.tsx b/frontend/src/modules/admin/components/ReviewRepairHistory.tsx new file mode 100644 index 000000000..b75929e11 --- /dev/null +++ b/frontend/src/modules/admin/components/ReviewRepairHistory.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState } from 'react'; +import { ReviewCancellationPanel } from './ReviewCancellationPanel'; +import { closeExpiredRepair, recoverRepair, repairOperations, type RecoveredRepair, type RepairOperationPage } from '../api/reviewRepair'; + +export function ReviewRepairHistory({ subject }: { subject?: string }) { + const [page, setPage] = useState(null); + const [receipt, setReceipt] = useState(null); + const [loading, setLoading] = useState(false); + const [confirmClose, setConfirmClose] = useState(false); + const [message, setMessage] = useState(''); + const [revision, setRevision] = useState(0); + const request = useRef(null); + const heading = useRef(null); + useEffect(() => () => { request.current?.abort(); }, []); + useEffect(() => { if (revision) heading.current?.focus(); }, [revision]); + async function run(task: (signal: AbortSignal) => Promise, accept: (value: T) => void) { + if (request.current) return; + const controller = new AbortController(); request.current = controller; setLoading(true); setMessage(''); setReceipt(null); setConfirmClose(false); + try { const value = await task(controller.signal); if (!controller.signal.aborted) accept(value); } + catch { if (!controller.signal.aborted) setMessage('The operation receipt could not be verified. The outcome remains unconfirmed; check the receipt again.'); } + finally { if (!controller.signal.aborted) { request.current = null; setLoading(false); setRevision(value => value + 1); } } + } + function load(cursor: string | null) { + if (request.current) return; + setPage(null); void run(signal => repairOperations(cursor, signal), setPage); + } + return
+

Your repair operation history

+

Find submitted operations even if their browser-tab reference was lost. References are ordered by ID, not date. Refresh to include new operations. Opening a reference only checks its historical receipt.

+ + {loading &&

Checking operation history…

} + {message &&

{message}

} + {page && <> + {page.items.length === 0 &&

No submitted operations found on this page.

} +
    {page.items.map(item =>
  • + + — Recorded state: {item.recordedState}. Verify the receipt for the outcome. +
  • )}
+ {page.nextCursor && } + } + {receipt &&
+

Operation reference: {receipt.prepared.id}

+

Original project {JSON.stringify(receipt.target.position.projectId)} ({receipt.target.position.projectIdType}), position {receipt.target.position.versionIndex}, version {receipt.target.versionId}.

+

Original record SHA-256: {receipt.prepared.sha256}

+ {receipt.result.state === 'UNKNOWN' &&
+

An expired attempt can be closed without repeating isolation. The server checks expiry; a committed isolation result is preserved. This does not approve the mod or cancel its remote job.

+ {!confirmClose ? : <> +

Confirm closing operation {receipt.prepared.id}. Closing a pending attempt prevents it from applying isolation. Any completed result stays unchanged. Refresh diagnostics before considering another action.

+ + + } +
} +

{receipt.result.state === 'APPLIED' ? 'The receipt confirms local isolation was applied. It does not establish the current version state or approve the mod.' : receipt.result.state === 'NOT_APPLIED' ? 'The receipt confirms isolation was not applied.' : 'The outcome remains unconfirmed. Do not repeat isolation. An expired attempt may be closed; otherwise check its receipt again.'}

+
} + {subject && receipt?.result.state === 'APPLIED' && } +
; +} diff --git a/frontend/src/modules/admin/components/ReviewRepairPanel.tsx b/frontend/src/modules/admin/components/ReviewRepairPanel.tsx new file mode 100644 index 000000000..2a11bf8fa --- /dev/null +++ b/frontend/src/modules/admin/components/ReviewRepairPanel.tsx @@ -0,0 +1,97 @@ +import { useEffect, useRef, useState } from 'react'; +import { ReviewRepairHistory } from './ReviewRepairHistory'; +import { executeRepair, inspectRepair, prepareRepair, repairAvailable, repairReceipt, restorePending, type PreparedRepair, type RepairPreview, type RepairResult, type RepairTarget } from '../api/reviewRepair'; + +type RepairPanelProps = { subject: string; selected: RepairTarget | null; onReady: (ready: boolean) => void; onLock: (locked: boolean) => void; onDismiss: () => void }; +export function ReviewRepairPanel(props: RepairPanelProps) { return ; } +function RepairPanel({ subject, selected, onReady, onLock, onDismiss }: RepairPanelProps) { + const [available, setAvailable] = useState(null); + const [availabilityError, setAvailabilityError] = useState(false); + const [target, setTarget] = useState(null); + const [preview, setPreview] = useState(null); + const [prepared, setPrepared] = useState(null); + const [result, setResult] = useState(null); + const [stage, setStage] = useState('idle'); + const [message, setMessage] = useState(''); + const request = useRef(null); + const epoch = useRef(0); const busy = useRef(false); const prepareId = useRef(''); + const heading = useRef(null); + const storageKey = `review-repair:${encodeURIComponent(subject)}`; + const terminal = result !== null && result.state !== 'UNKNOWN'; + const working = ['inspecting', 'preparing', 'executing', 'checking'].includes(stage); + const locked = working || stage === 'corrupt' || (prepared !== null && !terminal); + + useEffect(() => { onReady(available === true); }, [available, onReady]); + useEffect(() => { onLock(locked); }, [locked, onLock]); + useEffect(() => { + if (['preview', 'ready', 'result', 'error', 'corrupt'].includes(stage)) heading.current?.focus(); + }, [stage]); + useEffect(() => { + const controller = new AbortController(); let current = true; + repairAvailable(controller.signal).then(value => { if (current) setAvailable(value); }).catch(() => { if (current) setAvailabilityError(true); }); + try { + const raw = sessionStorage.getItem(storageKey); + if (raw) { const pending = restorePending(raw); setTarget(pending.target); setPrepared(pending.prepared); setResult({ state: 'UNKNOWN', afterSha256: null }); setStage('result'); } + } catch { setStage('corrupt'); setMessage('The saved operation reference could not be read. Resolve it before starting another repair.'); } + return () => { current = false; controller.abort(); epoch.current++; request.current?.abort(); }; + }, [storageKey]); + async function run(task: (signal: AbortSignal) => Promise, accept: (value: T) => void, fail: () => void) { + if (busy.current) return; + busy.current = true; request.current?.abort(); const controller = new AbortController(); request.current = controller; const generation = ++epoch.current; + try { const value = await task(controller.signal); if (epoch.current === generation && !controller.signal.aborted) accept(value); } + catch { if (epoch.current === generation && !controller.signal.aborted) fail(); } + finally { if (epoch.current === generation) { busy.current = false; request.current = null; } } + } + useEffect(() => { + if (!selected || available !== true || locked) return; + setTarget(selected); setPreview(null); setPrepared(null); setResult(null); setMessage(''); prepareId.current = ''; setStage('inspecting'); + void run(signal => inspectRepair(selected, signal), value => { setPreview(value); setStage('preview'); }, () => { setMessage('The current record could not be inspected. Refresh diagnostics before trying again.'); setStage('error'); }); + // Selection is deliberate; availability and state changes must never restart an inspection. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected]); + function acceptResult(value: RepairResult) { + setResult(value); setStage('result'); setMessage(''); + if (value.state !== 'UNKNOWN') { try { sessionStorage.removeItem(storageKey); } catch { /* A retained reference remains safe to look up again. */ } } + } + function prepare() { + if (!preview?.eligible || busy.current) return; + prepareId.current ||= crypto.randomUUID(); setStage('preparing'); setMessage(''); + void run(signal => prepareRepair(preview, prepareId.current, signal), value => { setPrepared(value); setStage('ready'); }, () => { setMessage('Preparation could not be confirmed. No isolation was submitted. You can retry preparation using this inspection.'); setStage('preview'); }); + } + function execute() { + if (!prepared || !target || busy.current || stage !== 'ready') return; + try { + const saved = JSON.stringify({ target, prepared }); sessionStorage.setItem(storageKey, saved); + if (sessionStorage.getItem(storageKey) !== saved) throw new Error(); + } catch { setMessage('The operation reference could not be saved. Isolation has not started.'); return; } + setStage('executing'); setMessage(''); + void run(signal => executeRepair(prepared, signal), acceptResult, () => acceptResult({ state: 'UNKNOWN', afterSha256: null })); + } + function checkReceipt() { + if (!prepared || !target || busy.current || available !== true) return; + setStage('checking'); setMessage(''); + void run(signal => repairReceipt(prepared, target, signal), acceptResult, () => { setResult({ state: 'UNKNOWN', afterSha256: null }); setMessage('The receipt is unavailable. The outcome remains unconfirmed.'); setStage('result'); }); + } + function dismiss() { setTarget(null); setPreview(null); setPrepared(null); setResult(null); setStage('idle'); setMessage(''); onDismiss(); } + return
+ {availabilityError &&

Repair availability could not be checked. Reopen diagnostics to try again.

} + {available === false &&

Repair tools are not enabled on this server.

} + {(target || stage === 'corrupt') &&
+

Local review isolation

+ {target &&

Project {JSON.stringify(target.position.projectId)} ({target.position.projectIdType}), position {target.position.versionIndex}, version {target.versionId}

} +

Isolation moves this version to review service attention. The version remains pending, findings and blocking decisions are preserved, and the original record is archived. It does not approve the mod, cancel its remote job, or start a new scan.

+ {preview &&

Inspected record SHA-256: {preview.sha256}

} + {prepared && <>

Operation reference: {prepared.id}

Prepared until {new Date(prepared.expiresAt).toLocaleString()}.

} + {working &&

{stage === 'executing' ? 'Submitting isolation… Closing this view does not cancel a submitted operation.' : stage === 'checking' ? 'Checking the operation receipt…' : 'Inspecting or preparing the record…'}

} + {message &&

{message}

} + {stage === 'preview' && (preview?.eligible ? : preview &&

This record is not eligible for local isolation. An operator must resolve its remaining state.

)} + {stage === 'ready' && <>

Confirm isolation of the exact inspected record. If it has changed, the server will refuse the change.

} + {result && stage === 'result' &&
+ {result.state === 'APPLIED' ? 'Local review isolated. Refresh diagnostics and the review queue to see its current state.' : result.state === 'UNKNOWN' ? 'The outcome is unconfirmed. Check the receipt; do not submit another isolation for this record. This operation reference is saved in this browser tab.' : 'Isolation was not applied. Refresh diagnostics and inspect the current record.'} +
} + {result?.state === 'UNKNOWN' && } + {!working && stage !== 'corrupt' && result?.state !== 'UNKNOWN' && } +
} + {available === true && } +
; +} diff --git a/frontend/src/modules/admin/components/ReviewStateDiagnostics.tsx b/frontend/src/modules/admin/components/ReviewStateDiagnostics.tsx new file mode 100644 index 000000000..6e552e862 --- /dev/null +++ b/frontend/src/modules/admin/components/ReviewStateDiagnostics.tsx @@ -0,0 +1,78 @@ +import { ReviewRepairPanel } from './ReviewRepairPanel'; +import type { RepairTarget } from '../api/reviewRepair'; +import { useEffect, useRef, useState } from 'react'; +import { diagnosticReasons, getDiagnosticPage, type DiagnosticPage } from '../api/reviewDiagnostics'; + +export function ReviewStateDiagnostics({ subject, canRepair = false }: { subject: string; canRepair?: boolean }) { + return ; +} +function DiagnosticPanel({ subject, canRepair }: { subject: string; canRepair: boolean }) { + const [open, setOpen] = useState(false); + return
+ + {open && } +
; +} +function DiagnosticContents({ subject, canRepair }: { subject: string; canRepair: boolean }) { + const [selected, setSelected] = useState(null); + const [repairAvailable, setRepairAvailable] = useState(false); + const [repairLocked, setRepairLocked] = useState(false); + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const request = useRef(null); + const generation = useRef(0); + const attempted = useRef(null); + const heading = useRef(null); + const failure = useRef(null); + const [navigation, setNavigation] = useState({ revision: 0, failed: false }); + async function load(cursor: string | null, focus = true) { + request.current?.abort(); const controller = new AbortController(); request.current = controller; + const epoch = ++generation.current; attempted.current = cursor; setLoading(true); + try { + const result = await getDiagnosticPage(cursor, controller.signal); + if (generation.current !== epoch) return; + setPage(result); setError(false); + if (focus) setNavigation(n => ({ revision: n.revision + 1, failed: false })); + } catch { + if (generation.current !== epoch || controller.signal.aborted) return; + setError(true); + if (focus) setNavigation(n => ({ revision: n.revision + 1, failed: true })); + } finally { + if (generation.current === epoch) { request.current = null; setLoading(false); } + } + } + useEffect(() => { + void load(null, false); + return () => { generation.current++; request.current?.abort(); request.current = null; }; + }, []); + useEffect(() => { + if (navigation.revision) (navigation.failed ? failure.current : heading.current)?.focus(); + }, [navigation]); + return
+

Scan state diagnostics

+

Find stored scan records that need operator attention. These checks examine review state, not whether a mod is safe. Record positions can change; an operator must recheck current state before repairing it.

+ {canRepair && setSelected(null)} />} + {error &&
+

We could not load this diagnostic page. Any entries below are from the last successful load.

+ +
} +
+ + + {loading ? 'Loading diagnostics…' : page ? `${page.items.length} diagnostic entries · ${page.examinedSlots} version slots examined on this page` : 'Diagnostics unavailable'} +
+ {page && !page.items.length &&

{page.nextCursor ? 'No structural problems found in these slots. Continue to the next page.' : 'No diagnostic entries on this page. Refresh from start to check for changes.'}

} + {page && page.items.length > 0 &&
    + {page.items.map(item =>
  • +

    Project record: {JSON.stringify(item.position.projectId)} ({item.position.projectIdType === 'STRING' ? 'string ID' : 'ObjectId'})

    +

    Stored version position: {item.position.versionIndex} (zero-based)

    +

    Version: {item.versionId ?? 'Identifier unavailable'}

    + {canRepair && repairAvailable && } +
      {item.reasons.map(reason =>
    • {diagnosticReasons[reason]}
    • )}
    +
  • )} +
} +
; +} diff --git a/frontend/src/modules/admin/components/VerificationQueue.tsx b/frontend/src/modules/admin/components/VerificationQueue.tsx index ea617d655..64ba5381c 100644 --- a/frontend/src/modules/admin/components/VerificationQueue.tsx +++ b/frontend/src/modules/admin/components/VerificationQueue.tsx @@ -8,14 +8,17 @@ interface VerificationQueueProps { loadingQueue: boolean; loadFailed?: boolean; loadingReview: boolean; + hasMore?: boolean; + unavailableItems?: number; + loaded?: boolean; reviewingId?: string; - onReview: (id: string) => void; + onReview: (id: string, versionId?: string) => void; } export const VerificationQueue: React.FC = ({ - pendingProjects, loadingQueue, loadFailed, loadingReview, reviewingId, onReview + pendingProjects, loadingQueue, loadFailed, loadingReview, reviewingId, onReview, hasMore = false, unavailableItems = 0, loaded = true }) => { - const Surface = loadingQueue ? SkeletonSurface : React.Fragment; + const Surface = loadingQueue && pendingProjects.length === 0 ? SkeletonSurface : React.Fragment; if (loadingQueue && pendingProjects.length === 0) { pendingProjects = Array.from({ length: 3 }, (_, index) => ({ id: `pending-${index}`, title: 'Project review title', author: 'Creator name', @@ -28,14 +31,17 @@ export const VerificationQueue: React.FC = ({ return null; } + if (pendingProjects.length === 0 && (hasMore || unavailableItems > 0 || !loaded)) { + return

{hasMore ? 'No openable entries on this page. Continue to the next page.' : unavailableItems > 0 ? 'Some entries require data repair before they can be reviewed.' : 'Queue has not loaded yet.'}

; + } if (pendingProjects.length === 0) { return (
-

All Caught Up!

-

No projects or versions pending verification.

+

No entries on this page

+

Refresh from the start to check for new or changed reviews.

); } @@ -44,7 +50,8 @@ export const VerificationQueue: React.FC = ({ const isProjectPending = mod.status === 'PENDING'; const targetVersion = mod.pendingVersion; const scan = targetVersion?.scan; - const hasIssues = scan && scan.status !== 'CLEAN'; + const needsService = scan?.status === 'FAILED'; + const hasIssues = scan && scan.status !== 'CLEAN' && !needsService; const newIssues = scan?.newIssueCount || 0; const knownIssues = scan?.knownIssueCount || 0; const escalatedIssues = scan?.escalatedIssueCount || 0; @@ -52,7 +59,7 @@ export const VerificationQueue: React.FC = ({ const risk = scan?.riskScore || 0; return ( -
+
e.currentTarget.src = '/assets/favicon.svg'} /> {isProjectPending && ( @@ -77,6 +84,7 @@ export const VerificationQueue: React.FC = ({
+ {isProjectPending && targetVersion &&

Version {targetVersion.versionNumber}

} {isProjectPending ? (

{mod.description}

) : ( @@ -91,13 +99,17 @@ export const VerificationQueue: React.FC = ({
+ {needsService &&
+ {scan?.scanState === 'REMOTE_ORIGIN_UNVERIFIED' ? 'Original review service unverified' : scan?.scanState === 'REMOTE_CONTEXT_CONFLICT' ? 'Review service context conflict' : scan?.scanState === 'REMOTE_ISOLATED' ? 'Local review isolated' : ['REMOTE_BINDING_MISSING', 'REMOTE_BINDING_MISMATCH'].includes(scan?.scanState || '') ? 'Review state needs repair' : scan?.scanState === 'REMOTE_UNSUPPORTED_CONTEXT' ? 'Review context unsupported' : scan?.scanState === 'REMOTE_EXPIRED' ? 'Review expired' : scan?.scanState === 'REMOTE_CANCELLED' ? 'Review cancelled' : scan?.scanState === 'REMOTE_HELD' ? 'Review held' : 'Review unavailable'} + Security clearance withheld +
} {scan?.status === 'SCANNING' && (
@@ -126,7 +138,15 @@ export const VerificationQueue: React.FC = ({ return (
- {pendingProjects.map(renderQueueItem)} + {pendingProjects.some(mod => mod.pendingVersion?.scan?.status !== 'FAILED') &&
+

Content and security review

+ {pendingProjects.filter(mod => mod.pendingVersion?.scan?.status !== 'FAILED').map(renderQueueItem)} +
} + {pendingProjects.some(mod => mod.pendingVersion?.scan?.status === 'FAILED') &&
+

Review service attention

+

These security reviews did not complete. Inspect the failure before requesting another scan. Existing findings still require review.

+ {pendingProjects.filter(mod => mod.pendingVersion?.scan?.status === 'FAILED').map(renderQueueItem)} +
}
); }; diff --git a/frontend/src/modules/admin/hooks/useModerationQueue.ts b/frontend/src/modules/admin/hooks/useModerationQueue.ts new file mode 100644 index 000000000..3d74ccabd --- /dev/null +++ b/frontend/src/modules/admin/hooks/useModerationQueue.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getModerationQueuePage, type QueuePage, type QueueFilter } from '../api/moderationQueue'; +import { extractApiErrorMessage } from '@/utils/api'; +const empty: QueuePage = { items: [], nextCursor: null, unavailableItems: 0, order: 'PROJECT_VERSION' }; +export function useModerationQueue(enabled: boolean, subject: string, filter: QueueFilter = 'ALL') { + const [page, setPage] = useState(empty); + const [pageSubject, setPageSubject] = useState(subject); + const [pageFilter, setPageFilter] = useState(filter); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [loaded, setLoaded] = useState(false); + const [navigation, setNavigation] = useState({ revision: 0, target: 'page' as 'page' | 'error' }); + const access = useRef({ enabled, subject, filter }); access.current = { enabled, subject, filter }; + const generation = useRef(0); + const request = useRef(null); + const cursor = useRef(null); + const nextCursor = useRef(null); + const attemptedCursor = useRef(null); + const failed = useRef(false); + const load = useCallback(async (position: string | null, background = false, focus = false) => { + if (!access.current.enabled || (background && (request.current || failed.current))) return; + request.current?.abort(); const controller = new AbortController(); request.current = controller; + const epoch = ++generation.current; const owner = access.current.subject; const selectedFilter = access.current.filter; + attemptedCursor.current = position; + setLoading(true); + const current = () => generation.current === epoch && access.current.enabled && access.current.subject === owner && access.current.filter === selectedFilter; + try { + const response = await getModerationQueuePage(position, controller.signal, selectedFilter); + if (!current()) return; + cursor.current = position; nextCursor.current = response.nextCursor; + setPage(response); setPageSubject(owner); setPageFilter(selectedFilter); failed.current = false; setError(null); setLoaded(true); + if (focus) setNavigation(previous => ({ revision: previous.revision + 1, target: 'page' })); + } catch (failure) { + if (current() && !controller.signal.aborted) { failed.current = true; setError(extractApiErrorMessage(failure, 'We could not load this queue page.')); + if (focus) setNavigation(previous => ({ revision: previous.revision + 1, target: 'error' })); + } + } finally { + if (generation.current === epoch) { request.current = null; setLoading(false); } + } + }, []); + useEffect(() => { + generation.current++; request.current?.abort(); request.current = null; + cursor.current = null; nextCursor.current = null; attemptedCursor.current = null; failed.current = false; + setPage(empty); setPageSubject(subject); setPageFilter(filter); setError(null); setLoaded(false); setLoading(false); setNavigation({ revision: 0, target: 'page' }); + if (enabled) void load(null); + return () => { generation.current++; request.current?.abort(); request.current = null; }; + }, [enabled, subject, filter, load]); + const refresh = useCallback((background = false) => load(cursor.current, background), [load]); + const restart = useCallback(() => load(null, false, true), [load]); + const next = useCallback(() => { if (!request.current && nextCursor.current) void load(nextCursor.current, false, true); }, [load]); + const retry = useCallback(() => load(attemptedCursor.current, false, true), [load]); + const visible = enabled && pageSubject === subject && pageFilter === filter; + return { page: visible ? page : empty, loading, error: visible ? error : null, loaded: visible && loaded, refresh, restart, next, retry, navigation }; +} diff --git a/frontend/src/modules/admin/utils/access.ts b/frontend/src/modules/admin/utils/access.ts index d4bb75113..7822f97ff 100644 --- a/frontend/src/modules/admin/utils/access.ts +++ b/frontend/src/modules/admin/utils/access.ts @@ -50,7 +50,7 @@ export const ADMIN_PERMISSION_GROUPS = [ { id: AdminPermission.PROJECT_DELETE, label: 'Delete Projects' }, { id: AdminPermission.PROJECT_RESTORE, label: 'Restore Projects' }, { id: AdminPermission.PROJECT_VERSION_DELETE, label: 'Delete Versions' }, - { id: AdminPermission.PROJECT_RAW_EDIT, label: 'Edit Raw Project JSON' } + { id: AdminPermission.PROJECT_RAW_EDIT, label: 'Repair Project Metadata' } ] }, { diff --git a/frontend/src/modules/admin/utils/projectMetadataRepair.ts b/frontend/src/modules/admin/utils/projectMetadataRepair.ts new file mode 100644 index 000000000..68c6a20c1 --- /dev/null +++ b/frontend/src/modules/admin/utils/projectMetadataRepair.ts @@ -0,0 +1,11 @@ +export const editableProjectMetadata = [ + 'title', 'about', 'description', 'imageUrl', 'bannerUrl', 'repositoryUrl', 'license', 'hmWikiSlug', + 'customLicenseOpenSource', 'allowModpacks', 'allowComments', 'hmWikiEnabled', 'galleryCarouselEnabled', + 'tags', 'galleryImages', 'links', 'galleryImageCaptions', +] as const; + +export function projectMetadataForRepair(project: Partial>): Record { + return Object.fromEntries(editableProjectMetadata + .filter(field => project[field] !== undefined && project[field] !== null) + .map(field => [field, project[field]])); +} diff --git a/frontend/src/modules/admin/views/AdminPanel.tsx b/frontend/src/modules/admin/views/AdminPanel.tsx index 3082284f5..6f7c9204d 100644 --- a/frontend/src/modules/admin/views/AdminPanel.tsx +++ b/frontend/src/modules/admin/views/AdminPanel.tsx @@ -1,3 +1,5 @@ +import { ReviewOrigins } from '../components/ReviewOrigins'; +import { ReviewStateDiagnostics } from '../components/ReviewStateDiagnostics'; import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Shield, Users, LayoutDashboard, ShieldAlert, Package, Activity, FileText, CalendarClock } from 'lucide-react'; import { adminClient } from '../api/adminClient'; @@ -12,7 +14,8 @@ import { PlatformAnalytics } from '../components/PlatformAnalytics'; import { AuditLogs } from '../components/AuditLogs'; import { StatusIncidents } from '../components/StatusIncidents'; import { AdminPermission, hasAdminPermission, hasAnyAdminPermission, isAdminUser } from '../utils/access'; -import type { AdminVerificationQueueItem } from '@/types'; +import { useModerationQueue } from '../hooks/useModerationQueue'; +import type { QueueFilter } from '../api/moderationQueue'; interface AdminPanelProps { currentUser: any; @@ -21,14 +24,10 @@ interface AdminPanelProps { type AdminTab = 'users' | 'verification' | 'reports' | 'projects' | 'analytics' | 'logs' | 'status'; export function AdminPanel({ currentUser }: AdminPanelProps) { + const [queueFilter, setQueueFilter] = useState('ALL'); const [activeTab, setActiveTab] = useState('verification'); const [status, setStatus] = useState(null); - const [pendingProjects, setPendingProjects] = useState([]); - const [loadingQueue, setLoadingQueue] = useState(false); - const [queueError, setQueueError] = useState(null); - const queueRequestInFlight = useRef(false); - const [reviewingProject, setReviewingProject] = useState(null); const [loadingReview, setLoadingReview] = useState(false); const [loadingReviewId, setLoadingReviewId] = useState(); @@ -83,16 +82,20 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { canUseProjectManagement, canUseUserManagement ]); + const queue = useModerationQueue(canReadReviewQueue, currentUser?.id || currentUser?.username || '', queueFilter); + const pendingProjects = queue.page.items; + const loadingQueue = queue.loading; + const queueError = queue.error; + const fetchQueue = queue.refresh; + const queueHeading = useRef(null); + const queueFailure = useRef(null); + useEffect(() => { + if (queue.navigation.revision === 0 || !canReadReviewQueue || activeTab !== 'verification' || reviewingProject) return; + (queue.navigation.target === 'error' ? queueFailure.current : queueHeading.current)?.focus(); + }, [queue.navigation, canReadReviewQueue, activeTab, reviewingProject]); const firstAllowedTab = (Object.keys(tabAccess) as AdminTab[]).find(tab => tabAccess[tab]); useEffect(() => { - if (canReadReviewQueue) { - fetchQueue(); - } else { - setPendingProjects([]); - setQueueError(null); - } - if (canReadReports) { fetchReports(); } else { @@ -120,23 +123,6 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { } }, [activeTab, firstAllowedTab, isAdmin, tabAccess]); - const fetchQueue = async (background = false) => { - if (!canReadReviewQueue || queueRequestInFlight.current) return; - queueRequestInFlight.current = true; - if (!background) setLoadingQueue(true); - try { - const data = await adminClient.getVerificationQueue(); - if (!Array.isArray(data)) throw new Error('The verification queue returned an invalid response.'); - setPendingProjects(data); - setQueueError(null); - } catch (e) { - setQueueError(extractApiErrorMessage(e, 'We could not load the verification queue.')); - } finally { - queueRequestInFlight.current = false; - if (!background) setLoadingQueue(false); - } - }; - const fetchReports = async () => { if (!canReadReports) return; setLoadingReports(true); @@ -151,13 +137,19 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { } }; - const fetchProjectDetails = async (id: string) => { + const fetchProjectDetails = async (id: string, versionId?: string) => { if (loadingReview) return; setLoadingReview(true); setLoadingReviewId(id); try { const data = await adminClient.getReviewDetails(id); - setReviewingProject(data); + if (versionId) { + const selected = data.mod.versions.filter((version: any) => version.id === versionId); + if (selected.length !== 1 || selected[0].reviewStatus !== 'PENDING' || selected[0].scanResult?.status === 'SCANNING') { + throw new Error('This version is no longer ready for review. Refresh the queue.'); + } + } + setReviewingProject({ ...data, selectedVersionId: versionId }); } catch (e) { setStatus({ type: 'error', title: 'Error', msg: extractApiErrorMessage(e, "We could not load this project's review details.") }); } finally { @@ -300,25 +292,42 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { {activeTab === 'verification' && canReadReviewQueue && (
-

Verification Queue

-

Review pending projects and updates.

+

Verification Queue

+

Review pending projects and updates. Pages are ordered by project and version, not risk.

{queueError && ( -
+
{queueError} -
)} +
+ + + + + {pendingProjects.length} entries on this page{loadingQueue ? ' · Loading…' : ''} +
+ {queue.page.unavailableItems > 0 &&

{queue.page.unavailableItems} entries on this page cannot be opened safely and need data repair. Continue to inspect other entries.

} + +
)} diff --git a/frontend/src/modules/admin/views/ArtifactChanges.tsx b/frontend/src/modules/admin/views/ArtifactChanges.tsx new file mode 100644 index 000000000..a4501043e --- /dev/null +++ b/frontend/src/modules/admin/views/ArtifactChanges.tsx @@ -0,0 +1,81 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { adminClient } from '../api/adminClient'; +import { extractApiErrorMessage } from '@/utils/api'; + +export interface ArtifactChangeSummary { + reviewToken: string; + baselineVersion: string | null; + contextComparable: boolean; + contextChanged: boolean; + added: number; + modified: number; + removed: number; + unchanged: number; + files: { path: string; change: 'ADDED' | 'MODIFIED' | 'REMOVED' | 'UNCHANGED' }[]; +} + +export function ArtifactChanges({ projectId, version, reviewToken, onInspect }: { + projectId: string; + version: string; + reviewToken: string; + onInspect: (version: string, path: string, reviewToken: string) => void; +}) { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [showUnchanged, setShowUnchanged] = useState(false); + const [search, setSearch] = useState(''); + const [limit, setLimit] = useState(100); + const generation = useRef(0); + useEffect(() => { + generation.current++; + setResult(null); setLoading(false); setError(''); setSearch(''); setLimit(100); setShowUnchanged(false); + return () => { generation.current++; }; + }, [projectId, version, reviewToken]); + const load = async () => { + const request = ++generation.current; + setLoading(true); setError(''); setResult(null); + try { + const next = await adminClient.getArtifactChanges(projectId, version, reviewToken); + if (next.reviewToken !== reviewToken) throw new Error('This comparison no longer matches the opened review. Refresh its evidence.'); + if (request === generation.current) setResult(next); + } catch (failure) { + if (request === generation.current) setError(extractApiErrorMessage(failure, 'Artifact comparison is unavailable.')); + } finally { + if (request === generation.current) setLoading(false); + } + }; + const visible = useMemo(() => (result?.files || []).filter(file => + (showUnchanged || file.change !== 'UNCHANGED') && file.path.toLowerCase().includes(search.toLowerCase())), [result, showUnchanged, search]); + return
+
+

Changes since approval

+ +
+ {error &&

{error}

} + {result && !result.baselineVersion &&

No previously approved version is available for comparison.

} + {result?.baselineVersion && <> +

Compared with approved version {result.baselineVersion}, using the contents of both stored artifacts.

+
+ {result.added} added{result.modified} modified{result.removed} removed{result.unchanged} unchanged +
+ {(!result.contextComparable || result.contextChanged) &&

+ {result.contextChanged ? 'Version metadata changed and needs review.' : 'Separate files or dependency metadata need additional comparison.'} +

} +
+ { setSearch(event.target.value); setLimit(100); }} placeholder="Filter paths…" className="min-w-0 rounded-lg border border-slate-200 dark:border-slate-700 bg-transparent px-3 py-2 text-sm dark:text-white" /> + +
+
+ {visible.slice(0, limit).map(file => )} + {visible.length === 0 &&

No files match this view.

} +
+ {visible.length > limit && } + } +
; +} diff --git a/frontend/src/modules/admin/views/FindingDecisions.tsx b/frontend/src/modules/admin/views/FindingDecisions.tsx new file mode 100644 index 000000000..22669d078 --- /dev/null +++ b/frontend/src/modules/admin/views/FindingDecisions.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import type { ScanIssue } from '@/types'; +import { findingReviews, type FindingDecision, type DecisionAssessment } from '../api/findingReviews'; +import { extractApiErrorMessage } from '@/utils/api'; + +type Props = { projectId: string; versionId: string; token?: string; issues: ScanIssue[]; canDecide: boolean; onSaved: () => void }; +export function FindingDecisions({ projectId, versionId, token, issues, canDecide, onSaved }: Props) { + const [open, setOpen] = useState(false); + const [events, setEvents] = useState([]); + const [assessments, setAssessments] = useState>({}); + const [assessedAt, setAssessedAt] = useState(); + const [showApplicable, setShowApplicable] = useState(false); + const [next, setNext] = useState(null); + const [issueIndex, setIssueIndex] = useState(0); + const [disposition, setDisposition] = useState<'ACCEPT' | 'REQUIRE_REVIEW'>('REQUIRE_REVIEW'); + const [rationale, setRationale] = useState(''); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(''); + async function load(offset = 0) { + if (!token) { setError('Reopen this review to load its current evidence.'); return; } + setBusy(true); setError(''); + try { + const result = await findingReviews.history(projectId, versionId, token, offset); + setEvents(old => offset ? [...old, ...result.events] : result.events); + setAssessments(result.assessments ?? {}); + setAssessedAt(result.assessedAt); setNext(result.nextOffset); setOpen(true); + } catch (e) { setError(extractApiErrorMessage(e, 'Could not load decision history.')); } + finally { setBusy(false); } + } + async function save(revokedId?: string) { + if (!token || !canDecide || saved || rationale.trim().length < 10) return; + setBusy(true); setError(''); + try { + const event = revokedId + ? await findingReviews.revoke(projectId, versionId, token, revokedId, rationale) + : await findingReviews.record(projectId, versionId, token, issueIndex, disposition, rationale); + setEvents(old => [event, ...old]); setSaved(true); onSaved(); + } catch (e) { setError(extractApiErrorMessage(e, 'Could not save the decision. Your explanation has been kept.')); } + finally { setBusy(false); } + } + const superseded = new Set(events.map(event => event.supersedesDecisionId).filter(Boolean)); + const revoked = new Set(events.map(event => event.revokedDecisionId).filter(Boolean)); + const retained = (event: FindingDecision) => !saved && !revoked.has(event.id) && !superseded.has(event.id) && assessments[event.id]?.state === 'APPLICABLE' && event.expiresAt > Date.now(); + const retainedCount = events.filter(retained).length; + const visible = events.filter(event => showApplicable || !retained(event)); + return
+ + {error &&

{error}

} + {saved &&

Decision saved. Refresh the review evidence before another decision or publication.

} + {open && <> +

These decisions retain your reasoning for the inspected artifact and context. Recording or revoking a decision pauses automatic publication and requires a current moderator review. It does not grant security clearance.

+ {canDecide &&
+ {issues.length > 0 && <> + + + } +