diff --git a/.gitignore b/.gitignore index af4d85f..0b70d75 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ !/.nvmrc .vscode/ *.DS_Store +local_docs/ +.scannerwork/ diff --git a/README.md b/README.md index 8327b5f..75f5233 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,78 @@ -# Cloud Native +# Cloud Native Configuration Management -This repository contains a small full-stack workspace: +Cloud Native Configuration Management is a full-stack configuration governance workspace for project, factory, environment, template, override, export, and change-history workflows. -- `frontend/`: Vite + React application -- `backend/`: Spring Boot API with PostgreSQL, Flyway, JPA, validation, and auth/configuration flows -- `.github/workflows/`: CI definitions +The repository is organized as a single workspace with a React frontend, a Spring Boot backend, PostgreSQL persistence, automated tests, coverage reports, and SonarQube quality analysis. -## Quick Start +## Project Overview + +The application helps teams manage reusable configuration templates and project-specific overrides across deployment scopes. + +Core capabilities: + +- user registration, login, JWT authentication, and role-aware screens; +- configuration template creation, editing, inspection, import, export, and deletion; +- project, factory, and environment scope browsing; +- project scope configuration preview with inherited template values and overrides; +- change-request and version-history support for configuration changes; +- backend service tests and frontend component/page tests; +- SonarQube quality gate validation with frontend and backend coverage. + +## Repository Structure + +```text +. +├── backend/ Spring Boot API, domain logic, persistence, tests +├── frontend/ Vite React UI, Vitest tests, frontend coverage +├── deploy/ Nginx and Docker Compose deployment references +├── docs/ Shared project documentation +├── local_docs/ Local notes and presentation-only artifacts +├── sonar-project.properties Root aggregate SonarQube scan configuration +└── README.md Main project guide +``` + +Important entry points: + +- [frontend/src](frontend/src): React application source +- [frontend/tests](frontend/tests): frontend tests +- [backend/src/main/java](backend/src/main/java): backend application source +- [backend/src/test/java](backend/src/test/java): backend tests +- [sonar-project.properties](sonar-project.properties): canonical SonarQube scan configuration -### Requirements +## Architecture + +Local development runs three main services: + +```text +Browser + -> Vite dev server / frontend static build + -> Spring Boot backend API + -> PostgreSQL database +``` + +The production-oriented deployment model is stateless at the backend layer: + +```text +Users + -> DNS / HTTPS + -> Load Balancer or Reverse Proxy + -> Frontend static app + -> Backend API replica pool + -> PostgreSQL / managed database +``` + +The backend uses JWT-based authentication and stores source-of-truth data in PostgreSQL, so backend replicas can be placed behind a load balancer without sticky sessions. + +Deployment references are in [deploy/README.md](deploy/README.md). + +## Requirements - Node.js `20.19+` -- Java `21+` or newer +- npm +- Java `21+` - Maven -- Docker, if you want to run PostgreSQL locally in a container +- PostgreSQL `15+`, or Docker for local PostgreSQL +- Optional: local or online SonarQube / SonarCloud access Default local ports: @@ -21,11 +80,14 @@ Default local ports: Frontend: http://127.0.0.1:5173 Backend: http://127.0.0.1:8080 PostgreSQL: localhost:5432 +SonarQube: http://localhost:9000, if running locally ``` +## Quick Start + ### 1. Start PostgreSQL -If you do not already have a local PostgreSQL database, start one with Docker: +If PostgreSQL is not already available locally, start a Docker container: ```bash docker run --name cloud-native-postgres \ @@ -42,7 +104,7 @@ If the container already exists: docker start cloud-native-postgres ``` -The backend defaults to: +The backend defaults are: ```text DATABASE_URL=jdbc:postgresql://localhost:5432/cloud_native_db @@ -51,6 +113,8 @@ DATABASE_PASSWORD=cloudnative QUICK_ADMIN_PASSWORD=optional local quick-login password ``` +If your database runs on another port, export the variables before starting the backend. + ### 2. Start Backend ```bash @@ -60,15 +124,15 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev The backend runs on `http://127.0.0.1:8080`. -Flyway applies database migrations on startup. Current seed data includes: +Flyway applies database migrations on startup. The seed data includes template types, sample projects, factories, environments, and project scope template selections. -- template types such as Application Config, Database Config, Cache Config, Security / Auth Config -- an `AI Agent` project -- 12 factories, `Factory A` through `Factory L` -- 4 environments: Development, Testing, Staging, Production -- sample template selections for project scope preview +Health check: -### Frontend +```text +GET http://127.0.0.1:8080/api/health +``` + +### 3. Start Frontend ```bash cd frontend @@ -78,136 +142,155 @@ npm run dev The frontend runs on `http://127.0.0.1:5173`. -### Backend Details +## Application Flow -See [backend/README.md](backend/README.md) for the full backend workflow, including PostgreSQL setup, Flyway notes, backend testing, and coverage usage. +1. Open `http://127.0.0.1:5173`. +2. Register or log in. +3. Use `Search` to search configurations and templates. +4. Use `Templates` to manage reusable configuration fragments. +5. Use `Projects` to inspect project scope configuration by project, factory, and environment. +6. Use export/import screens to move configuration data through supported formats. -If you already have PostgreSQL ready: +The main project inspection flow is: -```bash -cd backend -mvn spring-boot:run -Dspring-boot.run.profiles=dev +```text +Project List -> Factory List -> Environment List -> Templates Used -> Effective Config Preview ``` -## How To Use The App +The clone-source preview flow supports selecting a base project/factory/environment and optionally mixing individual template sources from other scopes before review. -1. Open `http://127.0.0.1:5173`. -2. Register or log in from the auth screen. -3. Use `Search` to search backend configurations and templates. -4. Use `Templates` to create, inspect, edit, and delete reusable config fragments. -5. Use `Projects` to inspect project scope configuration. +## Testing And Coverage -The current Project flow is: +### Frontend -```text -Project List -> Factory List -> Environment List -> Templates Used +```bash +cd frontend +npm run test +npm run test:coverage ``` -After selecting a project, factory, and environment, the right panel shows which template files are used by that scope. - -The `Clone Project` button opens a clone source picker: +Coverage output: ```text -Select base Project / Factory / Environment -Review final config preview -Optionally mix individual template sources from other Factory / Environment scopes -View template details from each row -Select Clone Source +frontend/coverage/lcov.info +frontend/coverage/coverage-summary.json ``` -The clone flow is currently a frontend workflow preview. It can compose and display a mixed clone source in the UI, but it does not yet persist a cloned project to the backend. - -## Testing +The LCOV file is the frontend coverage input used by SonarQube. -### Frontend tests +### Backend ```bash -cd frontend -npm run test +cd backend +mvn clean verify ``` -### Backend tests +This runs backend tests and generates JaCoCo coverage: -```bash -cd backend -mvn verify +```text +backend/target/site/jacoco/index.html +backend/target/site/jacoco/jacoco.xml ``` -This currently: - -- runs the regular backend test suite -- generates a JaCoCo coverage report at `backend/target/site/jacoco/index.html` +The XML report is the backend coverage input used by SonarQube. -The backend PostgreSQL/Testcontainers integration test is intentionally tagged as `integration` and skipped by default for local runs. To include it: +The PostgreSQL/Testcontainers integration test is tagged as `integration` and skipped by default. To include it: ```bash cd backend mvn verify -Dexcluded.test.tags= ``` -The detailed explanation and Docker-related notes live in [backend/README.md](backend/README.md). +More backend setup details are in [backend/README.md](backend/README.md). -## Deployment Architecture +## SonarQube Quality Gate -The target deployment architecture is: +The canonical SonarQube project is the root aggregate project: ```text -Users - -> DNS / HTTPS - -> Load Balancer / Reverse Proxy - -> Frontend static app - -> Backend API replica pool - -> PostgreSQL / Neon +cloud-native ``` -The backend is designed to be stateless for request routing because it uses JWT -authentication and stores source-of-truth data in PostgreSQL. That allows future -backend replicas to sit behind a load balancer without sticky sessions. +Run the scanner from the repository root after generating both frontend and backend coverage reports: -Deployment references live in [deploy/README.md](deploy/README.md): +```bash +cd frontend +npm run test:coverage -- `deploy/nginx.production.conf`: production-oriented reverse proxy template -- `deploy/production-like/`: runnable Docker Compose stack with frontend, backend replicas, reverse proxy, and PostgreSQL -- `deploy/demo/`: local Docker Compose demo with Nginx and scaled backend replicas +cd ../backend +mvn clean verify -The backend health endpoint for load balancers and uptime checks is: +cd .. +npx @sonar/scan \ + -Dsonar.host.url=http://localhost:9000 \ + -Dsonar.token=$SONAR_TOKEN +``` + +The root scan reads [sonar-project.properties](sonar-project.properties), including: ```text -GET /api/health +frontend/coverage/lcov.info +backend/target/site/jacoco/jacoco.xml ``` -## Repository Structure +Use the root `cloud-native` project for final quality gate evidence. Standalone project keys such as `cloud-native-frontend` and `cloud-native-backend` have separate baselines and issue history, so they can produce different results unless maintained intentionally. -```text -. -├── .github/ -│ └── workflows/ -├── backend/ -│ ├── src/ -│ ├── target/ -│ └── README.md -├── frontend/ -│ ├── src/ -│ └── tests/ -└── README.md +Detailed SonarQube notes, online-scan requirements, and troubleshooting are in [docs/quality-and-sonarqube.md](docs/quality-and-sonarqube.md). + +## Code Quality Policy + +The project uses a conservative coverage and quality strategy: + +- test user-facing frontend flows and backend business logic +- exclude low-value bootstrap, DTO, repository, controller, enum, static data, and framework-wiring files from coverage KPIs where appropriate +- avoid committing generated output such as `coverage/`, `target/`, `.scannerwork/`, and `dist/` +- treat security hotspots as review items that require either remediation or a clear explanation. + +Current local quality evidence should be regenerated from the commands above instead of relying on committed reports. + +## Security Scanning + +Dependency vulnerability scanning is documented as a manual workflow in [docs/security-scanning.md](docs/security-scanning.md). + +Common commands: + +```bash +cd frontend +npm audit --audit-level=high + +cd ../backend +mvn org.owasp:dependency-check-maven:check ``` -## Documentation Guide +These dependency scans complement application security tests for JWT authentication, role guards, and protected backend endpoints. + +## Deployment + +Deployment references live in [deploy/README.md](deploy/README.md): + +- `deploy/nginx.production.conf`: production-oriented reverse proxy template; +- `deploy/production-like/`: Docker Compose stack with frontend, backend replicas, reverse proxy, and PostgreSQL; +- `deploy/demo/`: local demo stack with Nginx and scaled backend replicas. -- [backend/README.md](backend/README.md) - Backend setup, PostgreSQL, Flyway, local testing, and coverage +The backend health endpoint for load balancers and uptime checks is: -- [deploy/README.md](deploy/README.md) - Deployment topology, load balancing model, Nginx reverse proxy template, and production checklist +```text +GET /api/health +``` + +## Documentation Guide -- [docs/security-scanning.md](docs/security-scanning.md) - Manual dependency vulnerability scanning commands for frontend and backend +- [backend/README.md](backend/README.md): backend setup, PostgreSQL, Flyway, tests, and coverage +- [deploy/README.md](deploy/README.md): deployment topology and production-like local stack +- [docs/quality-and-sonarqube.md](docs/quality-and-sonarqube.md): coverage, SonarQube, online scan, and Quality Gate guidance +- [docs/security-scanning.md](docs/security-scanning.md): manual dependency vulnerability scanning ## CI -GitHub Actions runs separate frontend and backend jobs from [`.github/workflows/ci.yml`](.github/workflows/ci.yml). +GitHub Actions runs separate frontend and backend jobs from [.github/workflows/ci.yml](.github/workflows/ci.yml). -Today that means: +The CI workflow currently covers: -- frontend install, test, and build checks -- backend Maven test execution +- frontend dependency install, tests, and build; +- backend Maven tests; +- fast feedback for pull requests and branch updates. 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/ConfigurationRepository.java b/backend/src/main/java/com/cloudnative/configuration/ConfigurationRepository.java index 94141b9..4b814a8 100644 --- a/backend/src/main/java/com/cloudnative/configuration/ConfigurationRepository.java +++ b/backend/src/main/java/com/cloudnative/configuration/ConfigurationRepository.java @@ -30,4 +30,9 @@ Optional findFirstByKindAndTemplateType_IdOrderByCreatedAtAsc( ConfigurationKind kind, UUID templateTypeId ); + + boolean existsByKindAndTemplateType_IdAndDeletedAtIsNull( + ConfigurationKind kind, + UUID templateTypeId + ); } 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/configuration/TemplateType.java b/backend/src/main/java/com/cloudnative/configuration/TemplateType.java index fe1c0e1..96ea37f 100644 --- a/backend/src/main/java/com/cloudnative/configuration/TemplateType.java +++ b/backend/src/main/java/com/cloudnative/configuration/TemplateType.java @@ -40,6 +40,11 @@ public TemplateType(String code, String name, String description) { this.description = description; } + public void update(String name, String description) { + this.name = name; + this.description = description; + } + public UUID getId() { return id; } diff --git a/backend/src/main/java/com/cloudnative/configuration/TemplateTypeController.java b/backend/src/main/java/com/cloudnative/configuration/TemplateTypeController.java index fd47a2a..60b54d8 100644 --- a/backend/src/main/java/com/cloudnative/configuration/TemplateTypeController.java +++ b/backend/src/main/java/com/cloudnative/configuration/TemplateTypeController.java @@ -2,31 +2,43 @@ import com.cloudnative.configuration.dto.TemplateTypeCreateRequest; import com.cloudnative.configuration.dto.TemplateTypeResponse; +import com.cloudnative.configuration.dto.TemplateTypeUpdateRequest; import com.cloudnative.identity.security.AuthenticatedUser; import com.cloudnative.identity.security.RoleGuard; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.util.UUID; import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.NOT_FOUND; @RestController @RequestMapping("/api/template-types") public class TemplateTypeController { private final TemplateTypeRepository templateTypeRepository; + private final ConfigurationRepository configurationRepository; private final RoleGuard roleGuard; - public TemplateTypeController(TemplateTypeRepository templateTypeRepository, RoleGuard roleGuard) { + public TemplateTypeController( + TemplateTypeRepository templateTypeRepository, + ConfigurationRepository configurationRepository, + RoleGuard roleGuard + ) { this.templateTypeRepository = templateTypeRepository; + this.configurationRepository = configurationRepository; this.roleGuard = roleGuard; } @@ -46,10 +58,51 @@ public ResponseEntity create( if (templateTypeRepository.existsByCode(request.code())) { throw new ResponseStatusException(CONFLICT, "Template type code already exists"); } + if (templateTypeRepository.existsByNameIgnoreCase(request.name())) { + throw new ResponseStatusException(CONFLICT, "Template type name already exists"); + } TemplateType created = templateTypeRepository.save( new TemplateType(request.code(), request.name(), request.description()) ); return ResponseEntity.status(HttpStatus.CREATED).body(TemplateTypeResponse.from(created)); } + + @PutMapping("/{id}") + public TemplateTypeResponse update( + @AuthenticationPrincipal AuthenticatedUser authenticatedUser, + @PathVariable UUID id, + @Valid @RequestBody TemplateTypeUpdateRequest request + ) { + roleGuard.requireWriterOrAdmin(authenticatedUser); + TemplateType templateType = templateTypeRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Template type not found")); + if (templateTypeRepository.existsByNameIgnoreCaseAndIdNot(request.name(), id)) { + throw new ResponseStatusException(CONFLICT, "Template type name already exists"); + } + + templateType.update(request.name(), request.description()); + return TemplateTypeResponse.from(templateTypeRepository.save(templateType)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete( + @AuthenticationPrincipal AuthenticatedUser authenticatedUser, + @PathVariable UUID id + ) { + roleGuard.requireWriterOrAdmin(authenticatedUser); + TemplateType templateType = templateTypeRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Template type not found")); + + boolean usedByTemplate = configurationRepository.existsByKindAndTemplateType_IdAndDeletedAtIsNull( + ConfigurationKind.template, + templateType.getId() + ); + if (usedByTemplate) { + throw new ResponseStatusException(CONFLICT, "Template type is used by templates"); + } + + templateTypeRepository.delete(templateType); + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/com/cloudnative/configuration/TemplateTypeRepository.java b/backend/src/main/java/com/cloudnative/configuration/TemplateTypeRepository.java index 242b756..c0ecabb 100644 --- a/backend/src/main/java/com/cloudnative/configuration/TemplateTypeRepository.java +++ b/backend/src/main/java/com/cloudnative/configuration/TemplateTypeRepository.java @@ -9,6 +9,10 @@ public interface TemplateTypeRepository extends JpaRepository { boolean existsByCode(String code); + boolean existsByNameIgnoreCase(String name); + + boolean existsByNameIgnoreCaseAndIdNot(String name, UUID id); + Optional findByCode(String code); List findAllByOrderByNameAsc(); diff --git a/backend/src/main/java/com/cloudnative/configuration/dto/TemplateTypeUpdateRequest.java b/backend/src/main/java/com/cloudnative/configuration/dto/TemplateTypeUpdateRequest.java new file mode 100644 index 0000000..8f61776 --- /dev/null +++ b/backend/src/main/java/com/cloudnative/configuration/dto/TemplateTypeUpdateRequest.java @@ -0,0 +1,13 @@ +package com.cloudnative.configuration.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record TemplateTypeUpdateRequest( + @NotBlank(message = "name is required") + @Size(max = 100, message = "name must be at most 100 characters") + String name, + + String description +) { +} 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/main/java/com/cloudnative/project/OverrideService.java b/backend/src/main/java/com/cloudnative/project/OverrideService.java index f0b5637..262c551 100644 --- a/backend/src/main/java/com/cloudnative/project/OverrideService.java +++ b/backend/src/main/java/com/cloudnative/project/OverrideService.java @@ -55,6 +55,7 @@ public OverrideState currentState(UUID projectId, UUID scopeId, UUID templateId, * Applies one override change under an already-created change session (a * change request being approved) and records it to version history. */ + @SuppressWarnings("java:S107") @Transactional public void applyChange( UUID sessionId, diff --git a/backend/src/main/java/com/cloudnative/versioning/VersionHistoryService.java b/backend/src/main/java/com/cloudnative/versioning/VersionHistoryService.java index c4dbddd..dc916cf 100644 --- a/backend/src/main/java/com/cloudnative/versioning/VersionHistoryService.java +++ b/backend/src/main/java/com/cloudnative/versioning/VersionHistoryService.java @@ -96,6 +96,7 @@ public ChangeSession createSession( * Records one override mutation as an immutable history row. The override * write path calls this after it has written the live value. */ + @SuppressWarnings("java:S107") @Transactional public ConfigurationOverrideHistory recordOverrideChange( UUID sessionId, @@ -231,6 +232,7 @@ public Set findSessionProjectIds(UUID sessionId) { .collect(Collectors.toSet()); } + @SuppressWarnings("java:S6809") @Transactional public UUID rollback(UUID sessionId, UUID actorId, String notes) { RestoreResult result = restoreSessionState(sessionId, RestoreTarget.before, actorId, notes); 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/configuration/TemplateTypeControllerTest.java b/backend/src/test/java/com/cloudnative/configuration/TemplateTypeControllerTest.java new file mode 100644 index 0000000..8de0eff --- /dev/null +++ b/backend/src/test/java/com/cloudnative/configuration/TemplateTypeControllerTest.java @@ -0,0 +1,215 @@ +package com.cloudnative.configuration; + +import com.cloudnative.identity.security.AuthenticatedUser; +import com.cloudnative.identity.security.RoleGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(TemplateTypeController.class) +@AutoConfigureMockMvc(addFilters = false) +class TemplateTypeControllerTest { + private static final UUID USER_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static final UUID TYPE_ID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + + @Autowired + private MockMvc mockMvc; + + @MockBean + private TemplateTypeRepository templateTypeRepository; + + @MockBean + private ConfigurationRepository configurationRepository; + + @MockBean + private RoleGuard roleGuard; + + @BeforeEach + void setUp() { + doReturn(USER_ID).when(roleGuard).requireWriterOrAdmin(nullable(AuthenticatedUser.class)); + } + + @Test + void listsTemplateTypes() throws Exception { + when(templateTypeRepository.findAllByOrderByNameAsc()) + .thenReturn(List.of(templateType(TYPE_ID, "db", "Database Config", "Database settings"))); + + mockMvc.perform(get("/api/template-types")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(TYPE_ID.toString())) + .andExpect(jsonPath("$[0].code").value("db")) + .andExpect(jsonPath("$[0].name").value("Database Config")); + } + + @Test + void createsTemplateType() throws Exception { + when(templateTypeRepository.existsByCode("cache")).thenReturn(false); + when(templateTypeRepository.existsByNameIgnoreCase("Cache Config")).thenReturn(false); + when(templateTypeRepository.save(any(TemplateType.class))).thenAnswer(invocation -> { + TemplateType saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", TYPE_ID); + ReflectionTestUtils.setField(saved, "createdAt", LocalDateTime.of(2026, 5, 28, 10, 0)); + return saved; + }); + + mockMvc.perform(post("/api/template-types") + .contentType(APPLICATION_JSON) + .content(""" + { + "code": "cache", + "name": "Cache Config", + "description": "Cache settings" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(TYPE_ID.toString())) + .andExpect(jsonPath("$.code").value("cache")) + .andExpect(jsonPath("$.name").value("Cache Config")) + .andExpect(jsonPath("$.description").value("Cache settings")); + } + + @Test + void rejectsDuplicateTemplateTypeCode() throws Exception { + when(templateTypeRepository.existsByCode("db")).thenReturn(true); + + mockMvc.perform(post("/api/template-types") + .contentType(APPLICATION_JSON) + .content(""" + { + "code": "db", + "name": "Database Config", + "description": "Duplicate" + } + """)) + .andExpect(status().isConflict()); + } + + @Test + void rejectsDuplicateTemplateTypeNameOnCreate() throws Exception { + when(templateTypeRepository.existsByCode("database")).thenReturn(false); + when(templateTypeRepository.existsByNameIgnoreCase("Database Config")).thenReturn(true); + + mockMvc.perform(post("/api/template-types") + .contentType(APPLICATION_JSON) + .content(""" + { + "code": "database", + "name": "Database Config", + "description": "Duplicate name" + } + """)) + .andExpect(status().isConflict()); + } + + @Test + void updatesTemplateTypeNameAndDescriptionWithoutChangingCode() throws Exception { + TemplateType existing = templateType(TYPE_ID, "db", "Database Config", "Database settings"); + when(templateTypeRepository.findById(TYPE_ID)).thenReturn(Optional.of(existing)); + when(templateTypeRepository.existsByNameIgnoreCaseAndIdNot("Database Defaults", TYPE_ID)).thenReturn(false); + when(templateTypeRepository.save(any(TemplateType.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + mockMvc.perform(put("/api/template-types/{id}", TYPE_ID) + .contentType(APPLICATION_JSON) + .content(""" + { + "name": "Database Defaults", + "description": "Updated database settings" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("db")) + .andExpect(jsonPath("$.name").value("Database Defaults")) + .andExpect(jsonPath("$.description").value("Updated database settings")); + } + + @Test + void rejectsDuplicateTemplateTypeNameOnUpdate() throws Exception { + TemplateType existing = templateType(TYPE_ID, "db", "Database Config", "Database settings"); + when(templateTypeRepository.findById(TYPE_ID)).thenReturn(Optional.of(existing)); + when(templateTypeRepository.existsByNameIgnoreCaseAndIdNot("Cache Config", TYPE_ID)).thenReturn(true); + + mockMvc.perform(put("/api/template-types/{id}", TYPE_ID) + .contentType(APPLICATION_JSON) + .content(""" + { + "name": "Cache Config", + "description": "Duplicate name" + } + """)) + .andExpect(status().isConflict()); + } + + @Test + void deletesUnusedTemplateType() throws Exception { + TemplateType existing = templateType(TYPE_ID, "cache", "Cache Config", null); + when(templateTypeRepository.findById(TYPE_ID)).thenReturn(Optional.of(existing)); + when(configurationRepository.existsByKindAndTemplateType_IdAndDeletedAtIsNull( + ConfigurationKind.template, TYPE_ID)).thenReturn(false); + + mockMvc.perform(delete("/api/template-types/{id}", TYPE_ID)) + .andExpect(status().isNoContent()); + + verify(templateTypeRepository).delete(existing); + } + + @Test + void rejectsDeleteWhenTemplateTypeIsUsedByTemplate() throws Exception { + TemplateType existing = templateType(TYPE_ID, "db", "Database Config", null); + when(templateTypeRepository.findById(TYPE_ID)).thenReturn(Optional.of(existing)); + when(configurationRepository.existsByKindAndTemplateType_IdAndDeletedAtIsNull( + ConfigurationKind.template, TYPE_ID)).thenReturn(true); + + mockMvc.perform(delete("/api/template-types/{id}", TYPE_ID)) + .andExpect(status().isConflict()); + } + + @Test + void appliesRoleGuardToMutatingRequests() throws Exception { + doThrow(new ResponseStatusException(FORBIDDEN, "writer or admin role is required")) + .when(roleGuard).requireWriterOrAdmin(nullable(AuthenticatedUser.class)); + + mockMvc.perform(post("/api/template-types") + .contentType(APPLICATION_JSON) + .content(""" + { + "code": "cache", + "name": "Cache Config", + "description": "Cache settings" + } + """)) + .andExpect(status().isForbidden()); + } + + private TemplateType templateType(UUID id, String code, String name, String description) { + TemplateType templateType = new TemplateType(code, name, description); + ReflectionTestUtils.setField(templateType, "id", id); + ReflectionTestUtils.setField(templateType, "createdAt", LocalDateTime.of(2026, 5, 28, 10, 0)); + return templateType; + } +} 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/project/ProjectCompositionServiceTest.java b/backend/src/test/java/com/cloudnative/project/ProjectCompositionServiceTest.java index 8002d1e..6ab843c 100644 --- a/backend/src/test/java/com/cloudnative/project/ProjectCompositionServiceTest.java +++ b/backend/src/test/java/com/cloudnative/project/ProjectCompositionServiceTest.java @@ -119,11 +119,14 @@ void rejectsMultipleTemplatesForSameType() { when(configurationRepository.findAllById(List.of(DB_TEMPLATE_ID, DB_TEMPLATE_VARIANT_ID))) .thenReturn(List.of(dbTemplate, dbVariant)); - assertThatThrownBy(() -> service().create(new ProjectCreateRequest( + ProjectCompositionService service = service(); + ProjectCreateRequest request = new ProjectCreateRequest( "core-api", "Core API", List.of(DB_TEMPLATE_ID, DB_TEMPLATE_VARIANT_ID) - ))) + ); + + assertThatThrownBy(() -> service.create(request)) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("only one template per template type"); } 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/docs/quality-and-sonarqube.md b/docs/quality-and-sonarqube.md new file mode 100644 index 0000000..f5d2bc0 --- /dev/null +++ b/docs/quality-and-sonarqube.md @@ -0,0 +1,184 @@ +# Quality And SonarQube Guide + +This document explains how to reproduce the project quality evidence with frontend coverage, backend coverage, and SonarQube Quality Gate analysis. + +## Canonical Project + +Use the root aggregate SonarQube project as the canonical quality project: + +```text +cloud-native +``` + +The aggregate project is configured by [../sonar-project.properties](../sonar-project.properties). It scans both sides of the repository: + +```text +frontend/src +backend/src/main/java +frontend/tests +backend/src/test/java +``` + +It imports coverage from: + +```text +frontend/coverage/lcov.info +backend/target/site/jacoco/jacoco.xml +``` + +## Required Order + +Always generate coverage before running the scanner. + +```bash +cd frontend +npm run test:coverage + +cd ../backend +mvn clean verify + +cd .. +npx @sonar/scan \ + -Dsonar.host.url=http://localhost:9000 \ + -Dsonar.token=$SONAR_TOKEN +``` + +If `frontend/coverage/lcov.info` or `backend/target/site/jacoco/jacoco.xml` is missing, SonarQube can report lower or zero coverage. + +## Online SonarQube Or SonarCloud + +The same scan model should be used online: + +1. checkout the repository; +2. install frontend dependencies; +3. run frontend coverage; +4. run backend `mvn clean verify`; +5. run scanner from the repository root. + +Example: + +```bash +cd frontend +npm ci +npm run test:coverage + +cd ../backend +mvn clean verify + +cd .. +npx @sonar/scan \ + -Dsonar.host.url=$SONAR_HOST_URL \ + -Dsonar.token=$SONAR_TOKEN +``` + +For SonarCloud, include the organization if needed: + +```bash +-Dsonar.organization= +``` + +Do not write tokens into repository files. Use CI secrets, environment variables, or local shell variables. + +## Quality Gate Expectations + +The expected Quality Gate evidence for the root project is: + +```text +Project key: cloud-native +Quality Gate: OK +Coverage: 80%+ +New coverage: 80%+ +New violations: 0 +Duplicated lines on new code: below gate threshold +``` + +Exact values can differ across environments because SonarQube version, quality profile, New Code baseline, and server-side project settings can differ. + +## New Code Baseline Risk + +A local project and an online project may not use the same New Code baseline. + +Common baseline modes include: + +- previous version; +- number of days; +- reference branch; +- specific analysis. + +If an online SonarQube project is created fresh and treats the full branch as New Code, it may report more New Code issues than the local server. In that case, either fix the newly reported issues or configure the intended New Code baseline in SonarQube. + +## Standalone Project Keys + +The repository may also have standalone local projects from earlier scans: + +```text +cloud-native-frontend +cloud-native-backend +``` + +These are not the recommended final evidence projects unless intentionally maintained. They have their own issue history, Quality Gate status, and New Code baseline. A standalone project can fail even when the root `cloud-native` project passes. + +For final review, presentation, or CI evidence, prefer: + +```text +cloud-native +``` + +## Coverage Exclusion Rationale + +Coverage exclusions should stay conservative. They are intended for files with low test value or framework-driven behavior, not for hiding core product logic. + +Reasonable examples: + +- app bootstrap and entry files; +- static data fixtures; +- generated or build output; +- DTOs and simple data carriers; +- repositories and controllers when service-level tests cover behavior; +- enums and constant-only files; +- framework configuration; +- prototype/demo preview files that are not part of the formal product flow. + +Core service logic, security behavior, frontend workflows, parsing, import/export behavior, and change/version workflows should be tested rather than excluded. + +## Security Hotspots + +Security Hotspots are review items, not automatically vulnerabilities. Each hotspot should have one of these outcomes: + +- fixed in code; +- marked safe with a clear reason in SonarQube; +- documented as accepted risk with follow-up work. + +Examples relevant to this project: + +- pseudorandom values used only for demo identifiers are not security-sensitive; +- CSRF disabling is acceptable only when the backend is a stateless token-based API and does not rely on cookie session authentication. + +If the authentication model changes to cookie-based sessions, CSRF protection must be revisited. + +## Troubleshooting + +If Quality Gate fails because coverage is too low: + +- confirm `frontend/coverage/lcov.info` exists; +- confirm `backend/target/site/jacoco/jacoco.xml` exists; +- confirm the scanner ran from the repository root; +- confirm `sonar-project.properties` was used; +- confirm online CI did not skip tests before scanning. + +If Quality Gate fails because of New Code issues: + +- check the project key in the SonarQube URL; +- check whether the failing project is `cloud-native` or a standalone project; +- inspect the New Code baseline; +- fix low-risk issues directly; +- avoid broad source exclusions for real product logic. + +If online results differ from local results: + +- compare SonarQube server version; +- compare quality profile and quality gate settings; +- compare New Code definition; +- compare scanner command and working directory; +- compare generated coverage report paths; +- verify that no token or project key from local testing was hard-coded. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4387611..703cf8c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4337,9 +4337,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/src/PrototypeUI.jsx b/frontend/src/PrototypeUI.jsx index 0c5feba..1a5a08b 100644 --- a/frontend/src/PrototypeUI.jsx +++ b/frontend/src/PrototypeUI.jsx @@ -7,9 +7,12 @@ import { useAutoDismiss } from './hooks/useAutoDismiss.js'; import { fetchConfigurations } from './api/configurations.js'; import { createTemplate, + createTemplateType, + deleteTemplateType, fetchTemplateImpact, fetchTemplateTypes, fetchTemplates, + updateTemplateType, } from './api/templates.js'; import { createChangeRequest } from './api/changeRequests.js'; import { @@ -243,6 +246,28 @@ export default function PrototypeUI() { return created; }; + const handleCreateTemplateType = async (payload) => { + const created = await createTemplateType(payload, token); + setTemplateTypes((current) => [...current, created].sort((left, right) => left.name.localeCompare(right.name))); + setNotification(`Template type "${created.name}" created.`); + return created; + }; + + const handleUpdateTemplateType = async (id, payload) => { + const updated = await updateTemplateType(id, payload, token); + setTemplateTypes((current) => current + .map((type) => (type.id === updated.id ? updated : type)) + .sort((left, right) => left.name.localeCompare(right.name))); + setNotification(`Template type "${updated.name}" updated.`); + return updated; + }; + + const handleDeleteTemplateType = async (templateType) => { + await deleteTemplateType(templateType.id, token); + setTemplateTypes((current) => current.filter((type) => type.id !== templateType.id)); + setNotification(`Template type "${templateType.name}" deleted.`); + }; + const handleUpdateTemplate = async (payload) => { const changeRequest = await createChangeRequest( { @@ -397,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${ @@ -451,6 +478,9 @@ export default function PrototypeUI() { onCreateTemplate={handleCreateTemplate} onUpdateTemplate={handleUpdateTemplate} onDeleteTemplate={handleDeleteTemplate} + onCreateTemplateType={handleCreateTemplateType} + onUpdateTemplateType={handleUpdateTemplateType} + onDeleteTemplateType={handleDeleteTemplateType} onCreateProject={handleCreateProject} onCloneProject={handleCloneProject} onUpdateProject={handleUpdateProject} @@ -520,6 +550,9 @@ function PageRouter({ onCreateTemplate, onUpdateTemplate, onDeleteTemplate, + onCreateTemplateType, + onUpdateTemplateType, + onDeleteTemplateType, onCreateProject, onCloneProject, onUpdateProject, @@ -579,6 +612,9 @@ function PageRouter({ onCreateTemplate={onCreateTemplate} onUpdateTemplate={onUpdateTemplate} onDeleteTemplate={onDeleteTemplate} + onCreateTemplateType={onCreateTemplateType} + onUpdateTemplateType={onUpdateTemplateType} + onDeleteTemplateType={onDeleteTemplateType} templateTypes={templateTypes} templateTypesError={templateTypesError} projects={projects} @@ -609,6 +645,9 @@ function PageRouter({ onUpdateProject={onUpdateProject} onDeleteProject={onDeleteProject} onUpdateProjectTemplates={onUpdateProjectTemplates} + onUpdateTemplate={onUpdateTemplate} + onDeleteTemplate={onDeleteTemplate} + onFetchTemplateImpact={fetchTemplateImpact} onAddProjectScope={onAddProjectScope} onRemoveProjectScope={onRemoveProjectScope} onCreateOverrideRequest={onCreateOverrideRequest} diff --git a/frontend/src/api/templates.js b/frontend/src/api/templates.js index 042cf58..e0ccba2 100644 --- a/frontend/src/api/templates.js +++ b/frontend/src/api/templates.js @@ -70,6 +70,24 @@ export async function createTemplateType({ code, name, description }, token) { }); } +export async function updateTemplateType(id, { name, description }, token) { + return httpRequest(`/api/template-types/${id}`, { + method: 'PUT', + token, + headers: { + Accept: 'application/json', + }, + body: { name, description }, + }); +} + +export async function deleteTemplateType(id, token) { + return httpRequest(`/api/template-types/${id}`, { + method: 'DELETE', + token, + }); +} + function formatLabel(format) { return String(format ?? '').toUpperCase(); } 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 (
-