Skip to content
This repository was archived by the owner on Jun 2, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@
!/.nvmrc
.vscode/
*.DS_Store
local_docs/
local_docs/
.scannerwork/
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<configuration>
<excludes>
<exclude>com/cloudnative/**/*Application*</exclude>
<exclude>com/cloudnative/**/dto/**</exclude>
<exclude>com/cloudnative/config/**</exclude>
<exclude>com/cloudnative/**/*Repository*</exclude>
<exclude>com/cloudnative/**/*Controller*</exclude>
<exclude>com/cloudnative/**/*Status*</exclude>
<exclude>com/cloudnative/**/*Kind*</exclude>
<exclude>com/cloudnative/**/*Format*</exclude>
<exclude>com/cloudnative/**/UserRole*</exclude>
<exclude>com/cloudnative/**/RestoreTarget*</exclude>
<exclude>com/cloudnative/**/ChangeSessionType*</exclude>
<exclude>com/cloudnative/**/ChangeRequestCommentType*</exclude>
</excludes>
</configuration>
<executions>
<execution>
<id>prepare-agent</id>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Loading
Loading