From 714a925d34ba72c1ab010e583362984f492685c9 Mon Sep 17 00:00:00 2001 From: Shivaji Byrapaneni Date: Tue, 8 Sep 2026 10:19:47 +0100 Subject: [PATCH 01/47] feat(calm-hub): GitHub-backed read-only storage for CALM resources Extracted from #3001. Adds a GitHub storage mode (calm.database.mode=github): namespaces map to cloned repos, resources are read from an in-memory registry built off the clone, and writes return 501 pending the account-linking work staged for a later iteration. Includes the producer wiring for all resource types, the BuildingBlock resource type, SHA-based version reads, and markdown rendering for raw documents served from a repo. Original-PR: finos/architecture-as-code#3001 --- .../timeline/TimelineHeader.test.tsx | 36 ++ .../timeline/TimelineHeader.tsx | 5 +- .../DocumentDetailSection.test.tsx | 54 +++ .../DocumentDetailSection.tsx | 29 +- .../components/namespace-page/ItemCard.tsx | 6 +- calm-hub-ui/src/model/version.spec.ts | 17 + calm-hub-ui/src/model/version.ts | 5 + .../components/reactflow/CustomNode.test.tsx | 45 ++- .../components/reactflow/CustomNode.tsx | 15 +- .../components/reactflow/FloatingEdge.tsx | 46 ++- .../reactflow/utils/edgeFactory.test.ts | 22 +- .../components/reactflow/utils/edgeFactory.ts | 2 - calm-hub/.gitignore | 2 + calm-hub/pom.xml | 4 + .../org/finos/calm/config/DatabaseMode.java | 10 + .../org/finos/calm/domain/ResourceType.java | 4 +- .../calm/domain/audit/AuditEntityType.java | 4 +- .../BuildingBlockNotFoundException.java | 7 + ...BuildingBlockVersionNotFoundException.java | 7 + .../GitHubWriteNotSupportedException.java | 8 + .../exception/PendingWriteException.java | 32 ++ .../steps/NamespaceAccessBackfillStep.java | 2 + .../calm/observability/GitHubMetrics.java | 65 ++++ .../calm/resources/ArchitectureResource.java | 3 +- .../calm/resources/CalmDocumentParser.java | 28 +- .../finos/calm/resources/ControlResource.java | 4 +- .../finos/calm/resources/FlowResource.java | 3 +- .../resources/MappingControllerResource.java | 18 +- .../finos/calm/resources/PatternResource.java | 3 +- .../PendingWriteExceptionMapper.java | 28 ++ .../ResourceValidationConstants.java | 6 +- .../calm/resources/StandardResource.java | 2 +- .../UnsupportedOperationExceptionMapper.java | 21 + .../calm/security/LocationSegmentParser.java | 2 +- .../services/MappingControllerService.java | 11 + .../finos/calm/store/BuildingBlockStore.java | 16 + .../classpath/ClasspathCoreSchemaStore.java | 123 ++++++ .../calm/store/github/GitHubAdrStore.java | 92 +++++ .../store/github/GitHubArchitectureStore.java | 141 +++++++ .../store/github/GitHubAuditLogStore.java | 33 ++ .../github/GitHubBuildingBlockStore.java | 132 +++++++ .../calm/store/github/GitHubControlStore.java | 234 +++++++++++ .../store/github/GitHubDecoratorStore.java | 70 ++++ .../calm/store/github/GitHubDomainStore.java | 86 ++++ .../calm/store/github/GitHubFlowStore.java | 147 +++++++ .../store/github/GitHubInterfaceStore.java | 141 +++++++ .../calm/store/github/GitHubLayoutStore.java | 33 ++ .../store/github/GitHubNamespaceStore.java | 57 +++ .../calm/store/github/GitHubPatternStore.java | 149 +++++++ .../github/GitHubResourceMappingStore.java | 131 +++++++ .../calm/store/github/GitHubSearchStore.java | 70 ++++ .../store/github/GitHubStandardStore.java | 151 ++++++++ .../store/github/GitHubTimelineStore.java | 141 +++++++ .../store/github/GitHubUserAccessStore.java | 132 +++++++ .../github/util/CalmContentDetector.java | 138 +++++++ .../store/github/util/CalmResourceType.java | 16 + .../store/github/util/GitHubCloneManager.java | 148 +++++++ .../github/util/GitHubForkPRService.java | 54 +++ .../store/github/util/GitHubRepoSync.java | 84 ++++ .../github/util/GitHubStartupInitializer.java | 98 +++++ .../store/github/util/GitHubStoreConfig.java | 51 +++ .../github/util/GitHubSyncScheduler.java | 60 +++ .../github/util/GitHubVersionService.java | 186 +++++++++ .../github/util/InMemoryRegistryService.java | 194 ++++++++++ .../github/util/NamespaceAccessFilter.java | 66 ++++ .../calm/store/github/util/RegistryEntry.java | 12 + .../store/github/util/RegistrySnapshot.java | 34 ++ .../store/noop/NoOpResourceMappingStore.java | 62 +++ .../store/noop/NoOpSchemaVersionStore.java | 35 ++ .../calm/store/producer/AdrStoreProducer.java | 22 +- .../producer/ArchitectureStoreProducer.java | 20 +- .../store/producer/AuditLogStoreProducer.java | 18 +- .../producer/BuildingBlockStoreProducer.java | 65 ++++ .../store/producer/ControlStoreProducer.java | 22 +- .../producer/CoreSchemaStoreProducer.java | 20 +- .../producer/DecoratorStoreProducer.java | 27 +- .../store/producer/DomainStoreProducer.java | 20 +- .../store/producer/FlowStoreProducer.java | 22 +- .../producer/InterfaceStoreProducer.java | 20 +- .../store/producer/LayoutStoreProducer.java | 25 +- .../producer/NamespaceStoreProducer.java | 20 +- .../store/producer/PatternStoreProducer.java | 20 +- .../ResourceMappingStoreProducer.java | 15 +- .../producer/SchemaVersionStoreProducer.java | 18 +- .../store/producer/SearchStoreProducer.java | 20 +- .../store/producer/StandardStoreProducer.java | 22 +- .../store/producer/TimelineStoreProducer.java | 20 +- .../producer/UserAccessStoreProducer.java | 20 +- .../src/main/resources/application.properties | 4 + .../TestPendingWriteExceptionShould.java | 38 ++ .../TestGitHubMetricsShould.java | 98 +++++ .../resources/TestControlResourceShould.java | 3 +- ...TestPendingWriteExceptionMapperShould.java | 39 ++ ...pportedOperationExceptionMapperShould.java | 36 ++ .../TestClasspathCoreSchemaStoreShould.java | 82 ++++ .../github/TestGitHubAdrStoreShould.java | 115 ++++++ .../TestGitHubArchitectureStoreShould.java | 220 +++++++++++ .../github/TestGitHubAuditLogStoreShould.java | 41 ++ .../TestGitHubBuildingBlockStoreShould.java | 231 +++++++++++ .../github/TestGitHubControlStoreShould.java | 366 ++++++++++++++++++ .../TestGitHubDecoratorStoreShould.java | 114 ++++++ .../github/TestGitHubDomainStoreShould.java | 122 ++++++ .../github/TestGitHubFlowStoreShould.java | 223 +++++++++++ .../TestGitHubInterfaceStoreShould.java | 228 +++++++++++ .../github/TestGitHubLayoutStoreShould.java | 46 +++ .../TestGitHubNamespaceStoreShould.java | 88 +++++ .../github/TestGitHubPatternStoreShould.java | 224 +++++++++++ .../TestGitHubResourceMappingStoreShould.java | 209 ++++++++++ .../github/TestGitHubSearchStoreShould.java | 90 +++++ .../github/TestGitHubStandardStoreShould.java | 245 ++++++++++++ .../github/TestGitHubTimelineStoreShould.java | 240 ++++++++++++ .../TestGitHubUserAccessStoreShould.java | 207 ++++++++++ .../util/TestCalmContentDetectorShould.java | 268 +++++++++++++ .../util/TestGitHubCloneManagerShould.java | 194 ++++++++++ .../util/TestGitHubForkPRServiceShould.java | 32 ++ .../github/util/TestGitHubRepoSyncShould.java | 125 ++++++ .../TestGitHubStartupInitializerShould.java | 144 +++++++ .../util/TestGitHubStoreConfigShould.java | 48 +++ .../util/TestGitHubSyncSchedulerShould.java | 70 ++++ .../util/TestGitHubVersionServiceShould.java | 113 ++++++ .../TestInMemoryRegistryServiceShould.java | 265 +++++++++++++ .../util/TestNamespaceAccessFilterShould.java | 134 +++++++ .../TestNoOpResourceMappingStoreShould.java | 74 ++++ .../TestNoOpSchemaVersionStoreShould.java | 46 +++ .../producer/TestAdrStoreProducerShould.java | 22 +- .../TestArchitectureStoreProducerShould.java | 22 +- .../TestAuditLogStoreProducerShould.java | 19 + .../TestBuildingBlockStoreProducerShould.java | 113 ++++++ .../TestControlStoreProducerShould.java | 18 + .../TestCoreSchemaStoreProducerShould.java | 32 +- .../TestDecoratorStoreProducerShould.java | 24 +- .../TestDomainStoreProducerShould.java | 22 +- .../producer/TestFlowStoreProducerShould.java | 22 +- .../TestInterfaceStoreProducerShould.java | 19 + .../TestLayoutStoreProducerShould.java | 24 +- .../TestNamespaceStoreProducerShould.java | 22 +- .../TestPatternStoreProducerShould.java | 22 +- ...estResourceMappingStoreProducerShould.java | 23 +- .../TestSchemaVersionStoreProducerShould.java | 23 +- .../TestSearchStoreProducerShould.java | 19 + .../TestStandardStoreProducerShould.java | 22 +- .../TestTimelineStoreProducerShould.java | 19 + .../TestUserAccessStoreProducerShould.java | 32 +- .../META-INF/calm-schemas/1.0/files.txt | 1 + .../META-INF/calm-schemas/1.0/meta/core.json | 1 + .../META-INF/calm-schemas/versions.txt | 1 + 146 files changed, 9342 insertions(+), 311 deletions(-) create mode 100644 calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.test.tsx create mode 100644 calm-hub/src/main/java/org/finos/calm/config/DatabaseMode.java create mode 100644 calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockNotFoundException.java create mode 100644 calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockVersionNotFoundException.java create mode 100644 calm-hub/src/main/java/org/finos/calm/domain/exception/GitHubWriteNotSupportedException.java create mode 100644 calm-hub/src/main/java/org/finos/calm/domain/exception/PendingWriteException.java create mode 100644 calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java create mode 100644 calm-hub/src/main/java/org/finos/calm/resources/PendingWriteExceptionMapper.java create mode 100644 calm-hub/src/main/java/org/finos/calm/resources/UnsupportedOperationExceptionMapper.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/BuildingBlockStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/classpath/ClasspathCoreSchemaStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubAuditLogStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubLayoutStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/CalmContentDetector.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubForkPRService.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubRepoSync.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/RegistryEntry.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/RegistrySnapshot.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/noop/NoOpResourceMappingStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/noop/NoOpSchemaVersionStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/producer/BuildingBlockStoreProducer.java create mode 100644 calm-hub/src/test/java/org/finos/calm/domain/exception/TestPendingWriteExceptionShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/resources/TestPendingWriteExceptionMapperShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/resources/TestUnsupportedOperationExceptionMapperShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAuditLogStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubLayoutStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubNamespaceStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubForkPRServiceShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubRepoSyncShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestInMemoryRegistryServiceShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/noop/TestNoOpResourceMappingStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/noop/TestNoOpSchemaVersionStoreShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/producer/TestBuildingBlockStoreProducerShould.java create mode 100644 calm-hub/src/test/resources/META-INF/calm-schemas/1.0/files.txt create mode 100644 calm-hub/src/test/resources/META-INF/calm-schemas/1.0/meta/core.json create mode 100644 calm-hub/src/test/resources/META-INF/calm-schemas/versions.txt diff --git a/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.test.tsx b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.test.tsx new file mode 100644 index 0000000000..ed27550375 --- /dev/null +++ b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { TimelineHeader } from './TimelineHeader.js'; + +describe('TimelineHeader', () => { + it('prepends "v" for semver versions', () => { + render(); + const pill = screen.getByTestId('timeline-version-pill'); + expect(pill).toHaveTextContent('v1.5.0'); + }); + + it('does not prepend "v" for commit SHAs', () => { + render(); + const pill = screen.getByTestId('timeline-version-pill'); + expect(pill).toHaveTextContent('cb7686e'); + expect(pill.textContent).not.toMatch(/^v/); + }); + + it('does not prepend "v" for full-length commit SHAs', () => { + render(); + const pill = screen.getByTestId('timeline-version-pill'); + expect(pill.textContent).not.toMatch(/^v/); + }); + + it('prepends "v" for versions with non-hex characters', () => { + render(); + const pill = screen.getByTestId('timeline-version-pill'); + expect(pill).toHaveTextContent('v2.0.0-beta'); + }); + + it('sets the title attribute with the raw version', () => { + render(); + const pill = screen.getByTestId('timeline-version-pill'); + expect(pill).toHaveAttribute('title', 'Viewing version cb7686e'); + }); +}); diff --git a/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.tsx b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.tsx index d851da2bbf..a220bbe85b 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineHeader.tsx @@ -18,7 +18,10 @@ interface TimelineHeaderProps { * explanatory copy makes it obvious that clicking a moment re-renders both the * Diagram and JSON views. */ +const isCommitSha = (v: string) => /^[0-9a-f]{5,40}$/.test(v); + export function TimelineHeader({ currentVersion, children }: TimelineHeaderProps) { + const displayVersion = isCommitSha(currentVersion) ? currentVersion : `v${currentVersion}`; return (
@@ -46,7 +49,7 @@ export function TimelineHeader({ currentVersion, children }: TimelineHeaderProps }} title={`Viewing version ${currentVersion}`} > - v{currentVersion} + {displayVersion} {children}
diff --git a/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.test.tsx b/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.test.tsx index cf8035ca78..565ed91c12 100644 --- a/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.test.tsx +++ b/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.test.tsx @@ -211,4 +211,58 @@ describe('DocumentDetailSection', () => { expect(mockFetchVersionsByCustomId).toHaveBeenCalledWith('test-ns', 'my-payment-standard', 'Standards'); expect(mockFetchStandardVersions).not.toHaveBeenCalled(); }); + + it('renders markdown content when data is a markdown string', () => { + const data: Data = { + id: 'std-123', + version: 'latest', + name: 'test-ns', + calmType: 'Standards', + data: '# TLS Policy\n\nAll services must use TLS 1.2+.', + }; + + render( + + + + ); + + expect(screen.getByText('All services must use TLS 1.2+.')).toBeInTheDocument(); + }); + + it('shows display name from markdown heading in breadcrumb', () => { + const data: Data = { + id: '12345', + version: 'latest', + name: 'test-ns', + calmType: 'Standards', + data: '# My Standard Name\n\nContent.', + }; + + const { container } = render( + + + + ); + + expect(container.textContent).toContain('My Standard Name'); + }); + + it('shows type label in breadcrumb', () => { + const data: Data = { + id: 'std-1', + version: 'latest', + name: 'fae-calm', + calmType: 'Standards', + data: '# Test\n\nBody.', + }; + + const { container } = render( + + + + ); + + expect(container.textContent).toContain('Standards'); + }); }); diff --git a/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.tsx b/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.tsx index 59ceae1cd5..7ec535fc92 100644 --- a/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.tsx +++ b/calm-hub-ui/src/hub/components/document-detail-section/DocumentDetailSection.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { IoGridOutline, IoGitNetworkOutline } from 'react-icons/io5'; +import Markdown from 'react-markdown'; import { Data, isSlug } from '../../../model/calm.js'; import { CalmService } from '../../../service/calm-service.js'; import { sortVersionsDescending } from '../../../model/version.js'; @@ -11,6 +12,24 @@ interface DocumentDetailSectionProps { data?: Data; } +function getDisplayName(data: Data): string { + if (typeof data.data === 'string') { + const content = data.data as string; + const headingMatch = content.match(/^#\s+(.+)$/m); + if (headingMatch) return headingMatch[1]; + } + if (typeof data.data === 'object' && data.data && 'name' in (data.data as object)) { + return String((data.data as Record).name); + } + return data.id; +} + +function isMarkdownContent(data: Data): boolean { + if (typeof data.data !== 'string') return false; + const content = data.data as string; + return content.startsWith('#') || content.startsWith('---') || !content.startsWith('{'); +} + function calmTypeToUrlSegment(calmType: string): string { switch (calmType) { case 'Standards': return 'standards'; @@ -69,6 +88,8 @@ export function DocumentDetailSection({ data }: DocumentDetailSectionProps) { icon={getIcon()} namespace={data.name} id={data.id} + displayName={getDisplayName(data)} + typeLabel={data.calmType} version={data.version} typeSegment={calmTypeToUrlSegment(data.calmType)} versions={versions} @@ -76,7 +97,13 @@ export function DocumentDetailSection({ data }: DocumentDetailSectionProps) { />
- + {isMarkdownContent(data) ? ( +
+ {data.data as string} +
+ ) : ( + + )}
diff --git a/calm-hub-ui/src/hub/components/namespace-page/ItemCard.tsx b/calm-hub-ui/src/hub/components/namespace-page/ItemCard.tsx index 97aaf95834..b547fb915c 100644 --- a/calm-hub-ui/src/hub/components/namespace-page/ItemCard.tsx +++ b/calm-hub-ui/src/hub/components/namespace-page/ItemCard.tsx @@ -95,9 +95,11 @@ export function ItemCard({ const chip = meta !== undefined ? meta - : versionCount !== undefined + : versionCount !== undefined && versionCount > 0 ? `${versionCount} ${versionCount === 1 ? 'version' : 'versions'}` - : customId; + : versionCount === 0 + ? customId || undefined + : customId; return (
{ expect(result).toEqual(['2.0.0', '1.5.0', '1.0.0']); expect(input).toEqual(['1.0.0', '2.0.0', '1.5.0']); }); + + it('reverses SHA versions from chronological to newest-first', () => { + const input = ['abc1234', 'def5678', 'f1339ab']; + const result = sortVersionsDescending(input); + expect(result).toEqual(['f1339ab', 'def5678', 'abc1234']); + }); + + it('does not re-sort SHA versions alphabetically', () => { + const input = ['aaa1111', 'fff9999', 'bbb2222']; + const result = sortVersionsDescending(input); + expect(result).toEqual(['bbb2222', 'fff9999', 'aaa1111']); + }); }); describe('pickLatestVersion', () => { @@ -44,4 +56,9 @@ describe('pickLatestVersion', () => { it('returns the only version when the list has one entry', () => { expect(pickLatestVersion(['3.4.5'])).toBe('3.4.5'); }); + + it('returns the last SHA (newest) from chronological list', () => { + const input = ['abc1234', 'def5678', 'f1339ab']; + expect(pickLatestVersion(input)).toBe('f1339ab'); + }); }); diff --git a/calm-hub-ui/src/model/version.ts b/calm-hub-ui/src/model/version.ts index 150a88e859..09383151e4 100644 --- a/calm-hub-ui/src/model/version.ts +++ b/calm-hub-ui/src/model/version.ts @@ -33,8 +33,13 @@ export function compareVersions(a: string, b: string): number { /** * Return versions sorted newest-first. + * For commit SHAs, the backend returns chronological (oldest first) — + * reverse to get newest-first, matching semver sort behavior. */ export function sortVersionsDescending(versions: string[]): string[] { + if (versions.length > 0 && /^[0-9a-f]{5,40}$/.test(versions[0])) { + return [...versions].reverse(); + } return [...versions].sort((a, b) => compareVersions(b, a)); } diff --git a/calm-hub-ui/src/visualizer/components/reactflow/CustomNode.test.tsx b/calm-hub-ui/src/visualizer/components/reactflow/CustomNode.test.tsx index d82f839471..ba7661b0fd 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/CustomNode.test.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/CustomNode.test.tsx @@ -5,8 +5,10 @@ import { DiagramActionsContext } from '../../context/DiagramActionsContext.js'; import { restoreLocation, setHostname } from '../../../test-support/window-location.js'; vi.mock('reactflow', () => ({ - Handle: () => null, - Position: { Right: 'right', Left: 'left' }, + Handle: ({ position, id }: { position: string; id: string }) => ( +
+ ), + Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, })); function makeNodeProps(details?: Record) { @@ -188,4 +190,43 @@ describe('CustomNode — external URL support', () => { expect(screen.getByTitle('Has detailed architecture')).toBeInTheDocument(); }); + + it('renders handles on all four sides for edge connection', () => { + const props = makeNodeProps(); + const { container } = renderNode(props); + + expect(container.querySelector('[data-testid="handle-top-target"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="handle-bottom-source"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="handle-left-target"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="handle-right-source"]')).not.toBeNull(); + }); + + it('applies building-block-style background and text colors from metadata', () => { + const props = { + id: 'node-styled', + type: 'custom', + selected: false, + zIndex: 0, + isConnectable: true, + xPos: 0, + yPos: 0, + dragging: false, + data: { + label: 'Styled Node', + description: 'A styled node', + 'node-type': 'webclient', + metadata: { + 'building-block-style': { + background: '#1C4587', + text: '#ffffff', + }, + }, + }, + }; + + const { container } = renderNode(props); + const nodeDiv = container.querySelector('[data-testid="custom-node"] > div'); + expect(nodeDiv).not.toBeNull(); + expect(nodeDiv?.getAttribute('style')).toContain('rgb(28, 69, 135)'); + }); }); diff --git a/calm-hub-ui/src/visualizer/components/reactflow/CustomNode.tsx b/calm-hub-ui/src/visualizer/components/reactflow/CustomNode.tsx index ae2324f0d8..148bb19ebd 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/CustomNode.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/CustomNode.tsx @@ -95,6 +95,9 @@ export function CustomNode({ data }: NodeProps) { const isUnknownArch = !!detailedArchitecture && archResolution.type === 'unknown'; const archPath = isSameOriginArch ? archResolution.path : undefined; + // Extract building-block-style colors from metadata (if present) + const buildingBlockStyle = data.metadata?.['building-block-style'] as { background?: string; text?: string } | undefined; + // Extract AIGF data (if present in node metadata) const aigf = data.metadata?.aigf; const riskLevel = aigf?.['risk-level'] || null; @@ -169,12 +172,12 @@ export function CustomNode({ data }: NodeProps) { {/* Base node - always visible, fixed size */}
{/* Hidden handles to satisfy React Flow; floating edge computes actual attachment */} - - + + + +
diff --git a/calm-hub-ui/src/visualizer/components/reactflow/FloatingEdge.tsx b/calm-hub-ui/src/visualizer/components/reactflow/FloatingEdge.tsx index b8c507e5dc..df5b38a95b 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/FloatingEdge.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/FloatingEdge.tsx @@ -1,19 +1,26 @@ import { useState, useCallback } from 'react'; -import { EdgeProps, getBezierPath, EdgeLabelRenderer, useStore } from 'reactflow'; +import { EdgeProps, getBezierPath, getSmoothStepPath, getStraightPath, EdgeLabelRenderer, useStore } from 'reactflow'; import { getEdgeParams } from './utils/floatingEdges.js'; -import { EdgeBadge, EdgeTooltip, getBadgeStyle } from './edge-components/index.js'; +import { EdgeTooltip } from './edge-components/index.js'; import type { EdgeData } from '../../contracts/contracts.js'; export function FloatingEdge({ id, source, target, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, style = {}, markerEnd, markerStart, data, }: EdgeProps) { const [isHovered, setIsHovered] = useState(false); + const routing = data?.metadata?.routing as string | undefined; const sourceNode = useStore(useCallback((store) => store.nodeInternals.get(source), [source])); const targetNode = useStore(useCallback((store) => store.nodeInternals.get(target), [target])); @@ -22,7 +29,12 @@ export function FloatingEdge({ return null; } - const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(sourceNode, targetNode); + // For smoothstep/straight: use ReactFlow's native handle-based positions (precise attachment) + // For bezier (default): use floating edge calculation (curves naturally into nodes) + const useNativePositions = routing === 'smoothstep' || routing === 'straight'; + const { sx, sy, tx, ty, sourcePos, targetPos } = useNativePositions + ? { sx: sourceX, sy: sourceY, tx: targetX, ty: targetY, sourcePos: sourcePosition, targetPos: targetPosition } + : getEdgeParams(sourceNode, targetNode); // Calculate perpendicular offset for bidirectional edges const direction = data?.direction; @@ -32,14 +44,19 @@ export function FloatingEdge({ sx, sy, tx, ty, offset, direction ); - const [edgePath, labelX, labelY] = getBezierPath({ + const pathParams = { sourceX: adjustedSourceX, sourceY: adjustedSourceY, sourcePosition: sourcePos, targetX: adjustedTargetX, targetY: adjustedTargetY, targetPosition: targetPos, - }); + }; + + const [edgePath, labelX, labelY] = + routing === 'straight' ? getStraightPath(pathParams) : + routing === 'smoothstep' ? getSmoothStepPath(pathParams) : + getBezierPath(pathParams); // Extract edge data const description = data?.description || ''; @@ -53,9 +70,8 @@ export function FloatingEdge({ const mitigations = aigf?.mitigations || []; const risks = aigf?.risks || []; - const hasFlowInfo = flowTransitions.length > 0; - const hasAIGF = controlsApplied.length > 0 || mitigations.length > 0 || risks.length > 0; - const badgeStyle = getBadgeStyle(hasFlowInfo, hasAIGF); + + const edgeLabel = description || protocol || ''; return ( <> @@ -67,7 +83,7 @@ export function FloatingEdge({ markerEnd={markerEnd} markerStart={markerStart} /> - {description && ( + {edgeLabel && (
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} > - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - /> + + {edgeLabel} +
{isHovered && ( diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/edgeFactory.test.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/edgeFactory.test.ts index cb1ac3e0ed..e24a58454f 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/edgeFactory.test.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/edgeFactory.test.ts @@ -20,10 +20,10 @@ describe('createEdge', () => { expect(edge.type).toBe('custom'); }); - it('sets source and target handles', () => { + it('does not set explicit handles so ReactFlow auto-selects nearest', () => { const edge = createEdge(baseConfig); - expect(edge.sourceHandle).toBe('source'); - expect(edge.targetHandle).toBe('target'); + expect(edge.sourceHandle).toBeUndefined(); + expect(edge.targetHandle).toBeUndefined(); }); it('includes description in data from label', () => { @@ -102,4 +102,20 @@ describe('createEdge', () => { expect(edge.data.target).toBeUndefined(); expect(edge.data.label).toBeUndefined(); }); + + it('passes routing metadata through to edge data', () => { + const edge = createEdge({ + ...baseConfig, + data: { metadata: { routing: 'smoothstep' } }, + }); + expect(edge.data.metadata.routing).toBe('smoothstep'); + }); + + it('passes protocol through to edge data', () => { + const edge = createEdge({ + ...baseConfig, + data: { protocol: 'HTTPS' }, + }); + expect(edge.data.protocol).toBe('HTTPS'); + }); }); diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/edgeFactory.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/edgeFactory.ts index 734c86dc4c..ebed411aab 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/edgeFactory.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/edgeFactory.ts @@ -35,8 +35,6 @@ export function createEdge(config: EdgeConfig): Edge { id, source, target, - sourceHandle: 'source', - targetHandle: 'target', type: 'custom', animated, style: { diff --git a/calm-hub/.gitignore b/calm-hub/.gitignore index 722f4cc3a0..965a7b093d 100644 --- a/calm-hub/.gitignore +++ b/calm-hub/.gitignore @@ -128,6 +128,8 @@ META-INF/ src/main/resources/META-INF/* !src/main/resources/META-INF/native-image/ !src/main/resources/META-INF/native-image/** +!src/test/resources/META-INF/ +!src/test/resources/META-INF/** # Compiled class file *.class diff --git a/calm-hub/pom.xml b/calm-hub/pom.xml index 505d212153..024ec80d09 100644 --- a/calm-hub/pom.xml +++ b/calm-hub/pom.xml @@ -335,6 +335,10 @@ **/*ArchitectureRequest.* **/config/**/* **/domain/**/* + + **/store/github/util/GitHubRepoSync.* + **/store/github/util/GitHubVersionService.* + **/store/classpath/ClasspathCoreSchemaStore.* diff --git a/calm-hub/src/main/java/org/finos/calm/config/DatabaseMode.java b/calm-hub/src/main/java/org/finos/calm/config/DatabaseMode.java new file mode 100644 index 0000000000..23d021e6bc --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/config/DatabaseMode.java @@ -0,0 +1,10 @@ +package org.finos.calm.config; + +public final class DatabaseMode { + + public static final String MONGO = "mongo"; + public static final String STANDALONE = "standalone"; + public static final String GITHUB = "github"; + + private DatabaseMode() {} +} diff --git a/calm-hub/src/main/java/org/finos/calm/domain/ResourceType.java b/calm-hub/src/main/java/org/finos/calm/domain/ResourceType.java index b0c4f46d5e..485f53a70b 100644 --- a/calm-hub/src/main/java/org/finos/calm/domain/ResourceType.java +++ b/calm-hub/src/main/java/org/finos/calm/domain/ResourceType.java @@ -8,5 +8,7 @@ public enum ResourceType { ARCHITECTURE, FLOW, STANDARD, - INTERFACE + INTERFACE, + BUILDING_BLOCK, + CONTROL } diff --git a/calm-hub/src/main/java/org/finos/calm/domain/audit/AuditEntityType.java b/calm-hub/src/main/java/org/finos/calm/domain/audit/AuditEntityType.java index 775a564b65..a06208aa24 100644 --- a/calm-hub/src/main/java/org/finos/calm/domain/audit/AuditEntityType.java +++ b/calm-hub/src/main/java/org/finos/calm/domain/audit/AuditEntityType.java @@ -7,6 +7,7 @@ public enum AuditEntityType { NAMESPACE, ARCHITECTURE, PATTERN, + CONTROL, CONTROL_REQUIREMENT, CONTROL_CONFIGURATION, ADR, @@ -19,5 +20,6 @@ public enum AuditEntityType { USER_ACCESS, SCHEMA, LAYOUT, - PATTERN_LAYOUT + PATTERN_LAYOUT, + BUILDING_BLOCK } diff --git a/calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockNotFoundException.java b/calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockNotFoundException.java new file mode 100644 index 0000000000..98d52cba8b --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockNotFoundException.java @@ -0,0 +1,7 @@ +package org.finos.calm.domain.exception; + +/** + * Exception thrown when the specified building block is not found. + */ +public class BuildingBlockNotFoundException extends Exception { +} diff --git a/calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockVersionNotFoundException.java b/calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockVersionNotFoundException.java new file mode 100644 index 0000000000..730ff34038 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockVersionNotFoundException.java @@ -0,0 +1,7 @@ +package org.finos.calm.domain.exception; + +/** + * Exception thrown when the specified building block version is not found. + */ +public class BuildingBlockVersionNotFoundException extends Exception { +} diff --git a/calm-hub/src/main/java/org/finos/calm/domain/exception/GitHubWriteNotSupportedException.java b/calm-hub/src/main/java/org/finos/calm/domain/exception/GitHubWriteNotSupportedException.java new file mode 100644 index 0000000000..6bd1958b53 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/domain/exception/GitHubWriteNotSupportedException.java @@ -0,0 +1,8 @@ +package org.finos.calm.domain.exception; + +public class GitHubWriteNotSupportedException extends UnsupportedOperationException { + + public GitHubWriteNotSupportedException(String message) { + super(message); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/domain/exception/PendingWriteException.java b/calm-hub/src/main/java/org/finos/calm/domain/exception/PendingWriteException.java new file mode 100644 index 0000000000..cddd922071 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/domain/exception/PendingWriteException.java @@ -0,0 +1,32 @@ +package org.finos.calm.domain.exception; + +/** + * Thrown by GitHub stores when a write operation successfully creates a PR. + * The resource layer catches this and returns 202 Accepted with the PR URL. + * This is NOT an error — it signals that the write was accepted asynchronously. + */ +public class PendingWriteException extends Exception { + + private final String pullRequestUrl; + private final int pullRequestNumber; + private final String branch; + + public PendingWriteException(String pullRequestUrl, int pullRequestNumber, String branch) { + super("Change submitted as PR #" + pullRequestNumber); + this.pullRequestUrl = pullRequestUrl; + this.pullRequestNumber = pullRequestNumber; + this.branch = branch; + } + + public String getPullRequestUrl() { + return pullRequestUrl; + } + + public int getPullRequestNumber() { + return pullRequestNumber; + } + + public String getBranch() { + return branch; + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/migration/steps/NamespaceAccessBackfillStep.java b/calm-hub/src/main/java/org/finos/calm/migration/steps/NamespaceAccessBackfillStep.java index 83282c5694..9eb41ce643 100644 --- a/calm-hub/src/main/java/org/finos/calm/migration/steps/NamespaceAccessBackfillStep.java +++ b/calm-hub/src/main/java/org/finos/calm/migration/steps/NamespaceAccessBackfillStep.java @@ -1,5 +1,6 @@ package org.finos.calm.migration.steps; +import io.quarkus.arc.lookup.LookupUnlessProperty; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.finos.calm.domain.UserAccess; @@ -26,6 +27,7 @@ * Uses the CDI-produced {@link NamespaceStore} and {@link UserAccessStore} interfaces * so it works for both MongoDB and Nitrite backends without duplication. */ +@LookupUnlessProperty(name = "calm.database.mode", stringValue = "github") @ApplicationScoped public class NamespaceAccessBackfillStep implements SchemaMigrationStep { diff --git a/calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java b/calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java new file mode 100644 index 0000000000..d95fcbcfa0 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java @@ -0,0 +1,65 @@ +package org.finos.calm.observability; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicLong; + +@ApplicationScoped +public class GitHubMetrics { + + private final Counter syncSuccessCounter; + private final Counter syncFailureCounter; + private final Timer syncDurationTimer; + private final Timer registryRebuildTimer; + private final AtomicLong lastSyncSuccessEpoch = new AtomicLong(0); + + @Inject + public GitHubMetrics(MeterRegistry registry) { + this.syncSuccessCounter = Counter.builder("calm.github.sync") + .tag("outcome", "success") + .description("Number of successful GitHub sync operations") + .register(registry); + + this.syncFailureCounter = Counter.builder("calm.github.sync") + .tag("outcome", "failure") + .description("Number of failed GitHub sync operations") + .register(registry); + + this.syncDurationTimer = Timer.builder("calm.github.sync.duration") + .description("Duration of GitHub sync operations") + .register(registry); + + this.registryRebuildTimer = Timer.builder("calm.github.registry.rebuild.duration") + .description("Duration of in-memory registry rebuilds") + .register(registry); + + registry.gauge("calm.github.sync.last_success_epoch_seconds", lastSyncSuccessEpoch); + } + + public void recordSyncSuccess(Duration duration) { + syncSuccessCounter.increment(); + syncDurationTimer.record(duration); + lastSyncSuccessEpoch.set(System.currentTimeMillis() / 1000); + } + + public void recordSyncFailure(Duration duration) { + syncFailureCounter.increment(); + syncDurationTimer.record(duration); + } + + public void recordRegistryRebuild(Duration duration) { + registryRebuildTimer.record(duration); + } + + public Counter contentDetectedCounter(MeterRegistry registry, String type) { + return Counter.builder("calm.github.content.detected") + .tag("type", type) + .description("Number of CALM documents detected by type") + .register(registry); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java b/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java index 2a9a41855a..8c0e8bc7d1 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java @@ -44,6 +44,7 @@ import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX; import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_REGEX; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX; @@ -174,7 +175,7 @@ public Response getArchitectureVersions( public Response getArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, - @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version) { + @PathParam("version") @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_MESSAGE) String version) { Architecture architecture = new Architecture.ArchitectureBuilder() .setNamespace(namespace) .setId(architectureId) diff --git a/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java b/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java index 6ae0d2bcd3..893991e98f 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java @@ -28,21 +28,25 @@ public class CalmDocumentParser { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); /** Maps the plural URL path segment (e.g. {@code "patterns"}) to {@link ResourceType}. */ - public static final Map TYPE_MAP = Map.of( - "patterns", ResourceType.PATTERN, - "architectures", ResourceType.ARCHITECTURE, - "flows", ResourceType.FLOW, - "standards", ResourceType.STANDARD, - "interfaces", ResourceType.INTERFACE + public static final Map TYPE_MAP = Map.ofEntries( + Map.entry("patterns", ResourceType.PATTERN), + Map.entry("architectures", ResourceType.ARCHITECTURE), + Map.entry("flows", ResourceType.FLOW), + Map.entry("standards", ResourceType.STANDARD), + Map.entry("interfaces", ResourceType.INTERFACE), + Map.entry("building-blocks", ResourceType.BUILDING_BLOCK), + Map.entry("controls", ResourceType.CONTROL) ); /** Maps {@link ResourceType} back to its plural URL segment. */ - public static final Map TYPE_PLURAL_MAP = Map.of( - ResourceType.PATTERN, "patterns", - ResourceType.ARCHITECTURE, "architectures", - ResourceType.FLOW, "flows", - ResourceType.STANDARD, "standards", - ResourceType.INTERFACE, "interfaces" + public static final Map TYPE_PLURAL_MAP = Map.ofEntries( + Map.entry(ResourceType.PATTERN, "patterns"), + Map.entry(ResourceType.ARCHITECTURE, "architectures"), + Map.entry(ResourceType.FLOW, "flows"), + Map.entry(ResourceType.STANDARD, "standards"), + Map.entry(ResourceType.INTERFACE, "interfaces"), + Map.entry(ResourceType.BUILDING_BLOCK, "building-blocks"), + Map.entry(ResourceType.CONTROL, "controls") ); @ConfigProperty(name = "calm.hub.base-url", defaultValue = "http://localhost:8080") diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java b/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java index 60f58304c4..28dacf18de 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java @@ -1,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.Authenticated; import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; @@ -30,6 +31,7 @@ */ @Tag(name = "Storage API", description = "Numeric-ID based CALM storage endpoints") @Path("/api/calm/domains") +@Authenticated public class ControlResource { private final ControlStore store; @@ -122,7 +124,7 @@ public Response getRequirementForVersion( String domain, @PathParam("controlId") int controlId, @PathParam("version") - @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) + @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_OR_SHA_MESSAGE) String version) { try { return Response.ok(store.getRequirementForVersion(domain, controlId, version)).build(); diff --git a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java index 0db651ebb3..586d3d087b 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java @@ -35,6 +35,7 @@ import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX; import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_REGEX; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX; @Tag(name = "Storage API", description = "Numeric-ID based CALM storage endpoints") @@ -172,7 +173,7 @@ public Response getFlowVersions( public Response getFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId, - @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version + @PathParam("version") @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_MESSAGE) String version ) { return getFlowInternal(namespace, flowId, version); } diff --git a/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java b/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java index 1f1fa63171..7f2a4ad0c8 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java @@ -266,7 +266,7 @@ private Response handlePost(String namespace, String type, String name, String p if (resourceType == null) { return Response.status(Response.Status.BAD_REQUEST) .entity("Unsupported resource type: " + STRICT_SANITIZATION_POLICY.sanitize(type) - + ". Supported: patterns, architectures, flows, standards, interfaces").build(); + + ". Supported: patterns, architectures, flows, standards, interfaces, building-blocks").build(); } if ("versions".equals(name)) { return Response.status(Response.Status.BAD_REQUEST) @@ -314,9 +314,15 @@ public Response listResourceVersions( try { ResourceMapping mapping = service.getMapping(namespace, resourceType, name); List versions = service.getVersionsForMapping(mapping); - List sortedVersions = versions.stream() - .sorted(Comparator.comparing(Semver::tryParse)) - .toList(); + boolean hasShas = versions.stream().anyMatch(v -> v.matches("[0-9a-f]{7,40}")); + List sortedVersions; + if (hasShas) { + sortedVersions = versions; + } else { + sortedVersions = versions.stream() + .sorted(Comparator.comparing(Semver::tryParse)) + .toList(); + } return Response.ok(new ValueWrapper<>(sortedVersions)).build(); } catch (MappingNotFoundException e) { return Response.status(Response.Status.NOT_FOUND) @@ -345,7 +351,7 @@ public Response listResourceVersions( @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Get a specific version of a named resource", - description = "Returns the resource at the specified semver version. " + + description = "Returns the resource at the specified version (semver, git SHA, or 'latest'). " + "The \"$id\" in the returned document is rewritten to the versioned canonical URL." ) @PermissionsAllowed(CalmHubScopes.READ) @@ -353,7 +359,7 @@ public Response getResourceVersion( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("type") String type, @PathParam("name") @Pattern(regexp = CUSTOM_ID_REGEX, message = CUSTOM_ID_MESSAGE) String name, - @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version + @PathParam("version") @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_OR_SHA_MESSAGE) String version ) { ResourceType resourceType = documentParser.parseTypePlural(type); if (resourceType == null) { diff --git a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java index deafc0903d..6fea478c68 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java @@ -33,6 +33,7 @@ import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX; import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_REGEX; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX; @Tag(name = "Storage API", description = "Numeric-ID based CALM storage endpoints") @@ -142,7 +143,7 @@ public Response getPatternVersions( public Response getPattern( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId, - @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version + @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_MESSAGE) String version ) { Pattern pattern = new Pattern.PatternBuilder() .setNamespace(namespace) diff --git a/calm-hub/src/main/java/org/finos/calm/resources/PendingWriteExceptionMapper.java b/calm-hub/src/main/java/org/finos/calm/resources/PendingWriteExceptionMapper.java new file mode 100644 index 0000000000..8f7b00174e --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/resources/PendingWriteExceptionMapper.java @@ -0,0 +1,28 @@ +package org.finos.calm.resources; + +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import org.finos.calm.domain.exception.PendingWriteException; + +import java.util.Map; + +/** + * Maps PendingWriteException to 202 Accepted with PR details. + * This is thrown by GitHub stores when a write successfully creates a PR — + * it's not an error, it's the expected outcome in fork-based write mode. + */ +@Provider +public class PendingWriteExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(PendingWriteException e) { + return Response.accepted(Map.of( + "status", "pending", + "pullRequestUrl", e.getPullRequestUrl(), + "pullRequestNumber", e.getPullRequestNumber(), + "message", e.getMessage() + )).type(MediaType.APPLICATION_JSON).build(); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java b/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java index f71ac9ecec..60f8611322 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java @@ -12,10 +12,12 @@ public class ResourceValidationConstants { public static final String DOMAIN_REGEX = "^[A-Za-z0-9-]+$"; public static final String DOMAIN_MESSAGE = "domain name must match pattern '^[A-Za-z0-9-]+$'"; public static final String VERSION_REGEX = "^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)$"; + public static final String VERSION_OR_SHA_REGEX = "^((0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)|[0-9a-f]{7,40}|latest)$"; public static final String VERSION_MESSAGE = "version must match pattern '^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)$'"; + public static final String VERSION_OR_SHA_MESSAGE = "version must be semver (e.g. 1.0.0), a hex SHA (7-40 chars), or 'latest'"; // First character must be a letter so slugs are never purely numeric (avoids clash with legacy numeric IDs). - public static final String CUSTOM_ID_REGEX = "^[a-z][a-z0-9]*(-[a-z0-9]+)*$"; - public static final String CUSTOM_ID_MESSAGE = "customId must match pattern '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'"; + public static final String CUSTOM_ID_REGEX = "^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$"; + public static final String CUSTOM_ID_MESSAGE = "customId must match pattern '^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$'"; public static final String QUERY_PARAM_NO_WHITESPACE_REGEX = "^[A-Za-z0-9_/.-]+$"; public static final String QUERY_PARAM_NO_WHITESPACE_MESSAGE = "Query parameter must match pattern '^[A-Za-z0-9_/.-]+$'"; public static final String LIMIT_MESSAGE = "limit must be greater than or equal to 1"; diff --git a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java index 5f79eb6101..f9eb54ecf9 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java @@ -102,7 +102,7 @@ public Response getStandardVersions( public Response getStandardForVersion( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("standardId") Integer standardId, - @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version + @PathParam("version") @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_MESSAGE) String version ) { try { return Response.ok(standardStore.getStandardForVersion(namespace, standardId, version)).build(); diff --git a/calm-hub/src/main/java/org/finos/calm/resources/UnsupportedOperationExceptionMapper.java b/calm-hub/src/main/java/org/finos/calm/resources/UnsupportedOperationExceptionMapper.java new file mode 100644 index 0000000000..87e37a00da --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/resources/UnsupportedOperationExceptionMapper.java @@ -0,0 +1,21 @@ +package org.finos.calm.resources; + +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import java.util.Map; + +@Provider +public class UnsupportedOperationExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(GitHubWriteNotSupportedException e) { + return Response.status(501) + .entity(Map.of("error", e.getMessage())) + .type(MediaType.APPLICATION_JSON) + .build(); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/security/LocationSegmentParser.java b/calm-hub/src/main/java/org/finos/calm/security/LocationSegmentParser.java index c24b9ce1e7..93b35fa9a3 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/LocationSegmentParser.java +++ b/calm-hub/src/main/java/org/finos/calm/security/LocationSegmentParser.java @@ -40,7 +40,7 @@ static LocationIds parse(AuditEntityType entityType, String locationPath) { // .../schemas/{version}/meta case SCHEMA -> new LocationIds(segmentBefore(segments, "meta"), null); // .../{plural}/{id}/versions/{version} - case ARCHITECTURE, PATTERN, FLOW, INTERFACE, STANDARD, TIMELINE -> + case ARCHITECTURE, PATTERN, FLOW, INTERFACE, STANDARD, TIMELINE, BUILDING_BLOCK, CONTROL -> new LocationIds(segmentBefore(segments, "versions"), segmentAfter(segments, "versions")); // .../adrs/{id}/revisions/{revision} case ADR -> new LocationIds(segmentBefore(segments, "revisions"), segmentAfter(segments, "revisions")); diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java index 392bd32e3a..8353992a1c 100644 --- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java +++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java @@ -46,6 +46,7 @@ public class MappingControllerService { private final InterfaceStore interfaceStore; private final DomainStore domainStore; private final ControlStore controlStore; + private final BuildingBlockStore buildingBlockStore; private final CalmDocumentParser documentParser; @Inject @@ -57,6 +58,7 @@ public MappingControllerService(ResourceMappingStore mappingStore, InterfaceStore interfaceStore, DomainStore domainStore, ControlStore controlStore, + BuildingBlockStore buildingBlockStore, CalmDocumentParser documentParser) { this.mappingStore = mappingStore; this.patternStore = patternStore; @@ -66,6 +68,7 @@ public MappingControllerService(ResourceMappingStore mappingStore, this.interfaceStore = interfaceStore; this.domainStore = domainStore; this.controlStore = controlStore; + this.buildingBlockStore = buildingBlockStore; this.documentParser = documentParser; } @@ -153,6 +156,8 @@ public List getVersionsForMapping(ResourceMapping mapping) throws Except } case STANDARD -> standardStore.getStandardVersions(mapping.getNamespace(), mapping.getNumericId()); case INTERFACE -> interfaceStore.getInterfaceVersions(mapping.getNamespace(), mapping.getNumericId()); + case BUILDING_BLOCK -> buildingBlockStore.getBuildingBlockVersions(mapping.getNamespace(), mapping.getNumericId()); + case CONTROL -> controlStore.getRequirementVersions(mapping.getNamespace(), mapping.getNumericId()); }; } @@ -184,6 +189,8 @@ public String getResourceJsonForVersion(ResourceMapping mapping, String version) } case STANDARD -> standardStore.getStandardForVersion(mapping.getNamespace(), mapping.getNumericId(), version); case INTERFACE -> interfaceStore.getInterfaceForVersion(mapping.getNamespace(), mapping.getNumericId(), version); + case BUILDING_BLOCK -> buildingBlockStore.getBuildingBlockForVersion(mapping.getNamespace(), mapping.getNumericId(), version); + case CONTROL -> controlStore.getRequirementForVersion(mapping.getNamespace(), mapping.getNumericId(), version); }; } @@ -554,6 +561,8 @@ private int createResourceInStore(ResourceType type, String namespace, String js CalmInterface created = interfaceStore.createInterfaceForNamespace(req, namespace); yield created.getId(); } + case BUILDING_BLOCK -> buildingBlockStore.createBuildingBlockForNamespace(namespace, json); + case CONTROL -> { throw new UnsupportedOperationException("Control creation via name-based API is not supported"); } }; } @@ -606,6 +615,8 @@ private void createVersionedResourceInStore(ResourceType type, String namespace, CreateInterfaceRequest req = new CreateInterfaceRequest(title, description, json); interfaceStore.createInterfaceForVersion(req, namespace, numericId, version); } + case BUILDING_BLOCK -> buildingBlockStore.createBuildingBlockForVersion(namespace, numericId, version, json); + case CONTROL -> { throw new UnsupportedOperationException("Control version creation via name-based API is not supported"); } } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/BuildingBlockStore.java b/calm-hub/src/main/java/org/finos/calm/store/BuildingBlockStore.java new file mode 100644 index 0000000000..8eb7499c94 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/BuildingBlockStore.java @@ -0,0 +1,16 @@ +package org.finos.calm.store; + +import org.finos.calm.domain.exception.BuildingBlockNotFoundException; +import org.finos.calm.domain.exception.BuildingBlockVersionNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.namespaces.NamespaceResourceSummary; + +import java.util.List; + +public interface BuildingBlockStore { + List getBuildingBlocksForNamespace(String namespace) throws NamespaceNotFoundException; + int createBuildingBlockForNamespace(String namespace, String buildingBlockJson) throws NamespaceNotFoundException; + List getBuildingBlockVersions(String namespace, int buildingBlockId) throws NamespaceNotFoundException, BuildingBlockNotFoundException; + String getBuildingBlockForVersion(String namespace, int buildingBlockId, String version) throws NamespaceNotFoundException, BuildingBlockNotFoundException, BuildingBlockVersionNotFoundException; + void createBuildingBlockForVersion(String namespace, int buildingBlockId, String version, String buildingBlockJson) throws NamespaceNotFoundException, BuildingBlockNotFoundException, BuildingBlockVersionNotFoundException; +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/classpath/ClasspathCoreSchemaStore.java b/calm-hub/src/main/java/org/finos/calm/store/classpath/ClasspathCoreSchemaStore.java new file mode 100644 index 0000000000..eed14a9196 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/classpath/ClasspathCoreSchemaStore.java @@ -0,0 +1,123 @@ +package org.finos.calm.store.classpath; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Serves CALM meta-schemas (e.g. calm.json, core.json, flow.json) from JAR-bundled + * resources when no database is available (GitHub storage mode). In Mongo/Nitrite + * modes these schemas live in the database; here they are bundled at build time + * under META-INF/calm-schemas/ so the /api/calm/schemas endpoints continue to work + * without a database connection. + */ +@ApplicationScoped +@Typed(ClasspathCoreSchemaStore.class) +public class ClasspathCoreSchemaStore implements org.finos.calm.store.CoreSchemaStore { + + private static final String SCHEMA_BASE_PATH = "META-INF/calm-schemas/"; + private static final String VERSIONS_INDEX = SCHEMA_BASE_PATH + "versions.txt"; + + private final Map> schemaCache = new ConcurrentHashMap<>(); + private final List versions; + + public ClasspathCoreSchemaStore() { + this.versions = loadVersions(); + } + + @Override + public List getVersions() { + return Collections.unmodifiableList(versions); + } + + @Override + public Map getSchemasForVersion(String version) { + if (!versions.contains(version)) { + return null; + } + return schemaCache.computeIfAbsent(version, this::loadSchemasForVersion); + } + + @Override + public void createSchemaVersion(String version, Map schemas) { + throw new UnsupportedOperationException( + "Schema creation is not supported in GitHub storage mode. Schemas are bundled at build time."); + } + + private List loadVersions() { + try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(VERSIONS_INDEX)) { + if (is == null) { + return List.of(); + } + List result = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + if (!trimmed.isEmpty()) { + result.add(trimmed); + } + } + } + return result; + } catch (IOException e) { + return List.of(); + } + } + + private Map loadSchemasForVersion(String version) { + String versionPath = SCHEMA_BASE_PATH + version + "/meta/"; + Map schemas = new HashMap<>(); + + try (InputStream listing = Thread.currentThread().getContextClassLoader() + .getResourceAsStream(SCHEMA_BASE_PATH + version + "/files.txt")) { + if (listing == null) { + return schemas; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(listing, StandardCharsets.UTF_8))) { + String fileName; + while ((fileName = reader.readLine()) != null) { + String trimmed = fileName.trim(); + if (trimmed.isEmpty()) continue; + String resourcePath = versionPath + trimmed; + Object schema = loadJsonResource(resourcePath); + if (schema != null) { + String schemaName = trimmed.replace(".json", ""); + schemas.put(schemaName, schema); + } + } + } + } catch (IOException e) { + // return whatever we managed to load + } + return schemas; + } + + private Object loadJsonResource(String path) { + try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(path)) { + if (is == null) { + return null; + } + try (JsonReader reader = Json.createReader(is)) { + JsonObject obj = reader.readObject(); + return obj.toString(); + } + } catch (IOException e) { + return null; + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java new file mode 100644 index 0000000000..a6d975dfb8 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java @@ -0,0 +1,92 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.finos.calm.domain.adr.AdrMeta; +import org.finos.calm.domain.adr.NamespaceAdrSummary; +import org.finos.calm.domain.adr.Status; +import org.finos.calm.domain.exception.AdrNotFoundException; +import org.finos.calm.domain.exception.AdrParseException; +import org.finos.calm.domain.exception.AdrPersistenceException; +import org.finos.calm.domain.exception.AdrRevisionExistsException; +import org.finos.calm.domain.exception.AdrRevisionNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.AdrStore; +import org.finos.calm.store.github.util.InMemoryRegistryService; + +import java.util.Collections; +import java.util.List; + +@ApplicationScoped +@Typed(GitHubAdrStore.class) +public class GitHubAdrStore implements AdrStore { + + private static final String WRITE_UNSUPPORTED = + "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; + + private static final String VERSION_UNSUPPORTED = + "Version history via GitHub API is not yet implemented."; + + private final InMemoryRegistryService registryService; + + @Inject + public GitHubAdrStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public List getAdrsForNamespace(String namespace) throws NamespaceNotFoundException { + verifyNamespace(namespace); + return Collections.emptyList(); + } + + @Override + public int countAdrsForNamespace(String namespace) throws NamespaceNotFoundException { + verifyNamespace(namespace); + return 0; + } + + @Override + public AdrMeta createAdrForNamespace(AdrMeta adrMeta) throws NamespaceNotFoundException, AdrParseException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public AdrMeta getAdr(AdrMeta adrMeta) throws NamespaceNotFoundException, AdrNotFoundException, AdrRevisionNotFoundException, AdrParseException { + throw new GitHubWriteNotSupportedException(VERSION_UNSUPPORTED); + } + + @Override + public List getAdrRevisions(AdrMeta adrMeta) throws NamespaceNotFoundException, AdrNotFoundException, AdrRevisionNotFoundException { + throw new GitHubWriteNotSupportedException(VERSION_UNSUPPORTED); + } + + @Override + public AdrMeta getAdrRevision(AdrMeta adrMeta) throws NamespaceNotFoundException, AdrNotFoundException, AdrRevisionNotFoundException, AdrParseException { + throw new GitHubWriteNotSupportedException(VERSION_UNSUPPORTED); + } + + @Override + public AdrMeta updateAdrForNamespace(AdrMeta adrMeta) throws NamespaceNotFoundException, AdrNotFoundException, AdrRevisionNotFoundException, AdrPersistenceException, AdrParseException, AdrRevisionExistsException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public AdrMeta updateAdrStatus(AdrMeta adrMeta, Status status) throws AdrNotFoundException, NamespaceNotFoundException, AdrRevisionNotFoundException, AdrPersistenceException, AdrParseException, AdrRevisionExistsException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteAdr(String namespace, int adrId) throws NamespaceNotFoundException, AdrNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + private void verifyNamespace(String namespace) throws NamespaceNotFoundException { + if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { + throw new NamespaceNotFoundException(); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java new file mode 100644 index 0000000000..20ec5dd88a --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java @@ -0,0 +1,141 @@ +package org.finos.calm.store.github; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.domain.Architecture; +import org.finos.calm.domain.exception.ArchitectureNotFoundException; +import org.finos.calm.domain.exception.ArchitectureVersionExistsException; +import org.finos.calm.domain.exception.ArchitectureVersionNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.namespaces.NamespaceResourceSummary; +import org.finos.calm.store.ArchitectureStore; +import org.finos.calm.store.PageRequest; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +@Typed(GitHubArchitectureStore.class) +public class GitHubArchitectureStore implements ArchitectureStore { + + private static final String WRITE_UNSUPPORTED = + "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; + + private static final Logger LOG = LoggerFactory.getLogger(GitHubArchitectureStore.class); + + private final InMemoryRegistryService registryService; + + @Inject + @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") + String cloneDirectory; + + @Inject + GitHubCloneManager cloneManager; + + @Inject + GitHubVersionService versionService; + + @Inject + public GitHubArchitectureStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public List getArchitecturesForNamespace(String namespace, PageRequest page) throws NamespaceNotFoundException { + verifyNamespace(namespace); + List entries = registryService.listByType(namespace, CalmResourceType.ARCHITECTURE); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; + return entries.stream() + .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) + .toList(); + } + + @Override + public Architecture createArchitectureForNamespace(Architecture architecture) throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public List getArchitectureVersions(Architecture architecture) throws NamespaceNotFoundException, ArchitectureNotFoundException { + verifyNamespace(architecture.getNamespace()); + RegistryEntry entry = findEntryById(architecture.getNamespace(), architecture.getId()); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(architecture.getNamespace()) : null; + if (repo != null && versionService != null) { + return versionService.getFileVersions(repo, entry.filePath().toString()); + } + return List.of("latest"); + } + + @Override + public String getArchitectureForVersion(Architecture architecture) throws NamespaceNotFoundException, ArchitectureNotFoundException, ArchitectureVersionNotFoundException { + verifyNamespace(architecture.getNamespace()); + RegistryEntry entry = findEntryById(architecture.getNamespace(), architecture.getId()); + String version = architecture.getDotVersion(); + + // If a specific SHA is requested and version service is available, fetch from GitHub API + if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") + && cloneManager != null && versionService != null) { + String repo = cloneManager.getRepoForNamespace(architecture.getNamespace()); + if (repo != null) { + String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); + if (content != null) { + return content; + } + } + } + + // Fallback: read from local clone (latest/HEAD) + try { + Path filePath = Path.of(cloneDirectory, architecture.getNamespace()).resolve(entry.filePath()); + return Files.readString(filePath); + } catch (IOException e) { + LOG.error("Failed to read architecture file: {}", entry.filePath(), e); + throw new ArchitectureVersionNotFoundException(); + } + } + + @Override + public Architecture createArchitectureForVersion(Architecture architecture) throws NamespaceNotFoundException, ArchitectureNotFoundException, ArchitectureVersionExistsException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public Architecture updateArchitectureForVersion(Architecture architecture) throws NamespaceNotFoundException, ArchitectureNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteArchitecture(String namespace, int architectureId) throws NamespaceNotFoundException, ArchitectureNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + private RegistryEntry findEntryById(String namespace, int id) throws ArchitectureNotFoundException { + List entries = registryService.listByType(namespace, CalmResourceType.ARCHITECTURE); + Optional found = entries.stream() + .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) + .findFirst(); + if (found.isEmpty()) { + throw new ArchitectureNotFoundException(); + } + return found.get(); + } + + private void verifyNamespace(String namespace) throws NamespaceNotFoundException { + if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { + throw new NamespaceNotFoundException(); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAuditLogStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAuditLogStore.java new file mode 100644 index 0000000000..2467e7a241 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAuditLogStore.java @@ -0,0 +1,33 @@ +package org.finos.calm.store.github; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import org.finos.calm.domain.audit.AuditLogEntry; +import org.finos.calm.domain.audit.AuditLogQuery; +import org.finos.calm.store.AuditLogStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * No-op audit store for GitHub mode. Audit trail is captured by + * AuditRequestFilter + OTEL structured logging — no database persistence needed. + */ +@ApplicationScoped +@Typed(GitHubAuditLogStore.class) +public class GitHubAuditLogStore implements AuditLogStore { + + private static final Logger LOG = LoggerFactory.getLogger(GitHubAuditLogStore.class); + + @Override + public void record(AuditLogEntry entry) { + LOG.debug("Audit (log-only): user={} action={} entityType={}", + entry.getActor(), entry.getAction(), entry.getEntityType()); + } + + @Override + public List query(AuditLogQuery query) { + return List.of(); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java new file mode 100644 index 0000000000..d4c8872e59 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java @@ -0,0 +1,132 @@ +package org.finos.calm.store.github; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.domain.exception.BuildingBlockNotFoundException; +import org.finos.calm.domain.exception.BuildingBlockVersionNotFoundException; +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.namespaces.NamespaceResourceSummary; +import org.finos.calm.store.BuildingBlockStore; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +@Typed(GitHubBuildingBlockStore.class) +public class GitHubBuildingBlockStore implements BuildingBlockStore { + + private static final String WRITE_UNSUPPORTED = + "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; + + private static final Logger LOG = LoggerFactory.getLogger(GitHubBuildingBlockStore.class); + + private final InMemoryRegistryService registryService; + + @Inject + @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") + String cloneDirectory; + + @Inject + GitHubCloneManager cloneManager; + + @Inject + GitHubVersionService versionService; + + @Inject + public GitHubBuildingBlockStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public List getBuildingBlocksForNamespace(String namespace) throws NamespaceNotFoundException { + verifyNamespace(namespace); + List entries = registryService.listByType(namespace, CalmResourceType.BUILDING_BLOCK); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; + return entries.stream() + .map(e -> { + + if (repo != null && versionService != null) { + } + return new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0); + }) + .toList(); + } + + @Override + public int createBuildingBlockForNamespace(String namespace, String buildingBlockJson) throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public List getBuildingBlockVersions(String namespace, int buildingBlockId) throws NamespaceNotFoundException, BuildingBlockNotFoundException { + verifyNamespace(namespace); + RegistryEntry entry = findEntryById(namespace, buildingBlockId); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; + if (repo != null && versionService != null) { + return versionService.getFileVersions(repo, entry.filePath().toString()); + } + return List.of("latest"); + } + + @Override + public String getBuildingBlockForVersion(String namespace, int buildingBlockId, String version) throws NamespaceNotFoundException, BuildingBlockNotFoundException, BuildingBlockVersionNotFoundException { + verifyNamespace(namespace); + RegistryEntry entry = findEntryById(namespace, buildingBlockId); + + // If a specific SHA is requested and version service is available, fetch from GitHub API + if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") + && cloneManager != null && versionService != null) { + String repo = cloneManager.getRepoForNamespace(namespace); + if (repo != null) { + String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); + if (content != null) { + return content; + } + } + } + + // Fallback: read from local clone (latest/HEAD) + try { + Path filePath = Path.of(cloneDirectory, namespace).resolve(entry.filePath()); + return Files.readString(filePath); + } catch (IOException e) { + LOG.error("Failed to read building block file: {}", entry.filePath(), e); + throw new BuildingBlockVersionNotFoundException(); + } + } + + @Override + public void createBuildingBlockForVersion(String namespace, int buildingBlockId, String version, String buildingBlockJson) throws NamespaceNotFoundException, BuildingBlockNotFoundException, BuildingBlockVersionNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + private RegistryEntry findEntryById(String namespace, int id) throws BuildingBlockNotFoundException { + List entries = registryService.listByType(namespace, CalmResourceType.BUILDING_BLOCK); + Optional found = entries.stream() + .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) + .findFirst(); + if (found.isEmpty()) { + throw new BuildingBlockNotFoundException(); + } + return found.get(); + } + + private void verifyNamespace(String namespace) throws NamespaceNotFoundException { + if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { + throw new NamespaceNotFoundException(); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java new file mode 100644 index 0000000000..75e511dc6a --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java @@ -0,0 +1,234 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.domain.controls.ControlConfigDetail; +import org.finos.calm.domain.controls.ControlDetail; +import org.finos.calm.domain.controls.CreateControlConfiguration; +import org.finos.calm.domain.controls.CreateControlRequirement; +import org.finos.calm.domain.exception.ControlConfigurationNotFoundException; +import org.finos.calm.domain.exception.ControlConfigurationVersionExistsException; +import org.finos.calm.domain.exception.ControlConfigurationVersionNotFoundException; +import org.finos.calm.domain.exception.ControlHasConfigurationsException; +import org.finos.calm.domain.exception.ControlNotFoundException; +import org.finos.calm.domain.exception.ControlRequirementVersionExistsException; +import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; +import org.finos.calm.domain.exception.DomainNotFoundException; +import org.finos.calm.store.ControlStore; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.NamespaceAccessFilter; +import org.finos.calm.store.github.util.RegistryEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@ApplicationScoped +@Typed(GitHubControlStore.class) +public class GitHubControlStore implements ControlStore { + + private static final String WRITE_UNSUPPORTED = + "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; + + private static final Logger LOG = LoggerFactory.getLogger(GitHubControlStore.class); + + private final InMemoryRegistryService registryService; + + @Inject + @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") + String cloneDirectory; + + @Inject + GitHubCloneManager cloneManager; + + @Inject + GitHubVersionService versionService; + + @Inject + NamespaceAccessFilter accessFilter; + + @Inject + public GitHubControlStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public List getControlsForDomain(String domain) throws DomainNotFoundException { + Set accessible = resolveAccessibleNamespaces(); + List results = new java.util.ArrayList<>(); + for (String namespace : registryService.getSnapshot().getNamespaces()) { + if (!accessible.contains(namespace)) { + continue; + } + List entries = registryService.listByType(namespace, CalmResourceType.CONTROL); + for (RegistryEntry entry : entries) { + String path = entry.filePath().toString(); + if (path.contains("controls/" + domain + "/") || path.contains("controls\\" + domain + "\\")) { + results.add(new ControlDetail( + (entry.uniqueId().hashCode() & 0x7FFFFFFF), + entry.uniqueId(), + null, + entry.name())); + } + } + } + if (results.isEmpty()) { + throw new DomainNotFoundException(domain); + } + return results; + } + + @Override + public ControlDetail createControlRequirement(CreateControlRequirement request, String domain) throws DomainNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public List getRequirementVersions(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException { + RegistryEntry entry = findControlEntry(domain, controlId); + String namespace = findNamespaceForControl(entry); + String repo = cloneManager != null && namespace != null ? cloneManager.getRepoForNamespace(namespace) : null; + if (repo != null && versionService != null) { + return versionService.getFileVersions(repo, entry.filePath().toString()); + } + return List.of("latest"); + } + + @Override + public String getRequirementForVersion(String domain, int controlId, String version) throws DomainNotFoundException, ControlNotFoundException, ControlRequirementVersionNotFoundException { + RegistryEntry entry = findControlEntry(domain, controlId); + String namespace = findNamespaceForControl(entry); + + // If a specific SHA is requested and version service is available, fetch from GitHub API + if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") + && cloneManager != null && versionService != null && namespace != null) { + String repo = cloneManager.getRepoForNamespace(namespace); + if (repo != null) { + String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); + if (content != null) { + return content; + } + } + } + + // Fallback: read from local clone (latest/HEAD) + try { + Path filePath = Path.of(cloneDirectory, namespace != null ? namespace : domain).resolve(entry.filePath()); + return Files.readString(filePath); + } catch (IOException e) { + LOG.error("Failed to read control file: {}", entry.filePath(), e); + throw new ControlRequirementVersionNotFoundException(); + } + } + + @Override + public void createRequirementForVersion(String domain, int controlId, String version, CreateControlRequirement request) throws DomainNotFoundException, ControlNotFoundException, ControlRequirementVersionExistsException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public List getConfigurationsForControl(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public List getConfigurationDetailsForControl(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public int createControlConfiguration(CreateControlConfiguration request, String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public List getConfigurationVersions(String domain, int controlId, int configurationId) throws DomainNotFoundException, ControlNotFoundException, ControlConfigurationNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public String getConfigurationForVersion(String domain, int controlId, int configurationId, String version) throws DomainNotFoundException, ControlNotFoundException, ControlConfigurationNotFoundException, ControlConfigurationVersionNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void createConfigurationForVersion(String domain, int controlId, int configurationId, String version, CreateControlConfiguration request) throws DomainNotFoundException, ControlNotFoundException, ControlConfigurationNotFoundException, ControlConfigurationVersionExistsException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteControlRequirement(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException, ControlHasConfigurationsException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteControlConfiguration(String domain, int controlId, int configurationId) throws DomainNotFoundException, ControlNotFoundException, ControlConfigurationNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + private RegistryEntry findControlEntry(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException { + Set accessible = resolveAccessibleNamespaces(); + boolean domainExists = false; + for (String namespace : registryService.getSnapshot().getNamespaces()) { + if (!accessible.contains(namespace)) { + continue; + } + List entries = registryService.listByType(namespace, CalmResourceType.CONTROL); + for (RegistryEntry entry : entries) { + String path = entry.filePath().toString(); + boolean inDomain = path.contains("controls/" + domain + "/") || path.contains("controls\\" + domain + "\\"); + if (inDomain) { + domainExists = true; + if ((entry.uniqueId().hashCode() & 0x7FFFFFFF) == controlId) { + return entry; + } + } + } + } + if (!domainExists) { + for (String namespace : registryService.getSnapshot().getNamespaces()) { + if (!accessible.contains(namespace)) { + continue; + } + List entries = registryService.listByType(namespace, CalmResourceType.CONTROL); + Optional found = entries.stream() + .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == controlId) + .findFirst(); + if (found.isPresent()) return found.get(); + } + throw new DomainNotFoundException(domain); + } + throw new ControlNotFoundException(); + } + + private String findNamespaceForControl(RegistryEntry entry) { + Set accessible = resolveAccessibleNamespaces(); + for (String namespace : registryService.getSnapshot().getNamespaces()) { + if (!accessible.contains(namespace)) { + continue; + } + List entries = registryService.listByType(namespace, CalmResourceType.CONTROL); + if (entries.contains(entry)) return namespace; + } + return null; + } + + private Set resolveAccessibleNamespaces() { + if (accessFilter == null) { + return new java.util.HashSet<>(registryService.getSnapshot().getNamespaces()); + } + return accessFilter.getAccessibleNamespaces(); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java new file mode 100644 index 0000000000..d3a9123268 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java @@ -0,0 +1,70 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.finos.calm.domain.Decorator; +import org.finos.calm.domain.exception.DecoratorNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.DecoratorStore; +import org.finos.calm.store.github.util.InMemoryRegistryService; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +@Typed(GitHubDecoratorStore.class) +public class GitHubDecoratorStore implements DecoratorStore { + + private static final String WRITE_UNSUPPORTED = + "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; + + private final InMemoryRegistryService registryService; + + @Inject + public GitHubDecoratorStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public List getDecoratorsForNamespace(String namespace, String target, String type) throws NamespaceNotFoundException { + verifyNamespace(namespace); + return Collections.emptyList(); + } + + @Override + public List getDecoratorValuesForNamespace(String namespace, String target, String type) throws NamespaceNotFoundException { + verifyNamespace(namespace); + return Collections.emptyList(); + } + + @Override + public Optional getDecoratorById(String namespace, int id) throws NamespaceNotFoundException { + verifyNamespace(namespace); + return Optional.empty(); + } + + @Override + public int createDecorator(String namespace, String decoratorJson) throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void updateDecorator(String namespace, int id, String decoratorJson) throws NamespaceNotFoundException, DecoratorNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteDecorator(String namespace, int id) throws NamespaceNotFoundException, DecoratorNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + private void verifyNamespace(String namespace) throws NamespaceNotFoundException { + if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { + throw new NamespaceNotFoundException(); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java new file mode 100644 index 0000000000..c08588f11e --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java @@ -0,0 +1,86 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.finos.calm.domain.Domain; +import org.finos.calm.domain.exception.DomainAlreadyExistsException; +import org.finos.calm.domain.exception.DomainNotFoundException; +import org.finos.calm.store.DomainStore; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.NamespaceAccessFilter; +import org.finos.calm.store.github.util.RegistryEntry; + +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +/** + * Domains are derived from the controls/ directory structure in each namespace's repo. + * E.g. controls/security/*.json -> domain "security". + */ +@ApplicationScoped +@Typed(GitHubDomainStore.class) +public class GitHubDomainStore implements DomainStore { + + private static final String UNSUPPORTED_MSG = + "Domains in GitHub mode are derived from the controls/ directory structure in the repo."; + + private final InMemoryRegistryService registryService; + + @Inject + NamespaceAccessFilter accessFilter; + + @Inject + public GitHubDomainStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public List getDomains() { + Set accessible = resolveAccessibleNamespaces(); + return registryService.getSnapshot().entriesByNamespace().entrySet().stream() + .filter(e -> accessible.contains(e.getKey())) + .flatMap(e -> e.getValue().stream()) + .filter(entry -> entry.type() == CalmResourceType.CONTROL) + .map(this::extractDomain) + .distinct() + .toList(); + } + + @Override + public Domain createDomain(String name) throws DomainAlreadyExistsException { + throw new GitHubWriteNotSupportedException(UNSUPPORTED_MSG); + } + + @Override + public boolean domainExists(String name) { + return getDomains().contains(name); + } + + @Override + public void deleteDomain(String name) throws DomainNotFoundException { + throw new GitHubWriteNotSupportedException(UNSUPPORTED_MSG); + } + + private String extractDomain(RegistryEntry entry) { + Path filePath = entry.filePath(); + if (filePath.getNameCount() >= 2) { + String firstDir = filePath.getName(0).toString(); + if ("controls".equals(firstDir)) { + return filePath.getName(1).toString(); + } + } + return "default"; + } + + private Set resolveAccessibleNamespaces() { + if (accessFilter == null) { + return new java.util.HashSet<>(registryService.getSnapshot().getNamespaces()); + } + return accessFilter.getAccessibleNamespaces(); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java new file mode 100644 index 0000000000..b896fa8984 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java @@ -0,0 +1,147 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.domain.Flow; +import org.finos.calm.domain.exception.FlowNotFoundException; +import org.finos.calm.domain.exception.FlowVersionExistsException; +import org.finos.calm.domain.exception.FlowVersionNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.flow.CreateFlowRequest; +import org.finos.calm.domain.namespaces.NamespaceResourceSummary; +import org.finos.calm.store.FlowStore; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +@Typed(GitHubFlowStore.class) +public class GitHubFlowStore implements FlowStore { + + private static final String WRITE_UNSUPPORTED = + "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; + + private static final Logger LOG = LoggerFactory.getLogger(GitHubFlowStore.class); + + private final InMemoryRegistryService registryService; + + @Inject + @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") + String cloneDirectory; + + @Inject + GitHubCloneManager cloneManager; + + @Inject + GitHubVersionService versionService; + + @Inject + public GitHubFlowStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public List getFlowsForNamespace(String namespace) throws NamespaceNotFoundException { + verifyNamespace(namespace); + List entries = registryService.listByType(namespace, CalmResourceType.FLOW); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; + return entries.stream() + .map(e -> { + + if (repo != null && versionService != null) { + } + return new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0); + }) + .toList(); + } + + @Override + public Flow createFlowForNamespace(CreateFlowRequest flowRequest, String namespace) throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public List getFlowVersions(Flow flow) throws NamespaceNotFoundException, FlowNotFoundException { + verifyNamespace(flow.getNamespace()); + RegistryEntry entry = findEntryById(flow.getNamespace(), flow.getId()); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(flow.getNamespace()) : null; + if (repo != null && versionService != null) { + return versionService.getFileVersions(repo, entry.filePath().toString()); + } + return List.of("latest"); + } + + @Override + public String getFlowForVersion(Flow flow) throws NamespaceNotFoundException, FlowNotFoundException, FlowVersionNotFoundException { + verifyNamespace(flow.getNamespace()); + RegistryEntry entry = findEntryById(flow.getNamespace(), flow.getId()); + String version = flow.getDotVersion(); + + // If a specific SHA is requested and version service is available, fetch from GitHub API + if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") + && cloneManager != null && versionService != null) { + String repo = cloneManager.getRepoForNamespace(flow.getNamespace()); + if (repo != null) { + String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); + if (content != null) { + return content; + } + } + } + + // Fallback: read from local clone (latest/HEAD) + try { + Path filePath = Path.of(cloneDirectory, flow.getNamespace()).resolve(entry.filePath()); + return Files.readString(filePath); + } catch (IOException e) { + LOG.error("Failed to read flow file: {}", entry.filePath(), e); + throw new FlowVersionNotFoundException(); + } + } + + @Override + public Flow createFlowForVersion(Flow flow) throws NamespaceNotFoundException, FlowNotFoundException, FlowVersionExistsException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public Flow updateFlowForVersion(Flow flow) throws NamespaceNotFoundException, FlowNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteFlow(String namespace, int flowId) throws NamespaceNotFoundException, FlowNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + private RegistryEntry findEntryById(String namespace, int id) throws FlowNotFoundException { + List entries = registryService.listByType(namespace, CalmResourceType.FLOW); + Optional found = entries.stream() + .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) + .findFirst(); + if (found.isEmpty()) { + throw new FlowNotFoundException(); + } + return found.get(); + } + + private void verifyNamespace(String namespace) throws NamespaceNotFoundException { + if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { + throw new NamespaceNotFoundException(); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java new file mode 100644 index 0000000000..a0c9857557 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java @@ -0,0 +1,141 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.domain.CalmInterface; +import org.finos.calm.domain.exception.InterfaceNotFoundException; +import org.finos.calm.domain.exception.InterfaceVersionExistsException; +import org.finos.calm.domain.exception.InterfaceVersionNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.interfaces.CreateInterfaceRequest; +import org.finos.calm.domain.interfaces.NamespaceInterfaceSummary; +import org.finos.calm.store.InterfaceStore; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +@Typed(GitHubInterfaceStore.class) +public class GitHubInterfaceStore implements InterfaceStore { + + private static final String WRITE_UNSUPPORTED = + "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; + + private static final Logger LOG = LoggerFactory.getLogger(GitHubInterfaceStore.class); + + private final InMemoryRegistryService registryService; + + @Inject + @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") + String cloneDirectory; + + @Inject + GitHubCloneManager cloneManager; + + @Inject + GitHubVersionService versionService; + + @Inject + public GitHubInterfaceStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public List getInterfacesForNamespace(String namespace) throws NamespaceNotFoundException { + verifyNamespace(namespace); + List entries = registryService.listByType(namespace, CalmResourceType.INTERFACE); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; + return entries.stream() + .map(e -> { + + if (repo != null && versionService != null) { + } + return new NamespaceInterfaceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF)); + }) + .toList(); + } + + @Override + public CalmInterface createInterfaceForNamespace(CreateInterfaceRequest interfaceRequest, String namespace) throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public List getInterfaceVersions(String namespace, Integer interfaceId) throws NamespaceNotFoundException, InterfaceNotFoundException { + verifyNamespace(namespace); + RegistryEntry entry = findEntryById(namespace, interfaceId); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; + if (repo != null && versionService != null) { + return versionService.getFileVersions(repo, entry.filePath().toString()); + } + return List.of("latest"); + } + + @Override + public String getInterfaceForVersion(String namespace, Integer interfaceId, String version) throws NamespaceNotFoundException, InterfaceNotFoundException, InterfaceVersionNotFoundException { + verifyNamespace(namespace); + RegistryEntry entry = findEntryById(namespace, interfaceId); + + // If a specific SHA is requested and version service is available, fetch from GitHub API + if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") + && cloneManager != null && versionService != null) { + String repo = cloneManager.getRepoForNamespace(namespace); + if (repo != null) { + String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); + if (content != null) { + return content; + } + } + } + + // Fallback: read from local clone (latest/HEAD) + try { + Path filePath = Path.of(cloneDirectory, namespace).resolve(entry.filePath()); + return Files.readString(filePath); + } catch (IOException e) { + LOG.error("Failed to read interface file: {}", entry.filePath(), e); + throw new InterfaceVersionNotFoundException(); + } + } + + @Override + public CalmInterface createInterfaceForVersion(CreateInterfaceRequest interfaceRequest, String namespace, Integer interfaceId, String version) throws NamespaceNotFoundException, InterfaceNotFoundException, InterfaceVersionExistsException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteInterface(String namespace, Integer interfaceId) throws NamespaceNotFoundException, InterfaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + private RegistryEntry findEntryById(String namespace, int id) throws InterfaceNotFoundException { + List entries = registryService.listByType(namespace, CalmResourceType.INTERFACE); + Optional found = entries.stream() + .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) + .findFirst(); + if (found.isEmpty()) { + throw new InterfaceNotFoundException(); + } + return found.get(); + } + + private void verifyNamespace(String namespace) throws NamespaceNotFoundException { + if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { + throw new NamespaceNotFoundException(); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubLayoutStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubLayoutStore.java new file mode 100644 index 0000000000..7bafaddb6f --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubLayoutStore.java @@ -0,0 +1,33 @@ +package org.finos.calm.store.github; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.LayoutStore; + +import java.util.List; +import java.util.Optional; + +/** + * Layout persistence is not supported in GitHub mode — layouts are client-side state + * managed via the browser's localStorage. All methods return empty/no-op responses. + */ +@ApplicationScoped +@Typed(GitHubLayoutStore.class) +public class GitHubLayoutStore implements LayoutStore { + + @Override + public Optional getLayout(String namespace, int architectureId) throws NamespaceNotFoundException { + return Optional.empty(); + } + + @Override + public void upsertLayout(String namespace, int architectureId, String layoutJson) throws NamespaceNotFoundException { + // no-op — layouts not persisted in GitHub mode + } + + @Override + public List getArchitectureIdsWithLayoutForNamespace(String namespace) throws NamespaceNotFoundException { + return List.of(); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java new file mode 100644 index 0000000000..211a6964ab --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java @@ -0,0 +1,57 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.finos.calm.domain.exception.NamespaceAlreadyExistsException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.namespaces.NamespaceInfo; +import org.finos.calm.store.NamespaceStore; +import org.finos.calm.store.github.util.InMemoryRegistryService; + +import java.util.List; + +@ApplicationScoped +@Typed(GitHubNamespaceStore.class) +public class GitHubNamespaceStore implements NamespaceStore { + + private static final String UNSUPPORTED_MSG = + "Namespaces in GitHub mode are admin-configured via deployment properties (calm.github.namespaces[*]). " + + "Update your deployment configuration to add or remove namespaces."; + + private final InMemoryRegistryService registryService; + + @Inject + public GitHubNamespaceStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public List getNamespaces() { + return registryService.getSnapshot().getNamespaces().stream() + .map(name -> new NamespaceInfo(name, "")) + .toList(); + } + + @Override + public boolean namespaceExists(String namespaceName) { + return registryService.getSnapshot().getNamespaces().contains(namespaceName); + } + + @Override + public void createNamespace(String name, String description) throws NamespaceAlreadyExistsException { + throw new GitHubWriteNotSupportedException(UNSUPPORTED_MSG); + } + + @Override + public void updateNamespaceDescription(String name, String description) throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(UNSUPPORTED_MSG); + } + + @Override + public void deleteNamespace(String name) throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(UNSUPPORTED_MSG); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java new file mode 100644 index 0000000000..fb9ec1901e --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java @@ -0,0 +1,149 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.bson.json.JsonParseException; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.domain.Pattern; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.PatternNotFoundException; +import org.finos.calm.domain.exception.PatternVersionExistsException; +import org.finos.calm.domain.exception.PatternVersionNotFoundException; +import org.finos.calm.domain.namespaces.NamespaceResourceSummary; +import org.finos.calm.domain.pattern.CreatePatternRequest; +import org.finos.calm.store.PageRequest; +import org.finos.calm.store.PatternStore; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +@Typed(GitHubPatternStore.class) +public class GitHubPatternStore implements PatternStore { + + private static final String WRITE_UNSUPPORTED = + "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; + + private static final Logger LOG = LoggerFactory.getLogger(GitHubPatternStore.class); + + private final InMemoryRegistryService registryService; + + @Inject + @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") + String cloneDirectory; + + @Inject + GitHubCloneManager cloneManager; + + @Inject + GitHubVersionService versionService; + + @Inject + public GitHubPatternStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public List getPatternsForNamespace(String namespace, PageRequest page) throws NamespaceNotFoundException { + verifyNamespace(namespace); + List entries = registryService.listByType(namespace, CalmResourceType.PATTERN); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; + return entries.stream() + .map(e -> { + + if (repo != null && versionService != null) { + } + return new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0); + }) + .toList(); + } + + @Override + public Pattern createPatternForNamespace(CreatePatternRequest patternRequest, String namespace) throws NamespaceNotFoundException, JsonParseException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public List getPatternVersions(Pattern pattern) throws NamespaceNotFoundException, PatternNotFoundException { + verifyNamespace(pattern.getNamespace()); + RegistryEntry entry = findEntryById(pattern.getNamespace(), pattern.getId()); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(pattern.getNamespace()) : null; + if (repo != null && versionService != null) { + return versionService.getFileVersions(repo, entry.filePath().toString()); + } + return List.of("latest"); + } + + @Override + public String getPatternForVersion(Pattern pattern) throws NamespaceNotFoundException, PatternNotFoundException, PatternVersionNotFoundException { + verifyNamespace(pattern.getNamespace()); + RegistryEntry entry = findEntryById(pattern.getNamespace(), pattern.getId()); + String version = pattern.getDotVersion(); + + // If a specific SHA is requested and version service is available, fetch from GitHub API + if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") + && cloneManager != null && versionService != null) { + String repo = cloneManager.getRepoForNamespace(pattern.getNamespace()); + if (repo != null) { + String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); + if (content != null) { + return content; + } + } + } + + // Fallback: read from local clone (latest/HEAD) + try { + Path filePath = Path.of(cloneDirectory, pattern.getNamespace()).resolve(entry.filePath()); + return Files.readString(filePath); + } catch (IOException e) { + LOG.error("Failed to read pattern file: {}", entry.filePath(), e); + throw new PatternVersionNotFoundException(); + } + } + + @Override + public Pattern createPatternForVersion(Pattern pattern) throws NamespaceNotFoundException, PatternNotFoundException, PatternVersionExistsException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public Pattern updatePatternForVersion(Pattern pattern) throws NamespaceNotFoundException, PatternNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deletePattern(String namespace, int patternId) throws NamespaceNotFoundException, PatternNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + private RegistryEntry findEntryById(String namespace, int id) throws PatternNotFoundException { + List entries = registryService.listByType(namespace, CalmResourceType.PATTERN); + Optional found = entries.stream() + .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) + .findFirst(); + if (found.isEmpty()) { + throw new PatternNotFoundException(); + } + return found.get(); + } + + private void verifyNamespace(String namespace) throws NamespaceNotFoundException { + if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { + throw new NamespaceNotFoundException(); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java new file mode 100644 index 0000000000..2a3cd1937a --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java @@ -0,0 +1,131 @@ +package org.finos.calm.store.github; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.finos.calm.domain.ResourceMapping; +import org.finos.calm.domain.ResourceType; +import org.finos.calm.domain.exception.DuplicateMappingException; +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; +import org.finos.calm.domain.exception.MappingNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.ResourceMappingStore; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; + +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +@Typed(GitHubResourceMappingStore.class) +public class GitHubResourceMappingStore implements ResourceMappingStore { + + private static final String WRITE_UNSUPPORTED = + "Resource ID mapping is managed by the GitHub repository. Writes are not supported in GitHub storage mode."; + + private final InMemoryRegistryService registryService; + + @Inject + public GitHubResourceMappingStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public ResourceMapping getMapping(String namespace, ResourceType type, String customId) + throws MappingNotFoundException, NamespaceNotFoundException { + verifyNamespace(namespace); + CalmResourceType calmType = toCalmResourceType(type); + Optional entry = registryService.findByUniqueId(namespace, customId); + if (entry.isEmpty() || entry.get().type() != calmType) { + throw new MappingNotFoundException(); + } + return toResourceMapping(namespace, type, entry.get()); + } + + @Override + public List listMappings(String namespace, ResourceType typeFilter) + throws NamespaceNotFoundException { + verifyNamespace(namespace); + CalmResourceType calmType = toCalmResourceType(typeFilter); + return registryService.listByType(namespace, calmType).stream() + .map(e -> toResourceMapping(namespace, typeFilter, e)) + .toList(); + } + + @Override + public ResourceMapping getMappingByNumericId(String namespace, ResourceType type, int numericId) + throws MappingNotFoundException, NamespaceNotFoundException { + verifyNamespace(namespace); + CalmResourceType calmType = toCalmResourceType(type); + Optional found = registryService.listByType(namespace, calmType).stream() + .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == numericId) + .findFirst(); + if (found.isEmpty()) { + throw new MappingNotFoundException(); + } + return toResourceMapping(namespace, type, found.get()); + } + + @Override + public List listMappingsByNumericIds(String namespace, ResourceType type, List ids) + throws NamespaceNotFoundException { + verifyNamespace(namespace); + CalmResourceType calmType = toCalmResourceType(type); + return registryService.listByType(namespace, calmType).stream() + .filter(e -> ids.contains(e.uniqueId().hashCode() & 0x7FFFFFFF)) + .map(e -> toResourceMapping(namespace, type, e)) + .toList(); + } + + @Override + public ResourceMapping createMapping(String namespace, String customId, ResourceType type, int numericId) + throws DuplicateMappingException, NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void updateMappingNumericId(String namespace, ResourceType type, String customId, int numericId) + throws MappingNotFoundException, NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteMapping(String namespace, ResourceType type, String customId) + throws MappingNotFoundException, NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteMappingByNumericId(String namespace, ResourceType type, int numericId) + throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + private ResourceMapping toResourceMapping(String namespace, ResourceType type, RegistryEntry entry) { + return new ResourceMapping.ResourceMappingBuilder() + .setNamespace(namespace) + .setCustomId(entry.uniqueId()) + .setResourceType(type) + .setNumericId(entry.uniqueId().hashCode() & 0x7FFFFFFF) + .build(); + } + + static CalmResourceType toCalmResourceType(ResourceType type) { + return switch (type) { + case PATTERN -> CalmResourceType.PATTERN; + case ARCHITECTURE -> CalmResourceType.ARCHITECTURE; + case FLOW -> CalmResourceType.FLOW; + case STANDARD -> CalmResourceType.STANDARD; + case INTERFACE -> CalmResourceType.INTERFACE; + case BUILDING_BLOCK -> CalmResourceType.BUILDING_BLOCK; + case CONTROL -> CalmResourceType.CONTROL; + }; + } + + private void verifyNamespace(String namespace) throws NamespaceNotFoundException { + if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { + throw new NamespaceNotFoundException(); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java new file mode 100644 index 0000000000..d44a50208d --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java @@ -0,0 +1,70 @@ +package org.finos.calm.store.github; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.finos.calm.domain.search.GroupedSearchResults; +import org.finos.calm.domain.search.SearchResult; +import org.finos.calm.store.SearchStore; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@ApplicationScoped +@Typed(GitHubSearchStore.class) +public class GitHubSearchStore implements SearchStore { + + @Inject + InMemoryRegistryService registryService; + + @Override + public GroupedSearchResults search(String query, Optional> readableNamespaces) { + if (query == null || query.isBlank()) { + return new GroupedSearchResults( + List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of()); + } + + String lowerQuery = query.toLowerCase(); + List allEntries = registryService.getSnapshot().getNamespaces().stream() + .filter(ns -> readableNamespaces.isEmpty() || readableNamespaces.get().contains(ns)) + .flatMap(ns -> registryService.getSnapshot().listAll(ns).stream()) + .filter(e -> matchesQuery(e, lowerQuery)) + .limit(MAX_RESULTS_PER_TYPE * 7L) + .toList(); + + return new GroupedSearchResults( + filterByType(allEntries, CalmResourceType.ARCHITECTURE), + filterByType(allEntries, CalmResourceType.PATTERN), + filterByType(allEntries, CalmResourceType.FLOW), + filterByType(allEntries, CalmResourceType.STANDARD), + filterByType(allEntries, CalmResourceType.INTERFACE), + filterByType(allEntries, CalmResourceType.CONTROL), + filterByType(allEntries, CalmResourceType.ADR) + ); + } + + private boolean matchesQuery(RegistryEntry entry, String lowerQuery) { + return (entry.name() != null && entry.name().toLowerCase().contains(lowerQuery)) + || (entry.uniqueId() != null && entry.uniqueId().toLowerCase().contains(lowerQuery)); + } + + private List filterByType(List entries, CalmResourceType type) { + return entries.stream() + .filter(e -> e.type() == type) + .limit(MAX_RESULTS_PER_TYPE) + .map(e -> new SearchResult( + registryService.getSnapshot().getNamespaces().stream() + .filter(ns -> registryService.getSnapshot().listAll(ns).contains(e)) + .findFirst().orElse(""), + (e.uniqueId().hashCode() & 0x7FFFFFFF), + e.name(), + e.uniqueId() + )) + .collect(Collectors.toList()); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java new file mode 100644 index 0000000000..aa5bec2498 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java @@ -0,0 +1,151 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.domain.Standard; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.StandardNotFoundException; +import org.finos.calm.domain.exception.StandardVersionExistsException; +import org.finos.calm.domain.exception.StandardVersionNotFoundException; +import org.finos.calm.domain.namespaces.NamespaceResourceSummary; +import org.finos.calm.domain.standards.CreateStandardRequest; +import org.finos.calm.store.StandardStore; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +@Typed(GitHubStandardStore.class) +public class GitHubStandardStore implements StandardStore { + + private static final String WRITE_UNSUPPORTED = + "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; + + private static final Logger LOG = LoggerFactory.getLogger(GitHubStandardStore.class); + + private final InMemoryRegistryService registryService; + + @Inject + @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") + String cloneDirectory; + + @Inject + GitHubCloneManager cloneManager; + + @Inject + GitHubVersionService versionService; + + @Inject + public GitHubStandardStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public List getStandardsForNamespace(String namespace) throws NamespaceNotFoundException { + verifyNamespace(namespace); + List entries = registryService.listByType(namespace, CalmResourceType.STANDARD); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; + return entries.stream() + .map(e -> { + + if (repo != null && versionService != null) { + } + return new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0); + }) + .toList(); + } + + @Override + public Standard createStandardForNamespace(CreateStandardRequest standardRequest, String namespace) throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public List getStandardVersions(String namespace, Integer standardId) throws NamespaceNotFoundException, StandardNotFoundException { + verifyNamespace(namespace); + RegistryEntry entry = findEntryById(namespace, standardId); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; + if (repo != null && versionService != null) { + return versionService.getFileVersions(repo, entry.filePath().toString()); + } + return List.of("latest"); + } + + @Override + public String getStandardForVersion(String namespace, Integer standardId, String version) throws NamespaceNotFoundException, StandardNotFoundException, StandardVersionNotFoundException { + verifyNamespace(namespace); + RegistryEntry entry = findEntryById(namespace, standardId); + + // If a specific SHA is requested and version service is available, fetch from GitHub API + if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") + && cloneManager != null && versionService != null) { + String repo = cloneManager.getRepoForNamespace(namespace); + if (repo != null) { + String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); + if (content != null) { + return content; + } + } + } + + // Fallback: read from local clone (latest/HEAD) + try { + Path filePath = Path.of(cloneDirectory, namespace).resolve(entry.filePath()); + // If this is a JSON file, check for a sibling .md file and prefer it + if (filePath.toString().endsWith(".json")) { + String baseName = filePath.getFileName().toString() + .replaceAll("\\.(guideline|standard|calm)\\.json$", "") + .replace(".json", ""); + Path mdSibling = filePath.getParent().resolve(baseName + ".md"); + if (Files.exists(mdSibling)) { + return Files.readString(mdSibling); + } + } + return Files.readString(filePath); + } catch (IOException e) { + LOG.error("Failed to read standard file: {}", entry.filePath(), e); + throw new StandardVersionNotFoundException(); + } + } + + @Override + public Standard createStandardForVersion(CreateStandardRequest standardRequest, String namespace, Integer standardId, String version) throws NamespaceNotFoundException, StandardNotFoundException, StandardVersionExistsException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteStandard(String namespace, Integer standardId) throws NamespaceNotFoundException, StandardNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + private RegistryEntry findEntryById(String namespace, int id) throws StandardNotFoundException { + List entries = registryService.listByType(namespace, CalmResourceType.STANDARD); + Optional found = entries.stream() + .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) + .findFirst(); + if (found.isEmpty()) { + throw new StandardNotFoundException(); + } + return found.get(); + } + + private void verifyNamespace(String namespace) throws NamespaceNotFoundException { + if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { + throw new NamespaceNotFoundException(); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java new file mode 100644 index 0000000000..36c715cbed --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java @@ -0,0 +1,141 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.TimelineNotFoundException; +import org.finos.calm.domain.exception.TimelineVersionExistsException; +import org.finos.calm.domain.exception.TimelineVersionNotFoundException; +import org.finos.calm.domain.timeline.CreateTimelineRequest; +import org.finos.calm.domain.timeline.NamespaceTimelineSummary; +import org.finos.calm.domain.timeline.Timeline; +import org.finos.calm.store.TimelineStore; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +@Typed(GitHubTimelineStore.class) +public class GitHubTimelineStore implements TimelineStore { + + private static final String WRITE_UNSUPPORTED = + "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; + + private static final Logger LOG = LoggerFactory.getLogger(GitHubTimelineStore.class); + + private final InMemoryRegistryService registryService; + + @Inject + @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") + String cloneDirectory; + + @Inject + GitHubCloneManager cloneManager; + + @Inject + GitHubVersionService versionService; + + @Inject + public GitHubTimelineStore(InMemoryRegistryService registryService) { + this.registryService = registryService; + } + + @Override + public List getTimelinesForNamespace(String namespace) throws NamespaceNotFoundException { + verifyNamespace(namespace); + List entries = registryService.listByType(namespace, CalmResourceType.TIMELINE); + return entries.stream() + .map(e -> new NamespaceTimelineSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF))) + .toList(); + } + + @Override + public Timeline createTimelineForNamespace(CreateTimelineRequest timelineRequest, String namespace) throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public List getTimelineVersions(Timeline timeline) throws NamespaceNotFoundException, TimelineNotFoundException { + verifyNamespace(timeline.getNamespace()); + RegistryEntry entry = findEntryById(timeline.getNamespace(), timeline.getId()); + String repo = cloneManager != null ? cloneManager.getRepoForNamespace(timeline.getNamespace()) : null; + if (repo != null && versionService != null) { + return versionService.getFileVersions(repo, entry.filePath().toString()); + } + return List.of("latest"); + } + + @Override + public String getTimelineForVersion(Timeline timeline) throws NamespaceNotFoundException, TimelineNotFoundException, TimelineVersionNotFoundException { + verifyNamespace(timeline.getNamespace()); + RegistryEntry entry = findEntryById(timeline.getNamespace(), timeline.getId()); + String version = timeline.getDotVersion(); + + // If a specific SHA is requested and version service is available, fetch from GitHub API + if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") + && cloneManager != null && versionService != null) { + String repo = cloneManager.getRepoForNamespace(timeline.getNamespace()); + if (repo != null) { + String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); + if (content != null) { + return content; + } + } + } + + // Fallback: read from local clone (latest/HEAD) + try { + Path filePath = Path.of(cloneDirectory, timeline.getNamespace()).resolve(entry.filePath()); + return Files.readString(filePath); + } catch (IOException e) { + LOG.error("Failed to read timeline file: {}", entry.filePath(), e); + throw new TimelineVersionNotFoundException(); + } + } + + @Override + public Timeline createTimelineForVersion(Timeline timeline) throws NamespaceNotFoundException, TimelineNotFoundException, TimelineVersionExistsException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public Timeline updateTimelineForVersion(Timeline timeline) throws NamespaceNotFoundException, TimelineNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteTimeline(String namespace, int timelineId) throws NamespaceNotFoundException, TimelineNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + private RegistryEntry findEntryById(String namespace, int id) throws TimelineNotFoundException { + List entries = registryService.listByType(namespace, CalmResourceType.TIMELINE); + Optional found = entries.stream() + .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) + .findFirst(); + if (found.isEmpty()) { + throw new TimelineNotFoundException(); + } + return found.get(); + } + + private void verifyNamespace(String namespace) throws NamespaceNotFoundException { + if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { + throw new NamespaceNotFoundException(); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java new file mode 100644 index 0000000000..987e5b4727 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java @@ -0,0 +1,132 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import jakarta.inject.Inject; +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.UserAccessNotFoundException; +import org.finos.calm.security.OidcRoleResolver; +import org.finos.calm.store.UserAccessStore; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * In GitHub mode, access is derived from OIDC token roles/claims. + * The user's OIDC token is inspected for configured role values that map to + * READ or WRITE permissions on all cloned namespaces. + */ +@ApplicationScoped +@Typed(GitHubUserAccessStore.class) +public class GitHubUserAccessStore implements UserAccessStore { + + private static final Logger LOG = LoggerFactory.getLogger(GitHubUserAccessStore.class); + + private static final String ADMIN_UNSUPPORTED = + "Access in GitHub mode is derived from OIDC roles. Configure IdP group assignments to manage access."; + private static final String WRITE_UNSUPPORTED = + "Access grants cannot be created in GitHub mode. Assign users to the appropriate IdP roles instead."; + + @Inject + InMemoryRegistryService registryService; + + @Inject + OidcRoleResolver roleResolver; + + @Inject + SecurityIdentity identity; + + @Inject + GitHubCloneManager cloneManager; + + @Override + public List getGrantsForUser(String username) { + List grants = new ArrayList<>(); + List namespaces = registryService.getSnapshot().getNamespaces(); + + if (namespaces.isEmpty()) { + return grants; + } + + for (String namespace : namespaces) { + Set accessGroups = cloneManager != null ? cloneManager.getAccessGroupsForNamespace(namespace) : Set.of(); + + OidcRoleResolver.AccessLevel level = roleResolver.resolve(identity, accessGroups); + + if (level != OidcRoleResolver.AccessLevel.NONE) { + grants.add(new UserAccess(username, UserAccess.Permission.read, namespace)); + } else { + LOG.debug("User [{}] denied access to namespace [{}] — no matching group", username, namespace); + } + } + return grants; + } + + @Override + public List getUserAccessForUsername(String username) throws UserAccessNotFoundException { + List grants = getGrantsForUser(username); + if (grants.isEmpty()) { + throw new UserAccessNotFoundException(); + } + return grants; + } + + @Override + public List getUserAccessForNamespace(String namespace) throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(ADMIN_UNSUPPORTED); + } + + @Override + public UserAccess getUserAccessForNamespaceAndId(String namespace, Integer userAccessId) throws NamespaceNotFoundException, UserAccessNotFoundException { + throw new GitHubWriteNotSupportedException(ADMIN_UNSUPPORTED); + } + + @Override + public List getUserAccessForDomain(String domain) { + throw new GitHubWriteNotSupportedException(ADMIN_UNSUPPORTED); + } + + @Override + public UserAccess getUserAccessForDomainAndId(String domain, Integer userAccessId) throws UserAccessNotFoundException { + throw new GitHubWriteNotSupportedException(ADMIN_UNSUPPORTED); + } + + @Override + public UserAccess createUserAccessForNamespace(UserAccess userAccess) throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public UserAccess createUserAccessForDomain(UserAccess userAccess) { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteUserAccessForNamespace(String namespace, Integer userAccessId) throws NamespaceNotFoundException, UserAccessNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteUserAccessForDomain(String domain, Integer userAccessId) throws UserAccessNotFoundException { + throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + } + + @Override + public void deleteAllUserAccessForNamespace(String namespace) { + // no-op + } + + @Override + public void deleteAllUserAccessForDomain(String domain) { + // no-op + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmContentDetector.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmContentDetector.java new file mode 100644 index 0000000000..bcf341537c --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmContentDetector.java @@ -0,0 +1,138 @@ +package org.finos.calm.store.github.util; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; + +import java.io.StringReader; +import java.nio.file.Path; + +/** + * Detects CALM resource types from JSON content and file path conventions. + * Ported from the VSCode plugin's self-discovery logic: uses a combination of + * JSON structure signals (nodes[], relationships[], moments[]) and parent + * directory naming conventions (architectures/, patterns/, etc.). + */ +@ApplicationScoped +public class CalmContentDetector { + + public CalmResourceType detect(String jsonContent, Path filePath) { + if (jsonContent == null || jsonContent.isBlank()) { + return CalmResourceType.UNKNOWN; + } + + JsonObject json; + try (JsonReader reader = Json.createReader(new StringReader(jsonContent))) { + json = reader.readObject(); + } catch (Exception e) { + return CalmResourceType.UNKNOWN; + } + + String parentDir = getParentDirectoryName(filePath); + + if (isTimeline(json)) { + return CalmResourceType.TIMELINE; + } + + if (hasNodesOrRelationships(json)) { + return detectByDirectoryHint(parentDir, CalmResourceType.UNKNOWN); + } + + if (hasSchemaMatching(json, "flow")) { + return CalmResourceType.FLOW; + } + + if (hasSchemaMatching(json, "interface")) { + return CalmResourceType.INTERFACE; + } + + if (hasSchemaMatching(json, "control")) { + return CalmResourceType.CONTROL; + } + + if (hasSchemaMatching(json, "decorator")) { + return CalmResourceType.DECORATOR; + } + + return detectByDirectoryOnly(parentDir); + } + + private boolean isTimeline(JsonObject json) { + boolean hasMoments = json.containsKey("moments"); + boolean hasTimelineSchema = hasSchemaMatching(json, "timeline"); + return hasMoments || hasTimelineSchema; + } + + private boolean hasNodesOrRelationships(JsonObject json) { + return json.containsKey("nodes") || json.containsKey("relationships"); + } + + private boolean hasSchemaMatching(JsonObject json, String keyword) { + if (!json.containsKey("$schema")) { + return false; + } + String schema = json.getString("$schema", ""); + return schema.toLowerCase().contains(keyword); + } + + private CalmResourceType detectByDirectoryHint(String parentDir, CalmResourceType fallback) { + if (parentDir == null) return fallback; + return switch (parentDir.toLowerCase()) { + case "patterns" -> CalmResourceType.PATTERN; + case "architectures" -> CalmResourceType.ARCHITECTURE; + case "standards" -> CalmResourceType.STANDARD; + case "guidelines" -> CalmResourceType.GUIDELINE; + case "flows" -> CalmResourceType.FLOW; + case "interfaces" -> CalmResourceType.INTERFACE; + case "adrs" -> CalmResourceType.ADR; + case "decorators" -> CalmResourceType.DECORATOR; + case "building-blocks" -> CalmResourceType.BUILDING_BLOCK; + default -> fallback; + }; + } + + private CalmResourceType detectByDirectoryOnly(String parentDir) { + if (parentDir == null) return CalmResourceType.UNKNOWN; + return switch (parentDir.toLowerCase()) { + case "architectures" -> CalmResourceType.ARCHITECTURE; + case "patterns" -> CalmResourceType.PATTERN; + case "standards" -> CalmResourceType.STANDARD; + case "guidelines" -> CalmResourceType.GUIDELINE; + case "controls" -> CalmResourceType.CONTROL; + case "adrs" -> CalmResourceType.ADR; + case "flows" -> CalmResourceType.FLOW; + case "interfaces" -> CalmResourceType.INTERFACE; + case "timelines" -> CalmResourceType.TIMELINE; + case "decorators" -> CalmResourceType.DECORATOR; + case "building-blocks" -> CalmResourceType.BUILDING_BLOCK; + default -> CalmResourceType.UNKNOWN; + }; + } + + private String getParentDirectoryName(Path filePath) { + if (filePath == null) { + return null; + } + for (int i = 0; i < filePath.getNameCount() - 1; i++) { + String segment = filePath.getName(i).toString().toLowerCase(); + if (isKnownDirectory(segment)) { + return segment; + } + } + if (filePath.getParent() != null) { + Path fileName = filePath.getParent().getFileName(); + return fileName != null ? fileName.toString() : null; + } + return null; + } + + private boolean isKnownDirectory(String name) { + return switch (name) { + case "architectures", "patterns", "standards", "guidelines", + "controls", "adrs", "flows", "interfaces", "timelines", "decorators", + "building-blocks" -> true; + default -> false; + }; + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java new file mode 100644 index 0000000000..a03d034a81 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java @@ -0,0 +1,16 @@ +package org.finos.calm.store.github.util; + +public enum CalmResourceType { + ARCHITECTURE, + PATTERN, + STANDARD, + GUIDELINE, + CONTROL, + ADR, + FLOW, + INTERFACE, + TIMELINE, + DECORATOR, + BUILDING_BLOCK, + UNKNOWN +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java new file mode 100644 index 0000000000..f5b13da788 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java @@ -0,0 +1,148 @@ +package org.finos.calm.store.github.util; + +import io.quarkus.arc.lookup.LookupIfProperty; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages parallel cloning of all registered namespace repos on startup, + * tracks clone state, and provides pull-all for periodic sync. + */ +@LookupIfProperty(name = "calm.database.mode", stringValue = "github") +@ApplicationScoped +public class GitHubCloneManager { + + private static final Logger LOG = LoggerFactory.getLogger(GitHubCloneManager.class); + + public enum State { INITIALIZING, CLONING, READY, DEGRADED, FAILED } + + private volatile State state = State.INITIALIZING; + private final Map namespaceRepos = new ConcurrentHashMap<>(); + + private final GitHubRepoSync repoSync; + private final GitHubStoreConfig config; + + @Inject + public GitHubCloneManager(GitHubRepoSync repoSync, GitHubStoreConfig config) { + this.repoSync = repoSync; + this.config = config; + } + + public State getState() { + return state; + } + + public void registerNamespace(String name, String repoFullName, String branch) { + namespaceRepos.put(name, new NamespaceRepo(repoFullName, branch, Set.of())); + } + + public void registerNamespace(String name, String repoFullName, String branch, Set accessGroups) { + namespaceRepos.put(name, new NamespaceRepo(repoFullName, branch, accessGroups)); + } + + public Set getAccessGroupsForNamespace(String namespace) { + NamespaceRepo repo = namespaceRepos.get(namespace); + return repo != null ? repo.accessGroups() : Set.of(); + } + + public void cloneAll() { + if (namespaceRepos.isEmpty()) { + LOG.warn("No namespaces registered — nothing to clone"); + state = State.READY; + return; + } + + state = State.CLONING; + int succeeded = 0; + int failed = 0; + + for (Map.Entry entry : namespaceRepos.entrySet()) { + String namespace = entry.getKey(); + NamespaceRepo repo = entry.getValue(); + Path targetDir = config.getCloneDirectory().resolve(namespace); + + boolean success; + if (repoSync.isValidRepo(targetDir)) { + success = repoSync.pullRepo(targetDir, config.getServiceToken()); + } else { + success = repoSync.cloneRepo(repo.repoFullName(), repo.branch(), targetDir, config.getServiceToken()); + } + + if (success) { + succeeded++; + } else { + failed++; + } + } + + if (failed == 0) { + state = State.READY; + } else if (succeeded > 0) { + state = State.DEGRADED; + LOG.warn("Clone completed in DEGRADED state: {} succeeded, {} failed", succeeded, failed); + } else { + state = State.FAILED; + LOG.error("Clone FAILED: all {} repos failed", failed); + } + } + + public void pullAll() { + if (state == State.CLONING || state == State.INITIALIZING) { + LOG.debug("Skipping pullAll — state is {}", state); + return; + } + + int succeeded = 0; + int failed = 0; + + for (Map.Entry entry : namespaceRepos.entrySet()) { + String namespace = entry.getKey(); + Path targetDir = config.getCloneDirectory().resolve(namespace); + + if (repoSync.isValidRepo(targetDir)) { + if (repoSync.pullRepo(targetDir, config.getServiceToken())) { + succeeded++; + } else { + failed++; + } + } else { + failed++; + } + } + + if (failed > 0 && succeeded == 0) { + state = State.FAILED; + } else if (failed > 0) { + state = State.DEGRADED; + } else { + state = State.READY; + } + } + + public Map getNamespaceClonePaths() { + Map paths = new HashMap<>(); + for (String namespace : namespaceRepos.keySet()) { + paths.put(namespace, config.getCloneDirectory().resolve(namespace)); + } + return paths; + } + + public boolean hasNamespaces() { + return !namespaceRepos.isEmpty(); + } + + public String getRepoForNamespace(String namespace) { + NamespaceRepo repo = namespaceRepos.get(namespace); + return repo != null ? repo.repoFullName() : null; + } + + record NamespaceRepo(String repoFullName, String branch, Set accessGroups) {} +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubForkPRService.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubForkPRService.java new file mode 100644 index 0000000000..f965957625 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubForkPRService.java @@ -0,0 +1,54 @@ +package org.finos.calm.store.github.util; + +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; + +import io.quarkus.arc.lookup.LookupIfProperty; +import jakarta.enterprise.context.ApplicationScoped; +import org.finos.calm.domain.exception.PendingWriteException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Orchestrates the fork-based write flow: + * 1. Ensure user's fork exists (create if not) + * 2. Sync fork to upstream HEAD + * 3. Create branch on fork + * 4. Commit file to branch + * 5. Open cross-repo PR: user/fork:branch -> upstream:main + * + * Requires: user's GitHub token (from session cookie) and the upstream repo details. + */ +@LookupIfProperty(name = "calm.database.mode", stringValue = "github") +@ApplicationScoped +public class GitHubForkPRService { + + private static final Logger LOG = LoggerFactory.getLogger(GitHubForkPRService.class); + + /** + * Creates a PR for the given content change. + * + * @param userGhToken the user's GitHub OAuth token + * @param upstreamRepo the upstream repo (e.g. "finos/architecture-as-code") + * @param branch the upstream branch (e.g. "main") + * @param filePath the file path within the repo (e.g. "patterns/event-driven.json") + * @param content the JSON content to write + * @param commitMessage the commit message + * @return never — always throws PendingWriteException on success + * @throws PendingWriteException on successful PR creation (contains PR URL) + * @throws IllegalStateException if the GitHub API calls fail + */ + public void createPullRequest(String userGhToken, String upstreamRepo, String branch, + String filePath, String content, String commitMessage) + throws PendingWriteException { + // Full implementation will: + // 1. GET /repos/{upstream}/forks filtered by user -> find or create fork + // 2. POST /repos/{user}/{repo}/merge-upstream -> sync fork + // 3. POST /repos/{user}/{repo}/git/refs -> create branch + // 4. PUT /repos/{user}/{repo}/contents/{path} -> commit file + // 5. POST /repos/{upstream}/pulls -> open cross-repo PR + + // Placeholder — will be implemented when GitHub OAuth token exchange is complete + throw new GitHubWriteNotSupportedException( + "PR creation requires GitHub account linking. This will be enabled when the full OAuth flow is wired."); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubRepoSync.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubRepoSync.java new file mode 100644 index 0000000000..6dff33504b --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubRepoSync.java @@ -0,0 +1,84 @@ +package org.finos.calm.store.github.util; + +import io.quarkus.arc.lookup.LookupIfProperty; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.jgit.api.CloneCommand; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ResetCommand; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Handles git clone and sync operations for a single repository. + * Uses fetch + reset instead of pull to handle upstream force-pushes gracefully. + */ +@LookupIfProperty(name = "calm.database.mode", stringValue = "github") +@ApplicationScoped +public class GitHubRepoSync { + + private static final Logger LOG = LoggerFactory.getLogger(GitHubRepoSync.class); + + @Inject + @ConfigProperty(name = "calm.github.oauth.base-url", defaultValue = "https://github.com") + String githubBaseUrl; + + public boolean cloneRepo(String repoFullName, String branch, Path targetDir, String token) { + String url = githubBaseUrl + "/" + repoFullName + ".git"; + try { + Files.createDirectories(targetDir); + CloneCommand clone = Git.cloneRepository() + .setURI(url) + .setDirectory(targetDir.toFile()) + .setBranch(branch) + .setDepth(1); + + if (token != null && !token.isBlank()) { + clone.setCredentialsProvider( + new UsernamePasswordCredentialsProvider(token, "")); + } + + try (Git git = clone.call()) { + LOG.info("Cloned {} (branch: {}) to {}", repoFullName, branch, targetDir); + return true; + } + } catch (GitAPIException | IOException e) { + LOG.error("Failed to clone {}: {}", repoFullName, e.getMessage()); + return false; + } + } + + public boolean pullRepo(Path repoDir, String token) { + try (Git git = Git.open(repoDir.toFile())) { + var fetchCommand = git.fetch(); + + if (token != null && !token.isBlank()) { + fetchCommand.setCredentialsProvider( + new UsernamePasswordCredentialsProvider(token, "")); + } + + fetchCommand.call(); + git.reset() + .setMode(ResetCommand.ResetType.HARD) + .setRef("origin/" + git.getRepository().getBranch()) + .call(); + + LOG.debug("Fetched and reset for {}", repoDir.getFileName()); + return true; + } catch (GitAPIException | IOException e) { + LOG.error("Failed to sync {}: {}", repoDir.getFileName(), e.getMessage()); + return false; + } + } + + public boolean isValidRepo(Path repoDir) { + return Files.isDirectory(repoDir.resolve(".git")); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java new file mode 100644 index 0000000000..81e1861a06 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java @@ -0,0 +1,98 @@ +package org.finos.calm.store.github.util; + +import io.quarkus.arc.lookup.LookupIfProperty; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.context.ManagedExecutor; +import org.finos.calm.observability.GitHubMetrics; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** + * Reads namespace configuration on startup, registers repos with the clone manager, + * triggers initial clone asynchronously, and rebuilds the in-memory registry once complete. + * Quarkus finishes starting immediately — health endpoints are available during clone. + */ +@LookupIfProperty(name = "calm.database.mode", stringValue = "github") +@ApplicationScoped +public class GitHubStartupInitializer { + + private static final Logger LOG = LoggerFactory.getLogger(GitHubStartupInitializer.class); + + @Inject + GitHubCloneManager cloneManager; + + @Inject + InMemoryRegistryService registryService; + + @Inject + GitHubMetrics metrics; + + @Inject + ManagedExecutor executor; + + @Inject + @ConfigProperty(name = "calm.github.namespaces") + Optional> namespaceConfigs; + + void onStart(@Observes StartupEvent ev) { + List configs = namespaceConfigs.orElse(List.of()); + if (configs.isEmpty()) { + LOG.warn("No GitHub namespaces configured (calm.github.namespaces). The registry will be empty."); + return; + } + + for (String entry : configs) { + String[] parts = entry.split("\\|"); + if (parts.length < 2) { + LOG.warn("Invalid namespace config entry (expected 'name|repo|branch[|accessGroups]'): {}", entry); + continue; + } + String name = parts[0].trim(); + String repo = parts[1].trim(); + String branch = parts.length > 2 ? parts[2].trim() : "main"; + java.util.Set accessGroups = parts.length > 3 ? parseGroups(parts[3]) : java.util.Set.of(); + cloneManager.registerNamespace(name, repo, branch, accessGroups); + LOG.info("Registered namespace [{}] → repo [{}] branch [{}] accessGroups={}", + name, repo, branch, accessGroups); + } + + executor.runAsync(this::cloneAndRebuild); + LOG.info("GitHub clone started asynchronously — state: {}", cloneManager.getState()); + } + + private java.util.Set parseGroups(String groupsStr) { + if (groupsStr == null || groupsStr.isBlank()) { + return java.util.Set.of(); + } + return java.util.Arrays.stream(groupsStr.split(";")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(java.util.stream.Collectors.toSet()); + } + + void cloneAndRebuild() { + try { + Instant start = Instant.now(); + cloneManager.cloneAll(); + + Instant rebuildStart = Instant.now(); + registryService.rebuild(cloneManager.getNamespaceClonePaths()); + metrics.recordRegistryRebuild(Duration.between(rebuildStart, Instant.now())); + + Duration total = Duration.between(start, Instant.now()); + metrics.recordSyncSuccess(total); + LOG.info("GitHub clone complete in {}ms — state: {}", total.toMillis(), cloneManager.getState()); + } catch (Exception e) { + LOG.error("GitHub clone failed: {}", e.getMessage(), e); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java new file mode 100644 index 0000000000..271f395b7c --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java @@ -0,0 +1,51 @@ +package org.finos.calm.store.github.util; + +import io.quarkus.arc.lookup.LookupIfProperty; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +@LookupIfProperty(name = "calm.database.mode", stringValue = "github") +@ApplicationScoped +public class GitHubStoreConfig { + + @Inject + @ConfigProperty(name = "calm.github.service-token") + Optional serviceToken; + + @Inject + @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") + String cloneDirectory; + + @Inject + @ConfigProperty(name = "calm.github.sync-interval", defaultValue = "60") + int syncInterval; + + @Inject + @ConfigProperty(name = "calm.github.api-url", defaultValue = "https://api.github.com") + String apiUrl; + + @Inject + @ConfigProperty(name = "calm.github.namespaces") + Optional> namespaceConfigs; + + public String getServiceToken() { + return serviceToken.orElse(""); + } + + public Path getCloneDirectory() { + return Path.of(cloneDirectory); + } + + public int getSyncInterval() { + return syncInterval; + } + + public String getApiUrl() { + return apiUrl; + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java new file mode 100644 index 0000000000..2f9c0bc828 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java @@ -0,0 +1,60 @@ +package org.finos.calm.store.github.util; + +import io.quarkus.arc.lookup.LookupIfProperty; +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.finos.calm.observability.GitHubMetrics; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.Instant; + +/** + * Periodically pulls all registered namespace repos and rebuilds the in-memory registry. + * Only active when calm.database.mode=github. + */ +@LookupIfProperty(name = "calm.database.mode", stringValue = "github") +@ApplicationScoped +public class GitHubSyncScheduler { + + private static final Logger LOG = LoggerFactory.getLogger(GitHubSyncScheduler.class); + + private final GitHubCloneManager cloneManager; + private final InMemoryRegistryService registryService; + private final GitHubMetrics metrics; + + @Inject + public GitHubSyncScheduler(GitHubCloneManager cloneManager, + InMemoryRegistryService registryService, + GitHubMetrics metrics) { + this.cloneManager = cloneManager; + this.registryService = registryService; + this.metrics = metrics; + } + + @Scheduled(every = "${calm.github.sync-interval:60}s", delayed = "${calm.github.sync-interval:60}s") + void sync() { + if (!cloneManager.hasNamespaces()) { + return; + } + + Instant start = Instant.now(); + try { + cloneManager.pullAll(); + + Instant rebuildStart = Instant.now(); + registryService.rebuild(cloneManager.getNamespaceClonePaths()); + metrics.recordRegistryRebuild(Duration.between(rebuildStart, Instant.now())); + + Duration total = Duration.between(start, Instant.now()); + metrics.recordSyncSuccess(total); + LOG.debug("Sync completed in {}ms", total.toMillis()); + } catch (Exception e) { + Duration total = Duration.between(start, Instant.now()); + metrics.recordSyncFailure(total); + LOG.error("Sync failed after {}ms: {}", total.toMillis(), e.getMessage()); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java new file mode 100644 index 0000000000..7f55a62d72 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java @@ -0,0 +1,186 @@ +package org.finos.calm.store.github.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.arc.lookup.LookupIfProperty; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.cache.CalmCacheService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Fetches file version history (commit SHAs) from the GitHub REST API. + * Results are cached — version lists for 5 min, content at SHA indefinitely (immutable). + */ +@LookupIfProperty(name = "calm.database.mode", stringValue = "github") +@ApplicationScoped +public class GitHubVersionService { + + private static final Logger LOG = LoggerFactory.getLogger(GitHubVersionService.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Pattern LINK_NEXT_PATTERN = Pattern.compile("<([^>]+)>;\\s*rel=\"next\""); + + @Inject + CalmCacheService cache; + + @Inject + @ConfigProperty(name = "calm.github.api-url", defaultValue = "https://api.github.com") + String apiUrl; + + @Inject + @ConfigProperty(name = "calm.github.service-token") + Optional serviceToken; + + @Inject + @ConfigProperty(name = "calm.github.http.connect-timeout", defaultValue = "10") + int connectTimeoutSeconds; + + @Inject + @ConfigProperty(name = "calm.github.http.request-timeout", defaultValue = "30") + int requestTimeoutSeconds; + + @Inject + @ConfigProperty(name = "calm.github.max-versions", defaultValue = "100") + int maxVersions; + + public List getFileVersions(String repoFullName, String filePath) { + String cacheKey = "versions:" + repoFullName + ":" + filePath; + Optional cached = cache.get(cacheKey, List.class); + if (cached.isPresent()) { + @SuppressWarnings("unchecked") + List result = cached.get(); + return result; + } + + try { + HttpClient client = HttpClient.newBuilder() + .proxy(ProxySelector.getDefault()) + .connectTimeout(Duration.ofSeconds(connectTimeoutSeconds)) + .build(); + + List allShas = new ArrayList<>(); + String url = apiUrl + "/repos/" + repoFullName + "/commits?path=" + filePath + "&per_page=100"; + + while (url != null && allShas.size() < maxVersions) { + HttpRequest request = buildRequest(url); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + LOG.warn("GitHub API returned {} for commits on {}/{}", response.statusCode(), repoFullName, filePath); + break; + } + + List pageShas = extractShas(response.body(), maxVersions - allShas.size()); + allShas.addAll(pageShas); + url = extractNextLink(response.headers()); + } + + if (allShas.isEmpty()) { + allShas = List.of("latest"); + } + List chronological = new ArrayList<>(allShas); + Collections.reverse(chronological); + cache.put(cacheKey, chronological, Duration.ofMinutes(5)); + return chronological; + } catch (Exception e) { + LOG.warn("Failed to fetch versions for {}/{}: {}", repoFullName, filePath, e.getMessage()); + return List.of("latest"); + } + } + + public String getFileAtVersion(String repoFullName, String filePath, String sha) { + String cacheKey = "content:" + repoFullName + ":" + filePath + ":" + sha; + Optional cached = cache.get(cacheKey, String.class); + if (cached.isPresent()) { + return cached.get(); + } + + try { + String url = apiUrl + "/repos/" + repoFullName + "/contents/" + filePath + "?ref=" + sha; + HttpClient client = HttpClient.newBuilder() + .proxy(ProxySelector.getDefault()) + .connectTimeout(Duration.ofSeconds(connectTimeoutSeconds)) + .build(); + HttpRequest request = buildRequest(url, "application/vnd.github.raw+json"); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + LOG.warn("GitHub API returned {} for content at SHA {} for {}/{}", response.statusCode(), sha, repoFullName, filePath); + return null; + } + + String content = response.body(); + cache.put(cacheKey, content, Duration.ofDays(365)); + return content; + } catch (Exception e) { + LOG.warn("Failed to fetch content at SHA {} for {}/{}: {}", sha, repoFullName, filePath, e.getMessage()); + return null; + } + } + + List extractShas(String json, int limit) { + List shas = new ArrayList<>(); + try { + JsonNode commits = MAPPER.readTree(json); + if (commits.isArray()) { + for (JsonNode commit : commits) { + JsonNode shaNode = commit.get("sha"); + if (shaNode != null && shaNode.isTextual() && shas.size() < limit) { + String fullSha = shaNode.asText(); + shas.add(fullSha.length() >= 7 ? fullSha.substring(0, 7) : fullSha); + } + } + } + } catch (Exception e) { + LOG.warn("Failed to parse commits JSON: {}", e.getMessage()); + } + return shas; + } + + String extractNextLink(HttpHeaders headers) { + Optional linkHeader = headers.firstValue("Link"); + if (linkHeader.isEmpty()) { + return null; + } + Matcher matcher = LINK_NEXT_PATTERN.matcher(linkHeader.get()); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + private HttpRequest buildRequest(String url) { + return buildRequest(url, "application/json"); + } + + private HttpRequest buildRequest(String url, String accept) { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(requestTimeoutSeconds)) + .header("Accept", accept) + .GET(); + + if (serviceToken.isPresent() && !serviceToken.get().isBlank()) { + requestBuilder.header("Authorization", "Bearer " + serviceToken.get()); + } + + return requestBuilder.build(); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java new file mode 100644 index 0000000000..ae5b2b58a7 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java @@ -0,0 +1,194 @@ +package org.finos.calm.store.github.util; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +@ApplicationScoped +public class InMemoryRegistryService { + + private static final Logger LOG = LoggerFactory.getLogger(InMemoryRegistryService.class); + + private volatile RegistrySnapshot snapshot = RegistrySnapshot.EMPTY; + + private final CalmContentDetector contentDetector; + + @Inject + public InMemoryRegistryService(CalmContentDetector contentDetector) { + this.contentDetector = contentDetector; + } + + public RegistrySnapshot getSnapshot() { + return snapshot; + } + + public void rebuild(Map namespaceClonePaths) { + Map> byNamespace = new HashMap<>(); + Map byQualifiedId = new HashMap<>(); + Map> byType = new EnumMap<>(CalmResourceType.class); + + for (Map.Entry entry : namespaceClonePaths.entrySet()) { + String namespace = entry.getKey(); + Path clonePath = entry.getValue(); + + List entries = scanDirectory(namespace, clonePath); + byNamespace.put(namespace, entries); + + for (RegistryEntry registryEntry : entries) { + byQualifiedId.put(namespace + ":" + registryEntry.uniqueId(), registryEntry); + byType.computeIfAbsent(registryEntry.type(), k -> new ArrayList<>()).add(registryEntry); + } + } + + this.snapshot = new RegistrySnapshot( + Map.copyOf(byNamespace), + Map.copyOf(byQualifiedId), + Map.copyOf(byType) + ); + LOG.info("Registry rebuilt: {} namespaces, {} total entries", + byNamespace.size(), byQualifiedId.size()); + } + + public Optional findByUniqueId(String namespace, String uniqueId) { + return snapshot.findByUniqueId(namespace, uniqueId); + } + + public List listByType(String namespace, CalmResourceType type) { + return snapshot.listByType(namespace, type); + } + + private List scanDirectory(String namespace, Path root) { + List entries = new ArrayList<>(); + + if (!Files.isDirectory(root)) { + LOG.warn("Clone path does not exist for namespace [{}]: {}", namespace, root); + return entries; + } + + try (Stream walk = Files.walk(root)) { + walk.filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith(".json") || p.toString().endsWith(".md")) + .filter(p -> !isHiddenOrMetadata(root, p)) + .forEach(filePath -> { + RegistryEntry entry = parseFile(root, filePath); + if (entry != null && entry.type() != CalmResourceType.UNKNOWN) { + entries.add(entry); + } + }); + } catch (IOException e) { + LOG.error("Failed to scan directory for namespace [{}]: {}", namespace, root, e); + } + + return entries; + } + + private RegistryEntry parseFile(Path root, Path filePath) { + try { + Path relativePath = root.relativize(filePath); + String fileName = filePath.getFileName().toString(); + + if (fileName.endsWith(".md")) { + return parseMarkdownFile(root, filePath, relativePath); + } + + String content = Files.readString(filePath); + CalmResourceType type = contentDetector.detect(content, relativePath); + + if (type == CalmResourceType.UNKNOWN) { + return null; + } + + String uniqueId = extractUniqueId(content, relativePath); + String name = extractName(content, relativePath); + Instant lastModified = Files.getLastModifiedTime(filePath).toInstant(); + + return new RegistryEntry(uniqueId, relativePath, type, name, lastModified); + } catch (IOException e) { + LOG.debug("Failed to parse file: {}", filePath, e); + return null; + } + } + + private RegistryEntry parseMarkdownFile(Path root, Path filePath, Path relativePath) throws IOException { + CalmResourceType type = detectMarkdownType(relativePath); + if (type == CalmResourceType.UNKNOWN) { + return null; + } + String fileName = filePath.getFileName().toString().replace(".md", ""); + if ("README".equalsIgnoreCase(fileName)) { + return null; + } + String name = fileName.replace("-", " "); + Instant lastModified = Files.getLastModifiedTime(filePath).toInstant(); + return new RegistryEntry(fileName, relativePath, type, name, lastModified); + } + + private CalmResourceType detectMarkdownType(Path relativePath) { + for (int i = 0; i < relativePath.getNameCount() - 1; i++) { + String segment = relativePath.getName(i).toString().toLowerCase(); + switch (segment) { + case "standards": return CalmResourceType.STANDARD; + case "guidelines": return CalmResourceType.GUIDELINE; + case "adrs": return CalmResourceType.ADR; + default: break; + } + } + return CalmResourceType.UNKNOWN; + } + + private String extractUniqueId(String content, Path relativePath) { + try (JsonReader reader = Json.createReader(new StringReader(content))) { + JsonObject json = reader.readObject(); + if (json.containsKey("unique-id")) { + return json.getString("unique-id"); + } + } catch (Exception e) { + // fall through to path-based derivation + } + return stripFileExtensions(relativePath.getFileName().toString()); + } + + private String extractName(String content, Path relativePath) { + try (JsonReader reader = Json.createReader(new StringReader(content))) { + JsonObject json = reader.readObject(); + if (json.containsKey("name")) { + return json.getString("name"); + } + } catch (Exception e) { + // fall through to path-based derivation + } + return stripFileExtensions(relativePath.getFileName().toString()); + } + + private String stripFileExtensions(String fileName) { + // Strip compound extensions: .calm.json, .architecture.json, .template.json, etc. + int firstDot = fileName.indexOf('.'); + return firstDot > 0 ? fileName.substring(0, firstDot) : fileName; + } + + private boolean isHiddenOrMetadata(Path root, Path filePath) { + Path relative = root.relativize(filePath); + String relativeStr = relative.toString(); + return relativeStr.startsWith(".") || + relativeStr.contains("/.") || + relativeStr.startsWith("node_modules") || + relativeStr.contains("/node_modules/"); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java new file mode 100644 index 0000000000..54086afb1e --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java @@ -0,0 +1,66 @@ +package org.finos.calm.store.github.util; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.security.OidcRoleResolver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Resolves the set of namespaces the current caller can access based on their + * OIDC group membership and each namespace's configured access groups. Used by + * GitHub-mode stores to restrict domain/control visibility to namespaces the + * caller is authorised to read. + */ +@ApplicationScoped +public class NamespaceAccessFilter { + + private static final Logger LOG = LoggerFactory.getLogger(NamespaceAccessFilter.class); + + @Inject + SecurityIdentity identity; + + @Inject + OidcRoleResolver roleResolver; + + @Inject + InMemoryRegistryService registryService; + + @Inject + GitHubCloneManager cloneManager; + + @Inject + @ConfigProperty(name = "calm.auth.enabled", defaultValue = "false") + boolean authEnabled; + + public Set getAccessibleNamespaces() { + List allNamespaces = registryService.getSnapshot().getNamespaces(); + + if (!authEnabled) { + return new HashSet<>(allNamespaces); + } + + if (identity == null || identity.isAnonymous()) { + return Set.of(); + } + + Set accessible = new HashSet<>(); + for (String namespace : allNamespaces) { + Set accessGroups = cloneManager.getAccessGroupsForNamespace(namespace); + if (roleResolver.resolve(identity, accessGroups) != OidcRoleResolver.AccessLevel.NONE) { + accessible.add(namespace); + } + } + + LOG.debug("User [{}] has access to {} of {} namespaces", + identity.getPrincipal().getName(), accessible.size(), allNamespaces.size()); + + return accessible; + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/RegistryEntry.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/RegistryEntry.java new file mode 100644 index 0000000000..52d9681545 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/RegistryEntry.java @@ -0,0 +1,12 @@ +package org.finos.calm.store.github.util; + +import java.nio.file.Path; +import java.time.Instant; + +public record RegistryEntry( + String uniqueId, + Path filePath, + CalmResourceType type, + String name, + Instant lastModified +) {} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/RegistrySnapshot.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/RegistrySnapshot.java new file mode 100644 index 0000000000..dfc31755a9 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/RegistrySnapshot.java @@ -0,0 +1,34 @@ +package org.finos.calm.store.github.util; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public record RegistrySnapshot( + Map> entriesByNamespace, + Map entriesByQualifiedId, + Map> entriesByType +) { + public static final RegistrySnapshot EMPTY = new RegistrySnapshot( + Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap()); + + public Optional findByUniqueId(String namespace, String uniqueId) { + return Optional.ofNullable(entriesByQualifiedId.get(namespace + ":" + uniqueId)); + } + + public List listByType(String namespace, CalmResourceType type) { + return entriesByNamespace.getOrDefault(namespace, Collections.emptyList()) + .stream() + .filter(e -> e.type() == type) + .toList(); + } + + public List listAll(String namespace) { + return entriesByNamespace.getOrDefault(namespace, Collections.emptyList()); + } + + public List getNamespaces() { + return List.copyOf(entriesByNamespace.keySet()); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/noop/NoOpResourceMappingStore.java b/calm-hub/src/main/java/org/finos/calm/store/noop/NoOpResourceMappingStore.java new file mode 100644 index 0000000000..539fde384d --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/noop/NoOpResourceMappingStore.java @@ -0,0 +1,62 @@ +package org.finos.calm.store.noop; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import org.finos.calm.domain.ResourceMapping; +import org.finos.calm.domain.ResourceType; +import org.finos.calm.domain.exception.DuplicateMappingException; +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; +import org.finos.calm.domain.exception.MappingNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.ResourceMappingStore; + +import java.util.Collections; +import java.util.List; + +@ApplicationScoped +@Typed(NoOpResourceMappingStore.class) +public class NoOpResourceMappingStore implements ResourceMappingStore { + + private static final String MESSAGE = + "Resource ID mapping is not used in GitHub storage mode. Documents are identified by their unique-id field."; + + @Override + public ResourceMapping createMapping(String namespace, String customId, ResourceType type, int numericId) throws DuplicateMappingException, NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(MESSAGE); + } + + @Override + public ResourceMapping getMapping(String namespace, ResourceType type, String customId) throws MappingNotFoundException, NamespaceNotFoundException { + throw new MappingNotFoundException(); + } + + @Override + public List listMappings(String namespace, ResourceType typeFilter) throws NamespaceNotFoundException { + return Collections.emptyList(); + } + + @Override + public ResourceMapping getMappingByNumericId(String namespace, ResourceType type, int numericId) throws MappingNotFoundException, NamespaceNotFoundException { + throw new MappingNotFoundException(); + } + + @Override + public List listMappingsByNumericIds(String namespace, ResourceType type, List ids) throws NamespaceNotFoundException { + return Collections.emptyList(); + } + + @Override + public void updateMappingNumericId(String namespace, ResourceType type, String customId, int numericId) throws MappingNotFoundException, NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(MESSAGE); + } + + @Override + public void deleteMapping(String namespace, ResourceType type, String customId) throws MappingNotFoundException, NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(MESSAGE); + } + + @Override + public void deleteMappingByNumericId(String namespace, ResourceType type, int numericId) throws NamespaceNotFoundException { + throw new GitHubWriteNotSupportedException(MESSAGE); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/noop/NoOpSchemaVersionStore.java b/calm-hub/src/main/java/org/finos/calm/store/noop/NoOpSchemaVersionStore.java new file mode 100644 index 0000000000..6aadb71f5c --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/noop/NoOpSchemaVersionStore.java @@ -0,0 +1,35 @@ +package org.finos.calm.store.noop; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import org.finos.calm.store.SchemaVersionStore; + +@ApplicationScoped +@Typed(NoOpSchemaVersionStore.class) +public class NoOpSchemaVersionStore implements SchemaVersionStore { + + @Override + public int getSchemaVersion() { + return Integer.MAX_VALUE; + } + + @Override + public void setSchemaVersion(int version) { + // no-op — GitHub mode has no database to version + } + + @Override + public boolean acquireMigrationLock(String instanceId) { + return true; + } + + @Override + public void releaseMigrationLock(String instanceId) { + // no-op + } + + @Override + public boolean isMigrationLockHeld() { + return false; + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/AdrStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/AdrStoreProducer.java index 720f4bb42d..6dcc4f6f4e 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/AdrStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/AdrStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.AdrStore; +import org.finos.calm.store.github.GitHubAdrStore; import org.finos.calm.store.mongo.MongoAdrStore; import org.finos.calm.store.nitrite.NitriteAdrStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for AdrStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class AdrStoreProducer { @@ -26,18 +24,18 @@ public class AdrStoreProducer { @Inject Instance standaloneAdrStore; - /** - * Produces the appropriate AdrStore implementation based on the configured database mode. - * - * @return the AdrStore implementation - */ + @Inject + Instance gitHubAdrStore; + @Produces @ApplicationScoped public AdrStore produceAdrStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubAdrStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneAdrStore.get(); } else { return mongoAdrStore.get(); } } -} \ No newline at end of file +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/ArchitectureStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/ArchitectureStoreProducer.java index 89fbd06ef3..1eb60f25f1 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/ArchitectureStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/ArchitectureStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.ArchitectureStore; +import org.finos.calm.store.github.GitHubArchitectureStore; import org.finos.calm.store.mongo.MongoArchitectureStore; import org.finos.calm.store.nitrite.NitriteArchitectureStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for ArchitectureStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class ArchitectureStoreProducer { @@ -26,15 +24,15 @@ public class ArchitectureStoreProducer { @Inject Instance standaloneArchitectureStore; - /** - * Produces the appropriate ArchitectureStore implementation based on the configured database mode. - * - * @return the ArchitectureStore implementation - */ + @Inject + Instance gitHubArchitectureStore; + @Produces @ApplicationScoped public ArchitectureStore produceArchitectureStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubArchitectureStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneArchitectureStore.get(); } else { return mongoArchitectureStore.get(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/AuditLogStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/AuditLogStoreProducer.java index d494fa3a90..dbeab8bd9f 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/AuditLogStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/AuditLogStoreProducer.java @@ -5,14 +5,12 @@ import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.AuditLogStore; +import org.finos.calm.store.github.GitHubAuditLogStore; import org.finos.calm.store.mongo.MongoAuditLogStore; import org.finos.calm.store.nitrite.NitriteAuditLogStore; -/** - * Producer for {@link AuditLogStore} implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class AuditLogStoreProducer { @@ -26,15 +24,15 @@ public class AuditLogStoreProducer { @Inject Instance standaloneAuditLogStore; - /** - * Produces the appropriate AuditLogStore implementation based on the configured database mode. - * - * @return the AuditLogStore implementation - */ + @Inject + Instance gitHubAuditLogStore; + @Produces @ApplicationScoped public AuditLogStore produceAuditLogStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubAuditLogStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneAuditLogStore.get(); } else { return mongoAuditLogStore.get(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/BuildingBlockStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/BuildingBlockStoreProducer.java new file mode 100644 index 0000000000..ebb093bf8b --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/BuildingBlockStoreProducer.java @@ -0,0 +1,65 @@ +package org.finos.calm.store.producer; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; +import org.finos.calm.store.BuildingBlockStore; +import org.finos.calm.store.github.GitHubBuildingBlockStore; + +@ApplicationScoped +public class BuildingBlockStoreProducer { + + @Inject + @ConfigProperty(name = "calm.database.mode", defaultValue = "mongo") + String databaseMode; + + @Inject + Instance gitHubBuildingBlockStore; + + @Produces + @ApplicationScoped + public BuildingBlockStore produceBuildingBlockStore() { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubBuildingBlockStore.get(); + } + // No Mongo or standalone implementation yet — return a no-op that throws on all operations + return new NoOpBuildingBlockStore(); + } + + /** + * Placeholder implementation for non-GitHub modes until Mongo/Nitrite support is added. + */ + private static class NoOpBuildingBlockStore implements BuildingBlockStore { + + private static final String NOT_SUPPORTED = + "Building blocks are not yet supported in standalone or mongo mode"; + + @Override + public java.util.List getBuildingBlocksForNamespace(String namespace) { + throw new UnsupportedOperationException(NOT_SUPPORTED); + } + + @Override + public int createBuildingBlockForNamespace(String namespace, String buildingBlockJson) { + throw new UnsupportedOperationException(NOT_SUPPORTED); + } + + @Override + public java.util.List getBuildingBlockVersions(String namespace, int buildingBlockId) { + throw new UnsupportedOperationException(NOT_SUPPORTED); + } + + @Override + public String getBuildingBlockForVersion(String namespace, int buildingBlockId, String version) { + throw new UnsupportedOperationException(NOT_SUPPORTED); + } + + @Override + public void createBuildingBlockForVersion(String namespace, int buildingBlockId, String version, String buildingBlockJson) { + throw new UnsupportedOperationException(NOT_SUPPORTED); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/ControlStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/ControlStoreProducer.java index 7480244dc7..d5bb36db23 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/ControlStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/ControlStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.ControlStore; +import org.finos.calm.store.github.GitHubControlStore; import org.finos.calm.store.mongo.MongoControlStore; import org.finos.calm.store.nitrite.NitriteControlStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for ControlStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class ControlStoreProducer { @@ -26,18 +24,18 @@ public class ControlStoreProducer { @Inject Instance standaloneControlStore; - /** - * Produces the appropriate ControlStore implementation based on the configured database mode. - * - * @return the ControlStore implementation - */ + @Inject + Instance gitHubControlStore; + @Produces @ApplicationScoped public ControlStore produceControlStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubControlStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneControlStore.get(); } else { return mongoControlStore.get(); } } -} \ No newline at end of file +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/CoreSchemaStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/CoreSchemaStoreProducer.java index 4be01c089e..dd5a4061fa 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/CoreSchemaStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/CoreSchemaStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.CoreSchemaStore; +import org.finos.calm.store.classpath.ClasspathCoreSchemaStore; import org.finos.calm.store.mongo.MongoCoreSchemaStore; import org.finos.calm.store.nitrite.NitriteCoreSchemaStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for CoreSchemaStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class CoreSchemaStoreProducer { @@ -26,15 +24,15 @@ public class CoreSchemaStoreProducer { @Inject Instance standaloneCoreSchemaStore; - /** - * Produces the appropriate CoreSchemaStore implementation based on the configured database mode. - * - * @return the CoreSchemaStore implementation - */ + @Inject + Instance classpathCoreSchemaStore; + @Produces @ApplicationScoped public CoreSchemaStore produceCoreSchemaStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return classpathCoreSchemaStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneCoreSchemaStore.get(); } else { return mongoCoreSchemaStore.get(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/DecoratorStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/DecoratorStoreProducer.java index 2975ca03b4..eaaf8ba9ef 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/DecoratorStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/DecoratorStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.DecoratorStore; +import org.finos.calm.store.github.GitHubDecoratorStore; import org.finos.calm.store.mongo.MongoDecoratorStore; import org.finos.calm.store.nitrite.NitriteDecoratorStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for DecoratorStore implementations. - * Selects the appropriate implementation based on the configured database mode. - */ @ApplicationScoped public class DecoratorStoreProducer { @@ -24,19 +22,20 @@ public class DecoratorStoreProducer { Instance mongoDecoratorStore; @Inject - Instance nitriteDecoratorStore; + Instance standaloneDecoratorStore; + + @Inject + Instance gitHubDecoratorStore; - /** - * Produces the appropriate DecoratorStore implementation based on the configured database mode. - * - * @return the DecoratorStore implementation - */ @Produces @ApplicationScoped public DecoratorStore produceDecoratorStore() { - if ("standalone".equals(databaseMode)) { - return nitriteDecoratorStore.get(); + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubDecoratorStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { + return standaloneDecoratorStore.get(); + } else { + return mongoDecoratorStore.get(); } - return mongoDecoratorStore.get(); } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/DomainStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/DomainStoreProducer.java index 594a7fb72d..9801e4688a 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/DomainStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/DomainStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.DomainStore; +import org.finos.calm.store.github.GitHubDomainStore; import org.finos.calm.store.mongo.MongoDomainStore; import org.finos.calm.store.nitrite.NitriteDomainStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for DomainStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class DomainStoreProducer { @@ -26,15 +24,15 @@ public class DomainStoreProducer { @Inject Instance standaloneDomainStore; - /** - * Produces the appropriate DomainStore implementation based on the configured database mode. - * - * @return the DomainStore implementation - */ + @Inject + Instance gitHubDomainStore; + @Produces @ApplicationScoped public DomainStore produceDomainStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubDomainStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneDomainStore.get(); } else { return mongoDomainStore.get(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/FlowStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/FlowStoreProducer.java index a6c7e57c51..bea577e8b4 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/FlowStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/FlowStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.FlowStore; +import org.finos.calm.store.github.GitHubFlowStore; import org.finos.calm.store.mongo.MongoFlowStore; import org.finos.calm.store.nitrite.NitriteFlowStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for FlowStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class FlowStoreProducer { @@ -26,18 +24,18 @@ public class FlowStoreProducer { @Inject Instance standaloneFlowStore; - /** - * Produces the appropriate FlowStore implementation based on the configured database mode. - * - * @return the FlowStore implementation - */ + @Inject + Instance gitHubFlowStore; + @Produces @ApplicationScoped public FlowStore produceFlowStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubFlowStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneFlowStore.get(); } else { return mongoFlowStore.get(); } } -} \ No newline at end of file +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/InterfaceStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/InterfaceStoreProducer.java index c9aab36024..81901592df 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/InterfaceStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/InterfaceStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.InterfaceStore; +import org.finos.calm.store.github.GitHubInterfaceStore; import org.finos.calm.store.mongo.MongoInterfaceStore; import org.finos.calm.store.nitrite.NitriteInterfaceStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for InterfaceStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class InterfaceStoreProducer { @@ -26,15 +24,15 @@ public class InterfaceStoreProducer { @Inject Instance standaloneInterfaceStore; - /** - * Produces the appropriate InterfaceStore implementation based on the configured database mode. - * - * @return the InterfaceStore implementation - */ + @Inject + Instance gitHubInterfaceStore; + @Produces @ApplicationScoped public InterfaceStore produceInterfaceStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubInterfaceStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneInterfaceStore.get(); } else { return mongoInterfaceStore.get(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/LayoutStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/LayoutStoreProducer.java index 2d2db00232..1a32fbc5e4 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/LayoutStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/LayoutStoreProducer.java @@ -5,14 +5,12 @@ import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.LayoutStore; +import org.finos.calm.store.github.GitHubLayoutStore; import org.finos.calm.store.mongo.MongoLayoutStore; import org.finos.calm.store.nitrite.NitriteLayoutStore; -/** - * Producer for LayoutStore implementations. - * Selects the appropriate implementation based on the configured database mode. - */ @ApplicationScoped public class LayoutStoreProducer { @@ -24,19 +22,20 @@ public class LayoutStoreProducer { Instance mongoLayoutStore; @Inject - Instance nitriteLayoutStore; + Instance standaloneLayoutStore; + + @Inject + Instance gitHubLayoutStore; - /** - * Produces the appropriate LayoutStore implementation based on the configured database mode. - * - * @return the LayoutStore implementation - */ @Produces @ApplicationScoped public LayoutStore produceLayoutStore() { - if ("standalone".equals(databaseMode)) { - return nitriteLayoutStore.get(); + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubLayoutStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { + return standaloneLayoutStore.get(); + } else { + return mongoLayoutStore.get(); } - return mongoLayoutStore.get(); } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/NamespaceStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/NamespaceStoreProducer.java index 22bd0fd500..8516ba96c3 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/NamespaceStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/NamespaceStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.NamespaceStore; +import org.finos.calm.store.github.GitHubNamespaceStore; import org.finos.calm.store.mongo.MongoNamespaceStore; import org.finos.calm.store.nitrite.NitriteNamespaceStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for NamespaceStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class NamespaceStoreProducer { @@ -26,15 +24,15 @@ public class NamespaceStoreProducer { @Inject Instance standaloneNamespaceStore; - /** - * Produces the appropriate NamespaceStore implementation based on the configured database mode. - * - * @return the NamespaceStore implementation - */ + @Inject + Instance gitHubNamespaceStore; + @Produces @ApplicationScoped public NamespaceStore produceNamespaceStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubNamespaceStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneNamespaceStore.get(); } else { return mongoNamespaceStore.get(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/PatternStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/PatternStoreProducer.java index e271e4b7f8..7b606cd7b7 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/PatternStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/PatternStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.PatternStore; +import org.finos.calm.store.github.GitHubPatternStore; import org.finos.calm.store.mongo.MongoPatternStore; import org.finos.calm.store.nitrite.NitritePatternStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for PatternStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class PatternStoreProducer { @@ -26,15 +24,15 @@ public class PatternStoreProducer { @Inject Instance standalonePatternStore; - /** - * Produces the appropriate PatternStore implementation based on the configured database mode. - * - * @return the PatternStore implementation - */ + @Inject + Instance gitHubPatternStore; + @Produces @ApplicationScoped public PatternStore producePatternStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubPatternStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standalonePatternStore.get(); } else { return mongoPatternStore.get(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/ResourceMappingStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/ResourceMappingStoreProducer.java index a49129735b..d947cd6714 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/ResourceMappingStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/ResourceMappingStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.ResourceMappingStore; +import org.finos.calm.store.github.GitHubResourceMappingStore; import org.finos.calm.store.mongo.MongoResourceMappingStore; import org.finos.calm.store.nitrite.NitriteResourceMappingStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for ResourceMappingStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class ResourceMappingStoreProducer { @@ -26,10 +24,15 @@ public class ResourceMappingStoreProducer { @Inject Instance standaloneResourceMappingStore; + @Inject + Instance gitHubResourceMappingStore; + @Produces @ApplicationScoped public ResourceMappingStore produceResourceMappingStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubResourceMappingStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneResourceMappingStore.get(); } else { return mongoResourceMappingStore.get(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/SchemaVersionStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/SchemaVersionStoreProducer.java index fdcbf7d244..e5721dfc2a 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/SchemaVersionStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/SchemaVersionStoreProducer.java @@ -5,14 +5,12 @@ import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.SchemaVersionStore; import org.finos.calm.store.mongo.MongoSchemaVersionStore; import org.finos.calm.store.nitrite.NitriteSchemaVersionStore; +import org.finos.calm.store.noop.NoOpSchemaVersionStore; -/** - * Producer for {@link SchemaVersionStore} implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class SchemaVersionStoreProducer { @@ -26,15 +24,15 @@ public class SchemaVersionStoreProducer { @Inject Instance standaloneSchemaVersionStore; - /** - * Produces the appropriate SchemaVersionStore implementation based on the configured database mode. - * - * @return the SchemaVersionStore implementation - */ + @Inject + Instance noOpSchemaVersionStore; + @Produces @ApplicationScoped public SchemaVersionStore produceSchemaVersionStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return noOpSchemaVersionStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneSchemaVersionStore.get(); } else { return mongoSchemaVersionStore.get(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/SearchStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/SearchStoreProducer.java index 681a60cf6f..596e7d58c4 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/SearchStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/SearchStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.SearchStore; +import org.finos.calm.store.github.GitHubSearchStore; import org.finos.calm.store.mongo.MongoSearchStore; import org.finos.calm.store.nitrite.NitriteSearchStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for SearchStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class SearchStoreProducer { @@ -26,15 +24,15 @@ public class SearchStoreProducer { @Inject Instance standaloneSearchStore; - /** - * Produces the appropriate SearchStore implementation based on the configured database mode. - * - * @return the SearchStore implementation - */ + @Inject + Instance gitHubSearchStore; + @Produces @ApplicationScoped public SearchStore produceSearchStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubSearchStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneSearchStore.get(); } else { return mongoSearchStore.get(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/StandardStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/StandardStoreProducer.java index 36e70a55d3..ca47199faf 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/StandardStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/StandardStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.StandardStore; +import org.finos.calm.store.github.GitHubStandardStore; import org.finos.calm.store.mongo.MongoStandardStore; import org.finos.calm.store.nitrite.NitriteStandardStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for PatternStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class StandardStoreProducer { @@ -26,18 +24,18 @@ public class StandardStoreProducer { @Inject Instance standaloneStandardStore; - /** - * Produces the appropriate PatternStore implementation based on the configured database mode. - * - * @return the PatternStore implementation - */ + @Inject + Instance gitHubStandardStore; + @Produces @ApplicationScoped public StandardStore produceStandardStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubStandardStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneStandardStore.get(); } else { return mongoStandardStore.get(); } } -} \ No newline at end of file +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/TimelineStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/TimelineStoreProducer.java index 203e2dd8df..2f7856cf8e 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/TimelineStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/TimelineStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.TimelineStore; +import org.finos.calm.store.github.GitHubTimelineStore; import org.finos.calm.store.mongo.MongoTimelineStore; import org.finos.calm.store.nitrite.NitriteTimelineStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for TimelineStore implementations. - * This class provides either the MongoDB or NitriteDB implementation based on configuration. - */ @ApplicationScoped public class TimelineStoreProducer { @@ -26,15 +24,15 @@ public class TimelineStoreProducer { @Inject Instance standaloneTimelineStore; - /** - * Produces the appropriate TimelineStore implementation based on the configured database mode. - * - * @return the TimelineStore implementation - */ + @Inject + Instance gitHubTimelineStore; + @Produces @ApplicationScoped public TimelineStore produceTimelineStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubTimelineStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneTimelineStore.get(); } else { return mongoTimelineStore.get(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/UserAccessStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/UserAccessStoreProducer.java index 7ae431cf6e..575e2721ad 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/UserAccessStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/UserAccessStoreProducer.java @@ -1,18 +1,16 @@ package org.finos.calm.store.producer; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.UserAccessStore; +import org.finos.calm.store.github.GitHubUserAccessStore; import org.finos.calm.store.mongo.MongoUserAccessStore; import org.finos.calm.store.nitrite.NitriteUserAccessStore; -import jakarta.enterprise.inject.Instance; -/** - * Producer for UserAccessStore implementations. - * Selects the appropriate implementation based on the configured database mode. - */ @ApplicationScoped public class UserAccessStoreProducer { @@ -26,15 +24,15 @@ public class UserAccessStoreProducer { @Inject Instance standaloneUserAccessStore; - /** - * Produces the appropriate UserAccessStore implementation based on the configured database mode. - * - * @return the UserAccessStore implementation - */ + @Inject + Instance gitHubUserAccessStore; + @Produces @ApplicationScoped public UserAccessStore produceUserAccessStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubUserAccessStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return standaloneUserAccessStore.get(); } else { return mongoUserAccessStore.get(); diff --git a/calm-hub/src/main/resources/application.properties b/calm-hub/src/main/resources/application.properties index 6fd3b7eabb..aa753714e0 100644 --- a/calm-hub/src/main/resources/application.properties +++ b/calm-hub/src/main/resources/application.properties @@ -21,6 +21,10 @@ calm.readonly=false # content are sized independently) — applies when calm.database.mode=github. calm.github.cache.max-size=10000 +# GitHub HTTP client timeouts (seconds) — applies when calm.database.mode=github +calm.github.http.connect-timeout=${CALM_GITHUB_HTTP_CONNECT_TIMEOUT:10} +calm.github.http.request-timeout=${CALM_GITHUB_HTTP_REQUEST_TIMEOUT:30} + # Audit logging: independently toggle persistence to the auditLogs store and # emission of a structured log line under the org.finos.calm.audit category. # Both default to true. sourceIp capture defaults to false and has its own diff --git a/calm-hub/src/test/java/org/finos/calm/domain/exception/TestPendingWriteExceptionShould.java b/calm-hub/src/test/java/org/finos/calm/domain/exception/TestPendingWriteExceptionShould.java new file mode 100644 index 0000000000..a82e873022 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/domain/exception/TestPendingWriteExceptionShould.java @@ -0,0 +1,38 @@ +package org.finos.calm.domain.exception; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +class TestPendingWriteExceptionShould { + + @Test + void carry_pr_url() { + PendingWriteException ex = new PendingWriteException("https://github.com/org/repo/pull/7", 7, "calm-hub/pattern-xyz"); + + assertThat(ex.getPullRequestUrl(), equalTo("https://github.com/org/repo/pull/7")); + } + + @Test + void carry_pr_number() { + PendingWriteException ex = new PendingWriteException("https://github.com/org/repo/pull/7", 7, "calm-hub/pattern-xyz"); + + assertThat(ex.getPullRequestNumber(), equalTo(7)); + } + + @Test + void carry_branch_name() { + PendingWriteException ex = new PendingWriteException("https://github.com/org/repo/pull/7", 7, "calm-hub/pattern-xyz"); + + assertThat(ex.getBranch(), equalTo("calm-hub/pattern-xyz")); + } + + @Test + void include_pr_number_in_message() { + PendingWriteException ex = new PendingWriteException("https://github.com/org/repo/pull/42", 42, "branch"); + + assertThat(ex.getMessage(), containsString("42")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java b/calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java new file mode 100644 index 0000000000..1d3ec5e51e --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java @@ -0,0 +1,98 @@ +package org.finos.calm.observability; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.closeTo; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +class TestGitHubMetricsShould { + + private SimpleMeterRegistry registry; + private GitHubMetrics metrics; + + @BeforeEach + void setup() { + registry = new SimpleMeterRegistry(); + metrics = new GitHubMetrics(registry); + } + + @Test + void record_sync_success_counter() { + metrics.recordSyncSuccess(Duration.ofSeconds(2)); + metrics.recordSyncSuccess(Duration.ofSeconds(1)); + + Counter counter = registry.find("calm.github.sync").tag("outcome", "success").counter(); + assertThat(counter, is(notNullValue())); + assertThat(counter.count(), equalTo(2.0)); + } + + @Test + void record_sync_failure_counter() { + metrics.recordSyncFailure(Duration.ofSeconds(5)); + + Counter counter = registry.find("calm.github.sync").tag("outcome", "failure").counter(); + assertThat(counter, is(notNullValue())); + assertThat(counter.count(), equalTo(1.0)); + } + + @Test + void record_sync_duration_timer() { + metrics.recordSyncSuccess(Duration.ofMillis(500)); + metrics.recordSyncFailure(Duration.ofMillis(1500)); + + Timer timer = registry.find("calm.github.sync.duration").timer(); + assertThat(timer, is(notNullValue())); + assertThat(timer.count(), equalTo(2L)); + assertThat(timer.totalTime(java.util.concurrent.TimeUnit.MILLISECONDS), closeTo(2000.0, 50.0)); + } + + @Test + void record_registry_rebuild_duration() { + metrics.recordRegistryRebuild(Duration.ofMillis(200)); + + Timer timer = registry.find("calm.github.registry.rebuild.duration").timer(); + assertThat(timer, is(notNullValue())); + assertThat(timer.count(), equalTo(1L)); + } + + @Test + void update_last_sync_success_epoch_on_success() { + long beforeEpoch = System.currentTimeMillis() / 1000; + metrics.recordSyncSuccess(Duration.ofMillis(100)); + + Gauge gauge = registry.find("calm.github.sync.last_success_epoch_seconds").gauge(); + assertThat(gauge, is(notNullValue())); + assertThat((long) gauge.value(), greaterThan(beforeEpoch - 1)); + } + + @Test + void not_update_last_sync_success_epoch_on_failure() { + metrics.recordSyncFailure(Duration.ofMillis(100)); + + Gauge gauge = registry.find("calm.github.sync.last_success_epoch_seconds").gauge(); + assertThat(gauge, is(notNullValue())); + assertThat(gauge.value(), equalTo(0.0)); + } + + @Test + void create_content_detected_counter() { + Counter counter = metrics.contentDetectedCounter(registry, "architecture"); + assertThat(counter, is(notNullValue())); + counter.increment(); + + Counter found = registry.find("calm.github.content.detected").tag("type", "architecture").counter(); + assertThat(found, is(notNullValue())); + assertThat(found.count(), equalTo(1.0)); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java index 2e8e399340..6fa7da710c 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java @@ -23,6 +23,7 @@ import static io.restassured.RestAssured.given; import static org.finos.calm.resources.ResourceValidationConstants.DOMAIN_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_MESSAGE; import static org.hamcrest.Matchers.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.*; @@ -204,7 +205,7 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_get_requireme .get("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/requirement/versions/1.0.invalid0") .then() .statusCode(400) - .body(containsString(VERSION_MESSAGE)); + .body(containsString(VERSION_OR_SHA_MESSAGE)); } static Stream provideParametersForGetRequirementTests() { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestPendingWriteExceptionMapperShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestPendingWriteExceptionMapperShould.java new file mode 100644 index 0000000000..c6d7af06f2 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestPendingWriteExceptionMapperShould.java @@ -0,0 +1,39 @@ +package org.finos.calm.resources; + +import jakarta.ws.rs.core.Response; +import org.finos.calm.domain.exception.PendingWriteException; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +class TestPendingWriteExceptionMapperShould { + + private final PendingWriteExceptionMapper mapper = new PendingWriteExceptionMapper(); + + @Test + @SuppressWarnings("unchecked") + void return_202_accepted_with_pr_details() { + PendingWriteException ex = new PendingWriteException( + "https://github.com/org/repo/pull/42", 42, "calm-hub/pattern-abc-123"); + + Response response = mapper.toResponse(ex); + + assertThat(response.getStatus(), equalTo(202)); + Map body = (Map) response.getEntity(); + assertThat(body.get("status"), equalTo("pending")); + assertThat(body.get("pullRequestUrl"), equalTo("https://github.com/org/repo/pull/42")); + assertThat(body.get("pullRequestNumber"), equalTo(42)); + } + + @Test + void return_application_json_content_type() { + PendingWriteException ex = new PendingWriteException("https://github.com/x/y/pull/1", 1, "branch"); + + Response response = mapper.toResponse(ex); + + assertThat(response.getMediaType().toString(), equalTo("application/json")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestUnsupportedOperationExceptionMapperShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestUnsupportedOperationExceptionMapperShould.java new file mode 100644 index 0000000000..f76ea395d6 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestUnsupportedOperationExceptionMapperShould.java @@ -0,0 +1,36 @@ +package org.finos.calm.resources; + +import jakarta.ws.rs.core.Response; +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +class TestUnsupportedOperationExceptionMapperShould { + + private final UnsupportedOperationExceptionMapper mapper = new UnsupportedOperationExceptionMapper(); + + @Test + @SuppressWarnings("unchecked") + void return_501_with_json_body() { + GitHubWriteNotSupportedException ex = new GitHubWriteNotSupportedException("Not available in GitHub mode"); + + Response response = mapper.toResponse(ex); + + assertThat(response.getStatus(), equalTo(501)); + Map body = (Map) response.getEntity(); + assertThat(body.get("error"), equalTo("Not available in GitHub mode")); + } + + @Test + void return_application_json_content_type() { + GitHubWriteNotSupportedException ex = new GitHubWriteNotSupportedException("test"); + + Response response = mapper.toResponse(ex); + + assertThat(response.getMediaType().toString(), equalTo("application/json")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaStoreShould.java new file mode 100644 index 0000000000..9e8ac4820b --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaStoreShould.java @@ -0,0 +1,82 @@ +package org.finos.calm.store.classpath; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TestClasspathCoreSchemaStoreShould { + + private ClasspathCoreSchemaStore store; + + @BeforeEach + void setup() { + store = new ClasspathCoreSchemaStore(); + } + + @Test + void return_empty_versions_when_no_index_file_present() { + List versions = store.getVersions(); + assertThat(versions, is(notNullValue())); + } + + @Test + void return_null_for_nonexistent_version() { + Map schemas = store.getSchemasForVersion("99.99.99"); + assertThat(schemas, is(nullValue())); + } + + @Test + void throw_on_create_schema_version() { + UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, + () -> store.createSchemaVersion("1.0", Map.of("core", "schema"))); + assertThat(ex.getMessage().contains("not supported"), is(true)); + } + + @Test + void return_unmodifiable_versions_list() { + List versions = store.getVersions(); + assertThrows(UnsupportedOperationException.class, () -> versions.add("hack")); + } + + @Test + void return_same_versions_on_repeated_calls() { + List first = store.getVersions(); + List second = store.getVersions(); + assertThat(first, equalTo(second)); + } + + @Test + void return_null_for_empty_string_version() { + Map schemas = store.getSchemasForVersion(""); + assertThat(schemas, is(nullValue())); + } + + @Test + void load_versions_from_classpath_resource() { + List versions = store.getVersions(); + assertThat(versions.contains("1.0"), is(true)); + } + + @Test + void load_schemas_for_known_version() { + Map schemas = store.getSchemasForVersion("1.0"); + assertThat(schemas, is(notNullValue())); + assertThat(schemas.containsKey("core"), is(true)); + } + + @Test + void cache_schemas_on_repeated_calls() { + Map first = store.getSchemasForVersion("1.0"); + Map second = store.getSchemasForVersion("1.0"); + assertThat(first, is(second)); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java new file mode 100644 index 0000000000..81d2b7da5c --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java @@ -0,0 +1,115 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.adr.AdrMeta; +import org.finos.calm.domain.adr.Status; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.adr.NamespaceAdrSummary; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubAdrStoreShould { + + @Mock + private InMemoryRegistryService registryService; + + private GitHubAdrStore store; + + @BeforeEach + void setup() { + store = new GitHubAdrStore(registryService); + } + + @Test + void return_empty_adrs_for_namespace() throws NamespaceNotFoundException { + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + List result = store.getAdrsForNamespace("finos"); + + assertThat(result, is(empty())); + } + + @Test + void return_zero_count_for_namespace() throws NamespaceNotFoundException { + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + assertThat(store.countAdrsForNamespace("finos"), equalTo(0)); + } + + @Test + void throw_namespace_not_found_on_get_adrs() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getAdrsForNamespace("nonexistent")); + } + + @Test + void throw_namespace_not_found_on_count_adrs() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.countAdrsForNamespace("nonexistent")); + } + + @Test + void throw_unsupported_on_create_adr() { + assertThrows(UnsupportedOperationException.class, + () -> store.createAdrForNamespace(new AdrMeta())); + } + + @Test + void throw_unsupported_on_get_adr() { + assertThrows(UnsupportedOperationException.class, + () -> store.getAdr(new AdrMeta())); + } + + @Test + void throw_unsupported_on_get_adr_revisions() { + assertThrows(UnsupportedOperationException.class, + () -> store.getAdrRevisions(new AdrMeta())); + } + + @Test + void throw_unsupported_on_get_adr_revision() { + assertThrows(UnsupportedOperationException.class, + () -> store.getAdrRevision(new AdrMeta())); + } + + @Test + void throw_unsupported_on_update_adr() { + assertThrows(UnsupportedOperationException.class, + () -> store.updateAdrForNamespace(new AdrMeta())); + } + + @Test + void throw_unsupported_on_update_adr_status() { + assertThrows(UnsupportedOperationException.class, + () -> store.updateAdrStatus(new AdrMeta(), Status.accepted)); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java new file mode 100644 index 0000000000..0294b945cd --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java @@ -0,0 +1,220 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.Architecture; +import org.finos.calm.domain.exception.ArchitectureNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.namespaces.NamespaceResourceSummary; +import org.finos.calm.store.PageRequest; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubArchitectureStoreShould { + + @Mock + private InMemoryRegistryService registryService; + + private GitHubArchitectureStore store; + + @BeforeEach + void setup() { + store = new GitHubArchitectureStore(registryService); + } + + @Test + void return_architectures_for_namespace() throws NamespaceNotFoundException { + RegistryEntry entry = new RegistryEntry("my-arch", Path.of("architectures/my-arch.json"), + CalmResourceType.ARCHITECTURE, "My Architecture", Instant.now()); + + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:my-arch", entry), + Map.of(CalmResourceType.ARCHITECTURE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.ARCHITECTURE)).thenReturn(List.of(entry)); + + List result = store.getArchitecturesForNamespace("finos", PageRequest.UNPAGED); + + assertThat(result, hasSize(1)); + assertThat(result.get(0).getName(), equalTo("My Architecture")); + } + + @Test + void throw_namespace_not_found_when_namespace_missing_on_get() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getArchitecturesForNamespace("nonexistent", PageRequest.UNPAGED)); + } + + @Test + void throw_unsupported_on_create_architecture() { + assertThrows(UnsupportedOperationException.class, + () -> store.createArchitectureForNamespace(new Architecture.ArchitectureBuilder().build())); + } + + @Test + void throw_namespace_not_found_on_get_architecture_versions() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + assertThrows(NamespaceNotFoundException.class, + () -> store.getArchitectureVersions(new Architecture.ArchitectureBuilder().setNamespace("nonexistent").build())); + } + + @Test + void throw_namespace_not_found_on_get_architecture_for_version() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + assertThrows(NamespaceNotFoundException.class, + () -> store.getArchitectureForVersion(new Architecture.ArchitectureBuilder().setNamespace("nonexistent").build())); + } + + @Test + void return_versions_list_for_existing_architecture() throws Exception { + RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), + CalmResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:test-arch", entry), + java.util.Map.of(CalmResourceType.ARCHITECTURE, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + + int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); + Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(hashId).build(); + java.util.List versions = store.getArchitectureVersions(arch); + + assertThat(versions, hasSize(1)); + assertThat(versions.get(0), equalTo("latest")); + } + + @Test + void return_sha_versions_when_version_service_available() throws Exception { + RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), + CalmResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:test-arch", entry), + java.util.Map.of(CalmResourceType.ARCHITECTURE, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + + org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); + org.finos.calm.store.github.util.GitHubVersionService mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); + when(mockVersionService.getFileVersions("finos/architecture-as-code", "architectures/test.json")) + .thenReturn(java.util.List.of("abc1234", "def5678")); + + int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); + Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(hashId).build(); + java.util.List versions = store.getArchitectureVersions(arch); + + assertThat(versions, hasSize(2)); + assertThat(versions.get(0), equalTo("abc1234")); + } + + @Test + void return_architecture_content_for_version(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tempDir) throws Exception { + java.nio.file.Path archDir = tempDir.resolve("finos/architectures"); + java.nio.file.Files.createDirectories(archDir); + java.nio.file.Files.writeString(archDir.resolve("test.json"), "{\"nodes\":[],\"relationships\":[]}"); + + RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), + CalmResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:test-arch", entry), + java.util.Map.of(CalmResourceType.ARCHITECTURE, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + + store.cloneDirectory = tempDir.toString(); + int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); + Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); + + String content = store.getArchitectureForVersion(arch); + assertThat(content, equalTo("{\"nodes\":[],\"relationships\":[]}")); + } + + @Test + void return_content_from_github_api_for_sha_version() throws Exception { + RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), + CalmResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:test-arch", entry), + java.util.Map.of(CalmResourceType.ARCHITECTURE, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + + org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); + org.finos.calm.store.github.util.GitHubVersionService mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(mockVersionService.getFileAtVersion("finos/repo", "architectures/test.json", "abc1234")) + .thenReturn("{\"nodes\":[{\"name\":\"old\"}]}"); + + int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); + Architecture arch = new Architecture.ArchitectureBuilder() + .setNamespace("finos").setId(hashId).setVersion("abc1234").build(); + String content = store.getArchitectureForVersion(arch); + + assertThat(content, equalTo("{\"nodes\":[{\"name\":\"old\"}]}")); + } + + @Test + void throw_architecture_not_found_when_id_does_not_match() { + RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), + CalmResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:test-arch", entry), + java.util.Map.of(CalmResourceType.ARCHITECTURE, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + + Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(99999).build(); + assertThrows(ArchitectureNotFoundException.class, () -> store.getArchitectureVersions(arch)); + } + + @Test + void throw_unsupported_on_create_architecture_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.createArchitectureForVersion(new Architecture.ArchitectureBuilder().build())); + } + + @Test + void throw_unsupported_on_update_architecture_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.updateArchitectureForVersion(new Architecture.ArchitectureBuilder().build())); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAuditLogStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAuditLogStoreShould.java new file mode 100644 index 0000000000..b3c8e8ccfb --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAuditLogStoreShould.java @@ -0,0 +1,41 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.audit.AuditLogEntry; +import org.finos.calm.domain.audit.AuditLogQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@ExtendWith(MockitoExtension.class) +class TestGitHubAuditLogStoreShould { + + private GitHubAuditLogStore store; + + @BeforeEach + void setup() { + store = new GitHubAuditLogStore(); + } + + @Test + void not_throw_on_record() { + AuditLogEntry entry = new AuditLogEntry(); + assertDoesNotThrow(() -> store.record(entry)); + } + + @Test + void return_empty_list_on_query() { + AuditLogQuery query = new AuditLogQuery(); + + List result = store.query(query); + + assertThat(result, is(empty())); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java new file mode 100644 index 0000000000..6055945fae --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java @@ -0,0 +1,231 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.BuildingBlockNotFoundException; +import org.finos.calm.domain.exception.BuildingBlockVersionNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.namespaces.NamespaceResourceSummary; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubBuildingBlockStoreShould { + + private static final String NAMESPACE = "finos"; + private static final String UNIQUE_ID = "my-building-block"; + private static final int HASH_ID = UNIQUE_ID.hashCode() & 0x7FFFFFFF; + + @Mock + private InMemoryRegistryService registryService; + + private GitHubBuildingBlockStore store; + + @BeforeEach + void setup() { + store = new GitHubBuildingBlockStore(registryService); + } + + @Test + void return_building_blocks_for_namespace() throws NamespaceNotFoundException { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/my-building-block.json"), + CalmResourceType.BUILDING_BLOCK, "My Building Block", Instant.now()); + + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(NAMESPACE, List.of(entry)), + Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); + + List result = store.getBuildingBlocksForNamespace(NAMESPACE); + + assertThat(result, hasSize(1)); + assertThat(result.get(0).getName(), equalTo("My Building Block")); + } + + @Test + void throw_namespace_not_found_when_namespace_missing_on_get() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getBuildingBlocksForNamespace("nonexistent")); + } + + @Test + void throw_unsupported_on_create_building_block() { + assertThrows(UnsupportedOperationException.class, + () -> store.createBuildingBlockForNamespace(NAMESPACE, "{}")); + } + + @Test + void throw_namespace_not_found_on_get_building_block_versions() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getBuildingBlockVersions("nonexistent", 1)); + } + + @Test + void throw_namespace_not_found_on_get_building_block_for_version() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getBuildingBlockForVersion("nonexistent", 1, "1.0.0")); + } + + @Test + void return_versions_list_for_existing_building_block() throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/test.json"), + CalmResourceType.BUILDING_BLOCK, "Test", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(NAMESPACE, List.of(entry)), + Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); + + List versions = store.getBuildingBlockVersions(NAMESPACE, HASH_ID); + + assertThat(versions, hasSize(1)); + assertThat(versions.get(0), equalTo("latest")); + } + + @Test + void return_sha_versions_when_version_service_available() throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/test.json"), + CalmResourceType.BUILDING_BLOCK, "Test", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(NAMESPACE, List.of(entry)), + Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); + + GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); + GitHubVersionService mockVersionService = mock(GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace(NAMESPACE)).thenReturn("finos/architecture-as-code"); + when(mockVersionService.getFileVersions("finos/architecture-as-code", "building-blocks/test.json")) + .thenReturn(List.of("abc1234", "def5678")); + + List versions = store.getBuildingBlockVersions(NAMESPACE, HASH_ID); + + assertThat(versions, hasSize(2)); + assertThat(versions.get(0), equalTo("abc1234")); + } + + @Test + void return_building_block_content_for_version(@TempDir Path tempDir) throws Exception { + Path bbDir = tempDir.resolve("finos/building-blocks"); + Files.createDirectories(bbDir); + Files.writeString(bbDir.resolve("test.json"), "{\"nodes\":[],\"relationships\":[]}"); + + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/test.json"), + CalmResourceType.BUILDING_BLOCK, "Test", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(NAMESPACE, List.of(entry)), + Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); + + store.cloneDirectory = tempDir.toString(); + String content = store.getBuildingBlockForVersion(NAMESPACE, HASH_ID, "1.0.0"); + + assertThat(content, equalTo("{\"nodes\":[],\"relationships\":[]}")); + } + + @Test + void return_content_from_github_api_for_sha_version() throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/test.json"), + CalmResourceType.BUILDING_BLOCK, "Test", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(NAMESPACE, List.of(entry)), + Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); + + GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); + GitHubVersionService mockVersionService = mock(GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace(NAMESPACE)).thenReturn("finos/repo"); + when(mockVersionService.getFileAtVersion("finos/repo", "building-blocks/test.json", "abc1234")) + .thenReturn("{\"nodes\":[{\"name\":\"old\"}]}"); + + String content = store.getBuildingBlockForVersion(NAMESPACE, HASH_ID, "abc1234"); + + assertThat(content, equalTo("{\"nodes\":[{\"name\":\"old\"}]}")); + } + + @Test + void throw_building_block_not_found_when_id_does_not_match() { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/test.json"), + CalmResourceType.BUILDING_BLOCK, "Test", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(NAMESPACE, List.of(entry)), + Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); + + assertThrows(BuildingBlockNotFoundException.class, + () -> store.getBuildingBlockVersions(NAMESPACE, 99999)); + } + + @Test + void throw_unsupported_on_create_building_block_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.createBuildingBlockForVersion(NAMESPACE, 1, "1.0.0", "{}")); + } + + @Test + void throw_building_block_version_not_found_when_file_missing(@TempDir Path tempDir) throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/nonexistent.json"), + CalmResourceType.BUILDING_BLOCK, "Test", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(NAMESPACE, List.of(entry)), + Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); + + store.cloneDirectory = tempDir.toString(); + + assertThrows(BuildingBlockVersionNotFoundException.class, + () -> store.getBuildingBlockForVersion(NAMESPACE, HASH_ID, "1.0.0")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java new file mode 100644 index 0000000000..4b212f4a3f --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -0,0 +1,366 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.controls.ControlDetail; +import org.finos.calm.domain.controls.CreateControlConfiguration; +import org.finos.calm.domain.controls.CreateControlRequirement; +import org.finos.calm.domain.exception.ControlNotFoundException; +import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; +import org.finos.calm.domain.exception.DomainNotFoundException; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.NamespaceAccessFilter; +import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubControlStoreShould { + + private static final String DOMAIN = "security"; + private static final String UNIQUE_ID = "my-control"; + private static final int HASH_ID = UNIQUE_ID.hashCode() & 0x7FFFFFFF; + + @Mock + private InMemoryRegistryService registryService; + + @Mock + private NamespaceAccessFilter accessFilter; + + private GitHubControlStore store; + + @BeforeEach + void setup() { + store = new GitHubControlStore(registryService); + store.accessFilter = accessFilter; + } + + @Test + void return_controls_for_domain() throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(DOMAIN, List.of(entry)), + Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.CONTROL, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + + List result = store.getControlsForDomain(DOMAIN); + + assertThat(result, hasSize(1)); + assertThat(result.get(0).getName(), equalTo(UNIQUE_ID)); + assertThat(result.get(0).getTitle(), equalTo("My Control")); + assertThat(result.get(0).getId(), equalTo(HASH_ID)); + } + + @Test + void throw_domain_not_found_when_domain_missing_on_get_controls() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of()); + + assertThrows(DomainNotFoundException.class, + () -> store.getControlsForDomain("nonexistent")); + } + + @Test + void return_all_controls_when_access_filter_is_null() throws Exception { + GitHubControlStore unfilteredStore = new GitHubControlStore(registryService); + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(DOMAIN, List.of(entry)), + Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.CONTROL, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + + List result = unfilteredStore.getControlsForDomain(DOMAIN); + + assertThat(result, hasSize(1)); + assertThat(result.get(0).getName(), equalTo(UNIQUE_ID)); + } + + @Test + void hide_controls_from_inaccessible_namespaces() throws Exception { + RegistryEntry accessibleEntry = new RegistryEntry("ctrl-a", Path.of("controls/security/ctrl-a.json"), + CalmResourceType.CONTROL, "Control A", Instant.now()); + RegistryEntry restrictedEntry = new RegistryEntry("ctrl-b", Path.of("controls/security/ctrl-b.json"), + CalmResourceType.CONTROL, "Control B", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(accessibleEntry), "private", List.of(restrictedEntry)), + Map.of("finos:ctrl-a", accessibleEntry, "private:ctrl-b", restrictedEntry), + Map.of(CalmResourceType.CONTROL, List.of(accessibleEntry, restrictedEntry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(accessibleEntry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + List result = store.getControlsForDomain(DOMAIN); + + assertThat(result, hasSize(1)); + assertThat(result.get(0).getName(), equalTo("ctrl-a")); + } + + @Test + void deny_version_lookup_for_control_in_inaccessible_namespace() { + RegistryEntry accessible = new RegistryEntry("ctrl-a", Path.of("controls/security/ctrl-a.json"), + CalmResourceType.CONTROL, "Control A", Instant.now()); + RegistryEntry restricted = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(accessible), "private", List.of(restricted)), + Map.of("finos:ctrl-a", accessible, "private:" + UNIQUE_ID, restricted), + Map.of(CalmResourceType.CONTROL, List.of(accessible, restricted)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(accessible)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + assertThrows(ControlNotFoundException.class, + () -> store.getRequirementVersions(DOMAIN, HASH_ID)); + } + + @Test + void return_versions_for_control_in_accessible_namespace_with_mixed_access(@TempDir Path tempDir) throws Exception { + RegistryEntry accessible = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistryEntry restricted = new RegistryEntry("other", Path.of("controls/security/other.json"), + CalmResourceType.CONTROL, "Other", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(accessible), "private", List.of(restricted)), + Map.of("finos:" + UNIQUE_ID, accessible, "private:other", restricted), + Map.of(CalmResourceType.CONTROL, List.of(accessible, restricted)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(accessible)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + List versions = store.getRequirementVersions(DOMAIN, HASH_ID); + + assertThat(versions, hasSize(1)); + assertThat(versions.get(0), equalTo("latest")); + } + + @Test + void return_versions_list_for_existing_control() throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/my-control.json"), + CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(DOMAIN, List.of(entry)), + Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.CONTROL, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + + List versions = store.getRequirementVersions(DOMAIN, HASH_ID); + + assertThat(versions, hasSize(1)); + assertThat(versions.get(0), equalTo("latest")); + } + + @Test + void return_sha_versions_when_version_service_available() throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/my-control.json"), + CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(DOMAIN, List.of(entry)), + Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.CONTROL, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + + GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); + GitHubVersionService mockVersionService = mock(GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace(DOMAIN)).thenReturn("org/repo"); + when(mockVersionService.getFileVersions("org/repo", "controls/my-control.json")) + .thenReturn(List.of("abc1234", "def5678")); + + List versions = store.getRequirementVersions(DOMAIN, HASH_ID); + + assertThat(versions, hasSize(2)); + assertThat(versions.get(0), equalTo("abc1234")); + } + + @Test + void return_control_content_for_version(@TempDir Path tempDir) throws Exception { + Path controlDir = tempDir.resolve("security/controls"); + Files.createDirectories(controlDir); + Files.writeString(controlDir.resolve("my-control.json"), "{\"control\":\"data\"}"); + + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/my-control.json"), + CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(DOMAIN, List.of(entry)), + Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.CONTROL, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + + store.cloneDirectory = tempDir.toString(); + String content = store.getRequirementForVersion(DOMAIN, HASH_ID, "1.0.0"); + + assertThat(content, equalTo("{\"control\":\"data\"}")); + } + + @Test + void return_content_from_github_api_for_sha_version() throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/my-control.json"), + CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(DOMAIN, List.of(entry)), + Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.CONTROL, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + + GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); + GitHubVersionService mockVersionService = mock(GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace(DOMAIN)).thenReturn("org/repo"); + when(mockVersionService.getFileAtVersion("org/repo", "controls/my-control.json", "abc1234")) + .thenReturn("{\"control\":\"old-data\"}"); + + String content = store.getRequirementForVersion(DOMAIN, HASH_ID, "abc1234"); + + assertThat(content, equalTo("{\"control\":\"old-data\"}")); + } + + @Test + void throw_domain_not_found_on_get_requirement_versions() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of()); + + assertThrows(DomainNotFoundException.class, + () -> store.getRequirementVersions("nonexistent", 1)); + } + + @Test + void throw_control_not_found_when_id_does_not_match() { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(DOMAIN, List.of(entry)), + Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.CONTROL, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + + assertThrows(ControlNotFoundException.class, + () -> store.getRequirementVersions(DOMAIN, 99999)); + } + + @Test + void throw_domain_not_found_on_get_requirement_for_version() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of()); + + assertThrows(DomainNotFoundException.class, + () -> store.getRequirementForVersion("nonexistent", 1, "1.0.0")); + } + + @Test + void throw_requirement_version_not_found_when_file_missing(@TempDir Path tempDir) throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/nonexistent.json"), + CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(DOMAIN, List.of(entry)), + Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of(CalmResourceType.CONTROL, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + + store.cloneDirectory = tempDir.toString(); + + assertThrows(ControlRequirementVersionNotFoundException.class, + () -> store.getRequirementForVersion(DOMAIN, HASH_ID, "1.0.0")); + } + + @Test + void throw_unsupported_on_create_control_requirement() { + assertThrows(UnsupportedOperationException.class, + () -> store.createControlRequirement(new CreateControlRequirement(), DOMAIN)); + } + + @Test + void throw_unsupported_on_create_requirement_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.createRequirementForVersion(DOMAIN, 1, "1.0.0", new CreateControlRequirement())); + } + + @Test + void throw_unsupported_on_get_configurations_for_control() { + assertThrows(UnsupportedOperationException.class, + () -> store.getConfigurationsForControl(DOMAIN, 1)); + } + + @Test + void throw_unsupported_on_get_configuration_details_for_control() { + assertThrows(UnsupportedOperationException.class, + () -> store.getConfigurationDetailsForControl(DOMAIN, 1)); + } + + @Test + void throw_unsupported_on_create_control_configuration() { + assertThrows(UnsupportedOperationException.class, + () -> store.createControlConfiguration(new CreateControlConfiguration(), DOMAIN, 1)); + } + + @Test + void throw_unsupported_on_get_configuration_versions() { + assertThrows(UnsupportedOperationException.class, + () -> store.getConfigurationVersions(DOMAIN, 1, 1)); + } + + @Test + void throw_unsupported_on_get_configuration_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.getConfigurationForVersion(DOMAIN, 1, 1, "1.0.0")); + } + + @Test + void throw_unsupported_on_create_configuration_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.createConfigurationForVersion(DOMAIN, 1, 1, "1.0.0", new CreateControlConfiguration())); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java new file mode 100644 index 0000000000..86e674b93a --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java @@ -0,0 +1,114 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.Decorator; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubDecoratorStoreShould { + + @Mock + private InMemoryRegistryService registryService; + + private GitHubDecoratorStore store; + + @BeforeEach + void setup() { + store = new GitHubDecoratorStore(registryService); + } + + @Test + void return_empty_decorators_for_namespace() throws NamespaceNotFoundException { + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + List result = store.getDecoratorsForNamespace("finos", "target", "type"); + + assertThat(result, is(empty())); + } + + @Test + void return_empty_decorator_values_for_namespace() throws NamespaceNotFoundException { + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + List result = store.getDecoratorValuesForNamespace("finos", "target", "type"); + + assertThat(result, is(empty())); + } + + @Test + void return_empty_optional_for_decorator_by_id() throws NamespaceNotFoundException { + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + Optional result = store.getDecoratorById("finos", 1); + + assertThat(result, equalTo(Optional.empty())); + } + + @Test + void throw_namespace_not_found_on_get_decorators() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getDecoratorsForNamespace("nonexistent", "target", "type")); + } + + @Test + void throw_namespace_not_found_on_get_decorator_values() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getDecoratorValuesForNamespace("nonexistent", "target", "type")); + } + + @Test + void throw_namespace_not_found_on_get_decorator_by_id() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getDecoratorById("nonexistent", 1)); + } + + @Test + void throw_unsupported_on_create_decorator() { + assertThrows(UnsupportedOperationException.class, + () -> store.createDecorator("finos", "{}")); + } + + @Test + void throw_unsupported_on_update_decorator() { + assertThrows(UnsupportedOperationException.class, + () -> store.updateDecorator("finos", 1, "{}")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java new file mode 100644 index 0000000000..b6ca5bc499 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java @@ -0,0 +1,122 @@ +package org.finos.calm.store.github; + +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.NamespaceAccessFilter; +import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubDomainStoreShould { + + @Mock + private InMemoryRegistryService registryService; + + @Mock + private NamespaceAccessFilter accessFilter; + + private GitHubDomainStore store; + + @BeforeEach + void setup() { + store = new GitHubDomainStore(registryService); + store.accessFilter = accessFilter; + } + + @Test + void return_domains_derived_from_controls_directory() { + RegistryEntry entry = new RegistryEntry("tls-policy", Path.of("controls/security/tls-policy.json"), + CalmResourceType.CONTROL, "TLS Policy", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:tls-policy", entry), + Map.of(CalmResourceType.CONTROL, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + List domains = store.getDomains(); + + assertThat(domains, contains("security")); + } + + @Test + void return_empty_when_no_controls_exist() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of()); + + List domains = store.getDomains(); + + assertThat(domains, is(empty())); + } + + @Test + void return_true_when_domain_exists() { + RegistryEntry entry = new RegistryEntry("ctrl", Path.of("controls/security/ctrl.json"), + CalmResourceType.CONTROL, "Ctrl", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:ctrl", entry), + Map.of(CalmResourceType.CONTROL, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + assertThat(store.domainExists("security"), is(true)); + } + + @Test + void return_false_when_domain_does_not_exist() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of()); + + assertThat(store.domainExists("nonexistent"), is(false)); + } + + @Test + void hide_domains_from_inaccessible_namespaces() { + RegistryEntry accessibleEntry = new RegistryEntry("ctrl-a", Path.of("controls/security/ctrl-a.json"), + CalmResourceType.CONTROL, "Control A", Instant.now()); + RegistryEntry restrictedEntry = new RegistryEntry("ctrl-b", Path.of("controls/compliance/ctrl-b.json"), + CalmResourceType.CONTROL, "Control B", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(accessibleEntry), "private", List.of(restrictedEntry)), + Map.of("finos:ctrl-a", accessibleEntry, "private:ctrl-b", restrictedEntry), + Map.of(CalmResourceType.CONTROL, List.of(accessibleEntry, restrictedEntry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + List domains = store.getDomains(); + + assertThat(domains, contains("security")); + } + + @Test + void throw_on_create_domain() { + assertThrows(UnsupportedOperationException.class, () -> store.createDomain("new-domain")); + } + + @Test + void throw_on_delete_domain() { + assertThrows(UnsupportedOperationException.class, () -> store.deleteDomain("security")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java new file mode 100644 index 0000000000..ab9c928e9e --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java @@ -0,0 +1,223 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.Flow; +import org.finos.calm.domain.exception.FlowNotFoundException; +import org.finos.calm.domain.exception.FlowVersionNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.flow.CreateFlowRequest; +import org.finos.calm.domain.namespaces.NamespaceResourceSummary; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubFlowStoreShould { + + @Mock + private InMemoryRegistryService registryService; + + private GitHubFlowStore store; + + @BeforeEach + void setup() { + store = new GitHubFlowStore(registryService); + } + + @Test + void return_flows_for_namespace() throws NamespaceNotFoundException { + RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), + CalmResourceType.FLOW, "Payment Flow", Instant.now()); + + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-flow", entry), + Map.of(CalmResourceType.FLOW, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.FLOW)).thenReturn(List.of(entry)); + + List result = store.getFlowsForNamespace("finos"); + + assertThat(result, hasSize(1)); + assertThat(result.get(0).getName(), equalTo("Payment Flow")); + } + + @Test + void throw_namespace_not_found_when_namespace_missing() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getFlowsForNamespace("nonexistent")); + } + + @Test + void throw_unsupported_on_create_flow() { + assertThrows(UnsupportedOperationException.class, + () -> store.createFlowForNamespace(new CreateFlowRequest(), "finos")); + } + + @Test + void throw_namespace_not_found_on_get_flow_versions() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + assertThrows(NamespaceNotFoundException.class, + () -> store.getFlowVersions(new Flow.FlowBuilder().setNamespace("nonexistent").build())); + } + + @Test + void throw_namespace_not_found_on_get_flow_for_version() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + assertThrows(NamespaceNotFoundException.class, + () -> store.getFlowForVersion(new Flow.FlowBuilder().setNamespace("nonexistent").build())); + } + + @Test + void return_versions_list_for_existing_flow() throws Exception { + RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), + CalmResourceType.FLOW, "Payment Flow", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-flow", entry), + Map.of(CalmResourceType.FLOW, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.FLOW)).thenReturn(List.of(entry)); + + int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); + Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(hashId).build(); + List versions = store.getFlowVersions(flow); + + assertThat(versions, hasSize(1)); + assertThat(versions.get(0), equalTo("latest")); + } + + @Test + void return_sha_versions_when_version_service_available() throws Exception { + RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), + CalmResourceType.FLOW, "Payment Flow", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-flow", entry), + Map.of(CalmResourceType.FLOW, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.FLOW)).thenReturn(List.of(entry)); + + GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); + GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); + when(mockVersionService.getFileVersions("finos/architecture-as-code", "flows/payment-flow.json")) + .thenReturn(List.of("abc1234", "def5678")); + + int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); + Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(hashId).build(); + List versions = store.getFlowVersions(flow); + + assertThat(versions, hasSize(2)); + assertThat(versions.get(0), equalTo("abc1234")); + } + + @Test + void return_flow_content_for_version(@TempDir Path tempDir) throws Exception { + Path flowDir = tempDir.resolve("finos/flows"); + Files.createDirectories(flowDir); + Files.writeString(flowDir.resolve("payment-flow.json"), "{\"steps\":[]}"); + + RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), + CalmResourceType.FLOW, "Payment Flow", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-flow", entry), + Map.of(CalmResourceType.FLOW, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.FLOW)).thenReturn(List.of(entry)); + + store.cloneDirectory = tempDir.toString(); + int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); + Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); + + String content = store.getFlowForVersion(flow); + assertThat(content, equalTo("{\"steps\":[]}")); + } + + @Test + void return_content_from_github_api_for_sha_version() throws Exception { + RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), + CalmResourceType.FLOW, "Payment Flow", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-flow", entry), + Map.of(CalmResourceType.FLOW, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.FLOW)).thenReturn(List.of(entry)); + + GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); + GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(mockVersionService.getFileAtVersion("finos/repo", "flows/payment-flow.json", "abc1234")) + .thenReturn("{\"steps\":[{\"name\":\"old\"}]}"); + + int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); + Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(hashId).setVersion("abc1234").build(); + String content = store.getFlowForVersion(flow); + + assertThat(content, equalTo("{\"steps\":[{\"name\":\"old\"}]}")); + } + + @Test + void throw_flow_not_found_when_id_does_not_match() { + RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), + CalmResourceType.FLOW, "Payment Flow", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-flow", entry), + Map.of(CalmResourceType.FLOW, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.FLOW)).thenReturn(List.of(entry)); + + Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(99999).build(); + assertThrows(FlowNotFoundException.class, () -> store.getFlowVersions(flow)); + } + + @Test + void throw_unsupported_on_create_flow_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.createFlowForVersion(new Flow.FlowBuilder().build())); + } + + @Test + void throw_unsupported_on_update_flow_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.updateFlowForVersion(new Flow.FlowBuilder().build())); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java new file mode 100644 index 0000000000..bffc707fcf --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java @@ -0,0 +1,228 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.InterfaceNotFoundException; +import org.finos.calm.domain.exception.InterfaceVersionNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.interfaces.CreateInterfaceRequest; +import org.finos.calm.domain.interfaces.NamespaceInterfaceSummary; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubInterfaceStoreShould { + + @Mock + private InMemoryRegistryService registryService; + + private GitHubInterfaceStore store; + + @BeforeEach + void setup() { + store = new GitHubInterfaceStore(registryService); + } + + @Test + void return_empty_interfaces_for_namespace() throws NamespaceNotFoundException { + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of()); + + List result = store.getInterfacesForNamespace("finos"); + + assertThat(result, is(empty())); + } + + @Test + void return_interfaces_for_namespace() throws NamespaceNotFoundException { + RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), + CalmResourceType.INTERFACE, "Payment API", Instant.now()); + + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-api", entry), + Map.of(CalmResourceType.INTERFACE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of(entry)); + + List result = store.getInterfacesForNamespace("finos"); + + assertThat(result, hasSize(1)); + assertThat(result.get(0).getName(), equalTo("Payment API")); + } + + @Test + void throw_namespace_not_found_when_namespace_missing() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getInterfacesForNamespace("nonexistent")); + } + + @Test + void throw_unsupported_on_create_interface() { + assertThrows(UnsupportedOperationException.class, + () -> store.createInterfaceForNamespace(new CreateInterfaceRequest(), "finos")); + } + + @Test + void throw_namespace_not_found_on_get_interface_versions() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + assertThrows(NamespaceNotFoundException.class, + () -> store.getInterfaceVersions("nonexistent", 1)); + } + + @Test + void throw_namespace_not_found_on_get_interface_for_version() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + assertThrows(NamespaceNotFoundException.class, + () -> store.getInterfaceForVersion("nonexistent", 1, "1.0.0")); + } + + @Test + void return_versions_list_for_existing_interface() throws Exception { + RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), + CalmResourceType.INTERFACE, "Payment API", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-api", entry), + Map.of(CalmResourceType.INTERFACE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of(entry)); + + int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); + List versions = store.getInterfaceVersions("finos", hashId); + + assertThat(versions, hasSize(1)); + assertThat(versions.get(0), equalTo("latest")); + } + + @Test + void return_sha_versions_when_version_service_available() throws Exception { + RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), + CalmResourceType.INTERFACE, "Payment API", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-api", entry), + Map.of(CalmResourceType.INTERFACE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of(entry)); + + GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); + GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); + when(mockVersionService.getFileVersions("finos/architecture-as-code", "interfaces/payment-api.json")) + .thenReturn(List.of("abc1234", "def5678")); + + int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); + List versions = store.getInterfaceVersions("finos", hashId); + + assertThat(versions, hasSize(2)); + assertThat(versions.get(0), equalTo("abc1234")); + } + + @Test + void return_interface_content_for_version(@TempDir Path tempDir) throws Exception { + Path ifaceDir = tempDir.resolve("finos/interfaces"); + Files.createDirectories(ifaceDir); + Files.writeString(ifaceDir.resolve("payment-api.json"), "{\"operations\":[]}"); + + RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), + CalmResourceType.INTERFACE, "Payment API", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-api", entry), + Map.of(CalmResourceType.INTERFACE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of(entry)); + + store.cloneDirectory = tempDir.toString(); + int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); + + String content = store.getInterfaceForVersion("finos", hashId, "1.0.0"); + assertThat(content, equalTo("{\"operations\":[]}")); + } + + @Test + void return_content_from_github_api_for_sha_version() throws Exception { + RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), + CalmResourceType.INTERFACE, "Payment API", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-api", entry), + Map.of(CalmResourceType.INTERFACE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of(entry)); + + GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); + GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(mockVersionService.getFileAtVersion("finos/repo", "interfaces/payment-api.json", "abc1234")) + .thenReturn("{\"operations\":[{\"name\":\"old\"}]}"); + + int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); + String content = store.getInterfaceForVersion("finos", hashId, "abc1234"); + + assertThat(content, equalTo("{\"operations\":[{\"name\":\"old\"}]}")); + } + + @Test + void throw_interface_not_found_when_id_does_not_match() { + RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), + CalmResourceType.INTERFACE, "Payment API", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-api", entry), + Map.of(CalmResourceType.INTERFACE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of(entry)); + + assertThrows(InterfaceNotFoundException.class, () -> store.getInterfaceVersions("finos", 99999)); + } + + @Test + void throw_unsupported_on_create_interface_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.createInterfaceForVersion(new CreateInterfaceRequest(), "finos", 1, "1.0.0")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubLayoutStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubLayoutStoreShould.java new file mode 100644 index 0000000000..b1e9d54010 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubLayoutStoreShould.java @@ -0,0 +1,46 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@ExtendWith(MockitoExtension.class) +class TestGitHubLayoutStoreShould { + + private GitHubLayoutStore store; + + @BeforeEach + void setup() { + store = new GitHubLayoutStore(); + } + + @Test + void return_empty_optional_for_get_layout() throws NamespaceNotFoundException { + Optional result = store.getLayout("finos", 1); + + assertThat(result, equalTo(Optional.empty())); + } + + @Test + void no_op_on_upsert_layout() { + assertDoesNotThrow(() -> store.upsertLayout("finos", 1, "{\"nodes\":[]}")); + } + + @Test + void return_empty_list_for_architecture_ids_with_layout() throws NamespaceNotFoundException { + List result = store.getArchitectureIdsWithLayoutForNamespace("finos"); + + assertThat(result, is(empty())); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubNamespaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubNamespaceStoreShould.java new file mode 100644 index 0000000000..4536932ae1 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubNamespaceStoreShould.java @@ -0,0 +1,88 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.namespaces.NamespaceInfo; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubNamespaceStoreShould { + + @Mock + private InMemoryRegistryService registryService; + + private GitHubNamespaceStore store; + + @BeforeEach + void setup() { + store = new GitHubNamespaceStore(registryService); + } + + @Test + void return_namespaces_from_registry() { + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(), "team", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + List result = store.getNamespaces(); + + assertThat(result, hasSize(2)); + } + + @Test + void return_true_when_namespace_exists() { + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + assertThat(store.namespaceExists("finos"), is(true)); + } + + @Test + void return_false_when_namespace_does_not_exist() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThat(store.namespaceExists("nonexistent"), is(false)); + } + + @Test + void throw_on_create_namespace() { + UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, + () -> store.createNamespace("new-ns", "desc")); + assertThat(ex.getMessage().contains("admin-configured"), is(true)); + } + + @Test + void throw_on_update_namespace_description() { + assertThrows(UnsupportedOperationException.class, + () -> store.updateNamespaceDescription("finos", "new desc")); + } + + @Test + void throw_on_delete_namespace() { + assertThrows(UnsupportedOperationException.class, + () -> store.deleteNamespace("finos")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java new file mode 100644 index 0000000000..babc34cedb --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java @@ -0,0 +1,224 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.Pattern; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.PatternNotFoundException; +import org.finos.calm.domain.exception.PatternVersionNotFoundException; +import org.finos.calm.domain.namespaces.NamespaceResourceSummary; +import org.finos.calm.domain.pattern.CreatePatternRequest; +import org.finos.calm.store.PageRequest; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubPatternStoreShould { + + @Mock + private InMemoryRegistryService registryService; + + private GitHubPatternStore store; + + @BeforeEach + void setup() { + store = new GitHubPatternStore(registryService); + } + + @Test + void return_patterns_for_namespace() throws NamespaceNotFoundException { + RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), + CalmResourceType.PATTERN, "Event Driven", Instant.now()); + + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:event-driven", entry), + Map.of(CalmResourceType.PATTERN, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.PATTERN)).thenReturn(List.of(entry)); + + List result = store.getPatternsForNamespace("finos", PageRequest.UNPAGED); + + assertThat(result, hasSize(1)); + assertThat(result.get(0).getName(), equalTo("Event Driven")); + } + + @Test + void throw_namespace_not_found_when_namespace_missing() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getPatternsForNamespace("nonexistent", PageRequest.UNPAGED)); + } + + @Test + void throw_unsupported_on_create_pattern() { + assertThrows(UnsupportedOperationException.class, + () -> store.createPatternForNamespace(new CreatePatternRequest(), "finos")); + } + + @Test + void throw_unsupported_on_create_pattern_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.createPatternForVersion(new Pattern.PatternBuilder().build())); + } + + @Test + void throw_unsupported_on_update_pattern_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.updatePatternForVersion(new Pattern.PatternBuilder().build())); + } + + @Test + void throw_namespace_not_found_on_get_pattern_versions() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + assertThrows(NamespaceNotFoundException.class, + () -> store.getPatternVersions(new Pattern.PatternBuilder().setNamespace("nonexistent").build())); + } + + @Test + void throw_namespace_not_found_on_get_pattern_for_version() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + assertThrows(NamespaceNotFoundException.class, + () -> store.getPatternForVersion(new Pattern.PatternBuilder().setNamespace("nonexistent").build())); + } + + @Test + void return_versions_list_for_existing_pattern() throws Exception { + RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), + CalmResourceType.PATTERN, "Event Driven", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:event-driven", entry), + Map.of(CalmResourceType.PATTERN, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.PATTERN)).thenReturn(List.of(entry)); + + int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); + Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(hashId).build(); + List versions = store.getPatternVersions(pattern); + + assertThat(versions, hasSize(1)); + assertThat(versions.get(0), equalTo("latest")); + } + + @Test + void return_sha_versions_when_version_service_available() throws Exception { + RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), + CalmResourceType.PATTERN, "Event Driven", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:event-driven", entry), + Map.of(CalmResourceType.PATTERN, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.PATTERN)).thenReturn(List.of(entry)); + + GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); + GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); + when(mockVersionService.getFileVersions("finos/architecture-as-code", "patterns/event-driven.json")) + .thenReturn(List.of("abc1234", "def5678")); + + int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); + Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(hashId).build(); + List versions = store.getPatternVersions(pattern); + + assertThat(versions, hasSize(2)); + assertThat(versions.get(0), equalTo("abc1234")); + } + + @Test + void return_pattern_content_for_version(@TempDir Path tempDir) throws Exception { + Path patternDir = tempDir.resolve("finos/patterns"); + Files.createDirectories(patternDir); + Files.writeString(patternDir.resolve("event-driven.json"), "{\"nodes\":[],\"relationships\":[]}"); + + RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), + CalmResourceType.PATTERN, "Event Driven", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:event-driven", entry), + Map.of(CalmResourceType.PATTERN, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.PATTERN)).thenReturn(List.of(entry)); + + store.cloneDirectory = tempDir.toString(); + int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); + Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); + + String content = store.getPatternForVersion(pattern); + assertThat(content, equalTo("{\"nodes\":[],\"relationships\":[]}")); + } + + @Test + void return_content_from_github_api_for_sha_version() throws Exception { + RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), + CalmResourceType.PATTERN, "Event Driven", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:event-driven", entry), + Map.of(CalmResourceType.PATTERN, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.PATTERN)).thenReturn(List.of(entry)); + + GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); + GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(mockVersionService.getFileAtVersion("finos/repo", "patterns/event-driven.json", "abc1234")) + .thenReturn("{\"nodes\":[{\"name\":\"old\"}]}"); + + int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); + Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(hashId).setVersion("abc1234").build(); + String content = store.getPatternForVersion(pattern); + + assertThat(content, equalTo("{\"nodes\":[{\"name\":\"old\"}]}")); + } + + @Test + void throw_pattern_not_found_when_id_does_not_match() { + RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), + CalmResourceType.PATTERN, "Event Driven", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:event-driven", entry), + Map.of(CalmResourceType.PATTERN, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.PATTERN)).thenReturn(List.of(entry)); + + Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(99999).build(); + assertThrows(PatternNotFoundException.class, () -> store.getPatternVersions(pattern)); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java new file mode 100644 index 0000000000..8f8a644a18 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java @@ -0,0 +1,209 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.ResourceMapping; +import org.finos.calm.domain.ResourceType; +import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; +import org.finos.calm.domain.exception.MappingNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubResourceMappingStoreShould { + + private static final String NAMESPACE = "finos"; + private static final String UNIQUE_ID = "my-architecture"; + private static final int NUMERIC_ID = UNIQUE_ID.hashCode() & 0x7FFFFFFF; + + @Mock + private InMemoryRegistryService registryService; + + private GitHubResourceMappingStore store; + + @BeforeEach + void setup() { + store = new GitHubResourceMappingStore(registryService); + } + + @Test + void return_mapping_when_entry_exists_in_registry() throws Exception { + RegistryEntry entry = createEntry(UNIQUE_ID, CalmResourceType.ARCHITECTURE); + setupNamespaceWithEntry(entry); + when(registryService.findByUniqueId(NAMESPACE, UNIQUE_ID)).thenReturn(Optional.of(entry)); + + ResourceMapping mapping = store.getMapping(NAMESPACE, ResourceType.ARCHITECTURE, UNIQUE_ID); + + assertThat(mapping.getNamespace(), equalTo(NAMESPACE)); + assertThat(mapping.getCustomId(), equalTo(UNIQUE_ID)); + assertThat(mapping.getResourceType(), equalTo(ResourceType.ARCHITECTURE)); + assertThat(mapping.getNumericId(), equalTo(NUMERIC_ID)); + } + + @Test + void throw_mapping_not_found_when_entry_missing() { + setupNamespace(); + when(registryService.findByUniqueId(NAMESPACE, "nonexistent")).thenReturn(Optional.empty()); + + assertThrows(MappingNotFoundException.class, + () -> store.getMapping(NAMESPACE, ResourceType.ARCHITECTURE, "nonexistent")); + } + + @Test + void throw_mapping_not_found_when_type_mismatch() { + RegistryEntry entry = createEntry(UNIQUE_ID, CalmResourceType.PATTERN); + setupNamespaceWithEntry(entry); + when(registryService.findByUniqueId(NAMESPACE, UNIQUE_ID)).thenReturn(Optional.of(entry)); + + assertThrows(MappingNotFoundException.class, + () -> store.getMapping(NAMESPACE, ResourceType.ARCHITECTURE, UNIQUE_ID)); + } + + @Test + void throw_namespace_not_found_when_namespace_unknown() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getMapping("nonexistent", ResourceType.ARCHITECTURE, UNIQUE_ID)); + } + + @Test + void list_mappings_for_namespace_and_type() throws Exception { + RegistryEntry entry1 = createEntry("arch-one", CalmResourceType.ARCHITECTURE); + RegistryEntry entry2 = createEntry("arch-two", CalmResourceType.ARCHITECTURE); + setupNamespace(); + when(registryService.listByType(NAMESPACE, CalmResourceType.ARCHITECTURE)) + .thenReturn(List.of(entry1, entry2)); + + List mappings = store.listMappings(NAMESPACE, ResourceType.ARCHITECTURE); + + assertThat(mappings, hasSize(2)); + assertThat(mappings.get(0).getCustomId(), equalTo("arch-one")); + assertThat(mappings.get(1).getCustomId(), equalTo("arch-two")); + } + + @Test + void return_empty_list_when_no_entries_of_type() throws Exception { + setupNamespace(); + when(registryService.listByType(NAMESPACE, CalmResourceType.FLOW)).thenReturn(List.of()); + + List mappings = store.listMappings(NAMESPACE, ResourceType.FLOW); + + assertThat(mappings, hasSize(0)); + } + + @Test + void get_mapping_by_numeric_id() throws Exception { + RegistryEntry entry = createEntry(UNIQUE_ID, CalmResourceType.ARCHITECTURE); + setupNamespace(); + when(registryService.listByType(NAMESPACE, CalmResourceType.ARCHITECTURE)) + .thenReturn(List.of(entry)); + + ResourceMapping mapping = store.getMappingByNumericId(NAMESPACE, ResourceType.ARCHITECTURE, NUMERIC_ID); + + assertThat(mapping.getCustomId(), equalTo(UNIQUE_ID)); + assertThat(mapping.getNumericId(), equalTo(NUMERIC_ID)); + } + + @Test + void throw_mapping_not_found_for_unknown_numeric_id() { + setupNamespace(); + when(registryService.listByType(NAMESPACE, CalmResourceType.ARCHITECTURE)).thenReturn(List.of()); + + assertThrows(MappingNotFoundException.class, + () -> store.getMappingByNumericId(NAMESPACE, ResourceType.ARCHITECTURE, 99999)); + } + + @Test + void list_mappings_by_numeric_ids() throws Exception { + RegistryEntry entry1 = createEntry("arch-one", CalmResourceType.ARCHITECTURE); + RegistryEntry entry2 = createEntry("arch-two", CalmResourceType.ARCHITECTURE); + setupNamespace(); + when(registryService.listByType(NAMESPACE, CalmResourceType.ARCHITECTURE)) + .thenReturn(List.of(entry1, entry2)); + + int id1 = "arch-one".hashCode() & 0x7FFFFFFF; + List mappings = store.listMappingsByNumericIds( + NAMESPACE, ResourceType.ARCHITECTURE, List.of(id1)); + + assertThat(mappings, hasSize(1)); + assertThat(mappings.get(0).getCustomId(), equalTo("arch-one")); + } + + @Test + void throw_on_create_mapping() { + assertThrows(GitHubWriteNotSupportedException.class, + () -> store.createMapping(NAMESPACE, UNIQUE_ID, ResourceType.ARCHITECTURE, 1)); + } + + @Test + void throw_on_update_mapping_numeric_id() { + assertThrows(GitHubWriteNotSupportedException.class, + () -> store.updateMappingNumericId(NAMESPACE, ResourceType.ARCHITECTURE, UNIQUE_ID, 1)); + } + + @Test + void throw_on_delete_mapping() { + assertThrows(GitHubWriteNotSupportedException.class, + () -> store.deleteMapping(NAMESPACE, ResourceType.ARCHITECTURE, UNIQUE_ID)); + } + + @Test + void map_all_resource_types_correctly() { + assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.PATTERN), equalTo(CalmResourceType.PATTERN)); + assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.ARCHITECTURE), equalTo(CalmResourceType.ARCHITECTURE)); + assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.FLOW), equalTo(CalmResourceType.FLOW)); + assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.STANDARD), equalTo(CalmResourceType.STANDARD)); + assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.INTERFACE), equalTo(CalmResourceType.INTERFACE)); + assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.BUILDING_BLOCK), equalTo(CalmResourceType.BUILDING_BLOCK)); + } + + private RegistryEntry createEntry(String uniqueId, CalmResourceType type) { + String folder = switch (type) { + case ARCHITECTURE -> "architectures"; + case PATTERN -> "patterns"; + case FLOW -> "flows"; + case STANDARD -> "standards"; + case INTERFACE -> "interfaces"; + default -> "other"; + }; + return new RegistryEntry(uniqueId, Path.of(folder + "/" + uniqueId + ".json"), + type, uniqueId.replace("-", " "), Instant.now()); + } + + private void setupNamespace() { + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(NAMESPACE, List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + } + + private void setupNamespaceWithEntry(RegistryEntry entry) { + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of(NAMESPACE, List.of(entry)), + Map.of(NAMESPACE + ":" + entry.uniqueId(), entry), + Map.of(entry.type(), List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java new file mode 100644 index 0000000000..0c8cd3bdde --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java @@ -0,0 +1,90 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.search.GroupedSearchResults; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Optional; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.Mockito.when; + +@MockitoSettings(strictness = Strictness.LENIENT) +@ExtendWith(MockitoExtension.class) +class TestGitHubSearchStoreShould { + + @Mock + private InMemoryRegistryService registryService; + + private GitHubSearchStore store; + + @BeforeEach + void setup() { + store = new GitHubSearchStore(); + store.registryService = registryService; + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + } + + @Test + void return_empty_grouped_search_results() { + GroupedSearchResults result = store.search("test query", Optional.empty()); + + assertThat(result, is(notNullValue())); + assertThat(result.getArchitectures(), is(empty())); + assertThat(result.getPatterns(), is(empty())); + assertThat(result.getFlows(), is(empty())); + assertThat(result.getStandards(), is(empty())); + assertThat(result.getInterfaces(), is(empty())); + assertThat(result.getControls(), is(empty())); + assertThat(result.getAdrs(), is(empty())); + } + + @Test + void return_empty_results_with_readable_namespaces() { + GroupedSearchResults result = store.search("test", Optional.of(Set.of("finos"))); + + assertThat(result, is(notNullValue())); + assertThat(result.getArchitectures(), is(empty())); + } + + @Test + void return_empty_for_blank_query() { + GroupedSearchResults result = store.search("", Optional.empty()); + assertThat(result.getArchitectures(), is(empty())); + } + + @Test + void return_empty_for_null_query() { + GroupedSearchResults result = store.search(null, Optional.empty()); + assertThat(result.getArchitectures(), is(empty())); + } + + @Test + void find_entries_matching_query_by_name() { + org.finos.calm.store.github.util.RegistryEntry entry = new org.finos.calm.store.github.util.RegistryEntry( + "payment-svc", java.nio.file.Path.of("architectures/payment.json"), + org.finos.calm.store.github.util.CalmResourceType.ARCHITECTURE, "Payment Service", java.time.Instant.now()); + org.finos.calm.store.github.util.RegistrySnapshot snapshot = new org.finos.calm.store.github.util.RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:payment-svc", entry), + java.util.Map.of(org.finos.calm.store.github.util.CalmResourceType.ARCHITECTURE, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + GroupedSearchResults result = store.search("payment", Optional.empty()); + + assertThat(result.getArchitectures(), is(org.hamcrest.Matchers.not(empty()))); + assertThat(result.getArchitectures().get(0).getName(), is("Payment Service")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java new file mode 100644 index 0000000000..f2466b1ce0 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java @@ -0,0 +1,245 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.namespaces.NamespaceResourceSummary; +import org.finos.calm.domain.standards.CreateStandardRequest; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubStandardStoreShould { + + @Mock + private InMemoryRegistryService registryService; + + private GitHubStandardStore store; + + @BeforeEach + void setup() { + store = new GitHubStandardStore(registryService); + } + + @Test + void return_standards_for_namespace() throws NamespaceNotFoundException { + RegistryEntry entry = new RegistryEntry("naming-convention", Path.of("standards/naming-convention.json"), + CalmResourceType.STANDARD, "Naming Convention", Instant.now()); + + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:naming-convention", entry), + Map.of(CalmResourceType.STANDARD, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(List.of(entry)); + + List result = store.getStandardsForNamespace("finos"); + + assertThat(result, hasSize(1)); + assertThat(result.get(0).getName(), equalTo("Naming Convention")); + } + + @Test + void throw_namespace_not_found_when_namespace_missing() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getStandardsForNamespace("nonexistent")); + } + + @Test + void throw_unsupported_on_create_standard() { + assertThrows(UnsupportedOperationException.class, + () -> store.createStandardForNamespace(new CreateStandardRequest(), "finos")); + } + + @Test + void throw_standard_not_found_when_id_does_not_match() throws NamespaceNotFoundException { + RegistryEntry entry = new RegistryEntry("std-x", java.nio.file.Path.of("standards/x.json"), + CalmResourceType.STANDARD, "X", java.time.Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:std-x", entry), + java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + + assertThrows(org.finos.calm.domain.exception.StandardNotFoundException.class, + () -> store.getStandardVersions("finos", 99999)); + } + + @Test + void throw_namespace_not_found_on_get_standard_versions() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + assertThrows(NamespaceNotFoundException.class, + () -> store.getStandardVersions("nonexistent", 1)); + } + + @Test + void throw_namespace_not_found_on_get_standard_for_version() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + assertThrows(NamespaceNotFoundException.class, + () -> store.getStandardForVersion("nonexistent", 1, "latest")); + } + + @Test + void return_versions_for_existing_standard() throws Exception { + RegistryEntry entry = new RegistryEntry("std-1", java.nio.file.Path.of("standards/std.json"), + CalmResourceType.STANDARD, "Std", java.time.Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:std-1", entry), + java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + + int hashId = ("std-1".hashCode() & 0x7FFFFFFF); + java.util.List versions = store.getStandardVersions("finos", hashId); + assertThat(versions, hasSize(1)); + assertThat(versions.get(0), equalTo("latest")); + } + + @Test + void return_content_for_standard(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tempDir) throws Exception { + java.nio.file.Path stdDir = tempDir.resolve("finos/standards"); + java.nio.file.Files.createDirectories(stdDir); + java.nio.file.Files.writeString(stdDir.resolve("test.json"), "{\"name\":\"Test Standard\"}"); + + RegistryEntry entry = new RegistryEntry("test-std", java.nio.file.Path.of("standards/test.json"), + CalmResourceType.STANDARD, "Test", java.time.Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:test-std", entry), + java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + + store.cloneDirectory = tempDir.toString(); + int hashId = ("test-std".hashCode() & 0x7FFFFFFF); + String content = store.getStandardForVersion("finos", hashId, "latest"); + assertThat(content, equalTo("{\"name\":\"Test Standard\"}")); + } + + @Test + void prefer_md_sibling_over_json(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tempDir) throws Exception { + java.nio.file.Path stdDir = tempDir.resolve("finos/standards"); + java.nio.file.Files.createDirectories(stdDir); + java.nio.file.Files.writeString(stdDir.resolve("policy.guideline.json"), "{\"nodes\":[]}"); + java.nio.file.Files.writeString(stdDir.resolve("policy.md"), "# Policy\n\nContent here."); + + RegistryEntry entry = new RegistryEntry("policy", java.nio.file.Path.of("standards/policy.guideline.json"), + CalmResourceType.STANDARD, "Policy", java.time.Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:policy", entry), + java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + + store.cloneDirectory = tempDir.toString(); + int hashId = ("policy".hashCode() & 0x7FFFFFFF); + String content = store.getStandardForVersion("finos", hashId, "latest"); + assertThat(content, org.hamcrest.Matchers.startsWith("# Policy")); + } + + @Test + void throw_unsupported_on_create_standard_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.createStandardForVersion(new CreateStandardRequest(), "finos", 1, "1.0.0")); + } + + @Test + void return_sha_versions_when_version_service_available() throws Exception { + RegistryEntry entry = new RegistryEntry("std-1", java.nio.file.Path.of("standards/std.json"), + CalmResourceType.STANDARD, "Std", java.time.Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:std-1", entry), + java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + + org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); + org.finos.calm.store.github.util.GitHubVersionService mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(mockVersionService.getFileVersions("finos/repo", "standards/std.json")) + .thenReturn(java.util.List.of("abc1234", "def5678")); + + int hashId = ("std-1".hashCode() & 0x7FFFFFFF); + java.util.List versions = store.getStandardVersions("finos", hashId); + + assertThat(versions, hasSize(2)); + assertThat(versions.get(0), equalTo("abc1234")); + } + + @Test + void return_content_from_github_api_for_sha_version() throws Exception { + RegistryEntry entry = new RegistryEntry("test-std", java.nio.file.Path.of("standards/test.json"), + CalmResourceType.STANDARD, "Test", java.time.Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:test-std", entry), + java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + + org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); + org.finos.calm.store.github.util.GitHubVersionService mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(mockVersionService.getFileAtVersion("finos/repo", "standards/test.json", "abc1234")) + .thenReturn("{\"name\":\"Old Standard\"}"); + + int hashId = ("test-std".hashCode() & 0x7FFFFFFF); + String content = store.getStandardForVersion("finos", hashId, "abc1234"); + + assertThat(content, equalTo("{\"name\":\"Old Standard\"}")); + } + + @Test + void throw_standard_version_not_found_when_file_missing(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tempDir) throws Exception { + RegistryEntry entry = new RegistryEntry("test-std", java.nio.file.Path.of("standards/nonexistent.json"), + CalmResourceType.STANDARD, "Test", java.time.Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + java.util.Map.of("finos", java.util.List.of(entry)), + java.util.Map.of("finos:test-std", entry), + java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + + store.cloneDirectory = tempDir.toString(); + int hashId = ("test-std".hashCode() & 0x7FFFFFFF); + + assertThrows(org.finos.calm.domain.exception.StandardVersionNotFoundException.class, + () -> store.getStandardForVersion("finos", hashId, "1.0.0")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java new file mode 100644 index 0000000000..82a49858d6 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java @@ -0,0 +1,240 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.TimelineNotFoundException; +import org.finos.calm.domain.exception.TimelineVersionNotFoundException; +import org.finos.calm.domain.timeline.CreateTimelineRequest; +import org.finos.calm.domain.timeline.NamespaceTimelineSummary; +import org.finos.calm.domain.timeline.Timeline; +import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubTimelineStoreShould { + + @Mock + private InMemoryRegistryService registryService; + + private GitHubTimelineStore store; + + @BeforeEach + void setup() { + store = new GitHubTimelineStore(registryService); + } + + @Test + void return_empty_timelines_for_namespace() throws NamespaceNotFoundException { + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of()); + + List result = store.getTimelinesForNamespace("finos"); + + assertThat(result, is(empty())); + } + + @Test + void return_timelines_for_namespace() throws NamespaceNotFoundException { + RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), + CalmResourceType.TIMELINE, "Release Timeline", Instant.now()); + + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:release-timeline", entry), + Map.of(CalmResourceType.TIMELINE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of(entry)); + + List result = store.getTimelinesForNamespace("finos"); + + assertThat(result, hasSize(1)); + assertThat(result.get(0).getName(), equalTo("Release Timeline")); + } + + @Test + void throw_namespace_not_found_when_namespace_missing() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getTimelinesForNamespace("nonexistent")); + } + + @Test + void throw_unsupported_on_create_timeline() { + assertThrows(UnsupportedOperationException.class, + () -> store.createTimelineForNamespace(new CreateTimelineRequest(), "finos")); + } + + @Test + void throw_namespace_not_found_on_get_timeline_versions() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + assertThrows(NamespaceNotFoundException.class, + () -> store.getTimelineVersions(new Timeline.TimelineBuilder().setNamespace("nonexistent").build())); + } + + @Test + void throw_namespace_not_found_on_get_timeline_for_version() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + assertThrows(NamespaceNotFoundException.class, + () -> store.getTimelineForVersion(new Timeline.TimelineBuilder().setNamespace("nonexistent").build())); + } + + @Test + void return_versions_list_for_existing_timeline() throws Exception { + RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), + CalmResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:release-timeline", entry), + Map.of(CalmResourceType.TIMELINE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of(entry)); + + int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); + Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(hashId).build(); + List versions = store.getTimelineVersions(timeline); + + assertThat(versions, hasSize(1)); + assertThat(versions.get(0), equalTo("latest")); + } + + @Test + void return_sha_versions_when_version_service_available() throws Exception { + RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), + CalmResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:release-timeline", entry), + Map.of(CalmResourceType.TIMELINE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of(entry)); + + GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); + GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); + when(mockVersionService.getFileVersions("finos/architecture-as-code", "timelines/release-timeline.json")) + .thenReturn(List.of("abc1234", "def5678")); + + int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); + Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(hashId).build(); + List versions = store.getTimelineVersions(timeline); + + assertThat(versions, hasSize(2)); + assertThat(versions.get(0), equalTo("abc1234")); + } + + @Test + void return_timeline_content_for_version(@TempDir Path tempDir) throws Exception { + Path timelineDir = tempDir.resolve("finos/timelines"); + Files.createDirectories(timelineDir); + Files.writeString(timelineDir.resolve("release-timeline.json"), "{\"milestones\":[]}"); + + RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), + CalmResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:release-timeline", entry), + Map.of(CalmResourceType.TIMELINE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of(entry)); + + store.cloneDirectory = tempDir.toString(); + int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); + Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); + + String content = store.getTimelineForVersion(timeline); + assertThat(content, equalTo("{\"milestones\":[]}")); + } + + @Test + void return_content_from_github_api_for_sha_version() throws Exception { + RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), + CalmResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:release-timeline", entry), + Map.of(CalmResourceType.TIMELINE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of(entry)); + + GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); + GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + store.cloneManager = mockCloneManager; + store.versionService = mockVersionService; + + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(mockVersionService.getFileAtVersion("finos/repo", "timelines/release-timeline.json", "abc1234")) + .thenReturn("{\"milestones\":[{\"name\":\"old\"}]}"); + + int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); + Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(hashId).setVersion("abc1234").build(); + String content = store.getTimelineForVersion(timeline); + + assertThat(content, equalTo("{\"milestones\":[{\"name\":\"old\"}]}")); + } + + @Test + void throw_timeline_not_found_when_id_does_not_match() { + RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), + CalmResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:release-timeline", entry), + Map.of(CalmResourceType.TIMELINE, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of(entry)); + + Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(99999).build(); + assertThrows(TimelineNotFoundException.class, () -> store.getTimelineVersions(timeline)); + } + + @Test + void throw_unsupported_on_create_timeline_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.createTimelineForVersion(new Timeline.TimelineBuilder().build())); + } + + @Test + void throw_unsupported_on_update_timeline_for_version() { + assertThrows(UnsupportedOperationException.class, + () -> store.updateTimelineForVersion(new Timeline.TimelineBuilder().build())); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java new file mode 100644 index 0000000000..0545e85946 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java @@ -0,0 +1,207 @@ +package org.finos.calm.store.github; + +import io.quarkus.security.identity.SecurityIdentity; +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.exception.UserAccessNotFoundException; +import org.finos.calm.security.OidcRoleResolver; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistrySnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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.when; + +@MockitoSettings(strictness = Strictness.LENIENT) +@ExtendWith(MockitoExtension.class) +class TestGitHubUserAccessStoreShould { + + private static final Set ACCESS_GROUPS = Set.of("SCMReadOnly", "SCMDeveloper"); + + @Mock + private InMemoryRegistryService registryService; + + @Mock + private OidcRoleResolver roleResolver; + + @Mock + private SecurityIdentity identity; + + @Mock + private GitHubCloneManager cloneManager; + + private GitHubUserAccessStore store; + + @BeforeEach + void setup() { + store = new GitHubUserAccessStore(); + store.registryService = registryService; + store.roleResolver = roleResolver; + store.identity = identity; + store.cloneManager = cloneManager; + + when(cloneManager.getAccessGroupsForNamespace("finos")).thenReturn(ACCESS_GROUPS); + when(cloneManager.getAccessGroupsForNamespace("team")).thenReturn(ACCESS_GROUPS); + } + + @Test + void return_empty_grants_when_no_matching_group() { + when(roleResolver.resolve(eq(identity), any())).thenReturn(OidcRoleResolver.AccessLevel.NONE); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(), "team", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + List result = store.getGrantsForUser("testuser"); + + assertThat(result, is(empty())); + } + + @Test + void return_read_grants_when_group_matches() { + when(roleResolver.resolve(eq(identity), eq(ACCESS_GROUPS))) + .thenReturn(OidcRoleResolver.AccessLevel.READ); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(), "team", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + List result = store.getGrantsForUser("testuser"); + + assertThat(result, hasSize(2)); + assertThat(result.get(0).getPermission(), equalTo(UserAccess.Permission.read)); + assertThat(result.get(1).getPermission(), equalTo(UserAccess.Permission.read)); + } + + @Test + void grant_per_namespace_access_independently() { + when(cloneManager.getAccessGroupsForNamespace("public")).thenReturn(Set.of("Everyone")); + when(roleResolver.resolve(eq(identity), eq(Set.of("Everyone")))) + .thenReturn(OidcRoleResolver.AccessLevel.READ); + when(roleResolver.resolve(eq(identity), eq(ACCESS_GROUPS))) + .thenReturn(OidcRoleResolver.AccessLevel.NONE); + + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(), "public", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + List result = store.getGrantsForUser("testuser"); + + assertThat(result, hasSize(1)); + assertThat(result.get(0).getNamespace(), equalTo("public")); + } + + @Test + void return_empty_grants_when_no_namespaces() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + List result = store.getGrantsForUser("testuser"); + + assertThat(result, is(empty())); + } + + @Test + void throw_user_access_not_found_when_no_access() { + when(roleResolver.resolve(eq(identity), any())).thenReturn(OidcRoleResolver.AccessLevel.NONE); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + assertThrows(UserAccessNotFoundException.class, + () -> store.getUserAccessForUsername("testuser")); + } + + @Test + void throw_user_access_not_found_when_no_namespaces() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(UserAccessNotFoundException.class, + () -> store.getUserAccessForUsername("testuser")); + } + + @Test + void throw_unsupported_on_get_user_access_for_namespace() { + assertThrows(UnsupportedOperationException.class, + () -> store.getUserAccessForNamespace("finos")); + } + + @Test + void throw_unsupported_on_get_user_access_for_namespace_and_id() { + assertThrows(UnsupportedOperationException.class, + () -> store.getUserAccessForNamespaceAndId("finos", 1)); + } + + @Test + void throw_unsupported_on_get_user_access_for_domain() { + assertThrows(UnsupportedOperationException.class, + () -> store.getUserAccessForDomain("security")); + } + + @Test + void throw_unsupported_on_get_user_access_for_domain_and_id() { + assertThrows(UnsupportedOperationException.class, + () -> store.getUserAccessForDomainAndId("security", 1)); + } + + @Test + void throw_unsupported_on_create_user_access_for_namespace() { + assertThrows(UnsupportedOperationException.class, + () -> store.createUserAccessForNamespace(new UserAccess())); + } + + @Test + void throw_unsupported_on_create_user_access_for_domain() { + assertThrows(UnsupportedOperationException.class, + () -> store.createUserAccessForDomain(new UserAccess())); + } + + @Test + void throw_unsupported_on_delete_user_access_for_namespace() { + assertThrows(UnsupportedOperationException.class, + () -> store.deleteUserAccessForNamespace("finos", 1)); + } + + @Test + void throw_unsupported_on_delete_user_access_for_domain() { + assertThrows(UnsupportedOperationException.class, + () -> store.deleteUserAccessForDomain("security", 1)); + } + + @Test + void no_op_on_delete_all_user_access_for_namespace() { + assertDoesNotThrow(() -> store.deleteAllUserAccessForNamespace("finos")); + } + + @Test + void no_op_on_delete_all_user_access_for_domain() { + assertDoesNotThrow(() -> store.deleteAllUserAccessForDomain("security")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java new file mode 100644 index 0000000000..1e1a107674 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java @@ -0,0 +1,268 @@ +package org.finos.calm.store.github.util; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +class TestCalmContentDetectorShould { + + private CalmContentDetector detector; + + @BeforeEach + void setup() { + detector = new CalmContentDetector(); + } + + @Test + void detect_architecture_from_nodes_array_in_architectures_directory() { + String json = "{\"nodes\": [], \"relationships\": []}"; + CalmResourceType type = detector.detect(json, Path.of("architectures/payment.json")); + assertThat(type, equalTo(CalmResourceType.ARCHITECTURE)); + } + + @Test + void detect_pattern_from_nodes_array_in_patterns_directory() { + String json = "{\"nodes\": [], \"relationships\": []}"; + CalmResourceType type = detector.detect(json, Path.of("patterns/microservices.json")); + assertThat(type, equalTo(CalmResourceType.PATTERN)); + } + + @Test + void return_unknown_when_nodes_present_but_unknown_directory() { + String json = "{\"nodes\": [{\"unique-id\": \"svc\"}]}"; + CalmResourceType type = detector.detect(json, Path.of("misc/something.json")); + assertThat(type, equalTo(CalmResourceType.UNKNOWN)); + } + + @Test + void detect_timeline_from_moments_array() { + String json = "{\"moments\": [{\"date\": \"2026-01-01\"}]}"; + CalmResourceType type = detector.detect(json, Path.of("timelines/roadmap.json")); + assertThat(type, equalTo(CalmResourceType.TIMELINE)); + } + + @Test + void detect_timeline_from_schema_url() { + String json = "{\"$schema\": \"https://calm.finos.org/draft/2026-03/meta/calm-timeline.json\"}"; + CalmResourceType type = detector.detect(json, Path.of("any/thing.json")); + assertThat(type, equalTo(CalmResourceType.TIMELINE)); + } + + @Test + void detect_flow_from_schema_url() { + String json = "{\"$schema\": \"https://calm.finos.org/draft/2026-03/meta/flow.json\"}"; + CalmResourceType type = detector.detect(json, Path.of("flows/checkout.json")); + assertThat(type, equalTo(CalmResourceType.FLOW)); + } + + @Test + void detect_interface_from_schema_url() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/interface.json\"}"; + CalmResourceType type = detector.detect(json, Path.of("interfaces/api.json")); + assertThat(type, equalTo(CalmResourceType.INTERFACE)); + } + + @Test + void detect_control_from_schema_url() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/control.json\"}"; + CalmResourceType type = detector.detect(json, Path.of("controls/security/tls.json")); + assertThat(type, equalTo(CalmResourceType.CONTROL)); + } + + @Test + void detect_decorator_from_schema_url() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/decorators.json\"}"; + CalmResourceType type = detector.detect(json, Path.of("decorators/governance.json")); + assertThat(type, equalTo(CalmResourceType.DECORATOR)); + } + + @Test + void detect_standard_from_directory_only() { + String json = "{\"title\": \"API Design Standard\"}"; + CalmResourceType type = detector.detect(json, Path.of("standards/api-design.json")); + assertThat(type, equalTo(CalmResourceType.STANDARD)); + } + + @Test + void detect_guideline_from_directory_only() { + String json = "{\"title\": \"Microservices Guideline\"}"; + CalmResourceType type = detector.detect(json, Path.of("guidelines/microservices.json")); + assertThat(type, equalTo(CalmResourceType.GUIDELINE)); + } + + @Test + void detect_adr_from_directory_only() { + String json = "{\"status\": \"accepted\", \"context\": \"we need X\"}"; + CalmResourceType type = detector.detect(json, Path.of("adrs/0001-use-kafka.json")); + assertThat(type, equalTo(CalmResourceType.ADR)); + } + + @Test + void detect_control_from_directory_convention() { + String json = "{\"requirement\": \"must use TLS\"}"; + CalmResourceType type = detector.detect(json, Path.of("controls/tls-policy.json")); + assertThat(type, equalTo(CalmResourceType.CONTROL)); + } + + @Test + void return_unknown_for_null_content() { + CalmResourceType type = detector.detect(null, Path.of("test.json")); + assertThat(type, equalTo(CalmResourceType.UNKNOWN)); + } + + @Test + void return_unknown_for_blank_content() { + CalmResourceType type = detector.detect(" ", Path.of("test.json")); + assertThat(type, equalTo(CalmResourceType.UNKNOWN)); + } + + @Test + void return_unknown_for_invalid_json() { + CalmResourceType type = detector.detect("not json at all", Path.of("test.json")); + assertThat(type, equalTo(CalmResourceType.UNKNOWN)); + } + + @Test + void return_unknown_for_unrecognized_content_in_unknown_directory() { + String json = "{\"foo\": \"bar\"}"; + CalmResourceType type = detector.detect(json, Path.of("random/stuff.json")); + assertThat(type, equalTo(CalmResourceType.UNKNOWN)); + } + + @Test + void handle_null_file_path_gracefully() { + String json = "{\"nodes\": []}"; + CalmResourceType type = detector.detect(json, null); + assertThat(type, equalTo(CalmResourceType.UNKNOWN)); + } + + @Test + void detect_pattern_from_nodes_in_patterns_directory_with_nested_path() { + String json = "{\"nodes\": [{\"unique-id\": \"svc\"}], \"relationships\": []}"; + CalmResourceType type = detector.detect(json, Path.of("patterns/sub/event-driven.json")); + assertThat(type, equalTo(CalmResourceType.PATTERN)); + } + + @Test + void detect_flow_from_directory_when_no_schema() { + String json = "{\"steps\": [{\"from\": \"a\", \"to\": \"b\"}]}"; + CalmResourceType type = detector.detect(json, Path.of("flows/checkout-flow.json")); + assertThat(type, equalTo(CalmResourceType.FLOW)); + } + + @Test + void detect_interface_from_directory_only() { + String json = "{\"endpoints\": []}"; + CalmResourceType type = detector.detect(json, Path.of("interfaces/payment-api.json")); + assertThat(type, equalTo(CalmResourceType.INTERFACE)); + } + + @Test + void detect_decorator_from_directory_only() { + String json = "{\"type\": \"governance\"}"; + CalmResourceType type = detector.detect(json, Path.of("decorators/aigf.json")); + assertThat(type, equalTo(CalmResourceType.DECORATOR)); + } + + @Test + void detect_timeline_from_directory_only_without_moments() { + String json = "{\"entries\": []}"; + CalmResourceType type = detector.detect(json, Path.of("timelines/roadmap.json")); + assertThat(type, equalTo(CalmResourceType.TIMELINE)); + } + + @Test + void prefer_content_signal_over_directory_for_timeline() { + String json = "{\"moments\": [{\"date\": \"2026-01-01\"}]}"; + CalmResourceType type = detector.detect(json, Path.of("architectures/misplaced.json")); + assertThat(type, equalTo(CalmResourceType.TIMELINE)); + } + + @Test + void detect_from_relationships_array_alone() { + String json = "{\"relationships\": [{\"unique-id\": \"rel-1\"}]}"; + CalmResourceType type = detector.detect(json, Path.of("architectures/conn.json")); + assertThat(type, equalTo(CalmResourceType.ARCHITECTURE)); + } + + @Test + void detect_flow_from_schema_without_directory_hint() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/flow.json\"}"; + CalmResourceType type = detector.detect(json, Path.of("misc/some-flow.json")); + assertThat(type, equalTo(CalmResourceType.FLOW)); + } + + @Test + void detect_interface_from_schema_without_directory_hint() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/interface.json\"}"; + CalmResourceType type = detector.detect(json, Path.of("misc/api.json")); + assertThat(type, equalTo(CalmResourceType.INTERFACE)); + } + + @Test + void detect_control_from_schema_without_directory_hint() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/control.json\"}"; + CalmResourceType type = detector.detect(json, Path.of("misc/tls.json")); + assertThat(type, equalTo(CalmResourceType.CONTROL)); + } + + @Test + void detect_decorator_from_schema_without_directory_hint() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/decorators.json\"}"; + CalmResourceType type = detector.detect(json, Path.of("misc/gov.json")); + assertThat(type, equalTo(CalmResourceType.DECORATOR)); + } + + @Test + void return_unknown_when_schema_does_not_match_any_keyword() { + String json = "{\"$schema\": \"https://example.com/unknown-schema.json\"}"; + CalmResourceType type = detector.detect(json, Path.of("misc/thing.json")); + assertThat(type, equalTo(CalmResourceType.UNKNOWN)); + } + + @Test + void detect_standard_from_nodes_in_standards_directory() { + String json = "{\"nodes\": [], \"relationships\": []}"; + CalmResourceType type = detector.detect(json, Path.of("standards/api-design.json")); + assertThat(type, equalTo(CalmResourceType.STANDARD)); + } + + @Test + void detect_guideline_from_nodes_in_guidelines_directory() { + String json = "{\"nodes\": []}"; + CalmResourceType type = detector.detect(json, Path.of("guidelines/best.json")); + assertThat(type, equalTo(CalmResourceType.GUIDELINE)); + } + + @Test + void detect_adr_from_nodes_in_adrs_directory() { + String json = "{\"nodes\": []}"; + CalmResourceType type = detector.detect(json, Path.of("adrs/0001.json")); + assertThat(type, equalTo(CalmResourceType.ADR)); + } + + @Test + void detect_building_block_from_nodes_in_building_blocks_directory() { + String json = "{\"nodes\": [{\"unique-id\": \"svc\"}], \"relationships\": []}"; + CalmResourceType type = detector.detect(json, Path.of("building-blocks/auth-block.json")); + assertThat(type, equalTo(CalmResourceType.BUILDING_BLOCK)); + } + + @Test + void detect_building_block_from_directory_only() { + String json = "{\"title\": \"Auth Building Block\"}"; + CalmResourceType type = detector.detect(json, Path.of("building-blocks/auth-block.json")); + assertThat(type, equalTo(CalmResourceType.BUILDING_BLOCK)); + } + + @Test + void detect_building_block_from_nested_path() { + String json = "{\"nodes\": []}"; + CalmResourceType type = detector.detect(json, Path.of("building-blocks/sub/auth-block.json")); + assertThat(type, equalTo(CalmResourceType.BUILDING_BLOCK)); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java new file mode 100644 index 0000000000..9427168ad5 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java @@ -0,0 +1,194 @@ +package org.finos.calm.store.github.util; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@MockitoSettings(strictness = Strictness.LENIENT) +@ExtendWith(MockitoExtension.class) +class TestGitHubCloneManagerShould { + + @Mock + private GitHubRepoSync repoSync; + + @Mock + private GitHubStoreConfig config; + + private GitHubCloneManager cloneManager; + + @BeforeEach + void setup() { + when(config.getCloneDirectory()).thenReturn(Path.of("/tmp/test-clones")); + when(config.getServiceToken()).thenReturn("test-token"); + cloneManager = new GitHubCloneManager(repoSync, config); + } + + @Test + void start_in_initializing_state() { + assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.INITIALIZING)); + } + + @Test + void transition_to_ready_when_no_namespaces_registered() { + cloneManager.cloneAll(); + assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.READY)); + } + + @Test + void transition_to_ready_when_all_clones_succeed() { + cloneManager.registerNamespace("finos", "finos/architecture-as-code", "main"); + when(repoSync.isValidRepo(any())).thenReturn(false); + when(repoSync.cloneRepo(eq("finos/architecture-as-code"), eq("main"), any(), eq("test-token"))) + .thenReturn(true); + + cloneManager.cloneAll(); + + assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.READY)); + } + + @Test + void transition_to_failed_when_all_clones_fail() { + cloneManager.registerNamespace("finos", "finos/architecture-as-code", "main"); + when(repoSync.isValidRepo(any())).thenReturn(false); + when(repoSync.cloneRepo(any(), any(), any(), any())).thenReturn(false); + + cloneManager.cloneAll(); + + assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.FAILED)); + } + + @Test + void transition_to_degraded_when_some_clones_fail() { + cloneManager.registerNamespace("ns1", "org/repo1", "main"); + cloneManager.registerNamespace("ns2", "org/repo2", "main"); + when(repoSync.isValidRepo(any())).thenReturn(false); + when(repoSync.cloneRepo(eq("org/repo1"), eq("main"), any(), any())).thenReturn(true); + when(repoSync.cloneRepo(eq("org/repo2"), eq("main"), any(), any())).thenReturn(false); + + cloneManager.cloneAll(); + + assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.DEGRADED)); + } + + @Test + void pull_instead_of_clone_when_repo_already_exists() { + cloneManager.registerNamespace("finos", "finos/architecture-as-code", "main"); + when(repoSync.isValidRepo(any())).thenReturn(true); + when(repoSync.pullRepo(any(), eq("test-token"))).thenReturn(true); + + cloneManager.cloneAll(); + + assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.READY)); + } + + @Test + void skip_pull_all_when_still_cloning() { + cloneManager.registerNamespace("finos", "finos/repo", "main"); + // State is INITIALIZING, pullAll should be a no-op + cloneManager.pullAll(); + assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.INITIALIZING)); + } + + @Test + void pull_all_repos_and_stay_ready() { + cloneManager.registerNamespace("finos", "finos/repo", "main"); + when(repoSync.isValidRepo(any())).thenReturn(false); + when(repoSync.cloneRepo(any(), any(), any(), any())).thenReturn(true); + cloneManager.cloneAll(); + + when(repoSync.isValidRepo(any())).thenReturn(true); + when(repoSync.pullRepo(any(), any())).thenReturn(true); + cloneManager.pullAll(); + + assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.READY)); + } + + @Test + void transition_to_failed_on_pull_all_when_all_fail() { + cloneManager.registerNamespace("ns1", "org/repo1", "main"); + when(repoSync.isValidRepo(any())).thenReturn(false); + when(repoSync.cloneRepo(any(), any(), any(), any())).thenReturn(true); + cloneManager.cloneAll(); + + when(repoSync.isValidRepo(any())).thenReturn(false); + cloneManager.pullAll(); + + assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.FAILED)); + } + + @Test + void return_namespace_clone_paths() { + cloneManager.registerNamespace("finos", "finos/repo", "main"); + cloneManager.registerNamespace("team", "org/team-repo", "main"); + + Map paths = cloneManager.getNamespaceClonePaths(); + + assertThat(paths, hasKey("finos")); + assertThat(paths, hasKey("team")); + assertThat(paths.get("finos"), equalTo(Path.of("/tmp/test-clones/finos"))); + } + + @Test + void report_has_namespaces_correctly() { + assertThat(cloneManager.hasNamespaces(), is(false)); + cloneManager.registerNamespace("finos", "finos/repo", "main"); + assertThat(cloneManager.hasNamespaces(), is(true)); + } + + @Test + void register_namespace_with_access_groups() { + cloneManager.registerNamespace("finos", "finos/repo", "main", Set.of("group-a", "group-b")); + assertThat(cloneManager.getAccessGroupsForNamespace("finos"), equalTo(Set.of("group-a", "group-b"))); + } + + @Test + void return_empty_access_groups_for_unknown_namespace() { + assertThat(cloneManager.getAccessGroupsForNamespace("unknown"), is(empty())); + } + + @Test + void return_repo_for_registered_namespace() { + cloneManager.registerNamespace("finos", "finos/repo", "main"); + assertThat(cloneManager.getRepoForNamespace("finos"), equalTo("finos/repo")); + } + + @Test + void return_null_for_unknown_namespace_repo() { + assertThat(cloneManager.getRepoForNamespace("unknown"), is(nullValue())); + } + + @Test + void transition_to_degraded_on_pull_all_when_some_fail() { + cloneManager.registerNamespace("ns1", "org/repo1", "main"); + cloneManager.registerNamespace("ns2", "org/repo2", "main"); + when(repoSync.isValidRepo(any())).thenReturn(false); + when(repoSync.cloneRepo(any(), any(), any(), any())).thenReturn(true); + cloneManager.cloneAll(); + + when(repoSync.isValidRepo(Path.of("/tmp/test-clones/ns1"))).thenReturn(true); + when(repoSync.isValidRepo(Path.of("/tmp/test-clones/ns2"))).thenReturn(true); + when(repoSync.pullRepo(Path.of("/tmp/test-clones/ns1"), "test-token")).thenReturn(true); + when(repoSync.pullRepo(Path.of("/tmp/test-clones/ns2"), "test-token")).thenReturn(false); + cloneManager.pullAll(); + + assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.DEGRADED)); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubForkPRServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubForkPRServiceShould.java new file mode 100644 index 0000000000..e4a1564d00 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubForkPRServiceShould.java @@ -0,0 +1,32 @@ +package org.finos.calm.store.github.util; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TestGitHubForkPRServiceShould { + + private GitHubForkPRService service; + + @BeforeEach + void setup() { + service = new GitHubForkPRService(); + } + + @Test + void throw_unsupported_until_oauth_flow_is_wired() { + UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, + () -> service.createPullRequest( + "gho_token", + "finos/architecture-as-code", + "main", + "patterns/new-pattern.json", + "{\"nodes\": []}", + "feat: add new pattern" + )); + assertThat(ex.getMessage(), containsString("GitHub account linking")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubRepoSyncShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubRepoSyncShould.java new file mode 100644 index 0000000000..aff96da2a4 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubRepoSyncShould.java @@ -0,0 +1,125 @@ +package org.finos.calm.store.github.util; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class TestGitHubRepoSyncShould { + + private GitHubRepoSync repoSync; + + @TempDir + Path tempDir; + + @BeforeEach + void setup() { + repoSync = new GitHubRepoSync(); + } + + @Test + void return_false_for_invalid_repo_when_directory_does_not_exist() { + assertThat(repoSync.isValidRepo(tempDir.resolve("nonexistent")), is(false)); + } + + @Test + void return_false_for_invalid_repo_when_no_git_directory() { + assertThat(repoSync.isValidRepo(tempDir), is(false)); + } + + @Test + void return_true_for_valid_repo_when_git_directory_exists() throws IOException { + Files.createDirectories(tempDir.resolve(".git")); + assertThat(repoSync.isValidRepo(tempDir), is(true)); + } + + @Test + void return_false_when_clone_fails_with_invalid_url() { + boolean result = repoSync.cloneRepo( + "nonexistent-org/nonexistent-repo", + "main", + tempDir.resolve("clone-target"), + "invalid-token" + ); + assertThat(result, is(false)); + } + + @Test + void return_false_when_pull_on_non_repo_directory() { + boolean result = repoSync.pullRepo(tempDir, "token"); + assertThat(result, is(false)); + } + + @Test + void clone_local_bare_repo_successfully() throws GitAPIException, IOException { + Path bareRepo = tempDir.resolve("bare.git"); + Git.init().setDirectory(bareRepo.toFile()).setBare(true).call().close(); + + Path cloneTarget = tempDir.resolve("cloned"); + boolean result = repoSync.cloneRepo( + bareRepo.toUri().toString().replace("file:", "").replace(".git", ""), + "master", + cloneTarget, + null + ); + // Clone from local bare repo using file:// URI directly + // The cloneRepo method prepends https://github.com/ so this will fail + // but we can test the pull path instead via a real local clone + assertThat(result, is(false)); + } + + @Test + void pull_local_repo_successfully() throws GitAPIException, IOException { + Path originDir = tempDir.resolve("origin"); + Files.createDirectories(originDir); + try (Git origin = Git.init().setDirectory(originDir.toFile()).setInitialBranch("main").call()) { + Files.writeString(originDir.resolve("test.txt"), "hello"); + origin.add().addFilepattern("test.txt").call(); + origin.commit().setMessage("init").call(); + } + + Path cloneDir = tempDir.resolve("clone"); + try (Git ignored = Git.cloneRepository() + .setURI(originDir.toUri().toString()) + .setDirectory(cloneDir.toFile()) + .setBranch("main") + .call()) { + // clone done + } + + boolean result = repoSync.pullRepo(cloneDir, null); + assertThat(result, is(true)); + } + + @Test + void pull_returns_true_when_already_up_to_date() throws GitAPIException, IOException { + Path originDir = tempDir.resolve("origin2"); + Files.createDirectories(originDir); + try (Git origin = Git.init().setDirectory(originDir.toFile()).setInitialBranch("main").call()) { + Files.writeString(originDir.resolve("file.txt"), "content"); + origin.add().addFilepattern("file.txt").call(); + origin.commit().setMessage("initial").call(); + } + + Path cloneDir = tempDir.resolve("clone2"); + try (Git ignored = Git.cloneRepository() + .setURI(originDir.toUri().toString()) + .setDirectory(cloneDir.toFile()) + .setBranch("main") + .call()) { + // clone done + } + + boolean result = repoSync.pullRepo(cloneDir, ""); + assertThat(result, is(true)); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java new file mode 100644 index 0000000000..343393004f --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java @@ -0,0 +1,144 @@ +package org.finos.calm.store.github.util; + +import io.quarkus.runtime.StartupEvent; +import org.eclipse.microprofile.context.ManagedExecutor; +import org.finos.calm.observability.GitHubMetrics; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@MockitoSettings(strictness = Strictness.LENIENT) +@ExtendWith(MockitoExtension.class) +class TestGitHubStartupInitializerShould { + + @Mock + private GitHubCloneManager cloneManager; + + @Mock + private InMemoryRegistryService registryService; + + @Mock + private GitHubMetrics metrics; + + @Mock + private ManagedExecutor executor; + + private GitHubStartupInitializer initializer; + + @BeforeEach + void setup() { + initializer = new GitHubStartupInitializer(); + initializer.cloneManager = cloneManager; + initializer.registryService = registryService; + initializer.metrics = metrics; + initializer.executor = executor; + + // Make executor.runAsync execute the Runnable immediately (synchronously for testing) + when(executor.runAsync(any(Runnable.class))).thenAnswer(invocation -> { + Runnable task = invocation.getArgument(0); + task.run(); + return CompletableFuture.completedFuture(null); + }); + } + + @Test + void skip_when_no_namespaces_configured() { + initializer.namespaceConfigs = Optional.empty(); + + initializer.onStart(new StartupEvent()); + + verify(cloneManager, never()).cloneAll(); + verify(registryService, never()).rebuild(any()); + } + + @Test + void register_namespaces_and_clone() { + initializer.namespaceConfigs = Optional.of(List.of( + "finos|finos/architecture-as-code|main", + "team|my-org/team-repo" + )); + when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of( + "finos", Path.of("/tmp/finos"), + "team", Path.of("/tmp/team") + )); + + initializer.onStart(new StartupEvent()); + + verify(cloneManager).registerNamespace("finos", "finos/architecture-as-code", "main", java.util.Set.of()); + verify(cloneManager).registerNamespace("team", "my-org/team-repo", "main", java.util.Set.of()); + verify(cloneManager).cloneAll(); + verify(registryService).rebuild(any()); + verify(metrics).recordSyncSuccess(any()); + } + + @Test + void skip_invalid_entries() { + initializer.namespaceConfigs = Optional.of(List.of("invalid-no-pipe")); + when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of()); + + initializer.onStart(new StartupEvent()); + + verify(cloneManager, never()).registerNamespace(any(), any(), any(), any()); + } + + @Test + void parse_access_groups_from_namespace_config() { + initializer.namespaceConfigs = Optional.of(List.of( + "finos|finos/repo|main|group-a;group-b" + )); + when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of( + "finos", Path.of("/tmp/finos") + )); + + initializer.onStart(new StartupEvent()); + + verify(cloneManager).registerNamespace("finos", "finos/repo", "main", + java.util.Set.of("group-a", "group-b")); + } + + @Test + void handle_clone_failure_gracefully() { + initializer.namespaceConfigs = Optional.of(List.of("finos|finos/repo|main")); + when(executor.runAsync(any(Runnable.class))).thenAnswer(invocation -> { + Runnable task = invocation.getArgument(0); + task.run(); + return java.util.concurrent.CompletableFuture.completedFuture(null); + }); + when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of()); + org.mockito.Mockito.doThrow(new RuntimeException("clone failed")).when(cloneManager).cloneAll(); + + initializer.onStart(new StartupEvent()); + + verify(cloneManager).cloneAll(); + verify(registryService, never()).rebuild(any()); + } + + @Test + void parse_blank_access_groups_as_empty_set() { + initializer.namespaceConfigs = Optional.of(List.of( + "finos|finos/repo|main| " + )); + when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of( + "finos", Path.of("/tmp/finos") + )); + + initializer.onStart(new StartupEvent()); + + verify(cloneManager).registerNamespace("finos", "finos/repo", "main", java.util.Set.of()); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java new file mode 100644 index 0000000000..4f42a7af6c --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java @@ -0,0 +1,48 @@ +package org.finos.calm.store.github.util; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +class TestGitHubStoreConfigShould { + + @Test + void return_service_token_when_present() { + GitHubStoreConfig config = new GitHubStoreConfig(); + config.serviceToken = Optional.of("ghp_test123"); + assertThat(config.getServiceToken(), equalTo("ghp_test123")); + } + + @Test + void return_empty_string_when_service_token_absent() { + GitHubStoreConfig config = new GitHubStoreConfig(); + config.serviceToken = Optional.empty(); + assertThat(config.getServiceToken(), equalTo("")); + } + + @Test + void return_clone_directory_as_path() { + GitHubStoreConfig config = new GitHubStoreConfig(); + config.cloneDirectory = "/tmp/calm-clones"; + assertThat(config.getCloneDirectory(), equalTo(Path.of("/tmp/calm-clones"))); + } + + @Test + void return_sync_interval() { + GitHubStoreConfig config = new GitHubStoreConfig(); + config.syncInterval = 120; + assertThat(config.getSyncInterval(), equalTo(120)); + } + + @Test + void return_api_url() { + GitHubStoreConfig config = new GitHubStoreConfig(); + config.apiUrl = "https://api.github.com"; + assertThat(config.getApiUrl(), equalTo("https://api.github.com")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java new file mode 100644 index 0000000000..728e10d1c6 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java @@ -0,0 +1,70 @@ +package org.finos.calm.store.github.util; + +import org.finos.calm.observability.GitHubMetrics; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Path; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestGitHubSyncSchedulerShould { + + @Mock + private GitHubCloneManager cloneManager; + + @Mock + private InMemoryRegistryService registryService; + + @Mock + private GitHubMetrics metrics; + + private GitHubSyncScheduler scheduler; + + @BeforeEach + void setup() { + scheduler = new GitHubSyncScheduler(cloneManager, registryService, metrics); + } + + @Test + void skip_sync_when_no_namespaces_registered() { + when(cloneManager.hasNamespaces()).thenReturn(false); + + scheduler.sync(); + + verify(cloneManager, never()).pullAll(); + verify(registryService, never()).rebuild(any()); + } + + @Test + void pull_all_and_rebuild_registry_on_sync() { + when(cloneManager.hasNamespaces()).thenReturn(true); + when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of("finos", Path.of("/tmp/finos"))); + + scheduler.sync(); + + verify(cloneManager).pullAll(); + verify(registryService).rebuild(Map.of("finos", Path.of("/tmp/finos"))); + verify(metrics).recordSyncSuccess(any()); + verify(metrics).recordRegistryRebuild(any()); + } + + @Test + void record_failure_metric_when_sync_throws() { + when(cloneManager.hasNamespaces()).thenReturn(true); + doThrow(new RuntimeException("sync error")).when(cloneManager).pullAll(); + + scheduler.sync(); + + verify(metrics).recordSyncFailure(any()); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java new file mode 100644 index 0000000000..5220b70969 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java @@ -0,0 +1,113 @@ +package org.finos.calm.store.github.util; + +import org.finos.calm.cache.CalmCacheService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.empty; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; + +@MockitoSettings(strictness = Strictness.LENIENT) +@ExtendWith(MockitoExtension.class) +class TestGitHubVersionServiceShould { + + @Mock + private CalmCacheService cache; + + private GitHubVersionService service; + + @BeforeEach + void setup() { + service = new GitHubVersionService(); + service.cache = cache; + service.apiUrl = "https://api.github.com"; + service.serviceToken = Optional.of("test-token"); + service.maxVersions = 100; + service.connectTimeoutSeconds = 10; + service.requestTimeoutSeconds = 30; + } + + @Test + void return_cached_versions_when_available() { + List cached = List.of("abc1234", "def5678"); + when(cache.get("versions:org/repo:path/file.json", List.class)).thenReturn(Optional.of(cached)); + + List result = service.getFileVersions("org/repo", "path/file.json"); + + assertThat(result, equalTo(cached)); + } + + @Test + void return_latest_when_api_fails() { + when(cache.get(any(), eq(List.class))).thenReturn(Optional.empty()); + + // API will fail since we're not running a real server + List result = service.getFileVersions("org/repo", "path/file.json"); + + assertThat(result, hasSize(1)); + assertThat(result.get(0), equalTo("latest")); + } + + @Test + void return_latest_when_no_token() { + service.serviceToken = Optional.empty(); + when(cache.get(any(), eq(List.class))).thenReturn(Optional.empty()); + + List result = service.getFileVersions("org/repo", "path/file.json"); + + assertThat(result, hasSize(1)); + assertThat(result.get(0), equalTo("latest")); + } + + @Test + void extract_abbreviated_7_char_shas_from_github_api_response() { + String sha1 = "abcdef1234567890abcdef1234567890abcdef12"; + String sha2 = "1234567890abcdef1234567890abcdef12345678"; + String json = "[{\"sha\":\"" + sha1 + "\",\"commit\":{}},{\"sha\":\"" + sha2 + "\",\"commit\":{}}]"; + + List shas = service.extractShas(json, 100); + + assertThat(shas, hasSize(2)); + assertThat(shas.get(0), equalTo("abcdef1")); + assertThat(shas.get(1), equalTo("1234567")); + } + + @Test + void return_empty_list_when_no_shas_in_response() { + List shas = service.extractShas("[]", 100); + + assertThat(shas, is(empty())); + } + + @Test + void limit_to_configured_max_versions() { + service.maxVersions = 5; + StringBuilder json = new StringBuilder("["); + for (int i = 0; i < 15; i++) { + if (i > 0) json.append(","); + json.append(String.format("{\"sha\":\"%040x\"}", i)); + } + json.append("]"); + + List shas = service.extractShas(json.toString(), 5); + + assertThat(shas, hasSize(5)); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestInMemoryRegistryServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestInMemoryRegistryServiceShould.java new file mode 100644 index 0000000000..32bb1fdd22 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestInMemoryRegistryServiceShould.java @@ -0,0 +1,265 @@ +package org.finos.calm.store.github.util; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +class TestInMemoryRegistryServiceShould { + + private InMemoryRegistryService registryService; + + @TempDir + Path tempDir; + + @BeforeEach + void setup() { + registryService = new InMemoryRegistryService(new CalmContentDetector()); + } + + @Test + void return_empty_snapshot_before_rebuild() { + List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + assertThat(entries, is(empty())); + } + + @Test + void discover_architecture_from_json_with_nodes() throws IOException { + Path archDir = tempDir.resolve("architectures"); + Files.createDirectories(archDir); + Files.writeString(archDir.resolve("payment.json"), + "{\"unique-id\": \"payment-platform\", \"name\": \"Payment Platform\", \"nodes\": [], \"relationships\": []}"); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + assertThat(entries, hasSize(1)); + assertThat(entries.get(0).uniqueId(), equalTo("payment-platform")); + assertThat(entries.get(0).name(), equalTo("Payment Platform")); + } + + @Test + void discover_pattern_from_patterns_directory() throws IOException { + Path patternDir = tempDir.resolve("patterns"); + Files.createDirectories(patternDir); + Files.writeString(patternDir.resolve("event-driven.json"), + "{\"unique-id\": \"event-driven\", \"name\": \"Event Driven\", \"nodes\": [{\"unique-id\": \"broker\"}]}"); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", CalmResourceType.PATTERN); + assertThat(entries, hasSize(1)); + assertThat(entries.get(0).uniqueId(), equalTo("event-driven")); + } + + @Test + void derive_unique_id_from_filename_when_missing() throws IOException { + Path archDir = tempDir.resolve("architectures"); + Files.createDirectories(archDir); + Files.writeString(archDir.resolve("my-system.json"), + "{\"name\": \"My System\", \"nodes\": []}"); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + assertThat(entries, hasSize(1)); + assertThat(entries.get(0).uniqueId(), equalTo("my-system")); + } + + @Test + void find_entry_by_unique_id() throws IOException { + Path archDir = tempDir.resolve("architectures"); + Files.createDirectories(archDir); + Files.writeString(archDir.resolve("payment.json"), + "{\"unique-id\": \"payment-svc\", \"nodes\": []}"); + + registryService.rebuild(Map.of("finos", tempDir)); + + Optional found = registryService.findByUniqueId("finos", "payment-svc"); + assertThat(found.isPresent(), is(true)); + assertThat(found.get().uniqueId(), equalTo("payment-svc")); + } + + @Test + void return_empty_for_unknown_unique_id() throws IOException { + registryService.rebuild(Map.of("finos", tempDir)); + + Optional found = registryService.findByUniqueId("finos", "nonexistent"); + assertThat(found.isPresent(), is(false)); + } + + @Test + void skip_hidden_directories() throws IOException { + Path hiddenDir = tempDir.resolve(".git"); + Files.createDirectories(hiddenDir); + Files.writeString(hiddenDir.resolve("config.json"), "{\"nodes\": []}"); + + Path archDir = tempDir.resolve("architectures"); + Files.createDirectories(archDir); + Files.writeString(archDir.resolve("real.json"), "{\"unique-id\": \"real\", \"nodes\": []}"); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + assertThat(entries, hasSize(1)); + assertThat(entries.get(0).uniqueId(), equalTo("real")); + } + + @Test + void handle_multiple_namespaces() throws IOException { + Path ns1 = tempDir.resolve("ns1"); + Path ns2 = tempDir.resolve("ns2"); + Files.createDirectories(ns1.resolve("architectures")); + Files.createDirectories(ns2.resolve("patterns")); + Files.writeString(ns1.resolve("architectures/a.json"), "{\"unique-id\": \"a\", \"nodes\": []}"); + Files.writeString(ns2.resolve("patterns/p.json"), "{\"unique-id\": \"p\", \"nodes\": []}"); + + registryService.rebuild(Map.of("team-a", ns1, "team-b", ns2)); + + assertThat(registryService.listByType("team-a", CalmResourceType.ARCHITECTURE), hasSize(1)); + assertThat(registryService.listByType("team-b", CalmResourceType.PATTERN), hasSize(1)); + assertThat(registryService.listByType("team-a", CalmResourceType.PATTERN), is(empty())); + } + + @Test + void rebuild_replaces_previous_snapshot_atomically() throws IOException { + Path archDir = tempDir.resolve("architectures"); + Files.createDirectories(archDir); + Files.writeString(archDir.resolve("v1.json"), "{\"unique-id\": \"v1\", \"nodes\": []}"); + + registryService.rebuild(Map.of("finos", tempDir)); + assertThat(registryService.findByUniqueId("finos", "v1").isPresent(), is(true)); + + Files.delete(archDir.resolve("v1.json")); + Files.writeString(archDir.resolve("v2.json"), "{\"unique-id\": \"v2\", \"nodes\": []}"); + + registryService.rebuild(Map.of("finos", tempDir)); + assertThat(registryService.findByUniqueId("finos", "v1").isPresent(), is(false)); + assertThat(registryService.findByUniqueId("finos", "v2").isPresent(), is(true)); + } + + @Test + void skip_invalid_json_files_without_crashing() throws IOException { + Path archDir = tempDir.resolve("architectures"); + Files.createDirectories(archDir); + Files.writeString(archDir.resolve("bad.json"), "not json at all"); + Files.writeString(archDir.resolve("good.json"), "{\"unique-id\": \"good\", \"nodes\": []}"); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + assertThat(entries, hasSize(1)); + assertThat(entries.get(0).uniqueId(), equalTo("good")); + } + + @Test + void detect_guideline_in_guidelines_directory() throws IOException { + Path guideDir = tempDir.resolve("guidelines"); + Files.createDirectories(guideDir); + Files.writeString(guideDir.resolve("best-practices.json"), + "{\"unique-id\": \"best-practices\", \"title\": \"Best Practices\"}"); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", CalmResourceType.GUIDELINE); + assertThat(entries, hasSize(1)); + } + + @Test + void detect_markdown_standards_in_nested_directories() throws IOException { + Path stdDir = tempDir.resolve("standards/cloud/compute"); + Files.createDirectories(stdDir); + Files.writeString(stdDir.resolve("vm-sizing.md"), "# VM Sizing Standard\n\nContent here."); + Files.writeString(stdDir.resolve("README.md"), "# README\n\nIgnored."); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", CalmResourceType.STANDARD); + assertThat(entries, hasSize(1)); + assertThat(entries.get(0).uniqueId(), equalTo("vm-sizing")); + } + + @Test + void detect_markdown_guidelines_in_nested_directories() throws IOException { + Path guideDir = tempDir.resolve("guidelines/security"); + Files.createDirectories(guideDir); + Files.writeString(guideDir.resolve("tls-policy.md"), "# TLS Policy\n\nAlways use TLS."); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", CalmResourceType.GUIDELINE); + assertThat(entries, hasSize(1)); + } + + @Test + void skip_markdown_files_outside_known_directories() throws IOException { + Files.writeString(tempDir.resolve("README.md"), "# Project README"); + Path miscDir = tempDir.resolve("docs"); + Files.createDirectories(miscDir); + Files.writeString(miscDir.resolve("notes.md"), "# Notes"); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", CalmResourceType.STANDARD); + assertThat(entries, is(empty())); + } + + @Test + void return_empty_list_for_unknown_namespace() { + registryService.rebuild(Map.of()); + List entries = registryService.listByType("nonexistent", CalmResourceType.ARCHITECTURE); + assertThat(entries, is(empty())); + } + + @Test + void return_empty_optional_for_unknown_namespace_in_find() { + registryService.rebuild(Map.of()); + Optional result = registryService.findByUniqueId("nonexistent", "any-id"); + assertThat(result.isPresent(), is(false)); + } + + @Test + void get_snapshot_returns_current_state() throws IOException { + Path archDir = tempDir.resolve("architectures"); + Files.createDirectories(archDir); + Files.writeString(archDir.resolve("test.json"), "{\"unique-id\": \"test\", \"nodes\": []}"); + + registryService.rebuild(Map.of("ns", tempDir)); + + RegistrySnapshot snapshot = registryService.getSnapshot(); + assertThat(snapshot.getNamespaces(), hasSize(1)); + assertThat(snapshot.listAll("ns"), hasSize(1)); + } + + @Test + void handle_empty_directory_gracefully() throws IOException { + Path emptyDir = tempDir.resolve("architectures"); + Files.createDirectories(emptyDir); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + assertThat(entries, is(empty())); + } + + @Test + void handle_nonexistent_clone_path() { + registryService.rebuild(Map.of("finos", tempDir.resolve("does-not-exist"))); + + List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + assertThat(entries, is(empty())); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java new file mode 100644 index 0000000000..468fbf8ca0 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java @@ -0,0 +1,134 @@ +package org.finos.calm.store.github.util; + +import io.quarkus.security.identity.SecurityIdentity; +import org.finos.calm.security.OidcRoleResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.security.Principal; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestNamespaceAccessFilterShould { + + @Mock + private SecurityIdentity identity; + + @Mock + private Principal principal; + + @Mock + private OidcRoleResolver roleResolver; + + @Mock + private InMemoryRegistryService registryService; + + @Mock + private GitHubCloneManager cloneManager; + + private NamespaceAccessFilter filter; + + @BeforeEach + void setup() { + filter = new NamespaceAccessFilter(); + filter.identity = identity; + filter.roleResolver = roleResolver; + filter.registryService = registryService; + filter.cloneManager = cloneManager; + } + + @Test + void return_all_namespaces_when_auth_disabled() { + filter.authEnabled = false; + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(), "private", List.of()), + Map.of(), Map.of()); + when(registryService.getSnapshot()).thenReturn(snapshot); + + Set result = filter.getAccessibleNamespaces(); + + assertThat(result, containsInAnyOrder("finos", "private")); + } + + @Test + void return_empty_when_identity_is_anonymous() { + filter.authEnabled = true; + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of()), Map.of(), Map.of()); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(identity.isAnonymous()).thenReturn(true); + + Set result = filter.getAccessibleNamespaces(); + + assertThat(result, is(empty())); + } + + @Test + void return_only_accessible_namespaces_based_on_oidc_groups() { + filter.authEnabled = true; + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(), "private", List.of(), "restricted", List.of()), + Map.of(), Map.of()); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(identity.isAnonymous()).thenReturn(false); + when(identity.getPrincipal()).thenReturn(principal); + when(principal.getName()).thenReturn("testuser"); + + when(cloneManager.getAccessGroupsForNamespace("finos")).thenReturn(Set.of("team-a")); + when(cloneManager.getAccessGroupsForNamespace("private")).thenReturn(Set.of("team-b")); + when(cloneManager.getAccessGroupsForNamespace("restricted")).thenReturn(Set.of("team-c")); + + when(roleResolver.resolve(eq(identity), eq(Set.of("team-a")))).thenReturn(OidcRoleResolver.AccessLevel.READ); + when(roleResolver.resolve(eq(identity), eq(Set.of("team-b")))).thenReturn(OidcRoleResolver.AccessLevel.NONE); + when(roleResolver.resolve(eq(identity), eq(Set.of("team-c")))).thenReturn(OidcRoleResolver.AccessLevel.READ); + + Set result = filter.getAccessibleNamespaces(); + + assertThat(result, containsInAnyOrder("finos", "restricted")); + } + + @Test + void return_empty_when_no_namespaces_match() { + filter.authEnabled = true; + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("private", List.of()), Map.of(), Map.of()); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(identity.isAnonymous()).thenReturn(false); + when(identity.getPrincipal()).thenReturn(principal); + when(principal.getName()).thenReturn("testuser"); + + when(cloneManager.getAccessGroupsForNamespace("private")).thenReturn(Set.of("admins")); + when(roleResolver.resolve(eq(identity), eq(Set.of("admins")))).thenReturn(OidcRoleResolver.AccessLevel.NONE); + + Set result = filter.getAccessibleNamespaces(); + + assertThat(result, is(empty())); + } + + @Test + void return_empty_when_no_namespaces_registered() { + filter.authEnabled = true; + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + when(identity.isAnonymous()).thenReturn(false); + when(identity.getPrincipal()).thenReturn(principal); + when(principal.getName()).thenReturn("testuser"); + + Set result = filter.getAccessibleNamespaces(); + + assertThat(result, is(empty())); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/noop/TestNoOpResourceMappingStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/noop/TestNoOpResourceMappingStoreShould.java new file mode 100644 index 0000000000..24f515335a --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/noop/TestNoOpResourceMappingStoreShould.java @@ -0,0 +1,74 @@ +package org.finos.calm.store.noop; + +import org.finos.calm.domain.ResourceType; +import org.finos.calm.domain.exception.MappingNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TestNoOpResourceMappingStoreShould { + + private NoOpResourceMappingStore store; + + @BeforeEach + void setup() { + store = new NoOpResourceMappingStore(); + } + + @Test + void throw_on_create_mapping() { + UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, + () -> store.createMapping("finos", "custom-id", ResourceType.ARCHITECTURE, 1)); + assertThat(ex.getMessage(), containsString("unique-id")); + } + + @Test + void throw_not_found_on_get_mapping() { + assertThrows(MappingNotFoundException.class, + () -> store.getMapping("finos", ResourceType.ARCHITECTURE, "custom-id")); + } + + @Test + void return_empty_list_on_list_mappings() throws Exception { + assertThat(store.listMappings("finos", ResourceType.PATTERN), is(empty())); + } + + @Test + void throw_not_found_on_get_mapping_by_numeric_id() { + assertThrows(MappingNotFoundException.class, + () -> store.getMappingByNumericId("finos", ResourceType.ARCHITECTURE, 1)); + } + + @Test + void return_empty_list_on_list_mappings_by_numeric_ids() throws Exception { + assertThat(store.listMappingsByNumericIds("finos", ResourceType.PATTERN, List.of(1, 2)), is(empty())); + } + + @Test + void throw_on_update_mapping_numeric_id() { + UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, + () -> store.updateMappingNumericId("finos", ResourceType.PATTERN, "custom-id", 2)); + assertThat(ex.getMessage(), containsString("unique-id")); + } + + @Test + void throw_on_delete_mapping() { + UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, + () -> store.deleteMapping("finos", ResourceType.PATTERN, "custom-id")); + assertThat(ex.getMessage(), containsString("unique-id")); + } + + @Test + void throw_on_delete_mapping_by_numeric_id() { + UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, + () -> store.deleteMappingByNumericId("finos", ResourceType.PATTERN, 1)); + assertThat(ex.getMessage(), containsString("unique-id")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/noop/TestNoOpSchemaVersionStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/noop/TestNoOpSchemaVersionStoreShould.java new file mode 100644 index 0000000000..0ee79a8c86 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/noop/TestNoOpSchemaVersionStoreShould.java @@ -0,0 +1,46 @@ +package org.finos.calm.store.noop; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +class TestNoOpSchemaVersionStoreShould { + + private NoOpSchemaVersionStore store; + + @BeforeEach + void setup() { + store = new NoOpSchemaVersionStore(); + } + + @Test + void return_max_value_for_schema_version() { + assertThat(store.getSchemaVersion(), equalTo(Integer.MAX_VALUE)); + } + + @Test + void not_throw_on_set_schema_version() { + store.setSchemaVersion(42); + assertThat(store.getSchemaVersion(), equalTo(Integer.MAX_VALUE)); + } + + @Test + void always_acquire_migration_lock() { + assertThat(store.acquireMigrationLock("instance-1"), is(true)); + assertThat(store.acquireMigrationLock("instance-2"), is(true)); + } + + @Test + void not_throw_on_release_migration_lock() { + store.releaseMigrationLock("instance-1"); + } + + @Test + void never_report_migration_lock_held() { + store.acquireMigrationLock("instance-1"); + assertThat(store.isMigrationLockHeld(), is(false)); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestAdrStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestAdrStoreProducerShould.java index 953ca28426..a5f91fbaeb 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestAdrStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestAdrStoreProducerShould.java @@ -4,7 +4,9 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.sameInstance; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.AdrStore; +import org.finos.calm.store.github.GitHubAdrStore; import org.finos.calm.store.mongo.MongoAdrStore; import org.finos.calm.store.nitrite.NitriteAdrStore; import org.junit.jupiter.api.BeforeEach; @@ -34,6 +36,12 @@ public class TestAdrStoreProducerShould { @Mock Instance nitriteAdrStoreInstance; + @Mock + GitHubAdrStore gitHubAdrStore; + + @Mock + Instance gitHubAdrStoreInstance; + private AdrStoreProducer adrStoreProducer; @BeforeEach @@ -43,6 +51,8 @@ void setup() { adrStoreProducer.mongoAdrStore = mongoAdrStoreInstance; when(nitriteAdrStoreInstance.get()).thenReturn(nitriteAdrStore); adrStoreProducer.standaloneAdrStore = nitriteAdrStoreInstance; + when(gitHubAdrStoreInstance.get()).thenReturn(gitHubAdrStore); + adrStoreProducer.gitHubAdrStore = gitHubAdrStoreInstance; } @Test @@ -71,13 +81,19 @@ void return_nitrite_adr_store_when_database_mode_is_standalone() { @Test void return_mongo_adr_store_when_database_mode_is_not_recognized() { - // Given adrStoreProducer.databaseMode = "unknown"; - // When AdrStore result = adrStoreProducer.produceAdrStore(); - // Then assertThat(result, is(sameInstance(mongoAdrStore))); } + + @Test + void return_github_adr_store_when_database_mode_is_github() { + adrStoreProducer.databaseMode = DatabaseMode.GITHUB; + + AdrStore result = adrStoreProducer.produceAdrStore(); + + assertThat(result, is(sameInstance(gitHubAdrStore))); + } } \ No newline at end of file diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestArchitectureStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestArchitectureStoreProducerShould.java index c9c229027c..be12e9504d 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestArchitectureStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestArchitectureStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.ArchitectureStore; +import org.finos.calm.store.github.GitHubArchitectureStore; import org.finos.calm.store.mongo.MongoArchitectureStore; import org.finos.calm.store.nitrite.NitriteArchitectureStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestArchitectureStoreProducerShould { @Mock Instance nitriteArchitectureStoreInstance; + + @Mock + GitHubArchitectureStore gitHubArchitectureStore; + + @Mock + Instance gitHubArchitectureStoreInstance; private ArchitectureStoreProducer architectureStoreProducer; @BeforeEach @@ -42,6 +50,8 @@ void setup() { architectureStoreProducer.mongoArchitectureStore = mongoArchitectureStoreInstance; when(nitriteArchitectureStoreInstance.get()).thenReturn(nitriteArchitectureStore); architectureStoreProducer.standaloneArchitectureStore = nitriteArchitectureStoreInstance; + when(gitHubArchitectureStoreInstance.get()).thenReturn(gitHubArchitectureStore); + architectureStoreProducer.gitHubArchitectureStore = gitHubArchitectureStoreInstance; } @Test @@ -70,13 +80,19 @@ void return_nitrite_architecture_store_when_database_mode_is_standalone() { @Test void return_mongo_architecture_store_when_database_mode_is_not_recognized() { - // Given architectureStoreProducer.databaseMode = "unknown"; - // When ArchitectureStore result = architectureStoreProducer.produceArchitectureStore(); - // Then assertThat(result, is(sameInstance(mongoArchitectureStore))); } + + @Test + void return_github_architecture_store_when_database_mode_is_github() { + architectureStoreProducer.databaseMode = DatabaseMode.GITHUB; + + ArchitectureStore result = architectureStoreProducer.produceArchitectureStore(); + + assertThat(result, is(sameInstance(gitHubArchitectureStore))); + } } \ No newline at end of file diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestAuditLogStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestAuditLogStoreProducerShould.java index 10f29285a4..af4fda5d91 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestAuditLogStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestAuditLogStoreProducerShould.java @@ -1,7 +1,9 @@ package org.finos.calm.store.producer; import jakarta.enterprise.inject.Instance; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.AuditLogStore; +import org.finos.calm.store.github.GitHubAuditLogStore; import org.finos.calm.store.mongo.MongoAuditLogStore; import org.finos.calm.store.nitrite.NitriteAuditLogStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestAuditLogStoreProducerShould { @Mock Instance nitriteAuditLogStoreInstance; + @Mock + GitHubAuditLogStore gitHubAuditLogStore; + + @Mock + Instance gitHubAuditLogStoreInstance; + private AuditLogStoreProducer producer; @BeforeEach @@ -42,6 +50,8 @@ void setup() { producer.mongoAuditLogStore = mongoAuditLogStoreInstance; when(nitriteAuditLogStoreInstance.get()).thenReturn(nitriteAuditLogStore); producer.standaloneAuditLogStore = nitriteAuditLogStoreInstance; + when(gitHubAuditLogStoreInstance.get()).thenReturn(gitHubAuditLogStore); + producer.gitHubAuditLogStore = gitHubAuditLogStoreInstance; } @Test @@ -70,4 +80,13 @@ void return_mongo_audit_log_store_when_database_mode_is_not_recognized() { assertThat(result, is(sameInstance(mongoAuditLogStore))); } + + @Test + void return_github_audit_log_store_when_database_mode_is_github() { + producer.databaseMode = DatabaseMode.GITHUB; + + AuditLogStore result = producer.produceAuditLogStore(); + + assertThat(result, is(sameInstance(gitHubAuditLogStore))); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestBuildingBlockStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestBuildingBlockStoreProducerShould.java new file mode 100644 index 0000000000..b0f14a9314 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestBuildingBlockStoreProducerShould.java @@ -0,0 +1,113 @@ +package org.finos.calm.store.producer; + +import org.finos.calm.config.DatabaseMode; +import org.finos.calm.store.BuildingBlockStore; +import org.finos.calm.store.github.GitHubBuildingBlockStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import jakarta.enterprise.inject.Instance; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.sameInstance; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@MockitoSettings(strictness = Strictness.LENIENT) +@ExtendWith(MockitoExtension.class) +class TestBuildingBlockStoreProducerShould { + + @Mock + GitHubBuildingBlockStore gitHubBuildingBlockStore; + + @Mock + Instance gitHubBuildingBlockStoreInstance; + + private BuildingBlockStoreProducer producer; + + @BeforeEach + void setup() { + producer = new BuildingBlockStoreProducer(); + when(gitHubBuildingBlockStoreInstance.get()).thenReturn(gitHubBuildingBlockStore); + producer.gitHubBuildingBlockStore = gitHubBuildingBlockStoreInstance; + } + + @Test + void return_github_building_block_store_when_database_mode_is_github() { + producer.databaseMode = DatabaseMode.GITHUB; + + BuildingBlockStore result = producer.produceBuildingBlockStore(); + + assertThat(result, is(sameInstance(gitHubBuildingBlockStore))); + } + + @Test + void return_no_op_store_when_database_mode_is_mongo() { + producer.databaseMode = DatabaseMode.MONGO; + + BuildingBlockStore result = producer.produceBuildingBlockStore(); + + assertThat(result, is(notNullValue())); + } + + @Test + void return_no_op_store_when_database_mode_is_standalone() { + producer.databaseMode = DatabaseMode.STANDALONE; + + BuildingBlockStore result = producer.produceBuildingBlockStore(); + + assertThat(result, is(notNullValue())); + } + + @Test + void no_op_store_throws_on_get_building_blocks_for_namespace() { + producer.databaseMode = DatabaseMode.MONGO; + BuildingBlockStore noOpStore = producer.produceBuildingBlockStore(); + + assertThrows(UnsupportedOperationException.class, + () -> noOpStore.getBuildingBlocksForNamespace("finos")); + } + + @Test + void no_op_store_throws_on_create_building_block_for_namespace() { + producer.databaseMode = DatabaseMode.MONGO; + BuildingBlockStore noOpStore = producer.produceBuildingBlockStore(); + + assertThrows(UnsupportedOperationException.class, + () -> noOpStore.createBuildingBlockForNamespace("finos", "{}")); + } + + @Test + void no_op_store_throws_on_get_building_block_versions() { + producer.databaseMode = DatabaseMode.MONGO; + BuildingBlockStore noOpStore = producer.produceBuildingBlockStore(); + + assertThrows(UnsupportedOperationException.class, + () -> noOpStore.getBuildingBlockVersions("finos", 1)); + } + + @Test + void no_op_store_throws_on_get_building_block_for_version() { + producer.databaseMode = DatabaseMode.MONGO; + BuildingBlockStore noOpStore = producer.produceBuildingBlockStore(); + + assertThrows(UnsupportedOperationException.class, + () -> noOpStore.getBuildingBlockForVersion("finos", 1, "1.0.0")); + } + + @Test + void no_op_store_throws_on_create_building_block_for_version() { + producer.databaseMode = DatabaseMode.MONGO; + BuildingBlockStore noOpStore = producer.produceBuildingBlockStore(); + + assertThrows(UnsupportedOperationException.class, + () -> noOpStore.createBuildingBlockForVersion("finos", 1, "1.0.0", "{}")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestControlStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestControlStoreProducerShould.java index 9001491ac0..d83de03309 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestControlStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestControlStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.ControlStore; +import org.finos.calm.store.github.GitHubControlStore; import org.finos.calm.store.mongo.MongoControlStore; import org.finos.calm.store.nitrite.NitriteControlStore; import org.junit.jupiter.api.BeforeEach; @@ -32,6 +34,12 @@ public class TestControlStoreProducerShould { @Mock Instance standaloneControlStoreInstance; + @Mock + GitHubControlStore gitHubControlStore; + + @Mock + Instance gitHubControlStoreInstance; + ControlStoreProducer producer; @BeforeEach @@ -41,6 +49,8 @@ public void setup() { producer = new ControlStoreProducer(); producer.mongoControlStore = mongoControlStoreInstance; producer.standaloneControlStore = standaloneControlStoreInstance; + when(gitHubControlStoreInstance.get()).thenReturn(gitHubControlStore); + producer.gitHubControlStore = gitHubControlStoreInstance; } @Test @@ -74,4 +84,12 @@ public void return_mongo_store_when_database_mode_is_null() { assertNotNull(result); assertEquals(mongoControlStore, result); } + + @Test + public void return_github_store_when_database_mode_is_github() { + producer.databaseMode = DatabaseMode.GITHUB; + ControlStore result = producer.produceControlStore(); + assertNotNull(result); + assertEquals(gitHubControlStore, result); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestCoreSchemaStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestCoreSchemaStoreProducerShould.java index dea464b329..fb0cfc0f0e 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestCoreSchemaStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestCoreSchemaStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.CoreSchemaStore; +import org.finos.calm.store.classpath.ClasspathCoreSchemaStore; import org.finos.calm.store.mongo.MongoCoreSchemaStore; import org.finos.calm.store.nitrite.NitriteCoreSchemaStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestCoreSchemaStoreProducerShould { @Mock Instance nitriteCoreSchemaStoreInstance; + @Mock + ClasspathCoreSchemaStore classpathCoreSchemaStore; + + @Mock + Instance classpathCoreSchemaStoreInstance; + private CoreSchemaStoreProducer coreSchemaStoreProducer; @BeforeEach @@ -42,41 +50,43 @@ void setup() { coreSchemaStoreProducer.mongoCoreSchemaStore = mongoCoreSchemaStoreInstance; when(nitriteCoreSchemaStoreInstance.get()).thenReturn(nitriteCoreSchemaStore); coreSchemaStoreProducer.standaloneCoreSchemaStore = nitriteCoreSchemaStoreInstance; + when(classpathCoreSchemaStoreInstance.get()).thenReturn(classpathCoreSchemaStore); + coreSchemaStoreProducer.classpathCoreSchemaStore = classpathCoreSchemaStoreInstance; } @Test void return_mongo_core_schema_store_when_database_mode_is_mongo() { - // Given - coreSchemaStoreProducer.databaseMode = "mongo"; + coreSchemaStoreProducer.databaseMode = DatabaseMode.MONGO; - // When CoreSchemaStore result = coreSchemaStoreProducer.produceCoreSchemaStore(); - // Then assertThat(result, is(sameInstance(mongoCoreSchemaStore))); } @Test void return_nitrite_core_schema_store_when_database_mode_is_standalone() { - // Given - coreSchemaStoreProducer.databaseMode = "standalone"; + coreSchemaStoreProducer.databaseMode = DatabaseMode.STANDALONE; - // When CoreSchemaStore result = coreSchemaStoreProducer.produceCoreSchemaStore(); - // Then assertThat(result, is(sameInstance(nitriteCoreSchemaStore))); } @Test void return_mongo_core_schema_store_when_database_mode_is_not_recognized() { - // Given coreSchemaStoreProducer.databaseMode = "unknown"; - // When CoreSchemaStore result = coreSchemaStoreProducer.produceCoreSchemaStore(); - // Then assertThat(result, is(sameInstance(mongoCoreSchemaStore))); } + + @Test + void return_classpath_core_schema_store_when_database_mode_is_github() { + coreSchemaStoreProducer.databaseMode = DatabaseMode.GITHUB; + + CoreSchemaStore result = coreSchemaStoreProducer.produceCoreSchemaStore(); + + assertThat(result, is(sameInstance(classpathCoreSchemaStore))); + } } \ No newline at end of file diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestDecoratorStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestDecoratorStoreProducerShould.java index 034f1def8b..391cf3426f 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestDecoratorStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestDecoratorStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.DecoratorStore; +import org.finos.calm.store.github.GitHubDecoratorStore; import org.finos.calm.store.mongo.MongoDecoratorStore; import org.finos.calm.store.nitrite.NitriteDecoratorStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestDecoratorStoreProducerShould { @Mock Instance nitriteDecoratorStoreInstance; + @Mock + GitHubDecoratorStore gitHubDecoratorStore; + + @Mock + Instance gitHubDecoratorStoreInstance; + private DecoratorStoreProducer decoratorStoreProducer; @BeforeEach @@ -41,7 +49,9 @@ void setup() { when(mongoDecoratorStoreInstance.get()).thenReturn(mongoDecoratorStore); decoratorStoreProducer.mongoDecoratorStore = mongoDecoratorStoreInstance; when(nitriteDecoratorStoreInstance.get()).thenReturn(nitriteDecoratorStore); - decoratorStoreProducer.nitriteDecoratorStore = nitriteDecoratorStoreInstance; + decoratorStoreProducer.standaloneDecoratorStore = nitriteDecoratorStoreInstance; + when(gitHubDecoratorStoreInstance.get()).thenReturn(gitHubDecoratorStore); + decoratorStoreProducer.gitHubDecoratorStore = gitHubDecoratorStoreInstance; } @Test @@ -70,13 +80,19 @@ void return_nitrite_decorator_store_when_database_mode_is_standalone() { @Test void return_mongo_decorator_store_when_database_mode_is_not_recognized() { - // Given decoratorStoreProducer.databaseMode = "unknown"; - // When DecoratorStore result = decoratorStoreProducer.produceDecoratorStore(); - // Then assertThat(result, is(sameInstance(mongoDecoratorStore))); } + + @Test + void return_github_decorator_store_when_database_mode_is_github() { + decoratorStoreProducer.databaseMode = DatabaseMode.GITHUB; + + DecoratorStore result = decoratorStoreProducer.produceDecoratorStore(); + + assertThat(result, is(sameInstance(gitHubDecoratorStore))); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestDomainStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestDomainStoreProducerShould.java index 72f52ff320..659809a718 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestDomainStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestDomainStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.DomainStore; +import org.finos.calm.store.github.GitHubDomainStore; import org.finos.calm.store.mongo.MongoDomainStore; import org.finos.calm.store.nitrite.NitriteDomainStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestDomainStoreProducerShould { @Mock Instance nitriteDomainStoreInstance; + @Mock + GitHubDomainStore gitHubDomainStore; + + @Mock + Instance gitHubDomainStoreInstance; + private DomainStoreProducer domainStoreProducer; @BeforeEach @@ -42,6 +50,8 @@ void setup() { domainStoreProducer.mongoDomainStore = mongoDomainStoreInstance; when(nitriteDomainStoreInstance.get()).thenReturn(nitriteDomainStore); domainStoreProducer.standaloneDomainStore = nitriteDomainStoreInstance; + when(gitHubDomainStoreInstance.get()).thenReturn(gitHubDomainStore); + domainStoreProducer.gitHubDomainStore = gitHubDomainStoreInstance; } @Test @@ -70,13 +80,19 @@ void return_nitrite_domain_store_when_database_mode_is_standalone() { @Test void return_mongo_domain_store_when_database_mode_is_not_recognized() { - // Given domainStoreProducer.databaseMode = "unknown"; - // When DomainStore result = domainStoreProducer.produceDomainStore(); - // Then assertThat(result, is(sameInstance(mongoDomainStore))); } + + @Test + void return_github_domain_store_when_database_mode_is_github() { + domainStoreProducer.databaseMode = DatabaseMode.GITHUB; + + DomainStore result = domainStoreProducer.produceDomainStore(); + + assertThat(result, is(sameInstance(gitHubDomainStore))); + } } \ No newline at end of file diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestFlowStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestFlowStoreProducerShould.java index 7b0803c75b..4ccd2a15f7 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestFlowStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestFlowStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.FlowStore; +import org.finos.calm.store.github.GitHubFlowStore; import org.finos.calm.store.mongo.MongoFlowStore; import org.finos.calm.store.nitrite.NitriteFlowStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestFlowStoreProducerShould { @Mock Instance nitriteFlowStoreInstance; + @Mock + GitHubFlowStore gitHubFlowStore; + + @Mock + Instance gitHubFlowStoreInstance; + private FlowStoreProducer flowStoreProducer; @BeforeEach @@ -42,6 +50,8 @@ void setup() { flowStoreProducer.mongoFlowStore = mongoFlowStoreInstance; when(nitriteFlowStoreInstance.get()).thenReturn(nitriteFlowStore); flowStoreProducer.standaloneFlowStore = nitriteFlowStoreInstance; + when(gitHubFlowStoreInstance.get()).thenReturn(gitHubFlowStore); + flowStoreProducer.gitHubFlowStore = gitHubFlowStoreInstance; } @Test @@ -70,13 +80,19 @@ void return_nitrite_flow_store_when_database_mode_is_standalone() { @Test void return_mongo_flow_store_when_database_mode_is_not_recognized() { - // Given flowStoreProducer.databaseMode = "unknown"; - // When FlowStore result = flowStoreProducer.produceFlowStore(); - // Then assertThat(result, is(sameInstance(mongoFlowStore))); } + + @Test + void return_github_flow_store_when_database_mode_is_github() { + flowStoreProducer.databaseMode = DatabaseMode.GITHUB; + + FlowStore result = flowStoreProducer.produceFlowStore(); + + assertThat(result, is(sameInstance(gitHubFlowStore))); + } } \ No newline at end of file diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestInterfaceStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestInterfaceStoreProducerShould.java index 9b85ee9531..91599c03ca 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestInterfaceStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestInterfaceStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.InterfaceStore; +import org.finos.calm.store.github.GitHubInterfaceStore; import org.finos.calm.store.mongo.MongoInterfaceStore; import org.finos.calm.store.nitrite.NitriteInterfaceStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestInterfaceStoreProducerShould { @Mock Instance nitriteInterfaceStoreInstance; + + @Mock + GitHubInterfaceStore gitHubInterfaceStore; + + @Mock + Instance gitHubInterfaceStoreInstance; private InterfaceStoreProducer interfaceStoreProducer; @BeforeEach @@ -42,6 +50,8 @@ void setup() { interfaceStoreProducer.mongoInterfaceStore = mongoInterfaceStoreInstance; when(nitriteInterfaceStoreInstance.get()).thenReturn(nitriteInterfaceStore); interfaceStoreProducer.standaloneInterfaceStore = nitriteInterfaceStoreInstance; + when(gitHubInterfaceStoreInstance.get()).thenReturn(gitHubInterfaceStore); + interfaceStoreProducer.gitHubInterfaceStore = gitHubInterfaceStoreInstance; } @Test @@ -70,4 +80,13 @@ void return_mongo_interface_store_when_database_mode_is_not_recognized() { assertThat(result, is(sameInstance(mongoInterfaceStore))); } + + @Test + void return_github_interface_store_when_database_mode_is_github() { + interfaceStoreProducer.databaseMode = DatabaseMode.GITHUB; + + InterfaceStore result = interfaceStoreProducer.produceInterfaceStore(); + + assertThat(result, is(sameInstance(gitHubInterfaceStore))); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestLayoutStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestLayoutStoreProducerShould.java index b9d4905510..cf80786e9b 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestLayoutStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestLayoutStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.LayoutStore; +import org.finos.calm.store.github.GitHubLayoutStore; import org.finos.calm.store.mongo.MongoLayoutStore; import org.finos.calm.store.nitrite.NitriteLayoutStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestLayoutStoreProducerShould { @Mock Instance nitriteLayoutStoreInstance; + @Mock + GitHubLayoutStore gitHubLayoutStore; + + @Mock + Instance gitHubLayoutStoreInstance; + private LayoutStoreProducer layoutStoreProducer; @BeforeEach @@ -41,7 +49,9 @@ void setup() { when(mongoLayoutStoreInstance.get()).thenReturn(mongoLayoutStore); layoutStoreProducer.mongoLayoutStore = mongoLayoutStoreInstance; when(nitriteLayoutStoreInstance.get()).thenReturn(nitriteLayoutStore); - layoutStoreProducer.nitriteLayoutStore = nitriteLayoutStoreInstance; + layoutStoreProducer.standaloneLayoutStore = nitriteLayoutStoreInstance; + when(gitHubLayoutStoreInstance.get()).thenReturn(gitHubLayoutStore); + layoutStoreProducer.gitHubLayoutStore = gitHubLayoutStoreInstance; } @Test @@ -70,13 +80,19 @@ void return_nitrite_layout_store_when_database_mode_is_standalone() { @Test void return_mongo_layout_store_when_database_mode_is_not_recognized() { - // Given layoutStoreProducer.databaseMode = "unknown"; - // When LayoutStore result = layoutStoreProducer.produceLayoutStore(); - // Then assertThat(result, is(sameInstance(mongoLayoutStore))); } + + @Test + void return_github_layout_store_when_database_mode_is_github() { + layoutStoreProducer.databaseMode = DatabaseMode.GITHUB; + + LayoutStore result = layoutStoreProducer.produceLayoutStore(); + + assertThat(result, is(sameInstance(gitHubLayoutStore))); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestNamespaceStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestNamespaceStoreProducerShould.java index 87f34004f2..090b725d5a 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestNamespaceStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestNamespaceStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.NamespaceStore; +import org.finos.calm.store.github.GitHubNamespaceStore; import org.finos.calm.store.mongo.MongoNamespaceStore; import org.finos.calm.store.nitrite.NitriteNamespaceStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestNamespaceStoreProducerShould { @Mock Instance nitriteNamespaceStoreInstance; + @Mock + GitHubNamespaceStore gitHubNamespaceStore; + + @Mock + Instance gitHubNamespaceStoreInstance; + private NamespaceStoreProducer namespaceStoreProducer; @BeforeEach @@ -42,6 +50,8 @@ void setup() { namespaceStoreProducer.mongoNamespaceStore = mongoNamespaceStoreInstance; when(nitriteNamespaceStoreInstance.get()).thenReturn(nitriteNamespaceStore); namespaceStoreProducer.standaloneNamespaceStore = nitriteNamespaceStoreInstance; + when(gitHubNamespaceStoreInstance.get()).thenReturn(gitHubNamespaceStore); + namespaceStoreProducer.gitHubNamespaceStore = gitHubNamespaceStoreInstance; } @Test @@ -70,13 +80,19 @@ void return_nitrite_namespace_store_when_database_mode_is_standalone() { @Test void return_mongo_namespace_store_when_database_mode_is_not_recognized() { - // Given namespaceStoreProducer.databaseMode = "unknown"; - // When NamespaceStore result = namespaceStoreProducer.produceNamespaceStore(); - // Then assertThat(result, is(sameInstance(mongoNamespaceStore))); } + + @Test + void return_github_namespace_store_when_database_mode_is_github() { + namespaceStoreProducer.databaseMode = DatabaseMode.GITHUB; + + NamespaceStore result = namespaceStoreProducer.produceNamespaceStore(); + + assertThat(result, is(sameInstance(gitHubNamespaceStore))); + } } \ No newline at end of file diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestPatternStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestPatternStoreProducerShould.java index c4512003e3..212d24ace7 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestPatternStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestPatternStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.PatternStore; +import org.finos.calm.store.github.GitHubPatternStore; import org.finos.calm.store.mongo.MongoPatternStore; import org.finos.calm.store.nitrite.NitritePatternStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestPatternStoreProducerShould { @Mock Instance nitritePatternStoreInstance; + @Mock + GitHubPatternStore gitHubPatternStore; + + @Mock + Instance gitHubPatternStoreInstance; + private PatternStoreProducer patternStoreProducer; @BeforeEach @@ -42,6 +50,8 @@ void setup() { patternStoreProducer.mongoPatternStore = mongoPatternStoreInstance; when(nitritePatternStoreInstance.get()).thenReturn(nitritePatternStore); patternStoreProducer.standalonePatternStore = nitritePatternStoreInstance; + when(gitHubPatternStoreInstance.get()).thenReturn(gitHubPatternStore); + patternStoreProducer.gitHubPatternStore = gitHubPatternStoreInstance; } @Test @@ -70,13 +80,19 @@ void return_nitrite_pattern_store_when_database_mode_is_standalone() { @Test void return_mongo_pattern_store_when_database_mode_is_not_recognized() { - // Given patternStoreProducer.databaseMode = "unknown"; - // When PatternStore result = patternStoreProducer.producePatternStore(); - // Then assertThat(result, is(sameInstance(mongoPatternStore))); } + + @Test + void return_github_pattern_store_when_database_mode_is_github() { + patternStoreProducer.databaseMode = DatabaseMode.GITHUB; + + PatternStore result = patternStoreProducer.producePatternStore(); + + assertThat(result, is(sameInstance(gitHubPatternStore))); + } } \ No newline at end of file diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestResourceMappingStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestResourceMappingStoreProducerShould.java index d92d9542ee..12290ddbd1 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestResourceMappingStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestResourceMappingStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.ResourceMappingStore; +import org.finos.calm.store.github.GitHubResourceMappingStore; import org.finos.calm.store.mongo.MongoResourceMappingStore; import org.finos.calm.store.nitrite.NitriteResourceMappingStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestResourceMappingStoreProducerShould { @Mock Instance nitriteResourceMappingStoreInstance; + @Mock + GitHubResourceMappingStore gitHubResourceMappingStore; + + @Mock + Instance gitHubResourceMappingStoreInstance; + private ResourceMappingStoreProducer producer; @BeforeEach @@ -42,11 +50,13 @@ void setup() { producer.mongoResourceMappingStore = mongoResourceMappingStoreInstance; when(nitriteResourceMappingStoreInstance.get()).thenReturn(nitriteResourceMappingStore); producer.standaloneResourceMappingStore = nitriteResourceMappingStoreInstance; + when(gitHubResourceMappingStoreInstance.get()).thenReturn(gitHubResourceMappingStore); + producer.gitHubResourceMappingStore = gitHubResourceMappingStoreInstance; } @Test void return_mongo_store_when_database_mode_is_mongo() { - producer.databaseMode = "mongo"; + producer.databaseMode = DatabaseMode.MONGO; ResourceMappingStore result = producer.produceResourceMappingStore(); @@ -55,7 +65,7 @@ void return_mongo_store_when_database_mode_is_mongo() { @Test void return_nitrite_store_when_database_mode_is_standalone() { - producer.databaseMode = "standalone"; + producer.databaseMode = DatabaseMode.STANDALONE; ResourceMappingStore result = producer.produceResourceMappingStore(); @@ -70,4 +80,13 @@ void return_mongo_store_when_database_mode_is_not_recognized() { assertThat(result, is(sameInstance(mongoResourceMappingStore))); } + + @Test + void return_github_store_when_database_mode_is_github() { + producer.databaseMode = DatabaseMode.GITHUB; + + ResourceMappingStore result = producer.produceResourceMappingStore(); + + assertThat(result, is(sameInstance(gitHubResourceMappingStore))); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestSchemaVersionStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestSchemaVersionStoreProducerShould.java index f4e90c4b1e..56effa66c8 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestSchemaVersionStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestSchemaVersionStoreProducerShould.java @@ -1,9 +1,11 @@ package org.finos.calm.store.producer; import jakarta.enterprise.inject.Instance; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.SchemaVersionStore; import org.finos.calm.store.mongo.MongoSchemaVersionStore; import org.finos.calm.store.nitrite.NitriteSchemaVersionStore; +import org.finos.calm.store.noop.NoOpSchemaVersionStore; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -33,6 +35,12 @@ class TestSchemaVersionStoreProducerShould { @Mock Instance nitriteSchemaVersionStoreInstance; + @Mock + NoOpSchemaVersionStore noOpSchemaVersionStore; + + @Mock + Instance noOpSchemaVersionStoreInstance; + private SchemaVersionStoreProducer producer; @BeforeEach @@ -42,11 +50,13 @@ void setup() { producer.mongoSchemaVersionStore = mongoSchemaVersionStoreInstance; when(nitriteSchemaVersionStoreInstance.get()).thenReturn(nitriteSchemaVersionStore); producer.standaloneSchemaVersionStore = nitriteSchemaVersionStoreInstance; + when(noOpSchemaVersionStoreInstance.get()).thenReturn(noOpSchemaVersionStore); + producer.noOpSchemaVersionStore = noOpSchemaVersionStoreInstance; } @Test void return_mongo_schema_version_store_when_database_mode_is_mongo() { - producer.databaseMode = "mongo"; + producer.databaseMode = DatabaseMode.MONGO; SchemaVersionStore result = producer.produceSchemaVersionStore(); @@ -55,7 +65,7 @@ void return_mongo_schema_version_store_when_database_mode_is_mongo() { @Test void return_nitrite_schema_version_store_when_database_mode_is_standalone() { - producer.databaseMode = "standalone"; + producer.databaseMode = DatabaseMode.STANDALONE; SchemaVersionStore result = producer.produceSchemaVersionStore(); @@ -70,4 +80,13 @@ void return_mongo_schema_version_store_when_database_mode_is_not_recognized() { assertThat(result, is(sameInstance(mongoSchemaVersionStore))); } + + @Test + void return_noop_schema_version_store_when_database_mode_is_github() { + producer.databaseMode = DatabaseMode.GITHUB; + + SchemaVersionStore result = producer.produceSchemaVersionStore(); + + assertThat(result, is(sameInstance(noOpSchemaVersionStore))); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestSearchStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestSearchStoreProducerShould.java index 18ffbb9f24..5f882e33ab 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestSearchStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestSearchStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.SearchStore; +import org.finos.calm.store.github.GitHubSearchStore; import org.finos.calm.store.mongo.MongoSearchStore; import org.finos.calm.store.nitrite.NitriteSearchStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestSearchStoreProducerShould { @Mock Instance nitriteSearchStoreInstance; + @Mock + GitHubSearchStore gitHubSearchStore; + + @Mock + Instance gitHubSearchStoreInstance; + private SearchStoreProducer searchStoreProducer; @BeforeEach @@ -42,6 +50,8 @@ void setup() { searchStoreProducer.mongoSearchStore = mongoSearchStoreInstance; when(nitriteSearchStoreInstance.get()).thenReturn(nitriteSearchStore); searchStoreProducer.standaloneSearchStore = nitriteSearchStoreInstance; + when(gitHubSearchStoreInstance.get()).thenReturn(gitHubSearchStore); + searchStoreProducer.gitHubSearchStore = gitHubSearchStoreInstance; } @Test @@ -70,4 +80,13 @@ void return_mongo_search_store_when_database_mode_is_not_recognized() { assertThat(result, is(sameInstance(mongoSearchStore))); } + + @Test + void return_github_search_store_when_database_mode_is_github() { + searchStoreProducer.databaseMode = DatabaseMode.GITHUB; + + SearchStore result = searchStoreProducer.produceSearchStore(); + + assertThat(result, is(sameInstance(gitHubSearchStore))); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestStandardStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestStandardStoreProducerShould.java index 3d6db66fa9..4d65f30e87 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestStandardStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestStandardStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.StandardStore; +import org.finos.calm.store.github.GitHubStandardStore; import org.finos.calm.store.mongo.MongoStandardStore; import org.finos.calm.store.nitrite.NitriteStandardStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestStandardStoreProducerShould { @Mock Instance nitriteStandardStoreInstance; + @Mock + GitHubStandardStore gitHubStandardStore; + + @Mock + Instance gitHubStandardStoreInstance; + private StandardStoreProducer standardStoreProducer; @BeforeEach @@ -42,6 +50,8 @@ void setup() { standardStoreProducer.mongoStandardStore = mongoStandardStoreInstance; when(nitriteStandardStoreInstance.get()).thenReturn(nitriteStandardStore); standardStoreProducer.standaloneStandardStore = nitriteStandardStoreInstance; + when(gitHubStandardStoreInstance.get()).thenReturn(gitHubStandardStore); + standardStoreProducer.gitHubStandardStore = gitHubStandardStoreInstance; } @Test @@ -70,13 +80,19 @@ void return_nitrite_standard_store_when_database_mode_is_standalone() { @Test void return_mongo_standard_store_when_database_mode_is_not_recognized() { - // Given standardStoreProducer.databaseMode = "unknown"; - // When StandardStore result = standardStoreProducer.produceStandardStore(); - // Then assertThat(result, is(sameInstance(mongoStandardStore))); } + + @Test + void return_github_standard_store_when_database_mode_is_github() { + standardStoreProducer.databaseMode = DatabaseMode.GITHUB; + + StandardStore result = standardStoreProducer.produceStandardStore(); + + assertThat(result, is(sameInstance(gitHubStandardStore))); + } } \ No newline at end of file diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestTimelineStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestTimelineStoreProducerShould.java index 0f6836320b..620bdd3e7d 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestTimelineStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestTimelineStoreProducerShould.java @@ -1,7 +1,9 @@ package org.finos.calm.store.producer; import jakarta.enterprise.inject.Instance; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.TimelineStore; +import org.finos.calm.store.github.GitHubTimelineStore; import org.finos.calm.store.mongo.MongoTimelineStore; import org.finos.calm.store.nitrite.NitriteTimelineStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestTimelineStoreProducerShould { @Mock Instance nitriteTimelineStoreInstance; + + @Mock + GitHubTimelineStore gitHubTimelineStore; + + @Mock + Instance gitHubTimelineStoreInstance; private TimelineStoreProducer timelineStoreProducer; @BeforeEach @@ -42,6 +50,8 @@ void setup() { timelineStoreProducer.mongoTimelineStore = mongoTimelineStoreInstance; when(nitriteTimelineStoreInstance.get()).thenReturn(nitriteTimelineStore); timelineStoreProducer.standaloneTimelineStore = nitriteTimelineStoreInstance; + when(gitHubTimelineStoreInstance.get()).thenReturn(gitHubTimelineStore); + timelineStoreProducer.gitHubTimelineStore = gitHubTimelineStoreInstance; } @Test @@ -70,4 +80,13 @@ void return_mongo_timeline_store_when_database_mode_is_not_recognized() { assertThat(result, is(sameInstance(mongoTimelineStore))); } + + @Test + void return_github_timeline_store_when_database_mode_is_github() { + timelineStoreProducer.databaseMode = DatabaseMode.GITHUB; + + TimelineStore result = timelineStoreProducer.produceTimelineStore(); + + assertThat(result, is(sameInstance(gitHubTimelineStore))); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestUserAccessStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestUserAccessStoreProducerShould.java index f902957645..adeb8ce58b 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestUserAccessStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestUserAccessStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.UserAccessStore; +import org.finos.calm.store.github.GitHubUserAccessStore; import org.finos.calm.store.mongo.MongoUserAccessStore; import org.finos.calm.store.nitrite.NitriteUserAccessStore; import org.junit.jupiter.api.BeforeEach; @@ -35,22 +37,32 @@ public class TestUserAccessStoreProducerShould { @Mock private Instance mockNitriteUserAccessStoreInstance; + @Mock + private GitHubUserAccessStore mockGitHubUserAccessStore; + + @Mock + private Instance mockGitHubUserAccessStoreInstance; + private UserAccessStoreProducer producer; @BeforeEach public void setup() throws Exception { producer = new UserAccessStoreProducer(); - - // Use reflection to inject the mocked dependencies + Field mongoField = UserAccessStoreProducer.class.getDeclaredField("mongoUserAccessStore"); mongoField.setAccessible(true); when(mockMongoUserAccessStoreInstance.get()).thenReturn(mockMongoUserAccessStore); mongoField.set(producer, mockMongoUserAccessStoreInstance); - + Field nitriteField = UserAccessStoreProducer.class.getDeclaredField("standaloneUserAccessStore"); nitriteField.setAccessible(true); when(mockNitriteUserAccessStoreInstance.get()).thenReturn(mockNitriteUserAccessStore); nitriteField.set(producer, mockNitriteUserAccessStoreInstance); + + Field githubField = UserAccessStoreProducer.class.getDeclaredField("gitHubUserAccessStore"); + githubField.setAccessible(true); + when(mockGitHubUserAccessStoreInstance.get()).thenReturn(mockGitHubUserAccessStore); + githubField.set(producer, mockGitHubUserAccessStoreInstance); } @Test @@ -97,15 +109,23 @@ public void testProduceUserAccessStore_whenDatabaseModeIsNull_returnsMongoStore( @Test public void testProduceUserAccessStore_whenDatabaseModeIsUnknown_returnsMongoStore() throws Exception { - // Arrange Field databaseModeField = UserAccessStoreProducer.class.getDeclaredField("databaseMode"); databaseModeField.setAccessible(true); databaseModeField.set(producer, "unknown"); - // Act UserAccessStore result = producer.produceUserAccessStore(); - // Assert assertThat(result, is(sameInstance(mockMongoUserAccessStore))); } + + @Test + public void testProduceUserAccessStore_whenDatabaseModeIsGithub_returnsGitHubStore() throws Exception { + Field databaseModeField = UserAccessStoreProducer.class.getDeclaredField("databaseMode"); + databaseModeField.setAccessible(true); + databaseModeField.set(producer, DatabaseMode.GITHUB); + + UserAccessStore result = producer.produceUserAccessStore(); + + assertThat(result, is(sameInstance(mockGitHubUserAccessStore))); + } } diff --git a/calm-hub/src/test/resources/META-INF/calm-schemas/1.0/files.txt b/calm-hub/src/test/resources/META-INF/calm-schemas/1.0/files.txt new file mode 100644 index 0000000000..b7e1bdf50b --- /dev/null +++ b/calm-hub/src/test/resources/META-INF/calm-schemas/1.0/files.txt @@ -0,0 +1 @@ +core.json diff --git a/calm-hub/src/test/resources/META-INF/calm-schemas/1.0/meta/core.json b/calm-hub/src/test/resources/META-INF/calm-schemas/1.0/meta/core.json new file mode 100644 index 0000000000..cef198843e --- /dev/null +++ b/calm-hub/src/test/resources/META-INF/calm-schemas/1.0/meta/core.json @@ -0,0 +1 @@ +{"$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://calm.finos.org/release/1.0/meta/core.json", "title": "CALM Core Schema"} diff --git a/calm-hub/src/test/resources/META-INF/calm-schemas/versions.txt b/calm-hub/src/test/resources/META-INF/calm-schemas/versions.txt new file mode 100644 index 0000000000..d3827e75a5 --- /dev/null +++ b/calm-hub/src/test/resources/META-INF/calm-schemas/versions.txt @@ -0,0 +1 @@ +1.0 From 6b73d0f38da2669c64be2dbe1e94d28bee7eeddd Mon Sep 17 00:00:00 2001 From: James Gough Date: Wed, 9 Sep 2026 09:27:23 +0100 Subject: [PATCH 02/47] fix(calm-hub): adapt GitHubVersionService to slice 3's cache API and fix a latent GitHubMetrics CDI break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GitHubVersionService.getFileVersions now calls cache.getList(key, String.class) instead of cache.get(key, List.class) plus an unchecked cast — using the typed accessor slice 3's review added specifically so this call site wouldn't need one. - GitHubMetrics injected MeterRegistry directly. quarkus.micrometer.enabled is build-time-fixed and disabled under every custom test profile in this module (see application.properties), so no MeterRegistry bean exists during a plain unit test build — this was a real, latent ArC validation failure, just never exercised before now because slice 5 had never been rebased onto a slice 2 base with that test-profile override in place. Switched to Instance, which defers resolution to runtime instead of failing CDI's build-time validation, falling back to an unregistered SimpleMeterRegistry so the bean still works under those profiles, it just doesn't export anywhere. --- .../calm/observability/GitHubMetrics.java | 15 ++++++++++++++- .../github/util/GitHubVersionService.java | 6 ++---- .../observability/TestGitHubMetricsShould.java | 18 ++++++++++++++++++ .../util/TestGitHubVersionServiceShould.java | 6 +++--- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java b/calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java index d95fcbcfa0..247e5bf9f5 100644 --- a/calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java +++ b/calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java @@ -3,7 +3,9 @@ import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import java.time.Duration; @@ -18,8 +20,19 @@ public class GitHubMetrics { private final Timer registryRebuildTimer; private final AtomicLong lastSyncSuccessEpoch = new AtomicLong(0); + // quarkus.micrometer.enabled is build-time-fixed and disabled under every custom + // test profile in this module (see application.properties), so no MeterRegistry + // bean exists while running plain unit tests. Instance — unlike injecting + // MeterRegistry directly — defers resolution to runtime instead of failing CDI's + // build-time validation, and falls back to an unregistered in-memory registry so + // this bean still works there, it just doesn't export anything. @Inject - public GitHubMetrics(MeterRegistry registry) { + public GitHubMetrics(Instance registryInstance) { + this(registryInstance.isResolvable() ? registryInstance.get() : new SimpleMeterRegistry()); + } + + // Package-private: lets tests supply a MeterRegistry directly. + GitHubMetrics(MeterRegistry registry) { this.syncSuccessCounter = Counter.builder("calm.github.sync") .tag("outcome", "success") .description("Number of successful GitHub sync operations") diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java index 7f55a62d72..7d2a462451 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java @@ -61,11 +61,9 @@ public class GitHubVersionService { public List getFileVersions(String repoFullName, String filePath) { String cacheKey = "versions:" + repoFullName + ":" + filePath; - Optional cached = cache.get(cacheKey, List.class); + Optional> cached = cache.getList(cacheKey, String.class); if (cached.isPresent()) { - @SuppressWarnings("unchecked") - List result = cached.get(); - return result; + return cached.get(); } try { diff --git a/calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java b/calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java index 1d3ec5e51e..ad15ee25a6 100644 --- a/calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java +++ b/calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java @@ -2,8 +2,10 @@ import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import jakarta.enterprise.inject.Instance; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,6 +17,8 @@ import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class TestGitHubMetricsShould { @@ -95,4 +99,18 @@ void create_content_detected_counter() { assertThat(found, is(notNullValue())); assertThat(found.count(), equalTo(1.0)); } + + @Test + @SuppressWarnings("unchecked") + void fall_back_to_an_in_memory_registry_when_no_meter_registry_bean_is_available() { + // Simulates the CDI state under this module's test profiles, where + // quarkus.micrometer.enabled=false means no MeterRegistry bean exists. + Instance unresolvable = mock(Instance.class); + when(unresolvable.isResolvable()).thenReturn(false); + + GitHubMetrics fallbackMetrics = new GitHubMetrics(unresolvable); + fallbackMetrics.recordSyncSuccess(Duration.ofMillis(100)); + // No exception means the fallback SimpleMeterRegistry accepted the write; + // there is no shared registry here to assert against. + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java index 5220b70969..d9f656324b 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java @@ -47,7 +47,7 @@ void setup() { @Test void return_cached_versions_when_available() { List cached = List.of("abc1234", "def5678"); - when(cache.get("versions:org/repo:path/file.json", List.class)).thenReturn(Optional.of(cached)); + when(cache.getList("versions:org/repo:path/file.json", String.class)).thenReturn(Optional.of(cached)); List result = service.getFileVersions("org/repo", "path/file.json"); @@ -56,7 +56,7 @@ void return_cached_versions_when_available() { @Test void return_latest_when_api_fails() { - when(cache.get(any(), eq(List.class))).thenReturn(Optional.empty()); + when(cache.getList(any(), eq(String.class))).thenReturn(Optional.empty()); // API will fail since we're not running a real server List result = service.getFileVersions("org/repo", "path/file.json"); @@ -68,7 +68,7 @@ void return_latest_when_api_fails() { @Test void return_latest_when_no_token() { service.serviceToken = Optional.empty(); - when(cache.get(any(), eq(List.class))).thenReturn(Optional.empty()); + when(cache.getList(any(), eq(String.class))).thenReturn(Optional.empty()); List result = service.getFileVersions("org/repo", "path/file.json"); From 191d13fe296418310f6d21898f684d34920d2930 Mon Sep 17 00:00:00 2001 From: James Gough Date: Wed, 9 Sep 2026 11:57:28 +0100 Subject: [PATCH 03/47] fix(calm-hub): adapt GitHubVersionService to the rescoped GitHubApiResponseCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3's cache moved from the generic CalmCacheService to GitHubApiResponseCache (org.finos.calm.store.github.util), with a purpose-built API — getVersions/putVersions, getContentAtSha/putContentAtSha — instead of generic get/put/getList. - Field type change, same package now so the import goes away. - Call sites updated: no more hand-built cache keys or inline TTL Durations at the call site, both now live inside the cache class. - Test updated: mock type, stub calls; dropped the now-unused eq/ verify/Duration/not imports along the way. --- .../github/util/GitHubVersionService.java | 18 ++++++++---------- .../util/TestGitHubVersionServiceShould.java | 13 ++++--------- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java index 7d2a462451..7c7968f152 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java @@ -6,7 +6,6 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.finos.calm.cache.CalmCacheService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,8 +24,9 @@ import java.util.regex.Pattern; /** - * Fetches file version history (commit SHAs) from the GitHub REST API. - * Results are cached — version lists for 5 min, content at SHA indefinitely (immutable). + * Fetches file version history (commit SHAs) and file content from the GitHub REST + * API. Results are cached via {@link GitHubApiResponseCache} — see that class for the + * caching contract and its cross-instance staleness scope. */ @LookupIfProperty(name = "calm.database.mode", stringValue = "github") @ApplicationScoped @@ -37,7 +37,7 @@ public class GitHubVersionService { private static final Pattern LINK_NEXT_PATTERN = Pattern.compile("<([^>]+)>;\\s*rel=\"next\""); @Inject - CalmCacheService cache; + GitHubApiResponseCache cache; @Inject @ConfigProperty(name = "calm.github.api-url", defaultValue = "https://api.github.com") @@ -60,8 +60,7 @@ public class GitHubVersionService { int maxVersions; public List getFileVersions(String repoFullName, String filePath) { - String cacheKey = "versions:" + repoFullName + ":" + filePath; - Optional> cached = cache.getList(cacheKey, String.class); + Optional> cached = cache.getVersions(repoFullName, filePath); if (cached.isPresent()) { return cached.get(); } @@ -94,7 +93,7 @@ public List getFileVersions(String repoFullName, String filePath) { } List chronological = new ArrayList<>(allShas); Collections.reverse(chronological); - cache.put(cacheKey, chronological, Duration.ofMinutes(5)); + cache.putVersions(repoFullName, filePath, chronological); return chronological; } catch (Exception e) { LOG.warn("Failed to fetch versions for {}/{}: {}", repoFullName, filePath, e.getMessage()); @@ -103,8 +102,7 @@ public List getFileVersions(String repoFullName, String filePath) { } public String getFileAtVersion(String repoFullName, String filePath, String sha) { - String cacheKey = "content:" + repoFullName + ":" + filePath + ":" + sha; - Optional cached = cache.get(cacheKey, String.class); + Optional cached = cache.getContentAtSha(repoFullName, filePath, sha); if (cached.isPresent()) { return cached.get(); } @@ -125,7 +123,7 @@ public String getFileAtVersion(String repoFullName, String filePath, String sha) } String content = response.body(); - cache.put(cacheKey, content, Duration.ofDays(365)); + cache.putContentAtSha(repoFullName, filePath, sha, content); return content; } catch (Exception e) { LOG.warn("Failed to fetch content at SHA {} for {}/{}: {}", sha, repoFullName, filePath, e.getMessage()); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java index d9f656324b..7e00ccaaee 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java @@ -1,6 +1,5 @@ package org.finos.calm.store.github.util; -import org.finos.calm.cache.CalmCacheService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -9,7 +8,6 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; -import java.time.Duration; import java.util.List; import java.util.Optional; @@ -17,19 +15,16 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.empty; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; -import static org.mockito.Mockito.verify; @MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubVersionServiceShould { @Mock - private CalmCacheService cache; + private GitHubApiResponseCache cache; private GitHubVersionService service; @@ -47,7 +42,7 @@ void setup() { @Test void return_cached_versions_when_available() { List cached = List.of("abc1234", "def5678"); - when(cache.getList("versions:org/repo:path/file.json", String.class)).thenReturn(Optional.of(cached)); + when(cache.getVersions("org/repo", "path/file.json")).thenReturn(Optional.of(cached)); List result = service.getFileVersions("org/repo", "path/file.json"); @@ -56,7 +51,7 @@ void return_cached_versions_when_available() { @Test void return_latest_when_api_fails() { - when(cache.getList(any(), eq(String.class))).thenReturn(Optional.empty()); + when(cache.getVersions(any(), any())).thenReturn(Optional.empty()); // API will fail since we're not running a real server List result = service.getFileVersions("org/repo", "path/file.json"); @@ -68,7 +63,7 @@ void return_latest_when_api_fails() { @Test void return_latest_when_no_token() { service.serviceToken = Optional.empty(); - when(cache.getList(any(), eq(String.class))).thenReturn(Optional.empty()); + when(cache.getVersions(any(), any())).thenReturn(Optional.empty()); List result = service.getFileVersions("org/repo", "path/file.json"); From 5c9a3ee8dde7d9b4148e381f6033cabe6024064c Mon Sep 17 00:00:00 2001 From: James Gough Date: Wed, 9 Sep 2026 14:59:49 +0100 Subject: [PATCH 04/47] fix(calm-hub): restore OidcRoleResolver removed as unused by slice 4's review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4's security review correctly found OidcRoleResolver unused in that slice's own diff and removed it as dead code — but this branch's GitHubUserAccessStore and NamespaceAccessFilter are its actual consumers, for group-based access control on the GitHub backend. The finding held for slice 4 and for main; it didn't hold for the stack. Restored here rather than reverting the removal on slice 4: this is where the class is actually used. Also restores the two OIDC properties it reads (quarkus.oidc.roles.role-claim-path, calm.oidc.roles.access) in application-oidc.properties, which slice 4's review also dropped as dead config for the same reason. mvn clean verify (unit+integration) green, including TestOidcRoleResolverShould, TestGitHubUserAccessStoreShould, and TestNamespaceAccessFilterShould. --- .../finos/calm/security/OidcRoleResolver.java | 76 ++++++++++++ .../resources/application-oidc.properties | 9 ++ .../security/TestOidcRoleResolverShould.java | 112 ++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 calm-hub/src/main/java/org/finos/calm/security/OidcRoleResolver.java create mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestOidcRoleResolverShould.java diff --git a/calm-hub/src/main/java/org/finos/calm/security/OidcRoleResolver.java b/calm-hub/src/main/java/org/finos/calm/security/OidcRoleResolver.java new file mode 100644 index 0000000000..2946916251 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/security/OidcRoleResolver.java @@ -0,0 +1,76 @@ +package org.finos.calm.security; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Resolves user access by checking SecurityIdentity roles (populated from the + * token's "groups" claim via {@code quarkus.oidc.roles.role-claim-path=groups}) + * against the required access groups for a namespace. + */ +@ApplicationScoped +public class OidcRoleResolver { + + private static final Logger LOG = LoggerFactory.getLogger(OidcRoleResolver.class); + + @Inject + @ConfigProperty(name = "calm.oidc.roles.access", defaultValue = "") + Optional globalAccessGroups; + + public enum AccessLevel { READ, NONE } + + /** + * Checks if the user belongs to any of the specified access groups. + * Groups come from the token's "groups" claim mapped to SecurityIdentity roles. + */ + public AccessLevel resolve(SecurityIdentity identity, Set accessGroups) { + if (identity == null || identity.isAnonymous()) { + return AccessLevel.NONE; + } + + Set requiredGroups = accessGroups.isEmpty() ? parseConfig(globalAccessGroups) : accessGroups; + + if (requiredGroups.isEmpty()) { + LOG.warn("No access groups configured. No access will be granted."); + return AccessLevel.NONE; + } + + Set userGroups = identity.getRoles(); + + if (userGroups == null || userGroups.isEmpty()) { + LOG.debug("User [{}] has no groups in token. Ensure the IdP emits a 'groups' claim.", + identity.getPrincipal().getName()); + return AccessLevel.NONE; + } + + for (String group : userGroups) { + if (requiredGroups.contains(group)) { + LOG.debug("User [{}] matched access group [{}]", identity.getPrincipal().getName(), group); + return AccessLevel.READ; + } + } + + LOG.debug("User [{}] has no matching access groups. User groups: {}, required: {}", + identity.getPrincipal().getName(), userGroups, requiredGroups); + return AccessLevel.NONE; + } + + private Set parseConfig(Optional config) { + if (config.isEmpty() || config.get().isBlank()) { + return Set.of(); + } + return Stream.of(config.get().split(";")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + } +} diff --git a/calm-hub/src/main/resources/application-oidc.properties b/calm-hub/src/main/resources/application-oidc.properties index 7638a1bbb0..b67a1c6d7d 100644 --- a/calm-hub/src/main/resources/application-oidc.properties +++ b/calm-hub/src/main/resources/application-oidc.properties @@ -17,6 +17,15 @@ quarkus.oidc.token.issuer=${CALM_OIDC_ISSUER_URL} calm.oidc.provider=${CALM_OIDC_PROVIDER:generic-oidc} calm.oidc.scopes=${CALM_OIDC_SCOPES:openid profile email} +# Map the "groups" claim from the ID token to SecurityIdentity roles — consumed by +# org.finos.calm.security.OidcRoleResolver, which GitHubUserAccessStore and +# NamespaceAccessFilter use for group-based access control on the GitHub backend. +quarkus.oidc.roles.role-claim-path=groups + +# Global fallback: semicolon-separated groups that grant access (used when namespace +# config has no groups). Also consumed by OidcRoleResolver. +calm.oidc.roles.access=${CALM_OIDC_ROLES_ACCESS:} + # GitHub storage backend properties (calm.github.*) belong to the "github" profile's # own properties, not here — this file previously carried a stray copy of them. diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestOidcRoleResolverShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestOidcRoleResolverShould.java new file mode 100644 index 0000000000..4657e2e04d --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/security/TestOidcRoleResolverShould.java @@ -0,0 +1,112 @@ +package org.finos.calm.security; + +import io.quarkus.security.identity.SecurityIdentity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.security.Principal; +import java.util.Optional; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestOidcRoleResolverShould { + + @Mock + private SecurityIdentity mockIdentity; + + @Mock + private Principal mockPrincipal; + + private OidcRoleResolver resolver; + + @BeforeEach + void setup() { + resolver = new OidcRoleResolver(); + resolver.globalAccessGroups = Optional.empty(); + } + + @Test + void return_none_for_null_identity() { + assertThat(resolver.resolve(null, Set.of("group-a")), equalTo(OidcRoleResolver.AccessLevel.NONE)); + } + + @Test + void return_none_for_anonymous_identity() { + when(mockIdentity.isAnonymous()).thenReturn(true); + assertThat(resolver.resolve(mockIdentity, Set.of("group-a")), equalTo(OidcRoleResolver.AccessLevel.NONE)); + } + + @Test + void return_none_when_no_access_groups_configured() { + when(mockIdentity.isAnonymous()).thenReturn(false); + resolver.globalAccessGroups = Optional.empty(); + + assertThat(resolver.resolve(mockIdentity, Set.of()), equalTo(OidcRoleResolver.AccessLevel.NONE)); + } + + @Test + void return_none_when_user_has_no_groups() { + when(mockIdentity.isAnonymous()).thenReturn(false); + when(mockIdentity.getRoles()).thenReturn(Set.of()); + when(mockIdentity.getPrincipal()).thenReturn(mockPrincipal); + when(mockPrincipal.getName()).thenReturn("alice"); + + assertThat(resolver.resolve(mockIdentity, Set.of("group-a")), equalTo(OidcRoleResolver.AccessLevel.NONE)); + } + + @Test + void return_none_when_user_has_null_groups() { + when(mockIdentity.isAnonymous()).thenReturn(false); + when(mockIdentity.getRoles()).thenReturn(null); + when(mockIdentity.getPrincipal()).thenReturn(mockPrincipal); + when(mockPrincipal.getName()).thenReturn("alice"); + + assertThat(resolver.resolve(mockIdentity, Set.of("group-a")), equalTo(OidcRoleResolver.AccessLevel.NONE)); + } + + @Test + void return_read_when_user_group_matches() { + when(mockIdentity.isAnonymous()).thenReturn(false); + when(mockIdentity.getRoles()).thenReturn(Set.of("group-a", "group-b")); + when(mockIdentity.getPrincipal()).thenReturn(mockPrincipal); + when(mockPrincipal.getName()).thenReturn("alice"); + + assertThat(resolver.resolve(mockIdentity, Set.of("group-b")), equalTo(OidcRoleResolver.AccessLevel.READ)); + } + + @Test + void return_none_when_user_group_does_not_match() { + when(mockIdentity.isAnonymous()).thenReturn(false); + when(mockIdentity.getRoles()).thenReturn(Set.of("group-x")); + when(mockIdentity.getPrincipal()).thenReturn(mockPrincipal); + when(mockPrincipal.getName()).thenReturn("alice"); + + assertThat(resolver.resolve(mockIdentity, Set.of("group-a")), equalTo(OidcRoleResolver.AccessLevel.NONE)); + } + + @Test + void fall_back_to_global_config_when_access_groups_empty() { + when(mockIdentity.isAnonymous()).thenReturn(false); + when(mockIdentity.getRoles()).thenReturn(Set.of("global-group")); + when(mockIdentity.getPrincipal()).thenReturn(mockPrincipal); + when(mockPrincipal.getName()).thenReturn("alice"); + resolver.globalAccessGroups = Optional.of("global-group;other-group"); + + assertThat(resolver.resolve(mockIdentity, Set.of()), equalTo(OidcRoleResolver.AccessLevel.READ)); + } + + @Test + void parse_blank_global_config_as_empty() { + when(mockIdentity.isAnonymous()).thenReturn(false); + resolver.globalAccessGroups = Optional.of(" "); + + assertThat(resolver.resolve(mockIdentity, Set.of()), equalTo(OidcRoleResolver.AccessLevel.NONE)); + } +} From d222dcd1c663ff181acbdb98b5830e950b7264f3 Mon Sep 17 00:00:00 2001 From: James Gough Date: Wed, 9 Sep 2026 19:17:58 +0100 Subject: [PATCH 05/47] fix(calm-hub): security and correctness fixes on the GitHub storage backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Add GitHubFileReader, a centralised containment-checked reader used by every GitHub-backed store's local-clone read path. Repo content is untrusted, and JGit checks out tracked symlinks by default: without this, a symlink committed to a synced repo (e.g. standards/leak.md -> /proc/self/environ, where calm.github.service-token lives) would be followed and served verbatim through the public read API. The check is real-path containment against the SPECIFIC namespace's own clone directory, not just the overall clone-directory root — a root-level-only check would still let a symlink into a sibling namespace's clone pass, defeating that namespace's own accessGroups restriction. - Harden InMemoryRegistryService's scan filter with NOFOLLOW_LINKS as a second, independent layer of defense (the read-time check above is the one that actually matters, since the registry only rebuilds once per calm.github.sync-interval). - GitHubStartupInitializer.onStart()/GitHubSyncScheduler.sync() now check calm.database.mode themselves at runtime, mirroring StandaloneDemoSeeder's identical guard. @LookupIfProperty only gates @Inject/Instance resolution — it does not stop an @Observes StartupEvent or @Scheduled method from firing once the bean exists, so without this guard both ran (and would clone repos and spend the service token) in every calm.database.mode, not just "github". Also set concurrentExecution=SKIP on the scheduler: a slow pullAll() could otherwise overlap the next tick and reset --hard a directory a request thread is mid-read on. Correctness: - MappingControllerService's broad catch(Exception) in both the create and update paths was swallowing GitHubWriteNotSupportedException into a 400 or a bodyless 500 before it could reach UnsupportedOperationExceptionMapper — writes to a GitHub-backed resource via the mapping controller now correctly 501 instead. - InterfaceResource/TimelineResource's GET-by-version endpoints still validated against the semver-only VERSION_REGEX, unlike the other six resource types this slice updated to VERSION_OR_SHA_REGEX — GitHubInterface/ TimelineStore's own getXVersions() methods return real commit SHAs, so those SHAs were rejected by validation before ever reaching the store. Fixed both to VERSION_OR_SHA_REGEX, write endpoints correctly left alone. - Four of the six resources already using VERSION_OR_SHA_REGEX (Architecture, Pattern, Standard, Flow) paired it with the stale VERSION_MESSAGE, so a rejected SHA-shaped version got a "must be semver" error message even though SHAs are now accepted. Switched all to VERSION_OR_SHA_MESSAGE for consistency with Control/MappingController, which already had it right. - Removed dead code found while touching these files: an empty conditional block (with an unused `repo` local) duplicated across five stores, and an unused `repo` local in a sixth. - Fixed two bot-flagged unused parameters (InMemoryRegistryService's parseMarkdownFile `root`, and the store constructor cleanups above). --- .../calm/resources/ArchitectureResource.java | 3 +- .../finos/calm/resources/FlowResource.java | 3 +- .../calm/resources/InterfaceResource.java | 2 +- .../finos/calm/resources/PatternResource.java | 3 +- .../calm/resources/StandardResource.java | 2 +- .../calm/resources/TimelineResource.java | 2 +- .../services/MappingControllerService.java | 8 ++ .../store/github/GitHubArchitectureStore.java | 7 +- .../github/GitHubBuildingBlockStore.java | 14 +-- .../calm/store/github/GitHubFlowStore.java | 14 +-- .../store/github/GitHubInterfaceStore.java | 14 +-- .../calm/store/github/GitHubPatternStore.java | 14 +-- .../store/github/GitHubStandardStore.java | 24 ++-- .../store/github/GitHubTimelineStore.java | 6 +- .../store/github/util/GitHubFileReader.java | 81 ++++++++++++ .../github/util/GitHubStartupInitializer.java | 19 ++- .../github/util/GitHubSyncScheduler.java | 16 ++- .../github/util/InMemoryRegistryService.java | 12 +- .../TestArchitectureResourceShould.java | 3 +- .../resources/TestFlowResourceShould.java | 3 +- .../TestInterfaceResourceShould.java | 3 +- .../resources/TestPatternResourceShould.java | 3 +- .../resources/TestStandardResourceShould.java | 3 +- .../resources/TestTimelineResourceShould.java | 3 +- .../util/TestGitHubFileReaderShould.java | 119 ++++++++++++++++++ .../TestGitHubStartupInitializerShould.java | 16 +++ .../util/TestGitHubSyncSchedulerShould.java | 14 +++ 27 files changed, 325 insertions(+), 86 deletions(-) create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubFileReader.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubFileReaderShould.java diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java b/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java index 8c0e8bc7d1..3e763e04b1 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java @@ -44,6 +44,7 @@ import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX; import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_REGEX; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX; @@ -175,7 +176,7 @@ public Response getArchitectureVersions( public Response getArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, - @PathParam("version") @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_MESSAGE) String version) { + @PathParam("version") @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_OR_SHA_MESSAGE) String version) { Architecture architecture = new Architecture.ArchitectureBuilder() .setNamespace(namespace) .setId(architectureId) diff --git a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java index 586d3d087b..1facdc6150 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java @@ -35,6 +35,7 @@ import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX; import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_REGEX; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX; @@ -173,7 +174,7 @@ public Response getFlowVersions( public Response getFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId, - @PathParam("version") @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_MESSAGE) String version + @PathParam("version") @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_OR_SHA_MESSAGE) String version ) { return getFlowInternal(namespace, flowId, version); } diff --git a/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java b/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java index b4871d117e..60d170ee21 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java @@ -110,7 +110,7 @@ public Response getInterfaceVersions( public Response getInterfaceForVersion( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("interfaceId") Integer interfaceId, - @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version + @PathParam("version") @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_OR_SHA_MESSAGE) String version ) { try { return Response.ok(interfaceStore.getInterfaceForVersion(namespace, interfaceId, version)).build(); diff --git a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java index 6fea478c68..cc2c0d85df 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java @@ -33,6 +33,7 @@ import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX; import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_REGEX; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX; @@ -143,7 +144,7 @@ public Response getPatternVersions( public Response getPattern( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId, - @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_MESSAGE) String version + @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_OR_SHA_MESSAGE) String version ) { Pattern pattern = new Pattern.PatternBuilder() .setNamespace(namespace) diff --git a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java index f9eb54ecf9..9341fb9b4d 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java @@ -102,7 +102,7 @@ public Response getStandardVersions( public Response getStandardForVersion( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("standardId") Integer standardId, - @PathParam("version") @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_MESSAGE) String version + @PathParam("version") @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_OR_SHA_MESSAGE) String version ) { try { return Response.ok(standardStore.getStandardForVersion(namespace, standardId, version)).build(); diff --git a/calm-hub/src/main/java/org/finos/calm/resources/TimelineResource.java b/calm-hub/src/main/java/org/finos/calm/resources/TimelineResource.java index e79faf25f8..f581aa2693 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/TimelineResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/TimelineResource.java @@ -131,7 +131,7 @@ public Response getTimelineVersions( public Response getTimeline( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("timelineId") int timelineId, - @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version + @PathParam("version") @Pattern(regexp = VERSION_OR_SHA_REGEX, message = VERSION_OR_SHA_MESSAGE) String version ) { Timeline timeline = new Timeline.TimelineBuilder() .setNamespace(namespace) diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java index 8353992a1c..b81cc52f87 100644 --- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java +++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java @@ -118,6 +118,10 @@ public Response updateVersionedResource(ResourceType resourceType, String namesp logger.error("Invalid namespace [{}] when updating resource via PUT", STRICT_SANITIZATION_POLICY.sanitize(namespace), e); return CalmResourceErrorResponses.invalidNamespaceResponse(namespace); + } catch (GitHubWriteNotSupportedException e) { + // Rethrow rather than let the broad catch below swallow it into a bodyless + // 500 - UnsupportedOperationExceptionMapper turns this into the intended 501. + throw e; } catch (Exception e) { logger.error("Error updating resource [{}] in namespace [{}] via PUT", STRICT_SANITIZATION_POLICY.sanitize(name), @@ -469,6 +473,10 @@ private Response createNewResource(String namespace, ResourceType resourceType, return CalmResourceErrorResponses.invalidNamespaceResponse(namespace); } catch (DuplicateMappingException e) { return CalmResourceErrorResponses.resourceAlreadyExistsResponse(resourceType, name, namespace); + } catch (GitHubWriteNotSupportedException e) { + // Rethrow rather than let the broad catch below swallow it into a 400 - + // UnsupportedOperationExceptionMapper turns this into the intended 501. + throw e; } catch (Exception e) { logger.error("Error creating resource [{}] in namespace [{}]", STRICT_SANITIZATION_POLICY.sanitize(name), STRICT_SANITIZATION_POLICY.sanitize(namespace), e); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java index 20ec5dd88a..74d6ce45ae 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java @@ -15,6 +15,7 @@ import org.finos.calm.store.PageRequest; import org.finos.calm.store.github.util.CalmResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.util.InMemoryRegistryService; import org.finos.calm.store.github.util.RegistryEntry; @@ -22,8 +23,6 @@ import org.slf4j.LoggerFactory; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; import java.util.Optional; @@ -57,7 +56,6 @@ public GitHubArchitectureStore(InMemoryRegistryService registryService) { public List getArchitecturesForNamespace(String namespace, PageRequest page) throws NamespaceNotFoundException { verifyNamespace(namespace); List entries = registryService.listByType(namespace, CalmResourceType.ARCHITECTURE); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; return entries.stream() .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); @@ -99,8 +97,7 @@ public String getArchitectureForVersion(Architecture architecture) throws Namesp // Fallback: read from local clone (latest/HEAD) try { - Path filePath = Path.of(cloneDirectory, architecture.getNamespace()).resolve(entry.filePath()); - return Files.readString(filePath); + return GitHubFileReader.readContained(cloneDirectory, architecture.getNamespace(), entry.filePath()); } catch (IOException e) { LOG.error("Failed to read architecture file: {}", entry.filePath(), e); throw new ArchitectureVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java index d4c8872e59..2489c3370a 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java @@ -12,6 +12,7 @@ import org.finos.calm.store.BuildingBlockStore; import org.finos.calm.store.github.util.CalmResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.util.InMemoryRegistryService; import org.finos.calm.store.github.util.RegistryEntry; @@ -19,8 +20,6 @@ import org.slf4j.LoggerFactory; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; import java.util.Optional; @@ -54,14 +53,8 @@ public GitHubBuildingBlockStore(InMemoryRegistryService registryService) { public List getBuildingBlocksForNamespace(String namespace) throws NamespaceNotFoundException { verifyNamespace(namespace); List entries = registryService.listByType(namespace, CalmResourceType.BUILDING_BLOCK); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; return entries.stream() - .map(e -> { - - if (repo != null && versionService != null) { - } - return new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0); - }) + .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); } @@ -100,8 +93,7 @@ public String getBuildingBlockForVersion(String namespace, int buildingBlockId, // Fallback: read from local clone (latest/HEAD) try { - Path filePath = Path.of(cloneDirectory, namespace).resolve(entry.filePath()); - return Files.readString(filePath); + return GitHubFileReader.readContained(cloneDirectory, namespace, entry.filePath()); } catch (IOException e) { LOG.error("Failed to read building block file: {}", entry.filePath(), e); throw new BuildingBlockVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java index b896fa8984..644e7784cb 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java @@ -16,6 +16,7 @@ import org.finos.calm.store.FlowStore; import org.finos.calm.store.github.util.CalmResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.util.InMemoryRegistryService; import org.finos.calm.store.github.util.RegistryEntry; @@ -23,8 +24,6 @@ import org.slf4j.LoggerFactory; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; import java.util.Optional; @@ -58,14 +57,8 @@ public GitHubFlowStore(InMemoryRegistryService registryService) { public List getFlowsForNamespace(String namespace) throws NamespaceNotFoundException { verifyNamespace(namespace); List entries = registryService.listByType(namespace, CalmResourceType.FLOW); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; return entries.stream() - .map(e -> { - - if (repo != null && versionService != null) { - } - return new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0); - }) + .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); } @@ -105,8 +98,7 @@ public String getFlowForVersion(Flow flow) throws NamespaceNotFoundException, Fl // Fallback: read from local clone (latest/HEAD) try { - Path filePath = Path.of(cloneDirectory, flow.getNamespace()).resolve(entry.filePath()); - return Files.readString(filePath); + return GitHubFileReader.readContained(cloneDirectory, flow.getNamespace(), entry.filePath()); } catch (IOException e) { LOG.error("Failed to read flow file: {}", entry.filePath(), e); throw new FlowVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java index a0c9857557..3dfa8f6147 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java @@ -16,6 +16,7 @@ import org.finos.calm.store.InterfaceStore; import org.finos.calm.store.github.util.CalmResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.util.InMemoryRegistryService; import org.finos.calm.store.github.util.RegistryEntry; @@ -23,8 +24,6 @@ import org.slf4j.LoggerFactory; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; import java.util.Optional; @@ -58,14 +57,8 @@ public GitHubInterfaceStore(InMemoryRegistryService registryService) { public List getInterfacesForNamespace(String namespace) throws NamespaceNotFoundException { verifyNamespace(namespace); List entries = registryService.listByType(namespace, CalmResourceType.INTERFACE); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; return entries.stream() - .map(e -> { - - if (repo != null && versionService != null) { - } - return new NamespaceInterfaceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF)); - }) + .map(e -> new NamespaceInterfaceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF))) .toList(); } @@ -104,8 +97,7 @@ public String getInterfaceForVersion(String namespace, Integer interfaceId, Stri // Fallback: read from local clone (latest/HEAD) try { - Path filePath = Path.of(cloneDirectory, namespace).resolve(entry.filePath()); - return Files.readString(filePath); + return GitHubFileReader.readContained(cloneDirectory, namespace, entry.filePath()); } catch (IOException e) { LOG.error("Failed to read interface file: {}", entry.filePath(), e); throw new InterfaceVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java index fb9ec1901e..2d2f1c9b1b 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java @@ -18,6 +18,7 @@ import org.finos.calm.store.PatternStore; import org.finos.calm.store.github.util.CalmResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.util.InMemoryRegistryService; import org.finos.calm.store.github.util.RegistryEntry; @@ -25,8 +26,6 @@ import org.slf4j.LoggerFactory; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; import java.util.Optional; @@ -60,14 +59,8 @@ public GitHubPatternStore(InMemoryRegistryService registryService) { public List getPatternsForNamespace(String namespace, PageRequest page) throws NamespaceNotFoundException { verifyNamespace(namespace); List entries = registryService.listByType(namespace, CalmResourceType.PATTERN); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; return entries.stream() - .map(e -> { - - if (repo != null && versionService != null) { - } - return new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0); - }) + .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); } @@ -107,8 +100,7 @@ public String getPatternForVersion(Pattern pattern) throws NamespaceNotFoundExce // Fallback: read from local clone (latest/HEAD) try { - Path filePath = Path.of(cloneDirectory, pattern.getNamespace()).resolve(entry.filePath()); - return Files.readString(filePath); + return GitHubFileReader.readContained(cloneDirectory, pattern.getNamespace(), entry.filePath()); } catch (IOException e) { LOG.error("Failed to read pattern file: {}", entry.filePath(), e); throw new PatternVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java index aa5bec2498..7769b7251d 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java @@ -16,6 +16,7 @@ import org.finos.calm.store.StandardStore; import org.finos.calm.store.github.util.CalmResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.util.InMemoryRegistryService; import org.finos.calm.store.github.util.RegistryEntry; @@ -23,7 +24,6 @@ import org.slf4j.LoggerFactory; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Optional; @@ -58,14 +58,8 @@ public GitHubStandardStore(InMemoryRegistryService registryService) { public List getStandardsForNamespace(String namespace) throws NamespaceNotFoundException { verifyNamespace(namespace); List entries = registryService.listByType(namespace, CalmResourceType.STANDARD); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; return entries.stream() - .map(e -> { - - if (repo != null && versionService != null) { - } - return new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0); - }) + .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); } @@ -104,18 +98,18 @@ public String getStandardForVersion(String namespace, Integer standardId, String // Fallback: read from local clone (latest/HEAD) try { - Path filePath = Path.of(cloneDirectory, namespace).resolve(entry.filePath()); + Path relativeFilePath = entry.filePath(); // If this is a JSON file, check for a sibling .md file and prefer it - if (filePath.toString().endsWith(".json")) { - String baseName = filePath.getFileName().toString() + if (relativeFilePath.toString().endsWith(".json")) { + String baseName = relativeFilePath.getFileName().toString() .replaceAll("\\.(guideline|standard|calm)\\.json$", "") .replace(".json", ""); - Path mdSibling = filePath.getParent().resolve(baseName + ".md"); - if (Files.exists(mdSibling)) { - return Files.readString(mdSibling); + Path relativeMdSibling = relativeFilePath.resolveSibling(baseName + ".md"); + if (GitHubFileReader.existsContained(cloneDirectory, namespace, relativeMdSibling)) { + return GitHubFileReader.readContained(cloneDirectory, namespace, relativeMdSibling); } } - return Files.readString(filePath); + return GitHubFileReader.readContained(cloneDirectory, namespace, relativeFilePath); } catch (IOException e) { LOG.error("Failed to read standard file: {}", entry.filePath(), e); throw new StandardVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java index 36c715cbed..d12f7fa344 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java @@ -16,6 +16,7 @@ import org.finos.calm.store.TimelineStore; import org.finos.calm.store.github.util.CalmResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.util.InMemoryRegistryService; import org.finos.calm.store.github.util.RegistryEntry; @@ -23,8 +24,6 @@ import org.slf4j.LoggerFactory; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; import java.util.Optional; @@ -99,8 +98,7 @@ public String getTimelineForVersion(Timeline timeline) throws NamespaceNotFoundE // Fallback: read from local clone (latest/HEAD) try { - Path filePath = Path.of(cloneDirectory, timeline.getNamespace()).resolve(entry.filePath()); - return Files.readString(filePath); + return GitHubFileReader.readContained(cloneDirectory, timeline.getNamespace(), entry.filePath()); } catch (IOException e) { LOG.error("Failed to read timeline file: {}", entry.filePath(), e); throw new TimelineVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubFileReader.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubFileReader.java new file mode 100644 index 0000000000..876714f105 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubFileReader.java @@ -0,0 +1,81 @@ +package org.finos.calm.store.github.util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; + +/** + * Centralised, containment-checked file reads for the GitHub-backed stores. + * + *

Repo content is untrusted: anyone who can land a commit on a synced repo's + * configured branch controls every path and file this reads. Without this guard, a + * symlink tracked in the repo (JGit checks out POSIX symlinks by default, and nothing + * in the clone/sync path overrides that) pointing outside the namespace's own clone + * directory — at another namespace's clone, or at a host path like + * {@code /proc/self/environ}, where {@code calm.github.service-token} lives in the + * process environment — would otherwise be followed and served verbatim through the + * public read API. + * + *

The check is against the real (symlink-resolved) location of the file actually + * read, not the nominal path, and containment is verified against the SPECIFIC + * namespace's own subdirectory — not just the overall {@code clone-directory} root. + * A root-level-only check would still let a symlink into a sibling + * namespace's clone pass, defeating that namespace's own {@code accessGroups} + * restriction even though both directories sit under the same configured root. + * + *

This check runs at read time, not just at registry-scan time: the registry is + * rebuilt only every {@code calm.github.sync-interval} seconds, so a symlink swapped + * in between rebuilds (a TOCTOU window against {@code GitHubRepoSync}'s + * {@code reset --hard}) would otherwise slip past a scan-time-only guard. + */ +public final class GitHubFileReader { + + private GitHubFileReader() { + } + + /** + * Reads {@code relativeFilePath} from within {@code cloneDirectory}/{@code namespace}, + * refusing to follow a symlink — direct or via an intermediate path component — that + * would escape that namespace's own clone directory. + * + * @throws NoSuchFileException if the resolved target is a symlink, does not exist, or + * its real path falls outside the namespace's clone directory + * @throws IOException if the read itself fails + */ + public static String readContained(String cloneDirectory, String namespace, Path relativeFilePath) throws IOException { + Path namespaceRoot = Path.of(cloneDirectory, namespace); + Path target = namespaceRoot.resolve(relativeFilePath); + if (!isContained(namespaceRoot, target)) { + throw new NoSuchFileException(target.toString()); + } + return Files.readString(target); + } + + /** + * Same containment guard as {@link #readContained}, without reading the file — for + * call sites (e.g. an optional sibling file) that need to check existence first. + */ + public static boolean existsContained(String cloneDirectory, String namespace, Path relativeFilePath) { + Path namespaceRoot = Path.of(cloneDirectory, namespace); + Path target = namespaceRoot.resolve(relativeFilePath); + return Files.exists(target) && isContained(namespaceRoot, target); + } + + private static boolean isContained(Path namespaceRoot, Path target) { + // Reject a direct symlink target outright, regardless of where it points - + // simplest and most defensible: repo content never legitimately needs to be a + // symlink for any resource this reads. + if (Files.isSymbolicLink(target)) { + return false; + } + try { + Path realRoot = namespaceRoot.toRealPath(); + Path realTarget = target.toRealPath(); + return realTarget.startsWith(realRoot); + } catch (IOException e) { + // Doesn't exist, or a component along the way doesn't resolve - not contained. + return false; + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java index 81e1861a06..19733cbfaf 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java @@ -7,6 +7,7 @@ import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.context.ManagedExecutor; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.observability.GitHubMetrics; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,7 +20,12 @@ /** * Reads namespace configuration on startup, registers repos with the clone manager, * triggers initial clone asynchronously, and rebuilds the in-memory registry once complete. - * Quarkus finishes starting immediately — health endpoints are available during clone. + * Quarkus finishes starting immediately — there is no readiness endpoint gating this + * (no {@code @Readiness}/smallrye-health dependency exists in this module); a request for + * a validly-configured namespace that arrives before the initial clone completes sees + * {@link org.finos.calm.domain.exception.NamespaceNotFoundException} (404) rather than a + * "still starting up" response, since the registry starts empty. See + * {@link GitHubCloneManager#getState()} for the actual in-progress signal callers can poll. */ @LookupIfProperty(name = "calm.database.mode", stringValue = "github") @ApplicationScoped @@ -43,7 +49,18 @@ public class GitHubStartupInitializer { @ConfigProperty(name = "calm.github.namespaces") Optional> namespaceConfigs; + // @LookupIfProperty only gates whether this bean satisfies @Inject/Instance + // resolution - it does NOT stop an @Observes StartupEvent method from firing once the + // bean exists, so without this check onStart() runs in every calm.database.mode. + // Mirrors the identical guard in StandaloneDemoSeeder for the same reason. + @ConfigProperty(name = "calm.database.mode", defaultValue = "mongo") + String databaseMode; + void onStart(@Observes StartupEvent ev) { + if (!DatabaseMode.GITHUB.equals(databaseMode)) { + return; + } + List configs = namespaceConfigs.orElse(List.of()); if (configs.isEmpty()) { LOG.warn("No GitHub namespaces configured (calm.github.namespaces). The registry will be empty."); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java index 2f9c0bc828..3bd27750c2 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java @@ -4,6 +4,8 @@ import io.quarkus.scheduler.Scheduled; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.observability.GitHubMetrics; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,6 +27,12 @@ public class GitHubSyncScheduler { private final InMemoryRegistryService registryService; private final GitHubMetrics metrics; + // @LookupIfProperty does not stop @Scheduled invocation once this bean exists - see + // the identical guard and comment in GitHubStartupInitializer for why this is needed. + @Inject + @ConfigProperty(name = "calm.database.mode", defaultValue = "mongo") + String databaseMode; + @Inject public GitHubSyncScheduler(GitHubCloneManager cloneManager, InMemoryRegistryService registryService, @@ -34,9 +42,13 @@ public GitHubSyncScheduler(GitHubCloneManager cloneManager, this.metrics = metrics; } - @Scheduled(every = "${calm.github.sync-interval:60}s", delayed = "${calm.github.sync-interval:60}s") + // concurrentExecution = SKIP: without it, a pullAll() slower than the sync interval + // overlaps the next tick and runs "reset --hard" on a clone directory a request + // thread may be mid-Files.readString on. + @Scheduled(every = "${calm.github.sync-interval:60}s", delayed = "${calm.github.sync-interval:60}s", + concurrentExecution = Scheduled.ConcurrentExecution.SKIP) void sync() { - if (!cloneManager.hasNamespaces()) { + if (!DatabaseMode.GITHUB.equals(databaseMode) || !cloneManager.hasNamespaces()) { return; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java index ae5b2b58a7..9ba6980d69 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.io.StringReader; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; @@ -83,7 +84,12 @@ private List scanDirectory(String namespace, Path root) { } try (Stream walk = Files.walk(root)) { - walk.filter(Files::isRegularFile) + // NOFOLLOW_LINKS: don't index a symlink as if it were real repo content - + // defense in depth alongside GitHubFileReader's read-time containment check, + // which is the check that actually matters (this scan only runs once per + // calm.github.sync-interval, so a symlink swapped in between rebuilds would + // slip past a scan-time-only guard). + walk.filter(p -> Files.isRegularFile(p, LinkOption.NOFOLLOW_LINKS)) .filter(p -> p.toString().endsWith(".json") || p.toString().endsWith(".md")) .filter(p -> !isHiddenOrMetadata(root, p)) .forEach(filePath -> { @@ -105,7 +111,7 @@ private RegistryEntry parseFile(Path root, Path filePath) { String fileName = filePath.getFileName().toString(); if (fileName.endsWith(".md")) { - return parseMarkdownFile(root, filePath, relativePath); + return parseMarkdownFile(filePath, relativePath); } String content = Files.readString(filePath); @@ -126,7 +132,7 @@ private RegistryEntry parseFile(Path root, Path filePath) { } } - private RegistryEntry parseMarkdownFile(Path root, Path filePath, Path relativePath) throws IOException { + private RegistryEntry parseMarkdownFile(Path filePath, Path relativePath) throws IOException { CalmResourceType type = detectMarkdownType(relativePath); if (type == CalmResourceType.UNKNOWN) { return null; diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java index 8373021504..2aad93e71b 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java @@ -33,6 +33,7 @@ import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.OFFSET_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_MESSAGE; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.any; @@ -311,7 +312,7 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_get_architect .get("/api/calm/namespaces/finos/architectures/12/versions/1.0.invalid0") .then() .statusCode(400) - .body(containsString(VERSION_MESSAGE)); + .body(containsString(VERSION_OR_SHA_MESSAGE)); } private void verifyExpectedGetArchitecture(String namespace) throws ArchitectureNotFoundException, NamespaceNotFoundException, ArchitectureVersionNotFoundException { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java index 45e11d488a..54bdcb85d3 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java @@ -26,6 +26,7 @@ import static io.restassured.RestAssured.given; import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_MESSAGE; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.any; @@ -254,7 +255,7 @@ void return_400_error_when_version_is_not_valid_when_getting_flow_version() { .get("/api/calm/namespaces/finos/flows/12/versions/invalid-version") .then() .statusCode(400) - .body(containsString(VERSION_MESSAGE)); + .body(containsString(VERSION_OR_SHA_MESSAGE)); } static Stream provideParametersForGetFlowTests() { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java index 34e11e8ecd..6a9071e4ec 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java @@ -32,6 +32,7 @@ import static io.restassured.RestAssured.given; import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_MESSAGE; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; @@ -265,7 +266,7 @@ void return_400_when_invalid_version_provided_when_getting_version_of_interface( .get("/api/calm/namespaces/finos/interfaces/5/versions/invalid_version") .then() .statusCode(400) - .body(containsString(VERSION_MESSAGE)); + .body(containsString(VERSION_OR_SHA_MESSAGE)); } static Stream provideParametersForGetInterfaceTests() { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java index 4dba94e64b..4c7e95e47f 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java @@ -32,6 +32,7 @@ import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.OFFSET_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_MESSAGE; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; @@ -328,7 +329,7 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_get_pattern() .get("/api/calm/namespaces/finos/patterns/12/versions/1.0.invalid0") .then() .statusCode(400) - .body(containsString(VERSION_MESSAGE)); + .body(containsString(VERSION_OR_SHA_MESSAGE)); } private void verifyExpectedGetPattern(String namespace) throws PatternNotFoundException, NamespaceNotFoundException, PatternVersionNotFoundException { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java index 7efb1c5c5c..55558b49e8 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java @@ -30,6 +30,7 @@ import static io.restassured.RestAssured.given; import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_MESSAGE; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.*; @@ -222,7 +223,7 @@ void return_400_when_invalid_version_provided_when_getting_version_of_standard() .get("/api/calm/namespaces/finos/standards/5/versions/invalid_version") .then() .statusCode(400) - .body(containsString(VERSION_MESSAGE)); + .body(containsString(VERSION_OR_SHA_MESSAGE)); } static Stream provideParametersForGetStandardTests() { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java index e3630f748f..d7a7e2d909 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java @@ -24,6 +24,7 @@ import static io.restassured.RestAssured.given; import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_OR_SHA_MESSAGE; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.any; @@ -206,7 +207,7 @@ void return_400_error_when_version_is_not_valid_when_getting_timeline_version() .get("/api/calm/namespaces/finos/timelines/12/versions/1.0.invalid0") .then() .statusCode(400) - .body(containsString(VERSION_MESSAGE)); + .body(containsString(VERSION_OR_SHA_MESSAGE)); } static Stream provideParametersForGetTimelineTests() { diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubFileReaderShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubFileReaderShould.java new file mode 100644 index 0000000000..69baea8f95 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubFileReaderShould.java @@ -0,0 +1,119 @@ +package org.finos.calm.store.github.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class TestGitHubFileReaderShould { + + @Test + void read_a_regular_file_within_the_namespace_directory(@TempDir Path cloneDirectory) throws IOException { + Path namespaceRoot = cloneDirectory.resolve("finos"); + Files.createDirectories(namespaceRoot.resolve("architectures")); + Files.writeString(namespaceRoot.resolve("architectures/a.json"), "{}"); + + String content = GitHubFileReader.readContained(cloneDirectory.toString(), "finos", + Path.of("architectures/a.json")); + + assertThat(content, equalTo("{}")); + } + + @Test + void reject_a_symlink_pointing_outside_its_own_namespace_directory(@TempDir Path cloneDirectory) throws IOException { + assumeTrue(supportsSymlinks(cloneDirectory), "filesystem does not support symlinks"); + + // Simulates the real-world attack: a repo committing a symlink that, once JGit + // checks it out, points at a file outside the namespace's own clone directory - + // here, a sibling namespace's clone, but the same check equally rejects a link to + // an arbitrary host path like /proc/self/environ. + Path namespaceRoot = cloneDirectory.resolve("finos"); + Files.createDirectories(namespaceRoot.resolve("standards")); + + Path otherNamespaceRoot = cloneDirectory.resolve("other-namespace"); + Files.createDirectories(otherNamespaceRoot); + Path secretFile = otherNamespaceRoot.resolve("secret.txt"); + Files.writeString(secretFile, "service-token-or-other-secret"); + + Path symlink = namespaceRoot.resolve("standards/leak.md"); + Files.createSymbolicLink(symlink, secretFile); + + NoSuchFileException e = assertThrows(NoSuchFileException.class, () -> + GitHubFileReader.readContained(cloneDirectory.toString(), "finos", + Path.of("standards/leak.md"))); + assertThat(e.getFile(), equalTo(symlink.toString())); + } + + @Test + void reject_a_symlink_pointing_at_a_host_path_outside_the_clone_directory_entirely(@TempDir Path cloneDirectory) throws IOException { + assumeTrue(supportsSymlinks(cloneDirectory), "filesystem does not support symlinks"); + + Path namespaceRoot = cloneDirectory.resolve("finos"); + Files.createDirectories(namespaceRoot.resolve("standards")); + + Path outsideClonesEntirely = Files.createTempFile("github-file-reader-test", ".txt"); + Files.writeString(outsideClonesEntirely, "host secret"); + try { + Path symlink = namespaceRoot.resolve("standards/leak.md"); + Files.createSymbolicLink(symlink, outsideClonesEntirely); + + assertThrows(NoSuchFileException.class, () -> + GitHubFileReader.readContained(cloneDirectory.toString(), "finos", + Path.of("standards/leak.md"))); + } finally { + Files.deleteIfExists(outsideClonesEntirely); + } + } + + @Test + void reject_a_missing_file(@TempDir Path cloneDirectory) throws IOException { + Files.createDirectories(cloneDirectory.resolve("finos")); + + assertThrows(NoSuchFileException.class, () -> + GitHubFileReader.readContained(cloneDirectory.toString(), "finos", + Path.of("standards/does-not-exist.md"))); + } + + @Test + void existsContained_returns_false_for_a_symlink_escape(@TempDir Path cloneDirectory) throws IOException { + assumeTrue(supportsSymlinks(cloneDirectory), "filesystem does not support symlinks"); + + Path namespaceRoot = cloneDirectory.resolve("finos"); + Files.createDirectories(namespaceRoot.resolve("standards")); + Path outside = cloneDirectory.resolve("outside.txt"); + Files.writeString(outside, "secret"); + Files.createSymbolicLink(namespaceRoot.resolve("standards/a.md"), outside); + + assertThat(GitHubFileReader.existsContained(cloneDirectory.toString(), "finos", + Path.of("standards/a.md")), equalTo(false)); + } + + @Test + void existsContained_returns_true_for_a_regular_contained_file(@TempDir Path cloneDirectory) throws IOException { + Path namespaceRoot = cloneDirectory.resolve("finos"); + Files.createDirectories(namespaceRoot.resolve("standards")); + Files.writeString(namespaceRoot.resolve("standards/a.md"), "content"); + + assertThat(GitHubFileReader.existsContained(cloneDirectory.toString(), "finos", + Path.of("standards/a.md")), equalTo(true)); + } + + private static boolean supportsSymlinks(Path dir) { + try { + Path link = dir.resolve("symlink-support-probe"); + Files.createSymbolicLink(link, dir); + Files.delete(link); + return true; + } catch (IOException | UnsupportedOperationException e) { + return false; + } + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java index 343393004f..685b598d29 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java @@ -47,6 +47,7 @@ void setup() { initializer.registryService = registryService; initializer.metrics = metrics; initializer.executor = executor; + initializer.databaseMode = "github"; // Make executor.runAsync execute the Runnable immediately (synchronously for testing) when(executor.runAsync(any(Runnable.class))).thenAnswer(invocation -> { @@ -66,6 +67,21 @@ void skip_when_no_namespaces_configured() { verify(registryService, never()).rebuild(any()); } + @Test + void skip_entirely_when_database_mode_is_not_github() { + // @LookupIfProperty only gates @Inject/Instance resolution, not @Observes + // invocation once the bean exists - this guard is what actually stops onStart() + // from registering namespaces and cloning in, say, mongo mode. + initializer.databaseMode = "mongo"; + initializer.namespaceConfigs = Optional.of(List.of("finos|finos/architecture-as-code|main")); + + initializer.onStart(new StartupEvent()); + + verify(cloneManager, never()).registerNamespace(any(), any(), any(), any()); + verify(cloneManager, never()).cloneAll(); + verify(registryService, never()).rebuild(any()); + } + @Test void register_namespaces_and_clone() { initializer.namespaceConfigs = Optional.of(List.of( diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java index 728e10d1c6..33818f0524 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java @@ -33,6 +33,7 @@ class TestGitHubSyncSchedulerShould { @BeforeEach void setup() { scheduler = new GitHubSyncScheduler(cloneManager, registryService, metrics); + scheduler.databaseMode = "github"; } @Test @@ -45,6 +46,19 @@ void skip_sync_when_no_namespaces_registered() { verify(registryService, never()).rebuild(any()); } + @Test + void skip_sync_entirely_when_database_mode_is_not_github() { + // @LookupIfProperty only gates @Inject/Instance resolution, not @Scheduled + // invocation once the bean exists - this guard is what actually stops sync() + // from pulling and rebuilding in, say, mongo mode. + scheduler.databaseMode = "mongo"; + + scheduler.sync(); + + verify(cloneManager, never()).pullAll(); + verify(registryService, never()).rebuild(any()); + } + @Test void pull_all_and_rebuild_registry_on_sync() { when(cloneManager.hasNamespaces()).thenReturn(true); From 4db3c9e304f4c9afbaa698693cfd5b1f6efa227c Mon Sep 17 00:00:00 2001 From: James Gough Date: Wed, 9 Sep 2026 19:35:02 +0100 Subject: [PATCH 06/47] feat(calm-hub): derive domain-level read access on the GitHub backend - GitHubUserAccessStore.getGrantsForUser now additionally emits a domain-scoped UserAccess grant for each distinct controls// prefix found within the namespaces the caller already has group-based access to. Previously it only ever emitted namespace-scoped grants, so CalmHubPermissionChecker. hasDomainAccess's domain.equals(g.getDomain()) match was always false and every DOMAIN_READ-gated endpoint (/api/calm/domains/**, /calm/domains/**/controls) hard-403'd for every GitHub-backend user. No CalmHubPermissionChecker change needed - it already knows how to consume a real domain grant once one exists. Deliberately not the "any-namespace-grant-unlocks-every-domain" shortcut floated during slice 4's review: a grant here is scoped to domains the namespace actually contains. - Extracted the domain-extraction convention (controls//... -> domain) out of GitHubDomainStore into a shared GitHubControlDomains so the two classes can't drift on what a "domain" means. - Fixed two latent bugs in GitHubControlStore that were dormant while every domain grant was theoretical and become live the moment one is real: - findControlEntry's no-domain-match fallback used to scan every accessible namespace for a controlId hash match with no domain check at all, returning a DIFFERENT domain's control instead of 404 when the requested domain didn't exist anywhere. Removed the fallback outright. - getRequirementForVersion substituted the caller-supplied domain string as a namespace/directory name when namespace resolution came back null - domain is not a namespace; now fails closed with a clear log line instead. fix(calm-hub): cache commit-SHA version lists per branch, not just per repo/path GitHubApiResponseCache's versions cache key and GitHubVersionService's commits API call both omitted branch. Two namespaces mapped to the same repoFullName on different branches (calm.github.namespaces supports that) shared one cache entry, and the API call itself always returned the default branch's history regardless of which branch was configured. Threaded branch through getFileVersions and the cache key/API call; added GitHubCloneManager. getBranchForNamespace to look it up per namespace. fix(calm-hub): stop following GitHub API pagination cross-host, URL-encode repo paths GitHubVersionService re-attached the Authorization: Bearer header to whatever host the upstream response's Link: rel="next" header named, with no check against the configured calm.github.api-url. Now only follows next-page links whose host matches. Also URL-encodes the repo-derived filePath/sha before building GitHub API URLs - previously concatenated raw, so a repo file named e.g. "x?ref=other&y" could inject or alter query parameters. refactor(calm-hub): collapse GitHubVersionService's config/HttpClient duplication - HttpClient is now built once in @PostConstruct and reused, not rebuilt on every call - same reasoning and pattern as OidcPluginAuthClient (slice 4): connectTimeoutSeconds is @ConfigProperty-injected, so a field initializer would read the pre-injection default. - apiUrl/serviceToken are no longer re-declared here; GitHubVersionService now injects GitHubStoreConfig, which already owned both properties (including the defaultValue literal, which had started to drift). - GitHubStoreConfig's three properties (service-token, clone-directory, api-url) are all env-only and now resolved via ConfigProvider in @PostConstruct rather than @ConfigProperty field injection, following this repo's documented native-image rule (ReadOnlyRequestFilter/AuditService are the existing exemplars) - a native image bakes in a field-injected value at build time and ignores a runtime env var override. - Removed GitHubStoreConfig.namespaceConfigs (dead field, no getter, nothing read it - GitHubStartupInitializer independently re-injects the same property) and getSyncInterval() (dead method, no callers). --- .../store/github/GitHubArchitectureStore.java | 5 +- .../github/GitHubBuildingBlockStore.java | 5 +- .../calm/store/github/GitHubControlStore.java | 43 +++++--- .../calm/store/github/GitHubDomainStore.java | 16 +-- .../calm/store/github/GitHubFlowStore.java | 5 +- .../store/github/GitHubInterfaceStore.java | 5 +- .../calm/store/github/GitHubPatternStore.java | 5 +- .../store/github/GitHubStandardStore.java | 5 +- .../store/github/GitHubTimelineStore.java | 5 +- .../store/github/GitHubUserAccessStore.java | 21 ++++ .../github/util/GitHubApiResponseCache.java | 29 +++-- .../store/github/util/GitHubCloneManager.java | 5 + .../github/util/GitHubControlDomains.java | 32 ++++++ .../store/github/util/GitHubStoreConfig.java | 52 ++++----- .../github/util/GitHubVersionService.java | 103 +++++++++++++----- .../TestGitHubArchitectureStoreShould.java | 3 +- .../TestGitHubBuildingBlockStoreShould.java | 3 +- .../github/TestGitHubControlStoreShould.java | 82 +++++++++----- .../github/TestGitHubFlowStoreShould.java | 3 +- .../TestGitHubInterfaceStoreShould.java | 3 +- .../github/TestGitHubPatternStoreShould.java | 3 +- .../github/TestGitHubStandardStoreShould.java | 3 +- .../github/TestGitHubTimelineStoreShould.java | 3 +- .../TestGitHubUserAccessStoreShould.java | 53 +++++++++ .../TestGitHubApiResponseCacheShould.java | 58 +++++----- .../util/TestGitHubStoreConfigShould.java | 19 ++-- .../util/TestGitHubVersionServiceShould.java | 24 ++-- 27 files changed, 401 insertions(+), 192 deletions(-) create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubControlDomains.java diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java index 74d6ce45ae..22d0db1c57 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java @@ -71,8 +71,9 @@ public List getArchitectureVersions(Architecture architecture) throws Na verifyNamespace(architecture.getNamespace()); RegistryEntry entry = findEntryById(architecture.getNamespace(), architecture.getId()); String repo = cloneManager != null ? cloneManager.getRepoForNamespace(architecture.getNamespace()) : null; - if (repo != null && versionService != null) { - return versionService.getFileVersions(repo, entry.filePath().toString()); + String branch = cloneManager != null ? cloneManager.getBranchForNamespace(architecture.getNamespace()) : null; + if (repo != null && branch != null && versionService != null) { + return versionService.getFileVersions(repo, branch, entry.filePath().toString()); } return List.of("latest"); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java index 2489c3370a..b62546ba9f 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java @@ -68,8 +68,9 @@ public List getBuildingBlockVersions(String namespace, int buildingBlock verifyNamespace(namespace); RegistryEntry entry = findEntryById(namespace, buildingBlockId); String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; - if (repo != null && versionService != null) { - return versionService.getFileVersions(repo, entry.filePath().toString()); + String branch = cloneManager != null ? cloneManager.getBranchForNamespace(namespace) : null; + if (repo != null && branch != null && versionService != null) { + return versionService.getFileVersions(repo, branch, entry.filePath().toString()); } return List.of("latest"); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java index 75e511dc6a..235100cf79 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java @@ -21,6 +21,7 @@ import org.finos.calm.store.ControlStore; import org.finos.calm.store.github.util.CalmResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.util.InMemoryRegistryService; import org.finos.calm.store.github.util.NamespaceAccessFilter; @@ -29,10 +30,7 @@ import org.slf4j.LoggerFactory; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; -import java.util.Optional; import java.util.Set; @ApplicationScoped @@ -100,8 +98,9 @@ public List getRequirementVersions(String domain, int controlId) throws RegistryEntry entry = findControlEntry(domain, controlId); String namespace = findNamespaceForControl(entry); String repo = cloneManager != null && namespace != null ? cloneManager.getRepoForNamespace(namespace) : null; - if (repo != null && versionService != null) { - return versionService.getFileVersions(repo, entry.filePath().toString()); + String branch = cloneManager != null && namespace != null ? cloneManager.getBranchForNamespace(namespace) : null; + if (repo != null && branch != null && versionService != null) { + return versionService.getFileVersions(repo, branch, entry.filePath().toString()); } return List.of("latest"); } @@ -110,10 +109,22 @@ public List getRequirementVersions(String domain, int controlId) throws public String getRequirementForVersion(String domain, int controlId, String version) throws DomainNotFoundException, ControlNotFoundException, ControlRequirementVersionNotFoundException { RegistryEntry entry = findControlEntry(domain, controlId); String namespace = findNamespaceForControl(entry); + if (namespace == null) { + // findControlEntry just found this exact entry by walking the same + // namespace/CONTROL listing findNamespaceForControl uses, so this only + // happens on a genuine registry-consistency problem (e.g. a rebuild + // swapped the snapshot out from under this request). Fail closed rather + // than fall back to using the caller-supplied domain string as a directory + // name - domain is not a namespace, and guessing one from the other was the + // bug here. + LOG.error("Could not resolve namespace for control [{}] in domain [{}] - registry may be mid-rebuild", + entry.uniqueId(), domain); + throw new ControlRequirementVersionNotFoundException(); + } // If a specific SHA is requested and version service is available, fetch from GitHub API if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") - && cloneManager != null && versionService != null && namespace != null) { + && cloneManager != null && versionService != null) { String repo = cloneManager.getRepoForNamespace(namespace); if (repo != null) { String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); @@ -125,8 +136,7 @@ public String getRequirementForVersion(String domain, int controlId, String vers // Fallback: read from local clone (latest/HEAD) try { - Path filePath = Path.of(cloneDirectory, namespace != null ? namespace : domain).resolve(entry.filePath()); - return Files.readString(filePath); + return GitHubFileReader.readContained(cloneDirectory, namespace, entry.filePath()); } catch (IOException e) { LOG.error("Failed to read control file: {}", entry.filePath(), e); throw new ControlRequirementVersionNotFoundException(); @@ -198,16 +208,13 @@ private RegistryEntry findControlEntry(String domain, int controlId) throws Doma } } if (!domainExists) { - for (String namespace : registryService.getSnapshot().getNamespaces()) { - if (!accessible.contains(namespace)) { - continue; - } - List entries = registryService.listByType(namespace, CalmResourceType.CONTROL); - Optional found = entries.stream() - .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == controlId) - .findFirst(); - if (found.isPresent()) return found.get(); - } + // The requested domain doesn't exist in any namespace this caller can see - + // do NOT fall back to scanning every accessible namespace for a coincidental + // controlId hash match regardless of domain. That previously let a caller + // pass a bogus/wrong domain alongside a real controlId from a DIFFERENT + // domain and get that other domain's content back instead of a 404 - a + // cross-domain read, and one that would become live the moment domain grants + // are scoped independently of namespace access (see getGrantsForUser). throw new DomainNotFoundException(domain); } throw new ControlNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java index c08588f11e..1ec9c93edc 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java @@ -10,11 +10,10 @@ import org.finos.calm.domain.exception.DomainNotFoundException; import org.finos.calm.store.DomainStore; import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.util.GitHubControlDomains; import org.finos.calm.store.github.util.InMemoryRegistryService; import org.finos.calm.store.github.util.NamespaceAccessFilter; -import org.finos.calm.store.github.util.RegistryEntry; -import java.nio.file.Path; import java.util.List; import java.util.Set; @@ -46,7 +45,7 @@ public List getDomains() { .filter(e -> accessible.contains(e.getKey())) .flatMap(e -> e.getValue().stream()) .filter(entry -> entry.type() == CalmResourceType.CONTROL) - .map(this::extractDomain) + .map(GitHubControlDomains::extractDomain) .distinct() .toList(); } @@ -66,17 +65,6 @@ public void deleteDomain(String name) throws DomainNotFoundException { throw new GitHubWriteNotSupportedException(UNSUPPORTED_MSG); } - private String extractDomain(RegistryEntry entry) { - Path filePath = entry.filePath(); - if (filePath.getNameCount() >= 2) { - String firstDir = filePath.getName(0).toString(); - if ("controls".equals(firstDir)) { - return filePath.getName(1).toString(); - } - } - return "default"; - } - private Set resolveAccessibleNamespaces() { if (accessFilter == null) { return new java.util.HashSet<>(registryService.getSnapshot().getNamespaces()); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java index 644e7784cb..bdd394ec11 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java @@ -72,8 +72,9 @@ public List getFlowVersions(Flow flow) throws NamespaceNotFoundException verifyNamespace(flow.getNamespace()); RegistryEntry entry = findEntryById(flow.getNamespace(), flow.getId()); String repo = cloneManager != null ? cloneManager.getRepoForNamespace(flow.getNamespace()) : null; - if (repo != null && versionService != null) { - return versionService.getFileVersions(repo, entry.filePath().toString()); + String branch = cloneManager != null ? cloneManager.getBranchForNamespace(flow.getNamespace()) : null; + if (repo != null && branch != null && versionService != null) { + return versionService.getFileVersions(repo, branch, entry.filePath().toString()); } return List.of("latest"); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java index 3dfa8f6147..8a6ad4d9cf 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java @@ -72,8 +72,9 @@ public List getInterfaceVersions(String namespace, Integer interfaceId) verifyNamespace(namespace); RegistryEntry entry = findEntryById(namespace, interfaceId); String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; - if (repo != null && versionService != null) { - return versionService.getFileVersions(repo, entry.filePath().toString()); + String branch = cloneManager != null ? cloneManager.getBranchForNamespace(namespace) : null; + if (repo != null && branch != null && versionService != null) { + return versionService.getFileVersions(repo, branch, entry.filePath().toString()); } return List.of("latest"); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java index 2d2f1c9b1b..025f33ceb7 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java @@ -74,8 +74,9 @@ public List getPatternVersions(Pattern pattern) throws NamespaceNotFound verifyNamespace(pattern.getNamespace()); RegistryEntry entry = findEntryById(pattern.getNamespace(), pattern.getId()); String repo = cloneManager != null ? cloneManager.getRepoForNamespace(pattern.getNamespace()) : null; - if (repo != null && versionService != null) { - return versionService.getFileVersions(repo, entry.filePath().toString()); + String branch = cloneManager != null ? cloneManager.getBranchForNamespace(pattern.getNamespace()) : null; + if (repo != null && branch != null && versionService != null) { + return versionService.getFileVersions(repo, branch, entry.filePath().toString()); } return List.of("latest"); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java index 7769b7251d..c9829ebe5f 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java @@ -73,8 +73,9 @@ public List getStandardVersions(String namespace, Integer standardId) th verifyNamespace(namespace); RegistryEntry entry = findEntryById(namespace, standardId); String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; - if (repo != null && versionService != null) { - return versionService.getFileVersions(repo, entry.filePath().toString()); + String branch = cloneManager != null ? cloneManager.getBranchForNamespace(namespace) : null; + if (repo != null && branch != null && versionService != null) { + return versionService.getFileVersions(repo, branch, entry.filePath().toString()); } return List.of("latest"); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java index d12f7fa344..6701602b2c 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java @@ -72,8 +72,9 @@ public List getTimelineVersions(Timeline timeline) throws NamespaceNotFo verifyNamespace(timeline.getNamespace()); RegistryEntry entry = findEntryById(timeline.getNamespace(), timeline.getId()); String repo = cloneManager != null ? cloneManager.getRepoForNamespace(timeline.getNamespace()) : null; - if (repo != null && versionService != null) { - return versionService.getFileVersions(repo, entry.filePath().toString()); + String branch = cloneManager != null ? cloneManager.getBranchForNamespace(timeline.getNamespace()) : null; + if (repo != null && branch != null && versionService != null) { + return versionService.getFileVersions(repo, branch, entry.filePath().toString()); } return List.of("latest"); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java index 987e5b4727..1341fce7db 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java @@ -11,12 +11,15 @@ import org.finos.calm.domain.exception.UserAccessNotFoundException; import org.finos.calm.security.OidcRoleResolver; import org.finos.calm.store.UserAccessStore; +import org.finos.calm.store.github.util.CalmResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.GitHubControlDomains; import org.finos.calm.store.github.util.InMemoryRegistryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Set; @@ -57,6 +60,14 @@ public List getGrantsForUser(String username) { return grants; } + // Domains this user can read, derived from the namespaces they have group-based + // access to below - not a flat "any namespace grant unlocks every domain" escape + // hatch (the shape CalmHubPermissionChecker's review reverted on slice 4), and + // not a CalmHubPermissionChecker change either: this store just starts emitting + // real UserAccess(domain=...) grants, which hasDomainAccess's existing + // domain.equals(g.getDomain()) match already knows how to consume. + Set accessibleDomains = new HashSet<>(); + for (String namespace : namespaces) { Set accessGroups = cloneManager != null ? cloneManager.getAccessGroupsForNamespace(namespace) : Set.of(); @@ -64,10 +75,20 @@ public List getGrantsForUser(String username) { if (level != OidcRoleResolver.AccessLevel.NONE) { grants.add(new UserAccess(username, UserAccess.Permission.read, namespace)); + registryService.getSnapshot().listByType(namespace, CalmResourceType.CONTROL).stream() + .map(GitHubControlDomains::extractDomain) + .forEach(accessibleDomains::add); } else { LOG.debug("User [{}] denied access to namespace [{}] — no matching group", username, namespace); } } + + for (String domain : accessibleDomains) { + UserAccess domainGrant = new UserAccess(username, UserAccess.Permission.read, null); + domainGrant.setDomain(domain); + grants.add(domainGrant); + } + return grants; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubApiResponseCache.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubApiResponseCache.java index 89484929e4..abd8d0c378 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubApiResponseCache.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubApiResponseCache.java @@ -75,21 +75,26 @@ private static Cache buildCache(long maxSize, Ticker ticker, Dura } /** - * Reads the cached commit-SHA version list for a file, if present and not - * expired. + * Reads the cached commit-SHA version list for a file on a given branch, if + * present and not expired. */ - public Optional> getVersions(String repoFullName, String filePath) { - return read(versionsCache, versionsKey(repoFullName, filePath)); + public Optional> getVersions(String repoFullName, String branch, String filePath) { + return read(versionsCache, versionsKey(repoFullName, branch, filePath)); } /** - * Caches the commit-SHA version list for a file for {@link #VERSIONS_TTL}. A - * {@code null} list is silently ignored. Stores an immutable copy, so a caller - * mutating the list it passed in — or held onto after a {@link #getVersions} - * call — can never corrupt the cached entry. + * Caches the commit-SHA version list for a file on a given branch for + * {@link #VERSIONS_TTL}. A {@code null} list is silently ignored. Stores an + * immutable copy, so a caller mutating the list it passed in — or held onto + * after a {@link #getVersions} call — can never corrupt the cached entry. + * + *

Branch is part of the key, not just the upstream request: two namespaces can + * map to the same {@code repoFullName} on different branches + * ({@code calm.github.namespaces} supports that), and without it in the key they'd + * share one cache entry holding whichever branch's history was fetched first. */ - public void putVersions(String repoFullName, String filePath, List versions) { - write(versionsCache, versionsKey(repoFullName, filePath), versions == null ? null : List.copyOf(versions)); + public void putVersions(String repoFullName, String branch, String filePath, List versions) { + write(versionsCache, versionsKey(repoFullName, branch, filePath), versions == null ? null : List.copyOf(versions)); } /** @@ -119,8 +124,8 @@ private static void write(Cache cache, String key, V value) { cache.put(key, value); } - private static String versionsKey(String repoFullName, String filePath) { - return "versions:" + repoFullName + ":" + filePath; + private static String versionsKey(String repoFullName, String branch, String filePath) { + return "versions:" + repoFullName + ":" + branch + ":" + filePath; } private static String contentKey(String repoFullName, String filePath, String sha) { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java index f5b13da788..8ad3d0ad09 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java @@ -144,5 +144,10 @@ public String getRepoForNamespace(String namespace) { return repo != null ? repo.repoFullName() : null; } + public String getBranchForNamespace(String namespace) { + NamespaceRepo repo = namespaceRepos.get(namespace); + return repo != null ? repo.branch() : null; + } + record NamespaceRepo(String repoFullName, String branch, Set accessGroups) {} } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubControlDomains.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubControlDomains.java new file mode 100644 index 0000000000..dc3b6a99d4 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubControlDomains.java @@ -0,0 +1,32 @@ +package org.finos.calm.store.github.util; + +import java.nio.file.Path; + +/** + * Shared domain-extraction convention for the GitHub-backed control store: a control's + * domain is the second path segment under a {@code controls/} directory + * (e.g. {@code controls/security/access-control.json} -> domain {@code "security"}). + * + *

Used by both {@link org.finos.calm.store.github.GitHubDomainStore} (to list the + * domains a namespace has) and {@link org.finos.calm.store.github.GitHubUserAccessStore} + * (to derive which domains a user's namespace-level access should also grant read on) - + * kept in one place so the two never drift. + */ +public final class GitHubControlDomains { + + private static final String DEFAULT_DOMAIN = "default"; + + private GitHubControlDomains() { + } + + public static String extractDomain(RegistryEntry entry) { + Path filePath = entry.filePath(); + if (filePath.getNameCount() >= 2) { + String firstDir = filePath.getName(0).toString(); + if ("controls".equals(firstDir)) { + return filePath.getName(1).toString(); + } + } + return DEFAULT_DOMAIN; + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java index 271f395b7c..7c3fb579b6 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java @@ -1,50 +1,52 @@ package org.finos.calm.store.github.util; import io.quarkus.arc.lookup.LookupIfProperty; +import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; -import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.config.ConfigProvider; import java.nio.file.Path; -import java.util.List; -import java.util.Optional; +/** + * {@code calm.github.service-token}, {@code calm.github.clone-directory}, and + * {@code calm.github.api-url} are all env-only (declared in no + * {@code application*.properties} file) — exactly the kind of operator-supplied, + * potentially-rotating value {@code @ConfigProperty} field injection gets wrong in a + * native image, where the value is captured at build time and a runtime env var + * override is silently ignored. Resolved once at runtime startup via + * {@link #init()} instead, the same pattern used by {@code ReadOnlyRequestFilter} and + * {@code AuditService} — see {@code calm-hub/AGENTS.md}'s native-image config guidance. + */ @LookupIfProperty(name = "calm.database.mode", stringValue = "github") @ApplicationScoped public class GitHubStoreConfig { - @Inject - @ConfigProperty(name = "calm.github.service-token") - Optional serviceToken; - - @Inject - @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") + // Package-private so unit tests can set these directly rather than going through init(). + String serviceToken; String cloneDirectory; - - @Inject - @ConfigProperty(name = "calm.github.sync-interval", defaultValue = "60") - int syncInterval; - - @Inject - @ConfigProperty(name = "calm.github.api-url", defaultValue = "https://api.github.com") String apiUrl; - @Inject - @ConfigProperty(name = "calm.github.namespaces") - Optional> namespaceConfigs; + @PostConstruct + void init() { + serviceToken = ConfigProvider.getConfig() + .getOptionalValue("calm.github.service-token", String.class) + .orElse(""); + cloneDirectory = ConfigProvider.getConfig() + .getOptionalValue("calm.github.clone-directory", String.class) + .orElse("/tmp/calm-hub-clones"); + apiUrl = ConfigProvider.getConfig() + .getOptionalValue("calm.github.api-url", String.class) + .orElse("https://api.github.com"); + } public String getServiceToken() { - return serviceToken.orElse(""); + return serviceToken; } public Path getCloneDirectory() { return Path.of(cloneDirectory); } - public int getSyncInterval() { - return syncInterval; - } - public String getApiUrl() { return apiUrl; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java index 7c7968f152..4ecd7147f9 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.arc.lookup.LookupIfProperty; +import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -11,10 +12,12 @@ import java.net.ProxySelector; import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpHeaders; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; @@ -40,12 +43,7 @@ public class GitHubVersionService { GitHubApiResponseCache cache; @Inject - @ConfigProperty(name = "calm.github.api-url", defaultValue = "https://api.github.com") - String apiUrl; - - @Inject - @ConfigProperty(name = "calm.github.service-token") - Optional serviceToken; + GitHubStoreConfig storeConfig; @Inject @ConfigProperty(name = "calm.github.http.connect-timeout", defaultValue = "10") @@ -59,24 +57,42 @@ public class GitHubVersionService { @ConfigProperty(name = "calm.github.max-versions", defaultValue = "100") int maxVersions; - public List getFileVersions(String repoFullName, String filePath) { - Optional> cached = cache.getVersions(repoFullName, filePath); + // Built in @PostConstruct, not as a field initializer: connectTimeoutSeconds is + // @ConfigProperty-injected, which happens after the constructor runs but before + // @PostConstruct - a field initializer here would read the pre-injection default + // (0). Built once and reused, not per-call, so requests share a connection pool. + private HttpClient httpClient; + + @PostConstruct + void init() { + httpClient = HttpClient.newBuilder() + .proxy(ProxySelector.getDefault()) + .connectTimeout(Duration.ofSeconds(connectTimeoutSeconds)) + .build(); + } + + /** + * @param branch part of the cache key alongside repoFullName/filePath, and sent + * as the commits API's {@code sha} parameter - without it, two + * namespaces mapped to the same repo on different branches would + * share one cache entry holding whichever branch's history was + * fetched first, and the API call itself would always return the + * default branch's history regardless of which branch is configured. + */ + public List getFileVersions(String repoFullName, String branch, String filePath) { + Optional> cached = cache.getVersions(repoFullName, branch, filePath); if (cached.isPresent()) { return cached.get(); } try { - HttpClient client = HttpClient.newBuilder() - .proxy(ProxySelector.getDefault()) - .connectTimeout(Duration.ofSeconds(connectTimeoutSeconds)) - .build(); - List allShas = new ArrayList<>(); - String url = apiUrl + "/repos/" + repoFullName + "/commits?path=" + filePath + "&per_page=100"; + String url = storeConfig.getApiUrl() + "/repos/" + repoFullName + "/commits?path=" + + encodePathSegment(filePath) + "&sha=" + encodeQueryValue(branch) + "&per_page=100"; while (url != null && allShas.size() < maxVersions) { HttpRequest request = buildRequest(url); - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { LOG.warn("GitHub API returned {} for commits on {}/{}", response.statusCode(), repoFullName, filePath); @@ -85,7 +101,7 @@ public List getFileVersions(String repoFullName, String filePath) { List pageShas = extractShas(response.body(), maxVersions - allShas.size()); allShas.addAll(pageShas); - url = extractNextLink(response.headers()); + url = nextPageUrl(response.headers()); } if (allShas.isEmpty()) { @@ -93,7 +109,7 @@ public List getFileVersions(String repoFullName, String filePath) { } List chronological = new ArrayList<>(allShas); Collections.reverse(chronological); - cache.putVersions(repoFullName, filePath, chronological); + cache.putVersions(repoFullName, branch, filePath, chronological); return chronological; } catch (Exception e) { LOG.warn("Failed to fetch versions for {}/{}: {}", repoFullName, filePath, e.getMessage()); @@ -108,14 +124,11 @@ public String getFileAtVersion(String repoFullName, String filePath, String sha) } try { - String url = apiUrl + "/repos/" + repoFullName + "/contents/" + filePath + "?ref=" + sha; - HttpClient client = HttpClient.newBuilder() - .proxy(ProxySelector.getDefault()) - .connectTimeout(Duration.ofSeconds(connectTimeoutSeconds)) - .build(); + String url = storeConfig.getApiUrl() + "/repos/" + repoFullName + "/contents/" + + encodePathSegment(filePath) + "?ref=" + encodeQueryValue(sha); HttpRequest request = buildRequest(url, "application/vnd.github.raw+json"); - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { LOG.warn("GitHub API returned {} for content at SHA {} for {}/{}", response.statusCode(), sha, repoFullName, filePath); @@ -131,6 +144,22 @@ public String getFileAtVersion(String repoFullName, String filePath, String sha) } } + // Percent-encodes a repo-controlled relative path as a sequence of URL path + // segments (preserving "/" as a separator, encoding everything else) - filePath + // comes from the repo's own tree (InMemoryRegistryService), not a request, so a + // file named e.g. "x?ref=other&y" must not be able to inject extra query + // parameters or alter the request the way an unencoded concatenation would. + private static String encodePathSegment(String relativePath) { + return java.util.Arrays.stream(relativePath.split("/", -1)) + .map(segment -> URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20")) + .reduce((a, b) -> a + "/" + b) + .orElse(""); + } + + private static String encodeQueryValue(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + List extractShas(String json, int limit) { List shas = new ArrayList<>(); try { @@ -162,6 +191,29 @@ String extractNextLink(HttpHeaders headers) { return null; } + // Only follows a Link: rel="next" URL whose host matches the configured API host - + // extractNextLink alone would re-attach the Authorization: Bearer + // header (via buildRequest) to whatever host the upstream response names. + private String nextPageUrl(HttpHeaders headers) { + String next = extractNextLink(headers); + if (next == null) { + return null; + } + try { + String nextHost = URI.create(next).getHost(); + String configuredHost = URI.create(storeConfig.getApiUrl()).getHost(); + if (nextHost == null || !nextHost.equalsIgnoreCase(configuredHost)) { + LOG.warn("Ignoring GitHub API Link header pointing at a different host ({}) than the configured api-url ({})", + nextHost, configuredHost); + return null; + } + } catch (IllegalArgumentException e) { + LOG.warn("Ignoring unparsable GitHub API Link header: {}", e.getMessage()); + return null; + } + return next; + } + private HttpRequest buildRequest(String url) { return buildRequest(url, "application/json"); } @@ -173,8 +225,9 @@ private HttpRequest buildRequest(String url, String accept) { .header("Accept", accept) .GET(); - if (serviceToken.isPresent() && !serviceToken.get().isBlank()) { - requestBuilder.header("Authorization", "Bearer " + serviceToken.get()); + String serviceToken = storeConfig.getServiceToken(); + if (serviceToken != null && !serviceToken.isBlank()) { + requestBuilder.header("Authorization", "Bearer " + serviceToken); } return requestBuilder.build(); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java index 0294b945cd..b7e8491689 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java @@ -126,7 +126,8 @@ void return_sha_versions_when_version_service_available() throws Exception { store.versionService = mockVersionService; when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); - when(mockVersionService.getFileVersions("finos/architecture-as-code", "architectures/test.json")) + when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(mockVersionService.getFileVersions("finos/architecture-as-code", "main", "architectures/test.json")) .thenReturn(java.util.List.of("abc1234", "def5678")); int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java index 6055945fae..90aa0ad17d 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java @@ -132,7 +132,8 @@ void return_sha_versions_when_version_service_available() throws Exception { store.versionService = mockVersionService; when(mockCloneManager.getRepoForNamespace(NAMESPACE)).thenReturn("finos/architecture-as-code"); - when(mockVersionService.getFileVersions("finos/architecture-as-code", "building-blocks/test.json")) + when(mockCloneManager.getBranchForNamespace(NAMESPACE)).thenReturn("main"); + when(mockVersionService.getFileVersions("finos/architecture-as-code", "main", "building-blocks/test.json")) .thenReturn(List.of("abc1234", "def5678")); List versions = store.getBuildingBlockVersions(NAMESPACE, HASH_ID); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index 4b212f4a3f..d4c11d0a06 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -167,16 +167,16 @@ void return_versions_for_control_in_accessible_namespace_with_mixed_access(@Temp @Test void return_versions_list_for_existing_control() throws Exception { - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/my-control.json"), + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), CalmResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(DOMAIN, List.of(entry)), - Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry), Map.of(CalmResourceType.CONTROL, List.of(entry)) ); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); - when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); List versions = store.getRequirementVersions(DOMAIN, HASH_ID); @@ -186,24 +186,25 @@ void return_versions_list_for_existing_control() throws Exception { @Test void return_sha_versions_when_version_service_available() throws Exception { - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/my-control.json"), + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), CalmResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(DOMAIN, List.of(entry)), - Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry), Map.of(CalmResourceType.CONTROL, List.of(entry)) ); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); - when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); GitHubVersionService mockVersionService = mock(GitHubVersionService.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; - when(mockCloneManager.getRepoForNamespace(DOMAIN)).thenReturn("org/repo"); - when(mockVersionService.getFileVersions("org/repo", "controls/my-control.json")) + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("org/repo"); + when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(mockVersionService.getFileVersions("org/repo", "main", "controls/security/my-control.json")) .thenReturn(List.of("abc1234", "def5678")); List versions = store.getRequirementVersions(DOMAIN, HASH_ID); @@ -214,20 +215,20 @@ void return_sha_versions_when_version_service_available() throws Exception { @Test void return_control_content_for_version(@TempDir Path tempDir) throws Exception { - Path controlDir = tempDir.resolve("security/controls"); + Path controlDir = tempDir.resolve("finos/controls/security"); Files.createDirectories(controlDir); Files.writeString(controlDir.resolve("my-control.json"), "{\"control\":\"data\"}"); - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/my-control.json"), + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), CalmResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(DOMAIN, List.of(entry)), - Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry), Map.of(CalmResourceType.CONTROL, List.of(entry)) ); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); - when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); store.cloneDirectory = tempDir.toString(); String content = store.getRequirementForVersion(DOMAIN, HASH_ID, "1.0.0"); @@ -237,24 +238,24 @@ void return_control_content_for_version(@TempDir Path tempDir) throws Exception @Test void return_content_from_github_api_for_sha_version() throws Exception { - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/my-control.json"), + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), CalmResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(DOMAIN, List.of(entry)), - Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry), Map.of(CalmResourceType.CONTROL, List.of(entry)) ); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); - when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); GitHubVersionService mockVersionService = mock(GitHubVersionService.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; - when(mockCloneManager.getRepoForNamespace(DOMAIN)).thenReturn("org/repo"); - when(mockVersionService.getFileAtVersion("org/repo", "controls/my-control.json", "abc1234")) + when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("org/repo"); + when(mockVersionService.getFileAtVersion("org/repo", "controls/security/my-control.json", "abc1234")) .thenReturn("{\"control\":\"old-data\"}"); String content = store.getRequirementForVersion(DOMAIN, HASH_ID, "abc1234"); @@ -299,16 +300,16 @@ void throw_domain_not_found_on_get_requirement_for_version() { @Test void throw_requirement_version_not_found_when_file_missing(@TempDir Path tempDir) throws Exception { - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/nonexistent.json"), + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/nonexistent.json"), CalmResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(DOMAIN, List.of(entry)), - Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry), Map.of(CalmResourceType.CONTROL, List.of(entry)) ); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); - when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); store.cloneDirectory = tempDir.toString(); @@ -363,4 +364,25 @@ void throw_unsupported_on_create_configuration_for_version() { assertThrows(UnsupportedOperationException.class, () -> store.createConfigurationForVersion(DOMAIN, 1, 1, "1.0.0", new CreateControlConfiguration())); } + + @Test + void throw_domain_not_found_rather_than_a_cross_domain_match_when_domain_does_not_exist() throws Exception { + // A control with this exact hash id genuinely exists, but only in the "payments" + // domain - requesting it under a domain that doesn't exist anywhere in the + // registry must 404 on the domain, never fall through to returning a different + // domain's control just because the id happened to match. + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/payments/my-control.json"), + CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry), + Map.of(CalmResourceType.CONTROL, List.of(entry)) + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + assertThrows(DomainNotFoundException.class, + () -> store.getRequirementVersions("nonexistent-domain", HASH_ID)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java index ab9c928e9e..090bf9e872 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java @@ -130,7 +130,8 @@ void return_sha_versions_when_version_service_available() throws Exception { store.versionService = mockVersionService; when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); - when(mockVersionService.getFileVersions("finos/architecture-as-code", "flows/payment-flow.json")) + when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(mockVersionService.getFileVersions("finos/architecture-as-code", "main", "flows/payment-flow.json")) .thenReturn(List.of("abc1234", "def5678")); int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java index bffc707fcf..9b3b5813ab 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java @@ -145,7 +145,8 @@ void return_sha_versions_when_version_service_available() throws Exception { store.versionService = mockVersionService; when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); - when(mockVersionService.getFileVersions("finos/architecture-as-code", "interfaces/payment-api.json")) + when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(mockVersionService.getFileVersions("finos/architecture-as-code", "main", "interfaces/payment-api.json")) .thenReturn(List.of("abc1234", "def5678")); int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java index babc34cedb..da3c8b0f4e 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java @@ -143,7 +143,8 @@ void return_sha_versions_when_version_service_available() throws Exception { store.versionService = mockVersionService; when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); - when(mockVersionService.getFileVersions("finos/architecture-as-code", "patterns/event-driven.json")) + when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(mockVersionService.getFileVersions("finos/architecture-as-code", "main", "patterns/event-driven.json")) .thenReturn(List.of("abc1234", "def5678")); int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java index f2466b1ce0..4222f05568 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java @@ -187,7 +187,8 @@ void return_sha_versions_when_version_service_available() throws Exception { store.versionService = mockVersionService; when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); - when(mockVersionService.getFileVersions("finos/repo", "standards/std.json")) + when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(mockVersionService.getFileVersions("finos/repo", "main", "standards/std.json")) .thenReturn(java.util.List.of("abc1234", "def5678")); int hashId = ("std-1".hashCode() & 0x7FFFFFFF); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java index 82a49858d6..fd34b74b0b 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java @@ -147,7 +147,8 @@ void return_sha_versions_when_version_service_available() throws Exception { store.versionService = mockVersionService; when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); - when(mockVersionService.getFileVersions("finos/architecture-as-code", "timelines/release-timeline.json")) + when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(mockVersionService.getFileVersions("finos/architecture-as-code", "main", "timelines/release-timeline.json")) .thenReturn(List.of("abc1234", "def5678")); int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java index 0545e85946..e884c4ebfc 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java @@ -4,8 +4,10 @@ import org.finos.calm.domain.UserAccess; import org.finos.calm.domain.exception.UserAccessNotFoundException; import org.finos.calm.security.OidcRoleResolver; +import org.finos.calm.store.github.util.CalmResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.util.RegistryEntry; import org.finos.calm.store.github.util.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,6 +17,8 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import java.nio.file.Path; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; @@ -116,6 +120,55 @@ void grant_per_namespace_access_independently() { assertThat(result.get(0).getNamespace(), equalTo("public")); } + @Test + void grant_domain_read_derived_from_the_accessible_namespaces_controls() { + when(roleResolver.resolve(eq(identity), eq(ACCESS_GROUPS))) + .thenReturn(OidcRoleResolver.AccessLevel.READ); + RegistryEntry securityControl = new RegistryEntry("ctrl-a", Path.of("controls/security/ctrl-a.json"), + CalmResourceType.CONTROL, "Control A", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(securityControl), "team", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + List result = store.getGrantsForUser("testuser"); + + // Two namespace grants (finos, team) plus one derived domain grant (security) - + // not a namespace grant standing in for domain access, an actual UserAccess + // record with domain set and namespace null. + assertThat(result, hasSize(3)); + List domainGrants = result.stream().filter(g -> g.getDomain() != null).toList(); + assertThat(domainGrants, hasSize(1)); + assertThat(domainGrants.get(0).getDomain(), equalTo("security")); + assertThat(domainGrants.get(0).getNamespace(), is((String) null)); + } + + @Test + void not_grant_domain_read_for_a_domain_only_present_in_an_inaccessible_namespace() { + // "team" is denied (NONE); its controls/payments/... entries must not leak a + // domain grant for "payments" just because the user can read a DIFFERENT + // namespace ("finos") that happens to have no controls at all. + when(roleResolver.resolve(eq(identity), eq(ACCESS_GROUPS))) + .thenReturn(OidcRoleResolver.AccessLevel.NONE); + when(cloneManager.getAccessGroupsForNamespace("public")).thenReturn(Set.of("Everyone")); + when(roleResolver.resolve(eq(identity), eq(Set.of("Everyone")))) + .thenReturn(OidcRoleResolver.AccessLevel.READ); + RegistryEntry paymentsControl = new RegistryEntry("ctrl-b", Path.of("controls/payments/ctrl-b.json"), + CalmResourceType.CONTROL, "Control B", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(), "team", List.of(paymentsControl), "public", List.of()), + Map.of(), + Map.of() + ); + when(registryService.getSnapshot()).thenReturn(snapshot); + + List result = store.getGrantsForUser("testuser"); + + assertThat(result.stream().anyMatch(g -> "payments".equals(g.getDomain())), is(false)); + } + @Test void return_empty_grants_when_no_namespaces() { when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubApiResponseCacheShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubApiResponseCacheShould.java index f9ed4c5051..d1bf9ae1cc 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubApiResponseCacheShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubApiResponseCacheShould.java @@ -35,20 +35,20 @@ void setup() { @Test void construct_via_the_config_property_constructor() { GitHubApiResponseCache service = new GitHubApiResponseCache(MAX_SIZE); - service.putVersions("org/repo", "path/file.json", List.of("abc1234")); - assertThat(service.getVersions("org/repo", "path/file.json").orElse(null), contains("abc1234")); + service.putVersions("org/repo", "main", "path/file.json", List.of("abc1234")); + assertThat(service.getVersions("org/repo", "main", "path/file.json").orElse(null), contains("abc1234")); } @Test void return_empty_versions_for_a_missing_key() { - assertThat(cache.getVersions("org/repo", "path/file.json").isEmpty(), is(true)); + assertThat(cache.getVersions("org/repo", "main", "path/file.json").isEmpty(), is(true)); } @Test void store_and_retrieve_versions() { - cache.putVersions("org/repo", "path/file.json", List.of("abc1234", "def5678")); + cache.putVersions("org/repo", "main", "path/file.json", List.of("abc1234", "def5678")); - Optional> result = cache.getVersions("org/repo", "path/file.json"); + Optional> result = cache.getVersions("org/repo", "main", "path/file.json"); assertThat(result.isPresent(), is(true)); assertThat(result.get(), contains("abc1234", "def5678")); @@ -56,65 +56,65 @@ void store_and_retrieve_versions() { @Test void keep_versions_for_different_files_independent() { - cache.putVersions("org/repo", "path/a.json", List.of("aaa")); - cache.putVersions("org/repo", "path/b.json", List.of("bbb")); + cache.putVersions("org/repo", "main", "path/a.json", List.of("aaa")); + cache.putVersions("org/repo", "main", "path/b.json", List.of("bbb")); - assertThat(cache.getVersions("org/repo", "path/a.json").orElse(null), contains("aaa")); - assertThat(cache.getVersions("org/repo", "path/b.json").orElse(null), contains("bbb")); + assertThat(cache.getVersions("org/repo", "main", "path/a.json").orElse(null), contains("aaa")); + assertThat(cache.getVersions("org/repo", "main", "path/b.json").orElse(null), contains("bbb")); } @Test void overwrite_existing_versions_entry() { - cache.putVersions("org/repo", "path/file.json", List.of("old")); - cache.putVersions("org/repo", "path/file.json", List.of("new")); + cache.putVersions("org/repo", "main", "path/file.json", List.of("old")); + cache.putVersions("org/repo", "main", "path/file.json", List.of("new")); - assertThat(cache.getVersions("org/repo", "path/file.json").orElse(null), contains("new")); + assertThat(cache.getVersions("org/repo", "main", "path/file.json").orElse(null), contains("new")); } @Test void ignore_a_null_versions_value() { - cache.putVersions("org/repo", "path/file.json", null); - assertThat(cache.getVersions("org/repo", "path/file.json").isEmpty(), is(true)); + cache.putVersions("org/repo", "main", "path/file.json", null); + assertThat(cache.getVersions("org/repo", "main", "path/file.json").isEmpty(), is(true)); } @Test void store_a_defensive_copy_so_a_caller_cannot_mutate_the_cached_entry() { List mutable = new ArrayList<>(List.of("original")); - cache.putVersions("org/repo", "path/file.json", mutable); + cache.putVersions("org/repo", "main", "path/file.json", mutable); mutable.add("mutated-after-put"); - assertThat(cache.getVersions("org/repo", "path/file.json").orElse(null), contains("original")); + assertThat(cache.getVersions("org/repo", "main", "path/file.json").orElse(null), contains("original")); } @Test void return_an_immutable_versions_list_so_a_caller_cannot_corrupt_the_cache() { - cache.putVersions("org/repo", "path/file.json", List.of("abc1234")); + cache.putVersions("org/repo", "main", "path/file.json", List.of("abc1234")); - List result = cache.getVersions("org/repo", "path/file.json").orElseThrow(); + List result = cache.getVersions("org/repo", "main", "path/file.json").orElseThrow(); assertThrows(UnsupportedOperationException.class, () -> result.add("should-fail")); } @Test void expire_versions_after_five_minutes() { - cache.putVersions("org/repo", "path/file.json", List.of("abc1234")); - assertThat(cache.getVersions("org/repo", "path/file.json").isPresent(), is(true)); + cache.putVersions("org/repo", "main", "path/file.json", List.of("abc1234")); + assertThat(cache.getVersions("org/repo", "main", "path/file.json").isPresent(), is(true)); ticker.advance(Duration.ofMinutes(5).plusSeconds(1)); - assertThat(cache.getVersions("org/repo", "path/file.json").isEmpty(), is(true)); + assertThat(cache.getVersions("org/repo", "main", "path/file.json").isEmpty(), is(true)); } @Test void not_refresh_versions_ttl_on_read() { - cache.putVersions("org/repo", "path/file.json", List.of("abc1234")); + cache.putVersions("org/repo", "main", "path/file.json", List.of("abc1234")); ticker.advance(Duration.ofMinutes(3)); - assertThat(cache.getVersions("org/repo", "path/file.json").isPresent(), is(true)); + assertThat(cache.getVersions("org/repo", "main", "path/file.json").isPresent(), is(true)); ticker.advance(Duration.ofMinutes(3)); - assertThat(cache.getVersions("org/repo", "path/file.json").isEmpty(), is(true)); + assertThat(cache.getVersions("org/repo", "main", "path/file.json").isEmpty(), is(true)); } @Test @@ -159,12 +159,12 @@ void expire_content_after_365_days() { @Test void expire_versions_and_content_independently_of_each_other() { - cache.putVersions("org/repo", "path/file.json", List.of("abc1234")); + cache.putVersions("org/repo", "main", "path/file.json", List.of("abc1234")); cache.putContentAtSha("org/repo", "path/file.json", "abc1234", "file contents"); ticker.advance(Duration.ofMinutes(5).plusSeconds(1)); - assertThat(cache.getVersions("org/repo", "path/file.json").isEmpty(), is(true)); + assertThat(cache.getVersions("org/repo", "main", "path/file.json").isEmpty(), is(true)); assertThat(cache.getContentAtSha("org/repo", "path/file.json", "abc1234").isPresent(), is(true)); } @@ -178,8 +178,8 @@ void handle_concurrent_access_safely() throws Exception { .>mapToObj(threadId -> executor.submit(() -> { for (int i = 0; i < iterationsPerThread; i++) { String filePath = "path/" + threadId + "/" + i + ".json"; - cache.putVersions("org/repo", filePath, List.of("sha-" + i)); - cache.getVersions("org/repo", filePath); + cache.putVersions("org/repo", "main", filePath, List.of("sha-" + i)); + cache.getVersions("org/repo", "main", filePath); } })) .toList(); @@ -192,7 +192,7 @@ void handle_concurrent_access_safely() throws Exception { for (int threadId = 0; threadId < threadCount; threadId++) { for (int i = 0; i < iterationsPerThread; i++) { String filePath = "path/" + threadId + "/" + i + ".json"; - assertThat(cache.getVersions("org/repo", filePath).orElse(null), contains("sha-" + i)); + assertThat(cache.getVersions("org/repo", "main", filePath).orElse(null), contains("sha-" + i)); } } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java index 4f42a7af6c..3dc64034cb 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java @@ -3,8 +3,6 @@ import org.junit.jupiter.api.Test; import java.nio.file.Path; -import java.util.List; -import java.util.Optional; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -14,14 +12,14 @@ class TestGitHubStoreConfigShould { @Test void return_service_token_when_present() { GitHubStoreConfig config = new GitHubStoreConfig(); - config.serviceToken = Optional.of("ghp_test123"); + config.serviceToken = "ghp_test123"; assertThat(config.getServiceToken(), equalTo("ghp_test123")); } @Test void return_empty_string_when_service_token_absent() { GitHubStoreConfig config = new GitHubStoreConfig(); - config.serviceToken = Optional.empty(); + config.serviceToken = ""; assertThat(config.getServiceToken(), equalTo("")); } @@ -33,16 +31,19 @@ void return_clone_directory_as_path() { } @Test - void return_sync_interval() { + void return_api_url() { GitHubStoreConfig config = new GitHubStoreConfig(); - config.syncInterval = 120; - assertThat(config.getSyncInterval(), equalTo(120)); + config.apiUrl = "https://api.github.com"; + assertThat(config.getApiUrl(), equalTo("https://api.github.com")); } @Test - void return_api_url() { + void init_resolves_defaults_when_nothing_configured() { GitHubStoreConfig config = new GitHubStoreConfig(); - config.apiUrl = "https://api.github.com"; + config.init(); + + assertThat(config.getServiceToken(), equalTo("")); + assertThat(config.getCloneDirectory(), equalTo(Path.of("/tmp/calm-hub-clones"))); assertThat(config.getApiUrl(), equalTo("https://api.github.com")); } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java index 7e00ccaaee..1edc55d774 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java @@ -28,33 +28,39 @@ class TestGitHubVersionServiceShould { private GitHubVersionService service; + private GitHubStoreConfig storeConfig; + @BeforeEach void setup() { service = new GitHubVersionService(); service.cache = cache; - service.apiUrl = "https://api.github.com"; - service.serviceToken = Optional.of("test-token"); + storeConfig = new GitHubStoreConfig(); + storeConfig.apiUrl = "https://api.github.com"; + storeConfig.serviceToken = "test-token"; + storeConfig.cloneDirectory = "/tmp/calm-hub-clones"; + service.storeConfig = storeConfig; service.maxVersions = 100; service.connectTimeoutSeconds = 10; service.requestTimeoutSeconds = 30; + service.init(); } @Test void return_cached_versions_when_available() { List cached = List.of("abc1234", "def5678"); - when(cache.getVersions("org/repo", "path/file.json")).thenReturn(Optional.of(cached)); + when(cache.getVersions("org/repo", "main", "path/file.json")).thenReturn(Optional.of(cached)); - List result = service.getFileVersions("org/repo", "path/file.json"); + List result = service.getFileVersions("org/repo", "main", "path/file.json"); assertThat(result, equalTo(cached)); } @Test void return_latest_when_api_fails() { - when(cache.getVersions(any(), any())).thenReturn(Optional.empty()); + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); // API will fail since we're not running a real server - List result = service.getFileVersions("org/repo", "path/file.json"); + List result = service.getFileVersions("org/repo", "main", "path/file.json"); assertThat(result, hasSize(1)); assertThat(result.get(0), equalTo("latest")); @@ -62,10 +68,10 @@ void return_latest_when_api_fails() { @Test void return_latest_when_no_token() { - service.serviceToken = Optional.empty(); - when(cache.getVersions(any(), any())).thenReturn(Optional.empty()); + storeConfig.serviceToken = ""; + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); - List result = service.getFileVersions("org/repo", "path/file.json"); + List result = service.getFileVersions("org/repo", "main", "path/file.json"); assertThat(result, hasSize(1)); assertThat(result.get(0), equalTo("latest")); From e1d7ad8a96a469733543933aef37a91267955a27 Mon Sep 17 00:00:00 2001 From: James Gough Date: Wed, 9 Sep 2026 20:40:53 +0100 Subject: [PATCH 07/47] chore(calm-hub): remove GitHub-mode dead code left over from prior design iterations GitHubForkPRService, PendingWriteException, and its exception mapper have zero callers now that writes uniformly return GitHubWriteNotSupportedException; GitHubMetrics.contentDetectedCounter was never wired to a production caller either. This is the last slice in the stack, so nothing downstream can need them back. Also closes the one untested branch on GitHubMetrics's constructor (Instance.isResolvable() == true), previously only exercised via the fallback path. --- .../exception/PendingWriteException.java | 32 ----------- .../calm/observability/GitHubMetrics.java | 7 --- .../PendingWriteExceptionMapper.java | 28 ---------- .../github/util/GitHubForkPRService.java | 54 ------------------- .../TestPendingWriteExceptionShould.java | 38 ------------- .../TestGitHubMetricsShould.java | 29 ++++++---- ...TestPendingWriteExceptionMapperShould.java | 39 -------------- .../util/TestGitHubForkPRServiceShould.java | 32 ----------- 8 files changed, 18 insertions(+), 241 deletions(-) delete mode 100644 calm-hub/src/main/java/org/finos/calm/domain/exception/PendingWriteException.java delete mode 100644 calm-hub/src/main/java/org/finos/calm/resources/PendingWriteExceptionMapper.java delete mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubForkPRService.java delete mode 100644 calm-hub/src/test/java/org/finos/calm/domain/exception/TestPendingWriteExceptionShould.java delete mode 100644 calm-hub/src/test/java/org/finos/calm/resources/TestPendingWriteExceptionMapperShould.java delete mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubForkPRServiceShould.java diff --git a/calm-hub/src/main/java/org/finos/calm/domain/exception/PendingWriteException.java b/calm-hub/src/main/java/org/finos/calm/domain/exception/PendingWriteException.java deleted file mode 100644 index cddd922071..0000000000 --- a/calm-hub/src/main/java/org/finos/calm/domain/exception/PendingWriteException.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.finos.calm.domain.exception; - -/** - * Thrown by GitHub stores when a write operation successfully creates a PR. - * The resource layer catches this and returns 202 Accepted with the PR URL. - * This is NOT an error — it signals that the write was accepted asynchronously. - */ -public class PendingWriteException extends Exception { - - private final String pullRequestUrl; - private final int pullRequestNumber; - private final String branch; - - public PendingWriteException(String pullRequestUrl, int pullRequestNumber, String branch) { - super("Change submitted as PR #" + pullRequestNumber); - this.pullRequestUrl = pullRequestUrl; - this.pullRequestNumber = pullRequestNumber; - this.branch = branch; - } - - public String getPullRequestUrl() { - return pullRequestUrl; - } - - public int getPullRequestNumber() { - return pullRequestNumber; - } - - public String getBranch() { - return branch; - } -} diff --git a/calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java b/calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java index 247e5bf9f5..2354a02e6f 100644 --- a/calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java +++ b/calm-hub/src/main/java/org/finos/calm/observability/GitHubMetrics.java @@ -68,11 +68,4 @@ public void recordSyncFailure(Duration duration) { public void recordRegistryRebuild(Duration duration) { registryRebuildTimer.record(duration); } - - public Counter contentDetectedCounter(MeterRegistry registry, String type) { - return Counter.builder("calm.github.content.detected") - .tag("type", type) - .description("Number of CALM documents detected by type") - .register(registry); - } } diff --git a/calm-hub/src/main/java/org/finos/calm/resources/PendingWriteExceptionMapper.java b/calm-hub/src/main/java/org/finos/calm/resources/PendingWriteExceptionMapper.java deleted file mode 100644 index 8f7b00174e..0000000000 --- a/calm-hub/src/main/java/org/finos/calm/resources/PendingWriteExceptionMapper.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.finos.calm.resources; - -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.ext.ExceptionMapper; -import jakarta.ws.rs.ext.Provider; -import org.finos.calm.domain.exception.PendingWriteException; - -import java.util.Map; - -/** - * Maps PendingWriteException to 202 Accepted with PR details. - * This is thrown by GitHub stores when a write successfully creates a PR — - * it's not an error, it's the expected outcome in fork-based write mode. - */ -@Provider -public class PendingWriteExceptionMapper implements ExceptionMapper { - - @Override - public Response toResponse(PendingWriteException e) { - return Response.accepted(Map.of( - "status", "pending", - "pullRequestUrl", e.getPullRequestUrl(), - "pullRequestNumber", e.getPullRequestNumber(), - "message", e.getMessage() - )).type(MediaType.APPLICATION_JSON).build(); - } -} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubForkPRService.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubForkPRService.java deleted file mode 100644 index f965957625..0000000000 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubForkPRService.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.finos.calm.store.github.util; - -import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; - -import io.quarkus.arc.lookup.LookupIfProperty; -import jakarta.enterprise.context.ApplicationScoped; -import org.finos.calm.domain.exception.PendingWriteException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Orchestrates the fork-based write flow: - * 1. Ensure user's fork exists (create if not) - * 2. Sync fork to upstream HEAD - * 3. Create branch on fork - * 4. Commit file to branch - * 5. Open cross-repo PR: user/fork:branch -> upstream:main - * - * Requires: user's GitHub token (from session cookie) and the upstream repo details. - */ -@LookupIfProperty(name = "calm.database.mode", stringValue = "github") -@ApplicationScoped -public class GitHubForkPRService { - - private static final Logger LOG = LoggerFactory.getLogger(GitHubForkPRService.class); - - /** - * Creates a PR for the given content change. - * - * @param userGhToken the user's GitHub OAuth token - * @param upstreamRepo the upstream repo (e.g. "finos/architecture-as-code") - * @param branch the upstream branch (e.g. "main") - * @param filePath the file path within the repo (e.g. "patterns/event-driven.json") - * @param content the JSON content to write - * @param commitMessage the commit message - * @return never — always throws PendingWriteException on success - * @throws PendingWriteException on successful PR creation (contains PR URL) - * @throws IllegalStateException if the GitHub API calls fail - */ - public void createPullRequest(String userGhToken, String upstreamRepo, String branch, - String filePath, String content, String commitMessage) - throws PendingWriteException { - // Full implementation will: - // 1. GET /repos/{upstream}/forks filtered by user -> find or create fork - // 2. POST /repos/{user}/{repo}/merge-upstream -> sync fork - // 3. POST /repos/{user}/{repo}/git/refs -> create branch - // 4. PUT /repos/{user}/{repo}/contents/{path} -> commit file - // 5. POST /repos/{upstream}/pulls -> open cross-repo PR - - // Placeholder — will be implemented when GitHub OAuth token exchange is complete - throw new GitHubWriteNotSupportedException( - "PR creation requires GitHub account linking. This will be enabled when the full OAuth flow is wired."); - } -} diff --git a/calm-hub/src/test/java/org/finos/calm/domain/exception/TestPendingWriteExceptionShould.java b/calm-hub/src/test/java/org/finos/calm/domain/exception/TestPendingWriteExceptionShould.java deleted file mode 100644 index a82e873022..0000000000 --- a/calm-hub/src/test/java/org/finos/calm/domain/exception/TestPendingWriteExceptionShould.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.finos.calm.domain.exception; - -import org.junit.jupiter.api.Test; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.equalTo; - -class TestPendingWriteExceptionShould { - - @Test - void carry_pr_url() { - PendingWriteException ex = new PendingWriteException("https://github.com/org/repo/pull/7", 7, "calm-hub/pattern-xyz"); - - assertThat(ex.getPullRequestUrl(), equalTo("https://github.com/org/repo/pull/7")); - } - - @Test - void carry_pr_number() { - PendingWriteException ex = new PendingWriteException("https://github.com/org/repo/pull/7", 7, "calm-hub/pattern-xyz"); - - assertThat(ex.getPullRequestNumber(), equalTo(7)); - } - - @Test - void carry_branch_name() { - PendingWriteException ex = new PendingWriteException("https://github.com/org/repo/pull/7", 7, "calm-hub/pattern-xyz"); - - assertThat(ex.getBranch(), equalTo("calm-hub/pattern-xyz")); - } - - @Test - void include_pr_number_in_message() { - PendingWriteException ex = new PendingWriteException("https://github.com/org/repo/pull/42", 42, "branch"); - - assertThat(ex.getMessage(), containsString("42")); - } -} diff --git a/calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java b/calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java index ad15ee25a6..f489128e7f 100644 --- a/calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java +++ b/calm-hub/src/test/java/org/finos/calm/observability/TestGitHubMetricsShould.java @@ -89,17 +89,6 @@ void not_update_last_sync_success_epoch_on_failure() { assertThat(gauge.value(), equalTo(0.0)); } - @Test - void create_content_detected_counter() { - Counter counter = metrics.contentDetectedCounter(registry, "architecture"); - assertThat(counter, is(notNullValue())); - counter.increment(); - - Counter found = registry.find("calm.github.content.detected").tag("type", "architecture").counter(); - assertThat(found, is(notNullValue())); - assertThat(found.count(), equalTo(1.0)); - } - @Test @SuppressWarnings("unchecked") void fall_back_to_an_in_memory_registry_when_no_meter_registry_bean_is_available() { @@ -113,4 +102,22 @@ void fall_back_to_an_in_memory_registry_when_no_meter_registry_bean_is_available // No exception means the fallback SimpleMeterRegistry accepted the write; // there is no shared registry here to assert against. } + + @Test + @SuppressWarnings("unchecked") + void use_the_resolved_meter_registry_bean_when_one_is_available() { + // The production branch: a real MeterRegistry bean exists (Micrometer enabled), + // so Instance.get() - not the SimpleMeterRegistry fallback - + // should be what metrics actually get registered against. + Instance resolvable = mock(Instance.class); + when(resolvable.isResolvable()).thenReturn(true); + when(resolvable.get()).thenReturn(registry); + + GitHubMetrics resolvedMetrics = new GitHubMetrics(resolvable); + resolvedMetrics.recordSyncSuccess(Duration.ofSeconds(1)); + + Counter counter = registry.find("calm.github.sync").tag("outcome", "success").counter(); + assertThat(counter, is(notNullValue())); + assertThat(counter.count(), equalTo(1.0)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestPendingWriteExceptionMapperShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestPendingWriteExceptionMapperShould.java deleted file mode 100644 index c6d7af06f2..0000000000 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestPendingWriteExceptionMapperShould.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.finos.calm.resources; - -import jakarta.ws.rs.core.Response; -import org.finos.calm.domain.exception.PendingWriteException; -import org.junit.jupiter.api.Test; - -import java.util.Map; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; - -class TestPendingWriteExceptionMapperShould { - - private final PendingWriteExceptionMapper mapper = new PendingWriteExceptionMapper(); - - @Test - @SuppressWarnings("unchecked") - void return_202_accepted_with_pr_details() { - PendingWriteException ex = new PendingWriteException( - "https://github.com/org/repo/pull/42", 42, "calm-hub/pattern-abc-123"); - - Response response = mapper.toResponse(ex); - - assertThat(response.getStatus(), equalTo(202)); - Map body = (Map) response.getEntity(); - assertThat(body.get("status"), equalTo("pending")); - assertThat(body.get("pullRequestUrl"), equalTo("https://github.com/org/repo/pull/42")); - assertThat(body.get("pullRequestNumber"), equalTo(42)); - } - - @Test - void return_application_json_content_type() { - PendingWriteException ex = new PendingWriteException("https://github.com/x/y/pull/1", 1, "branch"); - - Response response = mapper.toResponse(ex); - - assertThat(response.getMediaType().toString(), equalTo("application/json")); - } -} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubForkPRServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubForkPRServiceShould.java deleted file mode 100644 index e4a1564d00..0000000000 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubForkPRServiceShould.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.finos.calm.store.github.util; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class TestGitHubForkPRServiceShould { - - private GitHubForkPRService service; - - @BeforeEach - void setup() { - service = new GitHubForkPRService(); - } - - @Test - void throw_unsupported_until_oauth_flow_is_wired() { - UnsupportedOperationException ex = assertThrows(UnsupportedOperationException.class, - () -> service.createPullRequest( - "gho_token", - "finos/architecture-as-code", - "main", - "patterns/new-pattern.json", - "{\"nodes\": []}", - "feat: add new pattern" - )); - assertThat(ex.getMessage(), containsString("GitHub account linking")); - } -} From 6e2c2310687992010f4206a3d0d3344fbb21b6ea Mon Sep 17 00:00:00 2001 From: James Gough Date: Wed, 9 Sep 2026 20:41:08 +0100 Subject: [PATCH 08/47] docs(calm-hub): restore producer javadoc dropped by the GitHub-mode wiring diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GITHUB branch added to each *StoreProducer.produce*Store() deleted the pre-existing class/method javadoc as unrelated diff noise rather than an intentional documentation change. Restored verbatim, except StandardStoreProducer's class/method javadoc — which had a pre-existing copy-paste bug referring to "PatternStore" — corrected to "StandardStore" while restoring it. --- .../org/finos/calm/store/producer/AdrStoreProducer.java | 9 +++++++++ .../calm/store/producer/ArchitectureStoreProducer.java | 9 +++++++++ .../finos/calm/store/producer/AuditLogStoreProducer.java | 9 +++++++++ .../finos/calm/store/producer/ControlStoreProducer.java | 9 +++++++++ .../calm/store/producer/CoreSchemaStoreProducer.java | 9 +++++++++ .../calm/store/producer/DecoratorStoreProducer.java | 9 +++++++++ .../finos/calm/store/producer/DomainStoreProducer.java | 9 +++++++++ .../org/finos/calm/store/producer/FlowStoreProducer.java | 9 +++++++++ .../calm/store/producer/InterfaceStoreProducer.java | 9 +++++++++ .../finos/calm/store/producer/LayoutStoreProducer.java | 9 +++++++++ .../calm/store/producer/NamespaceStoreProducer.java | 9 +++++++++ .../finos/calm/store/producer/PatternStoreProducer.java | 9 +++++++++ .../store/producer/ResourceMappingStoreProducer.java | 4 ++++ .../calm/store/producer/SchemaVersionStoreProducer.java | 9 +++++++++ .../finos/calm/store/producer/SearchStoreProducer.java | 9 +++++++++ .../finos/calm/store/producer/StandardStoreProducer.java | 9 +++++++++ .../finos/calm/store/producer/TimelineStoreProducer.java | 9 +++++++++ .../calm/store/producer/UserAccessStoreProducer.java | 9 +++++++++ 18 files changed, 157 insertions(+) diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/AdrStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/AdrStoreProducer.java index 6dcc4f6f4e..c7866a19f3 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/AdrStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/AdrStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoAdrStore; import org.finos.calm.store.nitrite.NitriteAdrStore; +/** + * Producer for AdrStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class AdrStoreProducer { @@ -27,6 +31,11 @@ public class AdrStoreProducer { @Inject Instance gitHubAdrStore; + /** + * Produces the appropriate AdrStore implementation based on the configured database mode. + * + * @return the AdrStore implementation + */ @Produces @ApplicationScoped public AdrStore produceAdrStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/ArchitectureStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/ArchitectureStoreProducer.java index 1eb60f25f1..fa5e4d9720 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/ArchitectureStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/ArchitectureStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoArchitectureStore; import org.finos.calm.store.nitrite.NitriteArchitectureStore; +/** + * Producer for ArchitectureStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class ArchitectureStoreProducer { @@ -27,6 +31,11 @@ public class ArchitectureStoreProducer { @Inject Instance gitHubArchitectureStore; + /** + * Produces the appropriate ArchitectureStore implementation based on the configured database mode. + * + * @return the ArchitectureStore implementation + */ @Produces @ApplicationScoped public ArchitectureStore produceArchitectureStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/AuditLogStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/AuditLogStoreProducer.java index dbeab8bd9f..34a86da274 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/AuditLogStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/AuditLogStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoAuditLogStore; import org.finos.calm.store.nitrite.NitriteAuditLogStore; +/** + * Producer for {@link AuditLogStore} implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class AuditLogStoreProducer { @@ -27,6 +31,11 @@ public class AuditLogStoreProducer { @Inject Instance gitHubAuditLogStore; + /** + * Produces the appropriate AuditLogStore implementation based on the configured database mode. + * + * @return the AuditLogStore implementation + */ @Produces @ApplicationScoped public AuditLogStore produceAuditLogStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/ControlStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/ControlStoreProducer.java index d5bb36db23..8b3c9d3577 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/ControlStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/ControlStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoControlStore; import org.finos.calm.store.nitrite.NitriteControlStore; +/** + * Producer for ControlStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class ControlStoreProducer { @@ -27,6 +31,11 @@ public class ControlStoreProducer { @Inject Instance gitHubControlStore; + /** + * Produces the appropriate ControlStore implementation based on the configured database mode. + * + * @return the ControlStore implementation + */ @Produces @ApplicationScoped public ControlStore produceControlStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/CoreSchemaStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/CoreSchemaStoreProducer.java index dd5a4061fa..de3ac3cb46 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/CoreSchemaStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/CoreSchemaStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoCoreSchemaStore; import org.finos.calm.store.nitrite.NitriteCoreSchemaStore; +/** + * Producer for CoreSchemaStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class CoreSchemaStoreProducer { @@ -27,6 +31,11 @@ public class CoreSchemaStoreProducer { @Inject Instance classpathCoreSchemaStore; + /** + * Produces the appropriate CoreSchemaStore implementation based on the configured database mode. + * + * @return the CoreSchemaStore implementation + */ @Produces @ApplicationScoped public CoreSchemaStore produceCoreSchemaStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/DecoratorStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/DecoratorStoreProducer.java index eaaf8ba9ef..5bc383d63c 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/DecoratorStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/DecoratorStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoDecoratorStore; import org.finos.calm.store.nitrite.NitriteDecoratorStore; +/** + * Producer for DecoratorStore implementations. + * Selects the appropriate implementation based on the configured database mode. + */ @ApplicationScoped public class DecoratorStoreProducer { @@ -27,6 +31,11 @@ public class DecoratorStoreProducer { @Inject Instance gitHubDecoratorStore; + /** + * Produces the appropriate DecoratorStore implementation based on the configured database mode. + * + * @return the DecoratorStore implementation + */ @Produces @ApplicationScoped public DecoratorStore produceDecoratorStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/DomainStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/DomainStoreProducer.java index 9801e4688a..25a5f409f4 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/DomainStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/DomainStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoDomainStore; import org.finos.calm.store.nitrite.NitriteDomainStore; +/** + * Producer for DomainStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class DomainStoreProducer { @@ -27,6 +31,11 @@ public class DomainStoreProducer { @Inject Instance gitHubDomainStore; + /** + * Produces the appropriate DomainStore implementation based on the configured database mode. + * + * @return the DomainStore implementation + */ @Produces @ApplicationScoped public DomainStore produceDomainStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/FlowStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/FlowStoreProducer.java index bea577e8b4..b8caab7bde 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/FlowStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/FlowStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoFlowStore; import org.finos.calm.store.nitrite.NitriteFlowStore; +/** + * Producer for FlowStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class FlowStoreProducer { @@ -27,6 +31,11 @@ public class FlowStoreProducer { @Inject Instance gitHubFlowStore; + /** + * Produces the appropriate FlowStore implementation based on the configured database mode. + * + * @return the FlowStore implementation + */ @Produces @ApplicationScoped public FlowStore produceFlowStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/InterfaceStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/InterfaceStoreProducer.java index 81901592df..2267030b03 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/InterfaceStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/InterfaceStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoInterfaceStore; import org.finos.calm.store.nitrite.NitriteInterfaceStore; +/** + * Producer for InterfaceStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class InterfaceStoreProducer { @@ -27,6 +31,11 @@ public class InterfaceStoreProducer { @Inject Instance gitHubInterfaceStore; + /** + * Produces the appropriate InterfaceStore implementation based on the configured database mode. + * + * @return the InterfaceStore implementation + */ @Produces @ApplicationScoped public InterfaceStore produceInterfaceStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/LayoutStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/LayoutStoreProducer.java index 1a32fbc5e4..66cdee8cf8 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/LayoutStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/LayoutStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoLayoutStore; import org.finos.calm.store.nitrite.NitriteLayoutStore; +/** + * Producer for LayoutStore implementations. + * Selects the appropriate implementation based on the configured database mode. + */ @ApplicationScoped public class LayoutStoreProducer { @@ -27,6 +31,11 @@ public class LayoutStoreProducer { @Inject Instance gitHubLayoutStore; + /** + * Produces the appropriate LayoutStore implementation based on the configured database mode. + * + * @return the LayoutStore implementation + */ @Produces @ApplicationScoped public LayoutStore produceLayoutStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/NamespaceStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/NamespaceStoreProducer.java index 8516ba96c3..6d66cd201e 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/NamespaceStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/NamespaceStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoNamespaceStore; import org.finos.calm.store.nitrite.NitriteNamespaceStore; +/** + * Producer for NamespaceStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class NamespaceStoreProducer { @@ -27,6 +31,11 @@ public class NamespaceStoreProducer { @Inject Instance gitHubNamespaceStore; + /** + * Produces the appropriate NamespaceStore implementation based on the configured database mode. + * + * @return the NamespaceStore implementation + */ @Produces @ApplicationScoped public NamespaceStore produceNamespaceStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/PatternStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/PatternStoreProducer.java index 7b606cd7b7..b6e43bb293 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/PatternStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/PatternStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoPatternStore; import org.finos.calm.store.nitrite.NitritePatternStore; +/** + * Producer for PatternStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class PatternStoreProducer { @@ -27,6 +31,11 @@ public class PatternStoreProducer { @Inject Instance gitHubPatternStore; + /** + * Produces the appropriate PatternStore implementation based on the configured database mode. + * + * @return the PatternStore implementation + */ @Produces @ApplicationScoped public PatternStore producePatternStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/ResourceMappingStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/ResourceMappingStoreProducer.java index d947cd6714..973e1d2fcd 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/ResourceMappingStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/ResourceMappingStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoResourceMappingStore; import org.finos.calm.store.nitrite.NitriteResourceMappingStore; +/** + * Producer for ResourceMappingStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class ResourceMappingStoreProducer { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/SchemaVersionStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/SchemaVersionStoreProducer.java index e5721dfc2a..597251af59 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/SchemaVersionStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/SchemaVersionStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.nitrite.NitriteSchemaVersionStore; import org.finos.calm.store.noop.NoOpSchemaVersionStore; +/** + * Producer for {@link SchemaVersionStore} implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class SchemaVersionStoreProducer { @@ -27,6 +31,11 @@ public class SchemaVersionStoreProducer { @Inject Instance noOpSchemaVersionStore; + /** + * Produces the appropriate SchemaVersionStore implementation based on the configured database mode. + * + * @return the SchemaVersionStore implementation + */ @Produces @ApplicationScoped public SchemaVersionStore produceSchemaVersionStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/SearchStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/SearchStoreProducer.java index 596e7d58c4..7e1ce86c66 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/SearchStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/SearchStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoSearchStore; import org.finos.calm.store.nitrite.NitriteSearchStore; +/** + * Producer for SearchStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class SearchStoreProducer { @@ -27,6 +31,11 @@ public class SearchStoreProducer { @Inject Instance gitHubSearchStore; + /** + * Produces the appropriate SearchStore implementation based on the configured database mode. + * + * @return the SearchStore implementation + */ @Produces @ApplicationScoped public SearchStore produceSearchStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/StandardStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/StandardStoreProducer.java index ca47199faf..320fccf33a 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/StandardStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/StandardStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoStandardStore; import org.finos.calm.store.nitrite.NitriteStandardStore; +/** + * Producer for StandardStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class StandardStoreProducer { @@ -27,6 +31,11 @@ public class StandardStoreProducer { @Inject Instance gitHubStandardStore; + /** + * Produces the appropriate StandardStore implementation based on the configured database mode. + * + * @return the StandardStore implementation + */ @Produces @ApplicationScoped public StandardStore produceStandardStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/TimelineStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/TimelineStoreProducer.java index 2f7856cf8e..c55b237809 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/TimelineStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/TimelineStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoTimelineStore; import org.finos.calm.store.nitrite.NitriteTimelineStore; +/** + * Producer for TimelineStore implementations. + * This class provides either the MongoDB or NitriteDB implementation based on configuration. + */ @ApplicationScoped public class TimelineStoreProducer { @@ -27,6 +31,11 @@ public class TimelineStoreProducer { @Inject Instance gitHubTimelineStore; + /** + * Produces the appropriate TimelineStore implementation based on the configured database mode. + * + * @return the TimelineStore implementation + */ @Produces @ApplicationScoped public TimelineStore produceTimelineStore() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/UserAccessStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/UserAccessStoreProducer.java index 575e2721ad..eba6466e62 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/UserAccessStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/UserAccessStoreProducer.java @@ -11,6 +11,10 @@ import org.finos.calm.store.mongo.MongoUserAccessStore; import org.finos.calm.store.nitrite.NitriteUserAccessStore; +/** + * Producer for UserAccessStore implementations. + * Selects the appropriate implementation based on the configured database mode. + */ @ApplicationScoped public class UserAccessStoreProducer { @@ -27,6 +31,11 @@ public class UserAccessStoreProducer { @Inject Instance gitHubUserAccessStore; + /** + * Produces the appropriate UserAccessStore implementation based on the configured database mode. + * + * @return the UserAccessStore implementation + */ @Produces @ApplicationScoped public UserAccessStore produceUserAccessStore() { From 5ad14c5629a78e7beea4f7b16e46adc19bf81a4e Mon Sep 17 00:00:00 2001 From: James Gough Date: Wed, 9 Sep 2026 20:41:34 +0100 Subject: [PATCH 09/47] fix(calm-hub): populate ClasspathCoreSchemaStore's classpath resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClasspathCoreSchemaStore (the CoreSchemaStore used in GitHub storage mode) has shipped with no production resources under META-INF/calm-schemas/ since it was introduced — only a test fixture exists — so /api/calm/schemas returns an empty version list in every real github-mode deployment. pom.xml now copies the actual schema JSON straight from this repo's own calm/release//meta/ tree at build time, so there's one source of truth rather than a hand-duplicated copy. The two index files (versions.txt and each version's files.txt) are hand-authored under src/main/resources/META-INF/calm-schemas/ — TestClasspathCoreSchemaResourcesShould asserts they match calm/release/ exactly, so drift between the two fails the build rather than silently serving a stale or incomplete catalogue. Only released (non-rc, non-draft) versions are bundled: 1.0, 1.1, 1.2. quarkus.native.resources.includes is also set: GraalVM native-image excludes classpath resources by default unless explicitly registered, which would otherwise leave this class serving an empty catalogue in native builds even with the jar build now correctly populated. --- calm-hub/.gitignore | 5 ++ calm-hub/pom.xml | 23 ++++++ .../META-INF/calm-schemas/1.0/files.txt | 8 ++ .../META-INF/calm-schemas/1.1/files.txt | 8 ++ .../META-INF/calm-schemas/1.2/files.txt | 11 +++ .../META-INF/calm-schemas/versions.txt | 3 + .../src/main/resources/application.properties | 7 ++ ...estClasspathCoreSchemaResourcesShould.java | 81 +++++++++++++++++++ 8 files changed, 146 insertions(+) create mode 100644 calm-hub/src/main/resources/META-INF/calm-schemas/1.0/files.txt create mode 100644 calm-hub/src/main/resources/META-INF/calm-schemas/1.1/files.txt create mode 100644 calm-hub/src/main/resources/META-INF/calm-schemas/1.2/files.txt create mode 100644 calm-hub/src/main/resources/META-INF/calm-schemas/versions.txt create mode 100644 calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaResourcesShould.java diff --git a/calm-hub/.gitignore b/calm-hub/.gitignore index 965a7b093d..2b62657870 100644 --- a/calm-hub/.gitignore +++ b/calm-hub/.gitignore @@ -128,6 +128,11 @@ META-INF/ src/main/resources/META-INF/* !src/main/resources/META-INF/native-image/ !src/main/resources/META-INF/native-image/** +# ...and the hand-authored calm-schemas index files (versions.txt / files.txt) that +# ClasspathCoreSchemaStore reads alongside the calm/release/*/meta/*.json content the +# build copies in at compile time (see pom.xml's calm-schemas resource block). +!src/main/resources/META-INF/calm-schemas/ +!src/main/resources/META-INF/calm-schemas/** !src/test/resources/META-INF/ !src/test/resources/META-INF/** diff --git a/calm-hub/pom.xml b/calm-hub/pom.xml index 024ec80d09..61d41352bd 100644 --- a/calm-hub/pom.xml +++ b/calm-hub/pom.xml @@ -204,6 +204,29 @@ + + + + ${project.basedir}/src/main/resources + + + ${project.basedir}/../calm/release + META-INF/calm-schemas + + 1.0/meta/*.json + 1.1/meta/*.json + 1.2/meta/*.json + + + diff --git a/calm-hub/src/main/resources/META-INF/calm-schemas/1.0/files.txt b/calm-hub/src/main/resources/META-INF/calm-schemas/1.0/files.txt new file mode 100644 index 0000000000..3864fb2ccd --- /dev/null +++ b/calm-hub/src/main/resources/META-INF/calm-schemas/1.0/files.txt @@ -0,0 +1,8 @@ +calm.json +control-requirement.json +control.json +core.json +evidence.json +flow.json +interface.json +units.json diff --git a/calm-hub/src/main/resources/META-INF/calm-schemas/1.1/files.txt b/calm-hub/src/main/resources/META-INF/calm-schemas/1.1/files.txt new file mode 100644 index 0000000000..3864fb2ccd --- /dev/null +++ b/calm-hub/src/main/resources/META-INF/calm-schemas/1.1/files.txt @@ -0,0 +1,8 @@ +calm.json +control-requirement.json +control.json +core.json +evidence.json +flow.json +interface.json +units.json diff --git a/calm-hub/src/main/resources/META-INF/calm-schemas/1.2/files.txt b/calm-hub/src/main/resources/META-INF/calm-schemas/1.2/files.txt new file mode 100644 index 0000000000..408269f4dd --- /dev/null +++ b/calm-hub/src/main/resources/META-INF/calm-schemas/1.2/files.txt @@ -0,0 +1,11 @@ +calm-timeline.json +calm.json +control-requirement.json +control.json +core.json +decorators.json +evidence.json +flow.json +interface.json +timeline.json +units.json diff --git a/calm-hub/src/main/resources/META-INF/calm-schemas/versions.txt b/calm-hub/src/main/resources/META-INF/calm-schemas/versions.txt new file mode 100644 index 0000000000..4b76257794 --- /dev/null +++ b/calm-hub/src/main/resources/META-INF/calm-schemas/versions.txt @@ -0,0 +1,3 @@ +1.0 +1.1 +1.2 diff --git a/calm-hub/src/main/resources/application.properties b/calm-hub/src/main/resources/application.properties index aa753714e0..cb3bc8ac12 100644 --- a/calm-hub/src/main/resources/application.properties +++ b/calm-hub/src/main/resources/application.properties @@ -25,6 +25,13 @@ calm.github.cache.max-size=10000 calm.github.http.connect-timeout=${CALM_GITHUB_HTTP_CONNECT_TIMEOUT:10} calm.github.http.request-timeout=${CALM_GITHUB_HTTP_REQUEST_TIMEOUT:30} +# ClasspathCoreSchemaStore (GitHub storage mode's CoreSchemaStore) reads these at +# runtime via the context classloader. GraalVM native-image excludes classpath +# resources by default unless explicitly registered — without this, a native build +# would silently ship an empty schema catalogue despite the resources existing in the +# jar build (see pom.xml's calm-schemas resource block). +quarkus.native.resources.includes=META-INF/calm-schemas/** + # Audit logging: independently toggle persistence to the auditLogs store and # emission of a structured log line under the org.finos.calm.audit category. # Both default to true. sourceIp capture defaults to false and has its own diff --git a/calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaResourcesShould.java b/calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaResourcesShould.java new file mode 100644 index 0000000000..2cc9e28777 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaResourcesShould.java @@ -0,0 +1,81 @@ +package org.finos.calm.store.classpath; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.TreeSet; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Guards against drift between the canonical CALM schemas at ../calm/release and the + * versions.txt / files.txt index files hand-authored under + * src/main/resources/META-INF/calm-schemas/ (see pom.xml's calm-schemas resource + * block, which copies the actual JSON content from ../calm/release at build time). + * ClasspathCoreSchemaStoreShould exercises the store's read behaviour against a + * separate test fixture (which intentionally shadows these production resources on + * the test classpath), so it can't catch this drift itself - hence a dedicated test + * reading the real build output and the source tree directly. + */ +class TestClasspathCoreSchemaResourcesShould { + + private static final Path RELEASE_ROOT = Path.of("..", "calm", "release"); + private static final Path BUILT_SCHEMAS_ROOT = Path.of("target", "classes", "META-INF", "calm-schemas"); + + @Test + void list_every_released_non_rc_version_in_versions_txt() throws IOException { + assumeTrue(Files.isDirectory(BUILT_SCHEMAS_ROOT), "run `mvn process-resources` (or a full build) first"); + + TreeSet releasedVersions = new TreeSet<>(); + try (DirectoryStream dirs = Files.newDirectoryStream(RELEASE_ROOT, Files::isDirectory)) { + for (Path dir : dirs) { + String name = dir.getFileName().toString(); + if (!name.contains("-rc")) { + releasedVersions.add(name); + } + } + } + + TreeSet indexedVersions = new TreeSet<>( + Files.readAllLines(BUILT_SCHEMAS_ROOT.resolve("versions.txt"))); + indexedVersions.removeIf(String::isBlank); + + assertThat(indexedVersions, equalTo(releasedVersions)); + } + + @Test + void list_every_meta_json_file_for_each_indexed_version_in_its_files_txt() throws IOException { + assumeTrue(Files.isDirectory(BUILT_SCHEMAS_ROOT), "run `mvn process-resources` (or a full build) first"); + + for (String version : Files.readAllLines(BUILT_SCHEMAS_ROOT.resolve("versions.txt"))) { + if (version.isBlank()) { + continue; + } + + TreeSet onDisk = new TreeSet<>(); + try (DirectoryStream files = Files.newDirectoryStream( + RELEASE_ROOT.resolve(version).resolve("meta"), "*.json")) { + for (Path file : files) { + onDisk.add(file.getFileName().toString()); + } + } + + TreeSet indexed = new TreeSet<>( + Files.readAllLines(BUILT_SCHEMAS_ROOT.resolve(version).resolve("files.txt"))); + indexed.removeIf(String::isBlank); + + assertThat("files.txt for version " + version, indexed, equalTo(onDisk)); + + for (String fileName : indexed) { + assertThat("build did not copy " + version + "/meta/" + fileName, + Files.exists(BUILT_SCHEMAS_ROOT.resolve(version).resolve("meta").resolve(fileName)), + equalTo(true)); + } + } + } +} From 1069086f3c2829cd3b2a410fd4d204faae46a394 Mon Sep 17 00:00:00 2001 From: James Gough Date: Wed, 9 Sep 2026 20:42:29 +0100 Subject: [PATCH 10/47] test(calm-hub): close GitHub-mode unit test gaps and drop the now-redundant JaCoCo excludes Adds: delete-path tests for the 11 previously-untested delete* methods across 10 GitHub stores; a fixture-directory-symlink test for GitHubFileReader's read-time containment check on an intermediate path segment (the existing tests only covered a symlinked leaf and a same-directory-level escape); getFileAtVersion coverage for GitHubVersionService (cache-hit, non-200, 200, and connection-failure paths were all untested, unlike its sibling getFileVersions) plus pagination/Link-header coverage (multi-page follow, different-host rejection, unparsable-URI rejection); a real local-repo clone success path and a credentialed pull for GitHubRepoSync; dedicated tests for GitHubControlDomains and RegistrySnapshot, previously only exercised transitively. GitHubRepoSync, GitHubVersionService, and ClasspathCoreSchemaStore were excluded from the JaCoCo per-class 90% line-coverage gate on the stated grounds of needing integration-test coverage that didn't exist yet; with these additions all three clear the gate on unit tests alone (87.5%/70.4%/83.7% before, 100%/99%/96% after), so the exclusions no longer hold and are removed. --- calm-hub/pom.xml | 4 - .../TestClasspathCoreSchemaStoreShould.java | 80 +++++++ .../github/TestGitHubAdrStoreShould.java | 6 + .../TestGitHubArchitectureStoreShould.java | 6 + .../github/TestGitHubControlStoreShould.java | 12 + .../TestGitHubDecoratorStoreShould.java | 6 + .../github/TestGitHubFlowStoreShould.java | 6 + .../TestGitHubInterfaceStoreShould.java | 6 + .../github/TestGitHubPatternStoreShould.java | 6 + .../TestGitHubResourceMappingStoreShould.java | 6 + .../github/TestGitHubStandardStoreShould.java | 6 + .../github/TestGitHubTimelineStoreShould.java | 6 + .../util/TestGitHubControlDomainsShould.java | 48 ++++ .../util/TestGitHubFileReaderShould.java | 25 +++ .../github/util/TestGitHubRepoSyncShould.java | 45 ++++ .../util/TestGitHubVersionServiceShould.java | 207 ++++++++++++++++++ .../util/TestRegistrySnapshotShould.java | 112 ++++++++++ 17 files changed, 583 insertions(+), 4 deletions(-) create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubControlDomainsShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestRegistrySnapshotShould.java diff --git a/calm-hub/pom.xml b/calm-hub/pom.xml index 61d41352bd..2407db3f59 100644 --- a/calm-hub/pom.xml +++ b/calm-hub/pom.xml @@ -358,10 +358,6 @@ **/*ArchitectureRequest.* **/config/**/* **/domain/**/* - - **/store/github/util/GitHubRepoSync.* - **/store/github/util/GitHubVersionService.* - **/store/classpath/ClasspathCoreSchemaStore.* diff --git a/calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaStoreShould.java index 9e8ac4820b..d4e3b300bb 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/classpath/TestClasspathCoreSchemaStoreShould.java @@ -3,10 +3,15 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.List; import java.util.Map; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.anEmptyMap; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; @@ -79,4 +84,79 @@ void cache_schemas_on_repeated_calls() { Map second = store.getSchemasForVersion("1.0"); assertThat(first, is(second)); } + + @Test + void return_empty_versions_when_the_versions_index_resource_is_entirely_absent() { + withOverride(hiding("META-INF/calm-schemas/versions.txt"), () -> { + ClasspathCoreSchemaStore isolatedStore = new ClasspathCoreSchemaStore(); + assertThat(isolatedStore.getVersions(), is(notNullValue())); + assertThat(isolatedStore.getVersions().isEmpty(), is(true)); + }); + } + + @Test + void return_empty_schemas_when_a_known_versions_files_index_is_missing() { + withOverride(hiding("META-INF/calm-schemas/1.0/files.txt"), () -> { + ClasspathCoreSchemaStore isolatedStore = new ClasspathCoreSchemaStore(); + assertThat(isolatedStore.getSchemasForVersion("1.0"), is(anEmptyMap())); + }); + } + + @Test + void skip_blank_lines_in_the_files_index() { + withOverride(Map.of("META-INF/calm-schemas/1.0/files.txt", + "\ncore.json\n".getBytes(StandardCharsets.UTF_8)), () -> { + ClasspathCoreSchemaStore isolatedStore = new ClasspathCoreSchemaStore(); + assertThat(isolatedStore.getSchemasForVersion("1.0").containsKey("core"), is(true)); + }); + } + + @Test + void skip_a_file_listed_in_the_index_whose_own_resource_is_missing() { + withOverride(hiding("META-INF/calm-schemas/1.0/meta/core.json"), () -> { + ClasspathCoreSchemaStore isolatedStore = new ClasspathCoreSchemaStore(); + assertThat(isolatedStore.getSchemasForVersion("1.0").containsKey("core"), is(false)); + }); + } + + private static Map hiding(String resourceName) { + Map overrides = new HashMap<>(); + overrides.put(resourceName, null); + return overrides; + } + + /** + * Runs {@code action} with the current thread's context classloader replaced by + * one that serves the given resource-name overrides (a {@code null} value hides + * the resource entirely; otherwise its bytes are served) and delegates everything + * else to the real classloader - restores the original afterwards regardless of + * outcome. + */ + private static void withOverride(Map overrides, Runnable action) { + ClassLoader original = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(new ResourceOverridingClassLoader(original, overrides)); + try { + action.run(); + } finally { + Thread.currentThread().setContextClassLoader(original); + } + } + + private static class ResourceOverridingClassLoader extends ClassLoader { + private final Map overrides; + + ResourceOverridingClassLoader(ClassLoader parent, Map overrides) { + super(parent); + this.overrides = new HashMap<>(overrides); + } + + @Override + public InputStream getResourceAsStream(String name) { + if (overrides.containsKey(name)) { + byte[] bytes = overrides.get(name); + return bytes == null ? null : new ByteArrayInputStream(bytes); + } + return super.getResourceAsStream(name); + } + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java index 81d2b7da5c..4f58708c01 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java @@ -83,6 +83,12 @@ void throw_unsupported_on_create_adr() { () -> store.createAdrForNamespace(new AdrMeta())); } + @Test + void throw_unsupported_on_delete_adr() { + assertThrows(UnsupportedOperationException.class, + () -> store.deleteAdr("finos", 1)); + } + @Test void throw_unsupported_on_get_adr() { assertThrows(UnsupportedOperationException.class, diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java index b7e8491689..32e74c6671 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java @@ -74,6 +74,12 @@ void throw_unsupported_on_create_architecture() { () -> store.createArchitectureForNamespace(new Architecture.ArchitectureBuilder().build())); } + @Test + void throw_unsupported_on_delete_architecture() { + assertThrows(UnsupportedOperationException.class, + () -> store.deleteArchitecture("finos", 1)); + } + @Test void throw_namespace_not_found_on_get_architecture_versions() { when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index d4c11d0a06..cd76405896 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -329,6 +329,12 @@ void throw_unsupported_on_create_requirement_for_version() { () -> store.createRequirementForVersion(DOMAIN, 1, "1.0.0", new CreateControlRequirement())); } + @Test + void throw_unsupported_on_delete_control_requirement() { + assertThrows(UnsupportedOperationException.class, + () -> store.deleteControlRequirement(DOMAIN, 1)); + } + @Test void throw_unsupported_on_get_configurations_for_control() { assertThrows(UnsupportedOperationException.class, @@ -347,6 +353,12 @@ void throw_unsupported_on_create_control_configuration() { () -> store.createControlConfiguration(new CreateControlConfiguration(), DOMAIN, 1)); } + @Test + void throw_unsupported_on_delete_control_configuration() { + assertThrows(UnsupportedOperationException.class, + () -> store.deleteControlConfiguration(DOMAIN, 1, 1)); + } + @Test void throw_unsupported_on_get_configuration_versions() { assertThrows(UnsupportedOperationException.class, diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java index 86e674b93a..59a80326e5 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java @@ -111,4 +111,10 @@ void throw_unsupported_on_update_decorator() { assertThrows(UnsupportedOperationException.class, () -> store.updateDecorator("finos", 1, "{}")); } + + @Test + void throw_unsupported_on_delete_decorator() { + assertThrows(UnsupportedOperationException.class, + () -> store.deleteDecorator("finos", 1)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java index 090bf9e872..09b4287eed 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java @@ -78,6 +78,12 @@ void throw_unsupported_on_create_flow() { () -> store.createFlowForNamespace(new CreateFlowRequest(), "finos")); } + @Test + void throw_unsupported_on_delete_flow() { + assertThrows(UnsupportedOperationException.class, + () -> store.deleteFlow("finos", 1)); + } + @Test void throw_namespace_not_found_on_get_flow_versions() { when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java index 9b3b5813ab..a3f3153cfb 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java @@ -94,6 +94,12 @@ void throw_unsupported_on_create_interface() { () -> store.createInterfaceForNamespace(new CreateInterfaceRequest(), "finos")); } + @Test + void throw_unsupported_on_delete_interface() { + assertThrows(UnsupportedOperationException.class, + () -> store.deleteInterface("finos", 1)); + } + @Test void throw_namespace_not_found_on_get_interface_versions() { when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java index da3c8b0f4e..8e3bb04f42 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java @@ -79,6 +79,12 @@ void throw_unsupported_on_create_pattern() { () -> store.createPatternForNamespace(new CreatePatternRequest(), "finos")); } + @Test + void throw_unsupported_on_delete_pattern() { + assertThrows(UnsupportedOperationException.class, + () -> store.deletePattern("finos", 1)); + } + @Test void throw_unsupported_on_create_pattern_for_version() { assertThrows(UnsupportedOperationException.class, diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java index 8f8a644a18..45bd3d9d40 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java @@ -166,6 +166,12 @@ void throw_on_delete_mapping() { () -> store.deleteMapping(NAMESPACE, ResourceType.ARCHITECTURE, UNIQUE_ID)); } + @Test + void throw_on_delete_mapping_by_numeric_id() { + assertThrows(GitHubWriteNotSupportedException.class, + () -> store.deleteMappingByNumericId(NAMESPACE, ResourceType.ARCHITECTURE, 1)); + } + @Test void map_all_resource_types_correctly() { assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.PATTERN), equalTo(CalmResourceType.PATTERN)); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java index 4222f05568..e75538f7d6 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java @@ -70,6 +70,12 @@ void throw_unsupported_on_create_standard() { () -> store.createStandardForNamespace(new CreateStandardRequest(), "finos")); } + @Test + void throw_unsupported_on_delete_standard() { + assertThrows(UnsupportedOperationException.class, + () -> store.deleteStandard("finos", 1)); + } + @Test void throw_standard_not_found_when_id_does_not_match() throws NamespaceNotFoundException { RegistryEntry entry = new RegistryEntry("std-x", java.nio.file.Path.of("standards/x.json"), diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java index fd34b74b0b..dc99eb84c8 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java @@ -95,6 +95,12 @@ void throw_unsupported_on_create_timeline() { () -> store.createTimelineForNamespace(new CreateTimelineRequest(), "finos")); } + @Test + void throw_unsupported_on_delete_timeline() { + assertThrows(UnsupportedOperationException.class, + () -> store.deleteTimeline("finos", 1)); + } + @Test void throw_namespace_not_found_on_get_timeline_versions() { when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubControlDomainsShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubControlDomainsShould.java new file mode 100644 index 0000000000..8a4b541400 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubControlDomainsShould.java @@ -0,0 +1,48 @@ +package org.finos.calm.store.github.util; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.time.Instant; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +class TestGitHubControlDomainsShould { + + @Test + void extract_the_second_path_segment_as_the_domain_under_controls() { + RegistryEntry entry = new RegistryEntry("access-control", Path.of("controls/security/access-control.json"), + CalmResourceType.CONTROL, "Access Control", Instant.now()); + + assertThat(GitHubControlDomains.extractDomain(entry), equalTo("security")); + } + + @Test + void return_default_when_the_path_is_not_under_controls() { + RegistryEntry entry = new RegistryEntry("core", Path.of("standards/security/core.json"), + CalmResourceType.STANDARD, "Core", Instant.now()); + + assertThat(GitHubControlDomains.extractDomain(entry), equalTo("default")); + } + + @Test + void return_default_when_the_path_has_fewer_than_two_segments() { + RegistryEntry entry = new RegistryEntry("controls", Path.of("controls.json"), + CalmResourceType.CONTROL, "Controls", Instant.now()); + + assertThat(GitHubControlDomains.extractDomain(entry), equalTo("default")); + } + + @Test + void treat_a_direct_childs_filename_as_the_domain_when_controls_has_no_domain_subdirectory() { + // extractDomain takes the second path segment unconditionally once the first is + // "controls" - it doesn't distinguish a domain subdirectory from a file placed + // directly under controls/, so this is exactly what it returns today (including + // the .json suffix), not "default". + RegistryEntry entry = new RegistryEntry("root-control", Path.of("controls/root-control.json"), + CalmResourceType.CONTROL, "Root Control", Instant.now()); + + assertThat(GitHubControlDomains.extractDomain(entry), equalTo("root-control.json")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubFileReaderShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubFileReaderShould.java index 69baea8f95..03d6c41683 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubFileReaderShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubFileReaderShould.java @@ -73,6 +73,31 @@ void reject_a_symlink_pointing_at_a_host_path_outside_the_clone_directory_entire } } + @Test + void reject_a_regular_file_reached_through_a_symlinked_intermediate_directory( + @TempDir Path cloneDirectory) throws IOException { + assumeTrue(supportsSymlinks(cloneDirectory), "filesystem does not support symlinks"); + + // The leaf itself is a regular file - isSymbolicLink(target) is false here - + // so only the toRealPath().startsWith(realRoot) containment check catches + // this. Simulates a repo committing "standards" as a symlink to a directory + // outside the namespace's own clone (e.g. a sibling namespace's clone), with + // real files inside it. + Path namespaceRoot = cloneDirectory.resolve("finos"); + Files.createDirectories(namespaceRoot); + + Path otherNamespaceRoot = cloneDirectory.resolve("other-namespace"); + Files.createDirectories(otherNamespaceRoot.resolve("standards")); + Path secretFile = otherNamespaceRoot.resolve("standards/leak.md"); + Files.writeString(secretFile, "service-token-or-other-secret"); + + Files.createSymbolicLink(namespaceRoot.resolve("standards"), otherNamespaceRoot.resolve("standards")); + + assertThrows(NoSuchFileException.class, () -> + GitHubFileReader.readContained(cloneDirectory.toString(), "finos", + Path.of("standards/leak.md"))); + } + @Test void reject_a_missing_file(@TempDir Path cloneDirectory) throws IOException { Files.createDirectories(cloneDirectory.resolve("finos")); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubRepoSyncShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubRepoSyncShould.java index aff96da2a4..d02587bdac 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubRepoSyncShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubRepoSyncShould.java @@ -100,6 +100,51 @@ void pull_local_repo_successfully() throws GitAPIException, IOException { assertThat(result, is(true)); } + @Test + void pull_local_repo_successfully_with_a_credentials_provider_attached() throws GitAPIException, IOException { + Path originDir = tempDir.resolve("origin-with-token"); + Files.createDirectories(originDir); + try (Git origin = Git.init().setDirectory(originDir.toFile()).setInitialBranch("main").call()) { + Files.writeString(originDir.resolve("test.txt"), "hello"); + origin.add().addFilepattern("test.txt").call(); + origin.commit().setMessage("init").call(); + } + + Path cloneDir = tempDir.resolve("clone-with-token"); + try (Git ignored = Git.cloneRepository() + .setURI(originDir.toUri().toString()) + .setDirectory(cloneDir.toFile()) + .setBranch("main") + .call()) { + // clone done + } + + // A local file:// remote ignores the credentials provider entirely, but this + // still exercises the setCredentialsProvider branch (a non-blank token) that + // the "" and null cases used by the other pull tests don't reach. + boolean result = repoSync.pullRepo(cloneDir, "a-real-looking-token"); + assertThat(result, is(true)); + } + + @Test + void clone_a_real_repo_successfully_via_file_protocol() throws GitAPIException, IOException { + Path originDir = tempDir.resolve("myrepo.git"); + Files.createDirectories(originDir); + try (Git origin = Git.init().setDirectory(originDir.toFile()).setInitialBranch("main").call()) { + Files.writeString(originDir.resolve("test.txt"), "hello"); + origin.add().addFilepattern("test.txt").call(); + origin.commit().setMessage("init").call(); + } + + repoSync.githubBaseUrl = tempDir.toUri().toString().replaceAll("/$", ""); + Path cloneTarget = tempDir.resolve("cloned-real"); + + boolean result = repoSync.cloneRepo("myrepo", "main", cloneTarget, null); + + assertThat(result, is(true)); + assertThat(repoSync.isValidRepo(cloneTarget), is(true)); + } + @Test void pull_returns_true_when_already_up_to_date() throws GitAPIException, IOException { Path originDir = tempDir.resolve("origin2"); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java index 1edc55d774..df3c9a1c88 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java @@ -1,5 +1,7 @@ package org.finos.calm.store.github.util; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -8,14 +10,21 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import java.net.InetSocketAddress; +import java.net.http.HttpHeaders; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @@ -30,6 +39,8 @@ class TestGitHubVersionServiceShould { private GitHubStoreConfig storeConfig; + private HttpServer server; + @BeforeEach void setup() { service = new GitHubVersionService(); @@ -45,6 +56,13 @@ void setup() { service.init(); } + @AfterEach + void teardown() { + if (server != null) { + server.stop(0); + } + } + @Test void return_cached_versions_when_available() { List cached = List.of("abc1234", "def5678"); @@ -97,6 +115,29 @@ void return_empty_list_when_no_shas_in_response() { assertThat(shas, is(empty())); } + @Test + void return_empty_list_for_malformed_json() { + List shas = service.extractShas("not valid json", 100); + + assertThat(shas, is(empty())); + } + + @Test + void return_null_from_extract_next_link_when_no_link_header_present() { + HttpHeaders headers = HttpHeaders.of(Map.of(), (a, b) -> true); + + assertThat(service.extractNextLink(headers), is(nullValue())); + } + + @Test + void extract_the_next_link_url_from_a_link_header() { + HttpHeaders headers = HttpHeaders.of( + Map.of("Link", List.of("; rel=\"next\"")), + (a, b) -> true); + + assertThat(service.extractNextLink(headers), equalTo("https://api.github.com/repos/org/repo/commits?page=2")); + } + @Test void limit_to_configured_max_versions() { service.maxVersions = 5; @@ -111,4 +152,170 @@ void limit_to_configured_max_versions() { assertThat(shas, hasSize(5)); } + + @Test + void return_cached_content_at_sha_when_available() { + when(cache.getContentAtSha("org/repo", "path/file.json", "abc1234")) + .thenReturn(Optional.of("{\"cached\":true}")); + + String content = service.getFileAtVersion("org/repo", "path/file.json", "abc1234"); + + assertThat(content, equalTo("{\"cached\":true}")); + } + + @Test + void return_null_when_api_returns_non_200_for_content_at_sha() throws Exception { + when(cache.getContentAtSha(any(), any(), any())).thenReturn(Optional.empty()); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/contents/path/file.json", exchange -> { + exchange.sendResponseHeaders(404, -1); + exchange.close(); + }); + server.start(); + storeConfig.apiUrl = "http://localhost:" + server.getAddress().getPort(); + + String content = service.getFileAtVersion("org/repo", "path/file.json", "abc1234"); + + assertThat(content, is(nullValue())); + } + + @Test + void return_file_content_and_cache_it_on_a_200_response() throws Exception { + when(cache.getContentAtSha(any(), any(), any())).thenReturn(Optional.empty()); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/contents/path/file.json", exchange -> { + byte[] body = "{\"milestones\":[]}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + storeConfig.apiUrl = "http://localhost:" + server.getAddress().getPort(); + + String content = service.getFileAtVersion("org/repo", "path/file.json", "abc1234"); + + assertThat(content, equalTo("{\"milestones\":[]}")); + org.mockito.Mockito.verify(cache).putContentAtSha("org/repo", "path/file.json", "abc1234", content); + } + + @Test + void return_null_when_content_fetch_throws() throws Exception { + when(cache.getContentAtSha(any(), any(), any())).thenReturn(Optional.empty()); + + // Bind a server purely to claim a free port, then stop it immediately - + // nothing is listening there any more, so the request throws a connection + // exception, exercising the catch (Exception e) path rather than the + // non-200 branch above. + server = HttpServer.create(new InetSocketAddress(0), 0); + int freedPort = server.getAddress().getPort(); + server.stop(0); + server = null; + storeConfig.apiUrl = "http://localhost:" + freedPort; + // A closed local port can hang until the connect/request timeout rather than + // refusing instantly - keep this test fast rather than waiting out the + // production 10s/30s defaults set up in @BeforeEach. + service.connectTimeoutSeconds = 1; + service.requestTimeoutSeconds = 1; + service.init(); + + String content = service.getFileAtVersion("org/repo", "path/file.json", "abc1234"); + + assertThat(content, is(nullValue())); + } + + @Test + void return_latest_when_the_commits_request_connection_fails() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + server = HttpServer.create(new InetSocketAddress(0), 0); + int freedPort = server.getAddress().getPort(); + server.stop(0); + server = null; + storeConfig.apiUrl = "http://localhost:" + freedPort; + service.connectTimeoutSeconds = 1; + service.requestTimeoutSeconds = 1; + service.init(); + + List result = service.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(result, hasSize(1)); + assertThat(result.get(0), equalTo("latest")); + } + + @Test + void follow_pagination_across_multiple_pages_on_the_same_host() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + AtomicInteger callCount = new AtomicInteger(); + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/commits", exchange -> { + int call = callCount.incrementAndGet(); + byte[] body; + if (call == 1) { + body = "[{\"sha\":\"1111111111111111111111111111111111111111\"}]".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Link", + "; rel=\"next\""); + } else { + body = "[{\"sha\":\"2222222222222222222222222222222222222222\"}]".getBytes(StandardCharsets.UTF_8); + } + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + storeConfig.apiUrl = "http://localhost:" + server.getAddress().getPort(); + + List result = service.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(callCount.get(), equalTo(2)); + assertThat(result, contains("2222222", "1111111")); + } + + @Test + void stop_paginating_when_the_next_link_points_at_a_different_host() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + server = HttpServer.create(new InetSocketAddress(0), 0); + AtomicInteger callCount = new AtomicInteger(); + server.createContext("/repos/org/repo/commits", exchange -> { + callCount.incrementAndGet(); + byte[] body = "[{\"sha\":\"1111111111111111111111111111111111111111\"}]".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Link", "; rel=\"next\""); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + storeConfig.apiUrl = "http://localhost:" + server.getAddress().getPort(); + + List result = service.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(callCount.get(), equalTo(1)); + assertThat(result, contains("1111111")); + } + + @Test + void stop_paginating_when_the_next_link_is_unparsable() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + server = HttpServer.create(new InetSocketAddress(0), 0); + AtomicInteger callCount = new AtomicInteger(); + server.createContext("/repos/org/repo/commits", exchange -> { + callCount.incrementAndGet(); + byte[] body = "[{\"sha\":\"1111111111111111111111111111111111111111\"}]".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Link", "; rel=\"next\""); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + storeConfig.apiUrl = "http://localhost:" + server.getAddress().getPort(); + + List result = service.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(callCount.get(), equalTo(1)); + assertThat(result, contains("1111111")); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestRegistrySnapshotShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestRegistrySnapshotShould.java new file mode 100644 index 0000000000..2880f1a775 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestRegistrySnapshotShould.java @@ -0,0 +1,112 @@ +package org.finos.calm.store.github.util; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +class TestRegistrySnapshotShould { + + private static final RegistryEntry FINOS_PATTERN = new RegistryEntry("finos-pattern", + Path.of("patterns/finos-pattern.json"), CalmResourceType.PATTERN, "FINOS Pattern", Instant.now()); + private static final RegistryEntry FINOS_FLOW = new RegistryEntry("finos-flow", + Path.of("flows/finos-flow.json"), CalmResourceType.FLOW, "FINOS Flow", Instant.now()); + private static final RegistryEntry OTHER_PATTERN = new RegistryEntry("other-pattern", + Path.of("patterns/other-pattern.json"), CalmResourceType.PATTERN, "Other Pattern", Instant.now()); + + private static RegistrySnapshot buildSnapshot() { + return new RegistrySnapshot( + Map.of( + "finos", List.of(FINOS_PATTERN, FINOS_FLOW), + "other", List.of(OTHER_PATTERN) + ), + Map.of( + "finos:finos-pattern", FINOS_PATTERN, + "finos:finos-flow", FINOS_FLOW, + "other:other-pattern", OTHER_PATTERN + ), + Map.of( + CalmResourceType.PATTERN, List.of(FINOS_PATTERN, OTHER_PATTERN), + CalmResourceType.FLOW, List.of(FINOS_FLOW) + ) + ); + } + + @Test + void find_an_entry_by_its_qualified_id() { + Optional found = buildSnapshot().findByUniqueId("finos", "finos-pattern"); + + assertThat(found.isPresent(), is(true)); + assertThat(found.get(), equalTo(FINOS_PATTERN)); + } + + @Test + void not_find_an_entry_that_exists_only_under_a_different_namespace() { + Optional found = buildSnapshot().findByUniqueId("other", "finos-pattern"); + + assertThat(found.isPresent(), is(false)); + } + + @Test + void not_find_an_unknown_unique_id() { + Optional found = buildSnapshot().findByUniqueId("finos", "does-not-exist"); + + assertThat(found.isPresent(), is(false)); + } + + @Test + void list_entries_by_type_scoped_to_one_namespace() { + List patterns = buildSnapshot().listByType("finos", CalmResourceType.PATTERN); + + assertThat(patterns, contains(FINOS_PATTERN)); + } + + @Test + void return_empty_list_by_type_for_a_namespace_with_no_matching_entries() { + List flows = buildSnapshot().listByType("other", CalmResourceType.FLOW); + + assertThat(flows, is(empty())); + } + + @Test + void return_empty_list_by_type_for_an_unknown_namespace() { + List patterns = buildSnapshot().listByType("nonexistent", CalmResourceType.PATTERN); + + assertThat(patterns, is(empty())); + } + + @Test + void list_all_entries_for_a_namespace_regardless_of_type() { + List all = buildSnapshot().listAll("finos"); + + assertThat(all, containsInAnyOrder(FINOS_PATTERN, FINOS_FLOW)); + } + + @Test + void return_empty_list_all_for_an_unknown_namespace() { + assertThat(buildSnapshot().listAll("nonexistent"), is(empty())); + } + + @Test + void list_every_known_namespace() { + assertThat(buildSnapshot().getNamespaces(), containsInAnyOrder("finos", "other")); + } + + @Test + void the_empty_constant_has_no_namespaces_entries_or_types() { + assertThat(RegistrySnapshot.EMPTY.getNamespaces(), is(empty())); + assertThat(RegistrySnapshot.EMPTY.listAll("finos"), is(empty())); + assertThat(RegistrySnapshot.EMPTY.listByType("finos", CalmResourceType.PATTERN), is(empty())); + assertThat(RegistrySnapshot.EMPTY.findByUniqueId("finos", "anything").isPresent(), is(false)); + } +} From 5ab0c365a45cdeadd659abbfad74ec3a68c1acff Mon Sep 17 00:00:00 2001 From: James Gough Date: Wed, 9 Sep 2026 20:42:49 +0100 Subject: [PATCH 11/47] test(calm-hub): add an end-to-end github-mode integration test for DOMAIN_READ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No @QuarkusTest anywhere previously booted calm.database.mode=github — the producer-wiring → registry-rebuild → OidcRoleResolver → GitHubUserAccessStore → CalmHubPermissionChecker chain was only verified by code trace and unit tests with mocked collaborators, not empirically end-to-end. This is the only way to actually confirm the DOMAIN_READ grants GitHubUserAccessStore derives correctly gate GET /api/calm/domains/{domain}/controls — both that a domain within an accessible namespace is readable, and, more importantly, that a domain only present in a namespace the caller cannot access is not (the "any grant unlocks everything" shape a prior slice's review reverted). GitHubFixtureResource seeds two local, plain (non-bare) git repos, cloned over the file:// transport exactly as TestGitHubRepoSyncShould already proves works, standing in for two GitHub-hosted repos — no external network access or GitHub credentials needed. The default "test" config profile is kept rather than introducing a new named one, sidestepping the %. config-expansion trap already hit twice this session on the "oidc" profile; only calm.database.mode and calm.auth.enabled are switched via plain config overrides. IntegrationTestGithubProfile overrides testResources()/disableGlobalTestResources() rather than using a class-level @QuarkusTestResource annotation — the latter is global by default and was observed starting the Mongo and Keycloak containers other integration profiles use even for a run selecting only this profile's test class. Verified non-vacuous twice: reverting the domain-grant derivation loop fails the "domain read succeeds" assertion; reverting the namespace-access gate on it (so every namespace's controls become visible, not just accessible ones) fails the "domain outside every accessible namespace is denied" assertion. Both restored after confirming. --- .../integration/GitHubFixtureResource.java | 87 +++++++++++++++ ...GitHubUserAccessDomainReadIntegration.java | 105 ++++++++++++++++++ .../IntegrationTestGithubProfile.java | 37 ++++++ 3 files changed, 229 insertions(+) create mode 100644 calm-hub/src/integration-test/java/integration/GitHubFixtureResource.java create mode 100644 calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java create mode 100644 calm-hub/src/integration-test/java/integration/IntegrationTestGithubProfile.java diff --git a/calm-hub/src/integration-test/java/integration/GitHubFixtureResource.java b/calm-hub/src/integration-test/java/integration/GitHubFixtureResource.java new file mode 100644 index 0000000000..2710d37bd8 --- /dev/null +++ b/calm-hub/src/integration-test/java/integration/GitHubFixtureResource.java @@ -0,0 +1,87 @@ +package integration; + +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import org.eclipse.jgit.api.Git; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** + * Provisions two local, plain (non-bare) git repos - cloned over the file:// transport + * exactly as {@code TestGitHubRepoSyncShould} proves works for a unit test - standing + * in for two separate GitHub-hosted repos. "finos-repo" carries a control under the + * "security" domain; "other-repo" carries one under "finance". Namespace "finos" is + * configured with access group "group1", "other" with "group2" - see + * {@link IntegrationTestGithubProfile} for how these feed calm.github.namespaces, and + * GitHubUserAccessDomainReadIntegration for what this fixture is proving. + */ +public class GitHubFixtureResource implements QuarkusTestResourceLifecycleManager { + + private static final Logger LOG = LoggerFactory.getLogger(GitHubFixtureResource.class); + + private Path originRoot; + private Path cloneDirectory; + + @Override + public Map start() { + try { + originRoot = Files.createTempDirectory("calm-github-it-origin"); + cloneDirectory = Files.createTempDirectory("calm-github-it-clones"); + + createRepo(originRoot.resolve("finos-repo.git"), "controls/security/access-control.json"); + createRepo(originRoot.resolve("other-repo.git"), "controls/finance/other-control.json"); + + LOG.info("GitHub fixture repos created under {}", originRoot); + } catch (Exception e) { + throw new RuntimeException("Failed to set up GitHub fixture repos", e); + } + + String baseUrl = originRoot.toUri().toString().replaceAll("/$", ""); + + return Map.of( + "calm.database.mode", "github", + "calm.auth.enabled", "true", + "calm.github.oauth.base-url", baseUrl, + "calm.github.clone-directory", cloneDirectory.toString(), + "calm.github.namespaces", "finos|finos-repo|main|group1,other|other-repo|main|group2" + ); + } + + private void createRepo(Path repoDir, String controlRelativePath) throws Exception { + Files.createDirectories(repoDir); + try (Git git = Git.init().setDirectory(repoDir.toFile()).setInitialBranch("main").call()) { + Path controlFile = repoDir.resolve(controlRelativePath); + Files.createDirectories(controlFile.getParent()); + Files.writeString(controlFile, "{}"); + git.add().addFilepattern(".").call(); + git.commit().setMessage("seed fixture control").call(); + } + } + + @Override + public void stop() { + deleteRecursively(originRoot); + deleteRecursively(cloneDirectory); + } + + private void deleteRecursively(Path root) { + if (root == null) { + return; + } + try (var paths = Files.walk(root)) { + paths.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + // best-effort cleanup + } + }); + } catch (IOException ignored) { + // best-effort cleanup + } + } +} diff --git a/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java b/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java new file mode 100644 index 0000000000..115d89df8e --- /dev/null +++ b/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java @@ -0,0 +1,105 @@ +package integration; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.security.TestSecurity; +import jakarta.inject.Inject; +import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.Set; + +import static io.restassured.RestAssured.given; + +/** + * End-to-end proof, against real local git repos (no mocking of GitHubUserAccessStore, + * GitHubCloneManager, or InMemoryRegistryService) that the DOMAIN_READ grants + * GitHubUserAccessStore derives actually gate the {@code /api/calm/domains/{domain}/controls} + * endpoint correctly - both that a domain within an accessible namespace is readable, + * and that a domain that only exists in a namespace the caller cannot access is not + * (this is the exact "any grant unlocks everything" shape slice 4's review reverted, + * so it matters that this is scoped correctly rather than just present). + * + *

See {@link GitHubFixtureResource} for the fixture repos this relies on: namespace + * "finos" (access group "group1") contains a control under domain "security"; namespace + * "other" (access group "group2") contains one under domain "finance". alice only has + * role "group1". + */ +@QuarkusTest +@TestProfile(IntegrationTestGithubProfile.class) +class GitHubUserAccessDomainReadIntegration { + + @Inject + GitHubCloneManager cloneManager; + + @Inject + InMemoryRegistryService registryService; + + private static final Set EXPECTED_NAMESPACES = Set.of("finos", "other"); + + @BeforeEach + void waitForInitialClone() { + // GitHubCloneManager.cloneAll() sets state=READY on its own last line, but + // registryService.rebuild(...) - the step that actually populates the registry + // these assertions read from - runs AFTER cloneAll() returns, in + // GitHubStartupInitializer.cloneAndRebuild(). Polling getState() alone leaves a + // window where a request lands with state=READY but an empty (or partial) + // registry, so poll the registry's own contents instead - the same ordering + // gap noted against the readiness-gate bucket-C item. + Instant deadline = Instant.now().plus(Duration.ofSeconds(30)); + while (!registryService.getSnapshot().getNamespaces().containsAll(EXPECTED_NAMESPACES)) { + if (Instant.now().isAfter(deadline)) { + throw new IllegalStateException( + "Registry did not contain " + EXPECTED_NAMESPACES + " within 30s - clone state: " + + cloneManager.getState() + ", namespaces seen: " + + registryService.getSnapshot().getNamespaces()); + } + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + } + + @Test + @TestSecurity(user = "alice", roles = "group1") + void allow_a_namespace_scoped_read_for_a_user_with_a_matching_access_group() { + given() + .when().get("/api/calm/namespaces/finos/architectures") + .then() + .statusCode(200); + } + + @Test + @TestSecurity(user = "alice", roles = "group1") + void allow_domain_read_for_a_domain_the_users_accessible_namespace_actually_contains() { + given() + .when().get("/api/calm/domains/security/controls") + .then() + .statusCode(200) + .body("values[0].name", org.hamcrest.Matchers.notNullValue()); + } + + @Test + @TestSecurity(user = "alice", roles = "group1") + void deny_domain_read_for_a_domain_that_only_exists_outside_every_namespace_the_user_can_access() { + given() + .when().get("/api/calm/domains/finance/controls") + .then() + .statusCode(403); + } + + @Test + void deny_an_unauthenticated_request() { + given() + .when().get("/api/calm/namespaces/finos/architectures") + .then() + .statusCode(401); + } +} diff --git a/calm-hub/src/integration-test/java/integration/IntegrationTestGithubProfile.java b/calm-hub/src/integration-test/java/integration/IntegrationTestGithubProfile.java new file mode 100644 index 0000000000..2784c8ecfd --- /dev/null +++ b/calm-hub/src/integration-test/java/integration/IntegrationTestGithubProfile.java @@ -0,0 +1,37 @@ +package integration; + +import io.quarkus.test.junit.QuarkusTestProfile; + +import java.util.List; + +/** + * Deliberately does NOT override {@link #getConfigProfile()} - it stays the default + * "test" profile, so the existing {@code %test.} lines in application.properties + * (Mongo devservices, micrometer disabled, etc.) keep applying unchanged. Introducing + * a new named profile here would repeat the class of trap already hit twice this week + * on the "oidc" profile ({@code %.} config-expansion defaults with no fallback, + * etc.) - this only needs calm.database.mode switched to "github" and the github-mode + * properties set, which {@link GitHubFixtureResource#start()} supplies as plain config + * overrides on top of the default profile. + * + *

{@link #testResources()} (rather than a class-level {@code @QuarkusTestResource} + * annotation) is what actually scopes {@link GitHubFixtureResource} to tests using this + * profile - a class-level annotation on the profile itself is not restricted to it the + * same way. {@link #disableGlobalTestResources()} additionally stops OTHER modules' + * globally-scoped resources (the Mongo and Keycloak containers other integration test + * profiles use, neither declared with any scoping of their own) from starting + * alongside this one - observed directly when an early version of this profile still + * had both containers spin up for a run that only selected this profile's test class. + */ +public class IntegrationTestGithubProfile implements QuarkusTestProfile { + + @Override + public List testResources() { + return List.of(new TestResourceEntry(GitHubFixtureResource.class)); + } + + @Override + public boolean disableGlobalTestResources() { + return true; + } +} From b02d58afba57d600dbddff241e7fb46ac243a356 Mon Sep 17 00:00:00 2001 From: James Gough Date: Wed, 9 Sep 2026 20:49:08 +0100 Subject: [PATCH 12/47] fix(calm-hub): remove the unused @TempDir parameter github-code-quality flagged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed in the earlier pass addressing this bot finding on #3066 — the parameter was still present and still unused. --- .../finos/calm/store/github/TestGitHubControlStoreShould.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index cd76405896..9d76ef1c0f 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -145,7 +145,7 @@ void deny_version_lookup_for_control_in_inaccessible_namespace() { } @Test - void return_versions_for_control_in_accessible_namespace_with_mixed_access(@TempDir Path tempDir) throws Exception { + void return_versions_for_control_in_accessible_namespace_with_mixed_access() throws Exception { RegistryEntry accessible = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), CalmResourceType.CONTROL, "My Control", Instant.now()); RegistryEntry restricted = new RegistryEntry("other", Path.of("controls/security/other.json"), From ad3cb29db437a7e56b05c74d709d14f08919bde7 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 19:25:20 +0100 Subject: [PATCH 13/47] refactor(calm-hub): remove the Building Block concept, alias building-blocks/ to Standard "Building Block" was introduced as a new CALM Hub resource type in the GitHub storage backend without going through the CALM concept design process. Office Hours (2026-09-10, #3052) agreed it should be modelled with the existing Standard type instead - GitHubBuildingBlockStore duplicated GitHubStandardStore with a strict subset of its behaviour, so nothing is lost by the merge. - Delete BuildingBlockStore, GitHubBuildingBlockStore, BuildingBlockStoreProducer, and the two BuildingBlock exception types - Remove BUILDING_BLOCK from ResourceType, AuditEntityType, CalmResourceType, and every switch/map keyed on it - Repoint the building-blocks/ directory convention to STANDARD in both CalmContentDetector (JSON) and InMemoryRegistryService (Markdown), so repos using the VSCode plugin's building-blocks/ layout keep working - only the domain concept goes away, not the file layout. Markdown files under building-blocks/ were previously dropped entirely; they are now indexed as Standards, and building-blocks/ + standards/ entries in the same namespace merge into one listing - Remove GUIDELINE for the same reason: detected but never served by any store or endpoint. Files under guidelines/ stop being indexed; nothing regresses since nothing read them Full verify (3329 tests, 0 failures) and JaCoCo coverage checks pass. --- .../org/finos/calm/domain/ResourceType.java | 1 - .../calm/domain/audit/AuditEntityType.java | 3 +- .../BuildingBlockNotFoundException.java | 7 - ...BuildingBlockVersionNotFoundException.java | 7 - .../calm/resources/CalmDocumentParser.java | 2 - .../resources/MappingControllerResource.java | 2 +- .../calm/security/LocationSegmentParser.java | 2 +- .../services/MappingControllerService.java | 7 - .../finos/calm/store/BuildingBlockStore.java | 16 -- .../github/GitHubBuildingBlockStore.java | 125 ---------- .../github/GitHubResourceMappingStore.java | 1 - .../github/util/CalmContentDetector.java | 18 +- .../store/github/util/CalmResourceType.java | 2 - .../github/util/InMemoryRegistryService.java | 3 +- .../producer/BuildingBlockStoreProducer.java | 65 ----- .../TestGitHubBuildingBlockStoreShould.java | 232 ------------------ .../TestGitHubResourceMappingStoreShould.java | 2 +- .../util/TestCalmContentDetectorShould.java | 25 +- .../TestInMemoryRegistryServiceShould.java | 43 +++- .../TestBuildingBlockStoreProducerShould.java | 113 --------- 20 files changed, 69 insertions(+), 607 deletions(-) delete mode 100644 calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockNotFoundException.java delete mode 100644 calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockVersionNotFoundException.java delete mode 100644 calm-hub/src/main/java/org/finos/calm/store/BuildingBlockStore.java delete mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java delete mode 100644 calm-hub/src/main/java/org/finos/calm/store/producer/BuildingBlockStoreProducer.java delete mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java delete mode 100644 calm-hub/src/test/java/org/finos/calm/store/producer/TestBuildingBlockStoreProducerShould.java diff --git a/calm-hub/src/main/java/org/finos/calm/domain/ResourceType.java b/calm-hub/src/main/java/org/finos/calm/domain/ResourceType.java index 485f53a70b..6deafae583 100644 --- a/calm-hub/src/main/java/org/finos/calm/domain/ResourceType.java +++ b/calm-hub/src/main/java/org/finos/calm/domain/ResourceType.java @@ -9,6 +9,5 @@ public enum ResourceType { FLOW, STANDARD, INTERFACE, - BUILDING_BLOCK, CONTROL } diff --git a/calm-hub/src/main/java/org/finos/calm/domain/audit/AuditEntityType.java b/calm-hub/src/main/java/org/finos/calm/domain/audit/AuditEntityType.java index a06208aa24..89b1ecf679 100644 --- a/calm-hub/src/main/java/org/finos/calm/domain/audit/AuditEntityType.java +++ b/calm-hub/src/main/java/org/finos/calm/domain/audit/AuditEntityType.java @@ -20,6 +20,5 @@ public enum AuditEntityType { USER_ACCESS, SCHEMA, LAYOUT, - PATTERN_LAYOUT, - BUILDING_BLOCK + PATTERN_LAYOUT } diff --git a/calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockNotFoundException.java b/calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockNotFoundException.java deleted file mode 100644 index 98d52cba8b..0000000000 --- a/calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockNotFoundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.finos.calm.domain.exception; - -/** - * Exception thrown when the specified building block is not found. - */ -public class BuildingBlockNotFoundException extends Exception { -} diff --git a/calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockVersionNotFoundException.java b/calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockVersionNotFoundException.java deleted file mode 100644 index 730ff34038..0000000000 --- a/calm-hub/src/main/java/org/finos/calm/domain/exception/BuildingBlockVersionNotFoundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.finos.calm.domain.exception; - -/** - * Exception thrown when the specified building block version is not found. - */ -public class BuildingBlockVersionNotFoundException extends Exception { -} diff --git a/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java b/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java index 893991e98f..e2b1f27b59 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java @@ -34,7 +34,6 @@ public class CalmDocumentParser { Map.entry("flows", ResourceType.FLOW), Map.entry("standards", ResourceType.STANDARD), Map.entry("interfaces", ResourceType.INTERFACE), - Map.entry("building-blocks", ResourceType.BUILDING_BLOCK), Map.entry("controls", ResourceType.CONTROL) ); @@ -45,7 +44,6 @@ public class CalmDocumentParser { Map.entry(ResourceType.FLOW, "flows"), Map.entry(ResourceType.STANDARD, "standards"), Map.entry(ResourceType.INTERFACE, "interfaces"), - Map.entry(ResourceType.BUILDING_BLOCK, "building-blocks"), Map.entry(ResourceType.CONTROL, "controls") ); diff --git a/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java b/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java index 7f2a4ad0c8..3f3f80297a 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java @@ -266,7 +266,7 @@ private Response handlePost(String namespace, String type, String name, String p if (resourceType == null) { return Response.status(Response.Status.BAD_REQUEST) .entity("Unsupported resource type: " + STRICT_SANITIZATION_POLICY.sanitize(type) - + ". Supported: patterns, architectures, flows, standards, interfaces, building-blocks").build(); + + ". Supported: patterns, architectures, flows, standards, interfaces").build(); } if ("versions".equals(name)) { return Response.status(Response.Status.BAD_REQUEST) diff --git a/calm-hub/src/main/java/org/finos/calm/security/LocationSegmentParser.java b/calm-hub/src/main/java/org/finos/calm/security/LocationSegmentParser.java index 93b35fa9a3..f10d23433e 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/LocationSegmentParser.java +++ b/calm-hub/src/main/java/org/finos/calm/security/LocationSegmentParser.java @@ -40,7 +40,7 @@ static LocationIds parse(AuditEntityType entityType, String locationPath) { // .../schemas/{version}/meta case SCHEMA -> new LocationIds(segmentBefore(segments, "meta"), null); // .../{plural}/{id}/versions/{version} - case ARCHITECTURE, PATTERN, FLOW, INTERFACE, STANDARD, TIMELINE, BUILDING_BLOCK, CONTROL -> + case ARCHITECTURE, PATTERN, FLOW, INTERFACE, STANDARD, TIMELINE, CONTROL -> new LocationIds(segmentBefore(segments, "versions"), segmentAfter(segments, "versions")); // .../adrs/{id}/revisions/{revision} case ADR -> new LocationIds(segmentBefore(segments, "revisions"), segmentAfter(segments, "revisions")); diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java index b81cc52f87..66d447220a 100644 --- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java +++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java @@ -46,7 +46,6 @@ public class MappingControllerService { private final InterfaceStore interfaceStore; private final DomainStore domainStore; private final ControlStore controlStore; - private final BuildingBlockStore buildingBlockStore; private final CalmDocumentParser documentParser; @Inject @@ -58,7 +57,6 @@ public MappingControllerService(ResourceMappingStore mappingStore, InterfaceStore interfaceStore, DomainStore domainStore, ControlStore controlStore, - BuildingBlockStore buildingBlockStore, CalmDocumentParser documentParser) { this.mappingStore = mappingStore; this.patternStore = patternStore; @@ -68,7 +66,6 @@ public MappingControllerService(ResourceMappingStore mappingStore, this.interfaceStore = interfaceStore; this.domainStore = domainStore; this.controlStore = controlStore; - this.buildingBlockStore = buildingBlockStore; this.documentParser = documentParser; } @@ -160,7 +157,6 @@ public List getVersionsForMapping(ResourceMapping mapping) throws Except } case STANDARD -> standardStore.getStandardVersions(mapping.getNamespace(), mapping.getNumericId()); case INTERFACE -> interfaceStore.getInterfaceVersions(mapping.getNamespace(), mapping.getNumericId()); - case BUILDING_BLOCK -> buildingBlockStore.getBuildingBlockVersions(mapping.getNamespace(), mapping.getNumericId()); case CONTROL -> controlStore.getRequirementVersions(mapping.getNamespace(), mapping.getNumericId()); }; } @@ -193,7 +189,6 @@ public String getResourceJsonForVersion(ResourceMapping mapping, String version) } case STANDARD -> standardStore.getStandardForVersion(mapping.getNamespace(), mapping.getNumericId(), version); case INTERFACE -> interfaceStore.getInterfaceForVersion(mapping.getNamespace(), mapping.getNumericId(), version); - case BUILDING_BLOCK -> buildingBlockStore.getBuildingBlockForVersion(mapping.getNamespace(), mapping.getNumericId(), version); case CONTROL -> controlStore.getRequirementForVersion(mapping.getNamespace(), mapping.getNumericId(), version); }; } @@ -569,7 +564,6 @@ private int createResourceInStore(ResourceType type, String namespace, String js CalmInterface created = interfaceStore.createInterfaceForNamespace(req, namespace); yield created.getId(); } - case BUILDING_BLOCK -> buildingBlockStore.createBuildingBlockForNamespace(namespace, json); case CONTROL -> { throw new UnsupportedOperationException("Control creation via name-based API is not supported"); } }; } @@ -623,7 +617,6 @@ private void createVersionedResourceInStore(ResourceType type, String namespace, CreateInterfaceRequest req = new CreateInterfaceRequest(title, description, json); interfaceStore.createInterfaceForVersion(req, namespace, numericId, version); } - case BUILDING_BLOCK -> buildingBlockStore.createBuildingBlockForVersion(namespace, numericId, version, json); case CONTROL -> { throw new UnsupportedOperationException("Control version creation via name-based API is not supported"); } } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/BuildingBlockStore.java b/calm-hub/src/main/java/org/finos/calm/store/BuildingBlockStore.java deleted file mode 100644 index 8eb7499c94..0000000000 --- a/calm-hub/src/main/java/org/finos/calm/store/BuildingBlockStore.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.finos.calm.store; - -import org.finos.calm.domain.exception.BuildingBlockNotFoundException; -import org.finos.calm.domain.exception.BuildingBlockVersionNotFoundException; -import org.finos.calm.domain.exception.NamespaceNotFoundException; -import org.finos.calm.domain.namespaces.NamespaceResourceSummary; - -import java.util.List; - -public interface BuildingBlockStore { - List getBuildingBlocksForNamespace(String namespace) throws NamespaceNotFoundException; - int createBuildingBlockForNamespace(String namespace, String buildingBlockJson) throws NamespaceNotFoundException; - List getBuildingBlockVersions(String namespace, int buildingBlockId) throws NamespaceNotFoundException, BuildingBlockNotFoundException; - String getBuildingBlockForVersion(String namespace, int buildingBlockId, String version) throws NamespaceNotFoundException, BuildingBlockNotFoundException, BuildingBlockVersionNotFoundException; - void createBuildingBlockForVersion(String namespace, int buildingBlockId, String version, String buildingBlockJson) throws NamespaceNotFoundException, BuildingBlockNotFoundException, BuildingBlockVersionNotFoundException; -} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java deleted file mode 100644 index b62546ba9f..0000000000 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubBuildingBlockStore.java +++ /dev/null @@ -1,125 +0,0 @@ -package org.finos.calm.store.github; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.enterprise.inject.Typed; -import jakarta.inject.Inject; -import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.finos.calm.domain.exception.BuildingBlockNotFoundException; -import org.finos.calm.domain.exception.BuildingBlockVersionNotFoundException; -import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; -import org.finos.calm.domain.exception.NamespaceNotFoundException; -import org.finos.calm.domain.namespaces.NamespaceResourceSummary; -import org.finos.calm.store.BuildingBlockStore; -import org.finos.calm.store.github.util.CalmResourceType; -import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubFileReader; -import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.List; -import java.util.Optional; - -@ApplicationScoped -@Typed(GitHubBuildingBlockStore.class) -public class GitHubBuildingBlockStore implements BuildingBlockStore { - - private static final String WRITE_UNSUPPORTED = - "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; - - private static final Logger LOG = LoggerFactory.getLogger(GitHubBuildingBlockStore.class); - - private final InMemoryRegistryService registryService; - - @Inject - @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") - String cloneDirectory; - - @Inject - GitHubCloneManager cloneManager; - - @Inject - GitHubVersionService versionService; - - @Inject - public GitHubBuildingBlockStore(InMemoryRegistryService registryService) { - this.registryService = registryService; - } - - @Override - public List getBuildingBlocksForNamespace(String namespace) throws NamespaceNotFoundException { - verifyNamespace(namespace); - List entries = registryService.listByType(namespace, CalmResourceType.BUILDING_BLOCK); - return entries.stream() - .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) - .toList(); - } - - @Override - public int createBuildingBlockForNamespace(String namespace, String buildingBlockJson) throws NamespaceNotFoundException { - throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); - } - - @Override - public List getBuildingBlockVersions(String namespace, int buildingBlockId) throws NamespaceNotFoundException, BuildingBlockNotFoundException { - verifyNamespace(namespace); - RegistryEntry entry = findEntryById(namespace, buildingBlockId); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; - String branch = cloneManager != null ? cloneManager.getBranchForNamespace(namespace) : null; - if (repo != null && branch != null && versionService != null) { - return versionService.getFileVersions(repo, branch, entry.filePath().toString()); - } - return List.of("latest"); - } - - @Override - public String getBuildingBlockForVersion(String namespace, int buildingBlockId, String version) throws NamespaceNotFoundException, BuildingBlockNotFoundException, BuildingBlockVersionNotFoundException { - verifyNamespace(namespace); - RegistryEntry entry = findEntryById(namespace, buildingBlockId); - - // If a specific SHA is requested and version service is available, fetch from GitHub API - if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") - && cloneManager != null && versionService != null) { - String repo = cloneManager.getRepoForNamespace(namespace); - if (repo != null) { - String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); - if (content != null) { - return content; - } - } - } - - // Fallback: read from local clone (latest/HEAD) - try { - return GitHubFileReader.readContained(cloneDirectory, namespace, entry.filePath()); - } catch (IOException e) { - LOG.error("Failed to read building block file: {}", entry.filePath(), e); - throw new BuildingBlockVersionNotFoundException(); - } - } - - @Override - public void createBuildingBlockForVersion(String namespace, int buildingBlockId, String version, String buildingBlockJson) throws NamespaceNotFoundException, BuildingBlockNotFoundException, BuildingBlockVersionNotFoundException { - throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); - } - - private RegistryEntry findEntryById(String namespace, int id) throws BuildingBlockNotFoundException { - List entries = registryService.listByType(namespace, CalmResourceType.BUILDING_BLOCK); - Optional found = entries.stream() - .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) - .findFirst(); - if (found.isEmpty()) { - throw new BuildingBlockNotFoundException(); - } - return found.get(); - } - - private void verifyNamespace(String namespace) throws NamespaceNotFoundException { - if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { - throw new NamespaceNotFoundException(); - } - } -} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java index 2a3cd1937a..18885856f0 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java @@ -118,7 +118,6 @@ static CalmResourceType toCalmResourceType(ResourceType type) { case FLOW -> CalmResourceType.FLOW; case STANDARD -> CalmResourceType.STANDARD; case INTERFACE -> CalmResourceType.INTERFACE; - case BUILDING_BLOCK -> CalmResourceType.BUILDING_BLOCK; case CONTROL -> CalmResourceType.CONTROL; }; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmContentDetector.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmContentDetector.java index bcf341537c..08816a7006 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmContentDetector.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmContentDetector.java @@ -13,6 +13,14 @@ * Ported from the VSCode plugin's self-discovery logic: uses a combination of * JSON structure signals (nodes[], relationships[], moments[]) and parent * directory naming conventions (architectures/, patterns/, etc.). + * + *

{@code building-blocks/} is deliberately aliased to {@link CalmResourceType#STANDARD} + * rather than kept as its own type. "Building Block" was introduced as a new CALM Hub + * resource type without going through the CALM concept design process; Office Hours + * (2026-09-10, #3052) agreed it should be modelled as a {@code Standard} instead. Keeping + * the directory case here (rather than deleting it) means repos already using the VSCode + * plugin's building-blocks-directory convention for CALM/architecture JSON files keep + * working — only the domain concept goes away, not the file layout.

*/ @ApplicationScoped public class CalmContentDetector { @@ -81,13 +89,11 @@ private CalmResourceType detectByDirectoryHint(String parentDir, CalmResourceTyp return switch (parentDir.toLowerCase()) { case "patterns" -> CalmResourceType.PATTERN; case "architectures" -> CalmResourceType.ARCHITECTURE; - case "standards" -> CalmResourceType.STANDARD; - case "guidelines" -> CalmResourceType.GUIDELINE; + case "standards", "building-blocks" -> CalmResourceType.STANDARD; case "flows" -> CalmResourceType.FLOW; case "interfaces" -> CalmResourceType.INTERFACE; case "adrs" -> CalmResourceType.ADR; case "decorators" -> CalmResourceType.DECORATOR; - case "building-blocks" -> CalmResourceType.BUILDING_BLOCK; default -> fallback; }; } @@ -97,15 +103,13 @@ private CalmResourceType detectByDirectoryOnly(String parentDir) { return switch (parentDir.toLowerCase()) { case "architectures" -> CalmResourceType.ARCHITECTURE; case "patterns" -> CalmResourceType.PATTERN; - case "standards" -> CalmResourceType.STANDARD; - case "guidelines" -> CalmResourceType.GUIDELINE; + case "standards", "building-blocks" -> CalmResourceType.STANDARD; case "controls" -> CalmResourceType.CONTROL; case "adrs" -> CalmResourceType.ADR; case "flows" -> CalmResourceType.FLOW; case "interfaces" -> CalmResourceType.INTERFACE; case "timelines" -> CalmResourceType.TIMELINE; case "decorators" -> CalmResourceType.DECORATOR; - case "building-blocks" -> CalmResourceType.BUILDING_BLOCK; default -> CalmResourceType.UNKNOWN; }; } @@ -129,7 +133,7 @@ private String getParentDirectoryName(Path filePath) { private boolean isKnownDirectory(String name) { return switch (name) { - case "architectures", "patterns", "standards", "guidelines", + case "architectures", "patterns", "standards", "controls", "adrs", "flows", "interfaces", "timelines", "decorators", "building-blocks" -> true; default -> false; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java index a03d034a81..0964858637 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java @@ -4,13 +4,11 @@ public enum CalmResourceType { ARCHITECTURE, PATTERN, STANDARD, - GUIDELINE, CONTROL, ADR, FLOW, INTERFACE, TIMELINE, DECORATOR, - BUILDING_BLOCK, UNKNOWN } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java index 9ba6980d69..df0952d774 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java @@ -150,8 +150,7 @@ private CalmResourceType detectMarkdownType(Path relativePath) { for (int i = 0; i < relativePath.getNameCount() - 1; i++) { String segment = relativePath.getName(i).toString().toLowerCase(); switch (segment) { - case "standards": return CalmResourceType.STANDARD; - case "guidelines": return CalmResourceType.GUIDELINE; + case "standards", "building-blocks": return CalmResourceType.STANDARD; case "adrs": return CalmResourceType.ADR; default: break; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/BuildingBlockStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/BuildingBlockStoreProducer.java deleted file mode 100644 index ebb093bf8b..0000000000 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/BuildingBlockStoreProducer.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.finos.calm.store.producer; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.enterprise.inject.Instance; -import jakarta.enterprise.inject.Produces; -import jakarta.inject.Inject; -import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.finos.calm.config.DatabaseMode; -import org.finos.calm.store.BuildingBlockStore; -import org.finos.calm.store.github.GitHubBuildingBlockStore; - -@ApplicationScoped -public class BuildingBlockStoreProducer { - - @Inject - @ConfigProperty(name = "calm.database.mode", defaultValue = "mongo") - String databaseMode; - - @Inject - Instance gitHubBuildingBlockStore; - - @Produces - @ApplicationScoped - public BuildingBlockStore produceBuildingBlockStore() { - if (DatabaseMode.GITHUB.equals(databaseMode)) { - return gitHubBuildingBlockStore.get(); - } - // No Mongo or standalone implementation yet — return a no-op that throws on all operations - return new NoOpBuildingBlockStore(); - } - - /** - * Placeholder implementation for non-GitHub modes until Mongo/Nitrite support is added. - */ - private static class NoOpBuildingBlockStore implements BuildingBlockStore { - - private static final String NOT_SUPPORTED = - "Building blocks are not yet supported in standalone or mongo mode"; - - @Override - public java.util.List getBuildingBlocksForNamespace(String namespace) { - throw new UnsupportedOperationException(NOT_SUPPORTED); - } - - @Override - public int createBuildingBlockForNamespace(String namespace, String buildingBlockJson) { - throw new UnsupportedOperationException(NOT_SUPPORTED); - } - - @Override - public java.util.List getBuildingBlockVersions(String namespace, int buildingBlockId) { - throw new UnsupportedOperationException(NOT_SUPPORTED); - } - - @Override - public String getBuildingBlockForVersion(String namespace, int buildingBlockId, String version) { - throw new UnsupportedOperationException(NOT_SUPPORTED); - } - - @Override - public void createBuildingBlockForVersion(String namespace, int buildingBlockId, String version, String buildingBlockJson) { - throw new UnsupportedOperationException(NOT_SUPPORTED); - } - } -} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java deleted file mode 100644 index 90aa0ad17d..0000000000 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubBuildingBlockStoreShould.java +++ /dev/null @@ -1,232 +0,0 @@ -package org.finos.calm.store.github; - -import org.finos.calm.domain.exception.BuildingBlockNotFoundException; -import org.finos.calm.domain.exception.BuildingBlockVersionNotFoundException; -import org.finos.calm.domain.exception.NamespaceNotFoundException; -import org.finos.calm.domain.namespaces.NamespaceResourceSummary; -import org.finos.calm.store.github.util.CalmResourceType; -import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; -import org.finos.calm.store.github.util.RegistrySnapshot; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.util.List; -import java.util.Map; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasSize; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class TestGitHubBuildingBlockStoreShould { - - private static final String NAMESPACE = "finos"; - private static final String UNIQUE_ID = "my-building-block"; - private static final int HASH_ID = UNIQUE_ID.hashCode() & 0x7FFFFFFF; - - @Mock - private InMemoryRegistryService registryService; - - private GitHubBuildingBlockStore store; - - @BeforeEach - void setup() { - store = new GitHubBuildingBlockStore(registryService); - } - - @Test - void return_building_blocks_for_namespace() throws NamespaceNotFoundException { - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/my-building-block.json"), - CalmResourceType.BUILDING_BLOCK, "My Building Block", Instant.now()); - - RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(NAMESPACE, List.of(entry)), - Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), - Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) - ); - when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); - - List result = store.getBuildingBlocksForNamespace(NAMESPACE); - - assertThat(result, hasSize(1)); - assertThat(result.get(0).getName(), equalTo("My Building Block")); - } - - @Test - void throw_namespace_not_found_when_namespace_missing_on_get() { - when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); - - assertThrows(NamespaceNotFoundException.class, - () -> store.getBuildingBlocksForNamespace("nonexistent")); - } - - @Test - void throw_unsupported_on_create_building_block() { - assertThrows(UnsupportedOperationException.class, - () -> store.createBuildingBlockForNamespace(NAMESPACE, "{}")); - } - - @Test - void throw_namespace_not_found_on_get_building_block_versions() { - when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); - - assertThrows(NamespaceNotFoundException.class, - () -> store.getBuildingBlockVersions("nonexistent", 1)); - } - - @Test - void throw_namespace_not_found_on_get_building_block_for_version() { - when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); - - assertThrows(NamespaceNotFoundException.class, - () -> store.getBuildingBlockForVersion("nonexistent", 1, "1.0.0")); - } - - @Test - void return_versions_list_for_existing_building_block() throws Exception { - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/test.json"), - CalmResourceType.BUILDING_BLOCK, "Test", Instant.now()); - RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(NAMESPACE, List.of(entry)), - Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), - Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) - ); - when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); - - List versions = store.getBuildingBlockVersions(NAMESPACE, HASH_ID); - - assertThat(versions, hasSize(1)); - assertThat(versions.get(0), equalTo("latest")); - } - - @Test - void return_sha_versions_when_version_service_available() throws Exception { - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/test.json"), - CalmResourceType.BUILDING_BLOCK, "Test", Instant.now()); - RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(NAMESPACE, List.of(entry)), - Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), - Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) - ); - when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); - - GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); - GitHubVersionService mockVersionService = mock(GitHubVersionService.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; - - when(mockCloneManager.getRepoForNamespace(NAMESPACE)).thenReturn("finos/architecture-as-code"); - when(mockCloneManager.getBranchForNamespace(NAMESPACE)).thenReturn("main"); - when(mockVersionService.getFileVersions("finos/architecture-as-code", "main", "building-blocks/test.json")) - .thenReturn(List.of("abc1234", "def5678")); - - List versions = store.getBuildingBlockVersions(NAMESPACE, HASH_ID); - - assertThat(versions, hasSize(2)); - assertThat(versions.get(0), equalTo("abc1234")); - } - - @Test - void return_building_block_content_for_version(@TempDir Path tempDir) throws Exception { - Path bbDir = tempDir.resolve("finos/building-blocks"); - Files.createDirectories(bbDir); - Files.writeString(bbDir.resolve("test.json"), "{\"nodes\":[],\"relationships\":[]}"); - - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/test.json"), - CalmResourceType.BUILDING_BLOCK, "Test", Instant.now()); - RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(NAMESPACE, List.of(entry)), - Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), - Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) - ); - when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); - - store.cloneDirectory = tempDir.toString(); - String content = store.getBuildingBlockForVersion(NAMESPACE, HASH_ID, "1.0.0"); - - assertThat(content, equalTo("{\"nodes\":[],\"relationships\":[]}")); - } - - @Test - void return_content_from_github_api_for_sha_version() throws Exception { - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/test.json"), - CalmResourceType.BUILDING_BLOCK, "Test", Instant.now()); - RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(NAMESPACE, List.of(entry)), - Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), - Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) - ); - when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); - - GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); - GitHubVersionService mockVersionService = mock(GitHubVersionService.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; - - when(mockCloneManager.getRepoForNamespace(NAMESPACE)).thenReturn("finos/repo"); - when(mockVersionService.getFileAtVersion("finos/repo", "building-blocks/test.json", "abc1234")) - .thenReturn("{\"nodes\":[{\"name\":\"old\"}]}"); - - String content = store.getBuildingBlockForVersion(NAMESPACE, HASH_ID, "abc1234"); - - assertThat(content, equalTo("{\"nodes\":[{\"name\":\"old\"}]}")); - } - - @Test - void throw_building_block_not_found_when_id_does_not_match() { - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/test.json"), - CalmResourceType.BUILDING_BLOCK, "Test", Instant.now()); - RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(NAMESPACE, List.of(entry)), - Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), - Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) - ); - when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); - - assertThrows(BuildingBlockNotFoundException.class, - () -> store.getBuildingBlockVersions(NAMESPACE, 99999)); - } - - @Test - void throw_unsupported_on_create_building_block_for_version() { - assertThrows(UnsupportedOperationException.class, - () -> store.createBuildingBlockForVersion(NAMESPACE, 1, "1.0.0", "{}")); - } - - @Test - void throw_building_block_version_not_found_when_file_missing(@TempDir Path tempDir) throws Exception { - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("building-blocks/nonexistent.json"), - CalmResourceType.BUILDING_BLOCK, "Test", Instant.now()); - RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(NAMESPACE, List.of(entry)), - Map.of(NAMESPACE + ":" + UNIQUE_ID, entry), - Map.of(CalmResourceType.BUILDING_BLOCK, List.of(entry)) - ); - when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(NAMESPACE, CalmResourceType.BUILDING_BLOCK)).thenReturn(List.of(entry)); - - store.cloneDirectory = tempDir.toString(); - - assertThrows(BuildingBlockVersionNotFoundException.class, - () -> store.getBuildingBlockForVersion(NAMESPACE, HASH_ID, "1.0.0")); - } -} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java index 45bd3d9d40..59efe4aaad 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java @@ -179,7 +179,7 @@ void map_all_resource_types_correctly() { assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.FLOW), equalTo(CalmResourceType.FLOW)); assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.STANDARD), equalTo(CalmResourceType.STANDARD)); assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.INTERFACE), equalTo(CalmResourceType.INTERFACE)); - assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.BUILDING_BLOCK), equalTo(CalmResourceType.BUILDING_BLOCK)); + assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.CONTROL), equalTo(CalmResourceType.CONTROL)); } private RegistryEntry createEntry(String uniqueId, CalmResourceType type) { diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java index 1e1a107674..bc6f4ee70b 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java @@ -88,10 +88,12 @@ void detect_standard_from_directory_only() { } @Test - void detect_guideline_from_directory_only() { + void classify_a_file_under_a_removed_guidelines_directory_as_unknown() { + // GUIDELINE was removed as a resource type (Office Hours, 2026-09-10, #3052) — + // nothing served it, so files under guidelines/ are no longer indexed at all. String json = "{\"title\": \"Microservices Guideline\"}"; CalmResourceType type = detector.detect(json, Path.of("guidelines/microservices.json")); - assertThat(type, equalTo(CalmResourceType.GUIDELINE)); + assertThat(type, equalTo(CalmResourceType.UNKNOWN)); } @Test @@ -232,10 +234,11 @@ void detect_standard_from_nodes_in_standards_directory() { } @Test - void detect_guideline_from_nodes_in_guidelines_directory() { + void classify_nodes_in_a_removed_guidelines_directory_as_unknown() { + // GUIDELINE was removed as a resource type (Office Hours, 2026-09-10, #3052). String json = "{\"nodes\": []}"; CalmResourceType type = detector.detect(json, Path.of("guidelines/best.json")); - assertThat(type, equalTo(CalmResourceType.GUIDELINE)); + assertThat(type, equalTo(CalmResourceType.UNKNOWN)); } @Test @@ -246,23 +249,25 @@ void detect_adr_from_nodes_in_adrs_directory() { } @Test - void detect_building_block_from_nodes_in_building_blocks_directory() { + void detect_standard_from_nodes_in_building_blocks_directory() { + // building-blocks/ is aliased to STANDARD (Office Hours, 2026-09-10, #3052) — + // "Building Block" was removed as its own CALM Hub resource type. String json = "{\"nodes\": [{\"unique-id\": \"svc\"}], \"relationships\": []}"; CalmResourceType type = detector.detect(json, Path.of("building-blocks/auth-block.json")); - assertThat(type, equalTo(CalmResourceType.BUILDING_BLOCK)); + assertThat(type, equalTo(CalmResourceType.STANDARD)); } @Test - void detect_building_block_from_directory_only() { + void detect_standard_from_building_blocks_directory_only() { String json = "{\"title\": \"Auth Building Block\"}"; CalmResourceType type = detector.detect(json, Path.of("building-blocks/auth-block.json")); - assertThat(type, equalTo(CalmResourceType.BUILDING_BLOCK)); + assertThat(type, equalTo(CalmResourceType.STANDARD)); } @Test - void detect_building_block_from_nested_path() { + void detect_standard_from_nested_building_blocks_path() { String json = "{\"nodes\": []}"; CalmResourceType type = detector.detect(json, Path.of("building-blocks/sub/auth-block.json")); - assertThat(type, equalTo(CalmResourceType.BUILDING_BLOCK)); + assertThat(type, equalTo(CalmResourceType.STANDARD)); } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestInMemoryRegistryServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestInMemoryRegistryServiceShould.java index 32bb1fdd22..12be6bf40e 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestInMemoryRegistryServiceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestInMemoryRegistryServiceShould.java @@ -166,7 +166,10 @@ void skip_invalid_json_files_without_crashing() throws IOException { } @Test - void detect_guideline_in_guidelines_directory() throws IOException { + void not_index_a_json_file_under_a_removed_guidelines_directory() throws IOException { + // GUIDELINE was removed as a resource type (Office Hours, 2026-09-10, #3052) — + // nothing served it, so files under guidelines/ are dropped entirely, not + // reclassified. Path guideDir = tempDir.resolve("guidelines"); Files.createDirectories(guideDir); Files.writeString(guideDir.resolve("best-practices.json"), @@ -174,8 +177,7 @@ void detect_guideline_in_guidelines_directory() throws IOException { registryService.rebuild(Map.of("finos", tempDir)); - List entries = registryService.listByType("finos", CalmResourceType.GUIDELINE); - assertThat(entries, hasSize(1)); + assertThat(registryService.getSnapshot().listAll("finos"), empty()); } @Test @@ -193,15 +195,46 @@ void detect_markdown_standards_in_nested_directories() throws IOException { } @Test - void detect_markdown_guidelines_in_nested_directories() throws IOException { + void not_index_a_markdown_file_under_a_removed_guidelines_directory() throws IOException { Path guideDir = tempDir.resolve("guidelines/security"); Files.createDirectories(guideDir); Files.writeString(guideDir.resolve("tls-policy.md"), "# TLS Policy\n\nAlways use TLS."); registryService.rebuild(Map.of("finos", tempDir)); - List entries = registryService.listByType("finos", CalmResourceType.GUIDELINE); + assertThat(registryService.getSnapshot().listAll("finos"), empty()); + } + + @Test + void index_a_markdown_file_under_building_blocks_as_a_standard() throws IOException { + // building-blocks/ is aliased to STANDARD (Office Hours, 2026-09-10, #3052). + Path bbDir = tempDir.resolve("building-blocks"); + Files.createDirectories(bbDir); + Files.writeString(bbDir.resolve("auth-block.md"), "# Auth Block\n\nDescribes the auth building block."); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", CalmResourceType.STANDARD); assertThat(entries, hasSize(1)); + assertThat(entries.get(0).uniqueId(), equalTo("auth-block")); + } + + @Test + void merge_building_blocks_and_standards_entries_into_one_listing() throws IOException { + Path stdDir = tempDir.resolve("standards"); + Files.createDirectories(stdDir); + Files.writeString(stdDir.resolve("api-design.json"), + "{\"unique-id\": \"api-design\", \"title\": \"API Design Standard\"}"); + + Path bbDir = tempDir.resolve("building-blocks"); + Files.createDirectories(bbDir); + Files.writeString(bbDir.resolve("auth-block.json"), + "{\"unique-id\": \"auth-block\", \"title\": \"Auth Building Block\"}"); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", CalmResourceType.STANDARD); + assertThat(entries, hasSize(2)); } @Test diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestBuildingBlockStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestBuildingBlockStoreProducerShould.java deleted file mode 100644 index b0f14a9314..0000000000 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestBuildingBlockStoreProducerShould.java +++ /dev/null @@ -1,113 +0,0 @@ -package org.finos.calm.store.producer; - -import org.finos.calm.config.DatabaseMode; -import org.finos.calm.store.BuildingBlockStore; -import org.finos.calm.store.github.GitHubBuildingBlockStore; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; - -import jakarta.enterprise.inject.Instance; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.notNullValue; -import static org.hamcrest.Matchers.sameInstance; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.when; - -@MockitoSettings(strictness = Strictness.LENIENT) -@ExtendWith(MockitoExtension.class) -class TestBuildingBlockStoreProducerShould { - - @Mock - GitHubBuildingBlockStore gitHubBuildingBlockStore; - - @Mock - Instance gitHubBuildingBlockStoreInstance; - - private BuildingBlockStoreProducer producer; - - @BeforeEach - void setup() { - producer = new BuildingBlockStoreProducer(); - when(gitHubBuildingBlockStoreInstance.get()).thenReturn(gitHubBuildingBlockStore); - producer.gitHubBuildingBlockStore = gitHubBuildingBlockStoreInstance; - } - - @Test - void return_github_building_block_store_when_database_mode_is_github() { - producer.databaseMode = DatabaseMode.GITHUB; - - BuildingBlockStore result = producer.produceBuildingBlockStore(); - - assertThat(result, is(sameInstance(gitHubBuildingBlockStore))); - } - - @Test - void return_no_op_store_when_database_mode_is_mongo() { - producer.databaseMode = DatabaseMode.MONGO; - - BuildingBlockStore result = producer.produceBuildingBlockStore(); - - assertThat(result, is(notNullValue())); - } - - @Test - void return_no_op_store_when_database_mode_is_standalone() { - producer.databaseMode = DatabaseMode.STANDALONE; - - BuildingBlockStore result = producer.produceBuildingBlockStore(); - - assertThat(result, is(notNullValue())); - } - - @Test - void no_op_store_throws_on_get_building_blocks_for_namespace() { - producer.databaseMode = DatabaseMode.MONGO; - BuildingBlockStore noOpStore = producer.produceBuildingBlockStore(); - - assertThrows(UnsupportedOperationException.class, - () -> noOpStore.getBuildingBlocksForNamespace("finos")); - } - - @Test - void no_op_store_throws_on_create_building_block_for_namespace() { - producer.databaseMode = DatabaseMode.MONGO; - BuildingBlockStore noOpStore = producer.produceBuildingBlockStore(); - - assertThrows(UnsupportedOperationException.class, - () -> noOpStore.createBuildingBlockForNamespace("finos", "{}")); - } - - @Test - void no_op_store_throws_on_get_building_block_versions() { - producer.databaseMode = DatabaseMode.MONGO; - BuildingBlockStore noOpStore = producer.produceBuildingBlockStore(); - - assertThrows(UnsupportedOperationException.class, - () -> noOpStore.getBuildingBlockVersions("finos", 1)); - } - - @Test - void no_op_store_throws_on_get_building_block_for_version() { - producer.databaseMode = DatabaseMode.MONGO; - BuildingBlockStore noOpStore = producer.produceBuildingBlockStore(); - - assertThrows(UnsupportedOperationException.class, - () -> noOpStore.getBuildingBlockForVersion("finos", 1, "1.0.0")); - } - - @Test - void no_op_store_throws_on_create_building_block_for_version() { - producer.databaseMode = DatabaseMode.MONGO; - BuildingBlockStore noOpStore = producer.produceBuildingBlockStore(); - - assertThrows(UnsupportedOperationException.class, - () -> noOpStore.createBuildingBlockForVersion("finos", 1, "1.0.0", "{}")); - } -} From c14327b2a8eb8081283a96a4e93febb99b4e5e80 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 19:31:06 +0100 Subject: [PATCH 14/47] fix(calm-hub): revert CONTROL from the namespace-scoped front controller Controls are domain-scoped (ControlResource, every ControlStore method takes domain first), but the front controller is namespace-scoped. A prior commit wired CONTROL into ResourceType and passed mapping.getNamespace() into that domain parameter - broken in every storage mode: - GitHub mode: GitHubControlStore.findControlEntry matches file paths against "controls/{domain}/", so a namespace never matches and it throws DomainNotFoundException. That exception isn't caught by MappingControllerResource's list/get-version handlers, so it surfaces as an uncaught 500, not wrong data. This was masked by TestGitHubControlStoreShould using "security" as both the namespace and the domain in most of its fixtures - fixed here to use distinct values so the mix-up can't hide again. - Mongo/nitrite: a CONTROL mapping row is structurally impossible. createMapping requires a real namespace via requireNamespace, but a domain-scoped control has none - it's keyed by (domain, controlId). The only writer throws on the very next step and rolls the mapping back, so no row is ever durably written. The route 404s today and would keep 404ing regardless of the domain fix. Making this work across all backends needs domain-keyed resource_mappings rows, relaxed namespace requirements in two stores, a schema migration in both Mongo and Nitrite, and a backfill - out of scope here. Revert CONTROL from ResourceType, AuditEntityType, CalmDocumentParser's type maps, LocationSegmentParser, and the four MappingControllerService switch arms; the domain-scoped control endpoints in MappingControllerResource (/calm/domains/{domain}/controls/...) are untouched and already correct. controlStore stays injected - handleControlRequirementPost and friends still use it directly. Full verify (3332 tests, 0 failures) and JaCoCo coverage checks pass. --- .../org/finos/calm/domain/ResourceType.java | 3 +- .../calm/domain/audit/AuditEntityType.java | 1 - .../calm/resources/CalmDocumentParser.java | 26 ++++++++--------- .../calm/security/LocationSegmentParser.java | 2 +- .../services/MappingControllerService.java | 4 --- .../github/GitHubResourceMappingStore.java | 1 - .../TestMappingControllerResourceShould.java | 29 +++++++++++++++++++ .../github/TestGitHubControlStoreShould.java | 26 ++++++++++------- .../TestGitHubResourceMappingStoreShould.java | 1 - 9 files changed, 58 insertions(+), 35 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/domain/ResourceType.java b/calm-hub/src/main/java/org/finos/calm/domain/ResourceType.java index 6deafae583..b0c4f46d5e 100644 --- a/calm-hub/src/main/java/org/finos/calm/domain/ResourceType.java +++ b/calm-hub/src/main/java/org/finos/calm/domain/ResourceType.java @@ -8,6 +8,5 @@ public enum ResourceType { ARCHITECTURE, FLOW, STANDARD, - INTERFACE, - CONTROL + INTERFACE } diff --git a/calm-hub/src/main/java/org/finos/calm/domain/audit/AuditEntityType.java b/calm-hub/src/main/java/org/finos/calm/domain/audit/AuditEntityType.java index 89b1ecf679..775a564b65 100644 --- a/calm-hub/src/main/java/org/finos/calm/domain/audit/AuditEntityType.java +++ b/calm-hub/src/main/java/org/finos/calm/domain/audit/AuditEntityType.java @@ -7,7 +7,6 @@ public enum AuditEntityType { NAMESPACE, ARCHITECTURE, PATTERN, - CONTROL, CONTROL_REQUIREMENT, CONTROL_CONFIGURATION, ADR, diff --git a/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java b/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java index e2b1f27b59..6ae0d2bcd3 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java @@ -28,23 +28,21 @@ public class CalmDocumentParser { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); /** Maps the plural URL path segment (e.g. {@code "patterns"}) to {@link ResourceType}. */ - public static final Map TYPE_MAP = Map.ofEntries( - Map.entry("patterns", ResourceType.PATTERN), - Map.entry("architectures", ResourceType.ARCHITECTURE), - Map.entry("flows", ResourceType.FLOW), - Map.entry("standards", ResourceType.STANDARD), - Map.entry("interfaces", ResourceType.INTERFACE), - Map.entry("controls", ResourceType.CONTROL) + public static final Map TYPE_MAP = Map.of( + "patterns", ResourceType.PATTERN, + "architectures", ResourceType.ARCHITECTURE, + "flows", ResourceType.FLOW, + "standards", ResourceType.STANDARD, + "interfaces", ResourceType.INTERFACE ); /** Maps {@link ResourceType} back to its plural URL segment. */ - public static final Map TYPE_PLURAL_MAP = Map.ofEntries( - Map.entry(ResourceType.PATTERN, "patterns"), - Map.entry(ResourceType.ARCHITECTURE, "architectures"), - Map.entry(ResourceType.FLOW, "flows"), - Map.entry(ResourceType.STANDARD, "standards"), - Map.entry(ResourceType.INTERFACE, "interfaces"), - Map.entry(ResourceType.CONTROL, "controls") + public static final Map TYPE_PLURAL_MAP = Map.of( + ResourceType.PATTERN, "patterns", + ResourceType.ARCHITECTURE, "architectures", + ResourceType.FLOW, "flows", + ResourceType.STANDARD, "standards", + ResourceType.INTERFACE, "interfaces" ); @ConfigProperty(name = "calm.hub.base-url", defaultValue = "http://localhost:8080") diff --git a/calm-hub/src/main/java/org/finos/calm/security/LocationSegmentParser.java b/calm-hub/src/main/java/org/finos/calm/security/LocationSegmentParser.java index f10d23433e..c24b9ce1e7 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/LocationSegmentParser.java +++ b/calm-hub/src/main/java/org/finos/calm/security/LocationSegmentParser.java @@ -40,7 +40,7 @@ static LocationIds parse(AuditEntityType entityType, String locationPath) { // .../schemas/{version}/meta case SCHEMA -> new LocationIds(segmentBefore(segments, "meta"), null); // .../{plural}/{id}/versions/{version} - case ARCHITECTURE, PATTERN, FLOW, INTERFACE, STANDARD, TIMELINE, CONTROL -> + case ARCHITECTURE, PATTERN, FLOW, INTERFACE, STANDARD, TIMELINE -> new LocationIds(segmentBefore(segments, "versions"), segmentAfter(segments, "versions")); // .../adrs/{id}/revisions/{revision} case ADR -> new LocationIds(segmentBefore(segments, "revisions"), segmentAfter(segments, "revisions")); diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java index 66d447220a..a3be20167b 100644 --- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java +++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java @@ -157,7 +157,6 @@ public List getVersionsForMapping(ResourceMapping mapping) throws Except } case STANDARD -> standardStore.getStandardVersions(mapping.getNamespace(), mapping.getNumericId()); case INTERFACE -> interfaceStore.getInterfaceVersions(mapping.getNamespace(), mapping.getNumericId()); - case CONTROL -> controlStore.getRequirementVersions(mapping.getNamespace(), mapping.getNumericId()); }; } @@ -189,7 +188,6 @@ public String getResourceJsonForVersion(ResourceMapping mapping, String version) } case STANDARD -> standardStore.getStandardForVersion(mapping.getNamespace(), mapping.getNumericId(), version); case INTERFACE -> interfaceStore.getInterfaceForVersion(mapping.getNamespace(), mapping.getNumericId(), version); - case CONTROL -> controlStore.getRequirementForVersion(mapping.getNamespace(), mapping.getNumericId(), version); }; } @@ -564,7 +562,6 @@ private int createResourceInStore(ResourceType type, String namespace, String js CalmInterface created = interfaceStore.createInterfaceForNamespace(req, namespace); yield created.getId(); } - case CONTROL -> { throw new UnsupportedOperationException("Control creation via name-based API is not supported"); } }; } @@ -617,7 +614,6 @@ private void createVersionedResourceInStore(ResourceType type, String namespace, CreateInterfaceRequest req = new CreateInterfaceRequest(title, description, json); interfaceStore.createInterfaceForVersion(req, namespace, numericId, version); } - case CONTROL -> { throw new UnsupportedOperationException("Control version creation via name-based API is not supported"); } } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java index 18885856f0..1805ae2fb1 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java @@ -118,7 +118,6 @@ static CalmResourceType toCalmResourceType(ResourceType type) { case FLOW -> CalmResourceType.FLOW; case STANDARD -> CalmResourceType.STANDARD; case INTERFACE -> CalmResourceType.INTERFACE; - case CONTROL -> CalmResourceType.CONTROL; }; } diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java index b2d88a864e..f038120a85 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java @@ -1128,6 +1128,35 @@ void return_400_when_get_version_has_invalid_resource_type() { .then().statusCode(400).body(containsString("Unsupported resource type")); } + // ========================================================================= + // Namespace-scoped "controls" is not a supported type on the generic front + // controller - controls are domain-scoped (/calm/domains/{domain}/controls/..., + // tested above), not namespace-scoped. A prior revision wired CONTROL into + // ResourceType/TYPE_MAP here; that was reverted because a control mapping can + // never carry a domain through this namespace-keyed dispatch (see MappingControllerService + // and GitHubResourceMappingStore, which no longer produce a CONTROL arm). + // ========================================================================= + + @Test + void return_400_when_posting_a_control_via_the_namespace_scoped_front_controller() { + given().header("Content-Type", "application/json") + .body("{\"$id\":\"http://localhost:8080/calm/namespaces/finos/controls/access-control/versions/1.0.0\"}") + .when().post("/calm/namespaces/finos/controls/access-control/versions/1.0.0") + .then().statusCode(400).body(containsString("Unsupported resource type")); + } + + @Test + void return_400_when_listing_control_versions_via_the_namespace_scoped_front_controller() { + given().when().get("/calm/namespaces/finos/controls/access-control/versions") + .then().statusCode(400).body(containsString("Unsupported resource type")); + } + + @Test + void return_400_when_getting_a_control_version_via_the_namespace_scoped_front_controller() { + given().when().get("/calm/namespaces/finos/controls/access-control/versions/1.0.0") + .then().statusCode(400).body(containsString("Unsupported resource type")); + } + // ========================================================================= // createNewResource — NamespaceNotFoundException from createMapping // ========================================================================= diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index 9d76ef1c0f..1db18bcb14 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -57,16 +57,20 @@ void setup() { @Test void return_controls_for_domain() throws Exception { + // Registry namespace ("finos") deliberately differs from the control domain + // ("security", derived from the controls/security/ path segment) - the two are + // unrelated concepts, and a fixture where they happen to share a name would hide + // a namespace/domain mix-up regression (see the cross-domain-match test below). RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), CalmResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(DOMAIN, List.of(entry)), - Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry), Map.of(CalmResourceType.CONTROL, List.of(entry)) ); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); - when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); List result = store.getControlsForDomain(DOMAIN); @@ -91,12 +95,12 @@ void return_all_controls_when_access_filter_is_null() throws Exception { RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), CalmResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(DOMAIN, List.of(entry)), - Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry), Map.of(CalmResourceType.CONTROL, List.of(entry)) ); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); List result = unfilteredStore.getControlsForDomain(DOMAIN); @@ -277,13 +281,13 @@ void throw_control_not_found_when_id_does_not_match() { RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), CalmResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of(DOMAIN, List.of(entry)), - Map.of(DOMAIN + ":" + UNIQUE_ID, entry), + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry), Map.of(CalmResourceType.CONTROL, List.of(entry)) ); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType(DOMAIN, CalmResourceType.CONTROL)).thenReturn(List.of(entry)); - when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of(DOMAIN)); + when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); assertThrows(ControlNotFoundException.class, () -> store.getRequirementVersions(DOMAIN, 99999)); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java index 59efe4aaad..586cce9725 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java @@ -179,7 +179,6 @@ void map_all_resource_types_correctly() { assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.FLOW), equalTo(CalmResourceType.FLOW)); assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.STANDARD), equalTo(CalmResourceType.STANDARD)); assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.INTERFACE), equalTo(CalmResourceType.INTERFACE)); - assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.CONTROL), equalTo(CalmResourceType.CONTROL)); } private RegistryEntry createEntry(String uniqueId, CalmResourceType type) { From 484a912e3b1d77e4673c433a67cf8214c57ea9d8 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 19:35:40 +0100 Subject: [PATCH 15/47] fix(calm-hub): remove 'latest' from the shared version contract VERSION_OR_SHA_REGEX accepted the literal "latest" and was applied to the shared, backend-agnostic resource classes (Architecture, Pattern, Flow, Standard, Interface, Timeline, Control), so "latest" passed validation and was advertised in the OpenAPI text in every storage mode - reversing the design decision already recorded upstream in TestMappingControllerResourceShould ("GET latest (removed - only explicit versioned GET is supported)"). Drop the pipe-latest alternation from VERSION_OR_SHA_REGEX and the "or 'latest'" clause from its message and the OpenAPI description; the SHA alternation is unaffected. Add a regression test per resource type confirming GET .../versions/latest now returns 400, including on the namespace-scoped front controller. The GitHub-mode store-level fallback to the "latest" sentinel is a separate change, tracked for the util-package refactor where those stores' collaborators move. Full verify (3340 tests, 0 failures) and JaCoCo coverage checks pass. --- .../calm/resources/MappingControllerResource.java | 2 +- .../calm/resources/ResourceValidationConstants.java | 4 ++-- .../calm/resources/TestArchitectureResourceShould.java | 10 ++++++++++ .../calm/resources/TestControlResourceShould.java | 10 ++++++++++ .../finos/calm/resources/TestFlowResourceShould.java | 10 ++++++++++ .../calm/resources/TestInterfaceResourceShould.java | 10 ++++++++++ .../resources/TestMappingControllerResourceShould.java | 9 +++++++++ .../calm/resources/TestPatternResourceShould.java | 10 ++++++++++ .../calm/resources/TestStandardResourceShould.java | 10 ++++++++++ .../calm/resources/TestTimelineResourceShould.java | 10 ++++++++++ 10 files changed, 82 insertions(+), 3 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java b/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java index 3f3f80297a..6d9d10281b 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java @@ -351,7 +351,7 @@ public Response listResourceVersions( @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Get a specific version of a named resource", - description = "Returns the resource at the specified version (semver, git SHA, or 'latest'). " + + description = "Returns the resource at the specified version (semver or git SHA). " + "The \"$id\" in the returned document is rewritten to the versioned canonical URL." ) @PermissionsAllowed(CalmHubScopes.READ) diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java b/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java index 60f8611322..2de6c5c3f7 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java @@ -12,9 +12,9 @@ public class ResourceValidationConstants { public static final String DOMAIN_REGEX = "^[A-Za-z0-9-]+$"; public static final String DOMAIN_MESSAGE = "domain name must match pattern '^[A-Za-z0-9-]+$'"; public static final String VERSION_REGEX = "^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)$"; - public static final String VERSION_OR_SHA_REGEX = "^((0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)|[0-9a-f]{7,40}|latest)$"; + public static final String VERSION_OR_SHA_REGEX = "^((0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)|[0-9a-f]{7,40})$"; public static final String VERSION_MESSAGE = "version must match pattern '^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)$'"; - public static final String VERSION_OR_SHA_MESSAGE = "version must be semver (e.g. 1.0.0), a hex SHA (7-40 chars), or 'latest'"; + public static final String VERSION_OR_SHA_MESSAGE = "version must be semver (e.g. 1.0.0) or a hex SHA (7-40 chars)"; // First character must be a letter so slugs are never purely numeric (avoids clash with legacy numeric IDs). public static final String CUSTOM_ID_REGEX = "^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$"; public static final String CUSTOM_ID_MESSAGE = "customId must match pattern '^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$'"; diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java index 2aad93e71b..26fdc186e7 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java @@ -315,6 +315,16 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_get_architect .body(containsString(VERSION_OR_SHA_MESSAGE)); } + @Test + void return_a_400_when_latest_is_requested_as_a_version_on_get_architecture() { + given() + .when() + .get("/api/calm/namespaces/finos/architectures/12/versions/latest") + .then() + .statusCode(400) + .body(containsString(VERSION_OR_SHA_MESSAGE)); + } + private void verifyExpectedGetArchitecture(String namespace) throws ArchitectureNotFoundException, NamespaceNotFoundException, ArchitectureVersionNotFoundException { Architecture expectedArchitectureToRetrieve = new Architecture.ArchitectureBuilder() .setNamespace(namespace) diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java index 6fa7da710c..aeb7dd7937 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java @@ -208,6 +208,16 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_get_requireme .body(containsString(VERSION_OR_SHA_MESSAGE)); } + @Test + void return_a_400_when_latest_is_requested_as_a_version_on_get_requirement_for_version() { + given() + .when() + .get("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/requirement/versions/latest") + .then() + .statusCode(400) + .body(containsString(VERSION_OR_SHA_MESSAGE)); + } + static Stream provideParametersForGetRequirementTests() { return Stream.of( Arguments.of(INVALID_DOMAIN, new DomainNotFoundException(INVALID_DOMAIN), 404), diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java index 54bdcb85d3..086892fac7 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java @@ -258,6 +258,16 @@ void return_400_error_when_version_is_not_valid_when_getting_flow_version() { .body(containsString(VERSION_OR_SHA_MESSAGE)); } + @Test + void return_400_when_latest_is_requested_as_a_version_when_getting_flow_version() { + given() + .when() + .get("/api/calm/namespaces/finos/flows/12/versions/latest") + .then() + .statusCode(400) + .body(containsString(VERSION_OR_SHA_MESSAGE)); + } + static Stream provideParametersForGetFlowTests() { return Stream.of( Arguments.of("invalid", new NamespaceNotFoundException(), 404), diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java index 6a9071e4ec..f3e2a4050f 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java @@ -269,6 +269,16 @@ void return_400_when_invalid_version_provided_when_getting_version_of_interface( .body(containsString(VERSION_OR_SHA_MESSAGE)); } + @Test + void return_400_when_latest_is_requested_as_a_version_of_interface() { + given() + .when() + .get("/api/calm/namespaces/finos/interfaces/5/versions/latest") + .then() + .statusCode(400) + .body(containsString(VERSION_OR_SHA_MESSAGE)); + } + static Stream provideParametersForGetInterfaceTests() { return Stream.of( Arguments.of("invalid", new NamespaceNotFoundException(), 404), diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java index f038120a85..5b611ed313 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java @@ -593,6 +593,15 @@ void return_200_for_specific_pattern_version() throws Exception { .then().statusCode(200).body(containsString("1.0.0")); } + @Test + void return_400_when_latest_is_requested_as_a_version_on_the_shared_front_controller() { + // "latest" is not a version anywhere on this backend-agnostic route - it never + // reaches parseTypePlural/the store, it's rejected by bean validation before + // either is invoked. + given().when().get("/calm/namespaces/finos/patterns/api-gateway/versions/latest") + .then().statusCode(400).body(containsString(ResourceValidationConstants.VERSION_OR_SHA_MESSAGE)); + } + @Test void return_200_for_specific_architecture_version() throws Exception { ResourceMapping mapping = new ResourceMapping.ResourceMappingBuilder() diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java index 4c7e95e47f..af1f05897c 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java @@ -332,6 +332,16 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_get_pattern() .body(containsString(VERSION_OR_SHA_MESSAGE)); } + @Test + void return_a_400_when_latest_is_requested_as_a_version_on_get_pattern() { + given() + .when() + .get("/api/calm/namespaces/finos/patterns/12/versions/latest") + .then() + .statusCode(400) + .body(containsString(VERSION_OR_SHA_MESSAGE)); + } + private void verifyExpectedGetPattern(String namespace) throws PatternNotFoundException, NamespaceNotFoundException, PatternVersionNotFoundException { Pattern expectedPatternToRetrieve = new Pattern.PatternBuilder() .setNamespace(namespace) diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java index 55558b49e8..282fabce48 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java @@ -226,6 +226,16 @@ void return_400_when_invalid_version_provided_when_getting_version_of_standard() .body(containsString(VERSION_OR_SHA_MESSAGE)); } + @Test + void return_400_when_latest_is_requested_as_a_version_of_standard() { + given() + .when() + .get("/api/calm/namespaces/finos/standards/5/versions/latest") + .then() + .statusCode(400) + .body(containsString(VERSION_OR_SHA_MESSAGE)); + } + static Stream provideParametersForGetStandardTests() { return Stream.of( Arguments.of("invalid", new NamespaceNotFoundException(), 404), diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java index d7a7e2d909..20c4501832 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java @@ -210,6 +210,16 @@ void return_400_error_when_version_is_not_valid_when_getting_timeline_version() .body(containsString(VERSION_OR_SHA_MESSAGE)); } + @Test + void return_400_when_latest_is_requested_as_a_version_when_getting_timeline_version() { + given() + .when() + .get("/api/calm/namespaces/finos/timelines/12/versions/latest") + .then() + .statusCode(400) + .body(containsString(VERSION_OR_SHA_MESSAGE)); + } + static Stream provideParametersForGetTimelineTests() { return Stream.of( Arguments.of("invalid", new NamespaceNotFoundException(), 404), From b9247f41f21b680b49c1dd5bce52ae2c475f7190 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 19:39:44 +0100 Subject: [PATCH 16/47] refactor(calm-hub): move GitHubStoreConfig out of the github util package First class moved of the store/github/util dissolution - see calm-hub review discussion on PR #3066. GitHubStoreConfig resolves three env-only properties at runtime specifically because @ConfigProperty field injection is wrong for these under native image; it existed as a class mainly to hold three package-private fields tests wrote to directly, bypassing its own constructor. Give it two public constructors: one @Inject constructor taking the MicroProfile Config directly (still runtime resolution, still native-safe, now testable without a container), and one plain constructor for tests. Fields are now private final. Moves to store.github.config; GitHubCloneManager and GitHubVersionService pick up the new import (both already collaborated with it correctly - only the package changed). TestGitHubVersionServiceShould's 11 sites that mutated storeConfig.apiUrl/serviceToken directly now go through setApiUrl/setServiceToken helpers that reconstruct the immutable config - a minimal fix kept in the old test package since GitHubVersionService itself (destined to become GitHubFileHistoryClient) gets its own move and test rewrite next. Full verify (3341 tests, 0 failures) and JaCoCo coverage checks pass. --- .../github/config/GitHubStoreConfig.java | 59 ++++++++++++++++ .../store/github/util/GitHubCloneManager.java | 1 + .../store/github/util/GitHubStoreConfig.java | 53 -------------- .../github/util/GitHubVersionService.java | 1 + .../config/TestGitHubStoreConfigShould.java | 69 +++++++++++++++++++ .../util/TestGitHubCloneManagerShould.java | 1 + .../util/TestGitHubStoreConfigShould.java | 49 ------------- .../util/TestGitHubVersionServiceShould.java | 32 +++++---- 8 files changed, 151 insertions(+), 114 deletions(-) create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/config/GitHubStoreConfig.java delete mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/config/TestGitHubStoreConfigShould.java delete mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/config/GitHubStoreConfig.java b/calm-hub/src/main/java/org/finos/calm/store/github/config/GitHubStoreConfig.java new file mode 100644 index 0000000000..86a8221c07 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/config/GitHubStoreConfig.java @@ -0,0 +1,59 @@ +package org.finos.calm.store.github.config; + +import io.quarkus.arc.lookup.LookupIfProperty; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.Config; + +import java.nio.file.Path; + +/** + * {@code calm.github.service-token}, {@code calm.github.clone-directory}, and + * {@code calm.github.api-url} are all env-only (declared in no + * {@code application*.properties} file) — exactly the kind of operator-supplied, + * potentially-rotating value {@code @ConfigProperty} field injection gets wrong in a + * native image, where the value is captured at build time and a runtime env var + * override is silently ignored. Resolved once, in the constructor, via an injected + * {@link Config} instead — the same pattern used by {@code ReadOnlyRequestFilter} and + * {@code AuditService} — see {@code calm-hub/AGENTS.md}'s native-image config guidance. + * + *

A second, plain constructor takes the three resolved values directly. It exists so + * tests can build this class through its public API rather than reaching into + * package-private fields — the {@code Config} lookup is the only part of this class + * that needs a running container; the values themselves are inert once resolved.

+ */ +@LookupIfProperty(name = "calm.database.mode", stringValue = "github") +@ApplicationScoped +public class GitHubStoreConfig { + + private final String serviceToken; + private final String cloneDirectory; + private final String apiUrl; + + @Inject + public GitHubStoreConfig(Config config) { + this( + config.getOptionalValue("calm.github.service-token", String.class).orElse(""), + config.getOptionalValue("calm.github.clone-directory", String.class).orElse("/tmp/calm-hub-clones"), + config.getOptionalValue("calm.github.api-url", String.class).orElse("https://api.github.com") + ); + } + + public GitHubStoreConfig(String serviceToken, String cloneDirectory, String apiUrl) { + this.serviceToken = serviceToken; + this.cloneDirectory = cloneDirectory; + this.apiUrl = apiUrl; + } + + public String getServiceToken() { + return serviceToken; + } + + public Path getCloneDirectory() { + return Path.of(cloneDirectory); + } + + public String getApiUrl() { + return apiUrl; + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java index 8ad3d0ad09..3cc51da1de 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java @@ -3,6 +3,7 @@ import io.quarkus.arc.lookup.LookupIfProperty; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java deleted file mode 100644 index 7c3fb579b6..0000000000 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStoreConfig.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.finos.calm.store.github.util; - -import io.quarkus.arc.lookup.LookupIfProperty; -import jakarta.annotation.PostConstruct; -import jakarta.enterprise.context.ApplicationScoped; -import org.eclipse.microprofile.config.ConfigProvider; - -import java.nio.file.Path; - -/** - * {@code calm.github.service-token}, {@code calm.github.clone-directory}, and - * {@code calm.github.api-url} are all env-only (declared in no - * {@code application*.properties} file) — exactly the kind of operator-supplied, - * potentially-rotating value {@code @ConfigProperty} field injection gets wrong in a - * native image, where the value is captured at build time and a runtime env var - * override is silently ignored. Resolved once at runtime startup via - * {@link #init()} instead, the same pattern used by {@code ReadOnlyRequestFilter} and - * {@code AuditService} — see {@code calm-hub/AGENTS.md}'s native-image config guidance. - */ -@LookupIfProperty(name = "calm.database.mode", stringValue = "github") -@ApplicationScoped -public class GitHubStoreConfig { - - // Package-private so unit tests can set these directly rather than going through init(). - String serviceToken; - String cloneDirectory; - String apiUrl; - - @PostConstruct - void init() { - serviceToken = ConfigProvider.getConfig() - .getOptionalValue("calm.github.service-token", String.class) - .orElse(""); - cloneDirectory = ConfigProvider.getConfig() - .getOptionalValue("calm.github.clone-directory", String.class) - .orElse("/tmp/calm-hub-clones"); - apiUrl = ConfigProvider.getConfig() - .getOptionalValue("calm.github.api-url", String.class) - .orElse("https://api.github.com"); - } - - public String getServiceToken() { - return serviceToken; - } - - public Path getCloneDirectory() { - return Path.of(cloneDirectory); - } - - public String getApiUrl() { - return apiUrl; - } -} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java index 4ecd7147f9..814dc731f6 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java @@ -7,6 +7,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/config/TestGitHubStoreConfigShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/config/TestGitHubStoreConfigShould.java new file mode 100644 index 0000000000..0fcc3fe1a5 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/config/TestGitHubStoreConfigShould.java @@ -0,0 +1,69 @@ +package org.finos.calm.store.github.config; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +class TestGitHubStoreConfigShould { + + @Test + void return_service_token_when_present() { + GitHubStoreConfig config = new GitHubStoreConfig("ghp_test123", "/tmp/calm-hub-clones", "https://api.github.com"); + assertThat(config.getServiceToken(), equalTo("ghp_test123")); + } + + @Test + void return_empty_string_when_service_token_absent() { + GitHubStoreConfig config = new GitHubStoreConfig("", "/tmp/calm-hub-clones", "https://api.github.com"); + assertThat(config.getServiceToken(), equalTo("")); + } + + @Test + void return_clone_directory_as_path() { + GitHubStoreConfig config = new GitHubStoreConfig("", "/tmp/calm-clones", "https://api.github.com"); + assertThat(config.getCloneDirectory(), equalTo(Path.of("/tmp/calm-clones"))); + } + + @Test + void return_api_url() { + GitHubStoreConfig config = new GitHubStoreConfig("", "/tmp/calm-hub-clones", "https://api.github.com"); + assertThat(config.getApiUrl(), equalTo("https://api.github.com")); + } + + @Test + void resolve_defaults_from_config_when_nothing_set() { + org.eclipse.microprofile.config.Config config = org.mockito.Mockito.mock(org.eclipse.microprofile.config.Config.class); + org.mockito.Mockito.when(config.getOptionalValue("calm.github.service-token", String.class)) + .thenReturn(java.util.Optional.empty()); + org.mockito.Mockito.when(config.getOptionalValue("calm.github.clone-directory", String.class)) + .thenReturn(java.util.Optional.empty()); + org.mockito.Mockito.when(config.getOptionalValue("calm.github.api-url", String.class)) + .thenReturn(java.util.Optional.empty()); + + GitHubStoreConfig storeConfig = new GitHubStoreConfig(config); + + assertThat(storeConfig.getServiceToken(), equalTo("")); + assertThat(storeConfig.getCloneDirectory(), equalTo(Path.of("/tmp/calm-hub-clones"))); + assertThat(storeConfig.getApiUrl(), equalTo("https://api.github.com")); + } + + @Test + void resolve_configured_values_from_config() { + org.eclipse.microprofile.config.Config config = org.mockito.Mockito.mock(org.eclipse.microprofile.config.Config.class); + org.mockito.Mockito.when(config.getOptionalValue("calm.github.service-token", String.class)) + .thenReturn(java.util.Optional.of("ghp_configured")); + org.mockito.Mockito.when(config.getOptionalValue("calm.github.clone-directory", String.class)) + .thenReturn(java.util.Optional.of("/data/clones")); + org.mockito.Mockito.when(config.getOptionalValue("calm.github.api-url", String.class)) + .thenReturn(java.util.Optional.of("https://github.example.com/api/v3")); + + GitHubStoreConfig storeConfig = new GitHubStoreConfig(config); + + assertThat(storeConfig.getServiceToken(), equalTo("ghp_configured")); + assertThat(storeConfig.getCloneDirectory(), equalTo(Path.of("/data/clones"))); + assertThat(storeConfig.getApiUrl(), equalTo("https://github.example.com/api/v3")); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java index 9427168ad5..f6fad08c13 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java @@ -1,5 +1,6 @@ package org.finos.calm.store.github.util; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java deleted file mode 100644 index 3dc64034cb..0000000000 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStoreConfigShould.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.finos.calm.store.github.util; - -import org.junit.jupiter.api.Test; - -import java.nio.file.Path; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; - -class TestGitHubStoreConfigShould { - - @Test - void return_service_token_when_present() { - GitHubStoreConfig config = new GitHubStoreConfig(); - config.serviceToken = "ghp_test123"; - assertThat(config.getServiceToken(), equalTo("ghp_test123")); - } - - @Test - void return_empty_string_when_service_token_absent() { - GitHubStoreConfig config = new GitHubStoreConfig(); - config.serviceToken = ""; - assertThat(config.getServiceToken(), equalTo("")); - } - - @Test - void return_clone_directory_as_path() { - GitHubStoreConfig config = new GitHubStoreConfig(); - config.cloneDirectory = "/tmp/calm-clones"; - assertThat(config.getCloneDirectory(), equalTo(Path.of("/tmp/calm-clones"))); - } - - @Test - void return_api_url() { - GitHubStoreConfig config = new GitHubStoreConfig(); - config.apiUrl = "https://api.github.com"; - assertThat(config.getApiUrl(), equalTo("https://api.github.com")); - } - - @Test - void init_resolves_defaults_when_nothing_configured() { - GitHubStoreConfig config = new GitHubStoreConfig(); - config.init(); - - assertThat(config.getServiceToken(), equalTo("")); - assertThat(config.getCloneDirectory(), equalTo(Path.of("/tmp/calm-hub-clones"))); - assertThat(config.getApiUrl(), equalTo("https://api.github.com")); - } -} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java index df3c9a1c88..c31f1440c0 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java @@ -1,6 +1,7 @@ package org.finos.calm.store.github.util; import com.sun.net.httpserver.HttpServer; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -45,10 +46,7 @@ class TestGitHubVersionServiceShould { void setup() { service = new GitHubVersionService(); service.cache = cache; - storeConfig = new GitHubStoreConfig(); - storeConfig.apiUrl = "https://api.github.com"; - storeConfig.serviceToken = "test-token"; - storeConfig.cloneDirectory = "/tmp/calm-hub-clones"; + storeConfig = new GitHubStoreConfig("test-token", "/tmp/calm-hub-clones", "https://api.github.com"); service.storeConfig = storeConfig; service.maxVersions = 100; service.connectTimeoutSeconds = 10; @@ -63,6 +61,16 @@ void teardown() { } } + private void setApiUrl(String apiUrl) { + storeConfig = new GitHubStoreConfig(storeConfig.getServiceToken(), storeConfig.getCloneDirectory().toString(), apiUrl); + service.storeConfig = storeConfig; + } + + private void setServiceToken(String serviceToken) { + storeConfig = new GitHubStoreConfig(serviceToken, storeConfig.getCloneDirectory().toString(), storeConfig.getApiUrl()); + service.storeConfig = storeConfig; + } + @Test void return_cached_versions_when_available() { List cached = List.of("abc1234", "def5678"); @@ -86,7 +94,7 @@ void return_latest_when_api_fails() { @Test void return_latest_when_no_token() { - storeConfig.serviceToken = ""; + setServiceToken(""); when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); List result = service.getFileVersions("org/repo", "main", "path/file.json"); @@ -173,7 +181,7 @@ void return_null_when_api_returns_non_200_for_content_at_sha() throws Exception exchange.close(); }); server.start(); - storeConfig.apiUrl = "http://localhost:" + server.getAddress().getPort(); + setApiUrl("http://localhost:" + server.getAddress().getPort()); String content = service.getFileAtVersion("org/repo", "path/file.json", "abc1234"); @@ -192,7 +200,7 @@ void return_file_content_and_cache_it_on_a_200_response() throws Exception { exchange.close(); }); server.start(); - storeConfig.apiUrl = "http://localhost:" + server.getAddress().getPort(); + setApiUrl("http://localhost:" + server.getAddress().getPort()); String content = service.getFileAtVersion("org/repo", "path/file.json", "abc1234"); @@ -212,7 +220,7 @@ void return_null_when_content_fetch_throws() throws Exception { int freedPort = server.getAddress().getPort(); server.stop(0); server = null; - storeConfig.apiUrl = "http://localhost:" + freedPort; + setApiUrl("http://localhost:" + freedPort); // A closed local port can hang until the connect/request timeout rather than // refusing instantly - keep this test fast rather than waiting out the // production 10s/30s defaults set up in @BeforeEach. @@ -233,7 +241,7 @@ void return_latest_when_the_commits_request_connection_fails() throws Exception int freedPort = server.getAddress().getPort(); server.stop(0); server = null; - storeConfig.apiUrl = "http://localhost:" + freedPort; + setApiUrl("http://localhost:" + freedPort); service.connectTimeoutSeconds = 1; service.requestTimeoutSeconds = 1; service.init(); @@ -265,7 +273,7 @@ void follow_pagination_across_multiple_pages_on_the_same_host() throws Exception exchange.close(); }); server.start(); - storeConfig.apiUrl = "http://localhost:" + server.getAddress().getPort(); + setApiUrl("http://localhost:" + server.getAddress().getPort()); List result = service.getFileVersions("org/repo", "main", "path/file.json"); @@ -288,7 +296,7 @@ void stop_paginating_when_the_next_link_points_at_a_different_host() throws Exce exchange.close(); }); server.start(); - storeConfig.apiUrl = "http://localhost:" + server.getAddress().getPort(); + setApiUrl("http://localhost:" + server.getAddress().getPort()); List result = service.getFileVersions("org/repo", "main", "path/file.json"); @@ -311,7 +319,7 @@ void stop_paginating_when_the_next_link_is_unparsable() throws Exception { exchange.close(); }); server.start(); - storeConfig.apiUrl = "http://localhost:" + server.getAddress().getPort(); + setApiUrl("http://localhost:" + server.getAddress().getPort()); List result = service.getFileVersions("org/repo", "main", "path/file.json"); From ace58c4530525bb29f246706af12eed0e2531baf Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 19:43:05 +0100 Subject: [PATCH 17/47] refactor(calm-hub): move GitHubRepoSync out of the github util package, add headSha Second class moved of the store/github/util dissolution. The only seam was githubBaseUrl - a package-private @ConfigProperty field one test wrote to directly. Switch to constructor injection: one @Inject constructor taking the resolved base URL, usable directly by tests with no CDI container. Add headSha(Path) - the local clone's current commit SHA, truncated to 7 characters to match GitHubFileHistoryClient's abbreviated API SHAs. Every clone is --depth 1, so HEAD is the only version the local tree can honestly attest to. This replaces the "latest" sentinel fallback that GitHubVersionService currently returns when the commits API is unavailable - GitHubCloneManager will expose it as headSha(namespace) once it moves, and the shared store base assembles it into each store's version list in Phase 5. Moves to store.github.sync. GitHubCloneManager (not yet moved itself) picks up the import. Full verify (3344 tests, 0 failures) and JaCoCo coverage checks pass. --- .../github/{util => sync}/GitHubRepoSync.java | 33 +++++++++++-- .../store/github/util/GitHubCloneManager.java | 1 + .../TestGitHubRepoSyncShould.java | 48 +++++++++++++++++-- .../util/TestGitHubCloneManagerShould.java | 1 + 4 files changed, 75 insertions(+), 8 deletions(-) rename calm-hub/src/main/java/org/finos/calm/store/github/{util => sync}/GitHubRepoSync.java (67%) rename calm-hub/src/test/java/org/finos/calm/store/github/{util => sync}/TestGitHubRepoSyncShould.java (75%) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubRepoSync.java b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubRepoSync.java similarity index 67% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubRepoSync.java rename to calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubRepoSync.java index 6dff33504b..e7e59ab2b0 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubRepoSync.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubRepoSync.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.sync; import io.quarkus.arc.lookup.LookupIfProperty; import jakarta.enterprise.context.ApplicationScoped; @@ -7,6 +7,7 @@ import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.slf4j.Logger; @@ -26,9 +27,12 @@ public class GitHubRepoSync { private static final Logger LOG = LoggerFactory.getLogger(GitHubRepoSync.class); + private final String githubBaseUrl; + @Inject - @ConfigProperty(name = "calm.github.oauth.base-url", defaultValue = "https://github.com") - String githubBaseUrl; + public GitHubRepoSync(@ConfigProperty(name = "calm.github.oauth.base-url", defaultValue = "https://github.com") String githubBaseUrl) { + this.githubBaseUrl = githubBaseUrl; + } public boolean cloneRepo(String repoFullName, String branch, Path targetDir, String token) { String url = githubBaseUrl + "/" + repoFullName + ".git"; @@ -81,4 +85,27 @@ public boolean pullRepo(Path repoDir, String token) { public boolean isValidRepo(Path repoDir) { return Files.isDirectory(repoDir.resolve(".git")); } + + /** + * The commit SHA a clone directory's working tree currently holds, truncated to 7 + * characters to match the abbreviated SHAs {@code GitHubFileHistoryClient} returns from + * the GitHub commits API — a version list mixing 7-char and 40-char entries would be + * inconsistent. Every clone is {@code --depth 1} ({@link #cloneRepo}), so this is the + * only version the local tree can honestly attest to; anything else requires the API. + * + * @return the abbreviated HEAD SHA, or {@code null} if {@code repoDir} isn't a valid + * git repository (mirrors {@link #isValidRepo} rather than throwing). + */ + public String headSha(Path repoDir) { + if (!isValidRepo(repoDir)) { + return null; + } + try (Git git = Git.open(repoDir.toFile())) { + ObjectId head = git.getRepository().resolve("HEAD"); + return head == null ? null : head.abbreviate(7).name(); + } catch (IOException e) { + LOG.error("Failed to resolve HEAD for {}: {}", repoDir.getFileName(), e.getMessage()); + return null; + } + } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java index 3cc51da1de..5def03bbe3 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java @@ -4,6 +4,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.finos.calm.store.github.config.GitHubStoreConfig; +import org.finos.calm.store.github.sync.GitHubRepoSync; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubRepoSyncShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubRepoSyncShould.java similarity index 75% rename from calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubRepoSyncShould.java rename to calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubRepoSyncShould.java index d02587bdac..e8819ae912 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubRepoSyncShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubRepoSyncShould.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.sync; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -13,6 +13,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.matchesPattern; +import static org.hamcrest.Matchers.nullValue; class TestGitHubRepoSyncShould { @@ -23,7 +25,7 @@ class TestGitHubRepoSyncShould { @BeforeEach void setup() { - repoSync = new GitHubRepoSync(); + repoSync = new GitHubRepoSync("https://github.com"); } @Test @@ -136,13 +138,13 @@ void clone_a_real_repo_successfully_via_file_protocol() throws GitAPIException, origin.commit().setMessage("init").call(); } - repoSync.githubBaseUrl = tempDir.toUri().toString().replaceAll("/$", ""); + GitHubRepoSync localRepoSync = new GitHubRepoSync(tempDir.toUri().toString().replaceAll("/$", "")); Path cloneTarget = tempDir.resolve("cloned-real"); - boolean result = repoSync.cloneRepo("myrepo", "main", cloneTarget, null); + boolean result = localRepoSync.cloneRepo("myrepo", "main", cloneTarget, null); assertThat(result, is(true)); - assertThat(repoSync.isValidRepo(cloneTarget), is(true)); + assertThat(localRepoSync.isValidRepo(cloneTarget), is(true)); } @Test @@ -167,4 +169,40 @@ void pull_returns_true_when_already_up_to_date() throws GitAPIException, IOExcep boolean result = repoSync.pullRepo(cloneDir, ""); assertThat(result, is(true)); } + + @Test + void return_the_head_sha_of_a_valid_repo() throws GitAPIException, IOException { + Path originDir = tempDir.resolve("head-sha-repo"); + Files.createDirectories(originDir); + String expectedSha; + try (Git origin = Git.init().setDirectory(originDir.toFile()).setInitialBranch("main").call()) { + Files.writeString(originDir.resolve("file.txt"), "content"); + origin.add().addFilepattern("file.txt").call(); + expectedSha = origin.commit().setMessage("initial").call().getName(); + } + + String headSha = repoSync.headSha(originDir); + + assertThat(headSha, is(expectedSha.substring(0, 7))); + } + + @Test + void return_a_seven_character_head_sha() throws GitAPIException, IOException { + Path originDir = tempDir.resolve("head-sha-length-repo"); + Files.createDirectories(originDir); + try (Git origin = Git.init().setDirectory(originDir.toFile()).setInitialBranch("main").call()) { + Files.writeString(originDir.resolve("file.txt"), "content"); + origin.add().addFilepattern("file.txt").call(); + origin.commit().setMessage("initial").call(); + } + + String headSha = repoSync.headSha(originDir); + + assertThat(headSha, matchesPattern("[0-9a-f]{7}")); + } + + @Test + void return_null_head_sha_when_directory_is_not_a_repo() { + assertThat(repoSync.headSha(tempDir), is(nullValue())); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java index f6fad08c13..f5611bcf0a 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java @@ -1,6 +1,7 @@ package org.finos.calm.store.github.util; import org.finos.calm.store.github.config.GitHubStoreConfig; +import org.finos.calm.store.github.sync.GitHubRepoSync; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; From f99b7fc34d1ac5abbcdd106e5d8558a85924faae Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 19:55:59 +0100 Subject: [PATCH 18/47] refactor(calm-hub): move the registry types out of the github util package Third move of the store/github/util dissolution, covering the tightly coupled cluster: CalmResourceType, RegistryEntry, RegistrySnapshot, CalmContentDetector, InMemoryRegistryService, and GitHubControlDomains. Grouped into one commit rather than six because they're interdependent enough that splitting further would mean inventing throwaway shims - every one of the 14 GitHub*Store classes touches this cluster, so a partial move would leave nothing compiling. - CalmResourceType -> RegistryResourceType, with a class comment explaining why it deliberately doesn't merge with domain.ResourceType (different question answered, and ResourceType is a serialized public API shape name-coupled to AuditEntityType). Drops UNKNOWN - CalmContentDetector.detect now returns Optional, so "nothing detected" is the absence of a value, not a sentinel constant. Blast radius for that change was small: only ResourceRegistry (the sole caller of detect()) and its test needed adapting. - InMemoryRegistryService -> ResourceRegistry. Drops the entriesByType map on RegistrySnapshot - populated on every rebuild, never read anywhere (listByType filters entriesByNamespace instead). - GitHubControlDomains -> ControlDomains, staying a static utility deliberately (a pure function of a RegistryEntry, no state to inject). - Add @LookupIfProperty(github) to CalmContentDetector and ResourceRegistry, which previously instantiated in every storage mode despite being GitHub-only. Verified safe empirically rather than by inspection alone: ran the full unit suite under the default %test.calm.database.mode=mongo profile (a real risk, since a sibling class's @Observes StartupEvent is documented to fire regardless of @LookupIfProperty) and the GitHub-mode integration test (GitHubUserAccessDomainReadIntegration) under calm.database.mode=github - both boot and pass. - All 14 GitHub*Store classes, their tests, and the one integration test referencing these types move to the new imports. Moves to store.github.registry. Full verify (3344 unit + 549 with integration, 0 failures) and JaCoCo coverage checks pass. --- ...GitHubUserAccessDomainReadIntegration.java | 6 +- .../calm/store/github/GitHubAdrStore.java | 6 +- .../store/github/GitHubArchitectureStore.java | 14 +- .../calm/store/github/GitHubControlStore.java | 16 +- .../store/github/GitHubDecoratorStore.java | 6 +- .../calm/store/github/GitHubDomainStore.java | 14 +- .../calm/store/github/GitHubFlowStore.java | 14 +- .../store/github/GitHubInterfaceStore.java | 14 +- .../store/github/GitHubNamespaceStore.java | 6 +- .../calm/store/github/GitHubPatternStore.java | 14 +- .../github/GitHubResourceMappingStore.java | 30 +- .../calm/store/github/GitHubSearchStore.java | 24 +- .../store/github/GitHubStandardStore.java | 14 +- .../store/github/GitHubTimelineStore.java | 14 +- .../store/github/GitHubUserAccessStore.java | 12 +- .../CalmContentDetector.java | 69 ++--- .../ControlDomains.java} | 18 +- .../{util => registry}/RegistryEntry.java | 4 +- .../github/registry/RegistryResourceType.java | 33 +++ .../{util => registry}/RegistrySnapshot.java | 9 +- .../ResourceRegistry.java} | 44 +-- .../store/github/util/CalmResourceType.java | 14 - .../github/util/GitHubStartupInitializer.java | 3 +- .../github/util/GitHubSyncScheduler.java | 5 +- .../github/util/NamespaceAccessFilter.java | 3 +- .../github/TestGitHubAdrStoreShould.java | 14 +- .../TestGitHubArchitectureStoreShould.java | 58 ++-- .../github/TestGitHubControlStoreShould.java | 112 +++---- .../TestGitHubDecoratorStoreShould.java | 18 +- .../github/TestGitHubDomainStoreShould.java | 30 +- .../github/TestGitHubFlowStoreShould.java | 58 ++-- .../TestGitHubInterfaceStoreShould.java | 64 ++-- .../TestGitHubNamespaceStoreShould.java | 16 +- .../github/TestGitHubPatternStoreShould.java | 58 ++-- .../TestGitHubResourceMappingStoreShould.java | 54 ++-- .../github/TestGitHubSearchStoreShould.java | 15 +- .../github/TestGitHubStandardStoreShould.java | 74 ++--- .../github/TestGitHubTimelineStoreShould.java | 64 ++-- .../TestGitHubUserAccessStoreShould.java | 38 +-- .../TestCalmContentDetectorShould.java | 274 ++++++++++++++++++ .../TestControlDomainsShould.java} | 20 +- .../TestRegistrySnapshotShould.java | 23 +- .../TestResourceRegistryShould.java} | 40 +-- .../util/TestCalmContentDetectorShould.java | 273 ----------------- .../TestGitHubStartupInitializerShould.java | 3 +- .../util/TestGitHubSyncSchedulerShould.java | 3 +- .../util/TestNamespaceAccessFilterShould.java | 12 +- 47 files changed, 809 insertions(+), 918 deletions(-) rename calm-hub/src/main/java/org/finos/calm/store/github/{util => registry}/CalmContentDetector.java (60%) rename calm-hub/src/main/java/org/finos/calm/store/github/{util/GitHubControlDomains.java => registry/ControlDomains.java} (50%) rename calm-hub/src/main/java/org/finos/calm/store/github/{util => registry}/RegistryEntry.java (69%) create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistryResourceType.java rename calm-hub/src/main/java/org/finos/calm/store/github/{util => registry}/RegistrySnapshot.java (73%) rename calm-hub/src/main/java/org/finos/calm/store/github/{util/InMemoryRegistryService.java => registry/ResourceRegistry.java} (83%) delete mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/registry/TestCalmContentDetectorShould.java rename calm-hub/src/test/java/org/finos/calm/store/github/{util/TestGitHubControlDomainsShould.java => registry/TestControlDomainsShould.java} (64%) rename calm-hub/src/test/java/org/finos/calm/store/github/{util => registry}/TestRegistrySnapshotShould.java (80%) rename calm-hub/src/test/java/org/finos/calm/store/github/{util/TestInMemoryRegistryServiceShould.java => registry/TestResourceRegistryShould.java} (91%) delete mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java diff --git a/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java b/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java index 115d89df8e..7f1e32d83f 100644 --- a/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java +++ b/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java @@ -5,7 +5,7 @@ import io.quarkus.test.security.TestSecurity; import jakarta.inject.Inject; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.registry.ResourceRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -17,7 +17,7 @@ /** * End-to-end proof, against real local git repos (no mocking of GitHubUserAccessStore, - * GitHubCloneManager, or InMemoryRegistryService) that the DOMAIN_READ grants + * GitHubCloneManager, or ResourceRegistry) that the DOMAIN_READ grants * GitHubUserAccessStore derives actually gate the {@code /api/calm/domains/{domain}/controls} * endpoint correctly - both that a domain within an accessible namespace is readable, * and that a domain that only exists in a namespace the caller cannot access is not @@ -37,7 +37,7 @@ class GitHubUserAccessDomainReadIntegration { GitHubCloneManager cloneManager; @Inject - InMemoryRegistryService registryService; + ResourceRegistry registryService; private static final Set EXPECTED_NAMESPACES = Set.of("finos", "other"); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java index a6d975dfb8..b902aa76c5 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java @@ -15,7 +15,7 @@ import org.finos.calm.domain.exception.AdrRevisionNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.store.AdrStore; -import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.registry.ResourceRegistry; import java.util.Collections; import java.util.List; @@ -30,10 +30,10 @@ public class GitHubAdrStore implements AdrStore { private static final String VERSION_UNSUPPORTED = "Version history via GitHub API is not yet implemented."; - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; @Inject - public GitHubAdrStore(InMemoryRegistryService registryService) { + public GitHubAdrStore(ResourceRegistry registryService) { this.registryService = registryService; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java index 22d0db1c57..4cb4520f2c 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java @@ -13,12 +13,12 @@ import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.store.ArchitectureStore; import org.finos.calm.store.PageRequest; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,7 +35,7 @@ public class GitHubArchitectureStore implements ArchitectureStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubArchitectureStore.class); - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; @Inject @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") @@ -48,14 +48,14 @@ public class GitHubArchitectureStore implements ArchitectureStore { GitHubVersionService versionService; @Inject - public GitHubArchitectureStore(InMemoryRegistryService registryService) { + public GitHubArchitectureStore(ResourceRegistry registryService) { this.registryService = registryService; } @Override public List getArchitecturesForNamespace(String namespace, PageRequest page) throws NamespaceNotFoundException { verifyNamespace(namespace); - List entries = registryService.listByType(namespace, CalmResourceType.ARCHITECTURE); + List entries = registryService.listByType(namespace, RegistryResourceType.ARCHITECTURE); return entries.stream() .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); @@ -121,7 +121,7 @@ public void deleteArchitecture(String namespace, int architectureId) throws Name } private RegistryEntry findEntryById(String namespace, int id) throws ArchitectureNotFoundException { - List entries = registryService.listByType(namespace, CalmResourceType.ARCHITECTURE); + List entries = registryService.listByType(namespace, RegistryResourceType.ARCHITECTURE); Optional found = entries.stream() .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) .findFirst(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java index 235100cf79..ef7878e152 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java @@ -19,13 +19,13 @@ import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; import org.finos.calm.domain.exception.DomainNotFoundException; import org.finos.calm.store.ControlStore; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.util.NamespaceAccessFilter; -import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -42,7 +42,7 @@ public class GitHubControlStore implements ControlStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubControlStore.class); - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; @Inject @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") @@ -58,7 +58,7 @@ public class GitHubControlStore implements ControlStore { NamespaceAccessFilter accessFilter; @Inject - public GitHubControlStore(InMemoryRegistryService registryService) { + public GitHubControlStore(ResourceRegistry registryService) { this.registryService = registryService; } @@ -70,7 +70,7 @@ public List getControlsForDomain(String domain) throws DomainNotF if (!accessible.contains(namespace)) { continue; } - List entries = registryService.listByType(namespace, CalmResourceType.CONTROL); + List entries = registryService.listByType(namespace, RegistryResourceType.CONTROL); for (RegistryEntry entry : entries) { String path = entry.filePath().toString(); if (path.contains("controls/" + domain + "/") || path.contains("controls\\" + domain + "\\")) { @@ -195,7 +195,7 @@ private RegistryEntry findControlEntry(String domain, int controlId) throws Doma if (!accessible.contains(namespace)) { continue; } - List entries = registryService.listByType(namespace, CalmResourceType.CONTROL); + List entries = registryService.listByType(namespace, RegistryResourceType.CONTROL); for (RegistryEntry entry : entries) { String path = entry.filePath().toString(); boolean inDomain = path.contains("controls/" + domain + "/") || path.contains("controls\\" + domain + "\\"); @@ -226,7 +226,7 @@ private String findNamespaceForControl(RegistryEntry entry) { if (!accessible.contains(namespace)) { continue; } - List entries = registryService.listByType(namespace, CalmResourceType.CONTROL); + List entries = registryService.listByType(namespace, RegistryResourceType.CONTROL); if (entries.contains(entry)) return namespace; } return null; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java index d3a9123268..5f75d1bb17 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java @@ -9,7 +9,7 @@ import org.finos.calm.domain.exception.DecoratorNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.store.DecoratorStore; -import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.registry.ResourceRegistry; import java.util.Collections; import java.util.List; @@ -22,10 +22,10 @@ public class GitHubDecoratorStore implements DecoratorStore { private static final String WRITE_UNSUPPORTED = "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; @Inject - public GitHubDecoratorStore(InMemoryRegistryService registryService) { + public GitHubDecoratorStore(ResourceRegistry registryService) { this.registryService = registryService; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java index 1ec9c93edc..73db4046f3 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java @@ -9,9 +9,9 @@ import org.finos.calm.domain.exception.DomainAlreadyExistsException; import org.finos.calm.domain.exception.DomainNotFoundException; import org.finos.calm.store.DomainStore; -import org.finos.calm.store.github.util.CalmResourceType; -import org.finos.calm.store.github.util.GitHubControlDomains; -import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.registry.ControlDomains; +import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.util.NamespaceAccessFilter; import java.util.List; @@ -28,13 +28,13 @@ public class GitHubDomainStore implements DomainStore { private static final String UNSUPPORTED_MSG = "Domains in GitHub mode are derived from the controls/ directory structure in the repo."; - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; @Inject NamespaceAccessFilter accessFilter; @Inject - public GitHubDomainStore(InMemoryRegistryService registryService) { + public GitHubDomainStore(ResourceRegistry registryService) { this.registryService = registryService; } @@ -44,8 +44,8 @@ public List getDomains() { return registryService.getSnapshot().entriesByNamespace().entrySet().stream() .filter(e -> accessible.contains(e.getKey())) .flatMap(e -> e.getValue().stream()) - .filter(entry -> entry.type() == CalmResourceType.CONTROL) - .map(GitHubControlDomains::extractDomain) + .filter(entry -> entry.type() == RegistryResourceType.CONTROL) + .map(ControlDomains::extractDomain) .distinct() .toList(); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java index bdd394ec11..edba1eb06b 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java @@ -14,12 +14,12 @@ import org.finos.calm.domain.flow.CreateFlowRequest; import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.store.FlowStore; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,7 +36,7 @@ public class GitHubFlowStore implements FlowStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubFlowStore.class); - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; @Inject @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") @@ -49,14 +49,14 @@ public class GitHubFlowStore implements FlowStore { GitHubVersionService versionService; @Inject - public GitHubFlowStore(InMemoryRegistryService registryService) { + public GitHubFlowStore(ResourceRegistry registryService) { this.registryService = registryService; } @Override public List getFlowsForNamespace(String namespace) throws NamespaceNotFoundException { verifyNamespace(namespace); - List entries = registryService.listByType(namespace, CalmResourceType.FLOW); + List entries = registryService.listByType(namespace, RegistryResourceType.FLOW); return entries.stream() .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); @@ -122,7 +122,7 @@ public void deleteFlow(String namespace, int flowId) throws NamespaceNotFoundExc } private RegistryEntry findEntryById(String namespace, int id) throws FlowNotFoundException { - List entries = registryService.listByType(namespace, CalmResourceType.FLOW); + List entries = registryService.listByType(namespace, RegistryResourceType.FLOW); Optional found = entries.stream() .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) .findFirst(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java index 8a6ad4d9cf..66a9ab1c7a 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java @@ -14,12 +14,12 @@ import org.finos.calm.domain.interfaces.CreateInterfaceRequest; import org.finos.calm.domain.interfaces.NamespaceInterfaceSummary; import org.finos.calm.store.InterfaceStore; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,7 +36,7 @@ public class GitHubInterfaceStore implements InterfaceStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubInterfaceStore.class); - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; @Inject @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") @@ -49,14 +49,14 @@ public class GitHubInterfaceStore implements InterfaceStore { GitHubVersionService versionService; @Inject - public GitHubInterfaceStore(InMemoryRegistryService registryService) { + public GitHubInterfaceStore(ResourceRegistry registryService) { this.registryService = registryService; } @Override public List getInterfacesForNamespace(String namespace) throws NamespaceNotFoundException { verifyNamespace(namespace); - List entries = registryService.listByType(namespace, CalmResourceType.INTERFACE); + List entries = registryService.listByType(namespace, RegistryResourceType.INTERFACE); return entries.stream() .map(e -> new NamespaceInterfaceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF))) .toList(); @@ -116,7 +116,7 @@ public void deleteInterface(String namespace, Integer interfaceId) throws Namesp } private RegistryEntry findEntryById(String namespace, int id) throws InterfaceNotFoundException { - List entries = registryService.listByType(namespace, CalmResourceType.INTERFACE); + List entries = registryService.listByType(namespace, RegistryResourceType.INTERFACE); Optional found = entries.stream() .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) .findFirst(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java index 211a6964ab..e3fbc469f6 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java @@ -9,7 +9,7 @@ import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.namespaces.NamespaceInfo; import org.finos.calm.store.NamespaceStore; -import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.registry.ResourceRegistry; import java.util.List; @@ -21,10 +21,10 @@ public class GitHubNamespaceStore implements NamespaceStore { "Namespaces in GitHub mode are admin-configured via deployment properties (calm.github.namespaces[*]). " + "Update your deployment configuration to add or remove namespaces."; - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; @Inject - public GitHubNamespaceStore(InMemoryRegistryService registryService) { + public GitHubNamespaceStore(ResourceRegistry registryService) { this.registryService = registryService; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java index 025f33ceb7..b1bc04c6d1 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java @@ -16,12 +16,12 @@ import org.finos.calm.domain.pattern.CreatePatternRequest; import org.finos.calm.store.PageRequest; import org.finos.calm.store.PatternStore; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,7 +38,7 @@ public class GitHubPatternStore implements PatternStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubPatternStore.class); - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; @Inject @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") @@ -51,14 +51,14 @@ public class GitHubPatternStore implements PatternStore { GitHubVersionService versionService; @Inject - public GitHubPatternStore(InMemoryRegistryService registryService) { + public GitHubPatternStore(ResourceRegistry registryService) { this.registryService = registryService; } @Override public List getPatternsForNamespace(String namespace, PageRequest page) throws NamespaceNotFoundException { verifyNamespace(namespace); - List entries = registryService.listByType(namespace, CalmResourceType.PATTERN); + List entries = registryService.listByType(namespace, RegistryResourceType.PATTERN); return entries.stream() .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); @@ -124,7 +124,7 @@ public void deletePattern(String namespace, int patternId) throws NamespaceNotFo } private RegistryEntry findEntryById(String namespace, int id) throws PatternNotFoundException { - List entries = registryService.listByType(namespace, CalmResourceType.PATTERN); + List entries = registryService.listByType(namespace, RegistryResourceType.PATTERN); Optional found = entries.stream() .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) .findFirst(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java index 1805ae2fb1..0c29a4eb03 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java @@ -10,9 +10,9 @@ import org.finos.calm.domain.exception.MappingNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.store.ResourceMappingStore; -import org.finos.calm.store.github.util.CalmResourceType; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; import java.util.List; import java.util.Optional; @@ -24,10 +24,10 @@ public class GitHubResourceMappingStore implements ResourceMappingStore { private static final String WRITE_UNSUPPORTED = "Resource ID mapping is managed by the GitHub repository. Writes are not supported in GitHub storage mode."; - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; @Inject - public GitHubResourceMappingStore(InMemoryRegistryService registryService) { + public GitHubResourceMappingStore(ResourceRegistry registryService) { this.registryService = registryService; } @@ -35,7 +35,7 @@ public GitHubResourceMappingStore(InMemoryRegistryService registryService) { public ResourceMapping getMapping(String namespace, ResourceType type, String customId) throws MappingNotFoundException, NamespaceNotFoundException { verifyNamespace(namespace); - CalmResourceType calmType = toCalmResourceType(type); + RegistryResourceType calmType = toRegistryResourceType(type); Optional entry = registryService.findByUniqueId(namespace, customId); if (entry.isEmpty() || entry.get().type() != calmType) { throw new MappingNotFoundException(); @@ -47,7 +47,7 @@ public ResourceMapping getMapping(String namespace, ResourceType type, String cu public List listMappings(String namespace, ResourceType typeFilter) throws NamespaceNotFoundException { verifyNamespace(namespace); - CalmResourceType calmType = toCalmResourceType(typeFilter); + RegistryResourceType calmType = toRegistryResourceType(typeFilter); return registryService.listByType(namespace, calmType).stream() .map(e -> toResourceMapping(namespace, typeFilter, e)) .toList(); @@ -57,7 +57,7 @@ public List listMappings(String namespace, ResourceType typeFil public ResourceMapping getMappingByNumericId(String namespace, ResourceType type, int numericId) throws MappingNotFoundException, NamespaceNotFoundException { verifyNamespace(namespace); - CalmResourceType calmType = toCalmResourceType(type); + RegistryResourceType calmType = toRegistryResourceType(type); Optional found = registryService.listByType(namespace, calmType).stream() .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == numericId) .findFirst(); @@ -71,7 +71,7 @@ public ResourceMapping getMappingByNumericId(String namespace, ResourceType type public List listMappingsByNumericIds(String namespace, ResourceType type, List ids) throws NamespaceNotFoundException { verifyNamespace(namespace); - CalmResourceType calmType = toCalmResourceType(type); + RegistryResourceType calmType = toRegistryResourceType(type); return registryService.listByType(namespace, calmType).stream() .filter(e -> ids.contains(e.uniqueId().hashCode() & 0x7FFFFFFF)) .map(e -> toResourceMapping(namespace, type, e)) @@ -111,13 +111,13 @@ private ResourceMapping toResourceMapping(String namespace, ResourceType type, R .build(); } - static CalmResourceType toCalmResourceType(ResourceType type) { + static RegistryResourceType toRegistryResourceType(ResourceType type) { return switch (type) { - case PATTERN -> CalmResourceType.PATTERN; - case ARCHITECTURE -> CalmResourceType.ARCHITECTURE; - case FLOW -> CalmResourceType.FLOW; - case STANDARD -> CalmResourceType.STANDARD; - case INTERFACE -> CalmResourceType.INTERFACE; + case PATTERN -> RegistryResourceType.PATTERN; + case ARCHITECTURE -> RegistryResourceType.ARCHITECTURE; + case FLOW -> RegistryResourceType.FLOW; + case STANDARD -> RegistryResourceType.STANDARD; + case INTERFACE -> RegistryResourceType.INTERFACE; }; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java index d44a50208d..bdb1ed7ace 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java @@ -6,9 +6,9 @@ import org.finos.calm.domain.search.GroupedSearchResults; import org.finos.calm.domain.search.SearchResult; import org.finos.calm.store.SearchStore; -import org.finos.calm.store.github.util.CalmResourceType; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; import java.util.List; import java.util.Optional; @@ -20,7 +20,7 @@ public class GitHubSearchStore implements SearchStore { @Inject - InMemoryRegistryService registryService; + ResourceRegistry registryService; @Override public GroupedSearchResults search(String query, Optional> readableNamespaces) { @@ -38,13 +38,13 @@ public GroupedSearchResults search(String query, Optional> readableN .toList(); return new GroupedSearchResults( - filterByType(allEntries, CalmResourceType.ARCHITECTURE), - filterByType(allEntries, CalmResourceType.PATTERN), - filterByType(allEntries, CalmResourceType.FLOW), - filterByType(allEntries, CalmResourceType.STANDARD), - filterByType(allEntries, CalmResourceType.INTERFACE), - filterByType(allEntries, CalmResourceType.CONTROL), - filterByType(allEntries, CalmResourceType.ADR) + filterByType(allEntries, RegistryResourceType.ARCHITECTURE), + filterByType(allEntries, RegistryResourceType.PATTERN), + filterByType(allEntries, RegistryResourceType.FLOW), + filterByType(allEntries, RegistryResourceType.STANDARD), + filterByType(allEntries, RegistryResourceType.INTERFACE), + filterByType(allEntries, RegistryResourceType.CONTROL), + filterByType(allEntries, RegistryResourceType.ADR) ); } @@ -53,7 +53,7 @@ private boolean matchesQuery(RegistryEntry entry, String lowerQuery) { || (entry.uniqueId() != null && entry.uniqueId().toLowerCase().contains(lowerQuery)); } - private List filterByType(List entries, CalmResourceType type) { + private List filterByType(List entries, RegistryResourceType type) { return entries.stream() .filter(e -> e.type() == type) .limit(MAX_RESULTS_PER_TYPE) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java index c9829ebe5f..54a4a88611 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java @@ -14,12 +14,12 @@ import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.domain.standards.CreateStandardRequest; import org.finos.calm.store.StandardStore; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,7 +37,7 @@ public class GitHubStandardStore implements StandardStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubStandardStore.class); - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; @Inject @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") @@ -50,14 +50,14 @@ public class GitHubStandardStore implements StandardStore { GitHubVersionService versionService; @Inject - public GitHubStandardStore(InMemoryRegistryService registryService) { + public GitHubStandardStore(ResourceRegistry registryService) { this.registryService = registryService; } @Override public List getStandardsForNamespace(String namespace) throws NamespaceNotFoundException { verifyNamespace(namespace); - List entries = registryService.listByType(namespace, CalmResourceType.STANDARD); + List entries = registryService.listByType(namespace, RegistryResourceType.STANDARD); return entries.stream() .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); @@ -128,7 +128,7 @@ public void deleteStandard(String namespace, Integer standardId) throws Namespac } private RegistryEntry findEntryById(String namespace, int id) throws StandardNotFoundException { - List entries = registryService.listByType(namespace, CalmResourceType.STANDARD); + List entries = registryService.listByType(namespace, RegistryResourceType.STANDARD); Optional found = entries.stream() .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) .findFirst(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java index 6701602b2c..cf5ccecb34 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java @@ -14,12 +14,12 @@ import org.finos.calm.domain.timeline.NamespaceTimelineSummary; import org.finos.calm.domain.timeline.Timeline; import org.finos.calm.store.TimelineStore; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubFileReader; import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,7 +36,7 @@ public class GitHubTimelineStore implements TimelineStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubTimelineStore.class); - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; @Inject @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") @@ -49,14 +49,14 @@ public class GitHubTimelineStore implements TimelineStore { GitHubVersionService versionService; @Inject - public GitHubTimelineStore(InMemoryRegistryService registryService) { + public GitHubTimelineStore(ResourceRegistry registryService) { this.registryService = registryService; } @Override public List getTimelinesForNamespace(String namespace) throws NamespaceNotFoundException { verifyNamespace(namespace); - List entries = registryService.listByType(namespace, CalmResourceType.TIMELINE); + List entries = registryService.listByType(namespace, RegistryResourceType.TIMELINE); return entries.stream() .map(e -> new NamespaceTimelineSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF))) .toList(); @@ -122,7 +122,7 @@ public void deleteTimeline(String namespace, int timelineId) throws NamespaceNot } private RegistryEntry findEntryById(String namespace, int id) throws TimelineNotFoundException { - List entries = registryService.listByType(namespace, CalmResourceType.TIMELINE); + List entries = registryService.listByType(namespace, RegistryResourceType.TIMELINE); Optional found = entries.stream() .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) .findFirst(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java index 1341fce7db..18d9c4980a 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java @@ -11,10 +11,10 @@ import org.finos.calm.domain.exception.UserAccessNotFoundException; import org.finos.calm.security.OidcRoleResolver; import org.finos.calm.store.UserAccessStore; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubControlDomains; -import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.registry.ControlDomains; +import org.finos.calm.store.github.registry.ResourceRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,7 +40,7 @@ public class GitHubUserAccessStore implements UserAccessStore { "Access grants cannot be created in GitHub mode. Assign users to the appropriate IdP roles instead."; @Inject - InMemoryRegistryService registryService; + ResourceRegistry registryService; @Inject OidcRoleResolver roleResolver; @@ -75,8 +75,8 @@ public List getGrantsForUser(String username) { if (level != OidcRoleResolver.AccessLevel.NONE) { grants.add(new UserAccess(username, UserAccess.Permission.read, namespace)); - registryService.getSnapshot().listByType(namespace, CalmResourceType.CONTROL).stream() - .map(GitHubControlDomains::extractDomain) + registryService.getSnapshot().listByType(namespace, RegistryResourceType.CONTROL).stream() + .map(ControlDomains::extractDomain) .forEach(accessibleDomains::add); } else { LOG.debug("User [{}] denied access to namespace [{}] — no matching group", username, namespace); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmContentDetector.java b/calm-hub/src/main/java/org/finos/calm/store/github/registry/CalmContentDetector.java similarity index 60% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/CalmContentDetector.java rename to calm-hub/src/main/java/org/finos/calm/store/github/registry/CalmContentDetector.java index 08816a7006..0bdad728fa 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmContentDetector.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/registry/CalmContentDetector.java @@ -1,5 +1,6 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.registry; +import io.quarkus.arc.lookup.LookupIfProperty; import jakarta.enterprise.context.ApplicationScoped; import jakarta.json.Json; import jakarta.json.JsonObject; @@ -7,6 +8,7 @@ import java.io.StringReader; import java.nio.file.Path; +import java.util.Optional; /** * Detects CALM resource types from JSON content and file path conventions. @@ -14,7 +16,7 @@ * JSON structure signals (nodes[], relationships[], moments[]) and parent * directory naming conventions (architectures/, patterns/, etc.). * - *

{@code building-blocks/} is deliberately aliased to {@link CalmResourceType#STANDARD} + *

{@code building-blocks/} is deliberately aliased to {@link RegistryResourceType#STANDARD} * rather than kept as its own type. "Building Block" was introduced as a new CALM Hub * resource type without going through the CALM concept design process; Office Hours * (2026-09-10, #3052) agreed it should be modelled as a {@code Standard} instead. Keeping @@ -22,45 +24,46 @@ * plugin's building-blocks-directory convention for CALM/architecture JSON files keep * working — only the domain concept goes away, not the file layout.

*/ +@LookupIfProperty(name = "calm.database.mode", stringValue = "github") @ApplicationScoped public class CalmContentDetector { - public CalmResourceType detect(String jsonContent, Path filePath) { + public Optional detect(String jsonContent, Path filePath) { if (jsonContent == null || jsonContent.isBlank()) { - return CalmResourceType.UNKNOWN; + return Optional.empty(); } JsonObject json; try (JsonReader reader = Json.createReader(new StringReader(jsonContent))) { json = reader.readObject(); } catch (Exception e) { - return CalmResourceType.UNKNOWN; + return Optional.empty(); } String parentDir = getParentDirectoryName(filePath); if (isTimeline(json)) { - return CalmResourceType.TIMELINE; + return Optional.of(RegistryResourceType.TIMELINE); } if (hasNodesOrRelationships(json)) { - return detectByDirectoryHint(parentDir, CalmResourceType.UNKNOWN); + return detectByDirectoryHint(parentDir); } if (hasSchemaMatching(json, "flow")) { - return CalmResourceType.FLOW; + return Optional.of(RegistryResourceType.FLOW); } if (hasSchemaMatching(json, "interface")) { - return CalmResourceType.INTERFACE; + return Optional.of(RegistryResourceType.INTERFACE); } if (hasSchemaMatching(json, "control")) { - return CalmResourceType.CONTROL; + return Optional.of(RegistryResourceType.CONTROL); } if (hasSchemaMatching(json, "decorator")) { - return CalmResourceType.DECORATOR; + return Optional.of(RegistryResourceType.DECORATOR); } return detectByDirectoryOnly(parentDir); @@ -84,33 +87,33 @@ private boolean hasSchemaMatching(JsonObject json, String keyword) { return schema.toLowerCase().contains(keyword); } - private CalmResourceType detectByDirectoryHint(String parentDir, CalmResourceType fallback) { - if (parentDir == null) return fallback; + private Optional detectByDirectoryHint(String parentDir) { + if (parentDir == null) return Optional.empty(); return switch (parentDir.toLowerCase()) { - case "patterns" -> CalmResourceType.PATTERN; - case "architectures" -> CalmResourceType.ARCHITECTURE; - case "standards", "building-blocks" -> CalmResourceType.STANDARD; - case "flows" -> CalmResourceType.FLOW; - case "interfaces" -> CalmResourceType.INTERFACE; - case "adrs" -> CalmResourceType.ADR; - case "decorators" -> CalmResourceType.DECORATOR; - default -> fallback; + case "patterns" -> Optional.of(RegistryResourceType.PATTERN); + case "architectures" -> Optional.of(RegistryResourceType.ARCHITECTURE); + case "standards", "building-blocks" -> Optional.of(RegistryResourceType.STANDARD); + case "flows" -> Optional.of(RegistryResourceType.FLOW); + case "interfaces" -> Optional.of(RegistryResourceType.INTERFACE); + case "adrs" -> Optional.of(RegistryResourceType.ADR); + case "decorators" -> Optional.of(RegistryResourceType.DECORATOR); + default -> Optional.empty(); }; } - private CalmResourceType detectByDirectoryOnly(String parentDir) { - if (parentDir == null) return CalmResourceType.UNKNOWN; + private Optional detectByDirectoryOnly(String parentDir) { + if (parentDir == null) return Optional.empty(); return switch (parentDir.toLowerCase()) { - case "architectures" -> CalmResourceType.ARCHITECTURE; - case "patterns" -> CalmResourceType.PATTERN; - case "standards", "building-blocks" -> CalmResourceType.STANDARD; - case "controls" -> CalmResourceType.CONTROL; - case "adrs" -> CalmResourceType.ADR; - case "flows" -> CalmResourceType.FLOW; - case "interfaces" -> CalmResourceType.INTERFACE; - case "timelines" -> CalmResourceType.TIMELINE; - case "decorators" -> CalmResourceType.DECORATOR; - default -> CalmResourceType.UNKNOWN; + case "architectures" -> Optional.of(RegistryResourceType.ARCHITECTURE); + case "patterns" -> Optional.of(RegistryResourceType.PATTERN); + case "standards", "building-blocks" -> Optional.of(RegistryResourceType.STANDARD); + case "controls" -> Optional.of(RegistryResourceType.CONTROL); + case "adrs" -> Optional.of(RegistryResourceType.ADR); + case "flows" -> Optional.of(RegistryResourceType.FLOW); + case "interfaces" -> Optional.of(RegistryResourceType.INTERFACE); + case "timelines" -> Optional.of(RegistryResourceType.TIMELINE); + case "decorators" -> Optional.of(RegistryResourceType.DECORATOR); + default -> Optional.empty(); }; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubControlDomains.java b/calm-hub/src/main/java/org/finos/calm/store/github/registry/ControlDomains.java similarity index 50% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubControlDomains.java rename to calm-hub/src/main/java/org/finos/calm/store/github/registry/ControlDomains.java index dc3b6a99d4..8ef96f71e1 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubControlDomains.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/registry/ControlDomains.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.registry; import java.nio.file.Path; @@ -7,16 +7,20 @@ * domain is the second path segment under a {@code controls/} directory * (e.g. {@code controls/security/access-control.json} -> domain {@code "security"}). * - *

Used by both {@link org.finos.calm.store.github.GitHubDomainStore} (to list the - * domains a namespace has) and {@link org.finos.calm.store.github.GitHubUserAccessStore} - * (to derive which domains a user's namespace-level access should also grant read on) - - * kept in one place so the two never drift. + *

Used by both {@code GitHubDomainStore} (to list the domains a namespace has) and + * {@code GitHubUserAccessStore} (to derive which domains a user's namespace-level access + * should also grant read on) — kept in one place so the two never drift. + * + *

Stays a static utility deliberately: it's a pure function of a {@link RegistryEntry} + * with no state and no configuration to inject — exactly the case static methods exist for. + * {@code GitHubControlStore.findControlEntry} re-derives the same domain by inline substring + * match rather than calling this — see the tracking issue for that drift.

*/ -public final class GitHubControlDomains { +public final class ControlDomains { private static final String DEFAULT_DOMAIN = "default"; - private GitHubControlDomains() { + private ControlDomains() { } public static String extractDomain(RegistryEntry entry) { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/RegistryEntry.java b/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistryEntry.java similarity index 69% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/RegistryEntry.java rename to calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistryEntry.java index 52d9681545..d42856b074 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/RegistryEntry.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistryEntry.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.registry; import java.nio.file.Path; import java.time.Instant; @@ -6,7 +6,7 @@ public record RegistryEntry( String uniqueId, Path filePath, - CalmResourceType type, + RegistryResourceType type, String name, Instant lastModified ) {} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistryResourceType.java b/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistryResourceType.java new file mode 100644 index 0000000000..c3b58c95dc --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistryResourceType.java @@ -0,0 +1,33 @@ +package org.finos.calm.store.github.registry; + +import org.finos.calm.domain.ResourceType; + +/** + * What kind of CALM file the GitHub-mode registry found while scanning a cloned repo — + * deliberately not {@link ResourceType}, which answers a different question: + * "which plural URL segment can the namespace front controller address?" + * + *

The two overlap on the seven types both track, but neither one degrades to the other. + * {@code ResourceType} is serialized on {@code ResourceMapping} (a public API shape) and is + * name-coupled to {@code AuditEntityType} via {@code AuditEntityType.valueOf(...)} + * ({@code AuditRequestFilter}) — widening it to cover {@code ADR}, {@code TIMELINE} and + * {@code DECORATOR} would be a public schema change with a matching-name obligation on a + * second enum, not an internal refactor. This type carries no such constraint: it only + * ever crosses the boundary between the registry scanner and the GitHub stores that read + * from it, so it stays free to describe every file shape those stores classify. + * + *

There is no {@code UNKNOWN} constant. A file the scanner can't classify is not a + * ninth kind of resource — it's the absence of one, which {@link CalmContentDetector#detect} + * expresses as {@code Optional.empty()} rather than by adding a sentinel here.

+ */ +public enum RegistryResourceType { + ARCHITECTURE, + PATTERN, + STANDARD, + CONTROL, + ADR, + FLOW, + INTERFACE, + TIMELINE, + DECORATOR +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/RegistrySnapshot.java b/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistrySnapshot.java similarity index 73% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/RegistrySnapshot.java rename to calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistrySnapshot.java index dfc31755a9..e009862f54 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/RegistrySnapshot.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistrySnapshot.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.registry; import java.util.Collections; import java.util.List; @@ -7,17 +7,16 @@ public record RegistrySnapshot( Map> entriesByNamespace, - Map entriesByQualifiedId, - Map> entriesByType + Map entriesByQualifiedId ) { public static final RegistrySnapshot EMPTY = new RegistrySnapshot( - Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap()); + Collections.emptyMap(), Collections.emptyMap()); public Optional findByUniqueId(String namespace, String uniqueId) { return Optional.ofNullable(entriesByQualifiedId.get(namespace + ":" + uniqueId)); } - public List listByType(String namespace, CalmResourceType type) { + public List listByType(String namespace, RegistryResourceType type) { return entriesByNamespace.getOrDefault(namespace, Collections.emptyList()) .stream() .filter(e -> e.type() == type) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java b/calm-hub/src/main/java/org/finos/calm/store/github/registry/ResourceRegistry.java similarity index 83% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java rename to calm-hub/src/main/java/org/finos/calm/store/github/registry/ResourceRegistry.java index df0952d774..1905fa2aa1 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/InMemoryRegistryService.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/registry/ResourceRegistry.java @@ -1,5 +1,6 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.registry; +import io.quarkus.arc.lookup.LookupIfProperty; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.json.Json; @@ -15,24 +16,30 @@ import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; -import java.util.EnumMap; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Stream; +/** + * In-memory index of every CALM resource file across all GitHub-mode namespace clones, + * rebuilt from scratch each time {@link #rebuild} runs (on startup, and on the periodic + * sync schedule). Reads always see one atomically-published, immutable + * {@link RegistrySnapshot} — never a partially-built one. + */ +@LookupIfProperty(name = "calm.database.mode", stringValue = "github") @ApplicationScoped -public class InMemoryRegistryService { +public class ResourceRegistry { - private static final Logger LOG = LoggerFactory.getLogger(InMemoryRegistryService.class); + private static final Logger LOG = LoggerFactory.getLogger(ResourceRegistry.class); private volatile RegistrySnapshot snapshot = RegistrySnapshot.EMPTY; private final CalmContentDetector contentDetector; @Inject - public InMemoryRegistryService(CalmContentDetector contentDetector) { + public ResourceRegistry(CalmContentDetector contentDetector) { this.contentDetector = contentDetector; } @@ -43,7 +50,6 @@ public RegistrySnapshot getSnapshot() { public void rebuild(Map namespaceClonePaths) { Map> byNamespace = new HashMap<>(); Map byQualifiedId = new HashMap<>(); - Map> byType = new EnumMap<>(CalmResourceType.class); for (Map.Entry entry : namespaceClonePaths.entrySet()) { String namespace = entry.getKey(); @@ -54,14 +60,12 @@ public void rebuild(Map namespaceClonePaths) { for (RegistryEntry registryEntry : entries) { byQualifiedId.put(namespace + ":" + registryEntry.uniqueId(), registryEntry); - byType.computeIfAbsent(registryEntry.type(), k -> new ArrayList<>()).add(registryEntry); } } this.snapshot = new RegistrySnapshot( Map.copyOf(byNamespace), - Map.copyOf(byQualifiedId), - Map.copyOf(byType) + Map.copyOf(byQualifiedId) ); LOG.info("Registry rebuilt: {} namespaces, {} total entries", byNamespace.size(), byQualifiedId.size()); @@ -71,7 +75,7 @@ public Optional findByUniqueId(String namespace, String uniqueId) return snapshot.findByUniqueId(namespace, uniqueId); } - public List listByType(String namespace, CalmResourceType type) { + public List listByType(String namespace, RegistryResourceType type) { return snapshot.listByType(namespace, type); } @@ -94,7 +98,7 @@ private List scanDirectory(String namespace, Path root) { .filter(p -> !isHiddenOrMetadata(root, p)) .forEach(filePath -> { RegistryEntry entry = parseFile(root, filePath); - if (entry != null && entry.type() != CalmResourceType.UNKNOWN) { + if (entry != null) { entries.add(entry); } }); @@ -115,9 +119,9 @@ private RegistryEntry parseFile(Path root, Path filePath) { } String content = Files.readString(filePath); - CalmResourceType type = contentDetector.detect(content, relativePath); + Optional type = contentDetector.detect(content, relativePath); - if (type == CalmResourceType.UNKNOWN) { + if (type.isEmpty()) { return null; } @@ -125,7 +129,7 @@ private RegistryEntry parseFile(Path root, Path filePath) { String name = extractName(content, relativePath); Instant lastModified = Files.getLastModifiedTime(filePath).toInstant(); - return new RegistryEntry(uniqueId, relativePath, type, name, lastModified); + return new RegistryEntry(uniqueId, relativePath, type.get(), name, lastModified); } catch (IOException e) { LOG.debug("Failed to parse file: {}", filePath, e); return null; @@ -133,8 +137,8 @@ private RegistryEntry parseFile(Path root, Path filePath) { } private RegistryEntry parseMarkdownFile(Path filePath, Path relativePath) throws IOException { - CalmResourceType type = detectMarkdownType(relativePath); - if (type == CalmResourceType.UNKNOWN) { + RegistryResourceType type = detectMarkdownType(relativePath); + if (type == null) { return null; } String fileName = filePath.getFileName().toString().replace(".md", ""); @@ -146,16 +150,16 @@ private RegistryEntry parseMarkdownFile(Path filePath, Path relativePath) throws return new RegistryEntry(fileName, relativePath, type, name, lastModified); } - private CalmResourceType detectMarkdownType(Path relativePath) { + private RegistryResourceType detectMarkdownType(Path relativePath) { for (int i = 0; i < relativePath.getNameCount() - 1; i++) { String segment = relativePath.getName(i).toString().toLowerCase(); switch (segment) { - case "standards", "building-blocks": return CalmResourceType.STANDARD; - case "adrs": return CalmResourceType.ADR; + case "standards", "building-blocks": return RegistryResourceType.STANDARD; + case "adrs": return RegistryResourceType.ADR; default: break; } } - return CalmResourceType.UNKNOWN; + return null; } private String extractUniqueId(String content, Path relativePath) { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java deleted file mode 100644 index 0964858637..0000000000 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/CalmResourceType.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.finos.calm.store.github.util; - -public enum CalmResourceType { - ARCHITECTURE, - PATTERN, - STANDARD, - CONTROL, - ADR, - FLOW, - INTERFACE, - TIMELINE, - DECORATOR, - UNKNOWN -} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java index 19733cbfaf..fff70eda5b 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java @@ -9,6 +9,7 @@ import org.eclipse.microprofile.context.ManagedExecutor; import org.finos.calm.config.DatabaseMode; import org.finos.calm.observability.GitHubMetrics; +import org.finos.calm.store.github.registry.ResourceRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,7 +38,7 @@ public class GitHubStartupInitializer { GitHubCloneManager cloneManager; @Inject - InMemoryRegistryService registryService; + ResourceRegistry registryService; @Inject GitHubMetrics metrics; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java index 3bd27750c2..4b1fe0fe7e 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java @@ -7,6 +7,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.config.DatabaseMode; import org.finos.calm.observability.GitHubMetrics; +import org.finos.calm.store.github.registry.ResourceRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,7 +25,7 @@ public class GitHubSyncScheduler { private static final Logger LOG = LoggerFactory.getLogger(GitHubSyncScheduler.class); private final GitHubCloneManager cloneManager; - private final InMemoryRegistryService registryService; + private final ResourceRegistry registryService; private final GitHubMetrics metrics; // @LookupIfProperty does not stop @Scheduled invocation once this bean exists - see @@ -35,7 +36,7 @@ public class GitHubSyncScheduler { @Inject public GitHubSyncScheduler(GitHubCloneManager cloneManager, - InMemoryRegistryService registryService, + ResourceRegistry registryService, GitHubMetrics metrics) { this.cloneManager = cloneManager; this.registryService = registryService; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java index 54086afb1e..095fd20fd1 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java @@ -5,6 +5,7 @@ import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.security.OidcRoleResolver; +import org.finos.calm.store.github.registry.ResourceRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,7 +31,7 @@ public class NamespaceAccessFilter { OidcRoleResolver roleResolver; @Inject - InMemoryRegistryService registryService; + ResourceRegistry registryService; @Inject GitHubCloneManager cloneManager; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java index 4f58708c01..a27269a7f7 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java @@ -4,8 +4,8 @@ import org.finos.calm.domain.adr.Status; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.adr.NamespaceAdrSummary; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -26,7 +26,7 @@ class TestGitHubAdrStoreShould { @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; private GitHubAdrStore store; @@ -39,9 +39,7 @@ void setup() { void return_empty_adrs_for_namespace() throws NamespaceNotFoundException { RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); List result = store.getAdrsForNamespace("finos"); @@ -53,9 +51,7 @@ void return_empty_adrs_for_namespace() throws NamespaceNotFoundException { void return_zero_count_for_namespace() throws NamespaceNotFoundException { RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); assertThat(store.countAdrsForNamespace("finos"), equalTo(0)); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java index 32e74c6671..7eefe53e8d 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java @@ -5,10 +5,10 @@ import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.store.PageRequest; -import org.finos.calm.store.github.util.CalmResourceType; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -32,7 +32,7 @@ class TestGitHubArchitectureStoreShould { @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; private GitHubArchitectureStore store; @@ -44,15 +44,13 @@ void setup() { @Test void return_architectures_for_namespace() throws NamespaceNotFoundException { RegistryEntry entry = new RegistryEntry("my-arch", Path.of("architectures/my-arch.json"), - CalmResourceType.ARCHITECTURE, "My Architecture", Instant.now()); + RegistryResourceType.ARCHITECTURE, "My Architecture", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:my-arch", entry), - Map.of(CalmResourceType.ARCHITECTURE, List.of(entry)) - ); + Map.of("finos:my-arch", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.ARCHITECTURE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(List.of(entry)); List result = store.getArchitecturesForNamespace("finos", PageRequest.UNPAGED); @@ -97,14 +95,12 @@ void throw_namespace_not_found_on_get_architecture_for_version() { @Test void return_versions_list_for_existing_architecture() throws Exception { RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), - CalmResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistryResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-arch", entry), - java.util.Map.of(CalmResourceType.ARCHITECTURE, java.util.List.of(entry)) - ); + java.util.Map.of("finos:test-arch", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(hashId).build(); @@ -117,14 +113,12 @@ void return_versions_list_for_existing_architecture() throws Exception { @Test void return_sha_versions_when_version_service_available() throws Exception { RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), - CalmResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistryResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-arch", entry), - java.util.Map.of(CalmResourceType.ARCHITECTURE, java.util.List.of(entry)) - ); + java.util.Map.of("finos:test-arch", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); org.finos.calm.store.github.util.GitHubVersionService mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubVersionService.class); @@ -151,14 +145,12 @@ void return_architecture_content_for_version(@org.junit.jupiter.api.io.TempDir j java.nio.file.Files.writeString(archDir.resolve("test.json"), "{\"nodes\":[],\"relationships\":[]}"); RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), - CalmResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistryResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-arch", entry), - java.util.Map.of(CalmResourceType.ARCHITECTURE, java.util.List.of(entry)) - ); + java.util.Map.of("finos:test-arch", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); store.cloneDirectory = tempDir.toString(); int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); @@ -171,14 +163,12 @@ void return_architecture_content_for_version(@org.junit.jupiter.api.io.TempDir j @Test void return_content_from_github_api_for_sha_version() throws Exception { RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), - CalmResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistryResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-arch", entry), - java.util.Map.of(CalmResourceType.ARCHITECTURE, java.util.List.of(entry)) - ); + java.util.Map.of("finos:test-arch", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); org.finos.calm.store.github.util.GitHubVersionService mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubVersionService.class); @@ -200,14 +190,12 @@ void return_content_from_github_api_for_sha_version() throws Exception { @Test void throw_architecture_not_found_when_id_does_not_match() { RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), - CalmResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistryResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-arch", entry), - java.util.Map.of(CalmResourceType.ARCHITECTURE, java.util.List.of(entry)) - ); + java.util.Map.of("finos:test-arch", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(99999).build(); assertThrows(ArchitectureNotFoundException.class, () -> store.getArchitectureVersions(arch)); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index 1db18bcb14..e780eb9eea 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -6,13 +6,13 @@ import org.finos.calm.domain.exception.ControlNotFoundException; import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; import org.finos.calm.domain.exception.DomainNotFoundException; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.util.NamespaceAccessFilter; -import org.finos.calm.store.github.util.RegistryEntry; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,7 +42,7 @@ class TestGitHubControlStoreShould { private static final int HASH_ID = UNIQUE_ID.hashCode() & 0x7FFFFFFF; @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; @Mock private NamespaceAccessFilter accessFilter; @@ -62,14 +62,12 @@ void return_controls_for_domain() throws Exception { // unrelated concepts, and a fixture where they happen to share a name would hide // a namespace/domain mix-up regression (see the cross-domain-match test below). RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), - CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:" + UNIQUE_ID, entry), - Map.of(CalmResourceType.CONTROL, List.of(entry)) - ); + Map.of("finos:" + UNIQUE_ID, entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); List result = store.getControlsForDomain(DOMAIN); @@ -93,14 +91,12 @@ void throw_domain_not_found_when_domain_missing_on_get_controls() { void return_all_controls_when_access_filter_is_null() throws Exception { GitHubControlStore unfilteredStore = new GitHubControlStore(registryService); RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), - CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:" + UNIQUE_ID, entry), - Map.of(CalmResourceType.CONTROL, List.of(entry)) - ); + Map.of("finos:" + UNIQUE_ID, entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); List result = unfilteredStore.getControlsForDomain(DOMAIN); @@ -111,16 +107,14 @@ void return_all_controls_when_access_filter_is_null() throws Exception { @Test void hide_controls_from_inaccessible_namespaces() throws Exception { RegistryEntry accessibleEntry = new RegistryEntry("ctrl-a", Path.of("controls/security/ctrl-a.json"), - CalmResourceType.CONTROL, "Control A", Instant.now()); + RegistryResourceType.CONTROL, "Control A", Instant.now()); RegistryEntry restrictedEntry = new RegistryEntry("ctrl-b", Path.of("controls/security/ctrl-b.json"), - CalmResourceType.CONTROL, "Control B", Instant.now()); + RegistryResourceType.CONTROL, "Control B", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(accessibleEntry), "private", List.of(restrictedEntry)), - Map.of("finos:ctrl-a", accessibleEntry, "private:ctrl-b", restrictedEntry), - Map.of(CalmResourceType.CONTROL, List.of(accessibleEntry, restrictedEntry)) - ); + Map.of("finos:ctrl-a", accessibleEntry, "private:ctrl-b", restrictedEntry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(accessibleEntry)); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(accessibleEntry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); List result = store.getControlsForDomain(DOMAIN); @@ -132,16 +126,14 @@ void hide_controls_from_inaccessible_namespaces() throws Exception { @Test void deny_version_lookup_for_control_in_inaccessible_namespace() { RegistryEntry accessible = new RegistryEntry("ctrl-a", Path.of("controls/security/ctrl-a.json"), - CalmResourceType.CONTROL, "Control A", Instant.now()); + RegistryResourceType.CONTROL, "Control A", Instant.now()); RegistryEntry restricted = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), - CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(accessible), "private", List.of(restricted)), - Map.of("finos:ctrl-a", accessible, "private:" + UNIQUE_ID, restricted), - Map.of(CalmResourceType.CONTROL, List.of(accessible, restricted)) - ); + Map.of("finos:ctrl-a", accessible, "private:" + UNIQUE_ID, restricted)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(accessible)); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(accessible)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); assertThrows(ControlNotFoundException.class, @@ -151,16 +143,14 @@ void deny_version_lookup_for_control_in_inaccessible_namespace() { @Test void return_versions_for_control_in_accessible_namespace_with_mixed_access() throws Exception { RegistryEntry accessible = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), - CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistryEntry restricted = new RegistryEntry("other", Path.of("controls/security/other.json"), - CalmResourceType.CONTROL, "Other", Instant.now()); + RegistryResourceType.CONTROL, "Other", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(accessible), "private", List.of(restricted)), - Map.of("finos:" + UNIQUE_ID, accessible, "private:other", restricted), - Map.of(CalmResourceType.CONTROL, List.of(accessible, restricted)) - ); + Map.of("finos:" + UNIQUE_ID, accessible, "private:other", restricted)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(accessible)); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(accessible)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); List versions = store.getRequirementVersions(DOMAIN, HASH_ID); @@ -172,14 +162,12 @@ void return_versions_for_control_in_accessible_namespace_with_mixed_access() thr @Test void return_versions_list_for_existing_control() throws Exception { RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), - CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:" + UNIQUE_ID, entry), - Map.of(CalmResourceType.CONTROL, List.of(entry)) - ); + Map.of("finos:" + UNIQUE_ID, entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); List versions = store.getRequirementVersions(DOMAIN, HASH_ID); @@ -191,14 +179,12 @@ void return_versions_list_for_existing_control() throws Exception { @Test void return_sha_versions_when_version_service_available() throws Exception { RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), - CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:" + UNIQUE_ID, entry), - Map.of(CalmResourceType.CONTROL, List.of(entry)) - ); + Map.of("finos:" + UNIQUE_ID, entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); @@ -224,14 +210,12 @@ void return_control_content_for_version(@TempDir Path tempDir) throws Exception Files.writeString(controlDir.resolve("my-control.json"), "{\"control\":\"data\"}"); RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), - CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:" + UNIQUE_ID, entry), - Map.of(CalmResourceType.CONTROL, List.of(entry)) - ); + Map.of("finos:" + UNIQUE_ID, entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); store.cloneDirectory = tempDir.toString(); @@ -243,14 +227,12 @@ void return_control_content_for_version(@TempDir Path tempDir) throws Exception @Test void return_content_from_github_api_for_sha_version() throws Exception { RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), - CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:" + UNIQUE_ID, entry), - Map.of(CalmResourceType.CONTROL, List.of(entry)) - ); + Map.of("finos:" + UNIQUE_ID, entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); @@ -279,14 +261,12 @@ void throw_domain_not_found_on_get_requirement_versions() { @Test void throw_control_not_found_when_id_does_not_match() { RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), - CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:" + UNIQUE_ID, entry), - Map.of(CalmResourceType.CONTROL, List.of(entry)) - ); + Map.of("finos:" + UNIQUE_ID, entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); assertThrows(ControlNotFoundException.class, @@ -305,14 +285,12 @@ void throw_domain_not_found_on_get_requirement_for_version() { @Test void throw_requirement_version_not_found_when_file_missing(@TempDir Path tempDir) throws Exception { RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/nonexistent.json"), - CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:" + UNIQUE_ID, entry), - Map.of(CalmResourceType.CONTROL, List.of(entry)) - ); + Map.of("finos:" + UNIQUE_ID, entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); store.cloneDirectory = tempDir.toString(); @@ -388,14 +366,12 @@ void throw_domain_not_found_rather_than_a_cross_domain_match_when_domain_does_no // registry must 404 on the domain, never fall through to returning a different // domain's control just because the id happened to match. RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/payments/my-control.json"), - CalmResourceType.CONTROL, "My Control", Instant.now()); + RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:" + UNIQUE_ID, entry), - Map.of(CalmResourceType.CONTROL, List.of(entry)) - ); + Map.of("finos:" + UNIQUE_ID, entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.CONTROL)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); assertThrows(DomainNotFoundException.class, diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java index 59a80326e5..5a22859f2b 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDecoratorStoreShould.java @@ -2,8 +2,8 @@ import org.finos.calm.domain.Decorator; import org.finos.calm.domain.exception.NamespaceNotFoundException; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -25,7 +25,7 @@ class TestGitHubDecoratorStoreShould { @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; private GitHubDecoratorStore store; @@ -38,9 +38,7 @@ void setup() { void return_empty_decorators_for_namespace() throws NamespaceNotFoundException { RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); List result = store.getDecoratorsForNamespace("finos", "target", "type"); @@ -52,9 +50,7 @@ void return_empty_decorators_for_namespace() throws NamespaceNotFoundException { void return_empty_decorator_values_for_namespace() throws NamespaceNotFoundException { RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); List result = store.getDecoratorValuesForNamespace("finos", "target", "type"); @@ -66,9 +62,7 @@ void return_empty_decorator_values_for_namespace() throws NamespaceNotFoundExcep void return_empty_optional_for_decorator_by_id() throws NamespaceNotFoundException { RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); Optional result = store.getDecoratorById("finos", 1); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java index b6ca5bc499..31c812df50 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java @@ -1,10 +1,10 @@ package org.finos.calm.store.github; -import org.finos.calm.store.github.util.CalmResourceType; -import org.finos.calm.store.github.util.InMemoryRegistryService; +import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.util.NamespaceAccessFilter; -import org.finos.calm.store.github.util.RegistryEntry; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -28,7 +28,7 @@ class TestGitHubDomainStoreShould { @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; @Mock private NamespaceAccessFilter accessFilter; @@ -44,12 +44,10 @@ void setup() { @Test void return_domains_derived_from_controls_directory() { RegistryEntry entry = new RegistryEntry("tls-policy", Path.of("controls/security/tls-policy.json"), - CalmResourceType.CONTROL, "TLS Policy", Instant.now()); + RegistryResourceType.CONTROL, "TLS Policy", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:tls-policy", entry), - Map.of(CalmResourceType.CONTROL, List.of(entry)) - ); + Map.of("finos:tls-policy", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); @@ -71,12 +69,10 @@ void return_empty_when_no_controls_exist() { @Test void return_true_when_domain_exists() { RegistryEntry entry = new RegistryEntry("ctrl", Path.of("controls/security/ctrl.json"), - CalmResourceType.CONTROL, "Ctrl", Instant.now()); + RegistryResourceType.CONTROL, "Ctrl", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:ctrl", entry), - Map.of(CalmResourceType.CONTROL, List.of(entry)) - ); + Map.of("finos:ctrl", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); @@ -94,14 +90,12 @@ void return_false_when_domain_does_not_exist() { @Test void hide_domains_from_inaccessible_namespaces() { RegistryEntry accessibleEntry = new RegistryEntry("ctrl-a", Path.of("controls/security/ctrl-a.json"), - CalmResourceType.CONTROL, "Control A", Instant.now()); + RegistryResourceType.CONTROL, "Control A", Instant.now()); RegistryEntry restrictedEntry = new RegistryEntry("ctrl-b", Path.of("controls/compliance/ctrl-b.json"), - CalmResourceType.CONTROL, "Control B", Instant.now()); + RegistryResourceType.CONTROL, "Control B", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(accessibleEntry), "private", List.of(restrictedEntry)), - Map.of("finos:ctrl-a", accessibleEntry, "private:ctrl-b", restrictedEntry), - Map.of(CalmResourceType.CONTROL, List.of(accessibleEntry, restrictedEntry)) - ); + Map.of("finos:ctrl-a", accessibleEntry, "private:ctrl-b", restrictedEntry)); when(registryService.getSnapshot()).thenReturn(snapshot); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java index 09b4287eed..91dcf3c4b7 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java @@ -6,12 +6,12 @@ import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.flow.CreateFlowRequest; import org.finos.calm.domain.namespaces.NamespaceResourceSummary; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -36,7 +36,7 @@ class TestGitHubFlowStoreShould { @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; private GitHubFlowStore store; @@ -48,15 +48,13 @@ void setup() { @Test void return_flows_for_namespace() throws NamespaceNotFoundException { RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), - CalmResourceType.FLOW, "Payment Flow", Instant.now()); + RegistryResourceType.FLOW, "Payment Flow", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:payment-flow", entry), - Map.of(CalmResourceType.FLOW, List.of(entry)) - ); + Map.of("finos:payment-flow", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.FLOW)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); List result = store.getFlowsForNamespace("finos"); @@ -101,14 +99,12 @@ void throw_namespace_not_found_on_get_flow_for_version() { @Test void return_versions_list_for_existing_flow() throws Exception { RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), - CalmResourceType.FLOW, "Payment Flow", Instant.now()); + RegistryResourceType.FLOW, "Payment Flow", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:payment-flow", entry), - Map.of(CalmResourceType.FLOW, List.of(entry)) - ); + Map.of("finos:payment-flow", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.FLOW)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(hashId).build(); @@ -121,14 +117,12 @@ void return_versions_list_for_existing_flow() throws Exception { @Test void return_sha_versions_when_version_service_available() throws Exception { RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), - CalmResourceType.FLOW, "Payment Flow", Instant.now()); + RegistryResourceType.FLOW, "Payment Flow", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:payment-flow", entry), - Map.of(CalmResourceType.FLOW, List.of(entry)) - ); + Map.of("finos:payment-flow", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.FLOW)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); @@ -155,14 +149,12 @@ void return_flow_content_for_version(@TempDir Path tempDir) throws Exception { Files.writeString(flowDir.resolve("payment-flow.json"), "{\"steps\":[]}"); RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), - CalmResourceType.FLOW, "Payment Flow", Instant.now()); + RegistryResourceType.FLOW, "Payment Flow", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:payment-flow", entry), - Map.of(CalmResourceType.FLOW, List.of(entry)) - ); + Map.of("finos:payment-flow", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.FLOW)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); store.cloneDirectory = tempDir.toString(); int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); @@ -175,14 +167,12 @@ void return_flow_content_for_version(@TempDir Path tempDir) throws Exception { @Test void return_content_from_github_api_for_sha_version() throws Exception { RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), - CalmResourceType.FLOW, "Payment Flow", Instant.now()); + RegistryResourceType.FLOW, "Payment Flow", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:payment-flow", entry), - Map.of(CalmResourceType.FLOW, List.of(entry)) - ); + Map.of("finos:payment-flow", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.FLOW)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); @@ -203,14 +193,12 @@ void return_content_from_github_api_for_sha_version() throws Exception { @Test void throw_flow_not_found_when_id_does_not_match() { RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), - CalmResourceType.FLOW, "Payment Flow", Instant.now()); + RegistryResourceType.FLOW, "Payment Flow", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:payment-flow", entry), - Map.of(CalmResourceType.FLOW, List.of(entry)) - ); + Map.of("finos:payment-flow", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.FLOW)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(99999).build(); assertThrows(FlowNotFoundException.class, () -> store.getFlowVersions(flow)); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java index a3f3153cfb..2f5508e719 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java @@ -5,12 +5,12 @@ import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.interfaces.CreateInterfaceRequest; import org.finos.calm.domain.interfaces.NamespaceInterfaceSummary; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -37,7 +37,7 @@ class TestGitHubInterfaceStoreShould { @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; private GitHubInterfaceStore store; @@ -50,11 +50,9 @@ void setup() { void return_empty_interfaces_for_namespace() throws NamespaceNotFoundException { RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of()); + when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of()); List result = store.getInterfacesForNamespace("finos"); @@ -64,15 +62,13 @@ void return_empty_interfaces_for_namespace() throws NamespaceNotFoundException { @Test void return_interfaces_for_namespace() throws NamespaceNotFoundException { RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), - CalmResourceType.INTERFACE, "Payment API", Instant.now()); + RegistryResourceType.INTERFACE, "Payment API", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:payment-api", entry), - Map.of(CalmResourceType.INTERFACE, List.of(entry)) - ); + Map.of("finos:payment-api", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); List result = store.getInterfacesForNamespace("finos"); @@ -117,14 +113,12 @@ void throw_namespace_not_found_on_get_interface_for_version() { @Test void return_versions_list_for_existing_interface() throws Exception { RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), - CalmResourceType.INTERFACE, "Payment API", Instant.now()); + RegistryResourceType.INTERFACE, "Payment API", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:payment-api", entry), - Map.of(CalmResourceType.INTERFACE, List.of(entry)) - ); + Map.of("finos:payment-api", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); List versions = store.getInterfaceVersions("finos", hashId); @@ -136,14 +130,12 @@ void return_versions_list_for_existing_interface() throws Exception { @Test void return_sha_versions_when_version_service_available() throws Exception { RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), - CalmResourceType.INTERFACE, "Payment API", Instant.now()); + RegistryResourceType.INTERFACE, "Payment API", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:payment-api", entry), - Map.of(CalmResourceType.INTERFACE, List.of(entry)) - ); + Map.of("finos:payment-api", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); @@ -169,14 +161,12 @@ void return_interface_content_for_version(@TempDir Path tempDir) throws Exceptio Files.writeString(ifaceDir.resolve("payment-api.json"), "{\"operations\":[]}"); RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), - CalmResourceType.INTERFACE, "Payment API", Instant.now()); + RegistryResourceType.INTERFACE, "Payment API", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:payment-api", entry), - Map.of(CalmResourceType.INTERFACE, List.of(entry)) - ); + Map.of("finos:payment-api", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); store.cloneDirectory = tempDir.toString(); int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); @@ -188,14 +178,12 @@ void return_interface_content_for_version(@TempDir Path tempDir) throws Exceptio @Test void return_content_from_github_api_for_sha_version() throws Exception { RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), - CalmResourceType.INTERFACE, "Payment API", Instant.now()); + RegistryResourceType.INTERFACE, "Payment API", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:payment-api", entry), - Map.of(CalmResourceType.INTERFACE, List.of(entry)) - ); + Map.of("finos:payment-api", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); @@ -215,14 +203,12 @@ void return_content_from_github_api_for_sha_version() throws Exception { @Test void throw_interface_not_found_when_id_does_not_match() { RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), - CalmResourceType.INTERFACE, "Payment API", Instant.now()); + RegistryResourceType.INTERFACE, "Payment API", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:payment-api", entry), - Map.of(CalmResourceType.INTERFACE, List.of(entry)) - ); + Map.of("finos:payment-api", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.INTERFACE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); assertThrows(InterfaceNotFoundException.class, () -> store.getInterfaceVersions("finos", 99999)); } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubNamespaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubNamespaceStoreShould.java index 4536932ae1..cdfa843d6c 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubNamespaceStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubNamespaceStoreShould.java @@ -2,9 +2,9 @@ import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.namespaces.NamespaceInfo; -import org.finos.calm.store.github.util.CalmResourceType; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -25,7 +25,7 @@ class TestGitHubNamespaceStoreShould { @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; private GitHubNamespaceStore store; @@ -38,9 +38,7 @@ void setup() { void return_namespaces_from_registry() { RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(), "team", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); List result = store.getNamespaces(); @@ -52,9 +50,7 @@ void return_namespaces_from_registry() { void return_true_when_namespace_exists() { RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); assertThat(store.namespaceExists("finos"), is(true)); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java index 8e3bb04f42..d66c9214c5 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java @@ -7,12 +7,12 @@ import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.domain.pattern.CreatePatternRequest; import org.finos.calm.store.PageRequest; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -37,7 +37,7 @@ class TestGitHubPatternStoreShould { @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; private GitHubPatternStore store; @@ -49,15 +49,13 @@ void setup() { @Test void return_patterns_for_namespace() throws NamespaceNotFoundException { RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), - CalmResourceType.PATTERN, "Event Driven", Instant.now()); + RegistryResourceType.PATTERN, "Event Driven", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:event-driven", entry), - Map.of(CalmResourceType.PATTERN, List.of(entry)) - ); + Map.of("finos:event-driven", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.PATTERN)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); List result = store.getPatternsForNamespace("finos", PageRequest.UNPAGED); @@ -114,14 +112,12 @@ void throw_namespace_not_found_on_get_pattern_for_version() { @Test void return_versions_list_for_existing_pattern() throws Exception { RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), - CalmResourceType.PATTERN, "Event Driven", Instant.now()); + RegistryResourceType.PATTERN, "Event Driven", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:event-driven", entry), - Map.of(CalmResourceType.PATTERN, List.of(entry)) - ); + Map.of("finos:event-driven", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.PATTERN)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(hashId).build(); @@ -134,14 +130,12 @@ void return_versions_list_for_existing_pattern() throws Exception { @Test void return_sha_versions_when_version_service_available() throws Exception { RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), - CalmResourceType.PATTERN, "Event Driven", Instant.now()); + RegistryResourceType.PATTERN, "Event Driven", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:event-driven", entry), - Map.of(CalmResourceType.PATTERN, List.of(entry)) - ); + Map.of("finos:event-driven", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.PATTERN)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); @@ -168,14 +162,12 @@ void return_pattern_content_for_version(@TempDir Path tempDir) throws Exception Files.writeString(patternDir.resolve("event-driven.json"), "{\"nodes\":[],\"relationships\":[]}"); RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), - CalmResourceType.PATTERN, "Event Driven", Instant.now()); + RegistryResourceType.PATTERN, "Event Driven", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:event-driven", entry), - Map.of(CalmResourceType.PATTERN, List.of(entry)) - ); + Map.of("finos:event-driven", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.PATTERN)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); store.cloneDirectory = tempDir.toString(); int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); @@ -188,14 +180,12 @@ void return_pattern_content_for_version(@TempDir Path tempDir) throws Exception @Test void return_content_from_github_api_for_sha_version() throws Exception { RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), - CalmResourceType.PATTERN, "Event Driven", Instant.now()); + RegistryResourceType.PATTERN, "Event Driven", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:event-driven", entry), - Map.of(CalmResourceType.PATTERN, List.of(entry)) - ); + Map.of("finos:event-driven", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.PATTERN)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); @@ -216,14 +206,12 @@ void return_content_from_github_api_for_sha_version() throws Exception { @Test void throw_pattern_not_found_when_id_does_not_match() { RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), - CalmResourceType.PATTERN, "Event Driven", Instant.now()); + RegistryResourceType.PATTERN, "Event Driven", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:event-driven", entry), - Map.of(CalmResourceType.PATTERN, List.of(entry)) - ); + Map.of("finos:event-driven", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.PATTERN)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(99999).build(); assertThrows(PatternNotFoundException.class, () -> store.getPatternVersions(pattern)); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java index 586cce9725..24e8c7e9a2 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubResourceMappingStoreShould.java @@ -5,10 +5,10 @@ import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; import org.finos.calm.domain.exception.MappingNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; -import org.finos.calm.store.github.util.CalmResourceType; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -35,7 +35,7 @@ class TestGitHubResourceMappingStoreShould { private static final int NUMERIC_ID = UNIQUE_ID.hashCode() & 0x7FFFFFFF; @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; private GitHubResourceMappingStore store; @@ -46,7 +46,7 @@ void setup() { @Test void return_mapping_when_entry_exists_in_registry() throws Exception { - RegistryEntry entry = createEntry(UNIQUE_ID, CalmResourceType.ARCHITECTURE); + RegistryEntry entry = createEntry(UNIQUE_ID, RegistryResourceType.ARCHITECTURE); setupNamespaceWithEntry(entry); when(registryService.findByUniqueId(NAMESPACE, UNIQUE_ID)).thenReturn(Optional.of(entry)); @@ -69,7 +69,7 @@ void throw_mapping_not_found_when_entry_missing() { @Test void throw_mapping_not_found_when_type_mismatch() { - RegistryEntry entry = createEntry(UNIQUE_ID, CalmResourceType.PATTERN); + RegistryEntry entry = createEntry(UNIQUE_ID, RegistryResourceType.PATTERN); setupNamespaceWithEntry(entry); when(registryService.findByUniqueId(NAMESPACE, UNIQUE_ID)).thenReturn(Optional.of(entry)); @@ -87,10 +87,10 @@ void throw_namespace_not_found_when_namespace_unknown() { @Test void list_mappings_for_namespace_and_type() throws Exception { - RegistryEntry entry1 = createEntry("arch-one", CalmResourceType.ARCHITECTURE); - RegistryEntry entry2 = createEntry("arch-two", CalmResourceType.ARCHITECTURE); + RegistryEntry entry1 = createEntry("arch-one", RegistryResourceType.ARCHITECTURE); + RegistryEntry entry2 = createEntry("arch-two", RegistryResourceType.ARCHITECTURE); setupNamespace(); - when(registryService.listByType(NAMESPACE, CalmResourceType.ARCHITECTURE)) + when(registryService.listByType(NAMESPACE, RegistryResourceType.ARCHITECTURE)) .thenReturn(List.of(entry1, entry2)); List mappings = store.listMappings(NAMESPACE, ResourceType.ARCHITECTURE); @@ -103,7 +103,7 @@ void list_mappings_for_namespace_and_type() throws Exception { @Test void return_empty_list_when_no_entries_of_type() throws Exception { setupNamespace(); - when(registryService.listByType(NAMESPACE, CalmResourceType.FLOW)).thenReturn(List.of()); + when(registryService.listByType(NAMESPACE, RegistryResourceType.FLOW)).thenReturn(List.of()); List mappings = store.listMappings(NAMESPACE, ResourceType.FLOW); @@ -112,9 +112,9 @@ void return_empty_list_when_no_entries_of_type() throws Exception { @Test void get_mapping_by_numeric_id() throws Exception { - RegistryEntry entry = createEntry(UNIQUE_ID, CalmResourceType.ARCHITECTURE); + RegistryEntry entry = createEntry(UNIQUE_ID, RegistryResourceType.ARCHITECTURE); setupNamespace(); - when(registryService.listByType(NAMESPACE, CalmResourceType.ARCHITECTURE)) + when(registryService.listByType(NAMESPACE, RegistryResourceType.ARCHITECTURE)) .thenReturn(List.of(entry)); ResourceMapping mapping = store.getMappingByNumericId(NAMESPACE, ResourceType.ARCHITECTURE, NUMERIC_ID); @@ -126,7 +126,7 @@ void get_mapping_by_numeric_id() throws Exception { @Test void throw_mapping_not_found_for_unknown_numeric_id() { setupNamespace(); - when(registryService.listByType(NAMESPACE, CalmResourceType.ARCHITECTURE)).thenReturn(List.of()); + when(registryService.listByType(NAMESPACE, RegistryResourceType.ARCHITECTURE)).thenReturn(List.of()); assertThrows(MappingNotFoundException.class, () -> store.getMappingByNumericId(NAMESPACE, ResourceType.ARCHITECTURE, 99999)); @@ -134,10 +134,10 @@ void throw_mapping_not_found_for_unknown_numeric_id() { @Test void list_mappings_by_numeric_ids() throws Exception { - RegistryEntry entry1 = createEntry("arch-one", CalmResourceType.ARCHITECTURE); - RegistryEntry entry2 = createEntry("arch-two", CalmResourceType.ARCHITECTURE); + RegistryEntry entry1 = createEntry("arch-one", RegistryResourceType.ARCHITECTURE); + RegistryEntry entry2 = createEntry("arch-two", RegistryResourceType.ARCHITECTURE); setupNamespace(); - when(registryService.listByType(NAMESPACE, CalmResourceType.ARCHITECTURE)) + when(registryService.listByType(NAMESPACE, RegistryResourceType.ARCHITECTURE)) .thenReturn(List.of(entry1, entry2)); int id1 = "arch-one".hashCode() & 0x7FFFFFFF; @@ -174,14 +174,14 @@ void throw_on_delete_mapping_by_numeric_id() { @Test void map_all_resource_types_correctly() { - assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.PATTERN), equalTo(CalmResourceType.PATTERN)); - assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.ARCHITECTURE), equalTo(CalmResourceType.ARCHITECTURE)); - assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.FLOW), equalTo(CalmResourceType.FLOW)); - assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.STANDARD), equalTo(CalmResourceType.STANDARD)); - assertThat(GitHubResourceMappingStore.toCalmResourceType(ResourceType.INTERFACE), equalTo(CalmResourceType.INTERFACE)); + assertThat(GitHubResourceMappingStore.toRegistryResourceType(ResourceType.PATTERN), equalTo(RegistryResourceType.PATTERN)); + assertThat(GitHubResourceMappingStore.toRegistryResourceType(ResourceType.ARCHITECTURE), equalTo(RegistryResourceType.ARCHITECTURE)); + assertThat(GitHubResourceMappingStore.toRegistryResourceType(ResourceType.FLOW), equalTo(RegistryResourceType.FLOW)); + assertThat(GitHubResourceMappingStore.toRegistryResourceType(ResourceType.STANDARD), equalTo(RegistryResourceType.STANDARD)); + assertThat(GitHubResourceMappingStore.toRegistryResourceType(ResourceType.INTERFACE), equalTo(RegistryResourceType.INTERFACE)); } - private RegistryEntry createEntry(String uniqueId, CalmResourceType type) { + private RegistryEntry createEntry(String uniqueId, RegistryResourceType type) { String folder = switch (type) { case ARCHITECTURE -> "architectures"; case PATTERN -> "patterns"; @@ -197,18 +197,14 @@ private RegistryEntry createEntry(String uniqueId, CalmResourceType type) { private void setupNamespace() { RegistrySnapshot snapshot = new RegistrySnapshot( Map.of(NAMESPACE, List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); } private void setupNamespaceWithEntry(RegistryEntry entry) { RegistrySnapshot snapshot = new RegistrySnapshot( Map.of(NAMESPACE, List.of(entry)), - Map.of(NAMESPACE + ":" + entry.uniqueId(), entry), - Map.of(entry.type(), List.of(entry)) - ); + Map.of(NAMESPACE + ":" + entry.uniqueId(), entry)); when(registryService.getSnapshot()).thenReturn(snapshot); } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java index 0c8cd3bdde..e3c72c3cd6 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java @@ -1,8 +1,8 @@ package org.finos.calm.store.github; import org.finos.calm.domain.search.GroupedSearchResults; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -25,7 +25,7 @@ class TestGitHubSearchStoreShould { @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; private GitHubSearchStore store; @@ -72,13 +72,12 @@ void return_empty_for_null_query() { @Test void find_entries_matching_query_by_name() { - org.finos.calm.store.github.util.RegistryEntry entry = new org.finos.calm.store.github.util.RegistryEntry( + org.finos.calm.store.github.registry.RegistryEntry entry = new org.finos.calm.store.github.registry.RegistryEntry( "payment-svc", java.nio.file.Path.of("architectures/payment.json"), - org.finos.calm.store.github.util.CalmResourceType.ARCHITECTURE, "Payment Service", java.time.Instant.now()); - org.finos.calm.store.github.util.RegistrySnapshot snapshot = new org.finos.calm.store.github.util.RegistrySnapshot( + org.finos.calm.store.github.registry.RegistryResourceType.ARCHITECTURE, "Payment Service", java.time.Instant.now()); + org.finos.calm.store.github.registry.RegistrySnapshot snapshot = new org.finos.calm.store.github.registry.RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:payment-svc", entry), - java.util.Map.of(org.finos.calm.store.github.util.CalmResourceType.ARCHITECTURE, java.util.List.of(entry)) + java.util.Map.of("finos:payment-svc", entry) ); when(registryService.getSnapshot()).thenReturn(snapshot); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java index e75538f7d6..8264bfa68e 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java @@ -3,10 +3,10 @@ import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.domain.standards.CreateStandardRequest; -import org.finos.calm.store.github.util.CalmResourceType; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -28,7 +28,7 @@ class TestGitHubStandardStoreShould { @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; private GitHubStandardStore store; @@ -40,15 +40,13 @@ void setup() { @Test void return_standards_for_namespace() throws NamespaceNotFoundException { RegistryEntry entry = new RegistryEntry("naming-convention", Path.of("standards/naming-convention.json"), - CalmResourceType.STANDARD, "Naming Convention", Instant.now()); + RegistryResourceType.STANDARD, "Naming Convention", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:naming-convention", entry), - Map.of(CalmResourceType.STANDARD, List.of(entry)) - ); + Map.of("finos:naming-convention", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); List result = store.getStandardsForNamespace("finos"); @@ -79,14 +77,12 @@ void throw_unsupported_on_delete_standard() { @Test void throw_standard_not_found_when_id_does_not_match() throws NamespaceNotFoundException { RegistryEntry entry = new RegistryEntry("std-x", java.nio.file.Path.of("standards/x.json"), - CalmResourceType.STANDARD, "X", java.time.Instant.now()); + RegistryResourceType.STANDARD, "X", java.time.Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:std-x", entry), - java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) - ); + java.util.Map.of("finos:std-x", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); assertThrows(org.finos.calm.domain.exception.StandardNotFoundException.class, () -> store.getStandardVersions("finos", 99999)); @@ -109,14 +105,12 @@ void throw_namespace_not_found_on_get_standard_for_version() { @Test void return_versions_for_existing_standard() throws Exception { RegistryEntry entry = new RegistryEntry("std-1", java.nio.file.Path.of("standards/std.json"), - CalmResourceType.STANDARD, "Std", java.time.Instant.now()); + RegistryResourceType.STANDARD, "Std", java.time.Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:std-1", entry), - java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) - ); + java.util.Map.of("finos:std-1", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); int hashId = ("std-1".hashCode() & 0x7FFFFFFF); java.util.List versions = store.getStandardVersions("finos", hashId); @@ -131,14 +125,12 @@ void return_content_for_standard(@org.junit.jupiter.api.io.TempDir java.nio.file java.nio.file.Files.writeString(stdDir.resolve("test.json"), "{\"name\":\"Test Standard\"}"); RegistryEntry entry = new RegistryEntry("test-std", java.nio.file.Path.of("standards/test.json"), - CalmResourceType.STANDARD, "Test", java.time.Instant.now()); + RegistryResourceType.STANDARD, "Test", java.time.Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-std", entry), - java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) - ); + java.util.Map.of("finos:test-std", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); store.cloneDirectory = tempDir.toString(); int hashId = ("test-std".hashCode() & 0x7FFFFFFF); @@ -154,14 +146,12 @@ void prefer_md_sibling_over_json(@org.junit.jupiter.api.io.TempDir java.nio.file java.nio.file.Files.writeString(stdDir.resolve("policy.md"), "# Policy\n\nContent here."); RegistryEntry entry = new RegistryEntry("policy", java.nio.file.Path.of("standards/policy.guideline.json"), - CalmResourceType.STANDARD, "Policy", java.time.Instant.now()); + RegistryResourceType.STANDARD, "Policy", java.time.Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:policy", entry), - java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) - ); + java.util.Map.of("finos:policy", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); store.cloneDirectory = tempDir.toString(); int hashId = ("policy".hashCode() & 0x7FFFFFFF); @@ -178,14 +168,12 @@ void throw_unsupported_on_create_standard_for_version() { @Test void return_sha_versions_when_version_service_available() throws Exception { RegistryEntry entry = new RegistryEntry("std-1", java.nio.file.Path.of("standards/std.json"), - CalmResourceType.STANDARD, "Std", java.time.Instant.now()); + RegistryResourceType.STANDARD, "Std", java.time.Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:std-1", entry), - java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) - ); + java.util.Map.of("finos:std-1", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); org.finos.calm.store.github.util.GitHubVersionService mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubVersionService.class); @@ -207,14 +195,12 @@ void return_sha_versions_when_version_service_available() throws Exception { @Test void return_content_from_github_api_for_sha_version() throws Exception { RegistryEntry entry = new RegistryEntry("test-std", java.nio.file.Path.of("standards/test.json"), - CalmResourceType.STANDARD, "Test", java.time.Instant.now()); + RegistryResourceType.STANDARD, "Test", java.time.Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-std", entry), - java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) - ); + java.util.Map.of("finos:test-std", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); org.finos.calm.store.github.util.GitHubVersionService mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubVersionService.class); @@ -234,14 +220,12 @@ void return_content_from_github_api_for_sha_version() throws Exception { @Test void throw_standard_version_not_found_when_file_missing(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tempDir) throws Exception { RegistryEntry entry = new RegistryEntry("test-std", java.nio.file.Path.of("standards/nonexistent.json"), - CalmResourceType.STANDARD, "Test", java.time.Instant.now()); + RegistryResourceType.STANDARD, "Test", java.time.Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-std", entry), - java.util.Map.of(CalmResourceType.STANDARD, java.util.List.of(entry)) - ); + java.util.Map.of("finos:test-std", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); store.cloneDirectory = tempDir.toString(); int hashId = ("test-std".hashCode() & 0x7FFFFFFF); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java index dc99eb84c8..2b834ec688 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java @@ -6,12 +6,12 @@ import org.finos.calm.domain.timeline.CreateTimelineRequest; import org.finos.calm.domain.timeline.NamespaceTimelineSummary; import org.finos.calm.domain.timeline.Timeline; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubVersionService; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -38,7 +38,7 @@ class TestGitHubTimelineStoreShould { @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; private GitHubTimelineStore store; @@ -51,11 +51,9 @@ void setup() { void return_empty_timelines_for_namespace() throws NamespaceNotFoundException { RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of()); + when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of()); List result = store.getTimelinesForNamespace("finos"); @@ -65,15 +63,13 @@ void return_empty_timelines_for_namespace() throws NamespaceNotFoundException { @Test void return_timelines_for_namespace() throws NamespaceNotFoundException { RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), - CalmResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistryResourceType.TIMELINE, "Release Timeline", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:release-timeline", entry), - Map.of(CalmResourceType.TIMELINE, List.of(entry)) - ); + Map.of("finos:release-timeline", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); List result = store.getTimelinesForNamespace("finos"); @@ -118,14 +114,12 @@ void throw_namespace_not_found_on_get_timeline_for_version() { @Test void return_versions_list_for_existing_timeline() throws Exception { RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), - CalmResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistryResourceType.TIMELINE, "Release Timeline", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:release-timeline", entry), - Map.of(CalmResourceType.TIMELINE, List.of(entry)) - ); + Map.of("finos:release-timeline", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(hashId).build(); @@ -138,14 +132,12 @@ void return_versions_list_for_existing_timeline() throws Exception { @Test void return_sha_versions_when_version_service_available() throws Exception { RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), - CalmResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistryResourceType.TIMELINE, "Release Timeline", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:release-timeline", entry), - Map.of(CalmResourceType.TIMELINE, List.of(entry)) - ); + Map.of("finos:release-timeline", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); @@ -172,14 +164,12 @@ void return_timeline_content_for_version(@TempDir Path tempDir) throws Exception Files.writeString(timelineDir.resolve("release-timeline.json"), "{\"milestones\":[]}"); RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), - CalmResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistryResourceType.TIMELINE, "Release Timeline", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:release-timeline", entry), - Map.of(CalmResourceType.TIMELINE, List.of(entry)) - ); + Map.of("finos:release-timeline", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); store.cloneDirectory = tempDir.toString(); int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); @@ -192,14 +182,12 @@ void return_timeline_content_for_version(@TempDir Path tempDir) throws Exception @Test void return_content_from_github_api_for_sha_version() throws Exception { RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), - CalmResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistryResourceType.TIMELINE, "Release Timeline", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:release-timeline", entry), - Map.of(CalmResourceType.TIMELINE, List.of(entry)) - ); + Map.of("finos:release-timeline", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); @@ -220,14 +208,12 @@ void return_content_from_github_api_for_sha_version() throws Exception { @Test void throw_timeline_not_found_when_id_does_not_match() { RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), - CalmResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistryResourceType.TIMELINE, "Release Timeline", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(entry)), - Map.of("finos:release-timeline", entry), - Map.of(CalmResourceType.TIMELINE, List.of(entry)) - ); + Map.of("finos:release-timeline", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", CalmResourceType.TIMELINE)).thenReturn(List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(99999).build(); assertThrows(TimelineNotFoundException.class, () -> store.getTimelineVersions(timeline)); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java index e884c4ebfc..a6be280722 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java @@ -4,11 +4,11 @@ import org.finos.calm.domain.UserAccess; import org.finos.calm.domain.exception.UserAccessNotFoundException; import org.finos.calm.security.OidcRoleResolver; -import org.finos.calm.store.github.util.CalmResourceType; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.InMemoryRegistryService; -import org.finos.calm.store.github.util.RegistryEntry; -import org.finos.calm.store.github.util.RegistrySnapshot; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -41,7 +41,7 @@ class TestGitHubUserAccessStoreShould { private static final Set ACCESS_GROUPS = Set.of("SCMReadOnly", "SCMDeveloper"); @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; @Mock private OidcRoleResolver roleResolver; @@ -71,9 +71,7 @@ void return_empty_grants_when_no_matching_group() { when(roleResolver.resolve(eq(identity), any())).thenReturn(OidcRoleResolver.AccessLevel.NONE); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(), "team", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); List result = store.getGrantsForUser("testuser"); @@ -87,9 +85,7 @@ void return_read_grants_when_group_matches() { .thenReturn(OidcRoleResolver.AccessLevel.READ); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(), "team", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); List result = store.getGrantsForUser("testuser"); @@ -109,9 +105,7 @@ void grant_per_namespace_access_independently() { RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(), "public", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); List result = store.getGrantsForUser("testuser"); @@ -125,12 +119,10 @@ void grant_domain_read_derived_from_the_accessible_namespaces_controls() { when(roleResolver.resolve(eq(identity), eq(ACCESS_GROUPS))) .thenReturn(OidcRoleResolver.AccessLevel.READ); RegistryEntry securityControl = new RegistryEntry("ctrl-a", Path.of("controls/security/ctrl-a.json"), - CalmResourceType.CONTROL, "Control A", Instant.now()); + RegistryResourceType.CONTROL, "Control A", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(securityControl), "team", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); List result = store.getGrantsForUser("testuser"); @@ -156,12 +148,10 @@ void not_grant_domain_read_for_a_domain_only_present_in_an_inaccessible_namespac when(roleResolver.resolve(eq(identity), eq(Set.of("Everyone")))) .thenReturn(OidcRoleResolver.AccessLevel.READ); RegistryEntry paymentsControl = new RegistryEntry("ctrl-b", Path.of("controls/payments/ctrl-b.json"), - CalmResourceType.CONTROL, "Control B", Instant.now()); + RegistryResourceType.CONTROL, "Control B", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(), "team", List.of(paymentsControl), "public", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); List result = store.getGrantsForUser("testuser"); @@ -183,9 +173,7 @@ void throw_user_access_not_found_when_no_access() { when(roleResolver.resolve(eq(identity), any())).thenReturn(OidcRoleResolver.AccessLevel.NONE); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of()), - Map.of(), - Map.of() - ); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); assertThrows(UserAccessNotFoundException.class, diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestCalmContentDetectorShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestCalmContentDetectorShould.java new file mode 100644 index 0000000000..666f64abb5 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestCalmContentDetectorShould.java @@ -0,0 +1,274 @@ +package org.finos.calm.store.github.registry; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +class TestCalmContentDetectorShould { + + private CalmContentDetector detector; + + @BeforeEach + void setup() { + detector = new CalmContentDetector(); + } + + @Test + void detect_architecture_from_nodes_array_in_architectures_directory() { + String json = "{\"nodes\": [], \"relationships\": []}"; + Optional type = detector.detect(json, Path.of("architectures/payment.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.ARCHITECTURE))); + } + + @Test + void detect_pattern_from_nodes_array_in_patterns_directory() { + String json = "{\"nodes\": [], \"relationships\": []}"; + Optional type = detector.detect(json, Path.of("patterns/microservices.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.PATTERN))); + } + + @Test + void return_unknown_when_nodes_present_but_unknown_directory() { + String json = "{\"nodes\": [{\"unique-id\": \"svc\"}]}"; + Optional type = detector.detect(json, Path.of("misc/something.json")); + assertThat(type, equalTo(Optional.empty())); + } + + @Test + void detect_timeline_from_moments_array() { + String json = "{\"moments\": [{\"date\": \"2026-01-01\"}]}"; + Optional type = detector.detect(json, Path.of("timelines/roadmap.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.TIMELINE))); + } + + @Test + void detect_timeline_from_schema_url() { + String json = "{\"$schema\": \"https://calm.finos.org/draft/2026-03/meta/calm-timeline.json\"}"; + Optional type = detector.detect(json, Path.of("any/thing.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.TIMELINE))); + } + + @Test + void detect_flow_from_schema_url() { + String json = "{\"$schema\": \"https://calm.finos.org/draft/2026-03/meta/flow.json\"}"; + Optional type = detector.detect(json, Path.of("flows/checkout.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.FLOW))); + } + + @Test + void detect_interface_from_schema_url() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/interface.json\"}"; + Optional type = detector.detect(json, Path.of("interfaces/api.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.INTERFACE))); + } + + @Test + void detect_control_from_schema_url() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/control.json\"}"; + Optional type = detector.detect(json, Path.of("controls/security/tls.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.CONTROL))); + } + + @Test + void detect_decorator_from_schema_url() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/decorators.json\"}"; + Optional type = detector.detect(json, Path.of("decorators/governance.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.DECORATOR))); + } + + @Test + void detect_standard_from_directory_only() { + String json = "{\"title\": \"API Design Standard\"}"; + Optional type = detector.detect(json, Path.of("standards/api-design.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.STANDARD))); + } + + @Test + void classify_a_file_under_a_removed_guidelines_directory_as_unknown() { + // GUIDELINE was removed as a resource type (Office Hours, 2026-09-10, #3052) — + // nothing served it, so files under guidelines/ are no longer indexed at all. + String json = "{\"title\": \"Microservices Guideline\"}"; + Optional type = detector.detect(json, Path.of("guidelines/microservices.json")); + assertThat(type, equalTo(Optional.empty())); + } + + @Test + void detect_adr_from_directory_only() { + String json = "{\"status\": \"accepted\", \"context\": \"we need X\"}"; + Optional type = detector.detect(json, Path.of("adrs/0001-use-kafka.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.ADR))); + } + + @Test + void detect_control_from_directory_convention() { + String json = "{\"requirement\": \"must use TLS\"}"; + Optional type = detector.detect(json, Path.of("controls/tls-policy.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.CONTROL))); + } + + @Test + void return_unknown_for_null_content() { + Optional type = detector.detect(null, Path.of("test.json")); + assertThat(type, equalTo(Optional.empty())); + } + + @Test + void return_unknown_for_blank_content() { + Optional type = detector.detect(" ", Path.of("test.json")); + assertThat(type, equalTo(Optional.empty())); + } + + @Test + void return_unknown_for_invalid_json() { + Optional type = detector.detect("not json at all", Path.of("test.json")); + assertThat(type, equalTo(Optional.empty())); + } + + @Test + void return_unknown_for_unrecognized_content_in_unknown_directory() { + String json = "{\"foo\": \"bar\"}"; + Optional type = detector.detect(json, Path.of("random/stuff.json")); + assertThat(type, equalTo(Optional.empty())); + } + + @Test + void handle_null_file_path_gracefully() { + String json = "{\"nodes\": []}"; + Optional type = detector.detect(json, null); + assertThat(type, equalTo(Optional.empty())); + } + + @Test + void detect_pattern_from_nodes_in_patterns_directory_with_nested_path() { + String json = "{\"nodes\": [{\"unique-id\": \"svc\"}], \"relationships\": []}"; + Optional type = detector.detect(json, Path.of("patterns/sub/event-driven.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.PATTERN))); + } + + @Test + void detect_flow_from_directory_when_no_schema() { + String json = "{\"steps\": [{\"from\": \"a\", \"to\": \"b\"}]}"; + Optional type = detector.detect(json, Path.of("flows/checkout-flow.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.FLOW))); + } + + @Test + void detect_interface_from_directory_only() { + String json = "{\"endpoints\": []}"; + Optional type = detector.detect(json, Path.of("interfaces/payment-api.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.INTERFACE))); + } + + @Test + void detect_decorator_from_directory_only() { + String json = "{\"type\": \"governance\"}"; + Optional type = detector.detect(json, Path.of("decorators/aigf.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.DECORATOR))); + } + + @Test + void detect_timeline_from_directory_only_without_moments() { + String json = "{\"entries\": []}"; + Optional type = detector.detect(json, Path.of("timelines/roadmap.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.TIMELINE))); + } + + @Test + void prefer_content_signal_over_directory_for_timeline() { + String json = "{\"moments\": [{\"date\": \"2026-01-01\"}]}"; + Optional type = detector.detect(json, Path.of("architectures/misplaced.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.TIMELINE))); + } + + @Test + void detect_from_relationships_array_alone() { + String json = "{\"relationships\": [{\"unique-id\": \"rel-1\"}]}"; + Optional type = detector.detect(json, Path.of("architectures/conn.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.ARCHITECTURE))); + } + + @Test + void detect_flow_from_schema_without_directory_hint() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/flow.json\"}"; + Optional type = detector.detect(json, Path.of("misc/some-flow.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.FLOW))); + } + + @Test + void detect_interface_from_schema_without_directory_hint() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/interface.json\"}"; + Optional type = detector.detect(json, Path.of("misc/api.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.INTERFACE))); + } + + @Test + void detect_control_from_schema_without_directory_hint() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/control.json\"}"; + Optional type = detector.detect(json, Path.of("misc/tls.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.CONTROL))); + } + + @Test + void detect_decorator_from_schema_without_directory_hint() { + String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/decorators.json\"}"; + Optional type = detector.detect(json, Path.of("misc/gov.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.DECORATOR))); + } + + @Test + void return_unknown_when_schema_does_not_match_any_keyword() { + String json = "{\"$schema\": \"https://example.com/unknown-schema.json\"}"; + Optional type = detector.detect(json, Path.of("misc/thing.json")); + assertThat(type, equalTo(Optional.empty())); + } + + @Test + void detect_standard_from_nodes_in_standards_directory() { + String json = "{\"nodes\": [], \"relationships\": []}"; + Optional type = detector.detect(json, Path.of("standards/api-design.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.STANDARD))); + } + + @Test + void classify_nodes_in_a_removed_guidelines_directory_as_unknown() { + // GUIDELINE was removed as a resource type (Office Hours, 2026-09-10, #3052). + String json = "{\"nodes\": []}"; + Optional type = detector.detect(json, Path.of("guidelines/best.json")); + assertThat(type, equalTo(Optional.empty())); + } + + @Test + void detect_adr_from_nodes_in_adrs_directory() { + String json = "{\"nodes\": []}"; + Optional type = detector.detect(json, Path.of("adrs/0001.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.ADR))); + } + + @Test + void detect_standard_from_nodes_in_building_blocks_directory() { + // building-blocks/ is aliased to STANDARD (Office Hours, 2026-09-10, #3052) — + // "Building Block" was removed as its own CALM Hub resource type. + String json = "{\"nodes\": [{\"unique-id\": \"svc\"}], \"relationships\": []}"; + Optional type = detector.detect(json, Path.of("building-blocks/auth-block.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.STANDARD))); + } + + @Test + void detect_standard_from_building_blocks_directory_only() { + String json = "{\"title\": \"Auth Building Block\"}"; + Optional type = detector.detect(json, Path.of("building-blocks/auth-block.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.STANDARD))); + } + + @Test + void detect_standard_from_nested_building_blocks_path() { + String json = "{\"nodes\": []}"; + Optional type = detector.detect(json, Path.of("building-blocks/sub/auth-block.json")); + assertThat(type, equalTo(Optional.of(RegistryResourceType.STANDARD))); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubControlDomainsShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestControlDomainsShould.java similarity index 64% rename from calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubControlDomainsShould.java rename to calm-hub/src/test/java/org/finos/calm/store/github/registry/TestControlDomainsShould.java index 8a4b541400..7bdf562ceb 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubControlDomainsShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestControlDomainsShould.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.registry; import org.junit.jupiter.api.Test; @@ -8,30 +8,30 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -class TestGitHubControlDomainsShould { +class TestControlDomainsShould { @Test void extract_the_second_path_segment_as_the_domain_under_controls() { RegistryEntry entry = new RegistryEntry("access-control", Path.of("controls/security/access-control.json"), - CalmResourceType.CONTROL, "Access Control", Instant.now()); + RegistryResourceType.CONTROL, "Access Control", Instant.now()); - assertThat(GitHubControlDomains.extractDomain(entry), equalTo("security")); + assertThat(ControlDomains.extractDomain(entry), equalTo("security")); } @Test void return_default_when_the_path_is_not_under_controls() { RegistryEntry entry = new RegistryEntry("core", Path.of("standards/security/core.json"), - CalmResourceType.STANDARD, "Core", Instant.now()); + RegistryResourceType.STANDARD, "Core", Instant.now()); - assertThat(GitHubControlDomains.extractDomain(entry), equalTo("default")); + assertThat(ControlDomains.extractDomain(entry), equalTo("default")); } @Test void return_default_when_the_path_has_fewer_than_two_segments() { RegistryEntry entry = new RegistryEntry("controls", Path.of("controls.json"), - CalmResourceType.CONTROL, "Controls", Instant.now()); + RegistryResourceType.CONTROL, "Controls", Instant.now()); - assertThat(GitHubControlDomains.extractDomain(entry), equalTo("default")); + assertThat(ControlDomains.extractDomain(entry), equalTo("default")); } @Test @@ -41,8 +41,8 @@ void treat_a_direct_childs_filename_as_the_domain_when_controls_has_no_domain_su // directly under controls/, so this is exactly what it returns today (including // the .json suffix), not "default". RegistryEntry entry = new RegistryEntry("root-control", Path.of("controls/root-control.json"), - CalmResourceType.CONTROL, "Root Control", Instant.now()); + RegistryResourceType.CONTROL, "Root Control", Instant.now()); - assertThat(GitHubControlDomains.extractDomain(entry), equalTo("root-control.json")); + assertThat(ControlDomains.extractDomain(entry), equalTo("root-control.json")); } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestRegistrySnapshotShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestRegistrySnapshotShould.java similarity index 80% rename from calm-hub/src/test/java/org/finos/calm/store/github/util/TestRegistrySnapshotShould.java rename to calm-hub/src/test/java/org/finos/calm/store/github/registry/TestRegistrySnapshotShould.java index 2880f1a775..12679c0606 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestRegistrySnapshotShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestRegistrySnapshotShould.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.registry; import org.junit.jupiter.api.Test; @@ -18,11 +18,11 @@ class TestRegistrySnapshotShould { private static final RegistryEntry FINOS_PATTERN = new RegistryEntry("finos-pattern", - Path.of("patterns/finos-pattern.json"), CalmResourceType.PATTERN, "FINOS Pattern", Instant.now()); + Path.of("patterns/finos-pattern.json"), RegistryResourceType.PATTERN, "FINOS Pattern", Instant.now()); private static final RegistryEntry FINOS_FLOW = new RegistryEntry("finos-flow", - Path.of("flows/finos-flow.json"), CalmResourceType.FLOW, "FINOS Flow", Instant.now()); + Path.of("flows/finos-flow.json"), RegistryResourceType.FLOW, "FINOS Flow", Instant.now()); private static final RegistryEntry OTHER_PATTERN = new RegistryEntry("other-pattern", - Path.of("patterns/other-pattern.json"), CalmResourceType.PATTERN, "Other Pattern", Instant.now()); + Path.of("patterns/other-pattern.json"), RegistryResourceType.PATTERN, "Other Pattern", Instant.now()); private static RegistrySnapshot buildSnapshot() { return new RegistrySnapshot( @@ -34,12 +34,7 @@ private static RegistrySnapshot buildSnapshot() { "finos:finos-pattern", FINOS_PATTERN, "finos:finos-flow", FINOS_FLOW, "other:other-pattern", OTHER_PATTERN - ), - Map.of( - CalmResourceType.PATTERN, List.of(FINOS_PATTERN, OTHER_PATTERN), - CalmResourceType.FLOW, List.of(FINOS_FLOW) - ) - ); + )); } @Test @@ -66,21 +61,21 @@ void not_find_an_unknown_unique_id() { @Test void list_entries_by_type_scoped_to_one_namespace() { - List patterns = buildSnapshot().listByType("finos", CalmResourceType.PATTERN); + List patterns = buildSnapshot().listByType("finos", RegistryResourceType.PATTERN); assertThat(patterns, contains(FINOS_PATTERN)); } @Test void return_empty_list_by_type_for_a_namespace_with_no_matching_entries() { - List flows = buildSnapshot().listByType("other", CalmResourceType.FLOW); + List flows = buildSnapshot().listByType("other", RegistryResourceType.FLOW); assertThat(flows, is(empty())); } @Test void return_empty_list_by_type_for_an_unknown_namespace() { - List patterns = buildSnapshot().listByType("nonexistent", CalmResourceType.PATTERN); + List patterns = buildSnapshot().listByType("nonexistent", RegistryResourceType.PATTERN); assertThat(patterns, is(empty())); } @@ -106,7 +101,7 @@ void list_every_known_namespace() { void the_empty_constant_has_no_namespaces_entries_or_types() { assertThat(RegistrySnapshot.EMPTY.getNamespaces(), is(empty())); assertThat(RegistrySnapshot.EMPTY.listAll("finos"), is(empty())); - assertThat(RegistrySnapshot.EMPTY.listByType("finos", CalmResourceType.PATTERN), is(empty())); + assertThat(RegistrySnapshot.EMPTY.listByType("finos", RegistryResourceType.PATTERN), is(empty())); assertThat(RegistrySnapshot.EMPTY.findByUniqueId("finos", "anything").isPresent(), is(false)); } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestInMemoryRegistryServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestResourceRegistryShould.java similarity index 91% rename from calm-hub/src/test/java/org/finos/calm/store/github/util/TestInMemoryRegistryServiceShould.java rename to calm-hub/src/test/java/org/finos/calm/store/github/registry/TestResourceRegistryShould.java index 12be6bf40e..ff4eb0e9fe 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestInMemoryRegistryServiceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestResourceRegistryShould.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.registry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -18,21 +18,21 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; -class TestInMemoryRegistryServiceShould { +class TestResourceRegistryShould { - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; @TempDir Path tempDir; @BeforeEach void setup() { - registryService = new InMemoryRegistryService(new CalmContentDetector()); + registryService = new ResourceRegistry(new CalmContentDetector()); } @Test void return_empty_snapshot_before_rebuild() { - List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + List entries = registryService.listByType("finos", RegistryResourceType.ARCHITECTURE); assertThat(entries, is(empty())); } @@ -45,7 +45,7 @@ void discover_architecture_from_json_with_nodes() throws IOException { registryService.rebuild(Map.of("finos", tempDir)); - List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + List entries = registryService.listByType("finos", RegistryResourceType.ARCHITECTURE); assertThat(entries, hasSize(1)); assertThat(entries.get(0).uniqueId(), equalTo("payment-platform")); assertThat(entries.get(0).name(), equalTo("Payment Platform")); @@ -60,7 +60,7 @@ void discover_pattern_from_patterns_directory() throws IOException { registryService.rebuild(Map.of("finos", tempDir)); - List entries = registryService.listByType("finos", CalmResourceType.PATTERN); + List entries = registryService.listByType("finos", RegistryResourceType.PATTERN); assertThat(entries, hasSize(1)); assertThat(entries.get(0).uniqueId(), equalTo("event-driven")); } @@ -74,7 +74,7 @@ void derive_unique_id_from_filename_when_missing() throws IOException { registryService.rebuild(Map.of("finos", tempDir)); - List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + List entries = registryService.listByType("finos", RegistryResourceType.ARCHITECTURE); assertThat(entries, hasSize(1)); assertThat(entries.get(0).uniqueId(), equalTo("my-system")); } @@ -113,7 +113,7 @@ void skip_hidden_directories() throws IOException { registryService.rebuild(Map.of("finos", tempDir)); - List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + List entries = registryService.listByType("finos", RegistryResourceType.ARCHITECTURE); assertThat(entries, hasSize(1)); assertThat(entries.get(0).uniqueId(), equalTo("real")); } @@ -129,9 +129,9 @@ void handle_multiple_namespaces() throws IOException { registryService.rebuild(Map.of("team-a", ns1, "team-b", ns2)); - assertThat(registryService.listByType("team-a", CalmResourceType.ARCHITECTURE), hasSize(1)); - assertThat(registryService.listByType("team-b", CalmResourceType.PATTERN), hasSize(1)); - assertThat(registryService.listByType("team-a", CalmResourceType.PATTERN), is(empty())); + assertThat(registryService.listByType("team-a", RegistryResourceType.ARCHITECTURE), hasSize(1)); + assertThat(registryService.listByType("team-b", RegistryResourceType.PATTERN), hasSize(1)); + assertThat(registryService.listByType("team-a", RegistryResourceType.PATTERN), is(empty())); } @Test @@ -160,7 +160,7 @@ void skip_invalid_json_files_without_crashing() throws IOException { registryService.rebuild(Map.of("finos", tempDir)); - List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + List entries = registryService.listByType("finos", RegistryResourceType.ARCHITECTURE); assertThat(entries, hasSize(1)); assertThat(entries.get(0).uniqueId(), equalTo("good")); } @@ -189,7 +189,7 @@ void detect_markdown_standards_in_nested_directories() throws IOException { registryService.rebuild(Map.of("finos", tempDir)); - List entries = registryService.listByType("finos", CalmResourceType.STANDARD); + List entries = registryService.listByType("finos", RegistryResourceType.STANDARD); assertThat(entries, hasSize(1)); assertThat(entries.get(0).uniqueId(), equalTo("vm-sizing")); } @@ -214,7 +214,7 @@ void index_a_markdown_file_under_building_blocks_as_a_standard() throws IOExcept registryService.rebuild(Map.of("finos", tempDir)); - List entries = registryService.listByType("finos", CalmResourceType.STANDARD); + List entries = registryService.listByType("finos", RegistryResourceType.STANDARD); assertThat(entries, hasSize(1)); assertThat(entries.get(0).uniqueId(), equalTo("auth-block")); } @@ -233,7 +233,7 @@ void merge_building_blocks_and_standards_entries_into_one_listing() throws IOExc registryService.rebuild(Map.of("finos", tempDir)); - List entries = registryService.listByType("finos", CalmResourceType.STANDARD); + List entries = registryService.listByType("finos", RegistryResourceType.STANDARD); assertThat(entries, hasSize(2)); } @@ -246,14 +246,14 @@ void skip_markdown_files_outside_known_directories() throws IOException { registryService.rebuild(Map.of("finos", tempDir)); - List entries = registryService.listByType("finos", CalmResourceType.STANDARD); + List entries = registryService.listByType("finos", RegistryResourceType.STANDARD); assertThat(entries, is(empty())); } @Test void return_empty_list_for_unknown_namespace() { registryService.rebuild(Map.of()); - List entries = registryService.listByType("nonexistent", CalmResourceType.ARCHITECTURE); + List entries = registryService.listByType("nonexistent", RegistryResourceType.ARCHITECTURE); assertThat(entries, is(empty())); } @@ -284,7 +284,7 @@ void handle_empty_directory_gracefully() throws IOException { registryService.rebuild(Map.of("finos", tempDir)); - List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + List entries = registryService.listByType("finos", RegistryResourceType.ARCHITECTURE); assertThat(entries, is(empty())); } @@ -292,7 +292,7 @@ void handle_empty_directory_gracefully() throws IOException { void handle_nonexistent_clone_path() { registryService.rebuild(Map.of("finos", tempDir.resolve("does-not-exist"))); - List entries = registryService.listByType("finos", CalmResourceType.ARCHITECTURE); + List entries = registryService.listByType("finos", RegistryResourceType.ARCHITECTURE); assertThat(entries, is(empty())); } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java deleted file mode 100644 index bc6f4ee70b..0000000000 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestCalmContentDetectorShould.java +++ /dev/null @@ -1,273 +0,0 @@ -package org.finos.calm.store.github.util; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.nio.file.Path; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; - -class TestCalmContentDetectorShould { - - private CalmContentDetector detector; - - @BeforeEach - void setup() { - detector = new CalmContentDetector(); - } - - @Test - void detect_architecture_from_nodes_array_in_architectures_directory() { - String json = "{\"nodes\": [], \"relationships\": []}"; - CalmResourceType type = detector.detect(json, Path.of("architectures/payment.json")); - assertThat(type, equalTo(CalmResourceType.ARCHITECTURE)); - } - - @Test - void detect_pattern_from_nodes_array_in_patterns_directory() { - String json = "{\"nodes\": [], \"relationships\": []}"; - CalmResourceType type = detector.detect(json, Path.of("patterns/microservices.json")); - assertThat(type, equalTo(CalmResourceType.PATTERN)); - } - - @Test - void return_unknown_when_nodes_present_but_unknown_directory() { - String json = "{\"nodes\": [{\"unique-id\": \"svc\"}]}"; - CalmResourceType type = detector.detect(json, Path.of("misc/something.json")); - assertThat(type, equalTo(CalmResourceType.UNKNOWN)); - } - - @Test - void detect_timeline_from_moments_array() { - String json = "{\"moments\": [{\"date\": \"2026-01-01\"}]}"; - CalmResourceType type = detector.detect(json, Path.of("timelines/roadmap.json")); - assertThat(type, equalTo(CalmResourceType.TIMELINE)); - } - - @Test - void detect_timeline_from_schema_url() { - String json = "{\"$schema\": \"https://calm.finos.org/draft/2026-03/meta/calm-timeline.json\"}"; - CalmResourceType type = detector.detect(json, Path.of("any/thing.json")); - assertThat(type, equalTo(CalmResourceType.TIMELINE)); - } - - @Test - void detect_flow_from_schema_url() { - String json = "{\"$schema\": \"https://calm.finos.org/draft/2026-03/meta/flow.json\"}"; - CalmResourceType type = detector.detect(json, Path.of("flows/checkout.json")); - assertThat(type, equalTo(CalmResourceType.FLOW)); - } - - @Test - void detect_interface_from_schema_url() { - String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/interface.json\"}"; - CalmResourceType type = detector.detect(json, Path.of("interfaces/api.json")); - assertThat(type, equalTo(CalmResourceType.INTERFACE)); - } - - @Test - void detect_control_from_schema_url() { - String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/control.json\"}"; - CalmResourceType type = detector.detect(json, Path.of("controls/security/tls.json")); - assertThat(type, equalTo(CalmResourceType.CONTROL)); - } - - @Test - void detect_decorator_from_schema_url() { - String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/decorators.json\"}"; - CalmResourceType type = detector.detect(json, Path.of("decorators/governance.json")); - assertThat(type, equalTo(CalmResourceType.DECORATOR)); - } - - @Test - void detect_standard_from_directory_only() { - String json = "{\"title\": \"API Design Standard\"}"; - CalmResourceType type = detector.detect(json, Path.of("standards/api-design.json")); - assertThat(type, equalTo(CalmResourceType.STANDARD)); - } - - @Test - void classify_a_file_under_a_removed_guidelines_directory_as_unknown() { - // GUIDELINE was removed as a resource type (Office Hours, 2026-09-10, #3052) — - // nothing served it, so files under guidelines/ are no longer indexed at all. - String json = "{\"title\": \"Microservices Guideline\"}"; - CalmResourceType type = detector.detect(json, Path.of("guidelines/microservices.json")); - assertThat(type, equalTo(CalmResourceType.UNKNOWN)); - } - - @Test - void detect_adr_from_directory_only() { - String json = "{\"status\": \"accepted\", \"context\": \"we need X\"}"; - CalmResourceType type = detector.detect(json, Path.of("adrs/0001-use-kafka.json")); - assertThat(type, equalTo(CalmResourceType.ADR)); - } - - @Test - void detect_control_from_directory_convention() { - String json = "{\"requirement\": \"must use TLS\"}"; - CalmResourceType type = detector.detect(json, Path.of("controls/tls-policy.json")); - assertThat(type, equalTo(CalmResourceType.CONTROL)); - } - - @Test - void return_unknown_for_null_content() { - CalmResourceType type = detector.detect(null, Path.of("test.json")); - assertThat(type, equalTo(CalmResourceType.UNKNOWN)); - } - - @Test - void return_unknown_for_blank_content() { - CalmResourceType type = detector.detect(" ", Path.of("test.json")); - assertThat(type, equalTo(CalmResourceType.UNKNOWN)); - } - - @Test - void return_unknown_for_invalid_json() { - CalmResourceType type = detector.detect("not json at all", Path.of("test.json")); - assertThat(type, equalTo(CalmResourceType.UNKNOWN)); - } - - @Test - void return_unknown_for_unrecognized_content_in_unknown_directory() { - String json = "{\"foo\": \"bar\"}"; - CalmResourceType type = detector.detect(json, Path.of("random/stuff.json")); - assertThat(type, equalTo(CalmResourceType.UNKNOWN)); - } - - @Test - void handle_null_file_path_gracefully() { - String json = "{\"nodes\": []}"; - CalmResourceType type = detector.detect(json, null); - assertThat(type, equalTo(CalmResourceType.UNKNOWN)); - } - - @Test - void detect_pattern_from_nodes_in_patterns_directory_with_nested_path() { - String json = "{\"nodes\": [{\"unique-id\": \"svc\"}], \"relationships\": []}"; - CalmResourceType type = detector.detect(json, Path.of("patterns/sub/event-driven.json")); - assertThat(type, equalTo(CalmResourceType.PATTERN)); - } - - @Test - void detect_flow_from_directory_when_no_schema() { - String json = "{\"steps\": [{\"from\": \"a\", \"to\": \"b\"}]}"; - CalmResourceType type = detector.detect(json, Path.of("flows/checkout-flow.json")); - assertThat(type, equalTo(CalmResourceType.FLOW)); - } - - @Test - void detect_interface_from_directory_only() { - String json = "{\"endpoints\": []}"; - CalmResourceType type = detector.detect(json, Path.of("interfaces/payment-api.json")); - assertThat(type, equalTo(CalmResourceType.INTERFACE)); - } - - @Test - void detect_decorator_from_directory_only() { - String json = "{\"type\": \"governance\"}"; - CalmResourceType type = detector.detect(json, Path.of("decorators/aigf.json")); - assertThat(type, equalTo(CalmResourceType.DECORATOR)); - } - - @Test - void detect_timeline_from_directory_only_without_moments() { - String json = "{\"entries\": []}"; - CalmResourceType type = detector.detect(json, Path.of("timelines/roadmap.json")); - assertThat(type, equalTo(CalmResourceType.TIMELINE)); - } - - @Test - void prefer_content_signal_over_directory_for_timeline() { - String json = "{\"moments\": [{\"date\": \"2026-01-01\"}]}"; - CalmResourceType type = detector.detect(json, Path.of("architectures/misplaced.json")); - assertThat(type, equalTo(CalmResourceType.TIMELINE)); - } - - @Test - void detect_from_relationships_array_alone() { - String json = "{\"relationships\": [{\"unique-id\": \"rel-1\"}]}"; - CalmResourceType type = detector.detect(json, Path.of("architectures/conn.json")); - assertThat(type, equalTo(CalmResourceType.ARCHITECTURE)); - } - - @Test - void detect_flow_from_schema_without_directory_hint() { - String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/flow.json\"}"; - CalmResourceType type = detector.detect(json, Path.of("misc/some-flow.json")); - assertThat(type, equalTo(CalmResourceType.FLOW)); - } - - @Test - void detect_interface_from_schema_without_directory_hint() { - String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/interface.json\"}"; - CalmResourceType type = detector.detect(json, Path.of("misc/api.json")); - assertThat(type, equalTo(CalmResourceType.INTERFACE)); - } - - @Test - void detect_control_from_schema_without_directory_hint() { - String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/control.json\"}"; - CalmResourceType type = detector.detect(json, Path.of("misc/tls.json")); - assertThat(type, equalTo(CalmResourceType.CONTROL)); - } - - @Test - void detect_decorator_from_schema_without_directory_hint() { - String json = "{\"$schema\": \"https://calm.finos.org/release/1.2/meta/decorators.json\"}"; - CalmResourceType type = detector.detect(json, Path.of("misc/gov.json")); - assertThat(type, equalTo(CalmResourceType.DECORATOR)); - } - - @Test - void return_unknown_when_schema_does_not_match_any_keyword() { - String json = "{\"$schema\": \"https://example.com/unknown-schema.json\"}"; - CalmResourceType type = detector.detect(json, Path.of("misc/thing.json")); - assertThat(type, equalTo(CalmResourceType.UNKNOWN)); - } - - @Test - void detect_standard_from_nodes_in_standards_directory() { - String json = "{\"nodes\": [], \"relationships\": []}"; - CalmResourceType type = detector.detect(json, Path.of("standards/api-design.json")); - assertThat(type, equalTo(CalmResourceType.STANDARD)); - } - - @Test - void classify_nodes_in_a_removed_guidelines_directory_as_unknown() { - // GUIDELINE was removed as a resource type (Office Hours, 2026-09-10, #3052). - String json = "{\"nodes\": []}"; - CalmResourceType type = detector.detect(json, Path.of("guidelines/best.json")); - assertThat(type, equalTo(CalmResourceType.UNKNOWN)); - } - - @Test - void detect_adr_from_nodes_in_adrs_directory() { - String json = "{\"nodes\": []}"; - CalmResourceType type = detector.detect(json, Path.of("adrs/0001.json")); - assertThat(type, equalTo(CalmResourceType.ADR)); - } - - @Test - void detect_standard_from_nodes_in_building_blocks_directory() { - // building-blocks/ is aliased to STANDARD (Office Hours, 2026-09-10, #3052) — - // "Building Block" was removed as its own CALM Hub resource type. - String json = "{\"nodes\": [{\"unique-id\": \"svc\"}], \"relationships\": []}"; - CalmResourceType type = detector.detect(json, Path.of("building-blocks/auth-block.json")); - assertThat(type, equalTo(CalmResourceType.STANDARD)); - } - - @Test - void detect_standard_from_building_blocks_directory_only() { - String json = "{\"title\": \"Auth Building Block\"}"; - CalmResourceType type = detector.detect(json, Path.of("building-blocks/auth-block.json")); - assertThat(type, equalTo(CalmResourceType.STANDARD)); - } - - @Test - void detect_standard_from_nested_building_blocks_path() { - String json = "{\"nodes\": []}"; - CalmResourceType type = detector.detect(json, Path.of("building-blocks/sub/auth-block.json")); - assertThat(type, equalTo(CalmResourceType.STANDARD)); - } -} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java index 685b598d29..f47d866d2a 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java @@ -3,6 +3,7 @@ import io.quarkus.runtime.StartupEvent; import org.eclipse.microprofile.context.ManagedExecutor; import org.finos.calm.observability.GitHubMetrics; +import org.finos.calm.store.github.registry.ResourceRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,7 +31,7 @@ class TestGitHubStartupInitializerShould { private GitHubCloneManager cloneManager; @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; @Mock private GitHubMetrics metrics; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java index 33818f0524..f6d8c9bdaa 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java @@ -1,6 +1,7 @@ package org.finos.calm.store.github.util; import org.finos.calm.observability.GitHubMetrics; +import org.finos.calm.store.github.registry.ResourceRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,7 +24,7 @@ class TestGitHubSyncSchedulerShould { private GitHubCloneManager cloneManager; @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; @Mock private GitHubMetrics metrics; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java index 468fbf8ca0..5d56c24b93 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java @@ -2,6 +2,8 @@ import io.quarkus.security.identity.SecurityIdentity; import org.finos.calm.security.OidcRoleResolver; +import org.finos.calm.store.github.registry.RegistrySnapshot; +import org.finos.calm.store.github.registry.ResourceRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -35,7 +37,7 @@ class TestNamespaceAccessFilterShould { private OidcRoleResolver roleResolver; @Mock - private InMemoryRegistryService registryService; + private ResourceRegistry registryService; @Mock private GitHubCloneManager cloneManager; @@ -56,7 +58,7 @@ void return_all_namespaces_when_auth_disabled() { filter.authEnabled = false; RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(), "private", List.of()), - Map.of(), Map.of()); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); Set result = filter.getAccessibleNamespaces(); @@ -68,7 +70,7 @@ void return_all_namespaces_when_auth_disabled() { void return_empty_when_identity_is_anonymous() { filter.authEnabled = true; RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of("finos", List.of()), Map.of(), Map.of()); + Map.of("finos", List.of()), Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); when(identity.isAnonymous()).thenReturn(true); @@ -82,7 +84,7 @@ void return_only_accessible_namespaces_based_on_oidc_groups() { filter.authEnabled = true; RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(), "private", List.of(), "restricted", List.of()), - Map.of(), Map.of()); + Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); when(identity.isAnonymous()).thenReturn(false); when(identity.getPrincipal()).thenReturn(principal); @@ -105,7 +107,7 @@ void return_only_accessible_namespaces_based_on_oidc_groups() { void return_empty_when_no_namespaces_match() { filter.authEnabled = true; RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of("private", List.of()), Map.of(), Map.of()); + Map.of("private", List.of()), Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); when(identity.isAnonymous()).thenReturn(false); when(identity.getPrincipal()).thenReturn(principal); From e3235f65457d7fd1a889c71d893dea3573d513d2 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 20:01:12 +0100 Subject: [PATCH 19/47] refactor(calm-hub): move GitHubFileReader out of the util package as an injectable bean Fourth move of the store/github/util dissolution. GitHubFileReader was a static containment-checked reader taking cloneDirectory as a parameter - every one of its 7 callers duplicated an identical @ConfigProperty("calm.github.clone-directory") field purely to supply that argument, and GitHubStoreConfig's own javadoc says that exact property needs runtime (not build-time) resolution under native image, which the 7 duplicated fields bypassed. Convert to NamespaceFileReader, an @ApplicationScoped bean injecting GitHubStoreConfig directly - removes the 7 duplicated fields, 10 static call sites, and the native-image inconsistency in the same move. Moves to store.github.access. GitHubArchitectureStore, GitHubControlStore, GitHubFlowStore, GitHubInterfaceStore, GitHubStandardStore (3 call sites - it also reads a markdown sibling), GitHubPatternStore, and GitHubTimelineStore switch to a package-private @Inject fileReader field, matching the existing cloneManager/versionService pattern in these stores (constructor injection for all three is the shared-base sweep still to come). Full verify (3344 tests, 0 failures) and JaCoCo coverage checks pass. --- .../store/github/GitHubArchitectureStore.java | 12 +++--- .../calm/store/github/GitHubControlStore.java | 12 +++--- .../calm/store/github/GitHubFlowStore.java | 12 +++--- .../store/github/GitHubInterfaceStore.java | 12 +++--- .../calm/store/github/GitHubPatternStore.java | 12 +++--- .../store/github/GitHubStandardStore.java | 16 ++++---- .../store/github/GitHubTimelineStore.java | 12 +++--- .../NamespaceFileReader.java} | 39 +++++++++++++------ .../TestGitHubArchitectureStoreShould.java | 4 +- .../github/TestGitHubControlStoreShould.java | 6 ++- .../github/TestGitHubFlowStoreShould.java | 4 +- .../TestGitHubInterfaceStoreShould.java | 4 +- .../github/TestGitHubPatternStoreShould.java | 4 +- .../github/TestGitHubStandardStoreShould.java | 8 ++-- .../github/TestGitHubTimelineStoreShould.java | 4 +- .../TestNamespaceFileReaderShould.java} | 32 +++++++-------- 16 files changed, 104 insertions(+), 89 deletions(-) rename calm-hub/src/main/java/org/finos/calm/store/github/{util/GitHubFileReader.java => access/NamespaceFileReader.java} (66%) rename calm-hub/src/test/java/org/finos/calm/store/github/{util/TestGitHubFileReaderShould.java => access/TestNamespaceFileReaderShould.java} (82%) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java index 4cb4520f2c..6f8966fc82 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java @@ -4,7 +4,6 @@ import jakarta.enterprise.inject.Typed; import jakarta.inject.Inject; import org.finos.calm.domain.exception.GitHubWriteNotSupportedException; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.Architecture; import org.finos.calm.domain.exception.ArchitectureNotFoundException; import org.finos.calm.domain.exception.ArchitectureVersionExistsException; @@ -15,7 +14,7 @@ import org.finos.calm.store.PageRequest; import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubFileReader; +import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; @@ -37,16 +36,15 @@ public class GitHubArchitectureStore implements ArchitectureStore { private final ResourceRegistry registryService; - @Inject - @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") - String cloneDirectory; - @Inject GitHubCloneManager cloneManager; @Inject GitHubVersionService versionService; + @Inject + NamespaceFileReader fileReader; + @Inject public GitHubArchitectureStore(ResourceRegistry registryService) { this.registryService = registryService; @@ -98,7 +96,7 @@ public String getArchitectureForVersion(Architecture architecture) throws Namesp // Fallback: read from local clone (latest/HEAD) try { - return GitHubFileReader.readContained(cloneDirectory, architecture.getNamespace(), entry.filePath()); + return fileReader.readContained(architecture.getNamespace(), entry.filePath()); } catch (IOException e) { LOG.error("Failed to read architecture file: {}", entry.filePath(), e); throw new ArchitectureVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java index ef7878e152..116374ca64 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java @@ -5,7 +5,6 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Typed; import jakarta.inject.Inject; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.controls.ControlConfigDetail; import org.finos.calm.domain.controls.ControlDetail; import org.finos.calm.domain.controls.CreateControlConfiguration; @@ -21,7 +20,7 @@ import org.finos.calm.store.ControlStore; import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubFileReader; +import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.util.NamespaceAccessFilter; @@ -44,16 +43,15 @@ public class GitHubControlStore implements ControlStore { private final ResourceRegistry registryService; - @Inject - @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") - String cloneDirectory; - @Inject GitHubCloneManager cloneManager; @Inject GitHubVersionService versionService; + @Inject + NamespaceFileReader fileReader; + @Inject NamespaceAccessFilter accessFilter; @@ -136,7 +134,7 @@ public String getRequirementForVersion(String domain, int controlId, String vers // Fallback: read from local clone (latest/HEAD) try { - return GitHubFileReader.readContained(cloneDirectory, namespace, entry.filePath()); + return fileReader.readContained(namespace, entry.filePath()); } catch (IOException e) { LOG.error("Failed to read control file: {}", entry.filePath(), e); throw new ControlRequirementVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java index edba1eb06b..db2e780f35 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java @@ -5,7 +5,6 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Typed; import jakarta.inject.Inject; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.Flow; import org.finos.calm.domain.exception.FlowNotFoundException; import org.finos.calm.domain.exception.FlowVersionExistsException; @@ -16,7 +15,7 @@ import org.finos.calm.store.FlowStore; import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubFileReader; +import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; @@ -38,16 +37,15 @@ public class GitHubFlowStore implements FlowStore { private final ResourceRegistry registryService; - @Inject - @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") - String cloneDirectory; - @Inject GitHubCloneManager cloneManager; @Inject GitHubVersionService versionService; + @Inject + NamespaceFileReader fileReader; + @Inject public GitHubFlowStore(ResourceRegistry registryService) { this.registryService = registryService; @@ -99,7 +97,7 @@ public String getFlowForVersion(Flow flow) throws NamespaceNotFoundException, Fl // Fallback: read from local clone (latest/HEAD) try { - return GitHubFileReader.readContained(cloneDirectory, flow.getNamespace(), entry.filePath()); + return fileReader.readContained(flow.getNamespace(), entry.filePath()); } catch (IOException e) { LOG.error("Failed to read flow file: {}", entry.filePath(), e); throw new FlowVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java index 66a9ab1c7a..81aee3ac01 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java @@ -5,7 +5,6 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Typed; import jakarta.inject.Inject; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.CalmInterface; import org.finos.calm.domain.exception.InterfaceNotFoundException; import org.finos.calm.domain.exception.InterfaceVersionExistsException; @@ -16,7 +15,7 @@ import org.finos.calm.store.InterfaceStore; import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubFileReader; +import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; @@ -38,16 +37,15 @@ public class GitHubInterfaceStore implements InterfaceStore { private final ResourceRegistry registryService; - @Inject - @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") - String cloneDirectory; - @Inject GitHubCloneManager cloneManager; @Inject GitHubVersionService versionService; + @Inject + NamespaceFileReader fileReader; + @Inject public GitHubInterfaceStore(ResourceRegistry registryService) { this.registryService = registryService; @@ -98,7 +96,7 @@ public String getInterfaceForVersion(String namespace, Integer interfaceId, Stri // Fallback: read from local clone (latest/HEAD) try { - return GitHubFileReader.readContained(cloneDirectory, namespace, entry.filePath()); + return fileReader.readContained(namespace, entry.filePath()); } catch (IOException e) { LOG.error("Failed to read interface file: {}", entry.filePath(), e); throw new InterfaceVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java index b1bc04c6d1..65e1b111cf 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java @@ -6,7 +6,6 @@ import jakarta.enterprise.inject.Typed; import jakarta.inject.Inject; import org.bson.json.JsonParseException; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.Pattern; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.exception.PatternNotFoundException; @@ -18,7 +17,7 @@ import org.finos.calm.store.PatternStore; import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubFileReader; +import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; @@ -40,16 +39,15 @@ public class GitHubPatternStore implements PatternStore { private final ResourceRegistry registryService; - @Inject - @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") - String cloneDirectory; - @Inject GitHubCloneManager cloneManager; @Inject GitHubVersionService versionService; + @Inject + NamespaceFileReader fileReader; + @Inject public GitHubPatternStore(ResourceRegistry registryService) { this.registryService = registryService; @@ -101,7 +99,7 @@ public String getPatternForVersion(Pattern pattern) throws NamespaceNotFoundExce // Fallback: read from local clone (latest/HEAD) try { - return GitHubFileReader.readContained(cloneDirectory, pattern.getNamespace(), entry.filePath()); + return fileReader.readContained(pattern.getNamespace(), entry.filePath()); } catch (IOException e) { LOG.error("Failed to read pattern file: {}", entry.filePath(), e); throw new PatternVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java index 54a4a88611..e9a755fb7b 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java @@ -5,7 +5,6 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Typed; import jakarta.inject.Inject; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.Standard; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.exception.StandardNotFoundException; @@ -16,7 +15,7 @@ import org.finos.calm.store.StandardStore; import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubFileReader; +import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; @@ -39,16 +38,15 @@ public class GitHubStandardStore implements StandardStore { private final ResourceRegistry registryService; - @Inject - @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") - String cloneDirectory; - @Inject GitHubCloneManager cloneManager; @Inject GitHubVersionService versionService; + @Inject + NamespaceFileReader fileReader; + @Inject public GitHubStandardStore(ResourceRegistry registryService) { this.registryService = registryService; @@ -106,11 +104,11 @@ public String getStandardForVersion(String namespace, Integer standardId, String .replaceAll("\\.(guideline|standard|calm)\\.json$", "") .replace(".json", ""); Path relativeMdSibling = relativeFilePath.resolveSibling(baseName + ".md"); - if (GitHubFileReader.existsContained(cloneDirectory, namespace, relativeMdSibling)) { - return GitHubFileReader.readContained(cloneDirectory, namespace, relativeMdSibling); + if (fileReader.existsContained(namespace, relativeMdSibling)) { + return fileReader.readContained(namespace, relativeMdSibling); } } - return GitHubFileReader.readContained(cloneDirectory, namespace, relativeFilePath); + return fileReader.readContained(namespace, relativeFilePath); } catch (IOException e) { LOG.error("Failed to read standard file: {}", entry.filePath(), e); throw new StandardVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java index cf5ccecb34..ec09a0f3f3 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java @@ -5,7 +5,6 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Typed; import jakarta.inject.Inject; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.exception.TimelineNotFoundException; import org.finos.calm.domain.exception.TimelineVersionExistsException; @@ -16,7 +15,7 @@ import org.finos.calm.store.TimelineStore; import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubFileReader; +import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; @@ -38,16 +37,15 @@ public class GitHubTimelineStore implements TimelineStore { private final ResourceRegistry registryService; - @Inject - @ConfigProperty(name = "calm.github.clone-directory", defaultValue = "/tmp/calm-hub-clones") - String cloneDirectory; - @Inject GitHubCloneManager cloneManager; @Inject GitHubVersionService versionService; + @Inject + NamespaceFileReader fileReader; + @Inject public GitHubTimelineStore(ResourceRegistry registryService) { this.registryService = registryService; @@ -99,7 +97,7 @@ public String getTimelineForVersion(Timeline timeline) throws NamespaceNotFoundE // Fallback: read from local clone (latest/HEAD) try { - return GitHubFileReader.readContained(cloneDirectory, timeline.getNamespace(), entry.filePath()); + return fileReader.readContained(timeline.getNamespace(), entry.filePath()); } catch (IOException e) { LOG.error("Failed to read timeline file: {}", entry.filePath(), e); throw new TimelineVersionNotFoundException(); diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubFileReader.java b/calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceFileReader.java similarity index 66% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubFileReader.java rename to calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceFileReader.java index 876714f105..a5171117e3 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubFileReader.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceFileReader.java @@ -1,4 +1,8 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.access; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.finos.calm.store.github.config.GitHubStoreConfig; import java.io.IOException; import java.nio.file.Files; @@ -28,23 +32,36 @@ * rebuilt only every {@code calm.github.sync-interval} seconds, so a symlink swapped * in between rebuilds (a TOCTOU window against {@code GitHubRepoSync}'s * {@code reset --hard}) would otherwise slip past a scan-time-only guard. + * + *

An injected bean rather than a static utility, deliberately: every GitHub store + * used to declare its own {@code @ConfigProperty("calm.github.clone-directory")} field + * purely to pass to a static call here — 8 duplicated fields, and one of them bypassing + * {@link GitHubStoreConfig}'s own documented reason for existing (runtime, not + * build-time, resolution under native image). Injecting {@link GitHubStoreConfig} here + * instead removes both problems in one move. */ -public final class GitHubFileReader { +@ApplicationScoped +public class NamespaceFileReader { + + private final GitHubStoreConfig storeConfig; - private GitHubFileReader() { + @Inject + public NamespaceFileReader(GitHubStoreConfig storeConfig) { + this.storeConfig = storeConfig; } /** - * Reads {@code relativeFilePath} from within {@code cloneDirectory}/{@code namespace}, - * refusing to follow a symlink — direct or via an intermediate path component — that - * would escape that namespace's own clone directory. + * Reads {@code relativeFilePath} from within the configured clone directory's + * {@code namespace} subdirectory, refusing to follow a symlink — direct or via an + * intermediate path component — that would escape that namespace's own clone + * directory. * * @throws NoSuchFileException if the resolved target is a symlink, does not exist, or * its real path falls outside the namespace's clone directory * @throws IOException if the read itself fails */ - public static String readContained(String cloneDirectory, String namespace, Path relativeFilePath) throws IOException { - Path namespaceRoot = Path.of(cloneDirectory, namespace); + public String readContained(String namespace, Path relativeFilePath) throws IOException { + Path namespaceRoot = storeConfig.getCloneDirectory().resolve(namespace); Path target = namespaceRoot.resolve(relativeFilePath); if (!isContained(namespaceRoot, target)) { throw new NoSuchFileException(target.toString()); @@ -56,13 +73,13 @@ public static String readContained(String cloneDirectory, String namespace, Path * Same containment guard as {@link #readContained}, without reading the file — for * call sites (e.g. an optional sibling file) that need to check existence first. */ - public static boolean existsContained(String cloneDirectory, String namespace, Path relativeFilePath) { - Path namespaceRoot = Path.of(cloneDirectory, namespace); + public boolean existsContained(String namespace, Path relativeFilePath) { + Path namespaceRoot = storeConfig.getCloneDirectory().resolve(namespace); Path target = namespaceRoot.resolve(relativeFilePath); return Files.exists(target) && isContained(namespaceRoot, target); } - private static boolean isContained(Path namespaceRoot, Path target) { + private boolean isContained(Path namespaceRoot, Path target) { // Reject a direct symlink target outright, regardless of where it points - // simplest and most defensible: repo content never legitimately needs to be a // symlink for any resource this reads. diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java index 7eefe53e8d..40b603170c 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java @@ -5,6 +5,8 @@ import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.store.PageRequest; +import org.finos.calm.store.github.access.NamespaceFileReader; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; @@ -152,7 +154,7 @@ void return_architecture_content_for_version(@org.junit.jupiter.api.io.TempDir j when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); - store.cloneDirectory = tempDir.toString(); + store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index e780eb9eea..a977d27ecf 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -7,6 +7,8 @@ import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; import org.finos.calm.domain.exception.DomainNotFoundException; import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.access.NamespaceFileReader; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.registry.ResourceRegistry; @@ -218,7 +220,7 @@ void return_control_content_for_version(@TempDir Path tempDir) throws Exception when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); - store.cloneDirectory = tempDir.toString(); + store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); String content = store.getRequirementForVersion(DOMAIN, HASH_ID, "1.0.0"); assertThat(content, equalTo("{\"control\":\"data\"}")); @@ -293,7 +295,7 @@ void throw_requirement_version_not_found_when_file_missing(@TempDir Path tempDir when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); - store.cloneDirectory = tempDir.toString(); + store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); assertThrows(ControlRequirementVersionNotFoundException.class, () -> store.getRequirementForVersion(DOMAIN, HASH_ID, "1.0.0")); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java index 91dcf3c4b7..42f25883f7 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java @@ -7,6 +7,8 @@ import org.finos.calm.domain.flow.CreateFlowRequest; import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.access.NamespaceFileReader; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.registry.ResourceRegistry; @@ -156,7 +158,7 @@ void return_flow_content_for_version(@TempDir Path tempDir) throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); - store.cloneDirectory = tempDir.toString(); + store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java index 2f5508e719..18624f5458 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java @@ -6,6 +6,8 @@ import org.finos.calm.domain.interfaces.CreateInterfaceRequest; import org.finos.calm.domain.interfaces.NamespaceInterfaceSummary; import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.access.NamespaceFileReader; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.registry.ResourceRegistry; @@ -168,7 +170,7 @@ void return_interface_content_for_version(@TempDir Path tempDir) throws Exceptio when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); - store.cloneDirectory = tempDir.toString(); + store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); String content = store.getInterfaceForVersion("finos", hashId, "1.0.0"); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java index d66c9214c5..654642394b 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java @@ -8,6 +8,8 @@ import org.finos.calm.domain.pattern.CreatePatternRequest; import org.finos.calm.store.PageRequest; import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.access.NamespaceFileReader; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.registry.ResourceRegistry; @@ -169,7 +171,7 @@ void return_pattern_content_for_version(@TempDir Path tempDir) throws Exception when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); - store.cloneDirectory = tempDir.toString(); + store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java index 8264bfa68e..89b48f9370 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java @@ -3,6 +3,8 @@ import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.domain.standards.CreateStandardRequest; +import org.finos.calm.store.github.access.NamespaceFileReader; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; @@ -132,7 +134,7 @@ void return_content_for_standard(@org.junit.jupiter.api.io.TempDir java.nio.file when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); - store.cloneDirectory = tempDir.toString(); + store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); int hashId = ("test-std".hashCode() & 0x7FFFFFFF); String content = store.getStandardForVersion("finos", hashId, "latest"); assertThat(content, equalTo("{\"name\":\"Test Standard\"}")); @@ -153,7 +155,7 @@ void prefer_md_sibling_over_json(@org.junit.jupiter.api.io.TempDir java.nio.file when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); - store.cloneDirectory = tempDir.toString(); + store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); int hashId = ("policy".hashCode() & 0x7FFFFFFF); String content = store.getStandardForVersion("finos", hashId, "latest"); assertThat(content, org.hamcrest.Matchers.startsWith("# Policy")); @@ -227,7 +229,7 @@ void throw_standard_version_not_found_when_file_missing(@org.junit.jupiter.api.i when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); - store.cloneDirectory = tempDir.toString(); + store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); int hashId = ("test-std".hashCode() & 0x7FFFFFFF); assertThrows(org.finos.calm.domain.exception.StandardVersionNotFoundException.class, diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java index 2b834ec688..06801cdf1d 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java @@ -7,6 +7,8 @@ import org.finos.calm.domain.timeline.NamespaceTimelineSummary; import org.finos.calm.domain.timeline.Timeline; import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.access.NamespaceFileReader; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.util.GitHubVersionService; import org.finos.calm.store.github.registry.ResourceRegistry; @@ -171,7 +173,7 @@ void return_timeline_content_for_version(@TempDir Path tempDir) throws Exception when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); - store.cloneDirectory = tempDir.toString(); + store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubFileReaderShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/access/TestNamespaceFileReaderShould.java similarity index 82% rename from calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubFileReaderShould.java rename to calm-hub/src/test/java/org/finos/calm/store/github/access/TestNamespaceFileReaderShould.java index 03d6c41683..546cb3e7c9 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubFileReaderShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/access/TestNamespaceFileReaderShould.java @@ -1,5 +1,6 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.access; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -13,7 +14,11 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assumptions.assumeTrue; -class TestGitHubFileReaderShould { +class TestNamespaceFileReaderShould { + + private static NamespaceFileReader readerFor(Path cloneDirectory) { + return new NamespaceFileReader(new GitHubStoreConfig("", cloneDirectory.toString(), "https://api.github.com")); + } @Test void read_a_regular_file_within_the_namespace_directory(@TempDir Path cloneDirectory) throws IOException { @@ -21,8 +26,7 @@ void read_a_regular_file_within_the_namespace_directory(@TempDir Path cloneDirec Files.createDirectories(namespaceRoot.resolve("architectures")); Files.writeString(namespaceRoot.resolve("architectures/a.json"), "{}"); - String content = GitHubFileReader.readContained(cloneDirectory.toString(), "finos", - Path.of("architectures/a.json")); + String content = readerFor(cloneDirectory).readContained("finos", Path.of("architectures/a.json")); assertThat(content, equalTo("{}")); } @@ -47,8 +51,7 @@ void reject_a_symlink_pointing_outside_its_own_namespace_directory(@TempDir Path Files.createSymbolicLink(symlink, secretFile); NoSuchFileException e = assertThrows(NoSuchFileException.class, () -> - GitHubFileReader.readContained(cloneDirectory.toString(), "finos", - Path.of("standards/leak.md"))); + readerFor(cloneDirectory).readContained("finos", Path.of("standards/leak.md"))); assertThat(e.getFile(), equalTo(symlink.toString())); } @@ -59,15 +62,14 @@ void reject_a_symlink_pointing_at_a_host_path_outside_the_clone_directory_entire Path namespaceRoot = cloneDirectory.resolve("finos"); Files.createDirectories(namespaceRoot.resolve("standards")); - Path outsideClonesEntirely = Files.createTempFile("github-file-reader-test", ".txt"); + Path outsideClonesEntirely = Files.createTempFile("namespace-file-reader-test", ".txt"); Files.writeString(outsideClonesEntirely, "host secret"); try { Path symlink = namespaceRoot.resolve("standards/leak.md"); Files.createSymbolicLink(symlink, outsideClonesEntirely); assertThrows(NoSuchFileException.class, () -> - GitHubFileReader.readContained(cloneDirectory.toString(), "finos", - Path.of("standards/leak.md"))); + readerFor(cloneDirectory).readContained("finos", Path.of("standards/leak.md"))); } finally { Files.deleteIfExists(outsideClonesEntirely); } @@ -94,8 +96,7 @@ void reject_a_regular_file_reached_through_a_symlinked_intermediate_directory( Files.createSymbolicLink(namespaceRoot.resolve("standards"), otherNamespaceRoot.resolve("standards")); assertThrows(NoSuchFileException.class, () -> - GitHubFileReader.readContained(cloneDirectory.toString(), "finos", - Path.of("standards/leak.md"))); + readerFor(cloneDirectory).readContained("finos", Path.of("standards/leak.md"))); } @Test @@ -103,8 +104,7 @@ void reject_a_missing_file(@TempDir Path cloneDirectory) throws IOException { Files.createDirectories(cloneDirectory.resolve("finos")); assertThrows(NoSuchFileException.class, () -> - GitHubFileReader.readContained(cloneDirectory.toString(), "finos", - Path.of("standards/does-not-exist.md"))); + readerFor(cloneDirectory).readContained("finos", Path.of("standards/does-not-exist.md"))); } @Test @@ -117,8 +117,7 @@ void existsContained_returns_false_for_a_symlink_escape(@TempDir Path cloneDirec Files.writeString(outside, "secret"); Files.createSymbolicLink(namespaceRoot.resolve("standards/a.md"), outside); - assertThat(GitHubFileReader.existsContained(cloneDirectory.toString(), "finos", - Path.of("standards/a.md")), equalTo(false)); + assertThat(readerFor(cloneDirectory).existsContained("finos", Path.of("standards/a.md")), equalTo(false)); } @Test @@ -127,8 +126,7 @@ void existsContained_returns_true_for_a_regular_contained_file(@TempDir Path clo Files.createDirectories(namespaceRoot.resolve("standards")); Files.writeString(namespaceRoot.resolve("standards/a.md"), "content"); - assertThat(GitHubFileReader.existsContained(cloneDirectory.toString(), "finos", - Path.of("standards/a.md")), equalTo(true)); + assertThat(readerFor(cloneDirectory).existsContained("finos", Path.of("standards/a.md")), equalTo(true)); } private static boolean supportsSymlinks(Path dir) { From 496cca4f76f45e9b56b15d2dc3a4e9d360b01316 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 20:03:46 +0100 Subject: [PATCH 20/47] refactor(calm-hub): move GitHubApiResponseCache out of the github util package Fifth move of the store/github/util dissolution. GitHubApiResponseCache was already well-shaped - two constructors, one @Inject with a config value, one package-private for tests to drive expiry deterministically with a fake Ticker. Only change beyond the move: the test constructor becomes public, since package-private visibility stops being a usable seam once the test lives in a different package from the production class. Moves to store.github.api. GitHubVersionService (not yet moved itself) picks up the new import. Full verify (3344 tests, 0 failures) and JaCoCo coverage checks pass. --- .../github/{util => api}/GitHubApiResponseCache.java | 12 +++++++----- .../calm/store/github/util/GitHubVersionService.java | 1 + .../TestGitHubApiResponseCacheShould.java | 2 +- .../github/util/TestGitHubVersionServiceShould.java | 1 + 4 files changed, 10 insertions(+), 6 deletions(-) rename calm-hub/src/main/java/org/finos/calm/store/github/{util => api}/GitHubApiResponseCache.java (91%) rename calm-hub/src/test/java/org/finos/calm/store/github/{util => api}/TestGitHubApiResponseCacheShould.java (99%) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubApiResponseCache.java b/calm-hub/src/main/java/org/finos/calm/store/github/api/GitHubApiResponseCache.java similarity index 91% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubApiResponseCache.java rename to calm-hub/src/main/java/org/finos/calm/store/github/api/GitHubApiResponseCache.java index abd8d0c378..b8f70c37ce 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubApiResponseCache.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/api/GitHubApiResponseCache.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.api; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; @@ -58,10 +58,12 @@ public GitHubApiResponseCache(@ConfigProperty(name = "calm.github.cache.max-size this(maxSize, Ticker.systemTicker()); } - // Package-private: lets tests drive expiry deterministically with a fake Ticker - // instead of Thread.sleep, the same pattern used by SchemaMigrationInProgressFilter's - // injectable LongSupplier. - GitHubApiResponseCache(long maxSize, Ticker ticker) { + // Public rather than the package-private form this started as: lets tests drive + // expiry deterministically with a fake Ticker instead of Thread.sleep, the same + // pattern used by SchemaMigrationInProgressFilter's injectable LongSupplier. Public + // because a test now belongs to a different package than the production class - + // package-private visibility is not a seam once encapsulation is real. + public GitHubApiResponseCache(long maxSize, Ticker ticker) { this.versionsCache = buildCache(maxSize, ticker, VERSIONS_TTL); this.contentCache = buildCache(maxSize, ticker, CONTENT_TTL); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java index 814dc731f6..05a706fb6c 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java @@ -7,6 +7,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.store.github.api.GitHubApiResponseCache; import org.finos.calm.store.github.config.GitHubStoreConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubApiResponseCacheShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/api/TestGitHubApiResponseCacheShould.java similarity index 99% rename from calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubApiResponseCacheShould.java rename to calm-hub/src/test/java/org/finos/calm/store/github/api/TestGitHubApiResponseCacheShould.java index d1bf9ae1cc..712b611f47 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubApiResponseCacheShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/api/TestGitHubApiResponseCacheShould.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.api; import com.github.benmanes.caffeine.cache.Ticker; import org.junit.jupiter.api.BeforeEach; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java index c31f1440c0..0fa5f37e06 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java @@ -1,6 +1,7 @@ package org.finos.calm.store.github.util; import com.sun.net.httpserver.HttpServer; +import org.finos.calm.store.github.api.GitHubApiResponseCache; import org.finos.calm.store.github.config.GitHubStoreConfig; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; From 00ba9e4483d9ab0576cf83aca744283f90b997e0 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 20:08:58 +0100 Subject: [PATCH 21/47] refactor(calm-hub): move GitHubVersionService out of the util package as GitHubFileHistoryClient Sixth move of the store/github/util dissolution, and where the `latest` sentinel's outbound leak actually gets fixed - see the maintainer's decision on PR #3066 to remove it from the public contract entirely. - Rename to GitHubFileHistoryClient ("Service" said nothing about what it does; it's a REST client for one specific API). Constructor injection for all five collaborators (cache, storeConfig, three @ConfigProperty timeouts/limits), building the shared HttpClient in the constructor body instead of a separate @PostConstruct step - the only reason that split existed was that a field initializer would have read connectTimeoutSeconds before @ConfigProperty injection populated it; constructor injection has no such ordering gap. - getFileVersions returns List.of() on failure (empty commits response, non-200, malformed JSON, connection failure) instead of List.of("latest"). This class has no namespace, so it cannot meaningfully fall back to a local clone's HEAD SHA - that assembly happens one layer up, in the shared store base still to come, which has both the namespace and the clone manager. - extractShas/extractNextLink go from package-private test seams to private - every branch they have is reachable from the public getFileVersions/getFileAtVersion surface. The rewritten test hits a real local HTTP server throughout rather than reaching into internals, and adds two tests the old package-private-access version never had: that a blank service token sends no Authorization header, and that a configured one sends the expected Bearer value - both now verified against actual request headers instead of asserted only against the token field's getter. The 7 stores field-injecting this class (Architecture, Control, Flow, Interface, Pattern, Standard, Timeline) pick up the renamed type; the store-level `List.of("latest")` fallback in each is unchanged here - it's guarded by `cloneManager != null`/`versionService != null` checks that constructor injection removes in the shared-base sweep, which is where that fallback goes too. Full verify (3343 tests, 0 failures) and JaCoCo coverage checks pass. --- .../store/github/GitHubArchitectureStore.java | 4 +- .../calm/store/github/GitHubControlStore.java | 4 +- .../calm/store/github/GitHubFlowStore.java | 4 +- .../store/github/GitHubInterfaceStore.java | 4 +- .../calm/store/github/GitHubPatternStore.java | 4 +- .../store/github/GitHubStandardStore.java | 4 +- .../store/github/GitHubTimelineStore.java | 4 +- .../github/api/GitHubApiResponseCache.java | 2 +- .../GitHubFileHistoryClient.java} | 80 ++-- .../TestGitHubArchitectureStoreShould.java | 4 +- .../github/TestGitHubControlStoreShould.java | 6 +- .../github/TestGitHubFlowStoreShould.java | 6 +- .../TestGitHubInterfaceStoreShould.java | 6 +- .../github/TestGitHubPatternStoreShould.java | 6 +- .../github/TestGitHubStandardStoreShould.java | 4 +- .../github/TestGitHubTimelineStoreShould.java | 6 +- .../TestGitHubFileHistoryClientShould.java | 389 ++++++++++++++++++ .../util/TestGitHubVersionServiceShould.java | 330 --------------- 18 files changed, 463 insertions(+), 404 deletions(-) rename calm-hub/src/main/java/org/finos/calm/store/github/{util/GitHubVersionService.java => api/GitHubFileHistoryClient.java} (75%) create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/api/TestGitHubFileHistoryClientShould.java delete mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java index 6f8966fc82..c09bcc4b0b 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java @@ -15,7 +15,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; -import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; @@ -40,7 +40,7 @@ public class GitHubArchitectureStore implements ArchitectureStore { GitHubCloneManager cloneManager; @Inject - GitHubVersionService versionService; + GitHubFileHistoryClient versionService; @Inject NamespaceFileReader fileReader; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java index 116374ca64..899bfd3a38 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java @@ -21,7 +21,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; -import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.util.NamespaceAccessFilter; import org.finos.calm.store.github.registry.RegistryEntry; @@ -47,7 +47,7 @@ public class GitHubControlStore implements ControlStore { GitHubCloneManager cloneManager; @Inject - GitHubVersionService versionService; + GitHubFileHistoryClient versionService; @Inject NamespaceFileReader fileReader; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java index db2e780f35..d487a701b0 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java @@ -16,7 +16,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; -import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; @@ -41,7 +41,7 @@ public class GitHubFlowStore implements FlowStore { GitHubCloneManager cloneManager; @Inject - GitHubVersionService versionService; + GitHubFileHistoryClient versionService; @Inject NamespaceFileReader fileReader; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java index 81aee3ac01..a65db76472 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java @@ -16,7 +16,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; -import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; @@ -41,7 +41,7 @@ public class GitHubInterfaceStore implements InterfaceStore { GitHubCloneManager cloneManager; @Inject - GitHubVersionService versionService; + GitHubFileHistoryClient versionService; @Inject NamespaceFileReader fileReader; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java index 65e1b111cf..495bfbb82b 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java @@ -18,7 +18,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; -import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; @@ -43,7 +43,7 @@ public class GitHubPatternStore implements PatternStore { GitHubCloneManager cloneManager; @Inject - GitHubVersionService versionService; + GitHubFileHistoryClient versionService; @Inject NamespaceFileReader fileReader; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java index e9a755fb7b..90a42ee58a 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java @@ -16,7 +16,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; -import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; @@ -42,7 +42,7 @@ public class GitHubStandardStore implements StandardStore { GitHubCloneManager cloneManager; @Inject - GitHubVersionService versionService; + GitHubFileHistoryClient versionService; @Inject NamespaceFileReader fileReader; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java index ec09a0f3f3..281f050607 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java @@ -16,7 +16,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.util.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; -import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; @@ -41,7 +41,7 @@ public class GitHubTimelineStore implements TimelineStore { GitHubCloneManager cloneManager; @Inject - GitHubVersionService versionService; + GitHubFileHistoryClient versionService; @Inject NamespaceFileReader fileReader; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/api/GitHubApiResponseCache.java b/calm-hub/src/main/java/org/finos/calm/store/github/api/GitHubApiResponseCache.java index b8f70c37ce..433204b341 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/api/GitHubApiResponseCache.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/api/GitHubApiResponseCache.java @@ -13,7 +13,7 @@ import java.util.Optional; /** - * Caches responses from the GitHub REST API on behalf of {@code GitHubVersionService}: + * Caches responses from the GitHub REST API on behalf of {@link GitHubFileHistoryClient}: * version lists for a file (5 minutes) and file content at an immutable commit SHA * (365 days). * diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java b/calm-hub/src/main/java/org/finos/calm/store/github/api/GitHubFileHistoryClient.java similarity index 75% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java rename to calm-hub/src/main/java/org/finos/calm/store/github/api/GitHubFileHistoryClient.java index 05a706fb6c..dfe28f7e2e 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubVersionService.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/api/GitHubFileHistoryClient.java @@ -1,13 +1,11 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.api; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.arc.lookup.LookupIfProperty; -import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.finos.calm.store.github.api.GitHubApiResponseCache; import org.finos.calm.store.github.config.GitHubStoreConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,45 +27,48 @@ import java.util.regex.Pattern; /** - * Fetches file version history (commit SHAs) and file content from the GitHub REST - * API. Results are cached via {@link GitHubApiResponseCache} — see that class for the - * caching contract and its cross-instance staleness scope. + * REST client fetching file version history (commit SHAs) and file content from the + * GitHub API. Results are cached via {@link GitHubApiResponseCache} — see that class for + * the caching contract and its cross-instance staleness scope. + * + *

Never fabricates a version. On any failure to determine real commit history — + * the API unreachable, a non-200 response, an empty result — {@link #getFileVersions} + * returns an empty list, not a placeholder value. A version list may only ever contain + * versions the store can actually resolve; see the read-block design in the GitHub + * stores that consume this for how an empty list and a genuine SHA are told apart on + * read (namespace/#3066 review discussion — "latest" was removed as a sentinel value + * for exactly this reason).

*/ @LookupIfProperty(name = "calm.database.mode", stringValue = "github") @ApplicationScoped -public class GitHubVersionService { +public class GitHubFileHistoryClient { - private static final Logger LOG = LoggerFactory.getLogger(GitHubVersionService.class); + private static final Logger LOG = LoggerFactory.getLogger(GitHubFileHistoryClient.class); private static final ObjectMapper MAPPER = new ObjectMapper(); private static final Pattern LINK_NEXT_PATTERN = Pattern.compile("<([^>]+)>;\\s*rel=\"next\""); - @Inject - GitHubApiResponseCache cache; - - @Inject - GitHubStoreConfig storeConfig; - - @Inject - @ConfigProperty(name = "calm.github.http.connect-timeout", defaultValue = "10") - int connectTimeoutSeconds; - - @Inject - @ConfigProperty(name = "calm.github.http.request-timeout", defaultValue = "30") - int requestTimeoutSeconds; + private final GitHubApiResponseCache cache; + private final GitHubStoreConfig storeConfig; + private final int requestTimeoutSeconds; + private final int maxVersions; + private final HttpClient httpClient; @Inject - @ConfigProperty(name = "calm.github.max-versions", defaultValue = "100") - int maxVersions; - - // Built in @PostConstruct, not as a field initializer: connectTimeoutSeconds is - // @ConfigProperty-injected, which happens after the constructor runs but before - // @PostConstruct - a field initializer here would read the pre-injection default - // (0). Built once and reused, not per-call, so requests share a connection pool. - private HttpClient httpClient; - - @PostConstruct - void init() { - httpClient = HttpClient.newBuilder() + public GitHubFileHistoryClient(GitHubApiResponseCache cache, + GitHubStoreConfig storeConfig, + @ConfigProperty(name = "calm.github.http.connect-timeout", defaultValue = "10") int connectTimeoutSeconds, + @ConfigProperty(name = "calm.github.http.request-timeout", defaultValue = "30") int requestTimeoutSeconds, + @ConfigProperty(name = "calm.github.max-versions", defaultValue = "100") int maxVersions) { + this.cache = cache; + this.storeConfig = storeConfig; + this.requestTimeoutSeconds = requestTimeoutSeconds; + this.maxVersions = maxVersions; + // Built here rather than in a separate lifecycle step: constructor injection + // means connectTimeoutSeconds is already resolved by the time this line runs, + // unlike the field + @PostConstruct split this class used to need (a field + // initializer would have read @ConfigProperty's pre-injection default of 0). + // Built once and reused, not per-call, so requests share a connection pool. + this.httpClient = HttpClient.newBuilder() .proxy(ProxySelector.getDefault()) .connectTimeout(Duration.ofSeconds(connectTimeoutSeconds)) .build(); @@ -80,6 +81,8 @@ void init() { * share one cache entry holding whichever branch's history was * fetched first, and the API call itself would always return the * default branch's history regardless of which branch is configured. + * @return commit SHAs oldest-first, truncated to 7 characters; empty if none could + * be determined — never a placeholder value (see class javadoc) */ public List getFileVersions(String repoFullName, String branch, String filePath) { Optional> cached = cache.getVersions(repoFullName, branch, filePath); @@ -106,16 +109,13 @@ public List getFileVersions(String repoFullName, String branch, String f url = nextPageUrl(response.headers()); } - if (allShas.isEmpty()) { - allShas = List.of("latest"); - } List chronological = new ArrayList<>(allShas); Collections.reverse(chronological); cache.putVersions(repoFullName, branch, filePath, chronological); return chronological; } catch (Exception e) { LOG.warn("Failed to fetch versions for {}/{}: {}", repoFullName, filePath, e.getMessage()); - return List.of("latest"); + return List.of(); } } @@ -148,7 +148,7 @@ public String getFileAtVersion(String repoFullName, String filePath, String sha) // Percent-encodes a repo-controlled relative path as a sequence of URL path // segments (preserving "/" as a separator, encoding everything else) - filePath - // comes from the repo's own tree (InMemoryRegistryService), not a request, so a + // comes from the repo's own tree (ResourceRegistry), not a request, so a // file named e.g. "x?ref=other&y" must not be able to inject extra query // parameters or alter the request the way an unencoded concatenation would. private static String encodePathSegment(String relativePath) { @@ -162,7 +162,7 @@ private static String encodeQueryValue(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } - List extractShas(String json, int limit) { + private List extractShas(String json, int limit) { List shas = new ArrayList<>(); try { JsonNode commits = MAPPER.readTree(json); @@ -181,7 +181,7 @@ List extractShas(String json, int limit) { return shas; } - String extractNextLink(HttpHeaders headers) { + private String extractNextLink(HttpHeaders headers) { Optional linkHeader = headers.firstValue("Link"); if (linkHeader.isEmpty()) { return null; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java index 40b603170c..789683dc5b 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java @@ -123,7 +123,7 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); - org.finos.calm.store.github.util.GitHubVersionService mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubVersionService.class); + org.finos.calm.store.github.api.GitHubFileHistoryClient mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.api.GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; @@ -173,7 +173,7 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); - org.finos.calm.store.github.util.GitHubVersionService mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubVersionService.class); + org.finos.calm.store.github.api.GitHubFileHistoryClient mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.api.GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index a977d27ecf..861702a69a 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -10,7 +10,7 @@ import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.util.NamespaceAccessFilter; import org.finos.calm.store.github.registry.RegistryEntry; @@ -190,7 +190,7 @@ void return_sha_versions_when_version_service_available() throws Exception { when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); - GitHubVersionService mockVersionService = mock(GitHubVersionService.class); + GitHubFileHistoryClient mockVersionService = mock(GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; @@ -238,7 +238,7 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); - GitHubVersionService mockVersionService = mock(GitHubVersionService.class); + GitHubFileHistoryClient mockVersionService = mock(GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java index 42f25883f7..00456edc4e 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java @@ -10,7 +10,7 @@ import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; import org.finos.calm.store.github.registry.RegistrySnapshot; @@ -127,7 +127,7 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; @@ -177,7 +177,7 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java index 18624f5458..b2801a4862 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java @@ -9,7 +9,7 @@ import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; import org.finos.calm.store.github.registry.RegistrySnapshot; @@ -140,7 +140,7 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; @@ -188,7 +188,7 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java index 654642394b..fc5ba66632 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java @@ -11,7 +11,7 @@ import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; import org.finos.calm.store.github.registry.RegistrySnapshot; @@ -140,7 +140,7 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; @@ -190,7 +190,7 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java index 89b48f9370..1ec5a6cb17 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java @@ -178,7 +178,7 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); - org.finos.calm.store.github.util.GitHubVersionService mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubVersionService.class); + org.finos.calm.store.github.api.GitHubFileHistoryClient mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.api.GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; @@ -205,7 +205,7 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); - org.finos.calm.store.github.util.GitHubVersionService mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubVersionService.class); + org.finos.calm.store.github.api.GitHubFileHistoryClient mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.api.GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java index 06801cdf1d..ec2731e617 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java @@ -10,7 +10,7 @@ import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.util.GitHubCloneManager; -import org.finos.calm.store.github.util.GitHubVersionService; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; import org.finos.calm.store.github.registry.RegistrySnapshot; @@ -142,7 +142,7 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; @@ -192,7 +192,7 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubVersionService mockVersionService = Mockito.mock(GitHubVersionService.class); + GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/api/TestGitHubFileHistoryClientShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/api/TestGitHubFileHistoryClientShould.java new file mode 100644 index 0000000000..5273b88e93 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/api/TestGitHubFileHistoryClientShould.java @@ -0,0 +1,389 @@ +package org.finos.calm.store.github.api; + +import com.sun.net.httpserver.HttpServer; +import org.finos.calm.store.github.config.GitHubStoreConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Exercises {@link GitHubFileHistoryClient} entirely through its public API + * ({@link GitHubFileHistoryClient#getFileVersions} / {@link GitHubFileHistoryClient#getFileAtVersion}) + * against a real local {@link HttpServer} rather than reaching into package-private + * helpers — {@code extractShas}/{@code extractNextLink} are private, and every branch + * they have is reachable from the public surface. + */ +@MockitoSettings(strictness = Strictness.LENIENT) +@ExtendWith(MockitoExtension.class) +class TestGitHubFileHistoryClientShould { + + @Mock + private GitHubApiResponseCache cache; + + private HttpServer server; + + @AfterEach + void teardown() { + if (server != null) { + server.stop(0); + } + } + + private GitHubFileHistoryClient clientFor(String apiUrl) { + return clientFor(apiUrl, "test-token", 100); + } + + private GitHubFileHistoryClient clientFor(String apiUrl, String serviceToken, int maxVersions) { + GitHubStoreConfig storeConfig = new GitHubStoreConfig(serviceToken, "/tmp/calm-hub-clones", apiUrl); + return new GitHubFileHistoryClient(cache, storeConfig, 10, 10, maxVersions); + } + + private GitHubFileHistoryClient fastTimeoutClientFor(String apiUrl) { + GitHubStoreConfig storeConfig = new GitHubStoreConfig("test-token", "/tmp/calm-hub-clones", apiUrl); + // A closed local port can hang until the connect/request timeout rather than + // refusing instantly - keep this test fast rather than waiting out the + // production 10s/30s defaults. + return new GitHubFileHistoryClient(cache, storeConfig, 1, 1, 100); + } + + @Test + void return_cached_versions_when_available() { + List cachedVersions = List.of("abc1234", "def5678"); + when(cache.getVersions("org/repo", "main", "path/file.json")).thenReturn(Optional.of(cachedVersions)); + + GitHubFileHistoryClient client = clientFor("https://api.github.com"); + List result = client.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(result, equalTo(cachedVersions)); + } + + @Test + void return_empty_list_when_the_commits_api_returns_no_commits() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/commits", exchange -> { + byte[] body = "[]".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + GitHubFileHistoryClient client = clientFor("http://localhost:" + server.getAddress().getPort()); + List result = client.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(result, is(empty())); + } + + @Test + void return_empty_list_for_a_malformed_commits_response() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/commits", exchange -> { + byte[] body = "not valid json".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + GitHubFileHistoryClient client = clientFor("http://localhost:" + server.getAddress().getPort()); + List result = client.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(result, is(empty())); + } + + @Test + void return_empty_list_when_the_commits_request_connection_fails() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + server = HttpServer.create(new InetSocketAddress(0), 0); + int freedPort = server.getAddress().getPort(); + server.stop(0); + server = null; + + GitHubFileHistoryClient client = fastTimeoutClientFor("http://localhost:" + freedPort); + List result = client.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(result, is(empty())); + } + + @Test + void not_send_an_authorization_header_when_the_service_token_is_blank() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + AtomicReference capturedAuthHeader = new AtomicReference<>("not-set"); + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/commits", exchange -> { + capturedAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + byte[] body = "[]".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + GitHubFileHistoryClient client = clientFor("http://localhost:" + server.getAddress().getPort(), "", 100); + client.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(capturedAuthHeader.get(), is(nullValue())); + } + + @Test + void send_a_bearer_authorization_header_when_the_service_token_is_present() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + AtomicReference capturedAuthHeader = new AtomicReference<>(); + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/commits", exchange -> { + capturedAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + byte[] body = "[]".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + GitHubFileHistoryClient client = clientFor("http://localhost:" + server.getAddress().getPort(), "ghp_secret", 100); + client.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(capturedAuthHeader.get(), equalTo("Bearer ghp_secret")); + } + + @Test + void extract_abbreviated_seven_character_shas_from_the_commits_response() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + String sha1 = "abcdef1234567890abcdef1234567890abcdef12"; + String sha2 = "1234567890abcdef1234567890abcdef12345678"; + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/commits", exchange -> { + byte[] body = ("[{\"sha\":\"" + sha1 + "\",\"commit\":{}},{\"sha\":\"" + sha2 + "\",\"commit\":{}}]") + .getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + GitHubFileHistoryClient client = clientFor("http://localhost:" + server.getAddress().getPort()); + List result = client.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(result, contains("1234567", "abcdef1")); + } + + @Test + void limit_to_the_configured_max_versions() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + StringBuilder json = new StringBuilder("["); + for (int i = 0; i < 15; i++) { + if (i > 0) json.append(","); + json.append(String.format("{\"sha\":\"%040x\"}", i)); + } + json.append("]"); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/commits", exchange -> { + byte[] body = json.toString().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + GitHubFileHistoryClient client = clientFor("http://localhost:" + server.getAddress().getPort(), "test-token", 5); + List result = client.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(result, hasSize(5)); + } + + @Test + void follow_pagination_across_multiple_pages_on_the_same_host() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + AtomicInteger callCount = new AtomicInteger(); + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/commits", exchange -> { + int call = callCount.incrementAndGet(); + byte[] body; + if (call == 1) { + body = "[{\"sha\":\"1111111111111111111111111111111111111111\"}]".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Link", + "; rel=\"next\""); + } else { + body = "[{\"sha\":\"2222222222222222222222222222222222222222\"}]".getBytes(StandardCharsets.UTF_8); + } + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + GitHubFileHistoryClient client = clientFor("http://localhost:" + server.getAddress().getPort()); + List result = client.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(callCount.get(), equalTo(2)); + assertThat(result, contains("2222222", "1111111")); + } + + @Test + void stop_paginating_when_no_link_header_is_present() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + AtomicInteger callCount = new AtomicInteger(); + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/commits", exchange -> { + callCount.incrementAndGet(); + byte[] body = "[{\"sha\":\"1111111111111111111111111111111111111111\"}]".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + GitHubFileHistoryClient client = clientFor("http://localhost:" + server.getAddress().getPort()); + List result = client.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(callCount.get(), equalTo(1)); + assertThat(result, contains("1111111")); + } + + @Test + void stop_paginating_when_the_next_link_points_at_a_different_host() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + server = HttpServer.create(new InetSocketAddress(0), 0); + AtomicInteger callCount = new AtomicInteger(); + server.createContext("/repos/org/repo/commits", exchange -> { + callCount.incrementAndGet(); + byte[] body = "[{\"sha\":\"1111111111111111111111111111111111111111\"}]".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Link", "; rel=\"next\""); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + GitHubFileHistoryClient client = clientFor("http://localhost:" + server.getAddress().getPort()); + List result = client.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(callCount.get(), equalTo(1)); + assertThat(result, contains("1111111")); + } + + @Test + void stop_paginating_when_the_next_link_is_unparsable() throws Exception { + when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); + + server = HttpServer.create(new InetSocketAddress(0), 0); + AtomicInteger callCount = new AtomicInteger(); + server.createContext("/repos/org/repo/commits", exchange -> { + callCount.incrementAndGet(); + byte[] body = "[{\"sha\":\"1111111111111111111111111111111111111111\"}]".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Link", "; rel=\"next\""); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + GitHubFileHistoryClient client = clientFor("http://localhost:" + server.getAddress().getPort()); + List result = client.getFileVersions("org/repo", "main", "path/file.json"); + + assertThat(callCount.get(), equalTo(1)); + assertThat(result, contains("1111111")); + } + + @Test + void return_cached_content_at_sha_when_available() { + when(cache.getContentAtSha("org/repo", "path/file.json", "abc1234")) + .thenReturn(Optional.of("{\"cached\":true}")); + + GitHubFileHistoryClient client = clientFor("https://api.github.com"); + String content = client.getFileAtVersion("org/repo", "path/file.json", "abc1234"); + + assertThat(content, equalTo("{\"cached\":true}")); + } + + @Test + void return_null_when_api_returns_non_200_for_content_at_sha() throws Exception { + when(cache.getContentAtSha(any(), any(), any())).thenReturn(Optional.empty()); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/contents/path/file.json", exchange -> { + exchange.sendResponseHeaders(404, -1); + exchange.close(); + }); + server.start(); + + GitHubFileHistoryClient client = clientFor("http://localhost:" + server.getAddress().getPort()); + String content = client.getFileAtVersion("org/repo", "path/file.json", "abc1234"); + + assertThat(content, is(nullValue())); + } + + @Test + void return_file_content_and_cache_it_on_a_200_response() throws Exception { + when(cache.getContentAtSha(any(), any(), any())).thenReturn(Optional.empty()); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/repos/org/repo/contents/path/file.json", exchange -> { + byte[] body = "{\"milestones\":[]}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + GitHubFileHistoryClient client = clientFor("http://localhost:" + server.getAddress().getPort()); + String content = client.getFileAtVersion("org/repo", "path/file.json", "abc1234"); + + assertThat(content, equalTo("{\"milestones\":[]}")); + verify(cache).putContentAtSha("org/repo", "path/file.json", "abc1234", content); + } + + @Test + void return_null_when_content_fetch_throws() throws Exception { + when(cache.getContentAtSha(any(), any(), any())).thenReturn(Optional.empty()); + + // Bind a server purely to claim a free port, then stop it immediately - + // nothing is listening there any more, so the request throws a connection + // exception, exercising the catch (Exception e) path rather than the + // non-200 branch above. + server = HttpServer.create(new InetSocketAddress(0), 0); + int freedPort = server.getAddress().getPort(); + server.stop(0); + server = null; + + GitHubFileHistoryClient client = fastTimeoutClientFor("http://localhost:" + freedPort); + String content = client.getFileAtVersion("org/repo", "path/file.json", "abc1234"); + + assertThat(content, is(nullValue())); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java deleted file mode 100644 index 0fa5f37e06..0000000000 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubVersionServiceShould.java +++ /dev/null @@ -1,330 +0,0 @@ -package org.finos.calm.store.github.util; - -import com.sun.net.httpserver.HttpServer; -import org.finos.calm.store.github.api.GitHubApiResponseCache; -import org.finos.calm.store.github.config.GitHubStoreConfig; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; - -import java.net.InetSocketAddress; -import java.net.http.HttpHeaders; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.empty; -import static org.hamcrest.Matchers.nullValue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; - -@MockitoSettings(strictness = Strictness.LENIENT) -@ExtendWith(MockitoExtension.class) -class TestGitHubVersionServiceShould { - - @Mock - private GitHubApiResponseCache cache; - - private GitHubVersionService service; - - private GitHubStoreConfig storeConfig; - - private HttpServer server; - - @BeforeEach - void setup() { - service = new GitHubVersionService(); - service.cache = cache; - storeConfig = new GitHubStoreConfig("test-token", "/tmp/calm-hub-clones", "https://api.github.com"); - service.storeConfig = storeConfig; - service.maxVersions = 100; - service.connectTimeoutSeconds = 10; - service.requestTimeoutSeconds = 30; - service.init(); - } - - @AfterEach - void teardown() { - if (server != null) { - server.stop(0); - } - } - - private void setApiUrl(String apiUrl) { - storeConfig = new GitHubStoreConfig(storeConfig.getServiceToken(), storeConfig.getCloneDirectory().toString(), apiUrl); - service.storeConfig = storeConfig; - } - - private void setServiceToken(String serviceToken) { - storeConfig = new GitHubStoreConfig(serviceToken, storeConfig.getCloneDirectory().toString(), storeConfig.getApiUrl()); - service.storeConfig = storeConfig; - } - - @Test - void return_cached_versions_when_available() { - List cached = List.of("abc1234", "def5678"); - when(cache.getVersions("org/repo", "main", "path/file.json")).thenReturn(Optional.of(cached)); - - List result = service.getFileVersions("org/repo", "main", "path/file.json"); - - assertThat(result, equalTo(cached)); - } - - @Test - void return_latest_when_api_fails() { - when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); - - // API will fail since we're not running a real server - List result = service.getFileVersions("org/repo", "main", "path/file.json"); - - assertThat(result, hasSize(1)); - assertThat(result.get(0), equalTo("latest")); - } - - @Test - void return_latest_when_no_token() { - setServiceToken(""); - when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); - - List result = service.getFileVersions("org/repo", "main", "path/file.json"); - - assertThat(result, hasSize(1)); - assertThat(result.get(0), equalTo("latest")); - } - - @Test - void extract_abbreviated_7_char_shas_from_github_api_response() { - String sha1 = "abcdef1234567890abcdef1234567890abcdef12"; - String sha2 = "1234567890abcdef1234567890abcdef12345678"; - String json = "[{\"sha\":\"" + sha1 + "\",\"commit\":{}},{\"sha\":\"" + sha2 + "\",\"commit\":{}}]"; - - List shas = service.extractShas(json, 100); - - assertThat(shas, hasSize(2)); - assertThat(shas.get(0), equalTo("abcdef1")); - assertThat(shas.get(1), equalTo("1234567")); - } - - @Test - void return_empty_list_when_no_shas_in_response() { - List shas = service.extractShas("[]", 100); - - assertThat(shas, is(empty())); - } - - @Test - void return_empty_list_for_malformed_json() { - List shas = service.extractShas("not valid json", 100); - - assertThat(shas, is(empty())); - } - - @Test - void return_null_from_extract_next_link_when_no_link_header_present() { - HttpHeaders headers = HttpHeaders.of(Map.of(), (a, b) -> true); - - assertThat(service.extractNextLink(headers), is(nullValue())); - } - - @Test - void extract_the_next_link_url_from_a_link_header() { - HttpHeaders headers = HttpHeaders.of( - Map.of("Link", List.of("; rel=\"next\"")), - (a, b) -> true); - - assertThat(service.extractNextLink(headers), equalTo("https://api.github.com/repos/org/repo/commits?page=2")); - } - - @Test - void limit_to_configured_max_versions() { - service.maxVersions = 5; - StringBuilder json = new StringBuilder("["); - for (int i = 0; i < 15; i++) { - if (i > 0) json.append(","); - json.append(String.format("{\"sha\":\"%040x\"}", i)); - } - json.append("]"); - - List shas = service.extractShas(json.toString(), 5); - - assertThat(shas, hasSize(5)); - } - - @Test - void return_cached_content_at_sha_when_available() { - when(cache.getContentAtSha("org/repo", "path/file.json", "abc1234")) - .thenReturn(Optional.of("{\"cached\":true}")); - - String content = service.getFileAtVersion("org/repo", "path/file.json", "abc1234"); - - assertThat(content, equalTo("{\"cached\":true}")); - } - - @Test - void return_null_when_api_returns_non_200_for_content_at_sha() throws Exception { - when(cache.getContentAtSha(any(), any(), any())).thenReturn(Optional.empty()); - - server = HttpServer.create(new InetSocketAddress(0), 0); - server.createContext("/repos/org/repo/contents/path/file.json", exchange -> { - exchange.sendResponseHeaders(404, -1); - exchange.close(); - }); - server.start(); - setApiUrl("http://localhost:" + server.getAddress().getPort()); - - String content = service.getFileAtVersion("org/repo", "path/file.json", "abc1234"); - - assertThat(content, is(nullValue())); - } - - @Test - void return_file_content_and_cache_it_on_a_200_response() throws Exception { - when(cache.getContentAtSha(any(), any(), any())).thenReturn(Optional.empty()); - - server = HttpServer.create(new InetSocketAddress(0), 0); - server.createContext("/repos/org/repo/contents/path/file.json", exchange -> { - byte[] body = "{\"milestones\":[]}".getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(200, body.length); - exchange.getResponseBody().write(body); - exchange.close(); - }); - server.start(); - setApiUrl("http://localhost:" + server.getAddress().getPort()); - - String content = service.getFileAtVersion("org/repo", "path/file.json", "abc1234"); - - assertThat(content, equalTo("{\"milestones\":[]}")); - org.mockito.Mockito.verify(cache).putContentAtSha("org/repo", "path/file.json", "abc1234", content); - } - - @Test - void return_null_when_content_fetch_throws() throws Exception { - when(cache.getContentAtSha(any(), any(), any())).thenReturn(Optional.empty()); - - // Bind a server purely to claim a free port, then stop it immediately - - // nothing is listening there any more, so the request throws a connection - // exception, exercising the catch (Exception e) path rather than the - // non-200 branch above. - server = HttpServer.create(new InetSocketAddress(0), 0); - int freedPort = server.getAddress().getPort(); - server.stop(0); - server = null; - setApiUrl("http://localhost:" + freedPort); - // A closed local port can hang until the connect/request timeout rather than - // refusing instantly - keep this test fast rather than waiting out the - // production 10s/30s defaults set up in @BeforeEach. - service.connectTimeoutSeconds = 1; - service.requestTimeoutSeconds = 1; - service.init(); - - String content = service.getFileAtVersion("org/repo", "path/file.json", "abc1234"); - - assertThat(content, is(nullValue())); - } - - @Test - void return_latest_when_the_commits_request_connection_fails() throws Exception { - when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); - - server = HttpServer.create(new InetSocketAddress(0), 0); - int freedPort = server.getAddress().getPort(); - server.stop(0); - server = null; - setApiUrl("http://localhost:" + freedPort); - service.connectTimeoutSeconds = 1; - service.requestTimeoutSeconds = 1; - service.init(); - - List result = service.getFileVersions("org/repo", "main", "path/file.json"); - - assertThat(result, hasSize(1)); - assertThat(result.get(0), equalTo("latest")); - } - - @Test - void follow_pagination_across_multiple_pages_on_the_same_host() throws Exception { - when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); - - AtomicInteger callCount = new AtomicInteger(); - server = HttpServer.create(new InetSocketAddress(0), 0); - server.createContext("/repos/org/repo/commits", exchange -> { - int call = callCount.incrementAndGet(); - byte[] body; - if (call == 1) { - body = "[{\"sha\":\"1111111111111111111111111111111111111111\"}]".getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Link", - "; rel=\"next\""); - } else { - body = "[{\"sha\":\"2222222222222222222222222222222222222222\"}]".getBytes(StandardCharsets.UTF_8); - } - exchange.sendResponseHeaders(200, body.length); - exchange.getResponseBody().write(body); - exchange.close(); - }); - server.start(); - setApiUrl("http://localhost:" + server.getAddress().getPort()); - - List result = service.getFileVersions("org/repo", "main", "path/file.json"); - - assertThat(callCount.get(), equalTo(2)); - assertThat(result, contains("2222222", "1111111")); - } - - @Test - void stop_paginating_when_the_next_link_points_at_a_different_host() throws Exception { - when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); - - server = HttpServer.create(new InetSocketAddress(0), 0); - AtomicInteger callCount = new AtomicInteger(); - server.createContext("/repos/org/repo/commits", exchange -> { - callCount.incrementAndGet(); - byte[] body = "[{\"sha\":\"1111111111111111111111111111111111111111\"}]".getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Link", "; rel=\"next\""); - exchange.sendResponseHeaders(200, body.length); - exchange.getResponseBody().write(body); - exchange.close(); - }); - server.start(); - setApiUrl("http://localhost:" + server.getAddress().getPort()); - - List result = service.getFileVersions("org/repo", "main", "path/file.json"); - - assertThat(callCount.get(), equalTo(1)); - assertThat(result, contains("1111111")); - } - - @Test - void stop_paginating_when_the_next_link_is_unparsable() throws Exception { - when(cache.getVersions(any(), any(), any())).thenReturn(Optional.empty()); - - server = HttpServer.create(new InetSocketAddress(0), 0); - AtomicInteger callCount = new AtomicInteger(); - server.createContext("/repos/org/repo/commits", exchange -> { - callCount.incrementAndGet(); - byte[] body = "[{\"sha\":\"1111111111111111111111111111111111111111\"}]".getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Link", "; rel=\"next\""); - exchange.sendResponseHeaders(200, body.length); - exchange.getResponseBody().write(body); - exchange.close(); - }); - server.start(); - setApiUrl("http://localhost:" + server.getAddress().getPort()); - - List result = service.getFileVersions("org/repo", "main", "path/file.json"); - - assertThat(callCount.get(), equalTo(1)); - assertThat(result, contains("1111111")); - } -} From 50227579b5a0d35a4e0355352e5989aa291a45a1 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 20:13:10 +0100 Subject: [PATCH 22/47] refactor(calm-hub): move GitHubCloneManager out of the github util package Seventh move of the store/github/util dissolution. GitHubCloneManager was already constructor-injected and clean; this move is mostly mechanical, plus three fixes that belong with it: - Corrects the class javadoc, which claimed "parallel cloning" - cloneAll and pullAll are plain sequential for loops. Points at the tracking issue for actually parallelising them instead of leaving the false claim in place. - Adds headSha(String namespace), delegating to GitHubRepoSync.headSha on the namespace's own clone directory. This is what the shared store base (still to come) will use to give getFileVersions an honest fallback when the commits API has nothing - see GitHubFileHistoryClient's javadoc for why that fallback lives one layer up from the API client. - Deletes the 3-arg registerNamespace(name, repo, branch) overload - production code (GitHubStartupInitializer) only ever called the 4-arg form with access groups; the 3-arg form existed only for tests, which now call the 4-arg form with Set.of() instead. Moves to store.github.sync (alongside GitHubRepoSync, already there). NamespaceAccessFilter, GitHubSyncScheduler, and GitHubStartupInitializer (not yet moved themselves) pick up the new import; 9 stores field-injecting this class pick up the renamed package. Full verify (3344 tests, 0 failures) and JaCoCo coverage checks pass. --- ...GitHubUserAccessDomainReadIntegration.java | 2 +- .../store/github/GitHubArchitectureStore.java | 2 +- .../calm/store/github/GitHubControlStore.java | 2 +- .../calm/store/github/GitHubFlowStore.java | 2 +- .../store/github/GitHubInterfaceStore.java | 2 +- .../calm/store/github/GitHubPatternStore.java | 2 +- .../store/github/GitHubStandardStore.java | 2 +- .../store/github/GitHubTimelineStore.java | 2 +- .../store/github/GitHubUserAccessStore.java | 2 +- .../{util => sync}/GitHubCloneManager.java | 27 ++++++++---- .../github/util/GitHubStartupInitializer.java | 1 + .../github/util/GitHubSyncScheduler.java | 1 + .../github/util/NamespaceAccessFilter.java | 1 + .../TestGitHubArchitectureStoreShould.java | 4 +- .../github/TestGitHubControlStoreShould.java | 2 +- .../github/TestGitHubFlowStoreShould.java | 2 +- .../TestGitHubInterfaceStoreShould.java | 2 +- .../github/TestGitHubPatternStoreShould.java | 2 +- .../github/TestGitHubStandardStoreShould.java | 4 +- .../github/TestGitHubTimelineStoreShould.java | 2 +- .../TestGitHubUserAccessStoreShould.java | 2 +- .../TestGitHubCloneManagerShould.java | 44 ++++++++++++------- .../TestGitHubStartupInitializerShould.java | 1 + .../util/TestGitHubSyncSchedulerShould.java | 1 + .../util/TestNamespaceAccessFilterShould.java | 1 + 25 files changed, 72 insertions(+), 43 deletions(-) rename calm-hub/src/main/java/org/finos/calm/store/github/{util => sync}/GitHubCloneManager.java (84%) rename calm-hub/src/test/java/org/finos/calm/store/github/{util => sync}/TestGitHubCloneManagerShould.java (90%) diff --git a/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java b/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java index 7f1e32d83f..c00cea33a3 100644 --- a/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java +++ b/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java @@ -4,7 +4,7 @@ import io.quarkus.test.junit.TestProfile; import io.quarkus.test.security.TestSecurity; import jakarta.inject.Inject; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.registry.ResourceRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java index c09bcc4b0b..4b19036e12 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java @@ -13,7 +13,7 @@ import org.finos.calm.store.ArchitectureStore; import org.finos.calm.store.PageRequest; import org.finos.calm.store.github.registry.RegistryResourceType; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java index 899bfd3a38..7c48736e34 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java @@ -19,7 +19,7 @@ import org.finos.calm.domain.exception.DomainNotFoundException; import org.finos.calm.store.ControlStore; import org.finos.calm.store.github.registry.RegistryResourceType; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java index d487a701b0..7bf18c456e 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java @@ -14,7 +14,7 @@ import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.store.FlowStore; import org.finos.calm.store.github.registry.RegistryResourceType; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java index a65db76472..1db2bace9d 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java @@ -14,7 +14,7 @@ import org.finos.calm.domain.interfaces.NamespaceInterfaceSummary; import org.finos.calm.store.InterfaceStore; import org.finos.calm.store.github.registry.RegistryResourceType; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java index 495bfbb82b..9b643e2c6d 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java @@ -16,7 +16,7 @@ import org.finos.calm.store.PageRequest; import org.finos.calm.store.PatternStore; import org.finos.calm.store.github.registry.RegistryResourceType; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java index 90a42ee58a..92358088b9 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java @@ -14,7 +14,7 @@ import org.finos.calm.domain.standards.CreateStandardRequest; import org.finos.calm.store.StandardStore; import org.finos.calm.store.github.registry.RegistryResourceType; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java index 281f050607..6d4a782be9 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java @@ -14,7 +14,7 @@ import org.finos.calm.domain.timeline.Timeline; import org.finos.calm.store.TimelineStore; import org.finos.calm.store.github.registry.RegistryResourceType; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java index 18d9c4980a..fa4f2a24bd 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java @@ -12,7 +12,7 @@ import org.finos.calm.security.OidcRoleResolver; import org.finos.calm.store.UserAccessStore; import org.finos.calm.store.github.registry.RegistryResourceType; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.registry.ControlDomains; import org.finos.calm.store.github.registry.ResourceRegistry; import org.slf4j.Logger; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubCloneManager.java similarity index 84% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java rename to calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubCloneManager.java index 5def03bbe3..8b009f10df 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubCloneManager.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubCloneManager.java @@ -1,10 +1,9 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.sync; import io.quarkus.arc.lookup.LookupIfProperty; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.finos.calm.store.github.config.GitHubStoreConfig; -import org.finos.calm.store.github.sync.GitHubRepoSync; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,8 +14,10 @@ import java.util.concurrent.ConcurrentHashMap; /** - * Manages parallel cloning of all registered namespace repos on startup, - * tracks clone state, and provides pull-all for periodic sync. + * Clones all registered namespace repos on startup — sequentially, not in parallel + * despite what an earlier version of this class claimed; see the tracking issue for + * parallelising {@link #cloneAll} and {@link #pullAll} — tracks clone state, and + * provides pull-all for periodic sync. */ @LookupIfProperty(name = "calm.database.mode", stringValue = "github") @ApplicationScoped @@ -42,10 +43,6 @@ public State getState() { return state; } - public void registerNamespace(String name, String repoFullName, String branch) { - namespaceRepos.put(name, new NamespaceRepo(repoFullName, branch, Set.of())); - } - public void registerNamespace(String name, String repoFullName, String branch, Set accessGroups) { namespaceRepos.put(name, new NamespaceRepo(repoFullName, branch, accessGroups)); } @@ -151,5 +148,19 @@ public String getBranchForNamespace(String namespace) { return repo != null ? repo.branch() : null; } + /** + * The commit SHA a namespace's clone directory currently holds — see + * {@link GitHubRepoSync#headSha}, which this delegates to. {@code null} if the + * namespace isn't registered or its clone directory isn't a valid repo yet (e.g. + * during the initial clone window). + */ + public String headSha(String namespace) { + NamespaceRepo repo = namespaceRepos.get(namespace); + if (repo == null) { + return null; + } + return repoSync.headSha(config.getCloneDirectory().resolve(namespace)); + } + record NamespaceRepo(String repoFullName, String branch, Set accessGroups) {} } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java index fff70eda5b..36ac9d0d01 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java @@ -10,6 +10,7 @@ import org.finos.calm.config.DatabaseMode; import org.finos.calm.observability.GitHubMetrics; import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java index 4b1fe0fe7e..f5b15cd32c 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java @@ -8,6 +8,7 @@ import org.finos.calm.config.DatabaseMode; import org.finos.calm.observability.GitHubMetrics; import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java b/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java index 095fd20fd1..21172618f6 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java @@ -6,6 +6,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.security.OidcRoleResolver; import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java index 789683dc5b..d33f7926c4 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java @@ -122,7 +122,7 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); - org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); + org.finos.calm.store.github.sync.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.sync.GitHubCloneManager.class); org.finos.calm.store.github.api.GitHubFileHistoryClient mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.api.GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; @@ -172,7 +172,7 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); - org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); + org.finos.calm.store.github.sync.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.sync.GitHubCloneManager.class); org.finos.calm.store.github.api.GitHubFileHistoryClient mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.api.GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index 861702a69a..d1e130096d 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -9,7 +9,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.config.GitHubStoreConfig; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.util.NamespaceAccessFilter; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java index 00456edc4e..fd3e468d91 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java @@ -9,7 +9,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.config.GitHubStoreConfig; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java index b2801a4862..aa49bdbb44 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java @@ -8,7 +8,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.config.GitHubStoreConfig; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java index fc5ba66632..def7deef6b 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java @@ -10,7 +10,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.config.GitHubStoreConfig; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java index 1ec5a6cb17..4d46841fbb 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java @@ -177,7 +177,7 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); - org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); + org.finos.calm.store.github.sync.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.sync.GitHubCloneManager.class); org.finos.calm.store.github.api.GitHubFileHistoryClient mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.api.GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; @@ -204,7 +204,7 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); - org.finos.calm.store.github.util.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.util.GitHubCloneManager.class); + org.finos.calm.store.github.sync.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.sync.GitHubCloneManager.class); org.finos.calm.store.github.api.GitHubFileHistoryClient mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.api.GitHubFileHistoryClient.class); store.cloneManager = mockCloneManager; store.versionService = mockVersionService; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java index ec2731e617..a21f91b98e 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java @@ -9,7 +9,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.config.GitHubStoreConfig; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java index a6be280722..9548606226 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java @@ -5,7 +5,7 @@ import org.finos.calm.domain.exception.UserAccessNotFoundException; import org.finos.calm.security.OidcRoleResolver; import org.finos.calm.store.github.registry.RegistryResourceType; -import org.finos.calm.store.github.util.GitHubCloneManager; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; import org.finos.calm.store.github.registry.RegistrySnapshot; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubCloneManagerShould.java similarity index 90% rename from calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java rename to calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubCloneManagerShould.java index f5611bcf0a..b84dccc855 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubCloneManagerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubCloneManagerShould.java @@ -1,7 +1,6 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.sync; import org.finos.calm.store.github.config.GitHubStoreConfig; -import org.finos.calm.store.github.sync.GitHubRepoSync; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -56,7 +55,7 @@ void transition_to_ready_when_no_namespaces_registered() { @Test void transition_to_ready_when_all_clones_succeed() { - cloneManager.registerNamespace("finos", "finos/architecture-as-code", "main"); + cloneManager.registerNamespace("finos", "finos/architecture-as-code", "main", Set.of()); when(repoSync.isValidRepo(any())).thenReturn(false); when(repoSync.cloneRepo(eq("finos/architecture-as-code"), eq("main"), any(), eq("test-token"))) .thenReturn(true); @@ -68,7 +67,7 @@ void transition_to_ready_when_all_clones_succeed() { @Test void transition_to_failed_when_all_clones_fail() { - cloneManager.registerNamespace("finos", "finos/architecture-as-code", "main"); + cloneManager.registerNamespace("finos", "finos/architecture-as-code", "main", Set.of()); when(repoSync.isValidRepo(any())).thenReturn(false); when(repoSync.cloneRepo(any(), any(), any(), any())).thenReturn(false); @@ -79,8 +78,8 @@ void transition_to_failed_when_all_clones_fail() { @Test void transition_to_degraded_when_some_clones_fail() { - cloneManager.registerNamespace("ns1", "org/repo1", "main"); - cloneManager.registerNamespace("ns2", "org/repo2", "main"); + cloneManager.registerNamespace("ns1", "org/repo1", "main", Set.of()); + cloneManager.registerNamespace("ns2", "org/repo2", "main", Set.of()); when(repoSync.isValidRepo(any())).thenReturn(false); when(repoSync.cloneRepo(eq("org/repo1"), eq("main"), any(), any())).thenReturn(true); when(repoSync.cloneRepo(eq("org/repo2"), eq("main"), any(), any())).thenReturn(false); @@ -92,7 +91,7 @@ void transition_to_degraded_when_some_clones_fail() { @Test void pull_instead_of_clone_when_repo_already_exists() { - cloneManager.registerNamespace("finos", "finos/architecture-as-code", "main"); + cloneManager.registerNamespace("finos", "finos/architecture-as-code", "main", Set.of()); when(repoSync.isValidRepo(any())).thenReturn(true); when(repoSync.pullRepo(any(), eq("test-token"))).thenReturn(true); @@ -103,7 +102,7 @@ void pull_instead_of_clone_when_repo_already_exists() { @Test void skip_pull_all_when_still_cloning() { - cloneManager.registerNamespace("finos", "finos/repo", "main"); + cloneManager.registerNamespace("finos", "finos/repo", "main", Set.of()); // State is INITIALIZING, pullAll should be a no-op cloneManager.pullAll(); assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.INITIALIZING)); @@ -111,7 +110,7 @@ void skip_pull_all_when_still_cloning() { @Test void pull_all_repos_and_stay_ready() { - cloneManager.registerNamespace("finos", "finos/repo", "main"); + cloneManager.registerNamespace("finos", "finos/repo", "main", Set.of()); when(repoSync.isValidRepo(any())).thenReturn(false); when(repoSync.cloneRepo(any(), any(), any(), any())).thenReturn(true); cloneManager.cloneAll(); @@ -125,7 +124,7 @@ void pull_all_repos_and_stay_ready() { @Test void transition_to_failed_on_pull_all_when_all_fail() { - cloneManager.registerNamespace("ns1", "org/repo1", "main"); + cloneManager.registerNamespace("ns1", "org/repo1", "main", Set.of()); when(repoSync.isValidRepo(any())).thenReturn(false); when(repoSync.cloneRepo(any(), any(), any(), any())).thenReturn(true); cloneManager.cloneAll(); @@ -138,8 +137,8 @@ void transition_to_failed_on_pull_all_when_all_fail() { @Test void return_namespace_clone_paths() { - cloneManager.registerNamespace("finos", "finos/repo", "main"); - cloneManager.registerNamespace("team", "org/team-repo", "main"); + cloneManager.registerNamespace("finos", "finos/repo", "main", Set.of()); + cloneManager.registerNamespace("team", "org/team-repo", "main", Set.of()); Map paths = cloneManager.getNamespaceClonePaths(); @@ -151,7 +150,7 @@ void return_namespace_clone_paths() { @Test void report_has_namespaces_correctly() { assertThat(cloneManager.hasNamespaces(), is(false)); - cloneManager.registerNamespace("finos", "finos/repo", "main"); + cloneManager.registerNamespace("finos", "finos/repo", "main", Set.of()); assertThat(cloneManager.hasNamespaces(), is(true)); } @@ -168,7 +167,7 @@ void return_empty_access_groups_for_unknown_namespace() { @Test void return_repo_for_registered_namespace() { - cloneManager.registerNamespace("finos", "finos/repo", "main"); + cloneManager.registerNamespace("finos", "finos/repo", "main", Set.of()); assertThat(cloneManager.getRepoForNamespace("finos"), equalTo("finos/repo")); } @@ -179,8 +178,8 @@ void return_null_for_unknown_namespace_repo() { @Test void transition_to_degraded_on_pull_all_when_some_fail() { - cloneManager.registerNamespace("ns1", "org/repo1", "main"); - cloneManager.registerNamespace("ns2", "org/repo2", "main"); + cloneManager.registerNamespace("ns1", "org/repo1", "main", Set.of()); + cloneManager.registerNamespace("ns2", "org/repo2", "main", Set.of()); when(repoSync.isValidRepo(any())).thenReturn(false); when(repoSync.cloneRepo(any(), any(), any(), any())).thenReturn(true); cloneManager.cloneAll(); @@ -193,4 +192,17 @@ void transition_to_degraded_on_pull_all_when_some_fail() { assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.DEGRADED)); } + + @Test + void delegate_head_sha_to_repo_sync_for_a_registered_namespace() { + cloneManager.registerNamespace("finos", "finos/repo", "main", Set.of()); + when(repoSync.headSha(Path.of("/tmp/test-clones/finos"))).thenReturn("abc1234"); + + assertThat(cloneManager.headSha("finos"), equalTo("abc1234")); + } + + @Test + void return_null_head_sha_for_an_unregistered_namespace() { + assertThat(cloneManager.headSha("unknown"), is(nullValue())); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java index f47d866d2a..6586ffdd6c 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java @@ -4,6 +4,7 @@ import org.eclipse.microprofile.context.ManagedExecutor; import org.finos.calm.observability.GitHubMetrics; import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java index f6d8c9bdaa..f0afc35272 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java @@ -2,6 +2,7 @@ import org.finos.calm.observability.GitHubMetrics; import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java index 5d56c24b93..4c7c4967c6 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java @@ -4,6 +4,7 @@ import org.finos.calm.security.OidcRoleResolver; import org.finos.calm.store.github.registry.RegistrySnapshot; import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; From 74c4a116efc7131ab97fc0a76c0048d9264d0ef7 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 20:19:09 +0100 Subject: [PATCH 23/47] refactor(calm-hub): move NamespaceAccessFilter out of the github util package Eighth move of the store/github/util dissolution. Constructor injection for all five collaborators, replacing package-private @Inject fields tests wrote to directly. Adds @LookupIfProperty(github) - it previously instantiated in every storage mode despite only being consumed by GitHubControlStore and GitHubDomainStore, both GitHub-only. Verified against calm.database.mode=github specifically because this bean now constructor-injects SecurityIdentity, a request-scoped bean, into an @ApplicationScoped one - the full verify (unit + integration, Docker) confirms CDI still resolves it correctly under the real GitHub-mode profile, not just the mongo default. Moves to store.github.access (alongside NamespaceFileReader, already there). GitHubControlStore and GitHubDomainStore pick up the new import. Full verify (3344 unit + 549 with integration, 0 failures) and JaCoCo coverage checks pass. --- .../calm/store/github/GitHubControlStore.java | 2 +- .../calm/store/github/GitHubDomainStore.java | 2 +- .../NamespaceAccessFilter.java | 33 +++++++++++-------- .../github/TestGitHubControlStoreShould.java | 2 +- .../github/TestGitHubDomainStoreShould.java | 2 +- .../TestNamespaceAccessFilterShould.java | 25 +++++--------- 6 files changed, 31 insertions(+), 35 deletions(-) rename calm-hub/src/main/java/org/finos/calm/store/github/{util => access}/NamespaceAccessFilter.java (64%) rename calm-hub/src/test/java/org/finos/calm/store/github/{util => access}/TestNamespaceAccessFilterShould.java (88%) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java index 7c48736e34..823fbad98d 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java @@ -23,7 +23,7 @@ import org.finos.calm.store.github.access.NamespaceFileReader; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; -import org.finos.calm.store.github.util.NamespaceAccessFilter; +import org.finos.calm.store.github.access.NamespaceAccessFilter; import org.finos.calm.store.github.registry.RegistryEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java index 73db4046f3..126ca06f90 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java @@ -12,7 +12,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.registry.ControlDomains; import org.finos.calm.store.github.registry.ResourceRegistry; -import org.finos.calm.store.github.util.NamespaceAccessFilter; +import org.finos.calm.store.github.access.NamespaceAccessFilter; import java.util.List; import java.util.Set; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java b/calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceAccessFilter.java similarity index 64% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java rename to calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceAccessFilter.java index 21172618f6..80549bfb9a 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/NamespaceAccessFilter.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceAccessFilter.java @@ -1,5 +1,6 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.access; +import io.quarkus.arc.lookup.LookupIfProperty; import io.quarkus.security.identity.SecurityIdentity; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -20,26 +21,30 @@ * GitHub-mode stores to restrict domain/control visibility to namespaces the * caller is authorised to read. */ +@LookupIfProperty(name = "calm.database.mode", stringValue = "github") @ApplicationScoped public class NamespaceAccessFilter { private static final Logger LOG = LoggerFactory.getLogger(NamespaceAccessFilter.class); - @Inject - SecurityIdentity identity; - - @Inject - OidcRoleResolver roleResolver; - - @Inject - ResourceRegistry registryService; - - @Inject - GitHubCloneManager cloneManager; + private final SecurityIdentity identity; + private final OidcRoleResolver roleResolver; + private final ResourceRegistry registryService; + private final GitHubCloneManager cloneManager; + private final boolean authEnabled; @Inject - @ConfigProperty(name = "calm.auth.enabled", defaultValue = "false") - boolean authEnabled; + public NamespaceAccessFilter(SecurityIdentity identity, + OidcRoleResolver roleResolver, + ResourceRegistry registryService, + GitHubCloneManager cloneManager, + @ConfigProperty(name = "calm.auth.enabled", defaultValue = "false") boolean authEnabled) { + this.identity = identity; + this.roleResolver = roleResolver; + this.registryService = registryService; + this.cloneManager = cloneManager; + this.authEnabled = authEnabled; + } public Set getAccessibleNamespaces() { List allNamespaces = registryService.getSnapshot().getNamespaces(); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index d1e130096d..43928a74f9 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -12,7 +12,7 @@ import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.registry.ResourceRegistry; -import org.finos.calm.store.github.util.NamespaceAccessFilter; +import org.finos.calm.store.github.access.NamespaceAccessFilter; import org.finos.calm.store.github.registry.RegistryEntry; import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java index 31c812df50..b6c591d6f6 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java @@ -2,7 +2,7 @@ import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.registry.ResourceRegistry; -import org.finos.calm.store.github.util.NamespaceAccessFilter; +import org.finos.calm.store.github.access.NamespaceAccessFilter; import org.finos.calm.store.github.registry.RegistryEntry; import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/access/TestNamespaceAccessFilterShould.java similarity index 88% rename from calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java rename to calm-hub/src/test/java/org/finos/calm/store/github/access/TestNamespaceAccessFilterShould.java index 4c7c4967c6..10ebc50c35 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestNamespaceAccessFilterShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/access/TestNamespaceAccessFilterShould.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.access; import io.quarkus.security.identity.SecurityIdentity; import org.finos.calm.security.OidcRoleResolver; @@ -12,7 +12,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.security.Principal; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; @@ -21,7 +20,6 @@ import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @@ -43,20 +41,13 @@ class TestNamespaceAccessFilterShould { @Mock private GitHubCloneManager cloneManager; - private NamespaceAccessFilter filter; - - @BeforeEach - void setup() { - filter = new NamespaceAccessFilter(); - filter.identity = identity; - filter.roleResolver = roleResolver; - filter.registryService = registryService; - filter.cloneManager = cloneManager; + private NamespaceAccessFilter filterWithAuth(boolean authEnabled) { + return new NamespaceAccessFilter(identity, roleResolver, registryService, cloneManager, authEnabled); } @Test void return_all_namespaces_when_auth_disabled() { - filter.authEnabled = false; + NamespaceAccessFilter filter = filterWithAuth(false); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(), "private", List.of()), Map.of()); @@ -69,7 +60,7 @@ void return_all_namespaces_when_auth_disabled() { @Test void return_empty_when_identity_is_anonymous() { - filter.authEnabled = true; + NamespaceAccessFilter filter = filterWithAuth(true); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of()), Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); @@ -82,7 +73,7 @@ void return_empty_when_identity_is_anonymous() { @Test void return_only_accessible_namespaces_based_on_oidc_groups() { - filter.authEnabled = true; + NamespaceAccessFilter filter = filterWithAuth(true); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(), "private", List.of(), "restricted", List.of()), Map.of()); @@ -106,7 +97,7 @@ void return_only_accessible_namespaces_based_on_oidc_groups() { @Test void return_empty_when_no_namespaces_match() { - filter.authEnabled = true; + NamespaceAccessFilter filter = filterWithAuth(true); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("private", List.of()), Map.of()); when(registryService.getSnapshot()).thenReturn(snapshot); @@ -124,7 +115,7 @@ void return_empty_when_no_namespaces_match() { @Test void return_empty_when_no_namespaces_registered() { - filter.authEnabled = true; + NamespaceAccessFilter filter = filterWithAuth(true); when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); when(identity.isAnonymous()).thenReturn(false); when(identity.getPrincipal()).thenReturn(principal); From fdf7ccb344558711a1de5fa1152bf222a96df930 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 20:25:09 +0100 Subject: [PATCH 24/47] refactor(calm-hub): finish dissolving the github util package Ninth and tenth moves - the last two classes in store/github/util, which no longer exists after this commit. Both get constructor injection for every collaborator except the framework-mandated @Scheduled/@Observes callback parameters themselves (sync(), onStart(@Observes StartupEvent ev)) - CDI requires those exact method shapes, so they're the one deliberate exception to "no package-private members," per the checklist the rest of this dissolution has followed. GitHubSyncScheduler's misleading SKIP comment is corrected: it said concurrentExecution = SKIP protects a request thread reading a file mid-`reset --hard`. It doesn't - SKIP only prevents this method's own tick from overlapping its next tick. The corrected comment names both open races (a request thread racing a reset, and this scheduler racing GitHubStartupInitializer's own rebuild during the initial clone window) and points at the tracking issue for GitHub clone lifecycle coordination. Both moved test files construct their subject through the constructor now instead of building a no-arg instance and poking package-private fields afterward. Full verify with integration tests (Docker), confirming the real calm.database.mode=github CDI boot end to end, not just the mongo default: 3344 unit + 549 with integration, 0 failures, JaCoCo coverage checks pass. --- .../GitHubStartupInitializer.java | 58 +++++++++++-------- .../{util => sync}/GitHubSyncScheduler.java | 23 ++++---- .../TestGitHubStartupInitializerShould.java | 56 ++++++++---------- .../TestGitHubSyncSchedulerShould.java | 17 +++--- 4 files changed, 76 insertions(+), 78 deletions(-) rename calm-hub/src/main/java/org/finos/calm/store/github/{util => sync}/GitHubStartupInitializer.java (70%) rename calm-hub/src/main/java/org/finos/calm/store/github/{util => sync}/GitHubSyncScheduler.java (74%) rename calm-hub/src/test/java/org/finos/calm/store/github/{util => sync}/TestGitHubStartupInitializerShould.java (73%) rename calm-hub/src/test/java/org/finos/calm/store/github/{util => sync}/TestGitHubSyncSchedulerShould.java (83%) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubStartupInitializer.java similarity index 70% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java rename to calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubStartupInitializer.java index 36ac9d0d01..f355b1da68 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubStartupInitializer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubStartupInitializer.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.sync; import io.quarkus.arc.lookup.LookupIfProperty; import io.quarkus.runtime.StartupEvent; @@ -10,14 +10,16 @@ import org.finos.calm.config.DatabaseMode; import org.finos.calm.observability.GitHubMetrics; import org.finos.calm.store.github.registry.ResourceRegistry; -import org.finos.calm.store.github.sync.GitHubCloneManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Duration; import java.time.Instant; +import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; /** * Reads namespace configuration on startup, registers repos with the clone manager, @@ -35,29 +37,35 @@ public class GitHubStartupInitializer { private static final Logger LOG = LoggerFactory.getLogger(GitHubStartupInitializer.class); - @Inject - GitHubCloneManager cloneManager; - - @Inject - ResourceRegistry registryService; - - @Inject - GitHubMetrics metrics; - - @Inject - ManagedExecutor executor; - - @Inject - @ConfigProperty(name = "calm.github.namespaces") - Optional> namespaceConfigs; - + private final GitHubCloneManager cloneManager; + private final ResourceRegistry registryService; + private final GitHubMetrics metrics; + private final ManagedExecutor executor; + private final Optional> namespaceConfigs; // @LookupIfProperty only gates whether this bean satisfies @Inject/Instance // resolution - it does NOT stop an @Observes StartupEvent method from firing once the // bean exists, so without this check onStart() runs in every calm.database.mode. // Mirrors the identical guard in StandaloneDemoSeeder for the same reason. - @ConfigProperty(name = "calm.database.mode", defaultValue = "mongo") - String databaseMode; + private final String databaseMode; + + @Inject + public GitHubStartupInitializer(GitHubCloneManager cloneManager, + ResourceRegistry registryService, + GitHubMetrics metrics, + ManagedExecutor executor, + @ConfigProperty(name = "calm.github.namespaces") Optional> namespaceConfigs, + @ConfigProperty(name = "calm.database.mode", defaultValue = "mongo") String databaseMode) { + this.cloneManager = cloneManager; + this.registryService = registryService; + this.metrics = metrics; + this.executor = executor; + this.namespaceConfigs = namespaceConfigs; + this.databaseMode = databaseMode; + } + // Package-private, taking the CDI-mandated StartupEvent parameter: this is a + // framework contract method, not a test seam - @Observes requires it callable by + // the container with exactly this signature. void onStart(@Observes StartupEvent ev) { if (!DatabaseMode.GITHUB.equals(databaseMode)) { return; @@ -78,7 +86,7 @@ void onStart(@Observes StartupEvent ev) { String name = parts[0].trim(); String repo = parts[1].trim(); String branch = parts.length > 2 ? parts[2].trim() : "main"; - java.util.Set accessGroups = parts.length > 3 ? parseGroups(parts[3]) : java.util.Set.of(); + Set accessGroups = parts.length > 3 ? parseGroups(parts[3]) : Set.of(); cloneManager.registerNamespace(name, repo, branch, accessGroups); LOG.info("Registered namespace [{}] → repo [{}] branch [{}] accessGroups={}", name, repo, branch, accessGroups); @@ -88,14 +96,14 @@ void onStart(@Observes StartupEvent ev) { LOG.info("GitHub clone started asynchronously — state: {}", cloneManager.getState()); } - private java.util.Set parseGroups(String groupsStr) { + private Set parseGroups(String groupsStr) { if (groupsStr == null || groupsStr.isBlank()) { - return java.util.Set.of(); + return Set.of(); } - return java.util.Arrays.stream(groupsStr.split(";")) + return Arrays.stream(groupsStr.split(";")) .map(String::trim) .filter(s -> !s.isEmpty()) - .collect(java.util.stream.Collectors.toSet()); + .collect(Collectors.toSet()); } void cloneAndRebuild() { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubSyncScheduler.java similarity index 74% rename from calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java rename to calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubSyncScheduler.java index f5b15cd32c..34d307fb80 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/util/GitHubSyncScheduler.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubSyncScheduler.java @@ -1,4 +1,4 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.sync; import io.quarkus.arc.lookup.LookupIfProperty; import io.quarkus.scheduler.Scheduled; @@ -8,7 +8,6 @@ import org.finos.calm.config.DatabaseMode; import org.finos.calm.observability.GitHubMetrics; import org.finos.calm.store.github.registry.ResourceRegistry; -import org.finos.calm.store.github.sync.GitHubCloneManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,25 +27,27 @@ public class GitHubSyncScheduler { private final GitHubCloneManager cloneManager; private final ResourceRegistry registryService; private final GitHubMetrics metrics; - // @LookupIfProperty does not stop @Scheduled invocation once this bean exists - see // the identical guard and comment in GitHubStartupInitializer for why this is needed. - @Inject - @ConfigProperty(name = "calm.database.mode", defaultValue = "mongo") - String databaseMode; + private final String databaseMode; @Inject public GitHubSyncScheduler(GitHubCloneManager cloneManager, - ResourceRegistry registryService, - GitHubMetrics metrics) { + ResourceRegistry registryService, + GitHubMetrics metrics, + @ConfigProperty(name = "calm.database.mode", defaultValue = "mongo") String databaseMode) { this.cloneManager = cloneManager; this.registryService = registryService; this.metrics = metrics; + this.databaseMode = databaseMode; } - // concurrentExecution = SKIP: without it, a pullAll() slower than the sync interval - // overlaps the next tick and runs "reset --hard" on a clone directory a request - // thread may be mid-Files.readString on. + // concurrentExecution = SKIP prevents this method from overlapping its own next + // scheduled tick - it does NOT protect a request thread mid-Files.readString on a + // clone directory from a concurrent "reset --hard" here, and it does NOT prevent + // overlap with GitHubStartupInitializer's own registry rebuild during the initial + // clone window. Both remain open races - see the tracking issue for GitHub clone + // lifecycle coordination with concurrent readers. @Scheduled(every = "${calm.github.sync-interval:60}s", delayed = "${calm.github.sync-interval:60}s", concurrentExecution = Scheduled.ConcurrentExecution.SKIP) void sync() { diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubStartupInitializerShould.java similarity index 73% rename from calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java rename to calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubStartupInitializerShould.java index 6586ffdd6c..95605abf41 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubStartupInitializerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubStartupInitializerShould.java @@ -1,10 +1,9 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.sync; import io.quarkus.runtime.StartupEvent; import org.eclipse.microprofile.context.ManagedExecutor; import org.finos.calm.observability.GitHubMetrics; import org.finos.calm.store.github.registry.ResourceRegistry; -import org.finos.calm.store.github.sync.GitHubCloneManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,9 +16,11 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -40,17 +41,8 @@ class TestGitHubStartupInitializerShould { @Mock private ManagedExecutor executor; - private GitHubStartupInitializer initializer; - @BeforeEach void setup() { - initializer = new GitHubStartupInitializer(); - initializer.cloneManager = cloneManager; - initializer.registryService = registryService; - initializer.metrics = metrics; - initializer.executor = executor; - initializer.databaseMode = "github"; - // Make executor.runAsync execute the Runnable immediately (synchronously for testing) when(executor.runAsync(any(Runnable.class))).thenAnswer(invocation -> { Runnable task = invocation.getArgument(0); @@ -59,9 +51,13 @@ void setup() { }); } + private GitHubStartupInitializer initializerFor(Optional> namespaceConfigs, String databaseMode) { + return new GitHubStartupInitializer(cloneManager, registryService, metrics, executor, namespaceConfigs, databaseMode); + } + @Test void skip_when_no_namespaces_configured() { - initializer.namespaceConfigs = Optional.empty(); + GitHubStartupInitializer initializer = initializerFor(Optional.empty(), "github"); initializer.onStart(new StartupEvent()); @@ -74,8 +70,8 @@ void skip_entirely_when_database_mode_is_not_github() { // @LookupIfProperty only gates @Inject/Instance resolution, not @Observes // invocation once the bean exists - this guard is what actually stops onStart() // from registering namespaces and cloning in, say, mongo mode. - initializer.databaseMode = "mongo"; - initializer.namespaceConfigs = Optional.of(List.of("finos|finos/architecture-as-code|main")); + GitHubStartupInitializer initializer = initializerFor( + Optional.of(List.of("finos|finos/architecture-as-code|main")), "mongo"); initializer.onStart(new StartupEvent()); @@ -86,10 +82,10 @@ void skip_entirely_when_database_mode_is_not_github() { @Test void register_namespaces_and_clone() { - initializer.namespaceConfigs = Optional.of(List.of( + GitHubStartupInitializer initializer = initializerFor(Optional.of(List.of( "finos|finos/architecture-as-code|main", "team|my-org/team-repo" - )); + )), "github"); when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of( "finos", Path.of("/tmp/finos"), "team", Path.of("/tmp/team") @@ -97,8 +93,8 @@ void register_namespaces_and_clone() { initializer.onStart(new StartupEvent()); - verify(cloneManager).registerNamespace("finos", "finos/architecture-as-code", "main", java.util.Set.of()); - verify(cloneManager).registerNamespace("team", "my-org/team-repo", "main", java.util.Set.of()); + verify(cloneManager).registerNamespace("finos", "finos/architecture-as-code", "main", Set.of()); + verify(cloneManager).registerNamespace("team", "my-org/team-repo", "main", Set.of()); verify(cloneManager).cloneAll(); verify(registryService).rebuild(any()); verify(metrics).recordSyncSuccess(any()); @@ -106,7 +102,7 @@ void register_namespaces_and_clone() { @Test void skip_invalid_entries() { - initializer.namespaceConfigs = Optional.of(List.of("invalid-no-pipe")); + GitHubStartupInitializer initializer = initializerFor(Optional.of(List.of("invalid-no-pipe")), "github"); when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of()); initializer.onStart(new StartupEvent()); @@ -116,9 +112,9 @@ void skip_invalid_entries() { @Test void parse_access_groups_from_namespace_config() { - initializer.namespaceConfigs = Optional.of(List.of( + GitHubStartupInitializer initializer = initializerFor(Optional.of(List.of( "finos|finos/repo|main|group-a;group-b" - )); + )), "github"); when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of( "finos", Path.of("/tmp/finos") )); @@ -126,19 +122,15 @@ void parse_access_groups_from_namespace_config() { initializer.onStart(new StartupEvent()); verify(cloneManager).registerNamespace("finos", "finos/repo", "main", - java.util.Set.of("group-a", "group-b")); + Set.of("group-a", "group-b")); } @Test void handle_clone_failure_gracefully() { - initializer.namespaceConfigs = Optional.of(List.of("finos|finos/repo|main")); - when(executor.runAsync(any(Runnable.class))).thenAnswer(invocation -> { - Runnable task = invocation.getArgument(0); - task.run(); - return java.util.concurrent.CompletableFuture.completedFuture(null); - }); + GitHubStartupInitializer initializer = initializerFor( + Optional.of(List.of("finos|finos/repo|main")), "github"); when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of()); - org.mockito.Mockito.doThrow(new RuntimeException("clone failed")).when(cloneManager).cloneAll(); + doThrow(new RuntimeException("clone failed")).when(cloneManager).cloneAll(); initializer.onStart(new StartupEvent()); @@ -148,15 +140,15 @@ void handle_clone_failure_gracefully() { @Test void parse_blank_access_groups_as_empty_set() { - initializer.namespaceConfigs = Optional.of(List.of( + GitHubStartupInitializer initializer = initializerFor(Optional.of(List.of( "finos|finos/repo|main| " - )); + )), "github"); when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of( "finos", Path.of("/tmp/finos") )); initializer.onStart(new StartupEvent()); - verify(cloneManager).registerNamespace("finos", "finos/repo", "main", java.util.Set.of()); + verify(cloneManager).registerNamespace("finos", "finos/repo", "main", Set.of()); } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubSyncSchedulerShould.java similarity index 83% rename from calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java rename to calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubSyncSchedulerShould.java index f0afc35272..b140a81e75 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/util/TestGitHubSyncSchedulerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubSyncSchedulerShould.java @@ -1,9 +1,7 @@ -package org.finos.calm.store.github.util; +package org.finos.calm.store.github.sync; import org.finos.calm.observability.GitHubMetrics; import org.finos.calm.store.github.registry.ResourceRegistry; -import org.finos.calm.store.github.sync.GitHubCloneManager; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -30,16 +28,13 @@ class TestGitHubSyncSchedulerShould { @Mock private GitHubMetrics metrics; - private GitHubSyncScheduler scheduler; - - @BeforeEach - void setup() { - scheduler = new GitHubSyncScheduler(cloneManager, registryService, metrics); - scheduler.databaseMode = "github"; + private GitHubSyncScheduler schedulerFor(String databaseMode) { + return new GitHubSyncScheduler(cloneManager, registryService, metrics, databaseMode); } @Test void skip_sync_when_no_namespaces_registered() { + GitHubSyncScheduler scheduler = schedulerFor("github"); when(cloneManager.hasNamespaces()).thenReturn(false); scheduler.sync(); @@ -53,7 +48,7 @@ void skip_sync_entirely_when_database_mode_is_not_github() { // @LookupIfProperty only gates @Inject/Instance resolution, not @Scheduled // invocation once the bean exists - this guard is what actually stops sync() // from pulling and rebuilding in, say, mongo mode. - scheduler.databaseMode = "mongo"; + GitHubSyncScheduler scheduler = schedulerFor("mongo"); scheduler.sync(); @@ -63,6 +58,7 @@ void skip_sync_entirely_when_database_mode_is_not_github() { @Test void pull_all_and_rebuild_registry_on_sync() { + GitHubSyncScheduler scheduler = schedulerFor("github"); when(cloneManager.hasNamespaces()).thenReturn(true); when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of("finos", Path.of("/tmp/finos"))); @@ -76,6 +72,7 @@ void pull_all_and_rebuild_registry_on_sync() { @Test void record_failure_metric_when_sync_throws() { + GitHubSyncScheduler scheduler = schedulerFor("github"); when(cloneManager.hasNamespaces()).thenReturn(true); doThrow(new RuntimeException("sync error")).when(cloneManager).pullAll(); From b16ff87090907a860d364c63273c6fc37dec8824 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 20:53:58 +0100 Subject: [PATCH 25/47] refactor(calm-hub): extract shared GitHub store base, finish constructor injection - Add AbstractGitHubStore (verifyNamespace, WRITE_UNSUPPORTED) and AbstractReadOnlyGitHubStore (findEntry, getVersions, readAtVersion) to remove the copy-pasted read block that existed across 9 GitHub stores (#3079) - The shared read block only ever treats a version as a real, resolvable SHA: an unrecognised version now 404s via the store's *VersionNotFoundException instead of silently falling back to whatever the local clone's HEAD holds - closes the last "latest with the name removed" gap left after Phase 3/4 - Version lists fall back to the local clone's HEAD SHA only when the GitHub API returns nothing, assembled once in the base store rather than 8 separate copies - Migrate all 14 GitHub*Store classes with any @Inject field to full constructor injection (Architecture, Flow, Interface, Pattern, Standard, Timeline, Adr, Decorator, Control, Domain, Search, UserAccess); delete the now-unreachable cloneManager/versionService/accessFilter null guards - GitHubStandardStore keeps its markdown-sibling preference via a 4-arg readAtVersion overload, scoped so the override never changes what a pinned SHA fetch targets - only the local-HEAD optimisation - GitHubControlStore documents why it doesn't extend the shared bases (it looks up by domain, not namespace+type+id) while still following the same SHA-or-404 read rule - Add package-private no-arg constructors to both abstract base classes so Arc can generate CDI client proxies for the @ApplicationScoped subclasses; documented as proxy-only, never a real collaborator path - Add TestAbstractReadOnlyGitHubStoreShould and rewrite every affected store test for constructor-injected mocks, split "latest" version-list assertions into empty/head-SHA-fallback cases, and add local-file-missing coverage Verified: full unit suite (3380 tests) and Docker-based integration suite (549 tests) pass; JaCoCo 90%-per-class gate met. --- .../store/github/AbstractGitHubStore.java | 46 +++ .../github/AbstractReadOnlyGitHubStore.java | 132 +++++++++ .../calm/store/github/GitHubAdrStore.java | 15 +- .../store/github/GitHubArchitectureStore.java | 70 +---- .../calm/store/github/GitHubControlStore.java | 95 ++++--- .../store/github/GitHubDecoratorStore.java | 15 +- .../calm/store/github/GitHubDomainStore.java | 16 +- .../calm/store/github/GitHubFlowStore.java | 70 +---- .../store/github/GitHubInterfaceStore.java | 69 +---- .../calm/store/github/GitHubPatternStore.java | 70 +---- .../calm/store/github/GitHubSearchStore.java | 6 +- .../store/github/GitHubStandardStore.java | 96 ++----- .../store/github/GitHubTimelineStore.java | 70 +---- .../store/github/GitHubUserAccessStore.java | 22 +- ...TestAbstractReadOnlyGitHubStoreShould.java | 215 ++++++++++++++ .../TestGitHubArchitectureStoreShould.java | 172 +++++++---- .../github/TestGitHubControlStoreShould.java | 123 ++++---- .../github/TestGitHubDomainStoreShould.java | 3 +- .../github/TestGitHubFlowStoreShould.java | 104 +++++-- .../TestGitHubInterfaceStoreShould.java | 99 +++++-- .../github/TestGitHubPatternStoreShould.java | 104 +++++-- .../github/TestGitHubSearchStoreShould.java | 3 +- .../github/TestGitHubStandardStoreShould.java | 266 +++++++++++++----- .../github/TestGitHubTimelineStoreShould.java | 102 +++++-- .../TestGitHubUserAccessStoreShould.java | 6 +- 25 files changed, 1252 insertions(+), 737 deletions(-) create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/AbstractGitHubStore.java create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/AbstractReadOnlyGitHubStore.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestAbstractReadOnlyGitHubStoreShould.java diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/AbstractGitHubStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/AbstractGitHubStore.java new file mode 100644 index 0000000000..17d45d3514 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/AbstractGitHubStore.java @@ -0,0 +1,46 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.store.github.registry.ResourceRegistry; + +/** + * Shared base for the read-only GitHub-backed stores: {@code verifyNamespace} and the + * write-unsupported message text were duplicated byte-for-byte across every store in this + * package before this extraction (see PR #3066 review discussion — "no shared base class + * or trait" — and the tracking issue for this base). + * + *

Deliberately holds only what every GitHub store needs, including the ones with no + * content to read (ADR, Decorator — both return empty results / throw for everything else, + * with no clone/version/file-read collaborators). {@link AbstractReadOnlyGitHubStore} adds + * the read-path machinery for the stores that actually resolve file content.

+ */ +abstract class AbstractGitHubStore { + + static final String WRITE_UNSUPPORTED = + "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; + + final ResourceRegistry registryService; + + AbstractGitHubStore(ResourceRegistry registryService) { + this.registryService = registryService; + } + + /** + * CDI proxy constructor only — never call this directly. Every concrete subclass is an + * {@code @ApplicationScoped} (normal-scoped) bean, and Arc generates a client proxy that + * subclasses the bean class; that generated subclass needs a no-args constructor to + * exist somewhere in the hierarchy to compile, even though the proxy never actually runs + * it against real collaborators — every real call is delegated to the container-managed + * instance built through {@link #AbstractGitHubStore(ResourceRegistry)}. Without this, + * Arc fails bean validation with "unproxyable bean class" for every subclass. + */ + AbstractGitHubStore() { + this.registryService = null; + } + + void verifyNamespace(String namespace) throws NamespaceNotFoundException { + if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { + throw new NamespaceNotFoundException(); + } + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/AbstractReadOnlyGitHubStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/AbstractReadOnlyGitHubStore.java new file mode 100644 index 0000000000..e4884937aa --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/AbstractReadOnlyGitHubStore.java @@ -0,0 +1,132 @@ +package org.finos.calm.store.github; + +import org.finos.calm.store.github.access.NamespaceFileReader; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.sync.GitHubCloneManager; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +/** + * Shared base for the GitHub stores that resolve actual file content: id lookup, version + * listing, and reading a specific version — the same three blocks duplicated across every + * one of these stores before this extraction (PR #3066 review discussion). Each duplicate + * had already drifted — {@code GitHubPatternStore} inlined two error strings its siblings + * had hoisted into constants — which is exactly the kind of divergence a shared base + * prevents from happening again. + * + *

A version is only ever a real, resolvable SHA — never a placeholder. + * {@link #readAtVersion} is not a superset of the old per-store "try SHA, else read + * whatever HEAD holds" logic; that fallback silently served the wrong content for an + * unrecognised version (see the {@code latest} sentinel it replaced — PR #3066 review + * discussion). An unresolvable version is now {@code Optional.empty()}, and every caller + * turns that into its own typed {@code *VersionNotFoundException} — a 404, not "here's + * something else instead."

+ */ +abstract class AbstractReadOnlyGitHubStore extends AbstractGitHubStore { + + private static final String SHA_PATTERN = "[0-9a-f]{7,40}"; + + final GitHubCloneManager cloneManager; + final GitHubFileHistoryClient versionService; + final NamespaceFileReader fileReader; + + AbstractReadOnlyGitHubStore(ResourceRegistry registryService, + GitHubCloneManager cloneManager, + GitHubFileHistoryClient versionService, + NamespaceFileReader fileReader) { + super(registryService); + this.cloneManager = cloneManager; + this.versionService = versionService; + this.fileReader = fileReader; + } + + /** + * CDI proxy constructor only — see {@link AbstractGitHubStore#AbstractGitHubStore()}. + * Required here too: without it, Arc has no zero-arg path through this class to reach + * that one, and bean validation still fails for every concrete subclass. + */ + AbstractReadOnlyGitHubStore() { + super(); + this.cloneManager = null; + this.versionService = null; + this.fileReader = null; + } + + /** + * Looks up the single entry of {@code type} in {@code namespace} whose content-derived + * id matches. Returns {@link Optional#empty()} rather than throwing, so each store's + * caller supplies its own typed {@code *NotFoundException} via + * {@code .orElseThrow(XNotFoundException::new)}. + */ + Optional findEntry(String namespace, RegistryResourceType type, int id) { + return registryService.listByType(namespace, type).stream() + .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) + .findFirst(); + } + + /** + * The advertised version list for {@code entry}: real commit SHAs from the GitHub API + * when available, falling back to the local clone's current HEAD SHA when the API + * returns nothing (unreachable, rate-limited, or the file genuinely has no history via + * this path) — never an empty-but-lying placeholder. Empty only when neither the API + * nor a local clone can attest to any version at all. + */ + List getVersions(String namespace, RegistryEntry entry) { + String repo = cloneManager.getRepoForNamespace(namespace); + String branch = cloneManager.getBranchForNamespace(namespace); + List versions = (repo != null && branch != null) + ? versionService.getFileVersions(repo, branch, entry.filePath().toString()) + : List.of(); + if (!versions.isEmpty()) { + return versions; + } + String headSha = cloneManager.headSha(namespace); + return headSha != null ? List.of(headSha) : List.of(); + } + + /** + * Resolves {@code version} to content, reading {@code entry.filePath()} for the + * local-HEAD case. See the four-argument overload for the disambiguation rule and for + * stores (e.g. Standard's markdown-sibling preference) that need to read a different + * local path than the one the version history was fetched against. + */ + Optional readAtVersion(String namespace, RegistryEntry entry, String version) throws IOException { + return readAtVersion(namespace, entry, version, entry.filePath()); + } + + /** + * @param localReadPath the path read for the local-HEAD case; always + * {@code entry.filePath()} except where a store deliberately + * prefers a different file on disk for that one case (the + * GitHub-API SHA fetch below always targets + * {@code entry.filePath()} regardless — the override never + * changes what "this version" means, only what the local + * optimisation reads when the requested version happens to be + * the tree already on disk). + */ + Optional readAtVersion(String namespace, RegistryEntry entry, String version, Path localReadPath) throws IOException { + if (version == null || !version.matches(SHA_PATTERN)) { + return Optional.empty(); + } + + // The version being read is the exact tree the clone already holds - skip the + // network round trip and read straight off disk. + String headSha = cloneManager.headSha(namespace); + if (version.equals(headSha)) { + return Optional.of(fileReader.readContained(namespace, localReadPath)); + } + + String repo = cloneManager.getRepoForNamespace(namespace); + if (repo == null) { + return Optional.empty(); + } + String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); + return Optional.ofNullable(content); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java index b902aa76c5..340c7de16e 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java @@ -22,19 +22,14 @@ @ApplicationScoped @Typed(GitHubAdrStore.class) -public class GitHubAdrStore implements AdrStore { - - private static final String WRITE_UNSUPPORTED = - "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; +public class GitHubAdrStore extends AbstractGitHubStore implements AdrStore { private static final String VERSION_UNSUPPORTED = "Version history via GitHub API is not yet implemented."; - private final ResourceRegistry registryService; - @Inject public GitHubAdrStore(ResourceRegistry registryService) { - this.registryService = registryService; + super(registryService); } @Override @@ -83,10 +78,4 @@ public AdrMeta updateAdrStatus(AdrMeta adrMeta, Status status) throws AdrNotFoun public void deleteAdr(String namespace, int adrId) throws NamespaceNotFoundException, AdrNotFoundException { throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); } - - private void verifyNamespace(String namespace) throws NamespaceNotFoundException { - if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { - throw new NamespaceNotFoundException(); - } - } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java index 4b19036e12..9d0fe80b81 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java @@ -23,31 +23,17 @@ import java.io.IOException; import java.util.List; -import java.util.Optional; @ApplicationScoped @Typed(GitHubArchitectureStore.class) -public class GitHubArchitectureStore implements ArchitectureStore { - - private static final String WRITE_UNSUPPORTED = - "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; +public class GitHubArchitectureStore extends AbstractReadOnlyGitHubStore implements ArchitectureStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubArchitectureStore.class); - private final ResourceRegistry registryService; - - @Inject - GitHubCloneManager cloneManager; - - @Inject - GitHubFileHistoryClient versionService; - - @Inject - NamespaceFileReader fileReader; - @Inject - public GitHubArchitectureStore(ResourceRegistry registryService) { - this.registryService = registryService; + public GitHubArchitectureStore(ResourceRegistry registryService, GitHubCloneManager cloneManager, + GitHubFileHistoryClient versionService, NamespaceFileReader fileReader) { + super(registryService, cloneManager, versionService, fileReader); } @Override @@ -67,36 +53,19 @@ public Architecture createArchitectureForNamespace(Architecture architecture) th @Override public List getArchitectureVersions(Architecture architecture) throws NamespaceNotFoundException, ArchitectureNotFoundException { verifyNamespace(architecture.getNamespace()); - RegistryEntry entry = findEntryById(architecture.getNamespace(), architecture.getId()); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(architecture.getNamespace()) : null; - String branch = cloneManager != null ? cloneManager.getBranchForNamespace(architecture.getNamespace()) : null; - if (repo != null && branch != null && versionService != null) { - return versionService.getFileVersions(repo, branch, entry.filePath().toString()); - } - return List.of("latest"); + RegistryEntry entry = findEntry(architecture.getNamespace(), RegistryResourceType.ARCHITECTURE, architecture.getId()) + .orElseThrow(ArchitectureNotFoundException::new); + return getVersions(architecture.getNamespace(), entry); } @Override public String getArchitectureForVersion(Architecture architecture) throws NamespaceNotFoundException, ArchitectureNotFoundException, ArchitectureVersionNotFoundException { verifyNamespace(architecture.getNamespace()); - RegistryEntry entry = findEntryById(architecture.getNamespace(), architecture.getId()); - String version = architecture.getDotVersion(); - - // If a specific SHA is requested and version service is available, fetch from GitHub API - if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") - && cloneManager != null && versionService != null) { - String repo = cloneManager.getRepoForNamespace(architecture.getNamespace()); - if (repo != null) { - String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); - if (content != null) { - return content; - } - } - } - - // Fallback: read from local clone (latest/HEAD) + RegistryEntry entry = findEntry(architecture.getNamespace(), RegistryResourceType.ARCHITECTURE, architecture.getId()) + .orElseThrow(ArchitectureNotFoundException::new); try { - return fileReader.readContained(architecture.getNamespace(), entry.filePath()); + return readAtVersion(architecture.getNamespace(), entry, architecture.getDotVersion()) + .orElseThrow(ArchitectureVersionNotFoundException::new); } catch (IOException e) { LOG.error("Failed to read architecture file: {}", entry.filePath(), e); throw new ArchitectureVersionNotFoundException(); @@ -117,21 +86,4 @@ public Architecture updateArchitectureForVersion(Architecture architecture) thro public void deleteArchitecture(String namespace, int architectureId) throws NamespaceNotFoundException, ArchitectureNotFoundException { throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); } - - private RegistryEntry findEntryById(String namespace, int id) throws ArchitectureNotFoundException { - List entries = registryService.listByType(namespace, RegistryResourceType.ARCHITECTURE); - Optional found = entries.stream() - .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) - .findFirst(); - if (found.isEmpty()) { - throw new ArchitectureNotFoundException(); - } - return found.get(); - } - - private void verifyNamespace(String namespace) throws NamespaceNotFoundException { - if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { - throw new NamespaceNotFoundException(); - } - } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java index 823fbad98d..980e7e97aa 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java @@ -29,41 +29,49 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Set; +/** + * Does not extend {@link AbstractReadOnlyGitHubStore} — controls are looked up by + * (domain, controlId), not the (namespace, type, id) shape every other read-only GitHub + * store shares, so {@code findEntry} doesn't apply here. The version-list and + * read-at-version behaviour (never a fabricated "latest", 404 on an unresolvable version) + * mirrors {@link AbstractReadOnlyGitHubStore#getVersions} and + * {@link AbstractReadOnlyGitHubStore#readAtVersion} exactly - see those for why. + */ @ApplicationScoped @Typed(GitHubControlStore.class) public class GitHubControlStore implements ControlStore { private static final String WRITE_UNSUPPORTED = "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; + private static final String SHA_PATTERN = "[0-9a-f]{7,40}"; private static final Logger LOG = LoggerFactory.getLogger(GitHubControlStore.class); private final ResourceRegistry registryService; + private final GitHubCloneManager cloneManager; + private final GitHubFileHistoryClient versionService; + private final NamespaceFileReader fileReader; + private final NamespaceAccessFilter accessFilter; @Inject - GitHubCloneManager cloneManager; - - @Inject - GitHubFileHistoryClient versionService; - - @Inject - NamespaceFileReader fileReader; - - @Inject - NamespaceAccessFilter accessFilter; - - @Inject - public GitHubControlStore(ResourceRegistry registryService) { + public GitHubControlStore(ResourceRegistry registryService, GitHubCloneManager cloneManager, + GitHubFileHistoryClient versionService, NamespaceFileReader fileReader, + NamespaceAccessFilter accessFilter) { this.registryService = registryService; + this.cloneManager = cloneManager; + this.versionService = versionService; + this.fileReader = fileReader; + this.accessFilter = accessFilter; } @Override public List getControlsForDomain(String domain) throws DomainNotFoundException { - Set accessible = resolveAccessibleNamespaces(); - List results = new java.util.ArrayList<>(); + Set accessible = accessFilter.getAccessibleNamespaces(); + List results = new ArrayList<>(); for (String namespace : registryService.getSnapshot().getNamespaces()) { if (!accessible.contains(namespace)) { continue; @@ -95,12 +103,19 @@ public ControlDetail createControlRequirement(CreateControlRequirement request, public List getRequirementVersions(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException { RegistryEntry entry = findControlEntry(domain, controlId); String namespace = findNamespaceForControl(entry); - String repo = cloneManager != null && namespace != null ? cloneManager.getRepoForNamespace(namespace) : null; - String branch = cloneManager != null && namespace != null ? cloneManager.getBranchForNamespace(namespace) : null; - if (repo != null && branch != null && versionService != null) { - return versionService.getFileVersions(repo, branch, entry.filePath().toString()); + if (namespace == null) { + return List.of(); } - return List.of("latest"); + String repo = cloneManager.getRepoForNamespace(namespace); + String branch = cloneManager.getBranchForNamespace(namespace); + List versions = (repo != null && branch != null) + ? versionService.getFileVersions(repo, branch, entry.filePath().toString()) + : List.of(); + if (!versions.isEmpty()) { + return versions; + } + String headSha = cloneManager.headSha(namespace); + return headSha != null ? List.of(headSha) : List.of(); } @Override @@ -120,25 +135,26 @@ public String getRequirementForVersion(String domain, int controlId, String vers throw new ControlRequirementVersionNotFoundException(); } - // If a specific SHA is requested and version service is available, fetch from GitHub API - if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") - && cloneManager != null && versionService != null) { - String repo = cloneManager.getRepoForNamespace(namespace); - if (repo != null) { - String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); - if (content != null) { - return content; - } + if (version == null || !version.matches(SHA_PATTERN)) { + throw new ControlRequirementVersionNotFoundException(); + } + + String headSha = cloneManager.headSha(namespace); + if (version.equals(headSha)) { + try { + return fileReader.readContained(namespace, entry.filePath()); + } catch (IOException e) { + LOG.error("Failed to read control file: {}", entry.filePath(), e); + throw new ControlRequirementVersionNotFoundException(); } } - // Fallback: read from local clone (latest/HEAD) - try { - return fileReader.readContained(namespace, entry.filePath()); - } catch (IOException e) { - LOG.error("Failed to read control file: {}", entry.filePath(), e); + String repo = cloneManager.getRepoForNamespace(namespace); + String content = repo != null ? versionService.getFileAtVersion(repo, entry.filePath().toString(), version) : null; + if (content == null) { throw new ControlRequirementVersionNotFoundException(); } + return content; } @Override @@ -187,7 +203,7 @@ public void deleteControlConfiguration(String domain, int controlId, int configu } private RegistryEntry findControlEntry(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException { - Set accessible = resolveAccessibleNamespaces(); + Set accessible = accessFilter.getAccessibleNamespaces(); boolean domainExists = false; for (String namespace : registryService.getSnapshot().getNamespaces()) { if (!accessible.contains(namespace)) { @@ -219,7 +235,7 @@ private RegistryEntry findControlEntry(String domain, int controlId) throws Doma } private String findNamespaceForControl(RegistryEntry entry) { - Set accessible = resolveAccessibleNamespaces(); + Set accessible = accessFilter.getAccessibleNamespaces(); for (String namespace : registryService.getSnapshot().getNamespaces()) { if (!accessible.contains(namespace)) { continue; @@ -229,11 +245,4 @@ private String findNamespaceForControl(RegistryEntry entry) { } return null; } - - private Set resolveAccessibleNamespaces() { - if (accessFilter == null) { - return new java.util.HashSet<>(registryService.getSnapshot().getNamespaces()); - } - return accessFilter.getAccessibleNamespaces(); - } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java index 5f75d1bb17..b07b3ccd92 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java @@ -17,16 +17,11 @@ @ApplicationScoped @Typed(GitHubDecoratorStore.class) -public class GitHubDecoratorStore implements DecoratorStore { - - private static final String WRITE_UNSUPPORTED = - "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; - - private final ResourceRegistry registryService; +public class GitHubDecoratorStore extends AbstractGitHubStore implements DecoratorStore { @Inject public GitHubDecoratorStore(ResourceRegistry registryService) { - this.registryService = registryService; + super(registryService); } @Override @@ -61,10 +56,4 @@ public void updateDecorator(String namespace, int id, String decoratorJson) thro public void deleteDecorator(String namespace, int id) throws NamespaceNotFoundException, DecoratorNotFoundException { throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); } - - private void verifyNamespace(String namespace) throws NamespaceNotFoundException { - if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { - throw new NamespaceNotFoundException(); - } - } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java index 126ca06f90..39e745abba 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDomainStore.java @@ -29,18 +29,17 @@ public class GitHubDomainStore implements DomainStore { "Domains in GitHub mode are derived from the controls/ directory structure in the repo."; private final ResourceRegistry registryService; + private final NamespaceAccessFilter accessFilter; @Inject - NamespaceAccessFilter accessFilter; - - @Inject - public GitHubDomainStore(ResourceRegistry registryService) { + public GitHubDomainStore(ResourceRegistry registryService, NamespaceAccessFilter accessFilter) { this.registryService = registryService; + this.accessFilter = accessFilter; } @Override public List getDomains() { - Set accessible = resolveAccessibleNamespaces(); + Set accessible = accessFilter.getAccessibleNamespaces(); return registryService.getSnapshot().entriesByNamespace().entrySet().stream() .filter(e -> accessible.contains(e.getKey())) .flatMap(e -> e.getValue().stream()) @@ -64,11 +63,4 @@ public boolean domainExists(String name) { public void deleteDomain(String name) throws DomainNotFoundException { throw new GitHubWriteNotSupportedException(UNSUPPORTED_MSG); } - - private Set resolveAccessibleNamespaces() { - if (accessFilter == null) { - return new java.util.HashSet<>(registryService.getSnapshot().getNamespaces()); - } - return accessFilter.getAccessibleNamespaces(); - } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java index 7bf18c456e..4d71149493 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java @@ -24,31 +24,17 @@ import java.io.IOException; import java.util.List; -import java.util.Optional; @ApplicationScoped @Typed(GitHubFlowStore.class) -public class GitHubFlowStore implements FlowStore { - - private static final String WRITE_UNSUPPORTED = - "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; +public class GitHubFlowStore extends AbstractReadOnlyGitHubStore implements FlowStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubFlowStore.class); - private final ResourceRegistry registryService; - - @Inject - GitHubCloneManager cloneManager; - - @Inject - GitHubFileHistoryClient versionService; - - @Inject - NamespaceFileReader fileReader; - @Inject - public GitHubFlowStore(ResourceRegistry registryService) { - this.registryService = registryService; + public GitHubFlowStore(ResourceRegistry registryService, GitHubCloneManager cloneManager, + GitHubFileHistoryClient versionService, NamespaceFileReader fileReader) { + super(registryService, cloneManager, versionService, fileReader); } @Override @@ -68,36 +54,19 @@ public Flow createFlowForNamespace(CreateFlowRequest flowRequest, String namespa @Override public List getFlowVersions(Flow flow) throws NamespaceNotFoundException, FlowNotFoundException { verifyNamespace(flow.getNamespace()); - RegistryEntry entry = findEntryById(flow.getNamespace(), flow.getId()); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(flow.getNamespace()) : null; - String branch = cloneManager != null ? cloneManager.getBranchForNamespace(flow.getNamespace()) : null; - if (repo != null && branch != null && versionService != null) { - return versionService.getFileVersions(repo, branch, entry.filePath().toString()); - } - return List.of("latest"); + RegistryEntry entry = findEntry(flow.getNamespace(), RegistryResourceType.FLOW, flow.getId()) + .orElseThrow(FlowNotFoundException::new); + return getVersions(flow.getNamespace(), entry); } @Override public String getFlowForVersion(Flow flow) throws NamespaceNotFoundException, FlowNotFoundException, FlowVersionNotFoundException { verifyNamespace(flow.getNamespace()); - RegistryEntry entry = findEntryById(flow.getNamespace(), flow.getId()); - String version = flow.getDotVersion(); - - // If a specific SHA is requested and version service is available, fetch from GitHub API - if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") - && cloneManager != null && versionService != null) { - String repo = cloneManager.getRepoForNamespace(flow.getNamespace()); - if (repo != null) { - String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); - if (content != null) { - return content; - } - } - } - - // Fallback: read from local clone (latest/HEAD) + RegistryEntry entry = findEntry(flow.getNamespace(), RegistryResourceType.FLOW, flow.getId()) + .orElseThrow(FlowNotFoundException::new); try { - return fileReader.readContained(flow.getNamespace(), entry.filePath()); + return readAtVersion(flow.getNamespace(), entry, flow.getDotVersion()) + .orElseThrow(FlowVersionNotFoundException::new); } catch (IOException e) { LOG.error("Failed to read flow file: {}", entry.filePath(), e); throw new FlowVersionNotFoundException(); @@ -118,21 +87,4 @@ public Flow updateFlowForVersion(Flow flow) throws NamespaceNotFoundException, F public void deleteFlow(String namespace, int flowId) throws NamespaceNotFoundException, FlowNotFoundException { throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); } - - private RegistryEntry findEntryById(String namespace, int id) throws FlowNotFoundException { - List entries = registryService.listByType(namespace, RegistryResourceType.FLOW); - Optional found = entries.stream() - .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) - .findFirst(); - if (found.isEmpty()) { - throw new FlowNotFoundException(); - } - return found.get(); - } - - private void verifyNamespace(String namespace) throws NamespaceNotFoundException { - if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { - throw new NamespaceNotFoundException(); - } - } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java index 1db2bace9d..d82b69cef8 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java @@ -24,31 +24,17 @@ import java.io.IOException; import java.util.List; -import java.util.Optional; @ApplicationScoped @Typed(GitHubInterfaceStore.class) -public class GitHubInterfaceStore implements InterfaceStore { - - private static final String WRITE_UNSUPPORTED = - "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; +public class GitHubInterfaceStore extends AbstractReadOnlyGitHubStore implements InterfaceStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubInterfaceStore.class); - private final ResourceRegistry registryService; - - @Inject - GitHubCloneManager cloneManager; - - @Inject - GitHubFileHistoryClient versionService; - - @Inject - NamespaceFileReader fileReader; - @Inject - public GitHubInterfaceStore(ResourceRegistry registryService) { - this.registryService = registryService; + public GitHubInterfaceStore(ResourceRegistry registryService, GitHubCloneManager cloneManager, + GitHubFileHistoryClient versionService, NamespaceFileReader fileReader) { + super(registryService, cloneManager, versionService, fileReader); } @Override @@ -68,35 +54,19 @@ public CalmInterface createInterfaceForNamespace(CreateInterfaceRequest interfac @Override public List getInterfaceVersions(String namespace, Integer interfaceId) throws NamespaceNotFoundException, InterfaceNotFoundException { verifyNamespace(namespace); - RegistryEntry entry = findEntryById(namespace, interfaceId); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; - String branch = cloneManager != null ? cloneManager.getBranchForNamespace(namespace) : null; - if (repo != null && branch != null && versionService != null) { - return versionService.getFileVersions(repo, branch, entry.filePath().toString()); - } - return List.of("latest"); + RegistryEntry entry = findEntry(namespace, RegistryResourceType.INTERFACE, interfaceId) + .orElseThrow(InterfaceNotFoundException::new); + return getVersions(namespace, entry); } @Override public String getInterfaceForVersion(String namespace, Integer interfaceId, String version) throws NamespaceNotFoundException, InterfaceNotFoundException, InterfaceVersionNotFoundException { verifyNamespace(namespace); - RegistryEntry entry = findEntryById(namespace, interfaceId); - - // If a specific SHA is requested and version service is available, fetch from GitHub API - if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") - && cloneManager != null && versionService != null) { - String repo = cloneManager.getRepoForNamespace(namespace); - if (repo != null) { - String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); - if (content != null) { - return content; - } - } - } - - // Fallback: read from local clone (latest/HEAD) + RegistryEntry entry = findEntry(namespace, RegistryResourceType.INTERFACE, interfaceId) + .orElseThrow(InterfaceNotFoundException::new); try { - return fileReader.readContained(namespace, entry.filePath()); + return readAtVersion(namespace, entry, version) + .orElseThrow(InterfaceVersionNotFoundException::new); } catch (IOException e) { LOG.error("Failed to read interface file: {}", entry.filePath(), e); throw new InterfaceVersionNotFoundException(); @@ -112,21 +82,4 @@ public CalmInterface createInterfaceForVersion(CreateInterfaceRequest interfaceR public void deleteInterface(String namespace, Integer interfaceId) throws NamespaceNotFoundException, InterfaceNotFoundException { throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); } - - private RegistryEntry findEntryById(String namespace, int id) throws InterfaceNotFoundException { - List entries = registryService.listByType(namespace, RegistryResourceType.INTERFACE); - Optional found = entries.stream() - .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) - .findFirst(); - if (found.isEmpty()) { - throw new InterfaceNotFoundException(); - } - return found.get(); - } - - private void verifyNamespace(String namespace) throws NamespaceNotFoundException { - if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { - throw new NamespaceNotFoundException(); - } - } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java index 9b643e2c6d..71323c8b3a 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java @@ -26,31 +26,17 @@ import java.io.IOException; import java.util.List; -import java.util.Optional; @ApplicationScoped @Typed(GitHubPatternStore.class) -public class GitHubPatternStore implements PatternStore { - - private static final String WRITE_UNSUPPORTED = - "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; +public class GitHubPatternStore extends AbstractReadOnlyGitHubStore implements PatternStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubPatternStore.class); - private final ResourceRegistry registryService; - - @Inject - GitHubCloneManager cloneManager; - - @Inject - GitHubFileHistoryClient versionService; - - @Inject - NamespaceFileReader fileReader; - @Inject - public GitHubPatternStore(ResourceRegistry registryService) { - this.registryService = registryService; + public GitHubPatternStore(ResourceRegistry registryService, GitHubCloneManager cloneManager, + GitHubFileHistoryClient versionService, NamespaceFileReader fileReader) { + super(registryService, cloneManager, versionService, fileReader); } @Override @@ -70,36 +56,19 @@ public Pattern createPatternForNamespace(CreatePatternRequest patternRequest, St @Override public List getPatternVersions(Pattern pattern) throws NamespaceNotFoundException, PatternNotFoundException { verifyNamespace(pattern.getNamespace()); - RegistryEntry entry = findEntryById(pattern.getNamespace(), pattern.getId()); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(pattern.getNamespace()) : null; - String branch = cloneManager != null ? cloneManager.getBranchForNamespace(pattern.getNamespace()) : null; - if (repo != null && branch != null && versionService != null) { - return versionService.getFileVersions(repo, branch, entry.filePath().toString()); - } - return List.of("latest"); + RegistryEntry entry = findEntry(pattern.getNamespace(), RegistryResourceType.PATTERN, pattern.getId()) + .orElseThrow(PatternNotFoundException::new); + return getVersions(pattern.getNamespace(), entry); } @Override public String getPatternForVersion(Pattern pattern) throws NamespaceNotFoundException, PatternNotFoundException, PatternVersionNotFoundException { verifyNamespace(pattern.getNamespace()); - RegistryEntry entry = findEntryById(pattern.getNamespace(), pattern.getId()); - String version = pattern.getDotVersion(); - - // If a specific SHA is requested and version service is available, fetch from GitHub API - if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") - && cloneManager != null && versionService != null) { - String repo = cloneManager.getRepoForNamespace(pattern.getNamespace()); - if (repo != null) { - String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); - if (content != null) { - return content; - } - } - } - - // Fallback: read from local clone (latest/HEAD) + RegistryEntry entry = findEntry(pattern.getNamespace(), RegistryResourceType.PATTERN, pattern.getId()) + .orElseThrow(PatternNotFoundException::new); try { - return fileReader.readContained(pattern.getNamespace(), entry.filePath()); + return readAtVersion(pattern.getNamespace(), entry, pattern.getDotVersion()) + .orElseThrow(PatternVersionNotFoundException::new); } catch (IOException e) { LOG.error("Failed to read pattern file: {}", entry.filePath(), e); throw new PatternVersionNotFoundException(); @@ -120,21 +89,4 @@ public Pattern updatePatternForVersion(Pattern pattern) throws NamespaceNotFound public void deletePattern(String namespace, int patternId) throws NamespaceNotFoundException, PatternNotFoundException { throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); } - - private RegistryEntry findEntryById(String namespace, int id) throws PatternNotFoundException { - List entries = registryService.listByType(namespace, RegistryResourceType.PATTERN); - Optional found = entries.stream() - .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) - .findFirst(); - if (found.isEmpty()) { - throw new PatternNotFoundException(); - } - return found.get(); - } - - private void verifyNamespace(String namespace) throws NamespaceNotFoundException { - if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { - throw new NamespaceNotFoundException(); - } - } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java index bdb1ed7ace..ae2d470180 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java @@ -19,8 +19,12 @@ @Typed(GitHubSearchStore.class) public class GitHubSearchStore implements SearchStore { + private final ResourceRegistry registryService; + @Inject - ResourceRegistry registryService; + public GitHubSearchStore(ResourceRegistry registryService) { + this.registryService = registryService; + } @Override public GroupedSearchResults search(String query, Optional> readableNamespaces) { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java index 92358088b9..57412ba166 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java @@ -25,31 +25,17 @@ import java.io.IOException; import java.nio.file.Path; import java.util.List; -import java.util.Optional; @ApplicationScoped @Typed(GitHubStandardStore.class) -public class GitHubStandardStore implements StandardStore { - - private static final String WRITE_UNSUPPORTED = - "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; +public class GitHubStandardStore extends AbstractReadOnlyGitHubStore implements StandardStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubStandardStore.class); - private final ResourceRegistry registryService; - - @Inject - GitHubCloneManager cloneManager; - - @Inject - GitHubFileHistoryClient versionService; - @Inject - NamespaceFileReader fileReader; - - @Inject - public GitHubStandardStore(ResourceRegistry registryService) { - this.registryService = registryService; + public GitHubStandardStore(ResourceRegistry registryService, GitHubCloneManager cloneManager, + GitHubFileHistoryClient versionService, NamespaceFileReader fileReader) { + super(registryService, cloneManager, versionService, fileReader); } @Override @@ -69,52 +55,41 @@ public Standard createStandardForNamespace(CreateStandardRequest standardRequest @Override public List getStandardVersions(String namespace, Integer standardId) throws NamespaceNotFoundException, StandardNotFoundException { verifyNamespace(namespace); - RegistryEntry entry = findEntryById(namespace, standardId); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(namespace) : null; - String branch = cloneManager != null ? cloneManager.getBranchForNamespace(namespace) : null; - if (repo != null && branch != null && versionService != null) { - return versionService.getFileVersions(repo, branch, entry.filePath().toString()); - } - return List.of("latest"); + RegistryEntry entry = findEntry(namespace, RegistryResourceType.STANDARD, standardId) + .orElseThrow(StandardNotFoundException::new); + return getVersions(namespace, entry); } @Override public String getStandardForVersion(String namespace, Integer standardId, String version) throws NamespaceNotFoundException, StandardNotFoundException, StandardVersionNotFoundException { verifyNamespace(namespace); - RegistryEntry entry = findEntryById(namespace, standardId); - - // If a specific SHA is requested and version service is available, fetch from GitHub API - if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") - && cloneManager != null && versionService != null) { - String repo = cloneManager.getRepoForNamespace(namespace); - if (repo != null) { - String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); - if (content != null) { - return content; - } - } - } - - // Fallback: read from local clone (latest/HEAD) + RegistryEntry entry = findEntry(namespace, RegistryResourceType.STANDARD, standardId) + .orElseThrow(StandardNotFoundException::new); try { - Path relativeFilePath = entry.filePath(); - // If this is a JSON file, check for a sibling .md file and prefer it - if (relativeFilePath.toString().endsWith(".json")) { - String baseName = relativeFilePath.getFileName().toString() - .replaceAll("\\.(guideline|standard|calm)\\.json$", "") - .replace(".json", ""); - Path relativeMdSibling = relativeFilePath.resolveSibling(baseName + ".md"); - if (fileReader.existsContained(namespace, relativeMdSibling)) { - return fileReader.readContained(namespace, relativeMdSibling); - } - } - return fileReader.readContained(namespace, relativeFilePath); + return readAtVersion(namespace, entry, version, preferMarkdownSibling(namespace, entry.filePath())) + .orElseThrow(StandardVersionNotFoundException::new); } catch (IOException e) { LOG.error("Failed to read standard file: {}", entry.filePath(), e); throw new StandardVersionNotFoundException(); } } + // Standards render better as prose: when the JSON entry has a same-named .md sibling, + // the local-HEAD read prefers it - but only for the local read. A pinned-SHA fetch via + // the GitHub API (in readAtVersion's other branch) always targets entry.filePath() + // regardless, since the API request is keyed on the JSON file's own version history, + // not the sibling's. + private Path preferMarkdownSibling(String namespace, Path relativeFilePath) { + if (!relativeFilePath.toString().endsWith(".json")) { + return relativeFilePath; + } + String baseName = relativeFilePath.getFileName().toString() + .replaceAll("\\.(guideline|standard|calm)\\.json$", "") + .replace(".json", ""); + Path relativeMdSibling = relativeFilePath.resolveSibling(baseName + ".md"); + return fileReader.existsContained(namespace, relativeMdSibling) ? relativeMdSibling : relativeFilePath; + } + @Override public Standard createStandardForVersion(CreateStandardRequest standardRequest, String namespace, Integer standardId, String version) throws NamespaceNotFoundException, StandardNotFoundException, StandardVersionExistsException { throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); @@ -124,21 +99,4 @@ public Standard createStandardForVersion(CreateStandardRequest standardRequest, public void deleteStandard(String namespace, Integer standardId) throws NamespaceNotFoundException, StandardNotFoundException { throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); } - - private RegistryEntry findEntryById(String namespace, int id) throws StandardNotFoundException { - List entries = registryService.listByType(namespace, RegistryResourceType.STANDARD); - Optional found = entries.stream() - .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) - .findFirst(); - if (found.isEmpty()) { - throw new StandardNotFoundException(); - } - return found.get(); - } - - private void verifyNamespace(String namespace) throws NamespaceNotFoundException { - if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { - throw new NamespaceNotFoundException(); - } - } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java index 6d4a782be9..9e1896c7d0 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java @@ -24,31 +24,17 @@ import java.io.IOException; import java.util.List; -import java.util.Optional; @ApplicationScoped @Typed(GitHubTimelineStore.class) -public class GitHubTimelineStore implements TimelineStore { - - private static final String WRITE_UNSUPPORTED = - "Write operations are not yet available. GitHub account linking and PR creation will be enabled in a future release."; +public class GitHubTimelineStore extends AbstractReadOnlyGitHubStore implements TimelineStore { private static final Logger LOG = LoggerFactory.getLogger(GitHubTimelineStore.class); - private final ResourceRegistry registryService; - - @Inject - GitHubCloneManager cloneManager; - - @Inject - GitHubFileHistoryClient versionService; - - @Inject - NamespaceFileReader fileReader; - @Inject - public GitHubTimelineStore(ResourceRegistry registryService) { - this.registryService = registryService; + public GitHubTimelineStore(ResourceRegistry registryService, GitHubCloneManager cloneManager, + GitHubFileHistoryClient versionService, NamespaceFileReader fileReader) { + super(registryService, cloneManager, versionService, fileReader); } @Override @@ -68,36 +54,19 @@ public Timeline createTimelineForNamespace(CreateTimelineRequest timelineRequest @Override public List getTimelineVersions(Timeline timeline) throws NamespaceNotFoundException, TimelineNotFoundException { verifyNamespace(timeline.getNamespace()); - RegistryEntry entry = findEntryById(timeline.getNamespace(), timeline.getId()); - String repo = cloneManager != null ? cloneManager.getRepoForNamespace(timeline.getNamespace()) : null; - String branch = cloneManager != null ? cloneManager.getBranchForNamespace(timeline.getNamespace()) : null; - if (repo != null && branch != null && versionService != null) { - return versionService.getFileVersions(repo, branch, entry.filePath().toString()); - } - return List.of("latest"); + RegistryEntry entry = findEntry(timeline.getNamespace(), RegistryResourceType.TIMELINE, timeline.getId()) + .orElseThrow(TimelineNotFoundException::new); + return getVersions(timeline.getNamespace(), entry); } @Override public String getTimelineForVersion(Timeline timeline) throws NamespaceNotFoundException, TimelineNotFoundException, TimelineVersionNotFoundException { verifyNamespace(timeline.getNamespace()); - RegistryEntry entry = findEntryById(timeline.getNamespace(), timeline.getId()); - String version = timeline.getDotVersion(); - - // If a specific SHA is requested and version service is available, fetch from GitHub API - if (version != null && !version.equals("latest") && version.matches("[0-9a-f]{7,40}") - && cloneManager != null && versionService != null) { - String repo = cloneManager.getRepoForNamespace(timeline.getNamespace()); - if (repo != null) { - String content = versionService.getFileAtVersion(repo, entry.filePath().toString(), version); - if (content != null) { - return content; - } - } - } - - // Fallback: read from local clone (latest/HEAD) + RegistryEntry entry = findEntry(timeline.getNamespace(), RegistryResourceType.TIMELINE, timeline.getId()) + .orElseThrow(TimelineNotFoundException::new); try { - return fileReader.readContained(timeline.getNamespace(), entry.filePath()); + return readAtVersion(timeline.getNamespace(), entry, timeline.getDotVersion()) + .orElseThrow(TimelineVersionNotFoundException::new); } catch (IOException e) { LOG.error("Failed to read timeline file: {}", entry.filePath(), e); throw new TimelineVersionNotFoundException(); @@ -118,21 +87,4 @@ public Timeline updateTimelineForVersion(Timeline timeline) throws NamespaceNotF public void deleteTimeline(String namespace, int timelineId) throws NamespaceNotFoundException, TimelineNotFoundException { throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); } - - private RegistryEntry findEntryById(String namespace, int id) throws TimelineNotFoundException { - List entries = registryService.listByType(namespace, RegistryResourceType.TIMELINE); - Optional found = entries.stream() - .filter(e -> (e.uniqueId().hashCode() & 0x7FFFFFFF) == id) - .findFirst(); - if (found.isEmpty()) { - throw new TimelineNotFoundException(); - } - return found.get(); - } - - private void verifyNamespace(String namespace) throws NamespaceNotFoundException { - if (!registryService.getSnapshot().getNamespaces().contains(namespace)) { - throw new NamespaceNotFoundException(); - } - } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java index fa4f2a24bd..677116dd33 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubUserAccessStore.java @@ -39,17 +39,19 @@ public class GitHubUserAccessStore implements UserAccessStore { private static final String WRITE_UNSUPPORTED = "Access grants cannot be created in GitHub mode. Assign users to the appropriate IdP roles instead."; - @Inject - ResourceRegistry registryService; - - @Inject - OidcRoleResolver roleResolver; + private final ResourceRegistry registryService; + private final OidcRoleResolver roleResolver; + private final SecurityIdentity identity; + private final GitHubCloneManager cloneManager; @Inject - SecurityIdentity identity; - - @Inject - GitHubCloneManager cloneManager; + public GitHubUserAccessStore(ResourceRegistry registryService, OidcRoleResolver roleResolver, + SecurityIdentity identity, GitHubCloneManager cloneManager) { + this.registryService = registryService; + this.roleResolver = roleResolver; + this.identity = identity; + this.cloneManager = cloneManager; + } @Override public List getGrantsForUser(String username) { @@ -69,7 +71,7 @@ public List getGrantsForUser(String username) { Set accessibleDomains = new HashSet<>(); for (String namespace : namespaces) { - Set accessGroups = cloneManager != null ? cloneManager.getAccessGroupsForNamespace(namespace) : Set.of(); + Set accessGroups = cloneManager.getAccessGroupsForNamespace(namespace); OidcRoleResolver.AccessLevel level = roleResolver.resolve(identity, accessGroups); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestAbstractReadOnlyGitHubStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestAbstractReadOnlyGitHubStoreShould.java new file mode 100644 index 0000000000..7041f72ac8 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestAbstractReadOnlyGitHubStoreShould.java @@ -0,0 +1,215 @@ +package org.finos.calm.store.github; + +import org.finos.calm.store.github.access.NamespaceFileReader; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.sync.GitHubCloneManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestAbstractReadOnlyGitHubStoreShould { + + private static final String NAMESPACE = "finos"; + private static final RegistryEntry ENTRY = new RegistryEntry( + "my-pattern", Path.of("patterns/my-pattern.json"), RegistryResourceType.PATTERN, "My Pattern", Instant.now()); + + @Mock + private ResourceRegistry registryService; + + @Mock + private GitHubCloneManager cloneManager; + + @Mock + private GitHubFileHistoryClient versionService; + + @Mock + private NamespaceFileReader fileReader; + + // A minimal concrete subclass - AbstractReadOnlyGitHubStore is package-private and + // abstract, so this is the only way to exercise its shared logic directly rather than + // only indirectly through every store that extends it. + private static final class TestStore extends AbstractReadOnlyGitHubStore { + TestStore(ResourceRegistry registryService, GitHubCloneManager cloneManager, + GitHubFileHistoryClient versionService, NamespaceFileReader fileReader) { + super(registryService, cloneManager, versionService, fileReader); + } + + // Exercises the no-arg CDI proxy constructor directly - see + // AbstractGitHubStore.AbstractGitHubStore() for why it exists. + TestStore() { + super(); + } + } + + private TestStore store; + + @BeforeEach + void setup() { + store = new TestStore(registryService, cloneManager, versionService, fileReader); + } + + @Test + void leave_every_collaborator_null_when_built_through_the_cdi_proxy_constructor() { + TestStore proxyShell = new TestStore(); + + assertThat(proxyShell.registryService, is(nullValue())); + assertThat(proxyShell.cloneManager, is(nullValue())); + assertThat(proxyShell.versionService, is(nullValue())); + assertThat(proxyShell.fileReader, is(nullValue())); + } + + @Test + void find_an_entry_by_its_hashed_id() { + int id = "my-pattern".hashCode() & 0x7FFFFFFF; + when(registryService.listByType(NAMESPACE, RegistryResourceType.PATTERN)).thenReturn(List.of(ENTRY)); + + Optional found = store.findEntry(NAMESPACE, RegistryResourceType.PATTERN, id); + + assertThat(found.isPresent(), is(true)); + assertThat(found.get(), equalTo(ENTRY)); + } + + @Test + void return_empty_when_no_entry_matches_the_id() { + when(registryService.listByType(NAMESPACE, RegistryResourceType.PATTERN)).thenReturn(List.of(ENTRY)); + + Optional found = store.findEntry(NAMESPACE, RegistryResourceType.PATTERN, 99999); + + assertThat(found.isPresent(), is(false)); + } + + @Test + void return_versions_from_the_api_when_available() { + when(cloneManager.getRepoForNamespace(NAMESPACE)).thenReturn("org/repo"); + when(cloneManager.getBranchForNamespace(NAMESPACE)).thenReturn("main"); + when(versionService.getFileVersions("org/repo", "main", "patterns/my-pattern.json")) + .thenReturn(List.of("abc1234", "def5678")); + + List versions = store.getVersions(NAMESPACE, ENTRY); + + assertThat(versions, contains("abc1234", "def5678")); + } + + @Test + void fall_back_to_the_local_head_sha_when_the_api_returns_no_versions() { + when(cloneManager.getRepoForNamespace(NAMESPACE)).thenReturn("org/repo"); + when(cloneManager.getBranchForNamespace(NAMESPACE)).thenReturn("main"); + when(versionService.getFileVersions("org/repo", "main", "patterns/my-pattern.json")).thenReturn(List.of()); + when(cloneManager.headSha(NAMESPACE)).thenReturn("1234567"); + + List versions = store.getVersions(NAMESPACE, ENTRY); + + assertThat(versions, contains("1234567")); + } + + @Test + void return_an_empty_version_list_when_neither_the_api_nor_the_local_clone_have_anything() { + when(cloneManager.getRepoForNamespace(NAMESPACE)).thenReturn(null); + when(cloneManager.getBranchForNamespace(NAMESPACE)).thenReturn(null); + when(cloneManager.headSha(NAMESPACE)).thenReturn(null); + + List versions = store.getVersions(NAMESPACE, ENTRY); + + assertThat(versions, is(empty())); + } + + @Test + void return_empty_when_the_requested_version_is_not_sha_shaped() throws Exception { + Optional content = store.readAtVersion(NAMESPACE, ENTRY, "1.0.0"); + + assertThat(content.isPresent(), is(false)); + } + + @Test + void return_empty_for_a_null_version() throws Exception { + Optional content = store.readAtVersion(NAMESPACE, ENTRY, null); + + assertThat(content.isPresent(), is(false)); + } + + @Test + void read_from_the_local_clone_when_the_requested_version_is_the_current_head() throws Exception { + when(cloneManager.headSha(NAMESPACE)).thenReturn("abc1234"); + when(fileReader.readContained(NAMESPACE, ENTRY.filePath())).thenReturn("{\"content\":\"local\"}"); + + Optional content = store.readAtVersion(NAMESPACE, ENTRY, "abc1234"); + + assertThat(content, equalTo(Optional.of("{\"content\":\"local\"}"))); + } + + @Test + void fetch_from_the_api_when_the_requested_version_is_not_the_current_head() throws Exception { + when(cloneManager.headSha(NAMESPACE)).thenReturn("1111111"); + when(cloneManager.getRepoForNamespace(NAMESPACE)).thenReturn("org/repo"); + when(versionService.getFileAtVersion("org/repo", "patterns/my-pattern.json", "2222222")) + .thenReturn("{\"content\":\"old\"}"); + + Optional content = store.readAtVersion(NAMESPACE, ENTRY, "2222222"); + + assertThat(content, equalTo(Optional.of("{\"content\":\"old\"}"))); + } + + @Test + void return_empty_when_the_repo_cannot_be_resolved_for_an_api_fetch() throws Exception { + when(cloneManager.headSha(NAMESPACE)).thenReturn("1111111"); + when(cloneManager.getRepoForNamespace(NAMESPACE)).thenReturn(null); + + Optional content = store.readAtVersion(NAMESPACE, ENTRY, "2222222"); + + assertThat(content.isPresent(), is(false)); + } + + @Test + void return_empty_when_the_api_cannot_resolve_the_requested_sha() throws Exception { + when(cloneManager.headSha(NAMESPACE)).thenReturn("1111111"); + when(cloneManager.getRepoForNamespace(NAMESPACE)).thenReturn("org/repo"); + when(versionService.getFileAtVersion("org/repo", "patterns/my-pattern.json", "2222222")).thenReturn(null); + + Optional content = store.readAtVersion(NAMESPACE, ENTRY, "2222222"); + + assertThat(content.isPresent(), is(false)); + } + + @Test + void read_a_local_override_path_when_the_requested_version_is_the_current_head() throws Exception { + Path mdSibling = Path.of("patterns/my-pattern.md"); + when(cloneManager.headSha(NAMESPACE)).thenReturn("abc1234"); + when(fileReader.readContained(NAMESPACE, mdSibling)).thenReturn("# Markdown sibling"); + + Optional content = store.readAtVersion(NAMESPACE, ENTRY, "abc1234", mdSibling); + + assertThat(content, equalTo(Optional.of("# Markdown sibling"))); + } + + @Test + void still_target_the_entry_path_for_an_api_fetch_even_with_a_local_override() throws Exception { + Path mdSibling = Path.of("patterns/my-pattern.md"); + when(cloneManager.headSha(NAMESPACE)).thenReturn("1111111"); + when(cloneManager.getRepoForNamespace(NAMESPACE)).thenReturn("org/repo"); + when(versionService.getFileAtVersion("org/repo", "patterns/my-pattern.json", "2222222")) + .thenReturn("{\"content\":\"old\"}"); + + Optional content = store.readAtVersion(NAMESPACE, ENTRY, "2222222", mdSibling); + + assertThat(content, equalTo(Optional.of("{\"content\":\"old\"}"))); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java index d33f7926c4..bdfe9b6697 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java @@ -2,21 +2,28 @@ import org.finos.calm.domain.Architecture; import org.finos.calm.domain.exception.ArchitectureNotFoundException; +import org.finos.calm.domain.exception.ArchitectureVersionNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.store.PageRequest; import org.finos.calm.store.github.access.NamespaceFileReader; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.config.GitHubStoreConfig; -import org.finos.calm.store.github.registry.RegistryResourceType; -import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.registry.RegistrySnapshot; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.util.List; @@ -30,17 +37,27 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; +@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubArchitectureStoreShould { @Mock private ResourceRegistry registryService; + @Mock + private GitHubCloneManager cloneManager; + + @Mock + private GitHubFileHistoryClient versionService; + + @Mock + private NamespaceFileReader fileReader; + private GitHubArchitectureStore store; @BeforeEach void setup() { - store = new GitHubArchitectureStore(registryService); + store = new GitHubArchitectureStore(registryService, cloneManager, versionService, fileReader); } @Test @@ -95,90 +112,100 @@ void throw_namespace_not_found_on_get_architecture_for_version() { } @Test - void return_versions_list_for_existing_architecture() throws Exception { - RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), - RegistryResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + void return_empty_versions_when_neither_the_api_nor_the_local_clone_have_anything() throws Exception { + RegistryEntry entry = new RegistryEntry("test-arch", Path.of("architectures/test.json"), + RegistryResourceType.ARCHITECTURE, "Test", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:test-arch", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(List.of(entry)); + + int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); + Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(hashId).build(); + List versions = store.getArchitectureVersions(arch); + + assertThat(versions, is(empty())); + } + + @Test + void fall_back_to_the_local_head_sha_when_the_api_returns_no_versions() throws Exception { + RegistryEntry entry = new RegistryEntry("test-arch", Path.of("architectures/test.json"), + RegistryResourceType.ARCHITECTURE, "Test", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-arch", entry)); + Map.of("finos", List.of(entry)), + Map.of("finos:test-arch", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("1234567"); int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(hashId).build(); - java.util.List versions = store.getArchitectureVersions(arch); + List versions = store.getArchitectureVersions(arch); assertThat(versions, hasSize(1)); - assertThat(versions.get(0), equalTo("latest")); + assertThat(versions.get(0), equalTo("1234567")); } @Test void return_sha_versions_when_version_service_available() throws Exception { - RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), - RegistryResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistryEntry entry = new RegistryEntry("test-arch", Path.of("architectures/test.json"), + RegistryResourceType.ARCHITECTURE, "Test", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-arch", entry)); + Map.of("finos", List.of(entry)), + Map.of("finos:test-arch", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); - - org.finos.calm.store.github.sync.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.sync.GitHubCloneManager.class); - org.finos.calm.store.github.api.GitHubFileHistoryClient mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.api.GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(List.of(entry)); - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); - when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); - when(mockVersionService.getFileVersions("finos/architecture-as-code", "main", "architectures/test.json")) - .thenReturn(java.util.List.of("abc1234", "def5678")); + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); + when(cloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(versionService.getFileVersions("finos/architecture-as-code", "main", "architectures/test.json")) + .thenReturn(List.of("abc1234", "def5678")); int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(hashId).build(); - java.util.List versions = store.getArchitectureVersions(arch); + List versions = store.getArchitectureVersions(arch); assertThat(versions, hasSize(2)); assertThat(versions.get(0), equalTo("abc1234")); } @Test - void return_architecture_content_for_version(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tempDir) throws Exception { - java.nio.file.Path archDir = tempDir.resolve("finos/architectures"); - java.nio.file.Files.createDirectories(archDir); - java.nio.file.Files.writeString(archDir.resolve("test.json"), "{\"nodes\":[],\"relationships\":[]}"); + void return_architecture_content_for_the_current_head_version(@TempDir Path tempDir) throws Exception { + Path archDir = tempDir.resolve("finos/architectures"); + Files.createDirectories(archDir); + Files.writeString(archDir.resolve("test.json"), "{\"nodes\":[],\"relationships\":[]}"); - RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), - RegistryResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistryEntry entry = new RegistryEntry("test-arch", Path.of("architectures/test.json"), + RegistryResourceType.ARCHITECTURE, "Test", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-arch", entry)); + Map.of("finos", List.of(entry)), + Map.of("finos:test-arch", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); - store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); + GitHubArchitectureStore realFileReaderStore = new GitHubArchitectureStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); - Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); + Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(hashId).setVersion("abc1234").build(); - String content = store.getArchitectureForVersion(arch); + String content = realFileReaderStore.getArchitectureForVersion(arch); assertThat(content, equalTo("{\"nodes\":[],\"relationships\":[]}")); } @Test void return_content_from_github_api_for_sha_version() throws Exception { - RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), - RegistryResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistryEntry entry = new RegistryEntry("test-arch", Path.of("architectures/test.json"), + RegistryResourceType.ARCHITECTURE, "Test", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-arch", entry)); + Map.of("finos", List.of(entry)), + Map.of("finos:test-arch", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); - - org.finos.calm.store.github.sync.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.sync.GitHubCloneManager.class); - org.finos.calm.store.github.api.GitHubFileHistoryClient mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.api.GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(List.of(entry)); - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); - when(mockVersionService.getFileAtVersion("finos/repo", "architectures/test.json", "abc1234")) + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(versionService.getFileAtVersion("finos/repo", "architectures/test.json", "abc1234")) .thenReturn("{\"nodes\":[{\"name\":\"old\"}]}"); int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); @@ -189,15 +216,50 @@ void return_content_from_github_api_for_sha_version() throws Exception { assertThat(content, equalTo("{\"nodes\":[{\"name\":\"old\"}]}")); } + @Test + void throw_version_not_found_when_the_requested_version_is_not_sha_shaped() { + RegistryEntry entry = new RegistryEntry("test-arch", Path.of("architectures/test.json"), + RegistryResourceType.ARCHITECTURE, "Test", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:test-arch", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(List.of(entry)); + + int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); + Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); + + assertThrows(ArchitectureVersionNotFoundException.class, () -> store.getArchitectureForVersion(arch)); + } + + @Test + void throw_version_not_found_when_the_local_file_is_missing(@TempDir Path tempDir) throws Exception { + RegistryEntry entry = new RegistryEntry("test-arch", Path.of("architectures/nonexistent.json"), + RegistryResourceType.ARCHITECTURE, "Test", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:test-arch", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); + + GitHubArchitectureStore realFileReaderStore = new GitHubArchitectureStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); + int hashId = ("test-arch".hashCode() & 0x7FFFFFFF); + Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(hashId).setVersion("abc1234").build(); + + assertThrows(ArchitectureVersionNotFoundException.class, () -> realFileReaderStore.getArchitectureForVersion(arch)); + } + @Test void throw_architecture_not_found_when_id_does_not_match() { - RegistryEntry entry = new RegistryEntry("test-arch", java.nio.file.Path.of("architectures/test.json"), - RegistryResourceType.ARCHITECTURE, "Test", java.time.Instant.now()); + RegistryEntry entry = new RegistryEntry("test-arch", Path.of("architectures/test.json"), + RegistryResourceType.ARCHITECTURE, "Test", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-arch", entry)); + Map.of("finos", List.of(entry)), + Map.of("finos:test-arch", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.ARCHITECTURE)).thenReturn(List.of(entry)); Architecture arch = new Architecture.ArchitectureBuilder().setNamespace("finos").setId(99999).build(); assertThrows(ArchitectureNotFoundException.class, () -> store.getArchitectureVersions(arch)); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index 43928a74f9..19c1cc6312 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -6,21 +6,23 @@ import org.finos.calm.domain.exception.ControlNotFoundException; import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; import org.finos.calm.domain.exception.DomainNotFoundException; -import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.access.NamespaceAccessFilter; import org.finos.calm.store.github.access.NamespaceFileReader; -import org.finos.calm.store.github.config.GitHubStoreConfig; -import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.api.GitHubFileHistoryClient; -import org.finos.calm.store.github.registry.ResourceRegistry; -import org.finos.calm.store.github.access.NamespaceAccessFilter; +import org.finos.calm.store.github.config.GitHubStoreConfig; import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.registry.RegistrySnapshot; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import java.nio.file.Files; import java.nio.file.Path; @@ -30,12 +32,14 @@ import java.util.Set; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubControlStoreShould { @@ -46,6 +50,15 @@ class TestGitHubControlStoreShould { @Mock private ResourceRegistry registryService; + @Mock + private GitHubCloneManager cloneManager; + + @Mock + private GitHubFileHistoryClient versionService; + + @Mock + private NamespaceFileReader fileReader; + @Mock private NamespaceAccessFilter accessFilter; @@ -53,8 +66,7 @@ class TestGitHubControlStoreShould { @BeforeEach void setup() { - store = new GitHubControlStore(registryService); - store.accessFilter = accessFilter; + store = new GitHubControlStore(registryService, cloneManager, versionService, fileReader, accessFilter); } @Test @@ -89,23 +101,6 @@ void throw_domain_not_found_when_domain_missing_on_get_controls() { () -> store.getControlsForDomain("nonexistent")); } - @Test - void return_all_controls_when_access_filter_is_null() throws Exception { - GitHubControlStore unfilteredStore = new GitHubControlStore(registryService); - RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), - RegistryResourceType.CONTROL, "My Control", Instant.now()); - RegistrySnapshot snapshot = new RegistrySnapshot( - Map.of("finos", List.of(entry)), - Map.of("finos:" + UNIQUE_ID, entry)); - when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); - - List result = unfilteredStore.getControlsForDomain(DOMAIN); - - assertThat(result, hasSize(1)); - assertThat(result.get(0).getName(), equalTo(UNIQUE_ID)); - } - @Test void hide_controls_from_inaccessible_namespaces() throws Exception { RegistryEntry accessibleEntry = new RegistryEntry("ctrl-a", Path.of("controls/security/ctrl-a.json"), @@ -154,15 +149,16 @@ void return_versions_for_control_in_accessible_namespace_with_mixed_access() thr when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(accessible)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + when(cloneManager.headSha("finos")).thenReturn("1234567"); List versions = store.getRequirementVersions(DOMAIN, HASH_ID); assertThat(versions, hasSize(1)); - assertThat(versions.get(0), equalTo("latest")); + assertThat(versions.get(0), equalTo("1234567")); } @Test - void return_versions_list_for_existing_control() throws Exception { + void return_empty_versions_when_neither_the_api_nor_the_local_clone_have_anything() throws Exception { RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( @@ -174,8 +170,25 @@ void return_versions_list_for_existing_control() throws Exception { List versions = store.getRequirementVersions(DOMAIN, HASH_ID); + assertThat(versions, is(empty())); + } + + @Test + void fall_back_to_the_local_head_sha_when_the_api_returns_no_versions() throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + RegistryResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + when(cloneManager.headSha("finos")).thenReturn("1234567"); + + List versions = store.getRequirementVersions(DOMAIN, HASH_ID); + assertThat(versions, hasSize(1)); - assertThat(versions.get(0), equalTo("latest")); + assertThat(versions.get(0), equalTo("1234567")); } @Test @@ -189,14 +202,9 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); - GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); - GitHubFileHistoryClient mockVersionService = mock(GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; - - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("org/repo"); - when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); - when(mockVersionService.getFileVersions("org/repo", "main", "controls/security/my-control.json")) + when(cloneManager.getRepoForNamespace("finos")).thenReturn("org/repo"); + when(cloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(versionService.getFileVersions("org/repo", "main", "controls/security/my-control.json")) .thenReturn(List.of("abc1234", "def5678")); List versions = store.getRequirementVersions(DOMAIN, HASH_ID); @@ -206,7 +214,7 @@ void return_sha_versions_when_version_service_available() throws Exception { } @Test - void return_control_content_for_version(@TempDir Path tempDir) throws Exception { + void return_control_content_for_the_current_head_version(@TempDir Path tempDir) throws Exception { Path controlDir = tempDir.resolve("finos/controls/security"); Files.createDirectories(controlDir); Files.writeString(controlDir.resolve("my-control.json"), "{\"control\":\"data\"}"); @@ -219,9 +227,12 @@ void return_control_content_for_version(@TempDir Path tempDir) throws Exception when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); - store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); - String content = store.getRequirementForVersion(DOMAIN, HASH_ID, "1.0.0"); + GitHubControlStore realFileReaderStore = new GitHubControlStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")), + accessFilter); + String content = realFileReaderStore.getRequirementForVersion(DOMAIN, HASH_ID, "abc1234"); assertThat(content, equalTo("{\"control\":\"data\"}")); } @@ -237,13 +248,8 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); - GitHubCloneManager mockCloneManager = mock(GitHubCloneManager.class); - GitHubFileHistoryClient mockVersionService = mock(GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; - - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("org/repo"); - when(mockVersionService.getFileAtVersion("org/repo", "controls/security/my-control.json", "abc1234")) + when(cloneManager.getRepoForNamespace("finos")).thenReturn("org/repo"); + when(versionService.getFileAtVersion("org/repo", "controls/security/my-control.json", "abc1234")) .thenReturn("{\"control\":\"old-data\"}"); String content = store.getRequirementForVersion(DOMAIN, HASH_ID, "abc1234"); @@ -281,11 +287,26 @@ void throw_domain_not_found_on_get_requirement_for_version() { when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of()); assertThrows(DomainNotFoundException.class, - () -> store.getRequirementForVersion("nonexistent", 1, "1.0.0")); + () -> store.getRequirementForVersion("nonexistent", 1, "abc1234")); } @Test - void throw_requirement_version_not_found_when_file_missing(@TempDir Path tempDir) throws Exception { + void throw_version_not_found_when_the_requested_version_is_not_sha_shaped() { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + RegistryResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + assertThrows(ControlRequirementVersionNotFoundException.class, + () -> store.getRequirementForVersion(DOMAIN, HASH_ID, "1.0.0")); + } + + @Test + void throw_requirement_version_not_found_when_file_missing() throws Exception { RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/nonexistent.json"), RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( @@ -295,10 +316,12 @@ void throw_requirement_version_not_found_when_file_missing(@TempDir Path tempDir when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); - store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); + when(cloneManager.getRepoForNamespace("finos")).thenReturn("org/repo"); + when(versionService.getFileAtVersion("org/repo", "controls/security/nonexistent.json", "abc1234")) + .thenReturn(null); assertThrows(ControlRequirementVersionNotFoundException.class, - () -> store.getRequirementForVersion(DOMAIN, HASH_ID, "1.0.0")); + () -> store.getRequirementForVersion(DOMAIN, HASH_ID, "abc1234")); } @Test diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java index b6c591d6f6..76b7e18110 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubDomainStoreShould.java @@ -37,8 +37,7 @@ class TestGitHubDomainStoreShould { @BeforeEach void setup() { - store = new GitHubDomainStore(registryService); - store.accessFilter = accessFilter; + store = new GitHubDomainStore(registryService, accessFilter); } @Test diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java index fd3e468d91..a4258176b8 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java @@ -19,8 +19,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import java.nio.file.Files; import java.nio.file.Path; @@ -29,22 +30,34 @@ import java.util.Map; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; +@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubFlowStoreShould { @Mock private ResourceRegistry registryService; + @Mock + private GitHubCloneManager cloneManager; + + @Mock + private GitHubFileHistoryClient versionService; + + @Mock + private NamespaceFileReader fileReader; + private GitHubFlowStore store; @BeforeEach void setup() { - store = new GitHubFlowStore(registryService); + store = new GitHubFlowStore(registryService, cloneManager, versionService, fileReader); } @Test @@ -99,7 +112,7 @@ void throw_namespace_not_found_on_get_flow_for_version() { } @Test - void return_versions_list_for_existing_flow() throws Exception { + void return_empty_versions_when_neither_the_api_nor_the_local_clone_have_anything() throws Exception { RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), RegistryResourceType.FLOW, "Payment Flow", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( @@ -112,8 +125,26 @@ void return_versions_list_for_existing_flow() throws Exception { Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(hashId).build(); List versions = store.getFlowVersions(flow); + assertThat(versions, is(empty())); + } + + @Test + void fall_back_to_the_local_head_sha_when_the_api_returns_no_versions() throws Exception { + RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), + RegistryResourceType.FLOW, "Payment Flow", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-flow", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("1234567"); + + int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); + Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(hashId).build(); + List versions = store.getFlowVersions(flow); + assertThat(versions, hasSize(1)); - assertThat(versions.get(0), equalTo("latest")); + assertThat(versions.get(0), equalTo("1234567")); } @Test @@ -126,14 +157,9 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); - GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; - - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); - when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); - when(mockVersionService.getFileVersions("finos/architecture-as-code", "main", "flows/payment-flow.json")) + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); + when(cloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(versionService.getFileVersions("finos/architecture-as-code", "main", "flows/payment-flow.json")) .thenReturn(List.of("abc1234", "def5678")); int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); @@ -145,7 +171,7 @@ void return_sha_versions_when_version_service_available() throws Exception { } @Test - void return_flow_content_for_version(@TempDir Path tempDir) throws Exception { + void return_flow_content_for_the_current_head_version(@TempDir Path tempDir) throws Exception { Path flowDir = tempDir.resolve("finos/flows"); Files.createDirectories(flowDir); Files.writeString(flowDir.resolve("payment-flow.json"), "{\"steps\":[]}"); @@ -157,12 +183,14 @@ void return_flow_content_for_version(@TempDir Path tempDir) throws Exception { Map.of("finos:payment-flow", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); - store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); + GitHubFlowStore realFileReaderStore = new GitHubFlowStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); - Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); + Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(hashId).setVersion("abc1234").build(); - String content = store.getFlowForVersion(flow); + String content = realFileReaderStore.getFlowForVersion(flow); assertThat(content, equalTo("{\"steps\":[]}")); } @@ -176,13 +204,8 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); - GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; - - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); - when(mockVersionService.getFileAtVersion("finos/repo", "flows/payment-flow.json", "abc1234")) + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(versionService.getFileAtVersion("finos/repo", "flows/payment-flow.json", "abc1234")) .thenReturn("{\"steps\":[{\"name\":\"old\"}]}"); int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); @@ -192,6 +215,41 @@ void return_content_from_github_api_for_sha_version() throws Exception { assertThat(content, equalTo("{\"steps\":[{\"name\":\"old\"}]}")); } + @Test + void throw_version_not_found_when_the_requested_version_is_not_sha_shaped() { + RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), + RegistryResourceType.FLOW, "Payment Flow", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-flow", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); + + int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); + Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); + + assertThrows(FlowVersionNotFoundException.class, () -> store.getFlowForVersion(flow)); + } + + @Test + void throw_version_not_found_when_the_local_file_is_missing(@TempDir Path tempDir) throws Exception { + RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/nonexistent.json"), + RegistryResourceType.FLOW, "Payment Flow", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-flow", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.FLOW)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); + + GitHubFlowStore realFileReaderStore = new GitHubFlowStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); + int hashId = ("payment-flow".hashCode() & 0x7FFFFFFF); + Flow flow = new Flow.FlowBuilder().setNamespace("finos").setId(hashId).setVersion("abc1234").build(); + + assertThrows(FlowVersionNotFoundException.class, () -> realFileReaderStore.getFlowForVersion(flow)); + } + @Test void throw_flow_not_found_when_id_does_not_match() { RegistryEntry entry = new RegistryEntry("payment-flow", Path.of("flows/payment-flow.json"), diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java index aa49bdbb44..4bd7180fc3 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java @@ -18,8 +18,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import java.nio.file.Files; import java.nio.file.Path; @@ -35,17 +36,27 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; +@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubInterfaceStoreShould { @Mock private ResourceRegistry registryService; + @Mock + private GitHubCloneManager cloneManager; + + @Mock + private GitHubFileHistoryClient versionService; + + @Mock + private NamespaceFileReader fileReader; + private GitHubInterfaceStore store; @BeforeEach void setup() { - store = new GitHubInterfaceStore(registryService); + store = new GitHubInterfaceStore(registryService, cloneManager, versionService, fileReader); } @Test @@ -113,7 +124,23 @@ void throw_namespace_not_found_on_get_interface_for_version() { } @Test - void return_versions_list_for_existing_interface() throws Exception { + void return_empty_versions_when_neither_the_api_nor_the_local_clone_have_anything() throws Exception { + RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), + RegistryResourceType.INTERFACE, "Payment API", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-api", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); + + int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); + List versions = store.getInterfaceVersions("finos", hashId); + + assertThat(versions, is(empty())); + } + + @Test + void fall_back_to_the_local_head_sha_when_the_api_returns_no_versions() throws Exception { RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), RegistryResourceType.INTERFACE, "Payment API", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( @@ -121,12 +148,13 @@ void return_versions_list_for_existing_interface() throws Exception { Map.of("finos:payment-api", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("1234567"); int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); List versions = store.getInterfaceVersions("finos", hashId); assertThat(versions, hasSize(1)); - assertThat(versions.get(0), equalTo("latest")); + assertThat(versions.get(0), equalTo("1234567")); } @Test @@ -139,14 +167,9 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); - GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; - - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); - when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); - when(mockVersionService.getFileVersions("finos/architecture-as-code", "main", "interfaces/payment-api.json")) + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); + when(cloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(versionService.getFileVersions("finos/architecture-as-code", "main", "interfaces/payment-api.json")) .thenReturn(List.of("abc1234", "def5678")); int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); @@ -157,7 +180,7 @@ void return_sha_versions_when_version_service_available() throws Exception { } @Test - void return_interface_content_for_version(@TempDir Path tempDir) throws Exception { + void return_interface_content_for_the_current_head_version(@TempDir Path tempDir) throws Exception { Path ifaceDir = tempDir.resolve("finos/interfaces"); Files.createDirectories(ifaceDir); Files.writeString(ifaceDir.resolve("payment-api.json"), "{\"operations\":[]}"); @@ -169,11 +192,13 @@ void return_interface_content_for_version(@TempDir Path tempDir) throws Exceptio Map.of("finos:payment-api", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); - store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); + GitHubInterfaceStore realFileReaderStore = new GitHubInterfaceStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); - String content = store.getInterfaceForVersion("finos", hashId, "1.0.0"); + String content = realFileReaderStore.getInterfaceForVersion("finos", hashId, "abc1234"); assertThat(content, equalTo("{\"operations\":[]}")); } @@ -187,13 +212,8 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); - GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; - - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); - when(mockVersionService.getFileAtVersion("finos/repo", "interfaces/payment-api.json", "abc1234")) + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(versionService.getFileAtVersion("finos/repo", "interfaces/payment-api.json", "abc1234")) .thenReturn("{\"operations\":[{\"name\":\"old\"}]}"); int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); @@ -202,6 +222,41 @@ void return_content_from_github_api_for_sha_version() throws Exception { assertThat(content, equalTo("{\"operations\":[{\"name\":\"old\"}]}")); } + @Test + void throw_version_not_found_when_the_requested_version_is_not_sha_shaped() { + RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), + RegistryResourceType.INTERFACE, "Payment API", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-api", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); + + int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); + + assertThrows(InterfaceVersionNotFoundException.class, + () -> store.getInterfaceForVersion("finos", hashId, "1.0.0")); + } + + @Test + void throw_version_not_found_when_the_local_file_is_missing(@TempDir Path tempDir) throws Exception { + RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/nonexistent.json"), + RegistryResourceType.INTERFACE, "Payment API", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:payment-api", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.INTERFACE)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); + + GitHubInterfaceStore realFileReaderStore = new GitHubInterfaceStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); + int hashId = ("payment-api".hashCode() & 0x7FFFFFFF); + + assertThrows(InterfaceVersionNotFoundException.class, + () -> realFileReaderStore.getInterfaceForVersion("finos", hashId, "abc1234")); + } + @Test void throw_interface_not_found_when_id_does_not_match() { RegistryEntry entry = new RegistryEntry("payment-api", Path.of("interfaces/payment-api.json"), diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java index def7deef6b..d47cb95b78 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java @@ -20,8 +20,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import java.nio.file.Files; import java.nio.file.Path; @@ -30,22 +31,34 @@ import java.util.Map; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; +@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubPatternStoreShould { @Mock private ResourceRegistry registryService; + @Mock + private GitHubCloneManager cloneManager; + + @Mock + private GitHubFileHistoryClient versionService; + + @Mock + private NamespaceFileReader fileReader; + private GitHubPatternStore store; @BeforeEach void setup() { - store = new GitHubPatternStore(registryService); + store = new GitHubPatternStore(registryService, cloneManager, versionService, fileReader); } @Test @@ -112,7 +125,7 @@ void throw_namespace_not_found_on_get_pattern_for_version() { } @Test - void return_versions_list_for_existing_pattern() throws Exception { + void return_empty_versions_when_neither_the_api_nor_the_local_clone_have_anything() throws Exception { RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), RegistryResourceType.PATTERN, "Event Driven", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( @@ -125,8 +138,26 @@ void return_versions_list_for_existing_pattern() throws Exception { Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(hashId).build(); List versions = store.getPatternVersions(pattern); + assertThat(versions, is(empty())); + } + + @Test + void fall_back_to_the_local_head_sha_when_the_api_returns_no_versions() throws Exception { + RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), + RegistryResourceType.PATTERN, "Event Driven", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:event-driven", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("1234567"); + + int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); + Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(hashId).build(); + List versions = store.getPatternVersions(pattern); + assertThat(versions, hasSize(1)); - assertThat(versions.get(0), equalTo("latest")); + assertThat(versions.get(0), equalTo("1234567")); } @Test @@ -139,14 +170,9 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); - GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; - - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); - when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); - when(mockVersionService.getFileVersions("finos/architecture-as-code", "main", "patterns/event-driven.json")) + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); + when(cloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(versionService.getFileVersions("finos/architecture-as-code", "main", "patterns/event-driven.json")) .thenReturn(List.of("abc1234", "def5678")); int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); @@ -158,7 +184,7 @@ void return_sha_versions_when_version_service_available() throws Exception { } @Test - void return_pattern_content_for_version(@TempDir Path tempDir) throws Exception { + void return_pattern_content_for_the_current_head_version(@TempDir Path tempDir) throws Exception { Path patternDir = tempDir.resolve("finos/patterns"); Files.createDirectories(patternDir); Files.writeString(patternDir.resolve("event-driven.json"), "{\"nodes\":[],\"relationships\":[]}"); @@ -170,12 +196,14 @@ void return_pattern_content_for_version(@TempDir Path tempDir) throws Exception Map.of("finos:event-driven", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); - store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); + GitHubPatternStore realFileReaderStore = new GitHubPatternStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); - Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); + Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(hashId).setVersion("abc1234").build(); - String content = store.getPatternForVersion(pattern); + String content = realFileReaderStore.getPatternForVersion(pattern); assertThat(content, equalTo("{\"nodes\":[],\"relationships\":[]}")); } @@ -189,13 +217,8 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); - GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; - - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); - when(mockVersionService.getFileAtVersion("finos/repo", "patterns/event-driven.json", "abc1234")) + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(versionService.getFileAtVersion("finos/repo", "patterns/event-driven.json", "abc1234")) .thenReturn("{\"nodes\":[{\"name\":\"old\"}]}"); int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); @@ -205,6 +228,41 @@ void return_content_from_github_api_for_sha_version() throws Exception { assertThat(content, equalTo("{\"nodes\":[{\"name\":\"old\"}]}")); } + @Test + void throw_version_not_found_when_the_requested_version_is_not_sha_shaped() { + RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), + RegistryResourceType.PATTERN, "Event Driven", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:event-driven", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); + + int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); + Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); + + assertThrows(PatternVersionNotFoundException.class, () -> store.getPatternForVersion(pattern)); + } + + @Test + void throw_version_not_found_when_the_local_file_is_missing(@TempDir Path tempDir) throws Exception { + RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/nonexistent.json"), + RegistryResourceType.PATTERN, "Event Driven", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:event-driven", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.PATTERN)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); + + GitHubPatternStore realFileReaderStore = new GitHubPatternStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); + int hashId = ("event-driven".hashCode() & 0x7FFFFFFF); + Pattern pattern = new Pattern.PatternBuilder().setNamespace("finos").setId(hashId).setVersion("abc1234").build(); + + assertThrows(PatternVersionNotFoundException.class, () -> realFileReaderStore.getPatternForVersion(pattern)); + } + @Test void throw_pattern_not_found_when_id_does_not_match() { RegistryEntry entry = new RegistryEntry("event-driven", Path.of("patterns/event-driven.json"), diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java index e3c72c3cd6..c147cc9068 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java @@ -31,8 +31,7 @@ class TestGitHubSearchStoreShould { @BeforeEach void setup() { - store = new GitHubSearchStore(); - store.registryService = registryService; + store = new GitHubSearchStore(registryService); when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java index 4d46841fbb..746f6c6f48 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java @@ -1,42 +1,63 @@ package org.finos.calm.store.github; import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.StandardNotFoundException; +import org.finos.calm.domain.exception.StandardVersionNotFoundException; import org.finos.calm.domain.namespaces.NamespaceResourceSummary; import org.finos.calm.domain.standards.CreateStandardRequest; import org.finos.calm.store.github.access.NamespaceFileReader; +import org.finos.calm.store.github.api.GitHubFileHistoryClient; import org.finos.calm.store.github.config.GitHubStoreConfig; -import org.finos.calm.store.github.registry.RegistryResourceType; -import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.registry.RegistrySnapshot; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.sync.GitHubCloneManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.util.List; import java.util.Map; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; +@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubStandardStoreShould { @Mock private ResourceRegistry registryService; + @Mock + private GitHubCloneManager cloneManager; + + @Mock + private GitHubFileHistoryClient versionService; + + @Mock + private NamespaceFileReader fileReader; + private GitHubStandardStore store; @BeforeEach void setup() { - store = new GitHubStandardStore(registryService); + store = new GitHubStandardStore(registryService, cloneManager, versionService, fileReader); } @Test @@ -78,15 +99,15 @@ void throw_unsupported_on_delete_standard() { @Test void throw_standard_not_found_when_id_does_not_match() throws NamespaceNotFoundException { - RegistryEntry entry = new RegistryEntry("std-x", java.nio.file.Path.of("standards/x.json"), - RegistryResourceType.STANDARD, "X", java.time.Instant.now()); + RegistryEntry entry = new RegistryEntry("std-x", Path.of("standards/x.json"), + RegistryResourceType.STANDARD, "X", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:std-x", entry)); + Map.of("finos", List.of(entry)), + Map.of("finos:std-x", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); - assertThrows(org.finos.calm.domain.exception.StandardNotFoundException.class, + assertThrows(StandardNotFoundException.class, () -> store.getStandardVersions("finos", 99999)); } @@ -101,64 +122,104 @@ void throw_namespace_not_found_on_get_standard_versions() { void throw_namespace_not_found_on_get_standard_for_version() { when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); assertThrows(NamespaceNotFoundException.class, - () -> store.getStandardForVersion("nonexistent", 1, "latest")); + () -> store.getStandardForVersion("nonexistent", 1, "abc1234")); } @Test - void return_versions_for_existing_standard() throws Exception { - RegistryEntry entry = new RegistryEntry("std-1", java.nio.file.Path.of("standards/std.json"), - RegistryResourceType.STANDARD, "Std", java.time.Instant.now()); + void return_empty_versions_when_neither_the_api_nor_the_local_clone_have_anything() throws Exception { + RegistryEntry entry = new RegistryEntry("std-1", Path.of("standards/std.json"), + RegistryResourceType.STANDARD, "Std", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:std-1", entry)); + Map.of("finos", List.of(entry)), + Map.of("finos:std-1", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); int hashId = ("std-1".hashCode() & 0x7FFFFFFF); - java.util.List versions = store.getStandardVersions("finos", hashId); + List versions = store.getStandardVersions("finos", hashId); + assertThat(versions, is(empty())); + } + + @Test + void fall_back_to_the_local_head_sha_when_the_api_returns_no_versions() throws Exception { + RegistryEntry entry = new RegistryEntry("std-1", Path.of("standards/std.json"), + RegistryResourceType.STANDARD, "Std", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:std-1", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("1234567"); + + int hashId = ("std-1".hashCode() & 0x7FFFFFFF); + List versions = store.getStandardVersions("finos", hashId); + assertThat(versions, hasSize(1)); - assertThat(versions.get(0), equalTo("latest")); + assertThat(versions.get(0), equalTo("1234567")); } @Test - void return_content_for_standard(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tempDir) throws Exception { - java.nio.file.Path stdDir = tempDir.resolve("finos/standards"); - java.nio.file.Files.createDirectories(stdDir); - java.nio.file.Files.writeString(stdDir.resolve("test.json"), "{\"name\":\"Test Standard\"}"); + void return_content_for_the_current_head_version(@TempDir Path tempDir) throws Exception { + Path stdDir = tempDir.resolve("finos/standards"); + Files.createDirectories(stdDir); + Files.writeString(stdDir.resolve("test.json"), "{\"name\":\"Test Standard\"}"); - RegistryEntry entry = new RegistryEntry("test-std", java.nio.file.Path.of("standards/test.json"), - RegistryResourceType.STANDARD, "Test", java.time.Instant.now()); + RegistryEntry entry = new RegistryEntry("test-std", Path.of("standards/test.json"), + RegistryResourceType.STANDARD, "Test", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-std", entry)); + Map.of("finos", List.of(entry)), + Map.of("finos:test-std", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); - store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); + GitHubStandardStore realFileReaderStore = new GitHubStandardStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); int hashId = ("test-std".hashCode() & 0x7FFFFFFF); - String content = store.getStandardForVersion("finos", hashId, "latest"); + String content = realFileReaderStore.getStandardForVersion("finos", hashId, "abc1234"); assertThat(content, equalTo("{\"name\":\"Test Standard\"}")); } @Test - void prefer_md_sibling_over_json(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tempDir) throws Exception { - java.nio.file.Path stdDir = tempDir.resolve("finos/standards"); - java.nio.file.Files.createDirectories(stdDir); - java.nio.file.Files.writeString(stdDir.resolve("policy.guideline.json"), "{\"nodes\":[]}"); - java.nio.file.Files.writeString(stdDir.resolve("policy.md"), "# Policy\n\nContent here."); + void prefer_md_sibling_over_json(@TempDir Path tempDir) throws Exception { + Path stdDir = tempDir.resolve("finos/standards"); + Files.createDirectories(stdDir); + Files.writeString(stdDir.resolve("policy.guideline.json"), "{\"nodes\":[]}"); + Files.writeString(stdDir.resolve("policy.md"), "# Policy\n\nContent here."); + + RegistryEntry entry = new RegistryEntry("policy", Path.of("standards/policy.guideline.json"), + RegistryResourceType.STANDARD, "Policy", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:policy", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); - RegistryEntry entry = new RegistryEntry("policy", java.nio.file.Path.of("standards/policy.guideline.json"), - RegistryResourceType.STANDARD, "Policy", java.time.Instant.now()); + GitHubStandardStore realFileReaderStore = new GitHubStandardStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); + int hashId = ("policy".hashCode() & 0x7FFFFFFF); + String content = realFileReaderStore.getStandardForVersion("finos", hashId, "abc1234"); + assertThat(content, startsWith("# Policy")); + } + + @Test + void ignore_md_sibling_preference_when_fetching_a_pinned_sha_from_the_api(@TempDir Path tempDir) throws Exception { + RegistryEntry entry = new RegistryEntry("policy", Path.of("standards/policy.guideline.json"), + RegistryResourceType.STANDARD, "Policy", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:policy", entry)); + Map.of("finos", List.of(entry)), + Map.of("finos:policy", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); + + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(versionService.getFileAtVersion("finos/repo", "standards/policy.guideline.json", "abc1234")) + .thenReturn("{\"nodes\":[{\"name\":\"old\"}]}"); - store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); int hashId = ("policy".hashCode() & 0x7FFFFFFF); - String content = store.getStandardForVersion("finos", hashId, "latest"); - assertThat(content, org.hamcrest.Matchers.startsWith("# Policy")); + String content = store.getStandardForVersion("finos", hashId, "abc1234"); + assertThat(content, equalTo("{\"nodes\":[{\"name\":\"old\"}]}")); } @Test @@ -169,26 +230,21 @@ void throw_unsupported_on_create_standard_for_version() { @Test void return_sha_versions_when_version_service_available() throws Exception { - RegistryEntry entry = new RegistryEntry("std-1", java.nio.file.Path.of("standards/std.json"), - RegistryResourceType.STANDARD, "Std", java.time.Instant.now()); + RegistryEntry entry = new RegistryEntry("std-1", Path.of("standards/std.json"), + RegistryResourceType.STANDARD, "Std", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:std-1", entry)); + Map.of("finos", List.of(entry)), + Map.of("finos:std-1", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); - - org.finos.calm.store.github.sync.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.sync.GitHubCloneManager.class); - org.finos.calm.store.github.api.GitHubFileHistoryClient mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.api.GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); - when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); - when(mockVersionService.getFileVersions("finos/repo", "main", "standards/std.json")) - .thenReturn(java.util.List.of("abc1234", "def5678")); + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(cloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(versionService.getFileVersions("finos/repo", "main", "standards/std.json")) + .thenReturn(List.of("abc1234", "def5678")); int hashId = ("std-1".hashCode() & 0x7FFFFFFF); - java.util.List versions = store.getStandardVersions("finos", hashId); + List versions = store.getStandardVersions("finos", hashId); assertThat(versions, hasSize(2)); assertThat(versions.get(0), equalTo("abc1234")); @@ -196,21 +252,16 @@ void return_sha_versions_when_version_service_available() throws Exception { @Test void return_content_from_github_api_for_sha_version() throws Exception { - RegistryEntry entry = new RegistryEntry("test-std", java.nio.file.Path.of("standards/test.json"), - RegistryResourceType.STANDARD, "Test", java.time.Instant.now()); + RegistryEntry entry = new RegistryEntry("test-std", Path.of("standards/test.json"), + RegistryResourceType.STANDARD, "Test", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-std", entry)); + Map.of("finos", List.of(entry)), + Map.of("finos:test-std", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); - - org.finos.calm.store.github.sync.GitHubCloneManager mockCloneManager = org.mockito.Mockito.mock(org.finos.calm.store.github.sync.GitHubCloneManager.class); - org.finos.calm.store.github.api.GitHubFileHistoryClient mockVersionService = org.mockito.Mockito.mock(org.finos.calm.store.github.api.GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); - when(mockVersionService.getFileAtVersion("finos/repo", "standards/test.json", "abc1234")) + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(versionService.getFileAtVersion("finos/repo", "standards/test.json", "abc1234")) .thenReturn("{\"name\":\"Old Standard\"}"); int hashId = ("test-std".hashCode() & 0x7FFFFFFF); @@ -220,19 +271,80 @@ void return_content_from_github_api_for_sha_version() throws Exception { } @Test - void throw_standard_version_not_found_when_file_missing(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tempDir) throws Exception { - RegistryEntry entry = new RegistryEntry("test-std", java.nio.file.Path.of("standards/nonexistent.json"), - RegistryResourceType.STANDARD, "Test", java.time.Instant.now()); + void throw_version_not_found_when_the_requested_version_is_not_sha_shaped() { + RegistryEntry entry = new RegistryEntry("test-std", Path.of("standards/test.json"), + RegistryResourceType.STANDARD, "Test", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( - java.util.Map.of("finos", java.util.List.of(entry)), - java.util.Map.of("finos:test-std", entry)); + Map.of("finos", List.of(entry)), + Map.of("finos:test-std", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); - when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(java.util.List.of(entry)); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); - store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); int hashId = ("test-std".hashCode() & 0x7FFFFFFF); - assertThrows(org.finos.calm.domain.exception.StandardVersionNotFoundException.class, + assertThrows(StandardVersionNotFoundException.class, () -> store.getStandardForVersion("finos", hashId, "1.0.0")); } + + @Test + void throw_standard_version_not_found_when_file_missing() throws Exception { + RegistryEntry entry = new RegistryEntry("test-std", Path.of("standards/nonexistent.json"), + RegistryResourceType.STANDARD, "Test", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:test-std", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); + + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(versionService.getFileAtVersion("finos/repo", "standards/nonexistent.json", "abc1234")) + .thenReturn(null); + + int hashId = ("test-std".hashCode() & 0x7FFFFFFF); + + assertThrows(StandardVersionNotFoundException.class, + () -> store.getStandardForVersion("finos", hashId, "abc1234")); + } + + @Test + void throw_standard_version_not_found_when_the_local_head_file_is_missing_on_disk(@TempDir Path tempDir) throws Exception { + RegistryEntry entry = new RegistryEntry("test-std", Path.of("standards/nonexistent.json"), + RegistryResourceType.STANDARD, "Test", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:test-std", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); + + GitHubStandardStore realFileReaderStore = new GitHubStandardStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); + int hashId = ("test-std".hashCode() & 0x7FFFFFFF); + + assertThrows(StandardVersionNotFoundException.class, + () -> realFileReaderStore.getStandardForVersion("finos", hashId, "abc1234")); + } + + @Test + void leave_a_non_json_entry_path_unchanged_when_checking_for_a_markdown_sibling(@TempDir Path tempDir) throws Exception { + Path stdDir = tempDir.resolve("finos/standards"); + Files.createDirectories(stdDir); + Files.writeString(stdDir.resolve("already-markdown.md"), "# Already markdown"); + + RegistryEntry entry = new RegistryEntry("already-markdown", Path.of("standards/already-markdown.md"), + RegistryResourceType.STANDARD, "Already Markdown", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:already-markdown", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.STANDARD)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); + + GitHubStandardStore realFileReaderStore = new GitHubStandardStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); + int hashId = ("already-markdown".hashCode() & 0x7FFFFFFF); + + String content = realFileReaderStore.getStandardForVersion("finos", hashId, "abc1234"); + assertThat(content, equalTo("# Already markdown")); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java index a21f91b98e..b049084cac 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java @@ -19,8 +19,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import java.nio.file.Files; import java.nio.file.Path; @@ -36,17 +37,27 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; +@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubTimelineStoreShould { @Mock private ResourceRegistry registryService; + @Mock + private GitHubCloneManager cloneManager; + + @Mock + private GitHubFileHistoryClient versionService; + + @Mock + private NamespaceFileReader fileReader; + private GitHubTimelineStore store; @BeforeEach void setup() { - store = new GitHubTimelineStore(registryService); + store = new GitHubTimelineStore(registryService, cloneManager, versionService, fileReader); } @Test @@ -114,7 +125,7 @@ void throw_namespace_not_found_on_get_timeline_for_version() { } @Test - void return_versions_list_for_existing_timeline() throws Exception { + void return_empty_versions_when_neither_the_api_nor_the_local_clone_have_anything() throws Exception { RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), RegistryResourceType.TIMELINE, "Release Timeline", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( @@ -127,8 +138,26 @@ void return_versions_list_for_existing_timeline() throws Exception { Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(hashId).build(); List versions = store.getTimelineVersions(timeline); + assertThat(versions, is(empty())); + } + + @Test + void fall_back_to_the_local_head_sha_when_the_api_returns_no_versions() throws Exception { + RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), + RegistryResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:release-timeline", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("1234567"); + + int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); + Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(hashId).build(); + List versions = store.getTimelineVersions(timeline); + assertThat(versions, hasSize(1)); - assertThat(versions.get(0), equalTo("latest")); + assertThat(versions.get(0), equalTo("1234567")); } @Test @@ -141,14 +170,9 @@ void return_sha_versions_when_version_service_available() throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); - GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; - - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); - when(mockCloneManager.getBranchForNamespace("finos")).thenReturn("main"); - when(mockVersionService.getFileVersions("finos/architecture-as-code", "main", "timelines/release-timeline.json")) + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/architecture-as-code"); + when(cloneManager.getBranchForNamespace("finos")).thenReturn("main"); + when(versionService.getFileVersions("finos/architecture-as-code", "main", "timelines/release-timeline.json")) .thenReturn(List.of("abc1234", "def5678")); int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); @@ -160,7 +184,7 @@ void return_sha_versions_when_version_service_available() throws Exception { } @Test - void return_timeline_content_for_version(@TempDir Path tempDir) throws Exception { + void return_timeline_content_for_the_current_head_version(@TempDir Path tempDir) throws Exception { Path timelineDir = tempDir.resolve("finos/timelines"); Files.createDirectories(timelineDir); Files.writeString(timelineDir.resolve("release-timeline.json"), "{\"milestones\":[]}"); @@ -172,12 +196,14 @@ void return_timeline_content_for_version(@TempDir Path tempDir) throws Exception Map.of("finos:release-timeline", entry)); when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); - store.fileReader = new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")); + GitHubTimelineStore realFileReaderStore = new GitHubTimelineStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); - Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); + Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(hashId).setVersion("abc1234").build(); - String content = store.getTimelineForVersion(timeline); + String content = realFileReaderStore.getTimelineForVersion(timeline); assertThat(content, equalTo("{\"milestones\":[]}")); } @@ -191,13 +217,8 @@ void return_content_from_github_api_for_sha_version() throws Exception { when(registryService.getSnapshot()).thenReturn(snapshot); when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); - GitHubCloneManager mockCloneManager = Mockito.mock(GitHubCloneManager.class); - GitHubFileHistoryClient mockVersionService = Mockito.mock(GitHubFileHistoryClient.class); - store.cloneManager = mockCloneManager; - store.versionService = mockVersionService; - - when(mockCloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); - when(mockVersionService.getFileAtVersion("finos/repo", "timelines/release-timeline.json", "abc1234")) + when(cloneManager.getRepoForNamespace("finos")).thenReturn("finos/repo"); + when(versionService.getFileAtVersion("finos/repo", "timelines/release-timeline.json", "abc1234")) .thenReturn("{\"milestones\":[{\"name\":\"old\"}]}"); int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); @@ -207,6 +228,41 @@ void return_content_from_github_api_for_sha_version() throws Exception { assertThat(content, equalTo("{\"milestones\":[{\"name\":\"old\"}]}")); } + @Test + void throw_version_not_found_when_the_requested_version_is_not_sha_shaped() { + RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), + RegistryResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:release-timeline", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); + + int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); + Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(hashId).setVersion("1.0.0").build(); + + assertThrows(TimelineVersionNotFoundException.class, () -> store.getTimelineForVersion(timeline)); + } + + @Test + void throw_version_not_found_when_the_local_file_is_missing(@TempDir Path tempDir) throws Exception { + RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/nonexistent.json"), + RegistryResourceType.TIMELINE, "Release Timeline", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:release-timeline", entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.TIMELINE)).thenReturn(List.of(entry)); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); + + GitHubTimelineStore realFileReaderStore = new GitHubTimelineStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com"))); + int hashId = ("release-timeline".hashCode() & 0x7FFFFFFF); + Timeline timeline = new Timeline.TimelineBuilder().setNamespace("finos").setId(hashId).setVersion("abc1234").build(); + + assertThrows(TimelineVersionNotFoundException.class, () -> realFileReaderStore.getTimelineForVersion(timeline)); + } + @Test void throw_timeline_not_found_when_id_does_not_match() { RegistryEntry entry = new RegistryEntry("release-timeline", Path.of("timelines/release-timeline.json"), diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java index 9548606226..62ee2a016e 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubUserAccessStoreShould.java @@ -56,11 +56,7 @@ class TestGitHubUserAccessStoreShould { @BeforeEach void setup() { - store = new GitHubUserAccessStore(); - store.registryService = registryService; - store.roleResolver = roleResolver; - store.identity = identity; - store.cloneManager = cloneManager; + store = new GitHubUserAccessStore(registryService, roleResolver, identity, cloneManager); when(cloneManager.getAccessGroupsForNamespace("finos")).thenReturn(ACCESS_GROUPS); when(cloneManager.getAccessGroupsForNamespace("team")).thenReturn(ACCESS_GROUPS); From 0d19241f75fed6a80d2108c7748ffb789348ab35 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 20:58:35 +0100 Subject: [PATCH 26/47] docs(calm-hub): add house-standard JavaDoc to the remaining GitHub store classes - Add class-level JavaDoc to the 11 GitHub*Store classes and 2 registry records left undocumented after Phase 4/5 (Adr, Architecture, Decorator, Flow, Interface, Namespace, Pattern, ResourceMapping, Search, Standard, Timeline, RegistryEntry, RegistrySnapshot) - Each explains why the class exists and what it deliberately does not do, matching the store/util house standard (CanonicalVersion, VersionScheme): why Adr/Decorator have no registry representation, why GitHubResourceMappingStore is the one named place translating between ResourceType and RegistryResourceType, why Standard's markdown-sibling preference only applies to the local-HEAD read - No behaviour change; confirmed via a full mvn verify (3380 tests, JaCoCo 90%-per-class gate met) --- .../finos/calm/store/github/GitHubAdrStore.java | 10 ++++++++++ .../calm/store/github/GitHubArchitectureStore.java | 9 +++++++++ .../calm/store/github/GitHubDecoratorStore.java | 8 ++++++++ .../finos/calm/store/github/GitHubFlowStore.java | 9 +++++++++ .../calm/store/github/GitHubInterfaceStore.java | 9 +++++++++ .../calm/store/github/GitHubNamespaceStore.java | 9 +++++++++ .../calm/store/github/GitHubPatternStore.java | 9 +++++++++ .../store/github/GitHubResourceMappingStore.java | 11 +++++++++++ .../finos/calm/store/github/GitHubSearchStore.java | 8 ++++++++ .../calm/store/github/GitHubStandardStore.java | 14 ++++++++++++++ .../calm/store/github/GitHubTimelineStore.java | 9 +++++++++ .../calm/store/github/registry/RegistryEntry.java | 8 ++++++++ .../store/github/registry/RegistrySnapshot.java | 10 ++++++++++ 13 files changed, 123 insertions(+) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java index 340c7de16e..f2c5af531d 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java @@ -20,6 +20,16 @@ import java.util.Collections; import java.util.List; +/** + * GitHub-mode {@link AdrStore}: ADRs are not modelled in the registry at all - there is no + * {@code RegistryResourceType.ADR} and no file-classification rule that would produce one - + * so every namespace reports zero ADRs rather than a partial or wrong listing, and every + * per-ADR lookup is an unconditional {@link GitHubWriteNotSupportedException}. This is a + * deliberate scope cut, not an oversight: unlike the other resource types, ADRs have no + * settled on-disk convention this backend could safely detect. Extends only + * {@link AbstractGitHubStore} (not {@link AbstractReadOnlyGitHubStore}) because there is no + * file to read - {@code verifyNamespace} is the only shared behaviour this store needs. + */ @ApplicationScoped @Typed(GitHubAdrStore.class) public class GitHubAdrStore extends AbstractGitHubStore implements AdrStore { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java index 9d0fe80b81..66a2c841b9 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java @@ -24,6 +24,15 @@ import java.io.IOException; import java.util.List; +/** + * GitHub-mode {@link ArchitectureStore}: architectures are read from the {@code .json} files + * under {@code architectures/} in the namespace's clone, classified into the registry by + * {@link org.finos.calm.store.github.registry.CalmContentDetector}. Id lookup, version listing + * and version-content resolution all delegate to {@link AbstractReadOnlyGitHubStore} - see its + * class javadoc for why an unresolvable version 404s rather than silently reading whatever + * HEAD holds. Every mutating method throws {@link GitHubWriteNotSupportedException}: this + * backend is read-only until GitHub account linking and PR creation land. + */ @ApplicationScoped @Typed(GitHubArchitectureStore.class) public class GitHubArchitectureStore extends AbstractReadOnlyGitHubStore implements ArchitectureStore { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java index b07b3ccd92..788e7a2275 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubDecoratorStore.java @@ -15,6 +15,14 @@ import java.util.List; import java.util.Optional; +/** + * GitHub-mode {@link DecoratorStore}: like {@link GitHubAdrStore}, decorators have no + * registry representation - no file convention exists to classify a decorator from a repo + * checkout - so every lookup returns an empty result rather than guessing, and every write + * is an unconditional {@link GitHubWriteNotSupportedException}. Extends only + * {@link AbstractGitHubStore}: with nothing to read, the shared read-path machinery in + * {@link AbstractReadOnlyGitHubStore} would be dead code here. + */ @ApplicationScoped @Typed(GitHubDecoratorStore.class) public class GitHubDecoratorStore extends AbstractGitHubStore implements DecoratorStore { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java index 4d71149493..5d8c0f1809 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java @@ -25,6 +25,15 @@ import java.io.IOException; import java.util.List; +/** + * GitHub-mode {@link FlowStore}: flows are read from the {@code .json} files under + * {@code flows/} in the namespace's clone, classified into the registry by + * {@link org.finos.calm.store.github.registry.CalmContentDetector}. Id lookup, version listing + * and version-content resolution all delegate to {@link AbstractReadOnlyGitHubStore} - see its + * class javadoc for why an unresolvable version 404s rather than silently reading whatever + * HEAD holds. Every mutating method throws {@link GitHubWriteNotSupportedException}: this + * backend is read-only until GitHub account linking and PR creation land. + */ @ApplicationScoped @Typed(GitHubFlowStore.class) public class GitHubFlowStore extends AbstractReadOnlyGitHubStore implements FlowStore { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java index d82b69cef8..4fa32ed3ba 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java @@ -25,6 +25,15 @@ import java.io.IOException; import java.util.List; +/** + * GitHub-mode {@link InterfaceStore}: interfaces are read from the {@code .json} files under + * {@code interfaces/} in the namespace's clone, classified into the registry by + * {@link org.finos.calm.store.github.registry.CalmContentDetector}. Id lookup, version listing + * and version-content resolution all delegate to {@link AbstractReadOnlyGitHubStore} - see its + * class javadoc for why an unresolvable version 404s rather than silently reading whatever + * HEAD holds. Every mutating method throws {@link GitHubWriteNotSupportedException}: this + * backend is read-only until GitHub account linking and PR creation land. + */ @ApplicationScoped @Typed(GitHubInterfaceStore.class) public class GitHubInterfaceStore extends AbstractReadOnlyGitHubStore implements InterfaceStore { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java index e3fbc469f6..fdd5b08ad7 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubNamespaceStore.java @@ -13,6 +13,15 @@ import java.util.List; +/** + * GitHub-mode {@link NamespaceStore}. Namespaces are not a resource stored in any repo - they + * are the deployment-level mapping of {@code calm.github.namespaces[*]} entries to cloned + * repos, already materialised into {@link ResourceRegistry}'s snapshot by + * {@link org.finos.calm.store.github.sync.GitHubStartupInitializer} at boot. This store is + * therefore a thin read view over that snapshot, never a writer of it: creating or deleting a + * namespace here would mean editing the running deployment's configuration, which is exactly + * what {@link GitHubWriteNotSupportedException} exists to point the caller at instead. + */ @ApplicationScoped @Typed(GitHubNamespaceStore.class) public class GitHubNamespaceStore implements NamespaceStore { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java index 71323c8b3a..c5d4ddeaf9 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java @@ -27,6 +27,15 @@ import java.io.IOException; import java.util.List; +/** + * GitHub-mode {@link PatternStore}: patterns are read from the {@code .json} files under + * {@code patterns/} in the namespace's clone, classified into the registry by + * {@link org.finos.calm.store.github.registry.CalmContentDetector}. Id lookup, version listing + * and version-content resolution all delegate to {@link AbstractReadOnlyGitHubStore} - see its + * class javadoc for why an unresolvable version 404s rather than silently reading whatever + * HEAD holds. Every mutating method throws {@link GitHubWriteNotSupportedException}: this + * backend is read-only until GitHub account linking and PR creation land. + */ @ApplicationScoped @Typed(GitHubPatternStore.class) public class GitHubPatternStore extends AbstractReadOnlyGitHubStore implements PatternStore { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java index 0c29a4eb03..d04a27b106 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubResourceMappingStore.java @@ -17,6 +17,17 @@ import java.util.List; import java.util.Optional; +/** + * GitHub-mode {@link ResourceMappingStore}, and the one named place that translates between + * the front controller's {@link ResourceType} and this backend's own + * {@link RegistryResourceType} ({@link #toRegistryResourceType}). The two types are a + * deliberate duplication, not an oversight - {@code RegistryResourceType} answers "what kind + * of file is this" from repo content alone, while {@code ResourceType} answers "what can the + * namespace-scoped front controller address" and is serialized on {@link ResourceMapping} and + * name-coupled to {@code AuditEntityType}. Holding the mapping here, rather than inline in + * each store, keeps that translation a single reviewable switch instead of one copy per + * resource type. + */ @ApplicationScoped @Typed(GitHubResourceMappingStore.class) public class GitHubResourceMappingStore implements ResourceMappingStore { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java index ae2d470180..b076dfb6f3 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java @@ -15,6 +15,14 @@ import java.util.Set; import java.util.stream.Collectors; +/** + * GitHub-mode {@link SearchStore}: a read-only, in-memory name/id substring match over the + * current {@link ResourceRegistry} snapshot rather than a real index - there is no external + * search backend to query in this storage mode, and the registry is already fully resident in + * memory, so scanning it directly is the whole implementation. Namespace filtering + * ({@code readableNamespaces}) is applied before matching so a caller never sees a result from + * a namespace they cannot read, regardless of how good the match is. + */ @ApplicationScoped @Typed(GitHubSearchStore.class) public class GitHubSearchStore implements SearchStore { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java index 57412ba166..f31412add3 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java @@ -26,6 +26,20 @@ import java.nio.file.Path; import java.util.List; +/** + * GitHub-mode {@link StandardStore}: standards are read from files under {@code standards/} + * and {@code building-blocks/} in the namespace's clone (the two directories are merged into + * one listing - see {@link org.finos.calm.store.github.registry.CalmContentDetector} for why + * {@code building-blocks/} maps to {@code STANDARD} rather than a separate concept). Id + * lookup, version listing and version-content resolution all delegate to + * {@link AbstractReadOnlyGitHubStore} - see its class javadoc for why an unresolvable version + * 404s rather than silently reading whatever HEAD holds. The one thing this store adds beyond + * that shared behaviour is {@link #preferMarkdownSibling}: standards render better as prose, + * so a JSON entry with a same-named {@code .md} sibling serves that sibling instead - but only + * for the local-HEAD read, never for a pinned-SHA API fetch (see that method's javadoc for + * why). Every mutating method throws {@link GitHubWriteNotSupportedException}: this backend is + * read-only until GitHub account linking and PR creation land. + */ @ApplicationScoped @Typed(GitHubStandardStore.class) public class GitHubStandardStore extends AbstractReadOnlyGitHubStore implements StandardStore { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java index 9e1896c7d0..747aee32de 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java @@ -25,6 +25,15 @@ import java.io.IOException; import java.util.List; +/** + * GitHub-mode {@link TimelineStore}: timelines are read from the {@code .json} files under + * {@code timelines/} in the namespace's clone, classified into the registry by + * {@link org.finos.calm.store.github.registry.CalmContentDetector}. Id lookup, version listing + * and version-content resolution all delegate to {@link AbstractReadOnlyGitHubStore} - see its + * class javadoc for why an unresolvable version 404s rather than silently reading whatever + * HEAD holds. Every mutating method throws {@link GitHubWriteNotSupportedException}: this + * backend is read-only until GitHub account linking and PR creation land. + */ @ApplicationScoped @Typed(GitHubTimelineStore.class) public class GitHubTimelineStore extends AbstractReadOnlyGitHubStore implements TimelineStore { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistryEntry.java b/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistryEntry.java index d42856b074..e6c5f9abd9 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistryEntry.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistryEntry.java @@ -3,6 +3,14 @@ import java.nio.file.Path; import java.time.Instant; +/** + * One CALM resource classified out of a namespace's clone: a {@code filePath} relative to the + * clone root, the {@link RegistryResourceType} {@link CalmContentDetector} assigned it, and + * the display {@code name} and {@code uniqueId} every store's numeric id is derived from + * (via {@code uniqueId().hashCode() & 0x7FFFFFFF}). Produced only by + * {@link ResourceRegistry#rebuild}, held only inside a {@link RegistrySnapshot} - a store never + * constructs one directly, only reads entries back out of the snapshot it was given. + */ public record RegistryEntry( String uniqueId, Path filePath, diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistrySnapshot.java b/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistrySnapshot.java index e009862f54..e314af2d05 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistrySnapshot.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/registry/RegistrySnapshot.java @@ -5,6 +5,16 @@ import java.util.Map; import java.util.Optional; +/** + * An immutable point-in-time view of every {@link RegistryEntry} across every configured + * namespace, as of the last {@link ResourceRegistry#rebuild}. Immutability matters here more + * than in most snapshots: {@code rebuild} runs on a scheduler thread while request threads are + * concurrently reading, and a mutable, partially-rebuilt map would let a request see some + * entries from the old scan and some from the new one. {@code entriesByNamespace} and + * {@code entriesByQualifiedId} (keyed {@code "namespace:uniqueId"}) are two indices over the + * same entries, not two sources of truth - built together, in {@code rebuild}, from a single + * pass over the clone. + */ public record RegistrySnapshot( Map> entriesByNamespace, Map entriesByQualifiedId From 45a6c27547479003caa373367fc349fbf302adc5 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 21:06:05 +0100 Subject: [PATCH 27/47] test(calm-hub): scope Mockito lenient strictness, drop a package-private test seam - Remove blanket @MockitoSettings(LENIENT) from the 7 GitHub store tests that don't actually have any unused stubbing (Architecture, Control, Flow, Interface, Pattern, Standard, Timeline) - verified by running each without it first. Keep it only on Search and UserAccess, which genuinely stub shared setup used by a subset of their tests - Replace the CDI-proxy-constructor coverage test's four assertThat(...,nullValue()) assertions on package-private base-class fields with a single "constructs without throwing" check - reaching into those fields from a test was exactly the seam pattern Phase 4 removed everywhere else; the constructor executing is the whole contract this test needs to cover --- .../TestAbstractReadOnlyGitHubStoreShould.java | 16 ++++++++-------- .../TestGitHubArchitectureStoreShould.java | 3 --- .../github/TestGitHubControlStoreShould.java | 3 --- .../store/github/TestGitHubFlowStoreShould.java | 3 --- .../github/TestGitHubInterfaceStoreShould.java | 3 --- .../github/TestGitHubPatternStoreShould.java | 3 --- .../github/TestGitHubStandardStoreShould.java | 3 --- .../github/TestGitHubTimelineStoreShould.java | 3 --- 8 files changed, 8 insertions(+), 29 deletions(-) diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestAbstractReadOnlyGitHubStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestAbstractReadOnlyGitHubStoreShould.java index 7041f72ac8..4d3f6463ee 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestAbstractReadOnlyGitHubStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestAbstractReadOnlyGitHubStoreShould.java @@ -22,7 +22,7 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.notNullValue; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -68,13 +68,13 @@ void setup() { } @Test - void leave_every_collaborator_null_when_built_through_the_cdi_proxy_constructor() { - TestStore proxyShell = new TestStore(); - - assertThat(proxyShell.registryService, is(nullValue())); - assertThat(proxyShell.cloneManager, is(nullValue())); - assertThat(proxyShell.versionService, is(nullValue())); - assertThat(proxyShell.fileReader, is(nullValue())); + void expose_a_no_arg_constructor_for_the_cdi_client_proxy() { + // Exists purely so Arc can generate a client proxy for the @ApplicationScoped + // subclasses - see AbstractGitHubStore.AbstractGitHubStore() for why. Asserting on + // the resulting fields would mean reaching into package-private state the way + // Phase 4 removed everywhere else; executing the constructor without throwing is + // the whole contract this test needs to cover. + assertThat(new TestStore(), is(notNullValue())); } @Test diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java index bdfe9b6697..262e7ddb59 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubArchitectureStoreShould.java @@ -20,8 +20,6 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; import java.nio.file.Files; import java.nio.file.Path; @@ -37,7 +35,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; -@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubArchitectureStoreShould { diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index 19c1cc6312..f4d711e359 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -21,8 +21,6 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; import java.nio.file.Files; import java.nio.file.Path; @@ -39,7 +37,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; -@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubControlStoreShould { diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java index a4258176b8..15430851dd 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubFlowStoreShould.java @@ -20,8 +20,6 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; import java.nio.file.Files; import java.nio.file.Path; @@ -37,7 +35,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; -@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubFlowStoreShould { diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java index 4bd7180fc3..8fae3f203c 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubInterfaceStoreShould.java @@ -19,8 +19,6 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; import java.nio.file.Files; import java.nio.file.Path; @@ -36,7 +34,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; -@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubInterfaceStoreShould { diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java index d47cb95b78..a5d653dd70 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternStoreShould.java @@ -21,8 +21,6 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; import java.nio.file.Files; import java.nio.file.Path; @@ -38,7 +36,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; -@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubPatternStoreShould { diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java index 746f6c6f48..50f9863618 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubStandardStoreShould.java @@ -19,8 +19,6 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; import java.nio.file.Files; import java.nio.file.Path; @@ -37,7 +35,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; -@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubStandardStoreShould { diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java index b049084cac..23b006d975 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubTimelineStoreShould.java @@ -20,8 +20,6 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; import java.nio.file.Files; import java.nio.file.Path; @@ -37,7 +35,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; -@MockitoSettings(strictness = Strictness.LENIENT) @ExtendWith(MockitoExtension.class) class TestGitHubTimelineStoreShould { From 4e29467a3153bb9c4ed632c57df734cef9cb1525 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 10 Sep 2026 22:40:54 +0100 Subject: [PATCH 28/47] test(calm-hub): add Docker-based end-to-end coverage for this rework's five behaviours - Extend GitHubFixtureResource's finos-repo with a building-blocks/ file alongside its existing control fixture - Add GitHubReworkBehaviorIntegration covering, against a real local clone (no mocking): building-blocks/ classifies as a Standard; a version list never contains "latest" and is a real SHA; GET .../versions/latest 400s in GitHub mode same as every other backend; the namespace-scoped controls route 400s rather than 500ing - Extend GitHubUserAccessDomainReadIntegration with a control-content read through the domain route, namespace deliberately distinct from domain Verified: full Docker integration suite passes (554 tests, was 549). --- .../integration/GitHubFixtureResource.java | 26 +++- .../GitHubReworkBehaviorIntegration.java | 130 ++++++++++++++++++ ...GitHubUserAccessDomainReadIntegration.java | 32 +++++ 3 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 calm-hub/src/integration-test/java/integration/GitHubReworkBehaviorIntegration.java diff --git a/calm-hub/src/integration-test/java/integration/GitHubFixtureResource.java b/calm-hub/src/integration-test/java/integration/GitHubFixtureResource.java index 2710d37bd8..e2f42ba7d0 100644 --- a/calm-hub/src/integration-test/java/integration/GitHubFixtureResource.java +++ b/calm-hub/src/integration-test/java/integration/GitHubFixtureResource.java @@ -18,6 +18,11 @@ * configured with access group "group1", "other" with "group2" - see * {@link IntegrationTestGithubProfile} for how these feed calm.github.namespaces, and * GitHubUserAccessDomainReadIntegration for what this fixture is proving. + * + *

"finos-repo" also carries a {@code building-blocks/rate-limit-policy.json} file - + * see {@link GitHubReworkBehaviorIntegration} for what that proves (the Building + * Block-to-Standard alias, and the "latest" removal, end to end against a real local clone + * rather than a mock). */ public class GitHubFixtureResource implements QuarkusTestResourceLifecycleManager { @@ -32,8 +37,13 @@ public Map start() { originRoot = Files.createTempDirectory("calm-github-it-origin"); cloneDirectory = Files.createTempDirectory("calm-github-it-clones"); - createRepo(originRoot.resolve("finos-repo.git"), "controls/security/access-control.json"); - createRepo(originRoot.resolve("other-repo.git"), "controls/finance/other-control.json"); + createRepo(originRoot.resolve("finos-repo.git"), Map.of( + "controls/security/access-control.json", "{}", + "building-blocks/rate-limit-policy.json", "{\"name\":\"Rate Limit Policy\"}" + )); + createRepo(originRoot.resolve("other-repo.git"), Map.of( + "controls/finance/other-control.json", "{}" + )); LOG.info("GitHub fixture repos created under {}", originRoot); } catch (Exception e) { @@ -51,14 +61,16 @@ public Map start() { ); } - private void createRepo(Path repoDir, String controlRelativePath) throws Exception { + private void createRepo(Path repoDir, Map filesByRelativePath) throws Exception { Files.createDirectories(repoDir); try (Git git = Git.init().setDirectory(repoDir.toFile()).setInitialBranch("main").call()) { - Path controlFile = repoDir.resolve(controlRelativePath); - Files.createDirectories(controlFile.getParent()); - Files.writeString(controlFile, "{}"); + for (Map.Entry file : filesByRelativePath.entrySet()) { + Path target = repoDir.resolve(file.getKey()); + Files.createDirectories(target.getParent()); + Files.writeString(target, file.getValue()); + } git.add().addFilepattern(".").call(); - git.commit().setMessage("seed fixture control").call(); + git.commit().setMessage("seed fixture content").call(); } } diff --git a/calm-hub/src/integration-test/java/integration/GitHubReworkBehaviorIntegration.java b/calm-hub/src/integration-test/java/integration/GitHubReworkBehaviorIntegration.java new file mode 100644 index 0000000000..f7fd5aa114 --- /dev/null +++ b/calm-hub/src/integration-test/java/integration/GitHubReworkBehaviorIntegration.java @@ -0,0 +1,130 @@ +package integration; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.security.TestSecurity; +import jakarta.inject.Inject; +import org.finos.calm.store.github.registry.ResourceRegistry; +import org.finos.calm.store.github.sync.GitHubCloneManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Set; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.everyItem; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.matchesPattern; +import static org.hamcrest.Matchers.not; + +/** + * End-to-end proof, against the same real local git repos as + * {@link GitHubUserAccessDomainReadIntegration} (no mocking of any GitHub store or registry + * class), of four behaviours this rework changed and that a unit test alone can't fully + * verify because they depend on real clone/registry wiring: + * + *

    + *
  • a {@code building-blocks/} file classifies as a {@code Standard}, not a dropped or + * separately-typed concept (Phase 1)
  • + *
  • a version list never contains the literal {@code "latest"} - it's either a real SHA + * or the clone's current HEAD SHA (Phase 3/5)
  • + *
  • {@code GET .../versions/latest} is rejected with 400 by the shared validation regex, + * in GitHub mode exactly as in every other storage mode (Phase 3)
  • + *
  • the namespace-scoped {@code controls} route returns 400 "Unsupported resource type", + * not the 500 it returned before the Phase 2 revert (the domain-scoped route - the + * correct one - is proven separately in {@link GitHubUserAccessDomainReadIntegration}) + *
  • + *
+ */ +@QuarkusTest +@TestProfile(IntegrationTestGithubProfile.class) +class GitHubReworkBehaviorIntegration { + + @Inject + GitHubCloneManager cloneManager; + + @Inject + ResourceRegistry registryService; + + private static final Set EXPECTED_NAMESPACES = Set.of("finos", "other"); + private static final String SHA_PATTERN = "[0-9a-f]{7,40}"; + + @BeforeEach + void waitForInitialClone() { + // See GitHubUserAccessDomainReadIntegration.waitForInitialClone for why this polls + // the registry's contents rather than just GitHubCloneManager.getState(). + Instant deadline = Instant.now().plus(Duration.ofSeconds(30)); + while (!registryService.getSnapshot().getNamespaces().containsAll(EXPECTED_NAMESPACES)) { + if (Instant.now().isAfter(deadline)) { + throw new IllegalStateException( + "Registry did not contain " + EXPECTED_NAMESPACES + " within 30s - clone state: " + + cloneManager.getState() + ", namespaces seen: " + + registryService.getSnapshot().getNamespaces()); + } + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + } + + @Test + @TestSecurity(user = "alice", roles = "group1") + void classify_a_building_blocks_file_as_a_standard() { + given() + .when().get("/api/calm/namespaces/finos/standards") + .then() + .statusCode(200) + .body("values.name", hasItem("Rate Limit Policy")); + } + + @Test + @TestSecurity(user = "alice", roles = "group1") + void never_return_the_literal_latest_in_a_version_list_and_return_a_real_sha_instead() { + int standardId = rateLimitPolicyStandardId(); + + List versions = given() + .when().get("/api/calm/namespaces/finos/standards/" + standardId + "/versions") + .then() + .statusCode(200) + .extract().jsonPath().getList("values", String.class); + + assertThat(versions, not(hasItem("latest"))); + assertThat(versions, everyItem(matchesPattern(SHA_PATTERN))); + } + + @Test + @TestSecurity(user = "alice", roles = "group1") + void reject_the_latest_version_alias_with_a_400_in_github_mode_too() { + int standardId = rateLimitPolicyStandardId(); + + given() + .when().get("/api/calm/namespaces/finos/standards/" + standardId + "/versions/latest") + .then() + .statusCode(400); + } + + @Test + @TestSecurity(user = "alice", roles = "group1") + void reject_the_namespace_scoped_controls_route_with_a_400_not_a_500() { + given() + .when().get("/calm/namespaces/finos/controls/access-control/versions") + .then() + .statusCode(400) + .body(org.hamcrest.Matchers.containsString("Unsupported resource type")); + } + + private int rateLimitPolicyStandardId() { + return given() + .when().get("/api/calm/namespaces/finos/standards") + .then() + .statusCode(200) + .extract().jsonPath().getInt("values.find { it.name == 'Rate Limit Policy' }.id"); + } +} diff --git a/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java b/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java index c00cea33a3..392a3cd5b4 100644 --- a/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java +++ b/calm-hub/src/integration-test/java/integration/GitHubUserAccessDomainReadIntegration.java @@ -86,6 +86,38 @@ void allow_domain_read_for_a_domain_the_users_accessible_namespace_actually_cont .body("values[0].name", org.hamcrest.Matchers.notNullValue()); } + @Test + @TestSecurity(user = "alice", roles = "group1") + void read_a_control_requirements_content_through_the_domain_route_with_namespace_distinct_from_domain() { + // "finos" (the namespace this control actually lives in) and "security" (the + // domain it's addressed by) are deliberately different strings here - the exact + // conflation TestGitHubControlStoreShould's fixtures used to hide. Proves the + // domain-scoped route resolves real content end to end, not just a non-empty list. + int controlId = given() + .when().get("/api/calm/domains/security/controls") + .then() + .statusCode(200) + .extract().jsonPath().getInt("values[0].id"); + + given() + .when().get("/api/calm/domains/security/controls/" + controlId + "/requirement/versions") + .then() + .statusCode(200) + .body("values", org.hamcrest.Matchers.not(org.hamcrest.Matchers.empty())); + + String sha = given() + .when().get("/api/calm/domains/security/controls/" + controlId + "/requirement/versions") + .then() + .statusCode(200) + .extract().jsonPath().getString("values[0]"); + + given() + .when().get("/api/calm/domains/security/controls/" + controlId + "/requirement/versions/" + sha) + .then() + .statusCode(200) + .body(org.hamcrest.Matchers.equalTo("{}")); + } + @Test @TestSecurity(user = "alice", roles = "group1") void deny_domain_read_for_a_domain_that_only_exists_outside_every_namespace_the_user_can_access() { From aa316e06b0d2aec4e968d756cf4bcc59666e8caf Mon Sep 17 00:00:00 2001 From: James Gough Date: Fri, 11 Sep 2026 08:38:04 +0100 Subject: [PATCH 29/47] fix(calm-hub): remove unsanitised filesystem check flagged by CodeQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NamespaceFileReader.existsContained called Files.exists(target) on an unvalidated path before the containment check. isContained's own toRealPath() resolution already fails for a path that doesn't exist, so containment implies existence — drop the separate exists() call. --- .../calm/store/github/access/NamespaceFileReader.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceFileReader.java b/calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceFileReader.java index a5171117e3..c9a62b70ce 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceFileReader.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceFileReader.java @@ -72,11 +72,17 @@ public String readContained(String namespace, Path relativeFilePath) throws IOEx /** * Same containment guard as {@link #readContained}, without reading the file — for * call sites (e.g. an optional sibling file) that need to check existence first. + * + *

{@code isContained}'s own {@code toRealPath()} resolution already fails (caught + * as "not contained") for a path that doesn't exist, so containment implies existence — + * a separate {@code Files.exists(target)} call against the raw, not-yet-validated path + * would touch the filesystem with unsanitised input before the containment check runs, + * which is exactly the ordering this class exists to avoid. */ public boolean existsContained(String namespace, Path relativeFilePath) { Path namespaceRoot = storeConfig.getCloneDirectory().resolve(namespace); Path target = namespaceRoot.resolve(relativeFilePath); - return Files.exists(target) && isContained(namespaceRoot, target); + return isContained(namespaceRoot, target); } private boolean isContained(Path namespaceRoot, Path target) { From 01eed1b621f5e0238405ae01d87191024817512c Mon Sep 17 00:00:00 2001 From: James Gough Date: Fri, 11 Sep 2026 08:38:14 +0100 Subject: [PATCH 30/47] test(calm-hub): cover GitHubControlStore's registry-race and local-read-failure branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `clean verify` measured GitHubControlStore at 89% line coverage, below the 90% gate — my local runs never did a full clean between test runs this session, so stale jacoco.exec data masked the real number. Add tests for the two uncovered branches: findControlEntry and findNamespaceForControl disagreeing mid-registry-rebuild (via consecutive Mockito stubbing), and the local-HEAD read failing with IOException when the file is missing on disk. GitHubControlStore is now at 100% line coverage. --- .../github/TestGitHubControlStoreShould.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index f4d711e359..154b49c158 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -321,6 +321,64 @@ void throw_requirement_version_not_found_when_file_missing() throws Exception { () -> store.getRequirementForVersion(DOMAIN, HASH_ID, "abc1234")); } + @Test + void throw_requirement_version_not_found_when_the_local_head_file_is_missing_on_disk(@TempDir Path tempDir) throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/nonexistent.json"), + RegistryResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + when(cloneManager.headSha("finos")).thenReturn("abc1234"); + + GitHubControlStore realFileReaderStore = new GitHubControlStore(registryService, cloneManager, versionService, + new NamespaceFileReader(new GitHubStoreConfig("", tempDir.toString(), "https://api.github.com")), + accessFilter); + + assertThrows(ControlRequirementVersionNotFoundException.class, + () -> realFileReaderStore.getRequirementForVersion(DOMAIN, HASH_ID, "abc1234")); + } + + @Test + void return_empty_versions_when_the_registry_is_mid_rebuild_between_the_two_lookups() throws Exception { + // findControlEntry and findNamespaceForControl each re-derive the entry's namespace + // independently by re-walking the registry - a genuine (if rare) registry-rebuild + // race can have the entry present for the first walk and gone by the second. This + // simulates exactly that with consecutive stubbing, rather than a namespace/domain + // mismatch which is a different scenario entirely. + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + RegistryResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)) + .thenReturn(List.of(entry), List.of()); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + List versions = store.getRequirementVersions(DOMAIN, HASH_ID); + + assertThat(versions, is(empty())); + } + + @Test + void throw_requirement_version_not_found_when_the_registry_is_mid_rebuild_between_the_two_lookups() throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + RegistryResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)) + .thenReturn(List.of(entry), List.of()); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + assertThrows(ControlRequirementVersionNotFoundException.class, + () -> store.getRequirementForVersion(DOMAIN, HASH_ID, "abc1234")); + } + @Test void throw_unsupported_on_create_control_requirement() { assertThrows(UnsupportedOperationException.class, From 01d302f859931a5aef09179164b0ab1a43f5a9da Mon Sep 17 00:00:00 2001 From: James Gough Date: Fri, 11 Sep 2026 09:35:45 +0100 Subject: [PATCH 31/47] fix(calm-hub): fix domain derivation and configuration reads in GitHubControlStore - Route domain derivation through ControlDomains.extractDomain instead of an inline substring match. The substring check matched "controls/" at any path depth; extractDomain only recognises it as the first segment. A control nested below root (foo/controls/security/x.json) resolved to domain "security" via the substring check but "default" via extractDomain - GitHubUserAccessStore (which already uses extractDomain) could grant or deny access based on a domain the store's own lookups disagreed with - Fix getConfigurationsForControl/getConfigurationDetailsForControl/ getConfigurationVersions/getConfigurationForVersion: these are GET-backed reads with no registry representation (same shape as GitHubAdrStore), but threw GitHubWriteNotSupportedException - a misleading 501 for a request that was never a write. They now validate the control exists via findControlEntry and answer "none exist" (empty list, or ControlConfigurationNotFoundException for a specific id), matching every other read-only GitHub store's pattern Found by /code-review high. --- .../calm/store/github/GitHubControlStore.java | 25 ++++--- .../store/github/registry/ControlDomains.java | 15 ++-- .../github/TestGitHubControlStoreShould.java | 69 ++++++++++++++++--- 3 files changed, 85 insertions(+), 24 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java index 980e7e97aa..e0ea96d297 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java @@ -18,6 +18,7 @@ import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; import org.finos.calm.domain.exception.DomainNotFoundException; import org.finos.calm.store.ControlStore; +import org.finos.calm.store.github.registry.ControlDomains; import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.sync.GitHubCloneManager; import org.finos.calm.store.github.access.NamespaceFileReader; @@ -78,8 +79,7 @@ public List getControlsForDomain(String domain) throws DomainNotF } List entries = registryService.listByType(namespace, RegistryResourceType.CONTROL); for (RegistryEntry entry : entries) { - String path = entry.filePath().toString(); - if (path.contains("controls/" + domain + "/") || path.contains("controls\\" + domain + "\\")) { + if (domain.equals(ControlDomains.extractDomain(entry))) { results.add(new ControlDetail( (entry.uniqueId().hashCode() & 0x7FFFFFFF), entry.uniqueId(), @@ -162,14 +162,22 @@ public void createRequirementForVersion(String domain, int controlId, String ver throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); } + // Control configurations have no registry representation - no file convention + // exists to classify one from a repo checkout, the same shape of gap as + // GitHubAdrStore/GitHubDecoratorStore. These four are read (GET-backed) methods, so + // - unlike the write methods below, which correctly throw + // GitHubWriteNotSupportedException - they answer "none exist" rather than claiming + // writes are unsupported for a request that was never trying to write. @Override public List getConfigurationsForControl(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException { - throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + findControlEntry(domain, controlId); + return List.of(); } @Override public List getConfigurationDetailsForControl(String domain, int controlId) throws DomainNotFoundException, ControlNotFoundException { - throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + findControlEntry(domain, controlId); + return List.of(); } @Override @@ -179,12 +187,14 @@ public int createControlConfiguration(CreateControlConfiguration request, String @Override public List getConfigurationVersions(String domain, int controlId, int configurationId) throws DomainNotFoundException, ControlNotFoundException, ControlConfigurationNotFoundException { - throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + findControlEntry(domain, controlId); + throw new ControlConfigurationNotFoundException(); } @Override public String getConfigurationForVersion(String domain, int controlId, int configurationId, String version) throws DomainNotFoundException, ControlNotFoundException, ControlConfigurationNotFoundException, ControlConfigurationVersionNotFoundException { - throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); + findControlEntry(domain, controlId); + throw new ControlConfigurationNotFoundException(); } @Override @@ -211,8 +221,7 @@ private RegistryEntry findControlEntry(String domain, int controlId) throws Doma } List entries = registryService.listByType(namespace, RegistryResourceType.CONTROL); for (RegistryEntry entry : entries) { - String path = entry.filePath().toString(); - boolean inDomain = path.contains("controls/" + domain + "/") || path.contains("controls\\" + domain + "\\"); + boolean inDomain = domain.equals(ControlDomains.extractDomain(entry)); if (inDomain) { domainExists = true; if ((entry.uniqueId().hashCode() & 0x7FFFFFFF) == controlId) { diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/registry/ControlDomains.java b/calm-hub/src/main/java/org/finos/calm/store/github/registry/ControlDomains.java index 8ef96f71e1..7629068868 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/registry/ControlDomains.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/registry/ControlDomains.java @@ -7,14 +7,19 @@ * domain is the second path segment under a {@code controls/} directory * (e.g. {@code controls/security/access-control.json} -> domain {@code "security"}). * - *

Used by both {@code GitHubDomainStore} (to list the domains a namespace has) and + *

Used by {@code GitHubDomainStore} (to list the domains a namespace has), * {@code GitHubUserAccessStore} (to derive which domains a user's namespace-level access - * should also grant read on) — kept in one place so the two never drift. + * should also grant read on), and {@code GitHubControlStore} (to resolve a control's domain + * for lookup and listing) — kept in one place so none of the three drift. They previously + * did: {@code GitHubControlStore} re-derived the domain via an inline substring match + * ({@code path.contains("controls/" + domain + "/")}), which — unlike this method's + * first-path-segment rule — matched {@code controls/} at any depth, not just the root. A + * control at a non-root path like {@code foo/controls/security/x.json} would resolve to + * domain {@code "security"} here but {@code "default"} there, so a grant for one domain + * wrongly allowed or denied access derived via the other. * *

Stays a static utility deliberately: it's a pure function of a {@link RegistryEntry} - * with no state and no configuration to inject — exactly the case static methods exist for. - * {@code GitHubControlStore.findControlEntry} re-derives the same domain by inline substring - * match rather than calling this — see the tracking issue for that drift.

+ * with no state and no configuration to inject — exactly the case static methods exist for.

*/ public final class ControlDomains { diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index 154b49c158..49aa530088 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -3,6 +3,7 @@ import org.finos.calm.domain.controls.ControlDetail; import org.finos.calm.domain.controls.CreateControlConfiguration; import org.finos.calm.domain.controls.CreateControlRequirement; +import org.finos.calm.domain.exception.ControlConfigurationNotFoundException; import org.finos.calm.domain.exception.ControlNotFoundException; import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; import org.finos.calm.domain.exception.DomainNotFoundException; @@ -398,15 +399,43 @@ void throw_unsupported_on_delete_control_requirement() { } @Test - void throw_unsupported_on_get_configurations_for_control() { - assertThrows(UnsupportedOperationException.class, + void return_no_configurations_for_a_control_that_has_none() throws Exception { + // Control configurations have no registry representation at all in GitHub mode - + // this is a genuine "there are none", not the write-unsupported 501 the old + // behaviour incorrectly returned for what is a GET-backed read. + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + RegistryResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + assertThat(store.getConfigurationsForControl(DOMAIN, HASH_ID), is(empty())); + } + + @Test + void throw_control_not_found_on_get_configurations_for_control_when_control_does_not_exist() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of()); + + assertThrows(DomainNotFoundException.class, () -> store.getConfigurationsForControl(DOMAIN, 1)); } @Test - void throw_unsupported_on_get_configuration_details_for_control() { - assertThrows(UnsupportedOperationException.class, - () -> store.getConfigurationDetailsForControl(DOMAIN, 1)); + void return_no_configuration_details_for_a_control_that_has_none() throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + RegistryResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + assertThat(store.getConfigurationDetailsForControl(DOMAIN, HASH_ID), is(empty())); } @Test @@ -422,15 +451,33 @@ void throw_unsupported_on_delete_control_configuration() { } @Test - void throw_unsupported_on_get_configuration_versions() { - assertThrows(UnsupportedOperationException.class, - () -> store.getConfigurationVersions(DOMAIN, 1, 1)); + void throw_configuration_not_found_on_get_configuration_versions() throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + RegistryResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + assertThrows(ControlConfigurationNotFoundException.class, + () -> store.getConfigurationVersions(DOMAIN, HASH_ID, 1)); } @Test - void throw_unsupported_on_get_configuration_for_version() { - assertThrows(UnsupportedOperationException.class, - () -> store.getConfigurationForVersion(DOMAIN, 1, 1, "1.0.0")); + void throw_configuration_not_found_on_get_configuration_for_version() throws Exception { + RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), + RegistryResourceType.CONTROL, "My Control", Instant.now()); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(entry)), + Map.of("finos:" + UNIQUE_ID, entry)); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(registryService.listByType("finos", RegistryResourceType.CONTROL)).thenReturn(List.of(entry)); + when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); + + assertThrows(ControlConfigurationNotFoundException.class, + () -> store.getConfigurationForVersion(DOMAIN, HASH_ID, 1, "1.0.0")); } @Test From 1f3b667861044aab3a8cb2b056428d097ddf9f8f Mon Sep 17 00:00:00 2001 From: James Gough Date: Fri, 11 Sep 2026 09:35:56 +0100 Subject: [PATCH 32/47] fix(calm-hub): make NamespaceAccessFilter use the shared grant-resolution path NamespaceAccessFilter re-derived readable namespaces from OIDC role/group membership directly, independently of UserAccessValidator - the same resolution SearchResource/DomainResource use. That second implementation silently diverged on two counts: calm.auth.allow-public-read was never consulted (public-read deployments still got narrowed to the caller's own OIDC groups for domain/control visibility), and UserAccessValidator's ancestor-chain AND rule wasn't applied. Delegate to UserAccessValidator.getReadableNamespaces instead. It already calls into UserAccessStore.getGrantsForUser, which in GitHub mode is GitHubUserAccessStore - the OIDC role resolution still happens, just in one place instead of two. Found by /code-review high. --- .../github/access/NamespaceAccessFilter.java | 52 ++++++++----- .../TestNamespaceAccessFilterShould.java | 75 ++++++++++++++----- 2 files changed, 88 insertions(+), 39 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceAccessFilter.java b/calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceAccessFilter.java index 80549bfb9a..b728042729 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceAccessFilter.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/access/NamespaceAccessFilter.java @@ -5,21 +5,33 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.finos.calm.security.OidcRoleResolver; +import org.finos.calm.security.UserAccessValidator; import org.finos.calm.store.github.registry.ResourceRegistry; -import org.finos.calm.store.github.sync.GitHubCloneManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; /** - * Resolves the set of namespaces the current caller can access based on their - * OIDC group membership and each namespace's configured access groups. Used by - * GitHub-mode stores to restrict domain/control visibility to namespaces the - * caller is authorised to read. + * Resolves the set of namespaces the current caller can access, for GitHub-mode stores + * that need to restrict domain/control visibility (which have no per-namespace resource + * to run the usual {@code @PermissionsAllowed} check against) to namespaces the caller + * can actually read. + * + *

Delegates the identity-based decision to {@link UserAccessValidator}, the same + * grant-resolution path {@code SearchResource}/{@code DomainResource} use, rather than + * re-deriving it from OIDC role/group membership directly. An earlier version of this + * class did exactly that — a second, independent implementation of "what can this user + * read" that silently diverged from {@link UserAccessValidator} on two counts: + * {@code calm.auth.allow-public-read} was never consulted (public-read deployments still + * got narrowed to the caller's own OIDC groups), and {@link UserAccessValidator}'s + * ancestor-chain AND rule wasn't applied. {@link UserAccessValidator#getReadableNamespaces} + * already calls into {@code UserAccessStore.getGrantsForUser}, which in GitHub mode is + * {@code GitHubUserAccessStore} — the OIDC role resolution still happens, just in one + * place instead of two. */ @LookupIfProperty(name = "calm.database.mode", stringValue = "github") @ApplicationScoped @@ -28,27 +40,27 @@ public class NamespaceAccessFilter { private static final Logger LOG = LoggerFactory.getLogger(NamespaceAccessFilter.class); private final SecurityIdentity identity; - private final OidcRoleResolver roleResolver; + private final UserAccessValidator accessValidator; private final ResourceRegistry registryService; - private final GitHubCloneManager cloneManager; private final boolean authEnabled; @Inject public NamespaceAccessFilter(SecurityIdentity identity, - OidcRoleResolver roleResolver, + UserAccessValidator accessValidator, ResourceRegistry registryService, - GitHubCloneManager cloneManager, @ConfigProperty(name = "calm.auth.enabled", defaultValue = "false") boolean authEnabled) { this.identity = identity; - this.roleResolver = roleResolver; + this.accessValidator = accessValidator; this.registryService = registryService; - this.cloneManager = cloneManager; this.authEnabled = authEnabled; } public Set getAccessibleNamespaces() { List allNamespaces = registryService.getSnapshot().getNamespaces(); + // Distinct from calm.auth.allow-public-read (handled inside UserAccessValidator, + // below): this is auth being off entirely, e.g. no-auth/standalone deployments, + // where there is no real identity to resolve grants against at all. if (!authEnabled) { return new HashSet<>(allNamespaces); } @@ -57,13 +69,15 @@ public Set getAccessibleNamespaces() { return Set.of(); } - Set accessible = new HashSet<>(); - for (String namespace : allNamespaces) { - Set accessGroups = cloneManager.getAccessGroupsForNamespace(namespace); - if (roleResolver.resolve(identity, accessGroups) != OidcRoleResolver.AccessLevel.NONE) { - accessible.add(namespace); - } - } + Optional> readable = accessValidator.getReadableNamespaces(identity.getPrincipal().getName()); + Set accessible = readable.isPresent() + ? new HashSet<>(readable.get()) + // Optional.empty() means "every namespace is readable" (allow-public-read, + // or a GLOBAL admin grant) - readable.get() has no namespace list to + // intersect against in that case, so fall back to every namespace the + // registry actually knows about. + : new HashSet<>(allNamespaces); + accessible.retainAll(allNamespaces); LOG.debug("User [{}] has access to {} of {} namespaces", identity.getPrincipal().getName(), accessible.size(), allNamespaces.size()); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/access/TestNamespaceAccessFilterShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/access/TestNamespaceAccessFilterShould.java index 10ebc50c35..a7850f36db 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/access/TestNamespaceAccessFilterShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/access/TestNamespaceAccessFilterShould.java @@ -1,11 +1,9 @@ package org.finos.calm.store.github.access; import io.quarkus.security.identity.SecurityIdentity; -import org.finos.calm.security.OidcRoleResolver; +import org.finos.calm.security.UserAccessValidator; import org.finos.calm.store.github.registry.RegistrySnapshot; import org.finos.calm.store.github.registry.ResourceRegistry; -import org.finos.calm.store.github.sync.GitHubCloneManager; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -14,13 +12,13 @@ import java.security.Principal; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -33,16 +31,13 @@ class TestNamespaceAccessFilterShould { private Principal principal; @Mock - private OidcRoleResolver roleResolver; + private UserAccessValidator accessValidator; @Mock private ResourceRegistry registryService; - @Mock - private GitHubCloneManager cloneManager; - private NamespaceAccessFilter filterWithAuth(boolean authEnabled) { - return new NamespaceAccessFilter(identity, roleResolver, registryService, cloneManager, authEnabled); + return new NamespaceAccessFilter(identity, accessValidator, registryService, authEnabled); } @Test @@ -72,7 +67,7 @@ void return_empty_when_identity_is_anonymous() { } @Test - void return_only_accessible_namespaces_based_on_oidc_groups() { + void return_only_the_namespaces_the_access_validator_says_are_readable() { NamespaceAccessFilter filter = filterWithAuth(true); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("finos", List.of(), "private", List.of(), "restricted", List.of()), @@ -82,13 +77,8 @@ void return_only_accessible_namespaces_based_on_oidc_groups() { when(identity.getPrincipal()).thenReturn(principal); when(principal.getName()).thenReturn("testuser"); - when(cloneManager.getAccessGroupsForNamespace("finos")).thenReturn(Set.of("team-a")); - when(cloneManager.getAccessGroupsForNamespace("private")).thenReturn(Set.of("team-b")); - when(cloneManager.getAccessGroupsForNamespace("restricted")).thenReturn(Set.of("team-c")); - - when(roleResolver.resolve(eq(identity), eq(Set.of("team-a")))).thenReturn(OidcRoleResolver.AccessLevel.READ); - when(roleResolver.resolve(eq(identity), eq(Set.of("team-b")))).thenReturn(OidcRoleResolver.AccessLevel.NONE); - when(roleResolver.resolve(eq(identity), eq(Set.of("team-c")))).thenReturn(OidcRoleResolver.AccessLevel.READ); + when(accessValidator.getReadableNamespaces("testuser")) + .thenReturn(Optional.of(Set.of("finos", "restricted"))); Set result = filter.getAccessibleNamespaces(); @@ -96,7 +86,51 @@ void return_only_accessible_namespaces_based_on_oidc_groups() { } @Test - void return_empty_when_no_namespaces_match() { + void return_every_registered_namespace_when_the_access_validator_says_everything_is_readable() { + // Optional.empty() from UserAccessValidator means calm.auth.allow-public-read is + // true, or the caller holds a GLOBAL admin grant - either way, every namespace the + // registry actually knows about, not just the ones the caller has an explicit + // grant for. + NamespaceAccessFilter filter = filterWithAuth(true); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of(), "private", List.of()), + Map.of()); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(identity.isAnonymous()).thenReturn(false); + when(identity.getPrincipal()).thenReturn(principal); + when(principal.getName()).thenReturn("testuser"); + + when(accessValidator.getReadableNamespaces("testuser")).thenReturn(Optional.empty()); + + Set result = filter.getAccessibleNamespaces(); + + assertThat(result, containsInAnyOrder("finos", "private")); + } + + @Test + void exclude_a_readable_namespace_the_registry_no_longer_knows_about() { + // UserAccessValidator's grants can be stale relative to the registry's current + // snapshot (a namespace removed from calm.github.namespaces since the grant was + // computed) - the accessible set must never include a namespace the caller + // couldn't actually read anything from. + NamespaceAccessFilter filter = filterWithAuth(true); + RegistrySnapshot snapshot = new RegistrySnapshot( + Map.of("finos", List.of()), Map.of()); + when(registryService.getSnapshot()).thenReturn(snapshot); + when(identity.isAnonymous()).thenReturn(false); + when(identity.getPrincipal()).thenReturn(principal); + when(principal.getName()).thenReturn("testuser"); + + when(accessValidator.getReadableNamespaces("testuser")) + .thenReturn(Optional.of(Set.of("finos", "removed-namespace"))); + + Set result = filter.getAccessibleNamespaces(); + + assertThat(result, containsInAnyOrder("finos")); + } + + @Test + void return_empty_when_no_namespaces_are_readable() { NamespaceAccessFilter filter = filterWithAuth(true); RegistrySnapshot snapshot = new RegistrySnapshot( Map.of("private", List.of()), Map.of()); @@ -105,8 +139,7 @@ void return_empty_when_no_namespaces_match() { when(identity.getPrincipal()).thenReturn(principal); when(principal.getName()).thenReturn("testuser"); - when(cloneManager.getAccessGroupsForNamespace("private")).thenReturn(Set.of("admins")); - when(roleResolver.resolve(eq(identity), eq(Set.of("admins")))).thenReturn(OidcRoleResolver.AccessLevel.NONE); + when(accessValidator.getReadableNamespaces("testuser")).thenReturn(Optional.of(Set.of())); Set result = filter.getAccessibleNamespaces(); @@ -121,6 +154,8 @@ void return_empty_when_no_namespaces_registered() { when(identity.getPrincipal()).thenReturn(principal); when(principal.getName()).thenReturn("testuser"); + when(accessValidator.getReadableNamespaces("testuser")).thenReturn(Optional.of(Set.of())); + Set result = filter.getAccessibleNamespaces(); assertThat(result, is(empty())); From 249f44fa2f44adb1527aa23ecbd3618b9ffd9fe5 Mon Sep 17 00:00:00 2001 From: James Gough Date: Fri, 11 Sep 2026 09:36:08 +0100 Subject: [PATCH 33/47] fix(calm-hub): add a git transport timeout, pull the configured branch not the local one - Neither cloneRepo nor pullRepo set a JGit transport timeout. Combined with GitHubSyncScheduler's concurrentExecution = SKIP, a hung/unresponsive remote blocked one sync tick indefinitely and then blocked every future tick from ever starting - SKIP only prevents tick-vs-tick overlap, it doesn't time out a stuck execution. Add calm.github.git.timeout-seconds (default 30s) and apply it to both commands - pullRepo reset to origin/ instead of the configured branch. If an operator changes a namespace's branch without wiping its clone directory, cloneAll()/pullAll() still see a valid repo and resync via pullRepo instead of a fresh clone - resetting to the stale local branch left the working tree permanently pointed at the old branch while GitHubFileHistoryClient queried version history against the newly configured one. pullRepo now takes the configured branch explicitly and resets to that Found by /code-review high. --- .../store/github/sync/GitHubCloneManager.java | 5 +- .../store/github/sync/GitHubRepoSync.java | 34 +++++++++++--- .../sync/TestGitHubCloneManagerShould.java | 24 ++++++++-- .../github/sync/TestGitHubRepoSyncShould.java | 46 ++++++++++++++++--- 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubCloneManager.java b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubCloneManager.java index 8b009f10df..840cdff623 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubCloneManager.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubCloneManager.java @@ -70,7 +70,7 @@ public void cloneAll() { boolean success; if (repoSync.isValidRepo(targetDir)) { - success = repoSync.pullRepo(targetDir, config.getServiceToken()); + success = repoSync.pullRepo(targetDir, repo.branch(), config.getServiceToken()); } else { success = repoSync.cloneRepo(repo.repoFullName(), repo.branch(), targetDir, config.getServiceToken()); } @@ -104,10 +104,11 @@ public void pullAll() { for (Map.Entry entry : namespaceRepos.entrySet()) { String namespace = entry.getKey(); + NamespaceRepo repo = entry.getValue(); Path targetDir = config.getCloneDirectory().resolve(namespace); if (repoSync.isValidRepo(targetDir)) { - if (repoSync.pullRepo(targetDir, config.getServiceToken())) { + if (repoSync.pullRepo(targetDir, repo.branch(), config.getServiceToken())) { succeeded++; } else { failed++; diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubRepoSync.java b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubRepoSync.java index e7e59ab2b0..e111184a00 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubRepoSync.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubRepoSync.java @@ -20,6 +20,14 @@ /** * Handles git clone and sync operations for a single repository. * Uses fetch + reset instead of pull to handle upstream force-pushes gracefully. + * + *

Both {@link #cloneRepo} and {@link #pullRepo} set an explicit JGit transport timeout + * ({@code calm.github.git.timeout-seconds}) — without one, an unresponsive remote hangs the + * command indefinitely. That matters more here than a plain slow request: {@code pullRepo} + * runs from {@link GitHubSyncScheduler}, which is {@code @Scheduled(concurrentExecution = + * SKIP)} — a hung fetch doesn't just block one tick, it blocks every future tick from ever + * starting (SKIP only prevents overlap, it doesn't time out a stuck execution), so the + * namespace silently stops updating until the process is restarted. */ @LookupIfProperty(name = "calm.database.mode", stringValue = "github") @ApplicationScoped @@ -28,10 +36,13 @@ public class GitHubRepoSync { private static final Logger LOG = LoggerFactory.getLogger(GitHubRepoSync.class); private final String githubBaseUrl; + private final int gitTimeoutSeconds; @Inject - public GitHubRepoSync(@ConfigProperty(name = "calm.github.oauth.base-url", defaultValue = "https://github.com") String githubBaseUrl) { + public GitHubRepoSync(@ConfigProperty(name = "calm.github.oauth.base-url", defaultValue = "https://github.com") String githubBaseUrl, + @ConfigProperty(name = "calm.github.git.timeout-seconds", defaultValue = "30") int gitTimeoutSeconds) { this.githubBaseUrl = githubBaseUrl; + this.gitTimeoutSeconds = gitTimeoutSeconds; } public boolean cloneRepo(String repoFullName, String branch, Path targetDir, String token) { @@ -42,7 +53,8 @@ public boolean cloneRepo(String repoFullName, String branch, Path targetDir, Str .setURI(url) .setDirectory(targetDir.toFile()) .setBranch(branch) - .setDepth(1); + .setDepth(1) + .setTimeout(gitTimeoutSeconds); if (token != null && !token.isBlank()) { clone.setCredentialsProvider( @@ -59,9 +71,19 @@ public boolean cloneRepo(String repoFullName, String branch, Path targetDir, Str } } - public boolean pullRepo(Path repoDir, String token) { + /** + * Fetches and hard-resets {@code repoDir} to {@code origin/}. {@code branch} + * is the configured branch, deliberately not whatever the local checkout currently + * happens to be on: if an operator changes a namespace's configured branch without + * wiping its clone directory, {@code isValidRepo} still sees a valid repo and this + * runs instead of a fresh clone - resetting to the configured branch (rather than the + * stale local one) is what makes that change actually take effect, instead of leaving + * the working tree permanently pointed at the old branch while + * {@code GitHubFileHistoryClient} queries version history against the new one. + */ + public boolean pullRepo(Path repoDir, String branch, String token) { try (Git git = Git.open(repoDir.toFile())) { - var fetchCommand = git.fetch(); + var fetchCommand = git.fetch().setTimeout(gitTimeoutSeconds); if (token != null && !token.isBlank()) { fetchCommand.setCredentialsProvider( @@ -71,10 +93,10 @@ public boolean pullRepo(Path repoDir, String token) { fetchCommand.call(); git.reset() .setMode(ResetCommand.ResetType.HARD) - .setRef("origin/" + git.getRepository().getBranch()) + .setRef("origin/" + branch) .call(); - LOG.debug("Fetched and reset for {}", repoDir.getFileName()); + LOG.debug("Fetched and reset {} to origin/{}", repoDir.getFileName(), branch); return true; } catch (GitAPIException | IOException e) { LOG.error("Failed to sync {}: {}", repoDir.getFileName(), e.getMessage()); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubCloneManagerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubCloneManagerShould.java index b84dccc855..6479357de3 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubCloneManagerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubCloneManagerShould.java @@ -21,6 +21,7 @@ import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @MockitoSettings(strictness = Strictness.LENIENT) @@ -93,13 +94,28 @@ void transition_to_degraded_when_some_clones_fail() { void pull_instead_of_clone_when_repo_already_exists() { cloneManager.registerNamespace("finos", "finos/architecture-as-code", "main", Set.of()); when(repoSync.isValidRepo(any())).thenReturn(true); - when(repoSync.pullRepo(any(), eq("test-token"))).thenReturn(true); + when(repoSync.pullRepo(any(), any(), eq("test-token"))).thenReturn(true); cloneManager.cloneAll(); assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.READY)); } + @Test + void pull_the_configured_branch_not_whatever_is_locally_checked_out() { + // A namespace's clone directory can already exist on disk (e.g. from before its + // configured branch changed) - cloneAll() must resync it to the CONFIGURED + // branch, not silently keep whatever branch happens to be checked out locally. + cloneManager.registerNamespace("finos", "finos/architecture-as-code", "release", Set.of()); + when(repoSync.isValidRepo(any())).thenReturn(true); + when(repoSync.pullRepo(any(), eq("release"), eq("test-token"))).thenReturn(true); + + cloneManager.cloneAll(); + + verify(repoSync).pullRepo(Path.of("/tmp/test-clones/finos"), "release", "test-token"); + assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.READY)); + } + @Test void skip_pull_all_when_still_cloning() { cloneManager.registerNamespace("finos", "finos/repo", "main", Set.of()); @@ -116,7 +132,7 @@ void pull_all_repos_and_stay_ready() { cloneManager.cloneAll(); when(repoSync.isValidRepo(any())).thenReturn(true); - when(repoSync.pullRepo(any(), any())).thenReturn(true); + when(repoSync.pullRepo(any(), any(), any())).thenReturn(true); cloneManager.pullAll(); assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.READY)); @@ -186,8 +202,8 @@ void transition_to_degraded_on_pull_all_when_some_fail() { when(repoSync.isValidRepo(Path.of("/tmp/test-clones/ns1"))).thenReturn(true); when(repoSync.isValidRepo(Path.of("/tmp/test-clones/ns2"))).thenReturn(true); - when(repoSync.pullRepo(Path.of("/tmp/test-clones/ns1"), "test-token")).thenReturn(true); - when(repoSync.pullRepo(Path.of("/tmp/test-clones/ns2"), "test-token")).thenReturn(false); + when(repoSync.pullRepo(Path.of("/tmp/test-clones/ns1"), "main", "test-token")).thenReturn(true); + when(repoSync.pullRepo(Path.of("/tmp/test-clones/ns2"), "main", "test-token")).thenReturn(false); cloneManager.pullAll(); assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.DEGRADED)); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubRepoSyncShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubRepoSyncShould.java index e8819ae912..fa65f6ac55 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubRepoSyncShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubRepoSyncShould.java @@ -25,7 +25,7 @@ class TestGitHubRepoSyncShould { @BeforeEach void setup() { - repoSync = new GitHubRepoSync("https://github.com"); + repoSync = new GitHubRepoSync("https://github.com", 30); } @Test @@ -57,7 +57,7 @@ void return_false_when_clone_fails_with_invalid_url() { @Test void return_false_when_pull_on_non_repo_directory() { - boolean result = repoSync.pullRepo(tempDir, "token"); + boolean result = repoSync.pullRepo(tempDir, "main", "token"); assertThat(result, is(false)); } @@ -98,10 +98,44 @@ void pull_local_repo_successfully() throws GitAPIException, IOException { // clone done } - boolean result = repoSync.pullRepo(cloneDir, null); + boolean result = repoSync.pullRepo(cloneDir, "main", null); assertThat(result, is(true)); } + @Test + void reset_to_the_configured_branch_even_when_a_different_branch_is_locally_checked_out() throws GitAPIException, IOException { + // Simulates a namespace whose configured branch changed (main -> release) without + // its clone directory being wiped: the local checkout is still on "main", but + // pullRepo must move the working tree to match the newly configured branch, not + // silently keep serving whatever branch happens to be checked out. + Path originDir = tempDir.resolve("origin-branch-switch"); + Files.createDirectories(originDir); + try (Git origin = Git.init().setDirectory(originDir.toFile()).setInitialBranch("main").call()) { + Files.writeString(originDir.resolve("content.txt"), "main content"); + origin.add().addFilepattern("content.txt").call(); + origin.commit().setMessage("main commit").call(); + + origin.checkout().setCreateBranch(true).setName("release").call(); + Files.writeString(originDir.resolve("content.txt"), "release content"); + origin.add().addFilepattern("content.txt").call(); + origin.commit().setMessage("release commit").call(); + } + + Path cloneDir = tempDir.resolve("clone-branch-switch"); + try (Git ignored = Git.cloneRepository() + .setURI(originDir.toUri().toString()) + .setDirectory(cloneDir.toFile()) + .setBranch("main") + .call()) { + // cloned on "main" - stands in for a clone made before the branch config changed + } + + boolean result = repoSync.pullRepo(cloneDir, "release", null); + + assertThat(result, is(true)); + assertThat(Files.readString(cloneDir.resolve("content.txt")), is("release content")); + } + @Test void pull_local_repo_successfully_with_a_credentials_provider_attached() throws GitAPIException, IOException { Path originDir = tempDir.resolve("origin-with-token"); @@ -124,7 +158,7 @@ void pull_local_repo_successfully_with_a_credentials_provider_attached() throws // A local file:// remote ignores the credentials provider entirely, but this // still exercises the setCredentialsProvider branch (a non-blank token) that // the "" and null cases used by the other pull tests don't reach. - boolean result = repoSync.pullRepo(cloneDir, "a-real-looking-token"); + boolean result = repoSync.pullRepo(cloneDir, "main", "a-real-looking-token"); assertThat(result, is(true)); } @@ -138,7 +172,7 @@ void clone_a_real_repo_successfully_via_file_protocol() throws GitAPIException, origin.commit().setMessage("init").call(); } - GitHubRepoSync localRepoSync = new GitHubRepoSync(tempDir.toUri().toString().replaceAll("/$", "")); + GitHubRepoSync localRepoSync = new GitHubRepoSync(tempDir.toUri().toString().replaceAll("/$", ""), 30); Path cloneTarget = tempDir.resolve("cloned-real"); boolean result = localRepoSync.cloneRepo("myrepo", "main", cloneTarget, null); @@ -166,7 +200,7 @@ void pull_returns_true_when_already_up_to_date() throws GitAPIException, IOExcep // clone done } - boolean result = repoSync.pullRepo(cloneDir, ""); + boolean result = repoSync.pullRepo(cloneDir, "main", ""); assertThat(result, is(true)); } From 8988177d05d03d2b5e4e3cab6c20cb5d85308435 Mon Sep 17 00:00:00 2001 From: James Gough Date: Fri, 11 Sep 2026 09:36:18 +0100 Subject: [PATCH 34/47] fix(calm-hub): record a sync failure metric when every namespace fails to pull sync() recorded a success metric whenever pullAll() didn't throw, but pullAll() never throws - every per-repo git failure is caught inside GitHubRepoSync and folded into GitHubCloneManager's state instead. An unreachable GitHub, or an expired token, reported as a healthy sync for every namespace, hiding a total outage from monitoring. Check the clone state pullAll() actually left behind and record failure for FAILED or DEGRADED. Found by /code-review high. --- .../github/sync/GitHubSyncScheduler.java | 17 +++++++++-- .../sync/TestGitHubSyncSchedulerShould.java | 30 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubSyncScheduler.java b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubSyncScheduler.java index 34d307fb80..0c7cd2db23 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubSyncScheduler.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubSyncScheduler.java @@ -64,8 +64,21 @@ void sync() { metrics.recordRegistryRebuild(Duration.between(rebuildStart, Instant.now())); Duration total = Duration.between(start, Instant.now()); - metrics.recordSyncSuccess(total); - LOG.debug("Sync completed in {}ms", total.toMillis()); + // pullAll() never throws - every per-repo git failure is caught inside + // GitHubRepoSync and folded into cloneManager's state instead. Recording + // success purely on "no exception" would mean an unreachable GitHub, or an + // expired token, reports as a healthy sync for every namespace, forever - + // check the outcome pullAll() actually left behind. + GitHubCloneManager.State stateAfterSync = cloneManager.getState(); + if (stateAfterSync == GitHubCloneManager.State.FAILED + || stateAfterSync == GitHubCloneManager.State.DEGRADED) { + metrics.recordSyncFailure(total); + LOG.error("Sync completed in {}ms but left clone state {} - at least one namespace failed to pull", + total.toMillis(), stateAfterSync); + } else { + metrics.recordSyncSuccess(total); + LOG.debug("Sync completed in {}ms", total.toMillis()); + } } catch (Exception e) { Duration total = Duration.between(start, Instant.now()); metrics.recordSyncFailure(total); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubSyncSchedulerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubSyncSchedulerShould.java index b140a81e75..7ef3c1d6c0 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubSyncSchedulerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubSyncSchedulerShould.java @@ -61,6 +61,7 @@ void pull_all_and_rebuild_registry_on_sync() { GitHubSyncScheduler scheduler = schedulerFor("github"); when(cloneManager.hasNamespaces()).thenReturn(true); when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of("finos", Path.of("/tmp/finos"))); + when(cloneManager.getState()).thenReturn(GitHubCloneManager.State.READY); scheduler.sync(); @@ -80,4 +81,33 @@ void record_failure_metric_when_sync_throws() { verify(metrics).recordSyncFailure(any()); } + + @Test + void record_failure_metric_when_pull_all_leaves_clone_state_failed() { + // pullAll() never throws - every per-repo git error is caught internally and + // folded into cloneManager's state instead. A sync where every namespace failed + // to pull must not report as a healthy success just because nothing threw. + GitHubSyncScheduler scheduler = schedulerFor("github"); + when(cloneManager.hasNamespaces()).thenReturn(true); + when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of("finos", Path.of("/tmp/finos"))); + when(cloneManager.getState()).thenReturn(GitHubCloneManager.State.FAILED); + + scheduler.sync(); + + verify(metrics).recordSyncFailure(any()); + verify(metrics, never()).recordSyncSuccess(any()); + } + + @Test + void record_failure_metric_when_pull_all_leaves_clone_state_degraded() { + GitHubSyncScheduler scheduler = schedulerFor("github"); + when(cloneManager.hasNamespaces()).thenReturn(true); + when(cloneManager.getNamespaceClonePaths()).thenReturn(Map.of("finos", Path.of("/tmp/finos"))); + when(cloneManager.getState()).thenReturn(GitHubCloneManager.State.DEGRADED); + + scheduler.sync(); + + verify(metrics).recordSyncFailure(any()); + verify(metrics, never()).recordSyncSuccess(any()); + } } From 71718d389560f3c4683335f4bd9e377a5883bc14 Mon Sep 17 00:00:00 2001 From: James Gough Date: Fri, 11 Sep 2026 09:36:30 +0100 Subject: [PATCH 35/47] fix(calm-hub): add a GitHub-mode branch to PatternLayoutStoreProducer Every sibling *StoreProducer this rework touched got a GitHub branch; PatternLayoutStoreProducer was missed. In calm.database.mode=github, a pattern-layout request fell through to MongoPatternLayoutStore, which is itself @LookupIfProperty(stringValue = "mongo")-gated and so doesn't exist as a bean in GitHub mode - every request resolved to an unsatisfied injection and failed with a 500. Add GitHubPatternLayoutStore, mirroring GitHubLayoutStore's empty/no-op pattern (layouts are client-side state in GitHub mode), and wire it into the producer alongside the DatabaseMode constant rename the rest of this producer's siblings already had. Found by /code-review high. --- .../github/GitHubPatternLayoutStore.java | 42 +++++++++++++++++ .../producer/PatternLayoutStoreProducer.java | 12 ++++- .../TestGitHubPatternLayoutStoreShould.java | 46 +++++++++++++++++++ .../TestPatternLayoutStoreProducerShould.java | 19 ++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternLayoutStore.java create mode 100644 calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternLayoutStoreShould.java diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternLayoutStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternLayoutStore.java new file mode 100644 index 0000000000..899365568f --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternLayoutStore.java @@ -0,0 +1,42 @@ +package org.finos.calm.store.github; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Typed; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.PatternNotFoundException; +import org.finos.calm.store.PatternLayoutStore; + +import java.util.List; +import java.util.Optional; + +/** + * Layout persistence is not supported in GitHub mode - layouts are client-side state + * managed via the browser's localStorage. All methods return empty/no-op responses, + * mirroring {@link GitHubLayoutStore}'s equivalent for architectures. + * + *

Without this class, {@code PatternLayoutStoreProducer} had no GitHub branch at all - + * it fell through to {@code MongoPatternLayoutStore}, which is itself + * {@code @LookupIfProperty(stringValue = "mongo")}-gated and so doesn't exist as a bean in + * GitHub mode: every pattern-layout request resolved to an unsatisfied injection and + * failed with a 500, not a clean empty/no-op response. + */ +@ApplicationScoped +@Typed(GitHubPatternLayoutStore.class) +public class GitHubPatternLayoutStore implements PatternLayoutStore { + + @Override + public Optional getLayout(String namespace, int patternId) throws NamespaceNotFoundException { + return Optional.empty(); + } + + @Override + public void upsertLayout(String namespace, int patternId, String layoutJson) + throws NamespaceNotFoundException, PatternNotFoundException { + // no-op — layouts not persisted in GitHub mode + } + + @Override + public List getPatternIdsWithLayoutForNamespace(String namespace) throws NamespaceNotFoundException { + return List.of(); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/producer/PatternLayoutStoreProducer.java b/calm-hub/src/main/java/org/finos/calm/store/producer/PatternLayoutStoreProducer.java index dd7e11f274..fe981be25e 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/producer/PatternLayoutStoreProducer.java +++ b/calm-hub/src/main/java/org/finos/calm/store/producer/PatternLayoutStoreProducer.java @@ -5,7 +5,9 @@ import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.PatternLayoutStore; +import org.finos.calm.store.github.GitHubPatternLayoutStore; import org.finos.calm.store.mongo.MongoPatternLayoutStore; import org.finos.calm.store.nitrite.NitritePatternLayoutStore; @@ -26,6 +28,9 @@ public class PatternLayoutStoreProducer { @Inject Instance nitritePatternLayoutStore; + @Inject + Instance gitHubPatternLayoutStore; + /** * Produces the appropriate PatternLayoutStore implementation based on the configured database mode. * @@ -34,9 +39,12 @@ public class PatternLayoutStoreProducer { @Produces @ApplicationScoped public PatternLayoutStore producePatternLayoutStore() { - if ("standalone".equals(databaseMode)) { + if (DatabaseMode.GITHUB.equals(databaseMode)) { + return gitHubPatternLayoutStore.get(); + } else if (DatabaseMode.STANDALONE.equals(databaseMode)) { return nitritePatternLayoutStore.get(); + } else { + return mongoPatternLayoutStore.get(); } - return mongoPatternLayoutStore.get(); } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternLayoutStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternLayoutStoreShould.java new file mode 100644 index 0000000000..62a6b5cee0 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubPatternLayoutStoreShould.java @@ -0,0 +1,46 @@ +package org.finos.calm.store.github; + +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@ExtendWith(MockitoExtension.class) +class TestGitHubPatternLayoutStoreShould { + + private GitHubPatternLayoutStore store; + + @BeforeEach + void setup() { + store = new GitHubPatternLayoutStore(); + } + + @Test + void return_empty_optional_for_get_layout() throws NamespaceNotFoundException { + Optional result = store.getLayout("finos", 1); + + assertThat(result, equalTo(Optional.empty())); + } + + @Test + void no_op_on_upsert_layout() { + assertDoesNotThrow(() -> store.upsertLayout("finos", 1, "{\"nodes\":[]}")); + } + + @Test + void return_empty_list_for_pattern_ids_with_layout() throws NamespaceNotFoundException { + List result = store.getPatternIdsWithLayoutForNamespace("finos"); + + assertThat(result, is(empty())); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/producer/TestPatternLayoutStoreProducerShould.java b/calm-hub/src/test/java/org/finos/calm/store/producer/TestPatternLayoutStoreProducerShould.java index 2e7c18de08..24121b8f7c 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/producer/TestPatternLayoutStoreProducerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/producer/TestPatternLayoutStoreProducerShould.java @@ -1,6 +1,8 @@ package org.finos.calm.store.producer; +import org.finos.calm.config.DatabaseMode; import org.finos.calm.store.PatternLayoutStore; +import org.finos.calm.store.github.GitHubPatternLayoutStore; import org.finos.calm.store.mongo.MongoPatternLayoutStore; import org.finos.calm.store.nitrite.NitritePatternLayoutStore; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +35,12 @@ public class TestPatternLayoutStoreProducerShould { @Mock Instance nitritePatternLayoutStoreInstance; + @Mock + GitHubPatternLayoutStore gitHubPatternLayoutStore; + + @Mock + Instance gitHubPatternLayoutStoreInstance; + private PatternLayoutStoreProducer patternLayoutStoreProducer; @BeforeEach @@ -42,6 +50,8 @@ void setup() { patternLayoutStoreProducer.mongoPatternLayoutStore = mongoPatternLayoutStoreInstance; when(nitritePatternLayoutStoreInstance.get()).thenReturn(nitritePatternLayoutStore); patternLayoutStoreProducer.nitritePatternLayoutStore = nitritePatternLayoutStoreInstance; + when(gitHubPatternLayoutStoreInstance.get()).thenReturn(gitHubPatternLayoutStore); + patternLayoutStoreProducer.gitHubPatternLayoutStore = gitHubPatternLayoutStoreInstance; } @Test @@ -79,4 +89,13 @@ void return_mongo_pattern_layout_store_when_database_mode_is_not_recognized() { // Then assertThat(result, is(sameInstance(mongoPatternLayoutStore))); } + + @Test + void return_github_pattern_layout_store_when_database_mode_is_github() { + patternLayoutStoreProducer.databaseMode = DatabaseMode.GITHUB; + + PatternLayoutStore result = patternLayoutStoreProducer.producePatternLayoutStore(); + + assertThat(result, is(sameInstance(gitHubPatternLayoutStore))); + } } From 0f25dbe8bead9f3ef48770489894b9ca41dad2e6 Mon Sep 17 00:00:00 2001 From: James Gough Date: Fri, 11 Sep 2026 09:36:42 +0100 Subject: [PATCH 36/47] fix(calm-hub): make a uniqueId collision deterministic and observable The qualified-id index is keyed only by namespace + uniqueId. A uniqueId without an explicit "unique-id" field falls back to the filename minus extension, so two files with the same basename in different subdirectories (e.g. patterns/a/foo.json and patterns/b/foo.json) collide - rebuild() silently overwrote one with the other, and since Files.walk's iteration order is unspecified, which one survived could change between rebuilds of otherwise-unchanged content. listByType is unaffected (it reads the per-namespace list, not the qualified-id index), but a direct findByUniqueId lookup - and anything built on it, e.g. resource-mapping lookups - could only ever resolve to one of the two. This doesn't resolve the underlying collision (that's the scoped-out ID-collision-detection work tracked on #3079); it makes the outcome deterministic (entries sorted by path before indexing) and logs the collision so a colliding repo layout is diagnosable rather than a silent "wrong document served" surprise. Found by /code-review high. --- .../github/registry/ResourceRegistry.java | 24 +++++++++- .../registry/TestResourceRegistryShould.java | 46 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/registry/ResourceRegistry.java b/calm-hub/src/main/java/org/finos/calm/store/github/registry/ResourceRegistry.java index 1905fa2aa1..1ff369e3e3 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/registry/ResourceRegistry.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/registry/ResourceRegistry.java @@ -16,6 +16,7 @@ import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -59,7 +60,23 @@ public void rebuild(Map namespaceClonePaths) { byNamespace.put(namespace, entries); for (RegistryEntry registryEntry : entries) { - byQualifiedId.put(namespace + ":" + registryEntry.uniqueId(), registryEntry); + // A uniqueId is only guaranteed unique when a document sets its own + // "unique-id" - the path-derived fallback (extractUniqueId) is just the + // filename minus extension, so two files with the same name in different + // subdirectories (e.g. patterns/a/foo.json and patterns/b/foo.json) + // collide here. listByType still lists both (byNamespace is unaffected), + // but a direct findByUniqueId lookup can only ever resolve to one - log + // it so a colliding repo layout is at least diagnosable, not a silent + // "wrong document served" surprise. entries is sorted by path below, so + // which one wins is at least deterministic across rebuilds. + String qualifiedId = namespace + ":" + registryEntry.uniqueId(); + RegistryEntry previous = byQualifiedId.put(qualifiedId, registryEntry); + if (previous != null && !previous.filePath().equals(registryEntry.filePath())) { + LOG.warn("uniqueId collision in namespace [{}]: [{}] and [{}] both resolve to id [{}] - " + + "only [{}] is reachable via a direct id lookup", + namespace, previous.filePath(), registryEntry.filePath(), registryEntry.uniqueId(), + registryEntry.filePath()); + } } } @@ -106,6 +123,11 @@ private List scanDirectory(String namespace, Path root) { LOG.error("Failed to scan directory for namespace [{}]: {}", namespace, root, e); } + // Files.walk's iteration order is unspecified - without sorting, a + // uniqueId collision (see rebuild()) could pick a different winner on every + // rebuild even though the repo content hasn't changed. Sorting by path makes + // that choice at least stable. + entries.sort(Comparator.comparing(e -> e.filePath().toString())); return entries; } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestResourceRegistryShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestResourceRegistryShould.java index ff4eb0e9fe..fa0dfcfe25 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestResourceRegistryShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/registry/TestResourceRegistryShould.java @@ -295,4 +295,50 @@ void handle_nonexistent_clone_path() { List entries = registryService.listByType("finos", RegistryResourceType.ARCHITECTURE); assertThat(entries, is(empty())); } + + @Test + void list_both_entries_by_type_even_when_their_fallback_derived_unique_ids_collide() throws IOException { + // Two files in different subdirectories with the same basename, neither setting + // its own "unique-id" - both fall back to "foo", colliding in the qualified-id + // index. listByType (backed by the per-namespace list, not that index) must + // still surface both. + Path patternsA = tempDir.resolve("patterns/a"); + Path patternsB = tempDir.resolve("patterns/b"); + Files.createDirectories(patternsA); + Files.createDirectories(patternsB); + Files.writeString(patternsA.resolve("foo.json"), "{\"name\": \"Foo A\", \"nodes\": []}"); + Files.writeString(patternsB.resolve("foo.json"), "{\"name\": \"Foo B\", \"nodes\": []}"); + + registryService.rebuild(Map.of("finos", tempDir)); + + List entries = registryService.listByType("finos", RegistryResourceType.PATTERN); + assertThat(entries, hasSize(2)); + } + + @Test + void resolve_a_colliding_unique_id_to_the_same_entry_deterministically_across_rebuilds() throws IOException { + // findByUniqueId can only ever resolve one of the two colliding entries (that's + // the known, tracked limitation - see the class javadoc on the index build). + // What must hold is that repeated rebuilds of identical, unchanged content + // resolve to the SAME one every time, not whichever the filesystem happened to + // walk first. + Path patternsA = tempDir.resolve("patterns/a"); + Path patternsB = tempDir.resolve("patterns/b"); + Files.createDirectories(patternsA); + Files.createDirectories(patternsB); + Files.writeString(patternsA.resolve("foo.json"), "{\"name\": \"Foo A\", \"nodes\": []}"); + Files.writeString(patternsB.resolve("foo.json"), "{\"name\": \"Foo B\", \"nodes\": []}"); + + registryService.rebuild(Map.of("finos", tempDir)); + Optional firstRebuild = registryService.findByUniqueId("finos", "foo"); + + registryService.rebuild(Map.of("finos", tempDir)); + Optional secondRebuild = registryService.findByUniqueId("finos", "foo"); + + assertThat(firstRebuild.isPresent(), is(true)); + assertThat(secondRebuild, equalTo(firstRebuild)); + // Sorted by path ascending, then indexed in that order - the alphabetically + // last path ("patterns/b/foo.json") is put into the map last, so it wins. + assertThat(firstRebuild.get().filePath().toString(), equalTo("patterns/b/foo.json")); + } } From 3fce8efe13010e90a85a8293dbd07466a7157b6d Mon Sep 17 00:00:00 2001 From: James Gough Date: Fri, 11 Sep 2026 09:36:52 +0100 Subject: [PATCH 37/47] fix(calm-hub): stop one search result type from starving the others The merged, all-types stream was capped at MAX_RESULTS_PER_TYPE * 7 before being split by type - a namespace with many matching architectures could exhaust that cap before entries of other types were ever reached in the stream, so a real pattern/flow/standard match came back empty even though it existed. filterByType already caps each type independently, so the premature combined cap was redundant as well as harmful; the registry is an in-memory index, not an external call, so there's no cost reason to cut the merge short. Removed. Found by /code-review high. --- .../calm/store/github/GitHubSearchStore.java | 8 ++++- .../github/TestGitHubSearchStoreShould.java | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java index b076dfb6f3..ac42edb472 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java @@ -41,12 +41,18 @@ public GroupedSearchResults search(String query, Optional> readableN List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of()); } + // No cap on the merged stream here, deliberately: filterByType below already caps + // each type independently to MAX_RESULTS_PER_TYPE. A single combined cap applied + // before the per-type split would let one type's matches (e.g. a namespace with + // many matching architectures) exhaust it before entries of other types are ever + // reached, starving them even when real matches exist further into the stream. + // The registry is an in-memory index, not an external call, so there's no cost + // reason to cut the merge short. String lowerQuery = query.toLowerCase(); List allEntries = registryService.getSnapshot().getNamespaces().stream() .filter(ns -> readableNamespaces.isEmpty() || readableNamespaces.get().contains(ns)) .flatMap(ns -> registryService.getSnapshot().listAll(ns).stream()) .filter(e -> matchesQuery(e, lowerQuery)) - .limit(MAX_RESULTS_PER_TYPE * 7L) .toList(); return new GroupedSearchResults( diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java index c147cc9068..362c933245 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubSearchStoreShould.java @@ -1,6 +1,9 @@ package org.finos.calm.store.github; import org.finos.calm.domain.search.GroupedSearchResults; +import org.finos.calm.store.SearchStore; +import org.finos.calm.store.github.registry.RegistryEntry; +import org.finos.calm.store.github.registry.RegistryResourceType; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistrySnapshot; import org.junit.jupiter.api.BeforeEach; @@ -11,6 +14,11 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -85,4 +93,27 @@ void find_entries_matching_query_by_name() { assertThat(result.getArchitectures(), is(org.hamcrest.Matchers.not(empty()))); assertThat(result.getArchitectures().get(0).getName(), is("Payment Service")); } + + @Test + void not_let_one_types_matches_starve_another_types_matches() { + // More architecture matches than the old combined cap (MAX_RESULTS_PER_TYPE * 7) + // could ever let through, all sorting ahead of the one pattern match in the + // merged, per-namespace-ordered stream. The pattern match must still come back. + List entries = new ArrayList<>(); + for (int i = 0; i < (SearchStore.MAX_RESULTS_PER_TYPE * 7) + 5; i++) { + entries.add(new RegistryEntry("payment-arch-" + i, Path.of("architectures/payment-" + i + ".json"), + RegistryResourceType.ARCHITECTURE, "Payment Architecture " + i, Instant.now())); + } + entries.add(new RegistryEntry("payment-pattern", Path.of("patterns/payment.json"), + RegistryResourceType.PATTERN, "Payment Pattern", Instant.now())); + + RegistrySnapshot snapshot = new RegistrySnapshot(Map.of("finos", entries), Map.of()); + when(registryService.getSnapshot()).thenReturn(snapshot); + + GroupedSearchResults result = store.search("payment", Optional.empty()); + + assertThat(result.getArchitectures(), is(org.hamcrest.Matchers.not(empty()))); + assertThat(result.getPatterns(), is(org.hamcrest.Matchers.not(empty()))); + assertThat(result.getPatterns().get(0).getName(), is("Payment Pattern")); + } } From 294f3fc166164e821fb40c91533c5dabf2123a50 Mon Sep 17 00:00:00 2001 From: James Gough Date: Fri, 11 Sep 2026 09:37:01 +0100 Subject: [PATCH 38/47] fix(calm-hub): return 501 not 400 when adding a version in GitHub mode addNewVersion() had no explicit GitHubWriteNotSupportedException catch before its broad catch(Exception), unlike its sibling create/update paths. Adding a version to an existing resource in GitHub mode threw GitHubWriteNotSupportedException, which the broad catch swallowed into a misleading 400 "Failed to update resource" instead of the 501 every other GitHub-mode write path correctly returns. Found by /code-review high. --- .../services/MappingControllerService.java | 4 ++++ .../TestMappingControllerResourceShould.java | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java index a3be20167b..d0630f2e9d 100644 --- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java +++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java @@ -512,6 +512,10 @@ private Response addNewVersion(String namespace, String typePath, String name, logger.error("Invalid namespace [{}] when updating resource", STRICT_SANITIZATION_POLICY.sanitize(namespace), e); return CalmResourceErrorResponses.invalidNamespaceResponse(namespace); + } catch (GitHubWriteNotSupportedException e) { + // Rethrow rather than let the broad catch below swallow it into a 400 - + // UnsupportedOperationExceptionMapper turns this into the intended 501. + throw e; } catch (Exception e) { logger.error("Error updating resource [{}] in namespace [{}]", STRICT_SANITIZATION_POLICY.sanitize(name), STRICT_SANITIZATION_POLICY.sanitize(namespace), e); diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java index 5b611ed313..e67dbbee3e 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java @@ -268,6 +268,25 @@ void return_201_when_adding_explicit_version_to_existing_architecture() throws E verify(mockArchitectureStore).createArchitectureForVersion(any(Architecture.class)); } + @Test + void return_501_not_400_when_adding_a_version_in_github_mode() throws Exception { + // addNewVersion() previously had no explicit GitHubWriteNotSupportedException + // catch before its broad catch(Exception), unlike its sibling create/update + // paths - the broad catch swallowed it into a misleading 400 instead of the 501 + // every other GitHub-mode write path correctly returns. + ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder() + .setNamespace("finos").setCustomId("my-arch") + .setResourceType(ResourceType.ARCHITECTURE).setNumericId(2).build(); + when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "my-arch")).thenReturn(existing); + when(mockArchitectureStore.getArchitectureVersions(any(Architecture.class))).thenReturn(List.of("1.0.0")); + doThrow(new GitHubWriteNotSupportedException("Write operations are not yet available.")) + .when(mockArchitectureStore).createArchitectureForVersion(any(Architecture.class)); + + given().header("Content-Type", "application/json").body(versionedDoc("finos", "architectures", "my-arch", "2.0.0")).when() + .post("/calm") + .then().statusCode(501); + } + @Test void return_201_when_adding_explicit_version_to_existing_flow() throws Exception { ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder() From f8454d28fd81c47b3d3e385646a6c2b6b9fea4b8 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 24 Sep 2026 18:22:44 +0100 Subject: [PATCH 39/47] docs(calm-hub): cross-reference the building-blocks alias to the node-catalogue issue The javadoc explaining why building-blocks/ aliases to Standard (#3052) covered the schema-contract half of the original ask but had no link to #3102, which tracks the other half: a distributable catalogue of concrete node definitions. --- .../org/finos/calm/store/github/GitHubStandardStore.java | 5 +++-- .../calm/store/github/registry/CalmContentDetector.java | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java index f31412add3..eb3cd1915a 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubStandardStore.java @@ -30,7 +30,8 @@ * GitHub-mode {@link StandardStore}: standards are read from files under {@code standards/} * and {@code building-blocks/} in the namespace's clone (the two directories are merged into * one listing - see {@link org.finos.calm.store.github.registry.CalmContentDetector} for why - * {@code building-blocks/} maps to {@code STANDARD} rather than a separate concept). Id + * {@code building-blocks/} maps to {@code STANDARD} rather than a separate concept, and for + * the node-catalogue half of that ask this store does not cover - #3102). Id * lookup, version listing and version-content resolution all delegate to * {@link AbstractReadOnlyGitHubStore} - see its class javadoc for why an unresolvable version * 404s rather than silently reading whatever HEAD holds. The one thing this store adds beyond @@ -57,7 +58,7 @@ public List getStandardsForNamespace(String namespace) verifyNamespace(namespace); List entries = registryService.listByType(namespace, RegistryResourceType.STANDARD); return entries.stream() - .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) + .map(e -> new NamespaceResourceSummary(e.name(), null, (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/registry/CalmContentDetector.java b/calm-hub/src/main/java/org/finos/calm/store/github/registry/CalmContentDetector.java index 0bdad728fa..2d80577eb4 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/registry/CalmContentDetector.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/registry/CalmContentDetector.java @@ -22,7 +22,9 @@ * (2026-09-10, #3052) agreed it should be modelled as a {@code Standard} instead. Keeping * the directory case here (rather than deleting it) means repos already using the VSCode * plugin's building-blocks-directory convention for CALM/architecture JSON files keep - * working — only the domain concept goes away, not the file layout.

+ * working — only the domain concept goes away, not the file layout. This alias covers + * the schema-contract half of the original ask only; a distributable catalogue of + * concrete, reusable node definitions is tracked separately in #3102.

*/ @LookupIfProperty(name = "calm.database.mode", stringValue = "github") @ApplicationScoped From 499246e5e9fd1ba1c1e215e4ad34df7f8914de5a Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 24 Sep 2026 18:22:58 +0100 Subject: [PATCH 40/47] fix(calm-hub): stop namespace summaries leaking the internal uniqueId as a description Every GitHub-mode namespace-listing summary (architectures, flows, standards, patterns, interfaces, timelines) passed the resource's internal uniqueId slug into the summary's description field instead of a real description. The registry has no description data for these file-backed resources, so pass null rather than fabricate one from an implementation detail. --- .../org/finos/calm/store/github/GitHubArchitectureStore.java | 2 +- .../main/java/org/finos/calm/store/github/GitHubFlowStore.java | 2 +- .../java/org/finos/calm/store/github/GitHubInterfaceStore.java | 2 +- .../java/org/finos/calm/store/github/GitHubPatternStore.java | 2 +- .../java/org/finos/calm/store/github/GitHubTimelineStore.java | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java index 66a2c841b9..86f0af0747 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubArchitectureStore.java @@ -50,7 +50,7 @@ public List getArchitecturesForNamespace(String namesp verifyNamespace(namespace); List entries = registryService.listByType(namespace, RegistryResourceType.ARCHITECTURE); return entries.stream() - .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) + .map(e -> new NamespaceResourceSummary(e.name(), null, (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java index 5d8c0f1809..a77210cf20 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubFlowStore.java @@ -51,7 +51,7 @@ public List getFlowsForNamespace(String namespace) thr verifyNamespace(namespace); List entries = registryService.listByType(namespace, RegistryResourceType.FLOW); return entries.stream() - .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) + .map(e -> new NamespaceResourceSummary(e.name(), null, (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java index 4fa32ed3ba..6e006dd87f 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubInterfaceStore.java @@ -51,7 +51,7 @@ public List getInterfacesForNamespace(String namespac verifyNamespace(namespace); List entries = registryService.listByType(namespace, RegistryResourceType.INTERFACE); return entries.stream() - .map(e -> new NamespaceInterfaceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF))) + .map(e -> new NamespaceInterfaceSummary(e.name(), null, (e.uniqueId().hashCode() & 0x7FFFFFFF))) .toList(); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java index c5d4ddeaf9..e8bfbc6daf 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubPatternStore.java @@ -53,7 +53,7 @@ public List getPatternsForNamespace(String namespace, verifyNamespace(namespace); List entries = registryService.listByType(namespace, RegistryResourceType.PATTERN); return entries.stream() - .map(e -> new NamespaceResourceSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) + .map(e -> new NamespaceResourceSummary(e.name(), null, (e.uniqueId().hashCode() & 0x7FFFFFFF), 0)) .toList(); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java index 747aee32de..e439ed5ef2 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubTimelineStore.java @@ -51,7 +51,7 @@ public List getTimelinesForNamespace(String namespace) verifyNamespace(namespace); List entries = registryService.listByType(namespace, RegistryResourceType.TIMELINE); return entries.stream() - .map(e -> new NamespaceTimelineSummary(e.name(), e.uniqueId(), (e.uniqueId().hashCode() & 0x7FFFFFFF))) + .map(e -> new NamespaceTimelineSummary(e.name(), null, (e.uniqueId().hashCode() & 0x7FFFFFFF))) .toList(); } From be36ae4e096e71e11bcec5ed7eced2541f683eb9 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 24 Sep 2026 18:23:27 +0100 Subject: [PATCH 41/47] fix(calm-hub): pin one registry snapshot in search, scoped to readable namespaces filterByType's reverse namespace lookup scanned every namespace in the registry, not just the caller's readable set threaded through search() - a same-content entry in an unreadable namespace could resolve a result's namespace to one the caller cannot read. search() and filterByType() also each called getSnapshot() independently, so a registry rebuild landing between the two calls could blank out a result's namespace. Both are fixed by building one namespace-by-entry map, scoped to readable namespaces, from a single pinned snapshot. Also stops passing the entry's uniqueId as the result's description, same bug as the other GitHub-mode summary stores. --- .../calm/store/github/GitHubSearchStore.java | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java index ac42edb472..ed2e15e150 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubSearchStore.java @@ -7,10 +7,12 @@ import org.finos.calm.domain.search.SearchResult; import org.finos.calm.store.SearchStore; import org.finos.calm.store.github.registry.RegistryResourceType; +import org.finos.calm.store.github.registry.RegistrySnapshot; import org.finos.calm.store.github.registry.ResourceRegistry; import org.finos.calm.store.github.registry.RegistryEntry; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -49,20 +51,34 @@ public GroupedSearchResults search(String query, Optional> readableN // The registry is an in-memory index, not an external call, so there's no cost // reason to cut the merge short. String lowerQuery = query.toLowerCase(); - List allEntries = registryService.getSnapshot().getNamespaces().stream() + + // One snapshot for the whole request: a background registry rebuild landing between + // two independent getSnapshot() calls must not change which namespace a result is + // attributed to. namespaceByEntry is built from the same readable-namespace-filtered + // pass as allEntries, so an entry from a namespace this caller cannot read is never + // available to attribute a result to, however the reverse lookup is done. + RegistrySnapshot snapshot = registryService.getSnapshot(); + List readable = snapshot.getNamespaces().stream() .filter(ns -> readableNamespaces.isEmpty() || readableNamespaces.get().contains(ns)) - .flatMap(ns -> registryService.getSnapshot().listAll(ns).stream()) + .toList(); + + Map namespaceByEntry = readable.stream() + .flatMap(ns -> snapshot.listAll(ns).stream().map(e -> Map.entry(e, ns))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (first, second) -> first)); + + List allEntries = readable.stream() + .flatMap(ns -> snapshot.listAll(ns).stream()) .filter(e -> matchesQuery(e, lowerQuery)) .toList(); return new GroupedSearchResults( - filterByType(allEntries, RegistryResourceType.ARCHITECTURE), - filterByType(allEntries, RegistryResourceType.PATTERN), - filterByType(allEntries, RegistryResourceType.FLOW), - filterByType(allEntries, RegistryResourceType.STANDARD), - filterByType(allEntries, RegistryResourceType.INTERFACE), - filterByType(allEntries, RegistryResourceType.CONTROL), - filterByType(allEntries, RegistryResourceType.ADR) + filterByType(allEntries, RegistryResourceType.ARCHITECTURE, namespaceByEntry), + filterByType(allEntries, RegistryResourceType.PATTERN, namespaceByEntry), + filterByType(allEntries, RegistryResourceType.FLOW, namespaceByEntry), + filterByType(allEntries, RegistryResourceType.STANDARD, namespaceByEntry), + filterByType(allEntries, RegistryResourceType.INTERFACE, namespaceByEntry), + filterByType(allEntries, RegistryResourceType.CONTROL, namespaceByEntry), + filterByType(allEntries, RegistryResourceType.ADR, namespaceByEntry) ); } @@ -71,17 +87,16 @@ private boolean matchesQuery(RegistryEntry entry, String lowerQuery) { || (entry.uniqueId() != null && entry.uniqueId().toLowerCase().contains(lowerQuery)); } - private List filterByType(List entries, RegistryResourceType type) { + private List filterByType(List entries, RegistryResourceType type, + Map namespaceByEntry) { return entries.stream() .filter(e -> e.type() == type) .limit(MAX_RESULTS_PER_TYPE) .map(e -> new SearchResult( - registryService.getSnapshot().getNamespaces().stream() - .filter(ns -> registryService.getSnapshot().listAll(ns).contains(e)) - .findFirst().orElse(""), + namespaceByEntry.getOrDefault(e, ""), (e.uniqueId().hashCode() & 0x7FFFFFFF), e.name(), - e.uniqueId() + null )) .collect(Collectors.toList()); } From 53f691cd98bbeb6dd0688b1989d4fe2f788e569c Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 24 Sep 2026 18:23:36 +0100 Subject: [PATCH 42/47] fix(calm-hub): give pullAll the same clone fallback cloneAll already has pullAll() just counted a namespace with an invalid clone dir as failed, with no fallback to cloneRepo the way cloneAll() has. cloneAll() runs once at startup, so a namespace whose initial clone failed had no recovery path short of a restart - every subsequent sync tick re-failed it the same way. --- .../store/github/sync/GitHubCloneManager.java | 15 +++++++++------ .../sync/TestGitHubCloneManagerShould.java | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubCloneManager.java b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubCloneManager.java index 840cdff623..39d85efabc 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubCloneManager.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/sync/GitHubCloneManager.java @@ -107,12 +107,15 @@ public void pullAll() { NamespaceRepo repo = entry.getValue(); Path targetDir = config.getCloneDirectory().resolve(namespace); - if (repoSync.isValidRepo(targetDir)) { - if (repoSync.pullRepo(targetDir, repo.branch(), config.getServiceToken())) { - succeeded++; - } else { - failed++; - } + // A namespace whose initial clone failed (or whose clone dir was otherwise left + // invalid) never goes through cloneAll again — this is the only recovery path for + // it, so it needs the same clone fallback cloneAll itself uses, not just a pull. + boolean success = repoSync.isValidRepo(targetDir) + ? repoSync.pullRepo(targetDir, repo.branch(), config.getServiceToken()) + : repoSync.cloneRepo(repo.repoFullName(), repo.branch(), targetDir, config.getServiceToken()); + + if (success) { + succeeded++; } else { failed++; } diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubCloneManagerShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubCloneManagerShould.java index 6479357de3..708ec7f510 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubCloneManagerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/sync/TestGitHubCloneManagerShould.java @@ -145,12 +145,31 @@ void transition_to_failed_on_pull_all_when_all_fail() { when(repoSync.cloneRepo(any(), any(), any(), any())).thenReturn(true); cloneManager.cloneAll(); + // pullAll falls back to cloneRepo when the dir is still invalid, same as cloneAll - + // this only fails when that retry itself fails, not merely because the dir was invalid. when(repoSync.isValidRepo(any())).thenReturn(false); + when(repoSync.cloneRepo(any(), any(), any(), any())).thenReturn(false); cloneManager.pullAll(); assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.FAILED)); } + @Test + void recover_via_clone_fallback_on_pull_all_when_the_clone_dir_is_invalid() { + cloneManager.registerNamespace("ns1", "org/repo1", "main", Set.of()); + when(repoSync.isValidRepo(any())).thenReturn(false); + when(repoSync.cloneRepo(any(), any(), any(), any())).thenReturn(true); + cloneManager.cloneAll(); + + // A namespace whose initial clone left an invalid dir must be able to recover on the + // very next pullAll tick, without a restart, by falling back to cloneRepo again. + when(repoSync.isValidRepo(any())).thenReturn(false); + when(repoSync.cloneRepo(any(), any(), any(), any())).thenReturn(true); + cloneManager.pullAll(); + + assertThat(cloneManager.getState(), equalTo(GitHubCloneManager.State.READY)); + } + @Test void return_namespace_clone_paths() { cloneManager.registerNamespace("finos", "finos/repo", "main", Set.of()); From 82a7caa04a9dcffdbaa81ac4703d56444ce22c6f Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 24 Sep 2026 18:23:45 +0100 Subject: [PATCH 43/47] fix(calm-hub): disambiguate SHA-shaped versions from all-digit semver VERSION_REGEX's optional separators make an all-digit string like "1234567" valid semver and SHA-shaped at once, so a single such version in a mapping's list tripped the SHA heuristic and silently disabled semver sorting for the whole list - a bug reachable in every storage mode, not just GitHub. Only treat a version as a real SHA when it isn't also a valid semver on its own terms; a genuine SHA containing a letter can never satisfy VERSION_REGEX. --- .../finos/calm/resources/MappingControllerResource.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java b/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java index 6d9d10281b..bdd7fd1c25 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java @@ -314,7 +314,12 @@ public Response listResourceVersions( try { ResourceMapping mapping = service.getMapping(namespace, resourceType, name); List versions = service.getVersionsForMapping(mapping); - boolean hasShas = versions.stream().anyMatch(v -> v.matches("[0-9a-f]{7,40}")); + // VERSION_REGEX's optional separators make an all-digit string like "1234567" + // valid semver *and* SHA-shaped. Only treat a version as a real git SHA - and so + // skip semver sorting for the whole list - when it isn't also a valid semver on + // its own terms; a genuine SHA containing a letter can never satisfy VERSION_REGEX. + boolean hasShas = versions.stream() + .anyMatch(v -> v.matches("[0-9a-f]{7,40}") && !v.matches(VERSION_REGEX)); List sortedVersions; if (hasShas) { sortedVersions = versions; From 09fdaa75cf03f368830ed29ad4751718bf8edd95 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 24 Sep 2026 18:23:51 +0100 Subject: [PATCH 44/47] fix(calm-hub): return not-found instead of write-unsupported for single-ADR GETs getAdr/getAdrRevisions/getAdrRevision threw GitHubWriteNotSupportedException for plain GET requests, misreporting a read as a rejected write. GitHubAdrStore already answers "none exist" for the namespace listing and count - a lookup of one specific ADR should answer the same way, not claim writes aren't supported for a request that never tried to write. --- .../calm/store/github/GitHubAdrStore.java | 16 +++++---- .../github/TestGitHubAdrStoreShould.java | 36 ++++++++++++++----- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java index f2c5af531d..8434e9c97f 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubAdrStore.java @@ -34,9 +34,6 @@ @Typed(GitHubAdrStore.class) public class GitHubAdrStore extends AbstractGitHubStore implements AdrStore { - private static final String VERSION_UNSUPPORTED = - "Version history via GitHub API is not yet implemented."; - @Inject public GitHubAdrStore(ResourceRegistry registryService) { super(registryService); @@ -59,19 +56,26 @@ public AdrMeta createAdrForNamespace(AdrMeta adrMeta) throws NamespaceNotFoundEx throw new GitHubWriteNotSupportedException(WRITE_UNSUPPORTED); } + // ADRs always report zero for the namespace listing above, so a lookup of one specific + // ADR is never going to find it either - answer "not found," the same as any other GET + // for an ADR that doesn't exist, rather than a write-unsupported error for a request that + // was never trying to write. @Override public AdrMeta getAdr(AdrMeta adrMeta) throws NamespaceNotFoundException, AdrNotFoundException, AdrRevisionNotFoundException, AdrParseException { - throw new GitHubWriteNotSupportedException(VERSION_UNSUPPORTED); + verifyNamespace(adrMeta.getNamespace()); + throw new AdrNotFoundException(); } @Override public List getAdrRevisions(AdrMeta adrMeta) throws NamespaceNotFoundException, AdrNotFoundException, AdrRevisionNotFoundException { - throw new GitHubWriteNotSupportedException(VERSION_UNSUPPORTED); + verifyNamespace(adrMeta.getNamespace()); + throw new AdrNotFoundException(); } @Override public AdrMeta getAdrRevision(AdrMeta adrMeta) throws NamespaceNotFoundException, AdrNotFoundException, AdrRevisionNotFoundException, AdrParseException { - throw new GitHubWriteNotSupportedException(VERSION_UNSUPPORTED); + verifyNamespace(adrMeta.getNamespace()); + throw new AdrNotFoundException(); } @Override diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java index a27269a7f7..d183264f01 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubAdrStoreShould.java @@ -2,6 +2,7 @@ import org.finos.calm.domain.adr.AdrMeta; import org.finos.calm.domain.adr.Status; +import org.finos.calm.domain.exception.AdrNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.adr.NamespaceAdrSummary; import org.finos.calm.store.github.registry.ResourceRegistry; @@ -86,21 +87,38 @@ void throw_unsupported_on_delete_adr() { } @Test - void throw_unsupported_on_get_adr() { - assertThrows(UnsupportedOperationException.class, - () -> store.getAdr(new AdrMeta())); + void throw_adr_not_found_on_get_adr() { + RegistrySnapshot snapshot = new RegistrySnapshot(Map.of("finos", List.of()), Map.of()); + when(registryService.getSnapshot()).thenReturn(snapshot); + + assertThrows(AdrNotFoundException.class, + () -> store.getAdr(new AdrMeta.AdrMetaBuilder().setNamespace("finos").build())); } @Test - void throw_unsupported_on_get_adr_revisions() { - assertThrows(UnsupportedOperationException.class, - () -> store.getAdrRevisions(new AdrMeta())); + void throw_adr_not_found_on_get_adr_revisions() { + RegistrySnapshot snapshot = new RegistrySnapshot(Map.of("finos", List.of()), Map.of()); + when(registryService.getSnapshot()).thenReturn(snapshot); + + assertThrows(AdrNotFoundException.class, + () -> store.getAdrRevisions(new AdrMeta.AdrMetaBuilder().setNamespace("finos").build())); } @Test - void throw_unsupported_on_get_adr_revision() { - assertThrows(UnsupportedOperationException.class, - () -> store.getAdrRevision(new AdrMeta())); + void throw_adr_not_found_on_get_adr_revision() { + RegistrySnapshot snapshot = new RegistrySnapshot(Map.of("finos", List.of()), Map.of()); + when(registryService.getSnapshot()).thenReturn(snapshot); + + assertThrows(AdrNotFoundException.class, + () -> store.getAdrRevision(new AdrMeta.AdrMetaBuilder().setNamespace("finos").build())); + } + + @Test + void throw_namespace_not_found_on_get_adr_for_unknown_namespace() { + when(registryService.getSnapshot()).thenReturn(RegistrySnapshot.EMPTY); + + assertThrows(NamespaceNotFoundException.class, + () -> store.getAdr(new AdrMeta.AdrMetaBuilder().setNamespace("nonexistent").build())); } @Test From 1340123924795410d2a0b90de3ce8bba811e6177 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 24 Sep 2026 18:23:59 +0100 Subject: [PATCH 45/47] fix(calm-hub): make getRequirementVersions fail closed like its sibling getRequirementVersions returned 200 with an empty list when a control's namespace couldn't be re-resolved (a registry mid-rebuild), while getRequirementForVersion already throws for the identical condition and explains why in its own comment: fail closed rather than silently claim "no versions" for a control that was just found to exist. --- .../finos/calm/store/github/GitHubControlStore.java | 9 ++++++++- .../store/github/TestGitHubControlStoreShould.java | 10 +++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java index e0ea96d297..ee6ac23351 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/github/GitHubControlStore.java @@ -104,7 +104,14 @@ public List getRequirementVersions(String domain, int controlId) throws RegistryEntry entry = findControlEntry(domain, controlId); String namespace = findNamespaceForControl(entry); if (namespace == null) { - return List.of(); + // Same transient registry-consistency condition getRequirementForVersion below + // fails closed on (see its comment) - a genuine 404 there and a silent empty + // list here would tell two callers of the same control two different, contradictory + // things. Fail closed here too, rather than claiming "no versions" for a control that + // was just found to exist. + LOG.error("Could not resolve namespace for control [{}] in domain [{}] - registry may be mid-rebuild", + entry.uniqueId(), domain); + throw new ControlNotFoundException(); } String repo = cloneManager.getRepoForNamespace(namespace); String branch = cloneManager.getBranchForNamespace(namespace); diff --git a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java index 49aa530088..33fd9b8f6e 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/github/TestGitHubControlStoreShould.java @@ -343,12 +343,14 @@ void throw_requirement_version_not_found_when_the_local_head_file_is_missing_on_ } @Test - void return_empty_versions_when_the_registry_is_mid_rebuild_between_the_two_lookups() throws Exception { + void throw_control_not_found_when_the_registry_is_mid_rebuild_between_the_two_lookups() throws Exception { // findControlEntry and findNamespaceForControl each re-derive the entry's namespace // independently by re-walking the registry - a genuine (if rare) registry-rebuild // race can have the entry present for the first walk and gone by the second. This // simulates exactly that with consecutive stubbing, rather than a namespace/domain - // mismatch which is a different scenario entirely. + // mismatch which is a different scenario entirely. Fails closed the same way + // getRequirementForVersion does for the identical condition, rather than silently + // reporting "no versions" for a control that was just found to exist. RegistryEntry entry = new RegistryEntry(UNIQUE_ID, Path.of("controls/security/my-control.json"), RegistryResourceType.CONTROL, "My Control", Instant.now()); RegistrySnapshot snapshot = new RegistrySnapshot( @@ -359,9 +361,7 @@ void return_empty_versions_when_the_registry_is_mid_rebuild_between_the_two_look .thenReturn(List.of(entry), List.of()); when(accessFilter.getAccessibleNamespaces()).thenReturn(Set.of("finos")); - List versions = store.getRequirementVersions(DOMAIN, HASH_ID); - - assertThat(versions, is(empty())); + assertThrows(ControlNotFoundException.class, () -> store.getRequirementVersions(DOMAIN, HASH_ID)); } @Test From 300c44117346d6f43e3dd2707964b437271c7646 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 24 Sep 2026 18:24:10 +0100 Subject: [PATCH 46/47] fix(calm-hub-ui): restore the flow/AIGF edge indicator lost with EdgeBadge Replacing EdgeBadge with a plain text label dropped the only visual indicator for an edge that carries flow or AIGF metadata but no description or protocol text - the label only rendered when there was text to show. EdgeBadge now renders alongside the label whenever either is present, instead of only one or the other existing. --- .../components/reactflow/FloatingEdge.tsx | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/calm-hub-ui/src/visualizer/components/reactflow/FloatingEdge.tsx b/calm-hub-ui/src/visualizer/components/reactflow/FloatingEdge.tsx index 1f0f3517f9..59c2d57336 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/FloatingEdge.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/FloatingEdge.tsx @@ -1,7 +1,7 @@ import { useState, useCallback } from 'react'; import { EdgeProps, getBezierPath, getSmoothStepPath, getStraightPath, EdgeLabelRenderer, useStore } from 'reactflow'; import { getEdgeParams } from './utils/floatingEdges.js'; -import { EdgeTooltip } from './edge-components/index.js'; +import { EdgeBadge, EdgeTooltip, getBadgeStyle } from './edge-components/index.js'; import type { EdgeData } from '../../contracts/contracts.js'; export function FloatingEdge({ @@ -74,6 +74,13 @@ export function FloatingEdge({ const isFlowActive = !!data?.flowActive; const flowOpacity = style.opacity ?? 1; + // An edge can carry flow/AIGF metadata with no description or protocol text at all - + // the badge is the only signal that metadata exists, independent of edgeLabel. + const hasFlowInfo = flowTransitions.length > 0; + const hasAIGF = controlsApplied.length > 0 || mitigations.length > 0 || risks.length > 0; + const badgeStyle = getBadgeStyle(hasFlowInfo, hasAIGF); + const hasIndicator = Boolean(edgeLabel) || hasFlowInfo || hasAIGF; + return ( <> )} - {edgeLabel && ( + {hasIndicator && (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > - - {edgeLabel} - + {(hasFlowInfo || hasAIGF) && ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + /> + )} + {edgeLabel && ( + + {edgeLabel} + + )}
{isHovered && ( From 839abd773125b7323fa80866aa2d09b3be023167 Mon Sep 17 00:00:00 2001 From: James Gough Date: Thu, 24 Sep 2026 19:24:18 +0100 Subject: [PATCH 47/47] fix(calm-hub): fix GitHub-mode integration tests broken by the main merge The full -P integration suite regressed after merging main: every GitHub-mode Docker integration test timed out waiting for the initial clone, because GitHubStartupInitializer.onStart() saw calm.database.mode still resolve to the %test. default ("mongo") and returned before registering any namespace. Confirmed via a minimal reproduction that this is quarkusio/quarkus#52919 - system-properties-based config propagation for QuarkusTestResourceLifecycleManager was removed in the Quarkus 3.35+ range main's merge brought in, and the returned config Map alone is no longer reliably applied. EndToEndResource and KeycloakTestResource already carry the same explicit System.setProperty workaround for this; GitHubFixtureResource just predated it. Confirmed this was introduced by the merge, not pre-existing: both integration jobs passed on the PR's pre-merge head (294f3fc1), and failed identically on the merge commit before any of this session's other fixes. --- .../java/integration/GitHubFixtureResource.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/calm-hub/src/integration-test/java/integration/GitHubFixtureResource.java b/calm-hub/src/integration-test/java/integration/GitHubFixtureResource.java index e2f42ba7d0..421794f0fd 100644 --- a/calm-hub/src/integration-test/java/integration/GitHubFixtureResource.java +++ b/calm-hub/src/integration-test/java/integration/GitHubFixtureResource.java @@ -52,13 +52,23 @@ public Map start() { String baseUrl = originRoot.toUri().toString().replaceAll("/$", ""); - return Map.of( + Map config = Map.of( "calm.database.mode", "github", "calm.auth.enabled", "true", "calm.github.oauth.base-url", baseUrl, "calm.github.clone-directory", cloneDirectory.toString(), "calm.github.namespaces", "finos|finos-repo|main|group1,other|other-repo|main|group2" ); + + // Belt-and-braces alongside the returned Map above, mirroring EndToEndResource and + // KeycloakTestResource - the Map alone has been unreliable across Quarkus versions + // for propagating test resource config into @ConfigProperty-injected values + // (quarkusio/quarkus#52919). Without this, GitHubStartupInitializer's onStart() sees + // calm.database.mode still resolve to the %test. default ("mongo") and returns before + // registering any namespace, leaving the registry permanently empty for the test. + config.forEach(System::setProperty); + + return config; } private void createRepo(Path repoDir, Map filesByRelativePath) throws Exception { @@ -78,6 +88,10 @@ private void createRepo(Path repoDir, Map filesByRelativePath) t public void stop() { deleteRecursively(originRoot); deleteRecursively(cloneDirectory); + for (String key : new String[]{"calm.database.mode", "calm.auth.enabled", "calm.github.oauth.base-url", + "calm.github.clone-directory", "calm.github.namespaces"}) { + System.clearProperty(key); + } } private void deleteRecursively(Path root) {