diff --git a/.gitignore b/.gitignore index adbdaa2..0b70d75 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ !/.nvmrc .vscode/ *.DS_Store -local_docs/ \ No newline at end of file +local_docs/ +.scannerwork/ diff --git a/README.md b/README.md index 8327b5f..a70992c 100644 --- a/README.md +++ b/README.md @@ -124,19 +124,24 @@ The clone flow is currently a frontend workflow preview. It can compose and disp ```bash cd frontend npm run test +npm run test:coverage ``` +The coverage command writes reports under `frontend/coverage/`, including +`frontend/coverage/lcov.info` for Sonar scanner input. + ### Backend tests ```bash cd backend -mvn verify +mvn clean verify ``` This currently: - runs the regular backend test suite - generates a JaCoCo coverage report at `backend/target/site/jacoco/index.html` +- writes the XML report used by Sonar at `backend/target/site/jacoco/jacoco.xml` The backend PostgreSQL/Testcontainers integration test is intentionally tagged as `integration` and skipped by default for local runs. To include it: @@ -147,6 +152,20 @@ mvn verify -Dexcluded.test.tags= The detailed explanation and Docker-related notes live in [backend/README.md](backend/README.md). +### Sonar scanner + +After generating both frontend and backend coverage reports, run the scanner +from the repository root: + +```bash +npx @sonar/scan +``` + +The scanner reads `sonar-project.properties`. Provide server URL and token +through your local Sonar scanner environment or command-line options; do not +commit credentials to the repository. For SonarCloud, also provide +`sonar.organization`; for local SonarQube, provide `sonar.host.url`. + ## Deployment Architecture The target deployment architecture is: diff --git a/backend/pom.xml b/backend/pom.xml index 41712cb..499007f 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -108,6 +108,22 @@ org.jacoco jacoco-maven-plugin 0.8.12 + + + com/cloudnative/**/*Application* + com/cloudnative/**/dto/** + com/cloudnative/config/** + com/cloudnative/**/*Repository* + com/cloudnative/**/*Controller* + com/cloudnative/**/*Status* + com/cloudnative/**/*Kind* + com/cloudnative/**/*Format* + com/cloudnative/**/UserRole* + com/cloudnative/**/RestoreTarget* + com/cloudnative/**/ChangeSessionType* + com/cloudnative/**/ChangeRequestCommentType* + + prepare-agent diff --git a/backend/src/main/java/com/cloudnative/configuration/SecretMaskingService.java b/backend/src/main/java/com/cloudnative/configuration/SecretMaskingService.java index 6c6e9b0..27136f5 100644 --- a/backend/src/main/java/com/cloudnative/configuration/SecretMaskingService.java +++ b/backend/src/main/java/com/cloudnative/configuration/SecretMaskingService.java @@ -13,16 +13,11 @@ import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.regex.Pattern; @Component public class SecretMaskingService { public static final String MASK = "********************"; - private static final Pattern SENSITIVE_KEY_PATTERN = Pattern.compile( - "(?i).*(secret|password|token|api[-_]?key|private[-_]?key|credential).*" - ); - private final ObjectMapper jsonMapper; private final ObjectMapper yamlMapper; @@ -81,7 +76,20 @@ private JsonNode maskField(String key, JsonNode value) { } public boolean isSensitiveKey(String key) { - return key != null && SENSITIVE_KEY_PATTERN.matcher(key).matches(); + if (key == null) { + return false; + } + String normalized = key.toLowerCase(); + return normalized.contains("secret") + || normalized.contains("password") + || normalized.contains("token") + || normalized.contains("apikey") + || normalized.contains("api_key") + || normalized.contains("api-key") + || normalized.contains("privatekey") + || normalized.contains("private_key") + || normalized.contains("private-key") + || normalized.contains("credential"); } private String toProperties(JsonNode value) { diff --git a/backend/src/main/java/com/cloudnative/exporting/ConfigurationExportService.java b/backend/src/main/java/com/cloudnative/exporting/ConfigurationExportService.java index 0f608a1..51ffab2 100644 --- a/backend/src/main/java/com/cloudnative/exporting/ConfigurationExportService.java +++ b/backend/src/main/java/com/cloudnative/exporting/ConfigurationExportService.java @@ -175,7 +175,20 @@ private JsonNode maskSensitiveValues(JsonNode value, String key) { } private boolean isSensitiveKey(String key) { - return key != null && key.matches("(?i).*(secret|password|token|api[-_]?key|private[-_]?key|credential).*"); + if (key == null) { + return false; + } + String normalized = key.toLowerCase(); + return normalized.contains("secret") + || normalized.contains("password") + || normalized.contains("token") + || normalized.contains("apikey") + || normalized.contains("api_key") + || normalized.contains("api-key") + || normalized.contains("privatekey") + || normalized.contains("private_key") + || normalized.contains("private-key") + || normalized.contains("credential"); } private String serialize(ProjectScopeConfigResponse scopeConfig, JsonNode exportData, ExportFormat format) { @@ -246,15 +259,30 @@ private String formatKey(String key, boolean envKey) { if (!envKey) { return key; } - return key - .replaceAll("([a-z0-9])([A-Z])", "$1_$2") - .replaceAll("[^A-Za-z0-9]+", "_") - .replaceAll("^_+|_+$", "") - .toUpperCase(); + StringBuilder normalized = new StringBuilder(); + char previous = 0; + for (int index = 0; index < key.length(); index++) { + char current = key.charAt(index); + if (Character.isUpperCase(current) && (Character.isLowerCase(previous) || Character.isDigit(previous))) { + normalized.append('_'); + } + normalized.append(Character.isLetterOrDigit(current) ? current : '_'); + previous = current; + } + return trim(normalized.toString(), '_').toUpperCase(); } private String formatEnvValue(String value) { - return value.matches("[A-Za-z0-9_./:-]*") ? value : jsonQuote(value); + return value.chars().allMatch(ConfigurationExportService::isSafeEnvValueChar) ? value : jsonQuote(value); + } + + private static boolean isSafeEnvValueChar(int character) { + return Character.isLetterOrDigit(character) + || character == '_' + || character == '.' + || character == '/' + || character == ':' + || character == '-'; } private String writeToml(JsonNode value) { @@ -344,11 +372,32 @@ private String buildFilename(ProjectScopeConfigResponse scopeConfig, ExportForma } private String slug(String value) { - return String.valueOf(value) - .trim() - .toLowerCase() - .replaceAll("[^a-z0-9]+", "-") - .replaceAll("^-+|-+$", ""); + String lower = String.valueOf(value).trim().toLowerCase(); + StringBuilder slug = new StringBuilder(); + boolean previousDash = false; + for (int index = 0; index < lower.length(); index++) { + char current = lower.charAt(index); + if (Character.isLetterOrDigit(current)) { + slug.append(current); + previousDash = false; + } else if (!previousDash) { + slug.append('-'); + previousDash = true; + } + } + return trim(slug.toString(), '-'); + } + + private String trim(String value, char trimChar) { + int start = 0; + int end = value.length(); + while (start < end && value.charAt(start) == trimChar) { + start++; + } + while (end > start && value.charAt(end - 1) == trimChar) { + end--; + } + return value.substring(start, end); } private int countActiveOverrides(ProjectScopeConfigResponse scopeConfig) { diff --git a/backend/src/test/java/com/cloudnative/configuration/SecretMaskingServiceTest.java b/backend/src/test/java/com/cloudnative/configuration/SecretMaskingServiceTest.java new file mode 100644 index 0000000..cb37ea4 --- /dev/null +++ b/backend/src/test/java/com/cloudnative/configuration/SecretMaskingServiceTest.java @@ -0,0 +1,68 @@ +package com.cloudnative.configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SecretMaskingServiceTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + private final SecretMaskingService service = new SecretMaskingService(objectMapper); + + @Test + void masksSensitiveKeysRecursivelyInObjectsAndArrays() throws Exception { + var input = objectMapper.readTree(""" + { + "database": { + "password": "secret", + "users": [ + {"name": "alice", "api_key": "key-1"}, + {"name": "bob", "token": "token-2"} + ] + }, + "publicValue": "visible" + } + """); + + var masked = service.mask(input); + + assertThat(masked.path("database").path("password").asText()).isEqualTo(SecretMaskingService.MASK); + assertThat(masked.path("database").path("users").get(0).path("api_key").asText()).isEqualTo(SecretMaskingService.MASK); + assertThat(masked.path("database").path("users").get(1).path("token").asText()).isEqualTo(SecretMaskingService.MASK); + assertThat(masked.path("publicValue").asText()).isEqualTo("visible"); + } + + @Test + void masksRawContentForJsonYamlAndProperties() throws Exception { + var input = objectMapper.readTree(""" + { + "database": {"password": "secret", "host": "db.internal"}, + "tokens": ["one", "two"], + "nullable": null + } + """); + + assertThat(service.maskRawContent(ConfigurationFormat.json, input)) + .contains(SecretMaskingService.MASK) + .doesNotContain("secret"); + assertThat(service.maskRawContent(ConfigurationFormat.yaml, input)) + .contains(SecretMaskingService.MASK) + .doesNotContain("secret"); + assertThat(service.maskRawContent(ConfigurationFormat.properties, input)) + .contains("database.password=" + SecretMaskingService.MASK) + .contains("tokens=" + SecretMaskingService.MASK) + .contains("nullable="); + } + + @Test + void handlesNullScalarAndKeySpecificMasking() { + assertThat(service.mask(null)).isNull(); + assertThat(service.mask(objectMapper.nullNode()).isNull()).isTrue(); + assertThat(service.maskValueForKey("private-key", objectMapper.getNodeFactory().textNode("abc")).asText()) + .isEqualTo(SecretMaskingService.MASK); + assertThat(service.maskValueForKey("displayName", objectMapper.getNodeFactory().textNode("Alice")).asText()) + .isEqualTo("Alice"); + assertThat(service.isSensitiveKey(null)).isFalse(); + assertThat(service.isSensitiveKey("credentialsRef")).isTrue(); + } +} diff --git a/backend/src/test/java/com/cloudnative/configuration/TemplateMetadataServiceTest.java b/backend/src/test/java/com/cloudnative/configuration/TemplateMetadataServiceTest.java new file mode 100644 index 0000000..669203f --- /dev/null +++ b/backend/src/test/java/com/cloudnative/configuration/TemplateMetadataServiceTest.java @@ -0,0 +1,71 @@ +package com.cloudnative.configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TemplateMetadataServiceTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void reportsNoWarningWhenTemplateHasNoTypeOrBaseline() throws Exception { + ConfigurationRepository repository = mock(ConfigurationRepository.class); + TemplateMetadataService service = new TemplateMetadataService(repository, new TemplateKeyPathExtractor()); + + Configuration untyped = template("app.yaml", null, "{\"service\":{\"port\":8080}}", ConfigurationStatus.active); + + assertThat(service.describe(untyped).keyConsistency().consistent()).isTrue(); + + TemplateType type = new TemplateType("app", "Application", "App config"); + UUID typeId = UUID.randomUUID(); + org.springframework.test.util.ReflectionTestUtils.setField(type, "id", typeId); + Configuration typed = template("app-prod.yaml", type, "{\"service\":{\"port\":8080}}", ConfigurationStatus.draft); + when(repository.findFirstByKindAndTemplateType_IdAndStatusOrderByCreatedAtAsc( + ConfigurationKind.template, typeId, ConfigurationStatus.active)).thenReturn(Optional.empty()); + when(repository.findFirstByKindAndTemplateType_IdOrderByCreatedAtAsc( + ConfigurationKind.template, typeId)).thenReturn(Optional.empty()); + + assertThat(service.describe(typed).keyConsistency().consistent()).isTrue(); + } + + @Test + void comparesTemplateKeysAgainstActiveBaseline() throws Exception { + ConfigurationRepository repository = mock(ConfigurationRepository.class); + TemplateMetadataService service = new TemplateMetadataService(repository, new TemplateKeyPathExtractor()); + TemplateType type = new TemplateType("app", "Application", "App config"); + UUID typeId = UUID.randomUUID(); + org.springframework.test.util.ReflectionTestUtils.setField(type, "id", typeId); + + Configuration baseline = template("baseline.yaml", type, "{\"service\":{\"port\":8080,\"host\":\"app\"}}", ConfigurationStatus.active); + Configuration current = template("current.yaml", type, "{\"service\":{\"port\":9090},\"feature\":true}", ConfigurationStatus.draft); + when(repository.findFirstByKindAndTemplateType_IdAndStatusOrderByCreatedAtAsc( + ConfigurationKind.template, typeId, ConfigurationStatus.active)).thenReturn(Optional.of(baseline)); + + var response = service.describe(current); + + assertThat(response.keyPaths()).contains("service.port", "feature"); + assertThat(response.keyConsistency().consistent()).isFalse(); + assertThat(response.keyConsistency().baselineTemplateName()).isEqualTo("baseline.yaml"); + assertThat(response.keyConsistency().missingKeys()).containsExactly("service.host"); + assertThat(response.keyConsistency().extraKeys()).containsExactly("feature"); + } + + private Configuration template(String name, TemplateType type, String json, ConfigurationStatus status) throws Exception { + return new Configuration( + name, + ConfigurationFormat.json, + json, + objectMapper.readTree(json), + ConfigurationKind.template, + type, + status, + null + ); + } +} diff --git a/backend/src/test/java/com/cloudnative/exporting/ConfigurationExportServiceTest.java b/backend/src/test/java/com/cloudnative/exporting/ConfigurationExportServiceTest.java index ff0dcdb..436e9d3 100644 --- a/backend/src/test/java/com/cloudnative/exporting/ConfigurationExportServiceTest.java +++ b/backend/src/test/java/com/cloudnative/exporting/ConfigurationExportServiceTest.java @@ -60,6 +60,59 @@ void exportsJsonFromBackendManagedEffectiveConfig() { assertThat(preview.content()).contains("\"password\" : \"********************\""); } + + + @Test + void defaultsToYamlAndExportsNestedArraysNullsAndTemplateNameFallback() { + ProjectCompositionService compositionService = mock(ProjectCompositionService.class); + when(compositionService.findScopeConfig(PROJECT_ID, SCOPE_ID)).thenReturn(scopeConfigWithNestedValues()); + + ConfigurationExportService exportService = new ConfigurationExportService(compositionService, objectMapper); + + var preview = exportService.preview(new ExportRequest(PROJECT_ID, SCOPE_ID, null)); + + assertThat(preview.format()).isEqualTo(ExportFormat.yaml); + assertThat(preview.filename()).isEqualTo("core-api-global-default.yaml"); + assertThat(preview.content()).contains("Multi-Project Configuration Export"); + assertThat(preview.content()).contains("legacy-template.yaml"); + assertThat(preview.content()).contains("********************"); + assertThat(preview.keyCount()).isEqualTo(4); + assertThat(preview.overrideCount()).isEqualTo(2); + } + + @Test + void exportsEnvWithNormalizedKeysAndQuotedUnsafeValues() { + ProjectCompositionService compositionService = mock(ProjectCompositionService.class); + when(compositionService.findScopeConfig(PROJECT_ID, SCOPE_ID)).thenReturn(scopeConfigWithNestedValues()); + + ConfigurationExportService exportService = new ConfigurationExportService(compositionService, objectMapper); + + var preview = exportService.preview(new ExportRequest(PROJECT_ID, SCOPE_ID, ExportFormat.env)); + + assertThat(preview.filename()).isEqualTo("core-api-global-default.env"); + assertThat(preview.content()).contains("LEGACY_TEMPLATE_YAML_FEATURE_FLAGS_0=true"); + assertThat(preview.content()).contains("LEGACY_TEMPLATE_YAML_MESSAGE=\"hello world\""); + assertThat(preview.content()).contains("LEGACY_TEMPLATE_YAML_NULLABLE="); + } + + @Test + void exportsTomlAndIniSections() { + ProjectCompositionService compositionService = mock(ProjectCompositionService.class); + when(compositionService.findScopeConfig(PROJECT_ID, SCOPE_ID)).thenReturn(scopeConfigWithNestedValues()); + + ConfigurationExportService exportService = new ConfigurationExportService(compositionService, objectMapper); + + var toml = exportService.preview(new ExportRequest(PROJECT_ID, SCOPE_ID, ExportFormat.toml)); + var ini = exportService.preview(new ExportRequest(PROJECT_ID, SCOPE_ID, ExportFormat.ini)); + + assertThat(toml.content()).contains("[legacy-template.yaml]"); + assertThat(toml.content()).contains("message = \"hello world\""); + assertThat(toml.content()).contains("featureFlags = [true, false]"); + assertThat(ini.content()).contains("[legacy-template.yaml]"); + assertThat(ini.content()).contains("featureFlags[0]=true"); + assertThat(ini.content()).contains("nullable="); + } + private ProjectScopeConfigResponse scopeConfig() { var parsed = objectMapper.createObjectNode(); parsed.put("serviceName", "order-service"); @@ -117,4 +170,78 @@ private ProjectScopeConfigResponse scopeConfig() { )) ); } + + private ProjectScopeConfigResponse scopeConfigWithNestedValues() { + var parsed = objectMapper.createObjectNode(); + parsed.put("message", "hello world"); + parsed.putNull("nullable"); + var flags = parsed.putArray("featureFlags"); + flags.add(true); + flags.add(false); + var nested = parsed.putObject("nested"); + nested.put("apiToken", "raw-token"); + + return new ProjectScopeConfigResponse( + PROJECT_ID, + "Core API", + "Core API", + new ProjectScopeResponse( + SCOPE_ID, + UUID.fromString("55555555-5555-5555-5555-555555555555"), + null, + "Global", + UUID.fromString("66666666-6666-6666-6666-666666666666"), + null, + "Default", + 1 + ), + List.of(new ProjectConfigSectionResponse( + TEMPLATE_ID, + "legacy-template.yaml", + null, + ConfigurationFormat.yaml, + ConfigurationStatus.active, + "message: hello world\n", + parsed, + List.of("message", "nullable", "featureFlags[0]", "featureFlags[1]", "nested.apiToken"), + 100, + List.of( + new OverrideResponse( + UUID.fromString("99999999-9999-9999-9999-999999999999"), + PROJECT_ID, + SCOPE_ID, + UUID.randomUUID(), + "ignored.value", + objectMapper.getNodeFactory().textNode("ignored"), + false, + null, + LocalDateTime.of(2026, 5, 27, 10, 10) + ), + new OverrideResponse( + UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + PROJECT_ID, + SCOPE_ID, + TEMPLATE_ID, + "nested.apiToken", + objectMapper.getNodeFactory().textNode("override-token"), + false, + null, + LocalDateTime.of(2026, 5, 27, 10, 15) + ), + new OverrideResponse( + UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + PROJECT_ID, + SCOPE_ID, + TEMPLATE_ID, + "deleted.value", + objectMapper.getNodeFactory().textNode("deleted"), + true, + null, + LocalDateTime.of(2026, 5, 27, 10, 20) + ) + ) + )) + ); + } + } diff --git a/backend/src/test/java/com/cloudnative/project/OverrideServiceTest.java b/backend/src/test/java/com/cloudnative/project/OverrideServiceTest.java new file mode 100644 index 0000000..d22b237 --- /dev/null +++ b/backend/src/test/java/com/cloudnative/project/OverrideServiceTest.java @@ -0,0 +1,246 @@ +package com.cloudnative.project; + +import com.cloudnative.identity.role.ScopeRepository; +import com.cloudnative.versioning.VersionHistoryService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OverrideServiceTest { + private static final UUID PROJECT_ID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID SCOPE_ID = UUID.fromString("22222222-2222-2222-2222-222222222222"); + private static final UUID SESSION_ID = UUID.fromString("33333333-3333-3333-3333-333333333333"); + private static final UUID ACTOR_ID = UUID.fromString("44444444-4444-4444-4444-444444444444"); + private static final UUID TEMPLATE_ID = UUID.fromString("55555555-5555-5555-5555-555555555555"); + + @Mock + private ConfigurationOverrideRepository overrideRepository; + + @Mock + private ScopeRepository scopeRepository; + + @Mock + private VersionHistoryService versionHistoryService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void listByScopeValidatesScopeAndMapsOverrides() { + JsonNode value = objectMapper.createObjectNode().put("timeout", 60); + ConfigurationOverride override = new ConfigurationOverride( + PROJECT_ID, + SCOPE_ID, + TEMPLATE_ID, + "database.timeout", + value, + ACTOR_ID + ); + + when(scopeRepository.existsByIdAndProjectId(SCOPE_ID, PROJECT_ID)).thenReturn(true); + when(overrideRepository.findByProjectIdAndScopeIdAndIsDeletedFalse(PROJECT_ID, SCOPE_ID)) + .thenReturn(List.of(override)); + + var responses = service().listByScope(PROJECT_ID, SCOPE_ID); + + assertThat(responses).hasSize(1); + assertThat(responses.get(0).templateId()).isEqualTo(TEMPLATE_ID); + assertThat(responses.get(0).configKey()).isEqualTo("database.timeout"); + assertThat(responses.get(0).value()).isEqualTo(value); + } + + @Test + void currentStateReturnsExistingOverrideStateForTemplateKey() { + JsonNode value = objectMapper.createObjectNode().put("enabled", true); + ConfigurationOverride override = new ConfigurationOverride( + PROJECT_ID, + SCOPE_ID, + TEMPLATE_ID, + "feature.enabled", + value, + ACTOR_ID + ); + override.markDeleted(); + + when(overrideRepository.findByProjectIdAndScopeIdAndTemplateConfigIdAndConfigKey( + PROJECT_ID, SCOPE_ID, TEMPLATE_ID, "feature.enabled")) + .thenReturn(Optional.of(override)); + + var state = service().currentState(PROJECT_ID, SCOPE_ID, TEMPLATE_ID, "feature.enabled"); + + assertThat(state.value()).isNull(); + assertThat(state.deleted()).isTrue(); + } + + @Test + void currentStateReturnsEmptyStateWhenOverrideIsMissing() { + when(overrideRepository.findByProjectIdAndScopeIdAndTemplateConfigIdAndConfigKey( + PROJECT_ID, SCOPE_ID, TEMPLATE_ID, "feature.enabled")) + .thenReturn(Optional.empty()); + + var state = service().currentState(PROJECT_ID, SCOPE_ID, TEMPLATE_ID, "feature.enabled"); + + assertThat(state.value()).isNull(); + assertThat(state.deleted()).isFalse(); + } + + @Test + void applyChangeUpdatesExistingOverrideAndRecordsHistory() { + JsonNode before = objectMapper.createObjectNode().put("timeout", 30); + JsonNode after = objectMapper.createObjectNode().put("timeout", 60); + ConfigurationOverride override = new ConfigurationOverride( + PROJECT_ID, + SCOPE_ID, + TEMPLATE_ID, + "database.timeout", + before, + ACTOR_ID + ); + + when(scopeRepository.existsByIdAndProjectId(SCOPE_ID, PROJECT_ID)).thenReturn(true); + when(overrideRepository.findByProjectIdAndScopeIdAndTemplateConfigIdAndConfigKey( + PROJECT_ID, SCOPE_ID, TEMPLATE_ID, "database.timeout")) + .thenReturn(Optional.of(override)); + + service().applyChange( + SESSION_ID, + PROJECT_ID, + SCOPE_ID, + TEMPLATE_ID, + "database.timeout", + after, + false, + ACTOR_ID + ); + + assertThat(override.getValue()).isEqualTo(after); + assertThat(override.isDeleted()).isFalse(); + verify(overrideRepository, never()).save(any()); + verify(versionHistoryService).recordOverrideChange( + SESSION_ID, + PROJECT_ID, + SCOPE_ID, + TEMPLATE_ID, + "database.timeout", + before, + after, + false, + false + ); + } + + @Test + void applyChangeCreatesNewOverrideWhenMissing() { + JsonNode value = objectMapper.createObjectNode().put("pool", 10); + + when(scopeRepository.existsByIdAndProjectId(SCOPE_ID, PROJECT_ID)).thenReturn(true); + when(overrideRepository.findByProjectIdAndScopeIdAndTemplateConfigIdAndConfigKey( + PROJECT_ID, SCOPE_ID, TEMPLATE_ID, "database.pool")) + .thenReturn(Optional.empty()); + + service().applyChange( + SESSION_ID, + PROJECT_ID, + SCOPE_ID, + TEMPLATE_ID, + "database.pool", + value, + false, + ACTOR_ID + ); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(ConfigurationOverride.class); + verify(overrideRepository).save(captor.capture()); + assertThat(captor.getValue().getProjectId()).isEqualTo(PROJECT_ID); + assertThat(captor.getValue().getScopeId()).isEqualTo(SCOPE_ID); + assertThat(captor.getValue().getTemplateConfigId()).isEqualTo(TEMPLATE_ID); + assertThat(captor.getValue().getConfigKey()).isEqualTo("database.pool"); + assertThat(captor.getValue().getValue()).isEqualTo(value); + verify(versionHistoryService).recordOverrideChange( + SESSION_ID, + PROJECT_ID, + SCOPE_ID, + TEMPLATE_ID, + "database.pool", + null, + value, + false, + false + ); + } + + @Test + void applyChangeMarksExistingOverrideDeletedAndRecordsHistory() { + JsonNode before = objectMapper.createObjectNode().put("enabled", true); + ConfigurationOverride override = new ConfigurationOverride( + PROJECT_ID, + SCOPE_ID, + TEMPLATE_ID, + "feature.enabled", + before, + ACTOR_ID + ); + + when(scopeRepository.existsByIdAndProjectId(SCOPE_ID, PROJECT_ID)).thenReturn(true); + when(overrideRepository.findByProjectIdAndScopeIdAndTemplateConfigIdAndConfigKey( + PROJECT_ID, SCOPE_ID, TEMPLATE_ID, "feature.enabled")) + .thenReturn(Optional.of(override)); + + service().applyChange( + SESSION_ID, + PROJECT_ID, + SCOPE_ID, + TEMPLATE_ID, + "feature.enabled", + null, + true, + ACTOR_ID + ); + + assertThat(override.getValue()).isNull(); + assertThat(override.isDeleted()).isTrue(); + verify(versionHistoryService).recordOverrideChange( + SESSION_ID, + PROJECT_ID, + SCOPE_ID, + TEMPLATE_ID, + "feature.enabled", + before, + null, + false, + true + ); + } + + @Test + void validateScopeRejectsUnknownScope() { + when(scopeRepository.existsByIdAndProjectId(SCOPE_ID, PROJECT_ID)).thenReturn(false); + + OverrideService service = service(); + + assertThatThrownBy(() -> service.validateScope(PROJECT_ID, SCOPE_ID)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Project scope not found"); + } + + private OverrideService service() { + return new OverrideService(overrideRepository, scopeRepository, versionHistoryService); + } +} diff --git a/backend/src/test/java/com/cloudnative/versioning/VersionHistoryServiceTest.java b/backend/src/test/java/com/cloudnative/versioning/VersionHistoryServiceTest.java index 10166d8..fb341ed 100644 --- a/backend/src/test/java/com/cloudnative/versioning/VersionHistoryServiceTest.java +++ b/backend/src/test/java/com/cloudnative/versioning/VersionHistoryServiceTest.java @@ -7,8 +7,13 @@ import com.cloudnative.configuration.ConfigurationSnapshot; import com.cloudnative.configuration.ConfigurationSnapshotRepository; import com.cloudnative.configuration.ConfigurationStatus; +import com.cloudnative.identity.role.Environment; +import com.cloudnative.identity.role.Project; import com.cloudnative.identity.role.ProjectRepository; +import com.cloudnative.identity.role.Scope; import com.cloudnative.identity.role.ScopeRepository; +import com.cloudnative.identity.role.Site; +import com.cloudnative.identity.user.UserAccount; import com.cloudnative.identity.user.UserAccountRepository; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -26,9 +31,12 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.any; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -271,6 +279,161 @@ void restoreOverrideAfterStateAppliesHistoricalValuesAndRecordsHistory() throws assertThat(restoreHistory.isAfterDeleted()).isFalse(); } + + + @Test + void diffTemplateKeysFlattensNestedArraysNullsAndSkipsUnchangedValues() throws Exception { + JsonNode before = objectMapper.readTree(""" + {"service":{"port":8080,"hosts":["a","b"],"nullable":null}} + """); + JsonNode after = objectMapper.readTree(""" + {"service":{"port":9090,"hosts":["a","c"],"enabled":true}} + """); + + var changes = service().diffTemplateKeys(before, after); + + assertThat(changes).extracting("key") + .containsExactly("service.enabled", "service.hosts[1]", "service.nullable", "service.port"); + assertThat(changes).allMatch(change -> change.stillApplied()); + } + + @Test + void listSessionsSummarizesOverrideAndTemplateSessionsWithActorsAndScopeLabels() throws Exception { + UUID actorId = UUID.randomUUID(); + UUID overrideSessionId = UUID.randomUUID(); + UUID templateSessionId = UUID.randomUUID(); + UUID projectId = UUID.randomUUID(); + UUID scopeId = UUID.randomUUID(); + UUID templateId = UUID.randomUUID(); + + ChangeSession overrideSession = session(overrideSessionId, ChangeSessionType.direct, actorId, "Override values"); + ChangeSession templateSession = session(templateSessionId, ChangeSessionType.direct, actorId, "Template: app.yaml"); + ConfigurationOverrideHistory history = new ConfigurationOverrideHistory( + overrideSessionId, projectId, scopeId, templateId, "service.port", + objectMapper.readTree("8080"), objectMapper.readTree("9090"), false, false); + ConfigurationSnapshot snapshot = new ConfigurationSnapshot( + templateId, 1, templateSessionId, "{\"port\":8080}", objectMapper.readTree("{\"port\":8080}"), actorId); + Configuration template = template(templateId, "app.yaml", "{\"port\":9090}", objectMapper.readTree("{\"port\":9090}"), 2); + UserAccount user = new UserAccount("alice", "hash"); + ReflectionTestUtils.setField(user, "id", actorId); + + when(changeSessionRepository.findAllByOrderByCreatedAtDesc()).thenReturn(List.of(overrideSession, templateSession)); + when(overrideHistoryRepository.findBySessionIdIn(List.of(overrideSessionId, templateSessionId))).thenReturn(List.of(history)); + when(configurationSnapshotRepository.findBySessionIdIn(List.of(overrideSessionId, templateSessionId))).thenReturn(List.of(snapshot)); + when(configurationRepository.findAllById(any())).thenReturn(List.of(template)); + when(userAccountRepository.findAllById(any())).thenReturn(List.of(user)); + mockScope(scopeId, projectId, "Core API", "Taipei", "Production"); + + var summaries = service().listSessions(); + + assertThat(summaries).hasSize(2); + assertThat(summaries.get(0).actorName()).isEqualTo("alice"); + assertThat(summaries.get(0).changeCount()).isEqualTo(1); + assertThat(summaries.get(0).scopes()).containsExactly("Core API / Taipei / Production"); + assertThat(summaries.get(1).scopes()).containsExactly("app.yaml"); + assertThat(summaries.get(1).changeCount()).isEqualTo(1); + } + + @Test + void listSessionsReturnsEmptyWithoutRepositoryFanOut() { + when(changeSessionRepository.findAllByOrderByCreatedAtDesc()).thenReturn(List.of()); + + assertThat(service().listSessions()).isEmpty(); + + verify(overrideHistoryRepository, never()).findBySessionIdIn(any()); + } + + @Test + void getOverrideSessionDetailMarksSupersededChangesAndResolvesScopeLabels() throws Exception { + UUID actorId = UUID.randomUUID(); + UUID sessionId = UUID.randomUUID(); + UUID supersedingSessionId = UUID.randomUUID(); + UUID projectId = UUID.randomUUID(); + UUID scopeId = UUID.randomUUID(); + UUID templateId = UUID.randomUUID(); + ChangeSession original = session(sessionId, ChangeSessionType.direct, actorId, "Override values"); + UserAccount user = new UserAccount("alice", "hash"); + ReflectionTestUtils.setField(user, "id", actorId); + ConfigurationOverrideHistory history = new ConfigurationOverrideHistory( + sessionId, projectId, scopeId, templateId, "service.port", + objectMapper.readTree("8080"), objectMapper.readTree("9090"), false, false); + ConfigurationOverrideHistory newer = new ConfigurationOverrideHistory( + supersedingSessionId, projectId, scopeId, templateId, "service.port", + objectMapper.readTree("9090"), objectMapper.readTree("7070"), false, false); + + when(changeSessionRepository.findById(sessionId)).thenReturn(Optional.of(original)); + when(userAccountRepository.findById(actorId)).thenReturn(Optional.of(user)); + when(configurationSnapshotRepository.findFirstBySessionId(sessionId)).thenReturn(Optional.empty()); + when(overrideHistoryRepository.findBySessionId(sessionId)).thenReturn(List.of(history)); + when(overrideHistoryRepository.findByProjectIdAndScopeIdAndTemplateConfigIdAndConfigKeyAndCreatedAtGreaterThanOrderByCreatedAtAsc( + any(), any(), any(), anyString(), any())).thenReturn(List.of(newer)); + mockScope(scopeId, projectId, "Core API", "Taipei", "Production"); + + var detail = service().getSessionDetail(sessionId); + + assertThat(detail.actorName()).isEqualTo("alice"); + assertThat(detail.resourceKind()).isEqualTo("override"); + assertThat(detail.stillApplied()).isFalse(); + assertThat(detail.supersededBy()).isEqualTo(supersedingSessionId); + assertThat(detail.changes()).hasSize(1); + assertThat(detail.changes().get(0).project()).isEqualTo("Core API"); + assertThat(detail.changes().get(0).site()).isEqualTo("Taipei"); + assertThat(detail.changes().get(0).env()).isEqualTo("Production"); + assertThat(detail.changes().get(0).stillApplied()).isFalse(); + } + + @Test + void getTemplateSessionDetailUsesNextSnapshotAsAfterState() throws Exception { + UUID actorId = UUID.randomUUID(); + UUID sessionId = UUID.randomUUID(); + UUID nextSessionId = UUID.randomUUID(); + UUID templateId = UUID.randomUUID(); + ChangeSession original = session(sessionId, ChangeSessionType.direct, actorId, "Template: app.yaml"); + ConfigurationSnapshot snapshot = new ConfigurationSnapshot( + templateId, 1, sessionId, "{\"port\":8080}", objectMapper.readTree("{\"port\":8080}"), actorId); + ConfigurationSnapshot next = new ConfigurationSnapshot( + templateId, 2, nextSessionId, "{\"port\":9090}", objectMapper.readTree("{\"port\":9090}"), actorId); + Configuration template = template(templateId, "app.yaml", "{\"port\":7070}", objectMapper.readTree("{\"port\":7070}"), 3); + + when(changeSessionRepository.findById(sessionId)).thenReturn(Optional.of(original)); + when(userAccountRepository.findById(actorId)).thenReturn(Optional.empty()); + when(configurationSnapshotRepository.findFirstBySessionId(sessionId)).thenReturn(Optional.of(snapshot)); + when(configurationSnapshotRepository.findFirstByConfigurationIdAndVersionGreaterThanOrderByVersionAsc(templateId, 1)) + .thenReturn(Optional.of(next)); + when(configurationRepository.findById(templateId)).thenReturn(Optional.of(template)); + + var detail = service().getSessionDetail(sessionId); + + assertThat(detail.resourceKind()).isEqualTo("template"); + assertThat(detail.stillApplied()).isFalse(); + assertThat(detail.supersededBy()).isEqualTo(nextSessionId); + assertThat(detail.versionFrom()).isEqualTo(1); + assertThat(detail.versionTo()).isEqualTo(2); + assertThat(detail.changes()).hasSize(1); + assertThat(detail.changes().get(0).project()).isEqualTo("app.yaml"); + } + + @Test + void restoreRejectsMissingTargetMissingSessionAndEmptyOverrideHistory() { + UUID sessionId = UUID.randomUUID(); + UUID actorId = UUID.randomUUID(); + + assertThatThrownBy(() -> service().restoreSessionState(sessionId, null, actorId, "notes")) + .hasMessageContaining("Restore target is required"); + + when(changeSessionRepository.findById(sessionId)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service().restoreSessionState(sessionId, RestoreTarget.before, actorId, "notes")) + .hasMessageContaining("Change session not found"); + + ChangeSession session = session(sessionId, ChangeSessionType.direct, actorId, "No changes"); + when(changeSessionRepository.findById(sessionId)).thenReturn(Optional.of(session)); + when(configurationSnapshotRepository.findFirstBySessionId(sessionId)).thenReturn(Optional.empty()); + when(overrideHistoryRepository.findBySessionId(sessionId)).thenReturn(List.of()); + + assertThatThrownBy(() -> service().restoreSessionState(sessionId, RestoreTarget.before, actorId, "notes")) + .hasMessageContaining("Change session has no changes to restore"); + } + private VersionHistoryService service() { return new VersionHistoryService( changeSessionRepository, @@ -284,4 +447,39 @@ private VersionHistoryService service() { objectMapper ); } + + private ChangeSession session(UUID id, ChangeSessionType type, UUID actorId, String title) { + ChangeSession session = new ChangeSession(type, actorId, null, title, "notes"); + ReflectionTestUtils.setField(session, "id", id); + return session; + } + + private Configuration template(UUID id, String name, String raw, JsonNode parsed, int version) { + Configuration template = new Configuration( + name, + ConfigurationFormat.json, + raw, + parsed, + ConfigurationKind.template, + ConfigurationStatus.active, + null + ); + ReflectionTestUtils.setField(template, "id", id); + ReflectionTestUtils.setField(template, "version", version); + return template; + } + + private void mockScope(UUID scopeId, UUID projectId, String projectName, String siteName, String envName) { + Project project = new Project("core-api", projectName); + ReflectionTestUtils.setField(project, "id", projectId); + Site site = mock(Site.class); + when(site.getSiteName()).thenReturn(siteName); + Environment environment = mock(Environment.class); + when(environment.getEnvName()).thenReturn(envName); + Scope scope = new Scope(projectId, site, environment); + ReflectionTestUtils.setField(scope, "id", scopeId); + when(scopeRepository.findAllById(any())).thenReturn(List.of(scope)); + when(projectRepository.findAllById(any())).thenReturn(List.of(project)); + } + } diff --git a/frontend/src/PrototypeUI.jsx b/frontend/src/PrototypeUI.jsx index f04660c..5108b02 100644 --- a/frontend/src/PrototypeUI.jsx +++ b/frontend/src/PrototypeUI.jsx @@ -422,7 +422,9 @@ export default function PrototypeUI() { }; const handleChangeSubmit = ({ keyName, from, to, affectedProjects, pinnedScopes }) => { - const crId = `CR-${Math.floor(Math.random() * 900) + 200}`; + const randomValues = new Uint32Array(1); + crypto.getRandomValues(randomValues); + const crId = `CR-${(randomValues[0] % 900) + 200}`; setImpactKey(null); setNotification( `${crId} created — ${keyName} will change ${from || '—'} → ${to || '—'} across ${affectedProjects} project${ diff --git a/frontend/src/components/ConfigImportModal.jsx b/frontend/src/components/ConfigImportModal.jsx index c891955..64bf1fa 100644 --- a/frontend/src/components/ConfigImportModal.jsx +++ b/frontend/src/components/ConfigImportModal.jsx @@ -143,9 +143,9 @@ export function ConfigImportModal({ scope, onSubmit, onClose }) { function ScopeDisplay({ scope }) { return (
-