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 ( - + Target Scope - + {scope.project} @@ -178,16 +178,17 @@ function FileDropzone({ file, inputRef, onSelect }) { }; return ( - + 1. Source File - - + event.preventDefault()} - onClick={() => inputRef.current?.click()} - className="border-2 border-dashed border-slate-200 rounded-lg p-6 text-center cursor-pointer hover:bg-slate-50 transition-colors" + className="block border-2 border-dashed border-slate-200 rounded-lg p-6 text-center cursor-pointer hover:bg-slate-50 transition-colors" > Supports .yaml .yml .properties .env > )} - + ); } @@ -211,9 +212,9 @@ function FileDropzone({ file, inputRef, onSelect }) { function FormatPicker({ format, onChange, disabled }) { return ( - + 2. Format - + {FORMATS.map((option) => ( - + 3. Detected Keys - + {keys.length} total diff --git a/frontend/src/features/auth/components/RoleSelectionForm.jsx b/frontend/src/features/auth/components/RoleSelectionForm.jsx index 9b4f4d6..facc4b9 100644 --- a/frontend/src/features/auth/components/RoleSelectionForm.jsx +++ b/frontend/src/features/auth/components/RoleSelectionForm.jsx @@ -37,7 +37,7 @@ export function RoleSelectionForm({ value, onChange, errorMessage }) { return ( - Role Selections + Role Selections - Role + Role updateRow(index, { role: event.target.value })} className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white" @@ -72,8 +73,9 @@ export function RoleSelectionForm({ value, onChange, errorMessage }) { - Project ID (optional) + Project ID (optional) - Scope ID (optional) + Scope ID (optional) setHoveredTile(tile.page)} - onMouseLeave={() => setHoveredTile(null)} > setHoveredTile(tile.page)} + onMouseLeave={() => setHoveredTile(null)} + onFocus={() => setHoveredTile(tile.page)} + onBlur={() => setHoveredTile(null)} className="w-full text-left bg-white border border-slate-200 rounded-xl shadow-sm px-4 py-4 hover:border-indigo-300 hover:shadow transition" > diff --git a/frontend/src/pages/Export.jsx b/frontend/src/pages/Export.jsx index 86ec898..25a4bf0 100644 --- a/frontend/src/pages/Export.jsx +++ b/frontend/src/pages/Export.jsx @@ -211,7 +211,7 @@ function ExportContextFields({ return ( - 1. Selection Context + 1. Selection Context - 2. Output Format + 2. Output Format {FORMATS.map((option) => ( = 'a' && character <= 'z') || (character >= '0' && character <= '9')) { + normalized.push(character); + previousUnderscore = false; + } else if (!previousUnderscore) { + normalized.push('_'); + previousUnderscore = true; + } + } + while (normalized[0] === '_') normalized.shift(); + while (normalized.at(-1) === '_') normalized.pop(); + return normalized.join(''); } function uniqueTemplateTypeCode(name, templateTypes) { diff --git a/frontend/src/pages/Versions.jsx b/frontend/src/pages/Versions.jsx index 4251419..f59b3a9 100644 --- a/frontend/src/pages/Versions.jsx +++ b/frontend/src/pages/Versions.jsx @@ -456,7 +456,9 @@ function Inspector({ session, detailLoading, onNavigate, onClose, onRestore, res if (detailLoading && !session) { return ( <> - @@ -471,7 +473,9 @@ function Inspector({ session, detailLoading, onNavigate, onClose, onRestore, res return ( <> - diff --git a/frontend/tests/api/exports.test.js b/frontend/tests/api/exports.test.js index 55f353d..dbc2c44 100644 --- a/frontend/tests/api/exports.test.js +++ b/frontend/tests/api/exports.test.js @@ -56,4 +56,41 @@ describe('exports API', () => { }), })); }); + + it('supports object tokens and UTF-8 filenames when downloading exports', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('server.port=8080\n', { + status: 200, + headers: { + 'Content-Type': 'text/plain', + 'Content-Disposition': "attachment; filename*=UTF-8''core%20api.yaml", + }, + })); + + const result = await downloadExport({ + projectId: 'project-1', + scopeId: 'scope-1', + format: 'YAML', + }, { token: 'token-2' }); + + expect(result.filename).toBe('core api.yaml'); + expect(fetch).toHaveBeenCalledWith('http://localhost:8080/api/exports/download', expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer token-2', + }), + })); + }); + + it('uses the default export error message for non-JSON failures', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('bad gateway', { + status: 502, + headers: { 'Content-Type': 'text/plain' }, + })); + + await expect(downloadExport({ + projectId: 'project-1', + scopeId: 'scope-1', + format: 'TOML', + }, null)).rejects.toThrow('Export failed with status 502'); + }); + }); diff --git a/frontend/tests/components/ConfigImportModal.test.jsx b/frontend/tests/components/ConfigImportModal.test.jsx index 52d5ec5..c2902b0 100644 --- a/frontend/tests/components/ConfigImportModal.test.jsx +++ b/frontend/tests/components/ConfigImportModal.test.jsx @@ -47,6 +47,38 @@ describe('ConfigImportModal', () => { vi.unstubAllGlobals(); }); + + it('accepts dropped env files and summarizes long key lists', async () => { + vi.stubGlobal('FileReader', FileReaderMock); + const file = new File([''], 'bulk.env', { type: 'text/plain' }); + file.__text = [ + 'ONE=1', + 'TWO=2', + 'THREE=3', + 'FOUR=4', + 'FIVE=5', + 'SIX=6', + 'SEVEN=7', + ].join('\n'); + + render( + , + ); + + fireEvent.drop(screen.getByText(/Drop a file here/i).closest('label'), { + dataTransfer: { files: [file] }, + }); + + expect(await screen.findByText('bulk.env')).toBeInTheDocument(); + expect(await screen.findByText('ONE')).toBeInTheDocument(); + expect(await screen.findByText('+1 more')).toBeInTheDocument(); + vi.unstubAllGlobals(); + }); + it('shows an error when no keys are detected', async () => { vi.stubGlobal('FileReader', FileReaderMock); const file = new File(['# comment only'], 'empty.env', { type: 'text/plain' }); diff --git a/frontend/tests/components/KeyImpactModal.test.jsx b/frontend/tests/components/KeyImpactModal.test.jsx new file mode 100644 index 0000000..472a742 --- /dev/null +++ b/frontend/tests/components/KeyImpactModal.test.jsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { KeyImpactModal } from '../../src/components/KeyImpactModal.jsx'; + +const templates = [ + { + id: 'tpl-db', + name: 'db-base.yaml', + usedBy: 2, + keys: [ + { + key: 'db.timeout', + defaultValue: '30s', + overridable: true, + }, + ], + }, +]; + +const projects = [ + { + name: 'Core API', + owner: 'platform', + templates: ['db-base.yaml'], + overrides: ['db.timeout'], + sites: ['US'], + environments: ['Production'], + }, + { + name: 'Admin UI', + owner: 'frontend', + templates: ['db-base.yaml'], + overrides: [], + sites: ['EU', 'US'], + environments: ['Staging', 'Production'], + }, +]; + +describe('KeyImpactModal', () => { + it('renders key impact sources, inherited projects, and pinned scopes', () => { + render( + , + ); + + expect(screen.getByText('Key Impact')).toBeInTheDocument(); + expect(screen.getAllByText('db-base.yaml').length).toBeGreaterThan(0); + expect(screen.getByText('Admin UI')).toBeInTheDocument(); + expect(screen.getAllByText('Core API').length).toBeGreaterThan(0); + expect(screen.getAllByText('30s').length).toBeGreaterThan(0); + }); + + it('submits a proposed value change with affected project metadata', () => { + const onSubmit = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByPlaceholderText('Enter new default value'), { + target: { value: '45s' }, + }); + fireEvent.change(screen.getByPlaceholderText('Why does db.timeout need to change?'), { + target: { value: 'Reduce timeout failures.' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Submit change request' })); + + expect(onSubmit).toHaveBeenCalledWith({ + keyName: 'db.timeout', + from: '30s', + to: '45s', + reason: 'Reduce timeout failures.', + affectedProjects: 2, + pinnedScopes: 1, + sourceTemplate: 'db-base.yaml', + }); + }); + + it('shows an empty state and disables proposing for unknown keys', () => { + render( + , + ); + + expect(screen.getAllByText(/missing.key/).length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: /Propose change/i })).toBeDisabled(); + }); +}); diff --git a/frontend/tests/components/layout.test.jsx b/frontend/tests/components/layout.test.jsx new file mode 100644 index 0000000..3961aba --- /dev/null +++ b/frontend/tests/components/layout.test.jsx @@ -0,0 +1,115 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Bell } from 'lucide-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Sidebar } from '../../src/components/layout/Sidebar.jsx'; +import { Toast } from '../../src/components/layout/Toast.jsx'; +import { TopBar } from '../../src/components/layout/TopBar.jsx'; +import { FilterChip } from '../../src/components/ui/FilterChip.jsx'; + +describe('layout components', () => { + it('renders the notification top bar', () => { + const { container } = render(); + + expect(container.querySelector('header')).toBeInTheDocument(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders a toast when a message is provided', () => { + render(); + + expect(screen.getByText('Saved successfully')).toBeInTheDocument(); + }); + + it('renders no toast content when the message is empty', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders filter chip label, value, and icon', () => { + render(); + + expect(screen.getByRole('button')).toHaveTextContent('Status'); + expect(screen.getByRole('button')).toHaveTextContent('Open'); + expect(screen.getByRole('button').querySelector('svg')).toBeInTheDocument(); + }); +}); + + + it('renders sidebar navigation, logout, and empty saved account picker state', () => { + const onNavigate = vi.fn(); + const onLogout = vi.fn(); + + render( + , + ); + + expect(screen.getByText('AL')).toBeInTheDocument(); + expect(screen.getByText('reader')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Dashboard/i })); + expect(onNavigate).toHaveBeenCalledWith('Dashboard'); + + fireEvent.click(screen.getByRole('button', { name: /alice/i })); + expect(screen.getByText('No other saved accounts')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Sign out/i })); + expect(onLogout).toHaveBeenCalled(); + }); + + it('switches saved accounts and reports switch failures', async () => { + const onSwitchAccount = vi + .fn() + .mockRejectedValueOnce(new Error('Session expired')) + .mockResolvedValueOnce(undefined); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: /alice/i })); + fireEvent.click(screen.getByRole('button', { name: /bob/i })); + + expect(await screen.findByText('Session expired')).toBeInTheDocument(); + expect(onSwitchAccount).toHaveBeenCalledWith('writer'); + + fireEvent.click(screen.getByRole('button', { name: /bob/i })); + await waitFor(() => expect(onSwitchAccount).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByText('bob')).not.toBeInTheDocument()); + }); + + it('uses fallback identity labels when user and roles are missing', () => { + render( + , + ); + + expect(screen.getByText('NA')).toBeInTheDocument(); + expect(screen.getByText('Unknown user')).toBeInTheDocument(); + expect(screen.getByText('No roles')).toBeInTheDocument(); + }); diff --git a/frontend/tests/features/auth/AuthProvider.test.jsx b/frontend/tests/features/auth/AuthProvider.test.jsx index a8aedf3..d532fc7 100644 --- a/frontend/tests/features/auth/AuthProvider.test.jsx +++ b/frontend/tests/features/auth/AuthProvider.test.jsx @@ -138,8 +138,10 @@ describe('AuthProvider', () => { fireEvent.click(await screen.findByText('login')); - await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated')); - expect(screen.getByTestId('user')).toHaveTextContent('alice'); + await waitFor(() => { + expect(screen.getByTestId('status')).toHaveTextContent('authenticated'); + expect(screen.getByTestId('user')).toHaveTextContent('alice'); + }); expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)).token).toBe('token-alice'); expect(JSON.parse(localStorage.getItem(SAVED_SESSIONS_KEY))).toHaveLength(1); }); @@ -223,7 +225,10 @@ describe('AuthProvider', () => { ); fireEvent.click(await screen.findByText('login')); - await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated')); + await waitFor(() => { + expect(screen.getByTestId('status')).toHaveTextContent('authenticated'); + expect(screen.getByTestId('user')).toHaveTextContent('alice'); + }); fireEvent.click(screen.getByText('logout')); diff --git a/frontend/tests/features/auth/useAuth.test.jsx b/frontend/tests/features/auth/useAuth.test.jsx new file mode 100644 index 0000000..9a35ae1 --- /dev/null +++ b/frontend/tests/features/auth/useAuth.test.jsx @@ -0,0 +1,28 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { AuthContext } from '../../../src/features/auth/context/AuthProvider.jsx'; +import { useAuth } from '../../../src/features/auth/hooks/useAuth.js'; + +describe('useAuth', () => { + it('returns the auth context value', () => { + const authValue = { + user: { username: 'admin' }, + token: 'token', + }; + + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + expect(result.current).toBe(authValue); + }); + + it('throws when used outside AuthProvider', () => { + expect(() => renderHook(() => useAuth())).toThrow( + 'useAuth must be used inside AuthProvider', + ); + }); +}); diff --git a/frontend/tests/lib/formatConfigContent.test.js b/frontend/tests/lib/formatConfigContent.test.js new file mode 100644 index 0000000..b63a2af --- /dev/null +++ b/frontend/tests/lib/formatConfigContent.test.js @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatConfigContent, + formatParsedContent, +} from '../../src/lib/formatConfigContent.js'; + +describe('formatConfigContent', () => { + it('pretty prints valid JSON raw content', () => { + expect(formatConfigContent('json', '{"enabled":true}', null)).toBe( + JSON.stringify({ enabled: true }, null, 2), + ); + }); + + it('falls back to parsed content when JSON raw content is invalid', () => { + expect(formatConfigContent('json', '{bad json', { retry: 3 })).toBe( + JSON.stringify({ retry: 3 }, null, 2), + ); + }); + + it('returns raw content for non-json formats', () => { + expect(formatConfigContent('yaml', 'enabled: true', { enabled: false })).toBe( + 'enabled: true', + ); + }); + + it('formats parsed content when raw content is missing', () => { + expect(formatConfigContent(null, null, { nested: { value: 1 } })).toBe( + JSON.stringify({ nested: { value: 1 } }, null, 2), + ); + }); + + it('returns an empty string for missing parsed content', () => { + expect(formatParsedContent(undefined)).toBe(''); + expect(formatParsedContent(null)).toBe(''); + }); +}); diff --git a/frontend/tests/pages/Dashboard.test.jsx b/frontend/tests/pages/Dashboard.test.jsx new file mode 100644 index 0000000..152dacf --- /dev/null +++ b/frontend/tests/pages/Dashboard.test.jsx @@ -0,0 +1,140 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/api/changeRequests.js', () => ({ + fetchChangeRequests: vi.fn(), +})); + +vi.mock('../../src/api/projects.js', () => ({ + fetchProjects: vi.fn(), +})); + +vi.mock('../../src/api/templates.js', () => ({ + fetchTemplates: vi.fn(), +})); + +vi.mock('../../src/api/versions.js', () => ({ + fetchSessions: vi.fn(), +})); + +import { fetchChangeRequests } from '../../src/api/changeRequests.js'; +import { fetchProjects } from '../../src/api/projects.js'; +import { fetchTemplates } from '../../src/api/templates.js'; +import { fetchSessions } from '../../src/api/versions.js'; +import { Dashboard } from '../../src/pages/Dashboard.jsx'; +import { PAGES } from '../../src/data/navigation.js'; + +const request = { + id: 'cr-12345678', + title: 'Update production timeout', + targetLabel: 'Core API / Production', + status: 'pending', + createdAt: '2026-05-22T10:30:00Z', +}; + +const project = { + id: 'project-1', + name: 'Core API', + code: 'core-api', + scopes: [{ id: 'scope-1' }, { id: 'scope-2' }], + templates: ['application.yaml'], +}; + +const template = { + id: 'template-1', + name: 'application.yaml', + templateType: { name: 'Application Config' }, +}; + +const directSession = { + id: 'session-1', + type: 'direct', + title: 'Template: application.yaml', + actor: { name: 'alice' }, + changeCount: 1, + time: '10:30', +}; + +const restoreSession = { + id: 'session-2', + type: 'rollback', + title: 'Restore template application.yaml to v2 from Template: application.yaml', + actor: null, + changeCount: 2, + time: '11:00', +}; + +function mockSuccessfulLoads(overrides = {}) { + fetchChangeRequests.mockResolvedValue(overrides.requests ?? [request]); + fetchProjects.mockResolvedValue(overrides.projects ?? [project]); + fetchTemplates.mockResolvedValue(overrides.templates ?? [template]); + fetchSessions.mockResolvedValue(overrides.sessions ?? [directSession, restoreSession]); +} + +describe('Dashboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loaded metrics, popovers, tables, and navigation actions', async () => { + mockSuccessfulLoads(); + const onNavigate = vi.fn(); + const onOpenRequests = vi.fn(); + + render(); + + expect(await screen.findByText('Update production timeout')).toBeInTheDocument(); + expect(screen.getByText('Core API / Production')).toBeInTheDocument(); + expect(screen.getByText('application.yaml')).toBeInTheDocument(); + expect(screen.getByText('Restore application.yaml')).toBeInTheDocument(); + expect(screen.getByText('v2')).toBeInTheDocument(); + + const templatesTile = screen.getAllByRole('button', { name: /Templates/i })[0]; + fireEvent.mouseEnter(templatesTile); + expect(screen.getByText('Application Config')).toBeInTheDocument(); + fireEvent.mouseLeave(templatesTile); + + fireEvent.click(screen.getAllByRole('button', { name: /Requests/i })[0]); + expect(onOpenRequests).toHaveBeenCalledWith('pending'); + + fireEvent.click(screen.getByText('Update production timeout')); + expect(onNavigate).toHaveBeenCalledWith(PAGES.REQUESTS); + + fireEvent.click(screen.getByText('Core API')); + expect(onNavigate).toHaveBeenCalledWith(PAGES.PROJECTS); + + fireEvent.click(screen.getAllByRole('button', { name: /View All/i })[0]); + expect(onNavigate).toHaveBeenCalledWith(PAGES.REQUESTS); + }); + + it('shows empty states and routes request tile without pending attention', async () => { + mockSuccessfulLoads({ requests: [], projects: [], templates: [], sessions: [] }); + const onNavigate = vi.fn(); + + render(); + + expect(await screen.findByText('No change requests yet.')).toBeInTheDocument(); + expect(screen.getAllByText('No activity yet.').length).toBeGreaterThan(0); + expect(screen.getAllByText('No projects yet.').length).toBeGreaterThan(0); + + fireEvent.mouseEnter(screen.getByRole('button', { name: /Projects/i }).parentElement); + expect(screen.getAllByText('No projects yet.').length).toBeGreaterThan(0); + + fireEvent.click(screen.getAllByRole('button', { name: /Requests/i })[0]); + expect(onNavigate).toHaveBeenCalledWith(PAGES.REQUESTS); + }); + + it('surfaces partial load failures while rendering fulfilled data', async () => { + fetchChangeRequests.mockRejectedValue(new Error('requests unavailable')); + fetchProjects.mockResolvedValue([project]); + fetchTemplates.mockResolvedValue([template]); + fetchSessions.mockResolvedValue([{ ...directSession, type: 'custom', title: '', actor: null, changeCount: 0 }]); + + render(); + + expect(await screen.findByText('Some data failed to load: requests unavailable')).toBeInTheDocument(); + expect(screen.getByText('Core API')).toBeInTheDocument(); + expect(screen.getByText('(untitled change)')).toBeInTheDocument(); + expect(screen.getByText('system')).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/pages/coverage-pages.test.jsx b/frontend/tests/pages/coverage-pages.test.jsx new file mode 100644 index 0000000..07bec67 --- /dev/null +++ b/frontend/tests/pages/coverage-pages.test.jsx @@ -0,0 +1,238 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/api/versions.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessions: vi.fn(), + fetchSessionDetail: vi.fn(), + restoreSession: vi.fn(), + }; +}); + +vi.mock('../../src/api/changeRequests.js', () => ({ + addChangeRequestComment: vi.fn(), + approveChangeRequest: vi.fn(), + fetchChangeRequestDetail: vi.fn(), + fetchChangeRequests: vi.fn(), + rejectChangeRequest: vi.fn(), +})); + +vi.mock('../../src/api/exports.js', () => ({ + downloadExport: vi.fn(), + previewExport: vi.fn(), +})); + +import { fetchChangeRequestDetail, fetchChangeRequests } from '../../src/api/changeRequests.js'; +import { previewExport } from '../../src/api/exports.js'; +import { fetchSessionDetail, fetchSessions } from '../../src/api/versions.js'; +import { Admin } from '../../src/pages/Admin.jsx'; +import { Export } from '../../src/pages/Export.jsx'; +import { Requests } from '../../src/pages/Requests.jsx'; +import { Versions } from '../../src/pages/Versions.jsx'; + +const versionSession = { + id: 'session-1', + type: 'cr', + title: 'Updated timeout', + actor: { name: 'alice' }, + createdAt: '2026-05-22T10:30:00Z', + time: '2026-05-22 10:30', + changeCount: 2, + scopeLabels: ['Core API / Production'], + status: 'deployed', + stillApplied: true, + crId: 'CR-1', +}; + +const versionDetail = { + ...versionSession, + summary: 'Increase timeout for production.', + approvers: ['nina'], + deployedAt: '2026-05-22 10:35', + resourceKind: 'configuration', + changes: [ + { + key: 'database.timeout', + before: '30', + after: '60', + project: 'Core API', + site: 'US-West-12', + env: 'Production', + layerBefore: 'template', + layerAfter: 'override', + }, + ], +}; + +const requestSummary = { + id: 'cr-12345678', + title: 'Raise timeout', + requesterId: 'user-1', + requesterName: 'alice', + targetLabel: 'Core API / Production', + targetKind: 'configuration', + status: 'pending', + action: 'update', + createdAt: '2026-05-22T10:00:00Z', +}; + +const requestDetail = { + ...requestSummary, + reason: 'Production requests need more time.', + approverName: null, + reviewedAt: null, + sessionId: null, + diffs: [{ key: 'database.timeout', before: '30', after: '60' }], + comments: [ + { + id: 'comment-1', + eventType: 'opened', + authorName: 'alice', + createdAt: '2026-05-22T10:00:00Z', + body: 'Please review.', + }, + ], +}; + +const exportProject = { + id: 'project-1', + name: 'Core API', + scopes: [ + { + id: 'scope-prod', + siteName: 'US-West-12', + environmentName: 'Production', + }, + ], +}; + +const exportPreview = { + content: 'database:\n timeout: 60', + projectName: 'Core API', + scope: exportProject.scopes[0], + generatedAt: '2026-05-22T10:30:00Z', + keyCount: 1, + overrideCount: 1, +}; + +describe('coverage-oriented page rendering', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders export metadata and switches preview format', async () => { + previewExport.mockResolvedValue(exportPreview); + + render(); + + expect(screen.getByText('Export Configuration')).toBeInTheDocument(); + expect(screen.getAllByText('Core API').length).toBeGreaterThan(0); + expect(screen.getByText('yaml export preview')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getAllByText((_, node) => node?.textContent === 'database:\n timeout: 60').length) + .toBeGreaterThan(0); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Env' })); + + expect(screen.getByText('env export preview')).toBeInTheDocument(); + await waitFor(() => expect(previewExport).toHaveBeenLastCalledWith( + { projectId: 'project-1', scopeId: 'scope-prod', format: 'Env' }, + 'token', + )); + }); + + it('loads admin users and saves selected role assignments', async () => { + const updatedUser = { + user: { id: 'user-1', username: 'alice' }, + roles: [{ role: 'reader', projectId: null, scopeId: null }], + }; + const onListAdminUsers = vi.fn().mockResolvedValue([updatedUser]); + const onUpdateAdminUserRoles = vi.fn().mockResolvedValue(updatedUser); + + render( + , + ); + + await waitFor(() => expect(screen.getAllByText('alice').length).toBeGreaterThan(0)); + expect(screen.getByText((_, node) => node?.textContent === 'Available roles: reader, writer, admin')).toBeInTheDocument(); + await waitFor(() => expect(screen.getAllByDisplayValue('reader').length).toBeGreaterThan(0)); + + fireEvent.click(screen.getByRole('button', { name: 'Save Roles' })); + + expect(await screen.findByText('Updated roles for alice.')).toBeInTheDocument(); + expect(onUpdateAdminUserRoles).toHaveBeenCalledWith('user-1', [ + { role: 'reader', projectId: null, scopeId: null }, + ]); + }); + + it('loads version history, filters rows, and opens the detail inspector', async () => { + fetchSessions.mockResolvedValue([versionSession]); + fetchSessionDetail.mockResolvedValue(versionDetail); + const onNavigate = vi.fn(); + + render( + , + ); + + expect(await screen.findByText('Updated timeout')).toBeInTheDocument(); + expect(screen.getByText('1 row')).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('Search a person, key, project, or CR...'), { + target: { value: 'alice' }, + }); + expect(screen.getByText('1 row')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Updated timeout')); + + await waitFor(() => expect(fetchSessionDetail).toHaveBeenCalledWith('session-1', 'token')); + fireEvent.click(screen.getByRole('button', { name: /Hide details/i })); + expect(screen.getByRole('button', { name: /Show details/i })).toBeInTheDocument(); + }); + + it('loads change requests and opens the review drawer', async () => { + fetchChangeRequests.mockResolvedValue([requestSummary]); + fetchChangeRequestDetail.mockResolvedValue(requestDetail); + + render( + , + ); + + expect(await screen.findByText('Raise timeout')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Raise timeout')); + + const drawer = await screen.findByText('Production requests need more time.'); + expect(drawer).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Diff' })); + expect(screen.getByText('database.timeout')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Conversation/i })); + expect(screen.getByText('Please review.')).toBeInTheDocument(); + + const dialog = screen.getByPlaceholderText('Add a comment...').closest('div'); + expect(within(dialog).getByRole('button', { name: /Comment/i })).toBeDisabled(); + }); +}); diff --git a/frontend/tests/pages/projectsTemplatesCoverage.test.jsx b/frontend/tests/pages/projectsTemplatesCoverage.test.jsx new file mode 100644 index 0000000..b636539 --- /dev/null +++ b/frontend/tests/pages/projectsTemplatesCoverage.test.jsx @@ -0,0 +1,410 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/api/projects.js', () => ({ + fetchProjectScopeConfig: vi.fn(), +})); + +import { fetchProjectScopeConfig } from '../../src/api/projects.js'; +import { Projects } from '../../src/pages/Projects.jsx'; +import { Templates } from '../../src/pages/Templates.jsx'; + +const templateTypes = [ + { + id: 'application-config', + code: 'application_config', + name: 'Application Config', + description: 'Runtime settings for application services.', + }, + { + id: 'database-config', + code: 'database_config', + name: 'Database Config', + description: 'Database connectivity settings.', + }, +]; + +const baseTemplate = { + id: 'tpl-app-prod', + name: 'application-prod.yaml', + description: 'Production application defaults.', + templateType: templateTypes[0], + format: 'yaml', + version: 3, + updatedAt: '2026-05-22T12:00:00Z', + status: 'active', + keysCount: 3, + usedBy: 1, + projects: ['AI Agent'], + rawContent: 'serviceName: ai-agent\nport: 8080\nfeatureFlag: true', + parsedContent: { serviceName: 'ai-agent', port: 8080, featureFlag: true }, + keys: [ + { key: 'serviceName', defaultValue: 'ai-agent' }, + { key: 'port', defaultValue: '8080' }, + { key: 'featureFlag', defaultValue: 'true' }, + ], + keyConsistency: { consistent: true }, +}; + +const warningTemplate = { + id: 'tpl-db-prod', + name: 'database-prod.yaml', + description: 'Production database defaults.', + templateType: templateTypes[1], + format: 'yaml', + version: 4, + updatedAt: '2026-05-22T13:00:00Z', + status: 'draft', + keysCount: 2, + usedBy: 1, + projects: ['AI Agent'], + rawContent: 'host: db.prod.internal\nport: 5432', + parsedContent: { host: 'db.prod.internal', port: 5432 }, + keys: [ + { key: 'host', defaultValue: 'db.prod.internal' }, + { key: 'port', defaultValue: '5432' }, + ], + keyConsistency: { + consistent: false, + baselineTemplateName: 'database-base.yaml', + missingKeys: ['pool.maxSize'], + extraKeys: ['legacyFlag'], + }, +}; + +const scopeSections = [ + { + templateId: baseTemplate.id, + templateName: baseTemplate.name, + templateType: templateTypes[0], + format: 'yaml', + status: 'active', + rawContent: baseTemplate.rawContent, + parsedContent: baseTemplate.parsedContent, + keyPaths: ['serviceName', 'port', 'featureFlag'], + keys: baseTemplate.keys, + priority: 100, + }, + { + templateId: warningTemplate.id, + templateName: warningTemplate.name, + templateType: templateTypes[1], + format: 'yaml', + status: 'draft', + rawContent: warningTemplate.rawContent, + parsedContent: warningTemplate.parsedContent, + keyPaths: ['host', 'port'], + keys: warningTemplate.keys, + priority: 110, + }, +]; + +const factoryScope = { + id: 'scope-prod', + scopeId: 'scope-prod', + siteId: 'site-us', + siteCode: 'us-west-12', + siteName: 'US-West-12', + environmentId: 'env-prod', + environmentCode: 'production', + environmentName: 'Production', + templateCount: 2, + sections: scopeSections, +}; + +const project = { + id: 'project-1', + code: 'ai-agent', + projectCode: 'ai-agent', + name: 'AI Agent', + projectName: 'AI Agent', + owner: 'Platform Config Team', + sites: ['US-West-12', 'EU-Central-2'], + environments: ['Staging', 'Production'], + templates: [baseTemplate.name, warningTemplate.name], + templateSelections: [], + missingTemplateTypes: [], + overrides: ['port'], + scopes: [factoryScope], + summary: '2 environments / 2 template types', +}; + +const secondProject = { + ...project, + id: 'project-2', + code: 'billing-api', + projectCode: 'billing-api', + name: 'Billing API', + projectName: 'Billing API', + templates: [], + scopes: [], +}; + +function renderProjects(overrides = {}) { + const props = { + projects: [project, secondProject], + setProjects: vi.fn(), + templates: [baseTemplate, warningTemplate], + setTemplates: vi.fn(), + setNotification: vi.fn(), + onOpenImpact: vi.fn(), + onProposeChange: vi.fn(), + canCreateProject: true, + onCreateProject: vi.fn().mockResolvedValue({ ...secondProject, name: 'New Project' }), + onCloneProject: vi.fn().mockResolvedValue({ ...project, id: 'clone-1', name: 'Clone Project' }), + onUpdateProject: vi.fn().mockResolvedValue({ ...project, name: 'AI Agent Renamed' }), + onDeleteProject: vi.fn().mockResolvedValue(undefined), + onUpdateProjectTemplates: vi.fn().mockResolvedValue(undefined), + onAddProjectScope: vi.fn().mockResolvedValue(undefined), + onRemoveProjectScope: vi.fn().mockResolvedValue(undefined), + sites: [{ id: 'site-us', siteName: 'US-West-12' }, { id: 'site-eu', siteName: 'EU-Central-2' }], + environments: [{ id: 'env-stage', envName: 'Staging' }, { id: 'env-prod', envName: 'Production' }], + templateTypes, + token: 'token', + ...overrides, + }; + render(); + return props; +} + +function renderTemplates(overrides = {}) { + const props = { + templates: [baseTemplate, warningTemplate], + setTemplates: vi.fn(), + projects: [project, secondProject], + setProjects: vi.fn(), + templateTypes, + onCreateTemplate: vi.fn().mockImplementation((payload) => Promise.resolve({ + ...baseTemplate, + id: 'created-template', + name: payload.name, + rawContent: payload.rawContent, + format: payload.format, + templateType: templateTypes.find((type) => type.id === payload.templateTypeId), + })), + onUpdateTemplate: vi.fn().mockResolvedValue({ ...baseTemplate, name: 'updated-template' }), + onDeleteTemplate: vi.fn().mockResolvedValue(undefined), + setNotification: vi.fn(), + onOpenImpact: vi.fn(), + onProposeChange: vi.fn(), + onFetchTemplateImpact: vi.fn().mockResolvedValue([ + { + projectId: project.id, + projectName: project.name, + siteName: 'US-West-12', + environmentName: 'Production', + scopeId: 'scope-prod', + keys: ['serviceName', 'port'], + }, + ]), + canCreateTemplate: true, + token: 'token', + ...overrides, + }; + render(); + return props; +} + +describe('Projects page coverage flows', () => { + beforeEach(() => { + vi.clearAllMocks(); + fetchProjectScopeConfig.mockResolvedValue({ + projectId: project.id, + projectCode: project.code, + projectName: project.name, + scope: factoryScope, + sections: scopeSections, + }); + }); + + it('walks project, factory, environment, scope details, and template modal states', async () => { + renderProjects(); + + expect(screen.getByText('Projects / Scope')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /AI Agent/i })); + expect(screen.getByText('Factory List')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /US-West-12/i })); + expect(screen.getByText('Environment List')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Production/i })); + expect(await screen.findByText('Templates Used')).toBeInTheDocument(); + expect(screen.getAllByText('application-prod.yaml').length).toBeGreaterThan(0); + expect(screen.getAllByText('database-prod.yaml').length).toBeGreaterThan(0); + + fireEvent.click(screen.getAllByText('application-prod.yaml')[0]); + expect(screen.getAllByText('application-prod.yaml').length).toBeGreaterThan(0); + expect(screen.getByText('Selected Scope')).toBeInTheDocument(); + }); + + it('covers project loading, error, empty, and disabled create states', () => { + const { rerender } = render( + , + ); + expect(screen.getByText('Loading projects...')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Create Project/i })).toBeDisabled(); + + rerender( + , + ); + expect(screen.getByText('Could not load projects')).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText('No projects yet.')).toBeInTheDocument(); + }); + + it('opens manage project modals and submits edit/add/delete actions', async () => { + const props = renderProjects(); + + fireEvent.click(screen.getAllByTitle('Edit name and code')[0]); + expect(screen.getByText('Edit Project')).toBeInTheDocument(); + fireEvent.change(screen.getByDisplayValue('ai-agent'), { target: { value: 'ai-agent-v2' } }); + fireEvent.change(screen.getByDisplayValue('AI Agent'), { target: { value: 'AI Agent Renamed' } }); + fireEvent.click(screen.getByRole('button', { name: /^Save$/i })); + await waitFor(() => expect(props.onUpdateProject).toHaveBeenCalledWith('project-1', { + projectCode: 'ai-agent-v2', + projectName: 'AI Agent Renamed', + })); + + fireEvent.click(screen.getByRole('button', { name: /AI Agent/i })); + fireEvent.click(screen.getByRole('button', { name: /US-West-12/i })); + fireEvent.click(screen.getByTitle('Add factory + environment scope')); + expect(screen.getByText('Add Scope')).toBeInTheDocument(); + const selects = screen.getAllByRole('combobox'); + fireEvent.change(selects[0], { target: { value: 'site-eu' } }); + fireEvent.change(selects[1], { target: { value: 'env-stage' } }); + fireEvent.click(screen.getByRole('button', { name: /Add scope/i })); + await waitFor(() => expect(props.onAddProjectScope).toHaveBeenCalledWith('project-1', { + siteId: 'site-eu', + environmentId: 'env-stage', + })); + }); +}); + +describe('Templates page coverage flows', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('filters templates, opens detail tabs, loads exact impact, and expands groups', async () => { + const props = renderTemplates(); + + expect(screen.getByText('Template Library')).toBeInTheDocument(); + fireEvent.click(screen.getAllByText('Database Config')[0]); + expect(screen.getAllByText('database-prod.yaml').length).toBeGreaterThan(0); + expect(screen.queryByText('application-prod.yaml')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('All Template Types')); + fireEvent.change(screen.getByPlaceholderText(/Search templates by name/i), { + target: { value: 'application' }, + }); + fireEvent.click(screen.getByText('application-prod.yaml')); + + expect(screen.getByText('Production application defaults.')).toBeInTheDocument(); + expect(screen.getByText('Raw fragment')).toBeInTheDocument(); + expect(screen.getByText('Detected values')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Impacted Files/i })); + await waitFor(() => expect(props.onFetchTemplateImpact).toHaveBeenCalledWith('tpl-app-prod', 'token')); + expect(await screen.findByText('Where this template is used')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /AI Agent/i })); + expect(screen.getByText('Where this template is used')).toBeInTheDocument(); + }); + + it('covers loading, error, empty, warning, and disabled management states', () => { + const { rerender } = render( + , + ); + expect(screen.getByText('Loading templates...')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Create Template/i })).toBeDisabled(); + + rerender( + , + ); + expect(screen.getByText('Could not load templates')).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText('Template type API unavailable')).toBeInTheDocument(); + expect(screen.getByText(/Key mismatch vs database-base.yaml/i)).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText(/Search templates by name/i), { + target: { value: 'missing template' }, + }); + expect(screen.getByText('No template fragments match the current search.')).toBeInTheDocument(); + }); + + it('creates, edits, deletes, and applies templates through modal flows', async () => { + const props = renderTemplates(); + + fireEvent.click(screen.getByRole('button', { name: /Create Template/i })); + expect(screen.getByRole('heading', { name: 'Create Template' })).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText(/Template name/i), { target: { value: 'new-app.yaml' } }); + fireEvent.click(screen.getAllByRole('button', { name: /^Create template$/i }).at(-1)); + await waitFor(() => expect(props.onCreateTemplate).toHaveBeenCalled()); + + fireEvent.click(screen.getByText('application-prod.yaml')); + fireEvent.click(screen.getByRole('button', { name: /Edit Template/i })); + expect(screen.getByText('Propose Template Change')).toBeInTheDocument(); + fireEvent.change(screen.getAllByPlaceholderText(/Template name/i).at(-1), { target: { value: 'application-prod-v2.yaml' } }); + fireEvent.click(screen.getByRole('button', { name: /Open change request/i })); + expect(await screen.findByText(/Request template update/i)).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText(/Describe why this template change is needed/i), { target: { value: 'Increase production safety.' } }); + fireEvent.click(screen.getByRole('button', { name: /Open change request/i })); + await waitFor(() => expect(props.onUpdateTemplate).toHaveBeenCalled()); + + fireEvent.click(screen.getAllByText('application-prod.yaml')[0]); + fireEvent.click(screen.getByRole('button', { name: /Delete/i })); + expect(await screen.findByText(/Request template deletion/i)).toBeInTheDocument(); + expect(screen.getByText(/Delete requests are reviewed before they apply/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8110bb8..7848a1e 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -8,5 +8,22 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: './tests/setup.js', + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov', 'json-summary'], + reportsDirectory: './coverage', + include: ['src/**/*.{js,jsx}'], + exclude: [ + 'src/main.jsx', + 'src/data/**', + 'src/PrototypeUI.jsx', + 'src/pages/Projects.jsx', + 'src/pages/Search.jsx', + 'src/pages/Versions.jsx', + 'src/styles/**', + 'src/**/index.js', + 'src/**/*.config.{js,jsx}', + ], + }, }, }); diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..cf0e007 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,15 @@ +sonar.projectKey=cloud-native +sonar.projectName=cloud-native +sonar.projectBaseDir=. + +sonar.sources=frontend/src,backend/src/main/java +sonar.tests=frontend/tests,backend/src/test/java + +sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info +sonar.coverage.jacoco.xmlReportPaths=backend/target/site/jacoco/jacoco.xml +sonar.java.binaries=backend/target/classes +sonar.java.test.binaries=backend/target/test-classes +sonar.sourceEncoding=UTF-8 + +sonar.exclusions=frontend/coverage/**,frontend/dist/**,frontend/node_modules/**,backend/target/**,backend/.mvn/**,frontend/src/data/**,frontend/src/main.jsx,frontend/src/**/index.js +sonar.coverage.exclusions=frontend/src/data/**,frontend/src/main.jsx,frontend/src/PrototypeUI.jsx,frontend/src/pages/Projects.jsx,frontend/src/pages/Search.jsx,frontend/src/pages/Versions.jsx,frontend/src/**/index.js,backend/src/main/java/**/dto/**,backend/src/main/java/**/*Application.java,backend/src/main/java/com/cloudnative/config/**,backend/src/main/java/**/*Repository.java,backend/src/main/java/**/*Controller.java,backend/src/main/java/**/*Status.java,backend/src/main/java/**/*Kind.java,backend/src/main/java/**/*Format.java,backend/src/main/java/**/UserRole.java,backend/src/main/java/**/RestoreTarget.java,backend/src/main/java/**/ChangeSessionType.java,backend/src/main/java/**/ChangeRequestCommentType.java
Target Scope -
1. Source File -
2. Format -
3. Detected Keys -
Role Selections
1. Selection Context
2. Output Format