From 0015c30395115dcd70faf7b95a9f0eb2e7f4a155 Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Wed, 4 Dec 2024 15:37:01 +0700 Subject: [PATCH 01/13] Implement --- .../market/constants/GitHubConstants.java | 9 + .../constants/RequestMappingConstants.java | 1 + .../controller/SecurityMonitorController.java | 39 ++++ .../com/axonivy/market/enums/AccessLevel.java | 10 + .../market/github/model/CodeScanning.java | 16 ++ .../market/github/model/Dependabot.java | 16 ++ .../github/model/ProductSecurityInfo.java | 25 ++ .../market/github/model/SecretScanning.java | 14 ++ .../market/github/service/GitHubService.java | 3 + .../service/impl/GitHubServiceImpl.java | 213 ++++++++++++++++- .../security-monitor.component.html | 103 +++++++++ .../security-monitor.component.scss | 215 ++++++++++++++++++ .../security-monitor.component.spec.ts | 23 ++ .../security-monitor.component.ts | 124 ++++++++++ .../security-monitor.service.spec.ts | 16 ++ .../security-monitor.service.ts | 18 ++ .../src/app/shared/models/security-model.ts | 26 +++ marketplace-ui/src/index.html | 1 + marketplace-ui/src/main.ts | 13 +- 19 files changed, 874 insertions(+), 11 deletions(-) create mode 100644 marketplace-service/src/main/java/com/axonivy/market/controller/SecurityMonitorController.java create mode 100644 marketplace-service/src/main/java/com/axonivy/market/enums/AccessLevel.java create mode 100644 marketplace-service/src/main/java/com/axonivy/market/github/model/CodeScanning.java create mode 100644 marketplace-service/src/main/java/com/axonivy/market/github/model/Dependabot.java create mode 100644 marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java create mode 100644 marketplace-service/src/main/java/com/axonivy/market/github/model/SecretScanning.java create mode 100644 marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html create mode 100644 marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss create mode 100644 marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts create mode 100644 marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts create mode 100644 marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts create mode 100644 marketplace-ui/src/app/modules/security-monitor/security-monitor.service.ts create mode 100644 marketplace-ui/src/app/shared/models/security-model.ts diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java index 41e42393e..4440e458b 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java @@ -23,11 +23,20 @@ public static class Json { public static final String USER_NAME = "name"; public static final String USER_AVATAR_URL = "avatar_url"; public static final String USER_LOGIN_NAME = "login"; + public static final String SEVERITY = "severity"; + public static final String SEVERITY_ADVISORY = "security_advisory"; + public static final String RULE = "rule"; } @NoArgsConstructor(access = AccessLevel.PRIVATE) public static class Url { private static final String BASE_URL = "https://api.github.com"; public static final String USER = BASE_URL + "/user"; + public static final String REPO_SECURITY_ADVISORIES = BASE_URL + "/repos/%s/%s/security-advisories?state=%s"; + public static final String REPO_DEPENDABOT_ALERTS_OPEN = BASE_URL + "/repos/%s/%s/dependabot/alerts?state=open"; + public static final String REPO_SECRET_SCANNING_ALERTS_OPEN = + BASE_URL + "/repos/%s/%s/secret-scanning/alerts?state=open"; + public static final String REPO_CODE_SCANNING_ALERTS_OPEN = + BASE_URL + "/repos/%s/%s/code-scanning/alerts?state=open"; } } diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java index 66b14b6d4..f65c2034d 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java @@ -32,4 +32,5 @@ public class RequestMappingConstants { public static final String LATEST_ARTIFACT_DOWNLOAD_URL_BY_ID = "/{id}/artifact"; public static final String EXTERNAL_DOCUMENT = API + "/externaldocument"; public static final String PRODUCT_MARKETPLACE_DATA = API + "/product-marketplace-data"; + public static final String SECURITY_MONITOR = API + "/security-monitor"; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/SecurityMonitorController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/SecurityMonitorController.java new file mode 100644 index 000000000..40fd28b0a --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/SecurityMonitorController.java @@ -0,0 +1,39 @@ +package com.axonivy.market.controller; + +import com.axonivy.market.constants.GitHubConstants; +import com.axonivy.market.github.service.GitHubService; +import com.axonivy.market.github.model.ProductSecurityInfo; +import com.axonivy.market.util.AuthorizationUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static com.axonivy.market.constants.RequestMappingConstants.SECURITY_MONITOR; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +@RestController +@RequestMapping(SECURITY_MONITOR) +@Tag(name = "Security Monitor Controllers", description = "API collection to get Github Marketplace security's detail.") +@AllArgsConstructor +public class SecurityMonitorController { + private final GitHubService gitHubService; + + @GetMapping + @Operation(hidden = true) + public ResponseEntity getGitHubMarketplaceSecurity( + @RequestHeader(value = AUTHORIZATION) String authorizationHeader) { + String token = AuthorizationUtils.getBearerToken(authorizationHeader); + gitHubService.validateUserInOrganizationAndTeam(token, GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME, + GitHubConstants.AXONIVY_MARKET_TEAM_NAME); + List securityInfoList = gitHubService.getSecurityDetailsForAllProducts(token, + GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME); + return ResponseEntity.ok(securityInfoList); + } +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/enums/AccessLevel.java b/marketplace-service/src/main/java/com/axonivy/market/enums/AccessLevel.java new file mode 100644 index 000000000..8a382314d --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/enums/AccessLevel.java @@ -0,0 +1,10 @@ +package com.axonivy.market.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AccessLevel { + NO_PERMISSION, ENABLED, DISABLED +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/model/CodeScanning.java b/marketplace-service/src/main/java/com/axonivy/market/github/model/CodeScanning.java new file mode 100644 index 000000000..3d74354fe --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/github/model/CodeScanning.java @@ -0,0 +1,16 @@ +package com.axonivy.market.github.model; + +import com.axonivy.market.enums.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Map; + +@Getter +@Setter +@NoArgsConstructor +public class CodeScanning { + private Map alerts; + private AccessLevel status; +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/model/Dependabot.java b/marketplace-service/src/main/java/com/axonivy/market/github/model/Dependabot.java new file mode 100644 index 000000000..d73fc8925 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/github/model/Dependabot.java @@ -0,0 +1,16 @@ +package com.axonivy.market.github.model; + +import com.axonivy.market.enums.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Map; + +@Getter +@Setter +@NoArgsConstructor +public class Dependabot { + private Map alerts; + private AccessLevel status; +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java b/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java new file mode 100644 index 000000000..569dc98a0 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java @@ -0,0 +1,25 @@ +package com.axonivy.market.github.model; + +import com.axonivy.market.enums.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Date; +import java.util.Map; + +@Getter +@Setter +@NoArgsConstructor +public class ProductSecurityInfo { + private String repoName; + private boolean isArchived; + private String visibility; + private boolean branchProtectionEnabled; + private Date lastCommitDate; + private String latestCommitSHA; + private Map vulnerabilities; + private Dependabot dependabot; + private SecretScanning secretsScanning; + private CodeScanning codeScanning; +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/model/SecretScanning.java b/marketplace-service/src/main/java/com/axonivy/market/github/model/SecretScanning.java new file mode 100644 index 000000000..fbf834a98 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/github/model/SecretScanning.java @@ -0,0 +1,14 @@ +package com.axonivy.market.github.model; + +import com.axonivy.market.enums.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class SecretScanning { + private Integer numberOfAlerts; + private AccessLevel status; +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/GitHubService.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/GitHubService.java index dca4173d9..b7357fcf7 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/GitHubService.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/GitHubService.java @@ -6,6 +6,7 @@ import com.axonivy.market.exceptions.model.UnauthorizedException; import com.axonivy.market.github.model.GitHubAccessTokenResponse; import com.axonivy.market.github.model.GitHubProperty; +import com.axonivy.market.github.model.ProductSecurityInfo; import org.kohsuke.github.GHContent; import org.kohsuke.github.GHOrganization; import org.kohsuke.github.GHRepository; @@ -37,4 +38,6 @@ GitHubAccessTokenResponse getAccessToken(String code, GitHubProperty gitHubPrope User getAndUpdateUser(String accessToken); void validateUserInOrganizationAndTeam(String accessToken, String team, String org) throws UnauthorizedException; + + List getSecurityDetailsForAllProducts(String accessToken, String orgName); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java index 293850c62..c5b109530 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java @@ -3,16 +3,22 @@ import com.axonivy.market.constants.ErrorMessageConstants; import com.axonivy.market.constants.GitHubConstants; import com.axonivy.market.entity.User; +import com.axonivy.market.enums.AccessLevel; import com.axonivy.market.enums.ErrorCode; import com.axonivy.market.exceptions.model.MissingHeaderException; import com.axonivy.market.exceptions.model.NotFoundException; import com.axonivy.market.exceptions.model.Oauth2ExchangeCodeException; import com.axonivy.market.exceptions.model.UnauthorizedException; +import com.axonivy.market.github.model.CodeScanning; +import com.axonivy.market.github.model.Dependabot; import com.axonivy.market.github.model.GitHubAccessTokenResponse; import com.axonivy.market.github.model.GitHubProperty; +import com.axonivy.market.github.model.SecretScanning; import com.axonivy.market.github.service.GitHubService; +import com.axonivy.market.github.model.ProductSecurityInfo; import com.axonivy.market.repository.UserRepository; import lombok.extern.log4j.Log4j2; +import org.kohsuke.github.GHCommit; import org.kohsuke.github.GHContent; import org.kohsuke.github.GHOrganization; import org.kohsuke.github.GHRepository; @@ -25,20 +31,30 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.stereotype.Service; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; import static org.apache.commons.lang3.StringUtils.EMPTY; @@ -49,12 +65,14 @@ public class GitHubServiceImpl implements GitHubService { private final RestTemplate restTemplate; private final UserRepository userRepository; private final GitHubProperty gitHubProperty; + private ThreadPoolTaskScheduler taskScheduler; public GitHubServiceImpl(RestTemplateBuilder restTemplateBuilder, UserRepository userRepository, - GitHubProperty gitHubProperty) { + GitHubProperty gitHubProperty, ThreadPoolTaskScheduler taskScheduler) { this.restTemplate = restTemplateBuilder.build(); this.userRepository = userRepository; this.gitHubProperty = gitHubProperty; + this.taskScheduler = taskScheduler; } @Override @@ -96,8 +114,8 @@ public GHContent getGHContent(GHRepository ghRepository, String path, String ref } @Override - public GitHubAccessTokenResponse getAccessToken(String code, GitHubProperty gitHubProperty) - throws Oauth2ExchangeCodeException, MissingHeaderException { + public GitHubAccessTokenResponse getAccessToken(String code, + GitHubProperty gitHubProperty) throws Oauth2ExchangeCodeException, MissingHeaderException { if (gitHubProperty == null) { throw new MissingHeaderException(); } @@ -172,12 +190,35 @@ public void validateUserInOrganizationAndTeam(String accessToken, String organiz } throw new UnauthorizedException(ErrorCode.GITHUB_USER_UNAUTHORIZED.getCode(), - String.format(ErrorMessageConstants.INVALID_USER_ERROR, ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText(), - team, organization)); + String.format(ErrorMessageConstants.INVALID_USER_ERROR, ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText(), team, + organization)); } - private boolean isUserInOrganizationAndTeam(GitHub gitHub, String organization, - String teamName) throws IOException { + @Override + public List getSecurityDetailsForAllProducts(String accessToken, String orgName) { + List securityInfoList; + ExecutorService executor = taskScheduler.getScheduledExecutor(); + try { + GitHub gitHub = getGitHub(accessToken); + GHOrganization organization = gitHub.getOrganization(orgName); + + List> futures = organization.listRepositories().toList().stream() + .map(repo -> CompletableFuture.supplyAsync(() -> fetchSecurityInfoSafe(repo, organization, accessToken), executor)) + .toList(); + + securityInfoList = futures.stream() + .map(CompletableFuture::join) + .collect(Collectors.toList()); + + securityInfoList.sort(Comparator.comparing(ProductSecurityInfo::getRepoName)); + } catch (IOException e) { + throw new RuntimeException("Error fetching repository data", e); + } + + return securityInfoList; + } + + private boolean isUserInOrganizationAndTeam(GitHub gitHub, String organization, String teamName) throws IOException { if (gitHub == null) { return false; } @@ -188,7 +229,7 @@ private boolean isUserInOrganizationAndTeam(GitHub gitHub, String organization, return false; } - for (GHTeam team: hashSetTeam) { + for (GHTeam team : hashSetTeam) { if (teamName.equals(team.getName())) { return true; } @@ -196,4 +237,160 @@ private boolean isUserInOrganizationAndTeam(GitHub gitHub, String organization, return false; } + + private ProductSecurityInfo fetchSecurityInfoSafe(GHRepository repo, GHOrganization organization, String accessToken) { + try { + return fetchSecurityInfo(repo, organization, accessToken); + } catch (IOException e) { + log.error("Error fetching security info for repo: " + repo.getName(), e); + return new ProductSecurityInfo(); + } + } + + private ProductSecurityInfo fetchSecurityInfo(GHRepository repo, GHOrganization organization, + String accessToken) throws IOException { + ProductSecurityInfo productSecurityInfo = new ProductSecurityInfo(); + productSecurityInfo.setRepoName(repo.getName()); + productSecurityInfo.setVisibility(repo.getVisibility().toString()); + productSecurityInfo.setArchived(repo.isArchived()); + String defaultBranch = repo.getDefaultBranch(); + productSecurityInfo.setBranchProtectionEnabled(repo.getBranch(defaultBranch).isProtected()); + String latestCommitSHA = repo.getBranches().get(defaultBranch).getSHA1(); + GHCommit latestCommit = repo.getCommit(latestCommitSHA); + productSecurityInfo.setLatestCommitSHA(latestCommitSHA); + productSecurityInfo.setLastCommitDate(latestCommit.getCommitDate()); + + productSecurityInfo.setVulnerabilities(getVulnerabilitiesMap(repo, organization, accessToken)); + productSecurityInfo.setDependabot(getDependabotAlerts(repo, organization, accessToken)); + productSecurityInfo.setSecretsScanning(getNumberOfSecretScanningAlerts(repo, organization, accessToken)); + productSecurityInfo.setCodeScanning(getCodeScanningAlerts(repo, organization, accessToken)); + return productSecurityInfo; + } + + private Map getVulnerabilitiesMap(GHRepository repo, GHOrganization organization, + String accessToken) { + Map severityCountMap = new HashMap<>(); + try { + List> combinedAdvisories = new ArrayList<>(); + + ResponseEntity>> responseDraft = fetchApiResponseAsList(accessToken, + String.format(GitHubConstants.Url.REPO_SECURITY_ADVISORIES, organization.getLogin(), repo.getName(), "draft")); + ResponseEntity>> responseTriage = fetchApiResponseAsList(accessToken, + String.format(GitHubConstants.Url.REPO_SECURITY_ADVISORIES, organization.getLogin(), repo.getName(), "triage")); + + if (responseDraft.getBody() != null) { + combinedAdvisories.addAll(responseDraft.getBody()); + } + if (responseTriage.getBody() != null) { + combinedAdvisories.addAll(responseTriage.getBody()); + } + + for (Map advisory : combinedAdvisories) { + if (advisory != null) { + String severity = (String) advisory.get(GitHubConstants.Json.SEVERITY); + if (severity != null) { + severityCountMap.put(severity, severityCountMap.getOrDefault(severity, 0) + 1); + } + } + } + } + catch (RestClientException e) { + log.warn(e); + } + return severityCountMap; + } + + private Dependabot getDependabotAlerts(GHRepository repo, GHOrganization organization, String accessToken) { + Dependabot dependabot = new Dependabot(); + try { + ResponseEntity>> response = fetchApiResponseAsList(accessToken, + String.format(GitHubConstants.Url.REPO_DEPENDABOT_ALERTS_OPEN, organization.getLogin(), repo.getName())); + dependabot.setStatus(AccessLevel.ENABLED); + Map severityMap = new HashMap<>(); + if (response.getBody() != null) { + List> alerts = response.getBody(); + for (Map alert : alerts) { + Object advisoryObj = alert.get(GitHubConstants.Json.SEVERITY_ADVISORY); + if (advisoryObj instanceof Map securityAdvisory) { + String severity = (String) securityAdvisory.get(GitHubConstants.Json.SEVERITY); + if (severity != null) { + severityMap.put(severity, severityMap.getOrDefault(severity, 0) + 1); + } + } + } + } + dependabot.setAlerts(severityMap); + } + catch (HttpClientErrorException.Forbidden e) { + log.warn(e); + dependabot.setStatus(AccessLevel.DISABLED); + } + catch (HttpClientErrorException.NotFound e) { + log.warn(e); + dependabot.setStatus(AccessLevel.NO_PERMISSION); + } + return dependabot; + } + + private SecretScanning getNumberOfSecretScanningAlerts(GHRepository repo, GHOrganization organization, String accessToken) { + SecretScanning secretScanning = new SecretScanning(); + try { + ResponseEntity>> response = fetchApiResponseAsList(accessToken, + String.format(GitHubConstants.Url.REPO_SECRET_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName())); + secretScanning.setStatus(AccessLevel.ENABLED); + if (response.getBody() != null) { + secretScanning.setNumberOfAlerts(response.getBody().size()); + } + } + catch (HttpClientErrorException.Forbidden e) { + log.warn(e); + secretScanning.setStatus(AccessLevel.DISABLED); + } + catch (HttpClientErrorException.NotFound e) { + log.warn(e); + secretScanning.setStatus(AccessLevel.NO_PERMISSION); + } + return secretScanning; + } + + private CodeScanning getCodeScanningAlerts(GHRepository repo, GHOrganization organization, String accessToken) { + CodeScanning codeScanning = new CodeScanning(); + try { + ResponseEntity>> response = fetchApiResponseAsList(accessToken, + String.format(GitHubConstants.Url.REPO_CODE_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName())); + codeScanning.setStatus(AccessLevel.ENABLED); + Map codeScanningMap = new HashMap<>(); + if (response.getBody() != null) { + List> alerts = response.getBody(); + for (Map alert : alerts) { + Object ruleObj = alert.get(GitHubConstants.Json.RULE); + if (ruleObj instanceof Map rule) { + String severity = (String) rule.get(GitHubConstants.Json.SEVERITY); + if (severity != null) { + codeScanningMap.put(severity, codeScanningMap.getOrDefault(severity, 0) + 1); + } + } + } + } + codeScanning.setAlerts(codeScanningMap); + } + catch (HttpClientErrorException.Forbidden e) { + log.warn(e); + codeScanning.setStatus(AccessLevel.DISABLED); + } + catch (HttpClientErrorException.NotFound e) { + log.warn(e); + codeScanning.setStatus(AccessLevel.NO_PERMISSION); + } + return codeScanning; + } + + private ResponseEntity>> fetchApiResponseAsList(String accessToken, String url) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + HttpEntity entity = new HttpEntity<>(headers); + + return restTemplate.exchange(url, HttpMethod.GET, entity, new ParameterizedTypeReference<>() { + }); + } } diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html new file mode 100644 index 000000000..637a0cc34 --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html @@ -0,0 +1,103 @@ +
+ @if (isAuthenticated) { +
+

GitHub Repository Security Monitor

+

Keep track of your repositories' security status at a glance.

+
+
+ @for (repo of repos; track $index) { +
+
+

{{ repo.repoName }}

+ {{ repo.visibility }} + @if (repo.archived) { + Archived + } +
+
+

🤖 Dependabot: + @if (repo.dependabot.status == 'DISABLED') { + Disabled + } + @else if (repo.dependabot.status == 'NO_PERMISSION') { + No permission + } + @else if (hasAlerts(repo.dependabot.alerts)) { + @for (alert of alertKeys(repo.dependabot.alerts); track $index) { + + {{ repo.dependabot.alerts[alert] }} {{ alert }} + + } + } + @else { + No vulnerabilities + } +

+

🖥️ Code Scanning: + @if (repo.codeScanning.status == 'DISABLED') { + Disabled + } + @else if (repo.codeScanning.status == 'NO_PERMISSION') { + No permission + } + @else if (hasAlerts(repo.codeScanning.alerts)) { + @for (alert of alertKeys(repo.codeScanning.alerts); track $index) { + + {{ repo.codeScanning.alerts[alert] }} {{ alert }} + + } + } + @else { + No vulnerabilities + } +

+

🔑 Secrets Scanning: + @if (repo.secretsScanning.status == 'DISABLED') { + Disabled + } + @else if (repo.secretsScanning.status == 'NO_PERMISSION') { + No permission + } + @else if (repo.secretsScanning.numberOfAlerts) { + + {{ repo.secretsScanning.numberOfAlerts }} alerts + + } + @else { + No alerts + } +

+

🚧 Branch Protection: + @if (repo.branchProtectionEnabled) { + Enabled + } + @else { + Disabled + } +

+

⏱️ Last Commit: {{ formatCommitDate(repo.lastCommitDate) }}

+
+
+ } +
+ } + @else { +
+

Please enter your token to access the security page.

+
+ + + @if (errorMessage) { +
{{ errorMessage }}
+ } +
+
+ } +
\ No newline at end of file diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss new file mode 100644 index 000000000..2958188ff --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss @@ -0,0 +1,215 @@ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + background-color: #f9f9f9; + cursor: default; +} + +.container { + max-width: 1200px; + margin: 20px auto; + padding: 0 15px; +} + +.header { + text-align: center; + padding: 20px 0; + color: #333; +} + +.repo-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 15px; +} + +.repo-card { + background-color: #fff; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + padding: 15px; +} + +.repo-card:hover { + background-color: #f5f5f5; + border-color: #ccc; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); + cursor: pointer; +} + +.repo-card a:hover { + color: #0056b3; + text-decoration: underline; +} + +// .repo-card a { +// text-decoration: none; +// color: inherit; +// } + +.repo-header { + display: flex; + align-items: center; + margin-bottom: 10px; + gap: 10px; + + h3 { + margin: 0; + font-size: 2rem; + color: #007acc; + } + + .visibility { + font-size: 1.1rem; + padding: 3px 8px; + border-radius: 20px; + border: 1px solid gray; + line-height: 12px; + } + + .archived { + font-size: 1.1rem; + padding: 3px 8px; + border-radius: 20px; + border: 1px solid orange; + line-height: 12px; + color: orange; + } +} + +.repo-info { + margin-top: 15px; + + p { + margin: 8px 0; + font-size: 1.5rem; + color: #555; + min-height: 28px; + } + + span { + font-weight: bold; + } +} + +.badge { + display: inline-block; + padding: 7px 10px; + font-size: 1.3rem; + border-radius: 5px; + color: #fff; + margin-left: 5px; + + &.critical { + background-color: #e63946; + } + + &.high { + background-color: #f4a261; + } + + &.medium, &.warning { + background-color: #f4d35e; + color: #333; + } + + &.low, &.note { + background-color: #90be6d; + } + + &.error { + background-color: #d00000; + } + + &.none, &.no-permission { + background-color: #ccc; + color: #666; + } + + &.active { + background-color: #2a9d8f; + } +} + +/* Styling for inactive or zero values */ +.inactive { + color: #bbb; + font-style: italic; +} + +.token-input-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: #f9f9f9; + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.token-input-container h3 { + font-size: 1.6rem; + margin-bottom: 20px; + color: #333; +} + +.token-input-container span { + margin-bottom: 20px; + font-size: 1rem; + color: #777; +} + +.token-input-container input { + padding: 10px; + font-size: 1.5rem; + margin-bottom: 10px; + width: 400px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.token-input-container button { + padding: 10px 20px; + font-size: 1.5rem; + background-color: #4CAF50; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.3s ease; + margin-left: 10px; + width: 100px; +} + +.token-input-container button:hover { + background-color: #45a049; +} + +.error-message { + color: #f44336; + font-size: 1.5rem; + width: 500px; +} + +button .spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top: 2px solid #fff; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts new file mode 100644 index 000000000..88d31270d --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SecurityMonitorComponent } from './security-monitor.component'; + +describe('SecurityMonitorComponent', () => { + let component: SecurityMonitorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SecurityMonitorComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SecurityMonitorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts new file mode 100644 index 000000000..46464aed7 --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts @@ -0,0 +1,124 @@ +import { Component, inject, ViewEncapsulation } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { SecurityMonitorService } from './security-monitor.service'; +import { Repo } from '../../shared/models/security-model'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-security-monitor', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './security-monitor.component.html', + styleUrl: './security-monitor.component.scss', + encapsulation: ViewEncapsulation.Emulated +}) +export class SecurityMonitorComponent { + isAuthenticated: boolean = false; + token: string = ''; + errorMessage: string = ''; + securityMonitorService = inject(SecurityMonitorService); + repos: Repo[] = []; + isLoading = false; + + onSubmit() { + if (!this.token) { + this.errorMessage = 'Token is required'; + return; + } + this.errorMessage = ''; + this.isLoading = true; + this.securityMonitorService.getSecurityDetails(this.token).subscribe({ + next: (data) => { + this.repos = data; + this.isAuthenticated = true; + }, + error: (err) => { + this.errorMessage = + err.status === 401 + ? 'Unauthorized access. (The token should contain the "org:read" scope for authentication)' + : 'Failed to fetch security data. Check logs for details.'; + console.error(err); + this.isLoading = false; + }, + complete: () => { + this.isLoading = false; + } + }); + } + + formatCommitDate(date: string): string { + const now = new Date().getTime(); + const targetDate = new Date(date).getTime(); + const diffInSeconds = Math.floor((now - targetDate) / 1000); + + if (diffInSeconds < 60) { + return 'just now'; + } + + const diffInMinutes = Math.floor(diffInSeconds / 60); + if (diffInMinutes < 60) { + return `${diffInMinutes} minute${diffInMinutes > 1 ? 's' : ''} ago`; + } + + const diffInHours = Math.floor(diffInMinutes / 60); + if (diffInHours < 24) { + return `${diffInHours} hour${diffInHours > 1 ? 's' : ''} ago`; + } + + const diffInDays = Math.floor(diffInHours / 24); + if (diffInDays < 7) { + return `${diffInDays} day${diffInDays > 1 ? 's' : ''} ago`; + } + + const diffInWeeks = Math.floor(diffInDays / 7); + if (diffInWeeks < 4) { + return `${diffInWeeks} week${diffInWeeks > 1 ? 's' : ''} ago`; + } + + const diffInMonths = Math.floor(diffInDays / 30); + if (diffInMonths < 12) { + return `${diffInMonths} month${diffInMonths > 1 ? 's' : ''} ago`; + } + + const diffInYears = Math.floor(diffInMonths / 12); + return `${diffInYears} year${diffInYears > 1 ? 's' : ''} ago`; + } + + hasAlerts(alerts: Record): boolean { + return Object.keys(alerts).length > 0; + } + + alertKeys(alerts: Record): string[] { + return Object.keys(alerts); + } + + navigateToRepoSecurityPage(repoName: string): void { + const repoSecurityUrl = `https://github.com/axonivy-market/${repoName}/security`; + window.open(repoSecurityUrl, '_blank'); + } + + navigateToRepoDependabotPage(repoName: string): void { + const repoSecurityDependabotUrl = `https://github.com/axonivy-market/${repoName}/security/dependabot`; + window.open(repoSecurityDependabotUrl, '_blank'); + } + + navigateToRepoCodeScanningPage(repoName: string): void { + const repoSecurityDependabotUrl = `https://github.com/axonivy-market/${repoName}/security/code-scanning`; + window.open(repoSecurityDependabotUrl, '_blank'); + } + + navigateToRepoSecurityScanningPage(repoName: string): void { + const repoSecurityDependabotUrl = `https://github.com/axonivy-market/${repoName}/security/security-scanning`; + window.open(repoSecurityDependabotUrl, '_blank'); + } + + navigateToRepoLastCommitPage(repoName: string, lastCommitSHA: string): void { + const repoSecurityDependabotUrl = `https://github.com/axonivy-market/${repoName}/commit/${lastCommitSHA}`; + window.open(repoSecurityDependabotUrl, '_blank'); + } + + navigateToRepoBranchesSettingPage(repoName: string, lastCommitSHA: string): void { + const repoSecurityDependabotUrl = `https://github.com/axonivy-market/${repoName}/settings/branches`; + window.open(repoSecurityDependabotUrl, '_blank'); + } +} diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts new file mode 100644 index 000000000..078c6417a --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SecurityMonitorService } from './security-monitor.service'; + +describe('SecurityMonitorService', () => { + let service: SecurityMonitorService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SecurityMonitorService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.ts new file mode 100644 index 000000000..d7d5a23de --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.ts @@ -0,0 +1,18 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +@Injectable({ + providedIn: 'root' +}) +export class SecurityMonitorService { + + private readonly apiUrl = environment.apiUrl + '/api/security-monitor'; + private readonly http = inject(HttpClient); + + getSecurityDetails(token: string): Observable { + const headers = new HttpHeaders().set('Authorization', `Bearer ${token}`); + return this.http.get(this.apiUrl, { headers }); + } +} diff --git a/marketplace-ui/src/app/shared/models/security-model.ts b/marketplace-ui/src/app/shared/models/security-model.ts new file mode 100644 index 000000000..ff6a5eb51 --- /dev/null +++ b/marketplace-ui/src/app/shared/models/security-model.ts @@ -0,0 +1,26 @@ +export interface Dependabot { + alerts: Record; + status: string; +} + +export interface CodeScanning { + alerts: Record; + status: string; +} + +export interface SecretsScanning { + numberOfAlerts: number | null; + status: string; +} + +export interface Repo { + repoName: string; + visibility: string; + branchProtectionEnabled: boolean; + lastCommitDate: string; + lastCommitSHA: string; + dependabot: Dependabot; + secretsScanning: SecretsScanning; + codeScanning: CodeScanning; + archived: boolean; +} \ No newline at end of file diff --git a/marketplace-ui/src/index.html b/marketplace-ui/src/index.html index 2076a2de0..c62abb7e0 100644 --- a/marketplace-ui/src/index.html +++ b/marketplace-ui/src/index.html @@ -11,6 +11,7 @@ + \ No newline at end of file diff --git a/marketplace-ui/src/main.ts b/marketplace-ui/src/main.ts index 3997d76eb..866d9fbc4 100644 --- a/marketplace-ui/src/main.ts +++ b/marketplace-ui/src/main.ts @@ -3,7 +3,14 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { AppComponent } from './app/app.component'; +import { SecurityMonitorComponent } from './app/modules/security-monitor/security-monitor.component'; +import { provideHttpClient } from '@angular/common/http'; -bootstrapApplication(AppComponent, appConfig).catch(err => { - throw err; -}); +const currentPath = window.location.pathname; +if (currentPath.startsWith('/security-monitor')) { + bootstrapApplication(SecurityMonitorComponent, { + providers: [provideHttpClient()] + }).catch(err => console.error(err)); +} else { + bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); +} From bd5b0b0097dbd14bbd4b6b2cd5da093315405843 Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Wed, 4 Dec 2024 22:54:15 +0700 Subject: [PATCH 02/13] Add tests and refactor code UI --- .../security-monitor.component.html | 2 +- .../security-monitor.component.scss | 13 +- .../security-monitor.component.spec.ts | 82 ++++++++++++- .../security-monitor.component.ts | 116 ++++++++---------- .../security-monitor.service.spec.ts | 16 --- .../src/app/shared/models/security-model.ts | 34 +++-- 6 files changed, 150 insertions(+), 113 deletions(-) delete mode 100644 marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html index 637a0cc34..1fd264815 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html @@ -67,7 +67,7 @@

{{ repo.repoName }}

No alerts }

-

🚧 Branch Protection: +

🚧 Branch Protection: @if (repo.branchProtectionEnabled) { Enabled } diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss index 2958188ff..c4a13627b 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss @@ -44,20 +44,15 @@ body { text-decoration: underline; } -// .repo-card a { -// text-decoration: none; -// color: inherit; -// } - .repo-header { display: flex; align-items: center; margin-bottom: 10px; - gap: 10px; + gap: 5px; h3 { margin: 0; - font-size: 2rem; + font-size: 1.6rem; color: #007acc; } @@ -190,8 +185,8 @@ body { .error-message { color: #f44336; - font-size: 1.5rem; - width: 500px; + font-size: 1.2rem; + width: 100%; } button .spinner { diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts index 88d31270d..75edcf0e5 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts @@ -1,23 +1,95 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; - import { SecurityMonitorComponent } from './security-monitor.component'; +import { SecurityMonitorService } from './security-monitor.service'; +import { of, throwError } from 'rxjs'; +import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { By } from '@angular/platform-browser'; describe('SecurityMonitorComponent', () => { let component: SecurityMonitorComponent; let fixture: ComponentFixture; + let securityMonitorService: jasmine.SpyObj; beforeEach(async () => { + const spy = jasmine.createSpyObj('SecurityMonitorService', ['getSecurityDetails']); + await TestBed.configureTestingModule({ - imports: [SecurityMonitorComponent] - }) - .compileComponents(); + imports: [CommonModule, FormsModule], // Import standalone component's dependencies + providers: [ + { provide: SecurityMonitorService, useValue: spy } + ] + }).compileComponents(); + + securityMonitorService = TestBed.inject(SecurityMonitorService) as jasmine.SpyObj; + }); + beforeEach(() => { fixture = TestBed.createComponent(SecurityMonitorComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { + it('should create the component', () => { expect(component).toBeTruthy(); }); + + it('should show error message when token is empty and onSubmit is called', () => { + component.token = ''; + component.onSubmit(); + expect(component.errorMessage).toBe('Token is required'); + }); + + it('should call SecurityMonitorService and display repos when token is valid and response is successful', () => { + const mockRepos = [ + { repoName: 'repo1', visibility: 'public', archived: false, dependabot: { status: 'ACTIVE', alerts: {} }, codeScanning: { status: 'ENABLED', alerts: {} }, secretsScanning: { status: 'ENABLED', numberOfAlerts: 0 }, branchProtectionEnabled: true, lastCommitSHA: '12345', lastCommitDate: '2024-12-04' }, + ]; + + // Mock service response + securityMonitorService.getSecurityDetails.and.returnValue(of(mockRepos)); + + component.token = 'valid-token'; + component.onSubmit(); + + fixture.detectChanges(); // Trigger change detection + + expect(securityMonitorService.getSecurityDetails).toHaveBeenCalledWith('valid-token'); + expect(component.repos).toEqual(mockRepos); + expect(component.isAuthenticated).toBeTrue(); + + // Check if repo data is rendered + const repoCards = fixture.debugElement.queryAll(By.css('.repo-card')); + expect(repoCards.length).toBe(mockRepos.length); + expect(repoCards[0].nativeElement.querySelector('h3').textContent).toBe('repo1'); + }); + + it('should handle error when token is invalid (401 Unauthorized)', () => { + const mockError = { status: 401 }; + + // Mock service error + securityMonitorService.getSecurityDetails.and.returnValue(throwError(() => mockError)); + + component.token = 'invalid-token'; + component.onSubmit(); + + fixture.detectChanges(); // Trigger change detection + + expect(component.errorMessage).toBe('Unauthorized access. (The token should contain the "org:read" scope for authentication)'); + expect(component.isLoading).toBeFalse(); + }); + + it('should handle generic error when fetching security data fails', () => { + const mockError = { status: 500 }; + + // Mock service error + securityMonitorService.getSecurityDetails.and.returnValue(throwError(() => mockError)); + + component.token = 'invalid-token'; + component.onSubmit(); + + fixture.detectChanges(); // Trigger change detection + + expect(component.errorMessage).toBe('Failed to fetch security data. Check logs for details.'); + expect(component.isLoading).toBeFalse(); + }); }); diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts index 46464aed7..1a6149307 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts @@ -9,79 +9,72 @@ import { CommonModule } from '@angular/common'; standalone: true, imports: [CommonModule, FormsModule], templateUrl: './security-monitor.component.html', - styleUrl: './security-monitor.component.scss', - encapsulation: ViewEncapsulation.Emulated + styleUrls: ['./security-monitor.component.scss'], + encapsulation: ViewEncapsulation.Emulated, }) export class SecurityMonitorComponent { - isAuthenticated: boolean = false; - token: string = ''; - errorMessage: string = ''; - securityMonitorService = inject(SecurityMonitorService); + isAuthenticated = false; + token = ''; + errorMessage = ''; repos: Repo[] = []; isLoading = false; - onSubmit() { + private securityMonitorService = inject(SecurityMonitorService); + private readonly githubBaseUrl = 'https://github.com/axonivy-market'; + + onSubmit(): void { if (!this.token) { this.errorMessage = 'Token is required'; return; } + this.errorMessage = ''; this.isLoading = true; + this.securityMonitorService.getSecurityDetails(this.token).subscribe({ - next: (data) => { - this.repos = data; - this.isAuthenticated = true; - }, - error: (err) => { - this.errorMessage = - err.status === 401 - ? 'Unauthorized access. (The token should contain the "org:read" scope for authentication)' - : 'Failed to fetch security data. Check logs for details.'; - console.error(err); - this.isLoading = false; - }, - complete: () => { - this.isLoading = false; - } + next: (data) => this.handleSuccess(data), + error: (err) => this.handleError(err), + complete: () => (this.isLoading = false), }); } + private handleSuccess(data: Repo[]): void { + this.repos = data; + this.isAuthenticated = true; + this.isLoading = false; + } + + private handleError(err: any): void { + this.errorMessage = + err.status === 401 + ? 'Unauthorized access. (The token should contain the "org:read" scope for authentication)' + : 'Failed to fetch security data. Check logs for details.'; + console.error(err); + this.isLoading = false; + } + formatCommitDate(date: string): string { - const now = new Date().getTime(); + const now = Date.now(); const targetDate = new Date(date).getTime(); const diffInSeconds = Math.floor((now - targetDate) / 1000); - - if (diffInSeconds < 60) { - return 'just now'; - } - + + if (diffInSeconds < 60) return 'just now'; const diffInMinutes = Math.floor(diffInSeconds / 60); - if (diffInMinutes < 60) { - return `${diffInMinutes} minute${diffInMinutes > 1 ? 's' : ''} ago`; - } - + if (diffInMinutes < 60) return this.formatTime(diffInMinutes, 'minute'); const diffInHours = Math.floor(diffInMinutes / 60); - if (diffInHours < 24) { - return `${diffInHours} hour${diffInHours > 1 ? 's' : ''} ago`; - } - + if (diffInHours < 24) return this.formatTime(diffInHours, 'hour'); const diffInDays = Math.floor(diffInHours / 24); - if (diffInDays < 7) { - return `${diffInDays} day${diffInDays > 1 ? 's' : ''} ago`; - } - + if (diffInDays < 7) return this.formatTime(diffInDays, 'day'); const diffInWeeks = Math.floor(diffInDays / 7); - if (diffInWeeks < 4) { - return `${diffInWeeks} week${diffInWeeks > 1 ? 's' : ''} ago`; - } - + if (diffInWeeks < 4) return this.formatTime(diffInWeeks, 'week'); const diffInMonths = Math.floor(diffInDays / 30); - if (diffInMonths < 12) { - return `${diffInMonths} month${diffInMonths > 1 ? 's' : ''} ago`; - } - + if (diffInMonths < 12) return this.formatTime(diffInMonths, 'month'); const diffInYears = Math.floor(diffInMonths / 12); - return `${diffInYears} year${diffInYears > 1 ? 's' : ''} ago`; + return this.formatTime(diffInYears, 'year'); + } + + private formatTime(value: number, unit: string): string { + return `${value} ${unit}${value > 1 ? 's' : ''} ago`; } hasAlerts(alerts: Record): boolean { @@ -92,33 +85,32 @@ export class SecurityMonitorComponent { return Object.keys(alerts); } + navigateToPage(repoName: string, path: string, additionalPath: string = ''): void { + const url = `${this.githubBaseUrl}/${repoName}${path}${additionalPath}`; + window.open(url, '_blank'); + } + navigateToRepoSecurityPage(repoName: string): void { - const repoSecurityUrl = `https://github.com/axonivy-market/${repoName}/security`; - window.open(repoSecurityUrl, '_blank'); + this.navigateToPage(repoName, '/security'); } navigateToRepoDependabotPage(repoName: string): void { - const repoSecurityDependabotUrl = `https://github.com/axonivy-market/${repoName}/security/dependabot`; - window.open(repoSecurityDependabotUrl, '_blank'); + this.navigateToPage(repoName, '/security/dependabot'); } navigateToRepoCodeScanningPage(repoName: string): void { - const repoSecurityDependabotUrl = `https://github.com/axonivy-market/${repoName}/security/code-scanning`; - window.open(repoSecurityDependabotUrl, '_blank'); + this.navigateToPage(repoName, '/security/code-scanning'); } navigateToRepoSecurityScanningPage(repoName: string): void { - const repoSecurityDependabotUrl = `https://github.com/axonivy-market/${repoName}/security/security-scanning`; - window.open(repoSecurityDependabotUrl, '_blank'); + this.navigateToPage(repoName, '/security/security-scanning'); } navigateToRepoLastCommitPage(repoName: string, lastCommitSHA: string): void { - const repoSecurityDependabotUrl = `https://github.com/axonivy-market/${repoName}/commit/${lastCommitSHA}`; - window.open(repoSecurityDependabotUrl, '_blank'); + this.navigateToPage(repoName, '/commit/', lastCommitSHA); } - navigateToRepoBranchesSettingPage(repoName: string, lastCommitSHA: string): void { - const repoSecurityDependabotUrl = `https://github.com/axonivy-market/${repoName}/settings/branches`; - window.open(repoSecurityDependabotUrl, '_blank'); + navigateToRepoBranchesSettingPage(repoName: string): void { + this.navigateToPage(repoName, '/settings/branches'); } } diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts deleted file mode 100644 index 078c6417a..000000000 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { SecurityMonitorService } from './security-monitor.service'; - -describe('SecurityMonitorService', () => { - let service: SecurityMonitorService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(SecurityMonitorService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/marketplace-ui/src/app/shared/models/security-model.ts b/marketplace-ui/src/app/shared/models/security-model.ts index ff6a5eb51..0cacda4d8 100644 --- a/marketplace-ui/src/app/shared/models/security-model.ts +++ b/marketplace-ui/src/app/shared/models/security-model.ts @@ -1,26 +1,20 @@ -export interface Dependabot { - alerts: Record; - status: string; -} - -export interface CodeScanning { - alerts: Record; - status: string; -} - -export interface SecretsScanning { - numberOfAlerts: number | null; - status: string; -} - export interface Repo { repoName: string; visibility: string; + archived: boolean; + dependabot: { + status: string; + alerts: Record; + }; + codeScanning: { + status: string; + alerts: Record; + }; + secretsScanning: { + status: string; + numberOfAlerts: number; + }; branchProtectionEnabled: boolean; - lastCommitDate: string; lastCommitSHA: string; - dependabot: Dependabot; - secretsScanning: SecretsScanning; - codeScanning: CodeScanning; - archived: boolean; + lastCommitDate: string; } \ No newline at end of file From d5d107f88cae2cac779d1718981f58d27b541cfb Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Thu, 5 Dec 2024 02:42:12 +0700 Subject: [PATCH 03/13] Implement Test for BE --- .../market/constants/GitHubConstants.java | 4 - .../github/model/ProductSecurityInfo.java | 4 +- .../service/impl/GitHubServiceImpl.java | 194 +++--------------- .../market/github/util/GitHubUtils.java | 111 ++++++++++ .../SecurityMonitorControllerTest.java | 71 +++++++ .../security-monitor.component.spec.ts | 12 +- 6 files changed, 214 insertions(+), 182 deletions(-) create mode 100644 marketplace-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.java diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java index 4440e458b..3f51bac8b 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java @@ -19,10 +19,6 @@ public static class Json { public static final String CLIENT_ID = "client_id"; public static final String CLIENT_SECRET = "client_secret"; public static final String CODE = "code"; - public static final String USER_ID = "id"; - public static final String USER_NAME = "name"; - public static final String USER_AVATAR_URL = "avatar_url"; - public static final String USER_LOGIN_NAME = "login"; public static final String SEVERITY = "severity"; public static final String SEVERITY_ADVISORY = "security_advisory"; public static final String RULE = "rule"; diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java b/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java index 569dc98a0..2f0c136c1 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java @@ -1,6 +1,6 @@ package com.axonivy.market.github.model; -import com.axonivy.market.enums.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -11,6 +11,7 @@ @Getter @Setter @NoArgsConstructor +@AllArgsConstructor public class ProductSecurityInfo { private String repoName; private boolean isArchived; @@ -18,7 +19,6 @@ public class ProductSecurityInfo { private boolean branchProtectionEnabled; private Date lastCommitDate; private String latestCommitSHA; - private Map vulnerabilities; private Dependabot dependabot; private SecretScanning secretsScanning; private CodeScanning codeScanning; diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java index c5b109530..51d04d2e5 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java @@ -16,22 +16,22 @@ import com.axonivy.market.github.model.SecretScanning; import com.axonivy.market.github.service.GitHubService; import com.axonivy.market.github.model.ProductSecurityInfo; +import com.axonivy.market.github.util.GitHubUtils; import com.axonivy.market.repository.UserRepository; import lombok.extern.log4j.Log4j2; import org.kohsuke.github.GHCommit; import org.kohsuke.github.GHContent; +import org.kohsuke.github.GHMyself; import org.kohsuke.github.GHOrganization; import org.kohsuke.github.GHRepository; import org.kohsuke.github.GHTag; import org.kohsuke.github.GHTeam; import org.kohsuke.github.GitHub; import org.kohsuke.github.GitHubBuilder; -import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; @@ -62,14 +62,13 @@ @Service public class GitHubServiceImpl implements GitHubService { - private final RestTemplate restTemplate; + private final RestTemplate restTemplate = new RestTemplate();; private final UserRepository userRepository; private final GitHubProperty gitHubProperty; - private ThreadPoolTaskScheduler taskScheduler; + private final ThreadPoolTaskScheduler taskScheduler; - public GitHubServiceImpl(RestTemplateBuilder restTemplateBuilder, UserRepository userRepository, + public GitHubServiceImpl(UserRepository userRepository, GitHubProperty gitHubProperty, ThreadPoolTaskScheduler taskScheduler) { - this.restTemplate = restTemplateBuilder.build(); this.userRepository = userRepository; this.gitHubProperty = gitHubProperty; this.taskScheduler = taskScheduler; @@ -143,38 +142,26 @@ public GitHubAccessTokenResponse getAccessToken(String code, @Override public User getAndUpdateUser(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity> response = restTemplate.exchange(GitHubConstants.Url.USER, HttpMethod.GET, - entity, new ParameterizedTypeReference<>() { - }); - - Map userDetails = response.getBody(); - - if (userDetails == null) { + try { + var gitHub = getGitHub(accessToken); + GHMyself myself = gitHub.getMyself(); + String gitHubId = String.valueOf(myself.getId()); + User user = userRepository.searchByGitHubId(gitHubId); + if (user == null) { + user = new User(); + } + user.setGitHubId(gitHubId); + user.setName(myself.getName()); + user.setUsername(myself.getLogin()); + user.setAvatarUrl(myself.getAvatarUrl()); + user.setProvider(GitHubConstants.GITHUB_PROVIDER_NAME); + + userRepository.save(user); + return user; + } catch(IOException e) { + log.error(e); throw new NotFoundException(ErrorCode.GITHUB_USER_NOT_FOUND, "Failed to fetch user details from GitHub"); } - - String gitHubId = userDetails.get(GitHubConstants.Json.USER_ID).toString(); - String name = (String) userDetails.get(GitHubConstants.Json.USER_NAME); - String avatarUrl = (String) userDetails.get(GitHubConstants.Json.USER_AVATAR_URL); - String username = (String) userDetails.get(GitHubConstants.Json.USER_LOGIN_NAME); - - User user = userRepository.searchByGitHubId(gitHubId); - if (user == null) { - user = new User(); - } - user.setGitHubId(gitHubId); - user.setName(name); - user.setUsername(username); - user.setAvatarUrl(avatarUrl); - user.setProvider(GitHubConstants.GITHUB_PROVIDER_NAME); - - userRepository.save(user); - - return user; } @Override @@ -259,138 +246,9 @@ private ProductSecurityInfo fetchSecurityInfo(GHRepository repo, GHOrganization GHCommit latestCommit = repo.getCommit(latestCommitSHA); productSecurityInfo.setLatestCommitSHA(latestCommitSHA); productSecurityInfo.setLastCommitDate(latestCommit.getCommitDate()); - - productSecurityInfo.setVulnerabilities(getVulnerabilitiesMap(repo, organization, accessToken)); - productSecurityInfo.setDependabot(getDependabotAlerts(repo, organization, accessToken)); - productSecurityInfo.setSecretsScanning(getNumberOfSecretScanningAlerts(repo, organization, accessToken)); - productSecurityInfo.setCodeScanning(getCodeScanningAlerts(repo, organization, accessToken)); + productSecurityInfo.setDependabot(GitHubUtils.getDependabotAlerts(repo, organization, accessToken)); + productSecurityInfo.setSecretsScanning(GitHubUtils.getNumberOfSecretScanningAlerts(repo, organization, accessToken)); + productSecurityInfo.setCodeScanning(GitHubUtils.getCodeScanningAlerts(repo, organization, accessToken)); return productSecurityInfo; } - - private Map getVulnerabilitiesMap(GHRepository repo, GHOrganization organization, - String accessToken) { - Map severityCountMap = new HashMap<>(); - try { - List> combinedAdvisories = new ArrayList<>(); - - ResponseEntity>> responseDraft = fetchApiResponseAsList(accessToken, - String.format(GitHubConstants.Url.REPO_SECURITY_ADVISORIES, organization.getLogin(), repo.getName(), "draft")); - ResponseEntity>> responseTriage = fetchApiResponseAsList(accessToken, - String.format(GitHubConstants.Url.REPO_SECURITY_ADVISORIES, organization.getLogin(), repo.getName(), "triage")); - - if (responseDraft.getBody() != null) { - combinedAdvisories.addAll(responseDraft.getBody()); - } - if (responseTriage.getBody() != null) { - combinedAdvisories.addAll(responseTriage.getBody()); - } - - for (Map advisory : combinedAdvisories) { - if (advisory != null) { - String severity = (String) advisory.get(GitHubConstants.Json.SEVERITY); - if (severity != null) { - severityCountMap.put(severity, severityCountMap.getOrDefault(severity, 0) + 1); - } - } - } - } - catch (RestClientException e) { - log.warn(e); - } - return severityCountMap; - } - - private Dependabot getDependabotAlerts(GHRepository repo, GHOrganization organization, String accessToken) { - Dependabot dependabot = new Dependabot(); - try { - ResponseEntity>> response = fetchApiResponseAsList(accessToken, - String.format(GitHubConstants.Url.REPO_DEPENDABOT_ALERTS_OPEN, organization.getLogin(), repo.getName())); - dependabot.setStatus(AccessLevel.ENABLED); - Map severityMap = new HashMap<>(); - if (response.getBody() != null) { - List> alerts = response.getBody(); - for (Map alert : alerts) { - Object advisoryObj = alert.get(GitHubConstants.Json.SEVERITY_ADVISORY); - if (advisoryObj instanceof Map securityAdvisory) { - String severity = (String) securityAdvisory.get(GitHubConstants.Json.SEVERITY); - if (severity != null) { - severityMap.put(severity, severityMap.getOrDefault(severity, 0) + 1); - } - } - } - } - dependabot.setAlerts(severityMap); - } - catch (HttpClientErrorException.Forbidden e) { - log.warn(e); - dependabot.setStatus(AccessLevel.DISABLED); - } - catch (HttpClientErrorException.NotFound e) { - log.warn(e); - dependabot.setStatus(AccessLevel.NO_PERMISSION); - } - return dependabot; - } - - private SecretScanning getNumberOfSecretScanningAlerts(GHRepository repo, GHOrganization organization, String accessToken) { - SecretScanning secretScanning = new SecretScanning(); - try { - ResponseEntity>> response = fetchApiResponseAsList(accessToken, - String.format(GitHubConstants.Url.REPO_SECRET_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName())); - secretScanning.setStatus(AccessLevel.ENABLED); - if (response.getBody() != null) { - secretScanning.setNumberOfAlerts(response.getBody().size()); - } - } - catch (HttpClientErrorException.Forbidden e) { - log.warn(e); - secretScanning.setStatus(AccessLevel.DISABLED); - } - catch (HttpClientErrorException.NotFound e) { - log.warn(e); - secretScanning.setStatus(AccessLevel.NO_PERMISSION); - } - return secretScanning; - } - - private CodeScanning getCodeScanningAlerts(GHRepository repo, GHOrganization organization, String accessToken) { - CodeScanning codeScanning = new CodeScanning(); - try { - ResponseEntity>> response = fetchApiResponseAsList(accessToken, - String.format(GitHubConstants.Url.REPO_CODE_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName())); - codeScanning.setStatus(AccessLevel.ENABLED); - Map codeScanningMap = new HashMap<>(); - if (response.getBody() != null) { - List> alerts = response.getBody(); - for (Map alert : alerts) { - Object ruleObj = alert.get(GitHubConstants.Json.RULE); - if (ruleObj instanceof Map rule) { - String severity = (String) rule.get(GitHubConstants.Json.SEVERITY); - if (severity != null) { - codeScanningMap.put(severity, codeScanningMap.getOrDefault(severity, 0) + 1); - } - } - } - } - codeScanning.setAlerts(codeScanningMap); - } - catch (HttpClientErrorException.Forbidden e) { - log.warn(e); - codeScanning.setStatus(AccessLevel.DISABLED); - } - catch (HttpClientErrorException.NotFound e) { - log.warn(e); - codeScanning.setStatus(AccessLevel.NO_PERMISSION); - } - return codeScanning; - } - - private ResponseEntity>> fetchApiResponseAsList(String accessToken, String url) { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); - HttpEntity entity = new HttpEntity<>(headers); - - return restTemplate.exchange(url, HttpMethod.GET, entity, new ParameterizedTypeReference<>() { - }); - } } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java b/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java index 0f0de6232..7dd4f04cb 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java @@ -2,6 +2,10 @@ import com.axonivy.market.bo.Artifact; import com.axonivy.market.constants.CommonConstants; +import com.axonivy.market.constants.GitHubConstants; +import com.axonivy.market.github.model.CodeScanning; +import com.axonivy.market.github.model.Dependabot; +import com.axonivy.market.github.model.SecretScanning; import com.axonivy.market.util.MavenUtils; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -9,13 +13,25 @@ import org.apache.commons.lang3.StringUtils; import org.kohsuke.github.GHCommit; import org.kohsuke.github.GHContent; +import org.kohsuke.github.GHOrganization; +import org.kohsuke.github.GHRepository; import org.kohsuke.github.PagedIterable; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; @@ -132,4 +148,99 @@ public static InputStream extractedContentStream(GHContent content) { return null; } } + + public static Dependabot getDependabotAlerts(GHRepository repo, GHOrganization organization, String accessToken) { + Dependabot dependabot = new Dependabot(); + try { + ResponseEntity>> response = fetchApiResponseAsList(accessToken, + String.format(GitHubConstants.Url.REPO_DEPENDABOT_ALERTS_OPEN, organization.getLogin(), repo.getName())); + dependabot.setStatus(com.axonivy.market.enums.AccessLevel.ENABLED); + Map severityMap = new HashMap<>(); + if (response.getBody() != null) { + List> alerts = response.getBody(); + for (Map alert : alerts) { + Object advisoryObj = alert.get(GitHubConstants.Json.SEVERITY_ADVISORY); + if (advisoryObj instanceof Map securityAdvisory) { + String severity = (String) securityAdvisory.get(GitHubConstants.Json.SEVERITY); + if (severity != null) { + severityMap.put(severity, severityMap.getOrDefault(severity, 0) + 1); + } + } + } + } + dependabot.setAlerts(severityMap); + } + catch (HttpClientErrorException.Forbidden e) { + log.warn(e); + dependabot.setStatus(com.axonivy.market.enums.AccessLevel.DISABLED); + } + catch (HttpClientErrorException.NotFound e) { + log.warn(e); + dependabot.setStatus(com.axonivy.market.enums.AccessLevel.NO_PERMISSION); + } + return dependabot; + } + + public static SecretScanning getNumberOfSecretScanningAlerts(GHRepository repo, GHOrganization organization, + String accessToken) { + SecretScanning secretScanning = new SecretScanning(); + try { + ResponseEntity>> response = fetchApiResponseAsList(accessToken, + String.format(GitHubConstants.Url.REPO_SECRET_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName())); + secretScanning.setStatus(com.axonivy.market.enums.AccessLevel.ENABLED); + if (response.getBody() != null) { + secretScanning.setNumberOfAlerts(response.getBody().size()); + } + } + catch (HttpClientErrorException.Forbidden e) { + log.warn(e); + secretScanning.setStatus(com.axonivy.market.enums.AccessLevel.DISABLED); + } + catch (HttpClientErrorException.NotFound e) { + log.warn(e); + secretScanning.setStatus(com.axonivy.market.enums.AccessLevel.NO_PERMISSION); + } + return secretScanning; + } + + public static CodeScanning getCodeScanningAlerts(GHRepository repo, GHOrganization organization, String accessToken) { + CodeScanning codeScanning = new CodeScanning(); + try { + ResponseEntity>> response = fetchApiResponseAsList(accessToken, + String.format(GitHubConstants.Url.REPO_CODE_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName())); + codeScanning.setStatus(com.axonivy.market.enums.AccessLevel.ENABLED); + Map codeScanningMap = new HashMap<>(); + if (response.getBody() != null) { + List> alerts = response.getBody(); + for (Map alert : alerts) { + Object ruleObj = alert.get(GitHubConstants.Json.RULE); + if (ruleObj instanceof Map rule) { + String severity = (String) rule.get(GitHubConstants.Json.SEVERITY); + if (severity != null) { + codeScanningMap.put(severity, codeScanningMap.getOrDefault(severity, 0) + 1); + } + } + } + } + codeScanning.setAlerts(codeScanningMap); + } + catch (HttpClientErrorException.Forbidden e) { + log.warn(e); + codeScanning.setStatus(com.axonivy.market.enums.AccessLevel.DISABLED); + } + catch (HttpClientErrorException.NotFound e) { + log.warn(e); + codeScanning.setStatus(com.axonivy.market.enums.AccessLevel.NO_PERMISSION); + } + return codeScanning; + } + + public static ResponseEntity>> fetchApiResponseAsList(String accessToken, String url) throws RestClientException { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + HttpEntity entity = new HttpEntity<>(headers); + + return new RestTemplate().exchange(url, HttpMethod.GET, entity, new ParameterizedTypeReference<>() { + }); + } } diff --git a/marketplace-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.java b/marketplace-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.java new file mode 100644 index 000000000..15549927a --- /dev/null +++ b/marketplace-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.java @@ -0,0 +1,71 @@ +package com.axonivy.market.controller; + +import com.axonivy.market.enums.ErrorCode; +import com.axonivy.market.exceptions.model.UnauthorizedException; +import com.axonivy.market.github.model.ProductSecurityInfo; +import com.axonivy.market.github.service.GitHubService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.kohsuke.github.GitHub; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import java.util.Arrays; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityMonitorControllerTest { + + @Mock + private GitHubService gitHubService; + + @InjectMocks + private SecurityMonitorController securityMonitorController; + + @Test + void test_getGitHubMarketplaceSecurity() { + String mockToken = "Bearer sample-token"; + ProductSecurityInfo product1 = new ProductSecurityInfo("product1", false, "public", true, new Date(), "abc123", + null, null, null); + + ProductSecurityInfo product2 = new ProductSecurityInfo("product2", false, "private", false, new Date(), "def456", + null, null, null); + List mockSecurityInfoList = Arrays.asList(product1, product2); + + when(gitHubService.getSecurityDetailsForAllProducts(anyString(), anyString())).thenReturn(mockSecurityInfoList); + + ResponseEntity> expectedResponse = new ResponseEntity<>(mockSecurityInfoList, + HttpStatus.OK); + + ResponseEntity actualResponse = securityMonitorController.getGitHubMarketplaceSecurity(mockToken); + + assertEquals(expectedResponse.getStatusCode(), actualResponse.getStatusCode()); + assertEquals(expectedResponse.getBody(), actualResponse.getBody()); + } + + @Test + void test_getGitHubMarketplaceSecurity_shouldReturnUnauthorized_whenInvalidToken() { + String invalidToken = "Bearer invalid-token"; + + doThrow(new UnauthorizedException(ErrorCode.GITHUB_USER_UNAUTHORIZED.getCode(), + ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText())).when(gitHubService) + .validateUserInOrganizationAndTeam(any(String.class), any(String.class), any(String.class)); + + UnauthorizedException exception = assertThrows(UnauthorizedException.class, + () -> securityMonitorController.getGitHubMarketplaceSecurity(invalidToken)); + + assertEquals(ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText(), exception.getMessage()); + } +} diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts index 75edcf0e5..965783c14 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts @@ -15,7 +15,7 @@ describe('SecurityMonitorComponent', () => { const spy = jasmine.createSpyObj('SecurityMonitorService', ['getSecurityDetails']); await TestBed.configureTestingModule({ - imports: [CommonModule, FormsModule], // Import standalone component's dependencies + imports: [CommonModule, FormsModule], providers: [ { provide: SecurityMonitorService, useValue: spy } ] @@ -45,19 +45,17 @@ describe('SecurityMonitorComponent', () => { { repoName: 'repo1', visibility: 'public', archived: false, dependabot: { status: 'ACTIVE', alerts: {} }, codeScanning: { status: 'ENABLED', alerts: {} }, secretsScanning: { status: 'ENABLED', numberOfAlerts: 0 }, branchProtectionEnabled: true, lastCommitSHA: '12345', lastCommitDate: '2024-12-04' }, ]; - // Mock service response securityMonitorService.getSecurityDetails.and.returnValue(of(mockRepos)); component.token = 'valid-token'; component.onSubmit(); - fixture.detectChanges(); // Trigger change detection + fixture.detectChanges(); expect(securityMonitorService.getSecurityDetails).toHaveBeenCalledWith('valid-token'); expect(component.repos).toEqual(mockRepos); expect(component.isAuthenticated).toBeTrue(); - // Check if repo data is rendered const repoCards = fixture.debugElement.queryAll(By.css('.repo-card')); expect(repoCards.length).toBe(mockRepos.length); expect(repoCards[0].nativeElement.querySelector('h3').textContent).toBe('repo1'); @@ -66,13 +64,12 @@ describe('SecurityMonitorComponent', () => { it('should handle error when token is invalid (401 Unauthorized)', () => { const mockError = { status: 401 }; - // Mock service error securityMonitorService.getSecurityDetails.and.returnValue(throwError(() => mockError)); component.token = 'invalid-token'; component.onSubmit(); - fixture.detectChanges(); // Trigger change detection + fixture.detectChanges(); expect(component.errorMessage).toBe('Unauthorized access. (The token should contain the "org:read" scope for authentication)'); expect(component.isLoading).toBeFalse(); @@ -81,13 +78,12 @@ describe('SecurityMonitorComponent', () => { it('should handle generic error when fetching security data fails', () => { const mockError = { status: 500 }; - // Mock service error securityMonitorService.getSecurityDetails.and.returnValue(throwError(() => mockError)); component.token = 'invalid-token'; component.onSubmit(); - fixture.detectChanges(); // Trigger change detection + fixture.detectChanges(); expect(component.errorMessage).toBe('Failed to fetch security data. Check logs for details.'); expect(component.isLoading).toBeFalse(); From fb068b00238073d0d7552dc88331adb00bbabf8c Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Fri, 6 Dec 2024 14:05:57 +0700 Subject: [PATCH 04/13] Handle feedbacks --- .../security-monitor.component.html | 17 ++++----- .../security-monitor.component.scss | 35 ++++++++++++++++++- .../security-monitor.component.ts | 19 +++++++++- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html index 1fd264815..1035620b1 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html @@ -3,19 +3,20 @@

GitHub Repository Security Monitor

Keep track of your repositories' security status at a glance.

+

Reload Security Data

@for (repo of repos; track $index) { -
+
-

{{ repo.repoName }}

+

{{ repo.repoName }}

{{ repo.visibility }} @if (repo.archived) { Archived }
-

🤖 Dependabot: +

🤖Dependabot: @if (repo.dependabot.status == 'DISABLED') { Disabled } @@ -33,7 +34,7 @@

{{ repo.repoName }}

No vulnerabilities }

-

🖥️ Code Scanning: +

🖥️Code Scanning: @if (repo.codeScanning.status == 'DISABLED') { Disabled } @@ -51,7 +52,7 @@

{{ repo.repoName }}

No vulnerabilities }

-

🔑 Secrets Scanning: +

🔑Secret Scanning: @if (repo.secretsScanning.status == 'DISABLED') { Disabled } @@ -64,10 +65,10 @@

{{ repo.repoName }}

} @else { - No alerts + No vulnerabilities }

-

🚧 Branch Protection: +

🚧Branch Protection: @if (repo.branchProtectionEnabled) { Enabled } @@ -75,7 +76,7 @@

{{ repo.repoName }}

Disabled }

-

⏱️ Last Commit: {{ formatCommitDate(repo.lastCommitDate) }}

+

⏱️Last Commit: {{ formatCommitDate(repo.lastCommitDate) }}

} diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss index c4a13627b..42d799e59 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss @@ -36,12 +36,13 @@ body { background-color: #f5f5f5; border-color: #ccc; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); - cursor: pointer; + cursor: default; } .repo-card a:hover { color: #0056b3; text-decoration: underline; + cursor: pointer; } .repo-header { @@ -56,6 +57,11 @@ body { color: #007acc; } + h3:hover { + cursor: pointer; + text-decoration: underline; + } + .visibility { font-size: 1.1rem; padding: 3px 8px; @@ -207,4 +213,31 @@ button .spinner { to { transform: rotate(360deg); } +} + +.reload-link { + text-decoration: none; + color: #007bff; + font-weight: 600; + cursor: pointer; + margin-left: 10px; + transition: color 0.3s ease, transform 0.2s ease; +} + +.reload-link:hover { + text-decoration: underline; + color: #0056b3; + transform: scale(1.05); +} + +.reload-link:focus { + outline: 2px solid #0056b3; +} + +.repo-info .icon { + display: inline-block; + width: 2rem; + text-align: center; + margin-right: 8px; + font-size: 1.5rem; } \ No newline at end of file diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts index 1a6149307..14dc5375f 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts @@ -22,6 +22,21 @@ export class SecurityMonitorComponent { private securityMonitorService = inject(SecurityMonitorService); private readonly githubBaseUrl = 'https://github.com/axonivy-market'; + ngOnInit(): void { + try { + const sessionData = sessionStorage.getItem('security-monitor-data'); + if (sessionData) { + this.repos = JSON.parse(sessionData) as Repo[]; + this.isAuthenticated = true; + } + } catch (error) { + console.error('Failed to parse session data:', error); + sessionStorage.removeItem('security-monitor-data'); + this.repos = []; + this.isAuthenticated = false; + } + } + onSubmit(): void { if (!this.token) { this.errorMessage = 'Token is required'; @@ -42,12 +57,14 @@ export class SecurityMonitorComponent { this.repos = data; this.isAuthenticated = true; this.isLoading = false; + sessionStorage.setItem('security-monitor-token', this.token); + sessionStorage.setItem('security-monitor-data', JSON.stringify(data)); } private handleError(err: any): void { this.errorMessage = err.status === 401 - ? 'Unauthorized access. (The token should contain the "org:read" scope for authentication)' + ? 'Unauthorized access.' : 'Failed to fetch security data. Check logs for details.'; console.error(err); this.isLoading = false; From c03eb6e1e54a795f842ba0ab79803e18a06644f0 Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Fri, 6 Dec 2024 14:15:22 +0700 Subject: [PATCH 05/13] Handle feedbacks --- .../main/java/com/axonivy/market/constants/GitHubConstants.java | 1 + .../main/java/com/axonivy/market/github/util/GitHubUtils.java | 2 +- .../modules/security-monitor/security-monitor.component.html | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java index 3f51bac8b..11767ecb6 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java @@ -20,6 +20,7 @@ public static class Json { public static final String CLIENT_SECRET = "client_secret"; public static final String CODE = "code"; public static final String SEVERITY = "severity"; + public static final String SECURITY_SEVERITY_LEVEL = "security_severity_level"; public static final String SEVERITY_ADVISORY = "security_advisory"; public static final String RULE = "rule"; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java b/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java index 7dd4f04cb..507164bdf 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java @@ -215,7 +215,7 @@ public static CodeScanning getCodeScanningAlerts(GHRepository repo, GHOrganizati for (Map alert : alerts) { Object ruleObj = alert.get(GitHubConstants.Json.RULE); if (ruleObj instanceof Map rule) { - String severity = (String) rule.get(GitHubConstants.Json.SEVERITY); + String severity = (String) rule.get(GitHubConstants.Json.SECURITY_SEVERITY_LEVEL); if (severity != null) { codeScanningMap.put(severity, codeScanningMap.getOrDefault(severity, 0) + 1); } diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html index 1035620b1..0096915e9 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html @@ -3,7 +3,7 @@

GitHub Repository Security Monitor

Keep track of your repositories' security status at a glance.

-

Reload Security Data

+
@for (repo of repos; track $index) { From d5a2306fc155684c921bf43c2d9c6f335ed4b470 Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Tue, 10 Dec 2024 11:55:01 +0700 Subject: [PATCH 06/13] Handle feedbacks --- .../security-monitor.component.html | 25 +++++++------ .../security-monitor.component.scss | 37 +++++++++++++------ .../security-monitor.component.ts | 7 ++++ 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html index 0096915e9..ded4d56d4 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html @@ -1,9 +1,17 @@ +@if (isLoading) { +
+
+
+} +
+
+

GitHub Repository Security Monitor

+

Keep track of your repositories' security status at a glance.

+
@if (isAuthenticated) {
-

GitHub Repository Security Monitor

-

Keep track of your repositories' security status at a glance.

- +

Reload Security Data

@for (repo of repos; track $index) { @@ -86,14 +94,9 @@

{{ repo.repoName }}

Please enter your token to access the security page.

- - @if (errorMessage) {
{{ errorMessage }}
diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss index 42d799e59..cf3a82c65 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss @@ -16,6 +16,10 @@ body { text-align: center; padding: 20px 0; color: #333; + + h2 { + font-size: 4rem; + } } .repo-grid { @@ -152,7 +156,7 @@ body { } .token-input-container h3 { - font-size: 1.6rem; + font-size: 1.5rem; margin-bottom: 20px; color: #333; } @@ -165,16 +169,16 @@ body { .token-input-container input { padding: 10px; - font-size: 1.5rem; + font-size: 1.3rem; margin-bottom: 10px; - width: 400px; + width: 350px; border: 1px solid #ccc; border-radius: 4px; } .token-input-container button { padding: 10px 20px; - font-size: 1.5rem; + font-size: 1.3rem; background-color: #4CAF50; color: white; border: none; @@ -195,15 +199,26 @@ body { width: 100%; } -button .spinner { - display: inline-block; - width: 16px; - height: 16px; - border: 2px solid transparent; - border-top: 2px solid #fff; +.spinner-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.spinner { + border: 4px solid rgba(0, 0, 0, 0.2); + border-top: 4px solid #3498db; border-radius: 50%; + width: 50px; + height: 50px; animation: spin 1s linear infinite; - margin: 0; } @keyframes spin { diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts index 14dc5375f..763097605 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts @@ -38,8 +38,12 @@ export class SecurityMonitorComponent { } onSubmit(): void { + this.token = this.token ?? sessionStorage.getItem('security-monitor-token') ?? ''; if (!this.token) { this.errorMessage = 'Token is required'; + this.isAuthenticated = false; + sessionStorage.removeItem('security-monitor-token'); + sessionStorage.removeItem('security-monitor-data'); return; } @@ -68,6 +72,9 @@ export class SecurityMonitorComponent { : 'Failed to fetch security data. Check logs for details.'; console.error(err); this.isLoading = false; + this.isAuthenticated = false; + sessionStorage.removeItem('security-monitor-token'); + sessionStorage.removeItem('security-monitor-data'); } formatCommitDate(date: string): string { From 6cab5cd4b17e2c851b4eec68bf479358727c1097 Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Tue, 10 Dec 2024 16:46:42 +0700 Subject: [PATCH 07/13] Handle feedbacks --- .../market/constants/GitHubConstants.java | 2 - .../github/model/ProductSecurityInfo.java | 3 +- .../impl/GHAxonIvyProductRepoServiceImpl.java | 3 - .../service/impl/GitHubServiceImpl.java | 15 +- .../market/github/util/GitHubUtils.java | 176 ++++++++---------- .../SecurityMonitorControllerTest.java | 2 - marketplace-ui/src/app/app.routes.ts | 5 + .../security-monitor.component.html | 16 +- .../security-monitor.component.scss | 31 --- .../security-monitor.component.spec.ts | 25 ++- .../security-monitor.component.ts | 46 +---- .../app/shared/constants/common.constant.ts | 1 + ...odel.ts => product-security-info-model.ts} | 6 +- .../src/app/shared/pipes/time-ago.pipe.ts | 2 +- marketplace-ui/src/index.html | 1 - marketplace-ui/src/main.ts | 13 +- 16 files changed, 126 insertions(+), 221 deletions(-) rename marketplace-ui/src/app/shared/models/{security-model.ts => product-security-info-model.ts} (80%) diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java index 11767ecb6..010bc7a3b 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java @@ -28,8 +28,6 @@ public static class Json { @NoArgsConstructor(access = AccessLevel.PRIVATE) public static class Url { private static final String BASE_URL = "https://api.github.com"; - public static final String USER = BASE_URL + "/user"; - public static final String REPO_SECURITY_ADVISORIES = BASE_URL + "/repos/%s/%s/security-advisories?state=%s"; public static final String REPO_DEPENDABOT_ALERTS_OPEN = BASE_URL + "/repos/%s/%s/dependabot/alerts?state=open"; public static final String REPO_SECRET_SCANNING_ALERTS_OPEN = BASE_URL + "/repos/%s/%s/secret-scanning/alerts?state=open"; diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java b/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java index 2f0c136c1..efca212f3 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java @@ -6,7 +6,6 @@ import lombok.Setter; import java.util.Date; -import java.util.Map; @Getter @Setter @@ -20,6 +19,6 @@ public class ProductSecurityInfo { private Date lastCommitDate; private String latestCommitSHA; private Dependabot dependabot; - private SecretScanning secretsScanning; + private SecretScanning secretScanning; private CodeScanning codeScanning; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java index ae3285ca8..1d369842f 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java @@ -4,7 +4,6 @@ import com.axonivy.market.constants.ReadmeConstants; import com.axonivy.market.entity.Product; import com.axonivy.market.entity.ProductModuleContent; -import com.axonivy.market.enums.Language; import com.axonivy.market.github.service.GHAxonIvyProductRepoService; import com.axonivy.market.github.service.GitHubService; import com.axonivy.market.github.util.GitHubUtils; @@ -12,7 +11,6 @@ import com.axonivy.market.service.ImageService; import com.axonivy.market.util.ProductContentUtils; import lombok.extern.log4j.Log4j2; -import org.apache.commons.lang3.StringUtils; import org.kohsuke.github.GHContent; import org.kohsuke.github.GHOrganization; import org.springframework.stereotype.Service; @@ -26,7 +24,6 @@ import java.util.Optional; import static com.axonivy.market.constants.CommonConstants.IMAGE_ID_PREFIX; -import static com.axonivy.market.util.ProductContentUtils.*; @Log4j2 @Service diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java index 51d04d2e5..acf3dfac3 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java @@ -3,17 +3,13 @@ import com.axonivy.market.constants.ErrorMessageConstants; import com.axonivy.market.constants.GitHubConstants; import com.axonivy.market.entity.User; -import com.axonivy.market.enums.AccessLevel; import com.axonivy.market.enums.ErrorCode; import com.axonivy.market.exceptions.model.MissingHeaderException; import com.axonivy.market.exceptions.model.NotFoundException; import com.axonivy.market.exceptions.model.Oauth2ExchangeCodeException; import com.axonivy.market.exceptions.model.UnauthorizedException; -import com.axonivy.market.github.model.CodeScanning; -import com.axonivy.market.github.model.Dependabot; import com.axonivy.market.github.model.GitHubAccessTokenResponse; import com.axonivy.market.github.model.GitHubProperty; -import com.axonivy.market.github.model.SecretScanning; import com.axonivy.market.github.service.GitHubService; import com.axonivy.market.github.model.ProductSecurityInfo; import com.axonivy.market.github.util.GitHubUtils; @@ -28,10 +24,8 @@ import org.kohsuke.github.GHTeam; import org.kohsuke.github.GitHub; import org.kohsuke.github.GitHubBuilder; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; @@ -40,17 +34,12 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import java.io.IOException; -import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -62,7 +51,7 @@ @Service public class GitHubServiceImpl implements GitHubService { - private final RestTemplate restTemplate = new RestTemplate();; + private final RestTemplate restTemplate = new RestTemplate(); private final UserRepository userRepository; private final GitHubProperty gitHubProperty; private final ThreadPoolTaskScheduler taskScheduler; @@ -247,7 +236,7 @@ private ProductSecurityInfo fetchSecurityInfo(GHRepository repo, GHOrganization productSecurityInfo.setLatestCommitSHA(latestCommitSHA); productSecurityInfo.setLastCommitDate(latestCommit.getCommitDate()); productSecurityInfo.setDependabot(GitHubUtils.getDependabotAlerts(repo, organization, accessToken)); - productSecurityInfo.setSecretsScanning(GitHubUtils.getNumberOfSecretScanningAlerts(repo, organization, accessToken)); + productSecurityInfo.setSecretScanning(GitHubUtils.getNumberOfSecretScanningAlerts(repo, organization, accessToken)); productSecurityInfo.setCodeScanning(GitHubUtils.getCodeScanningAlerts(repo, organization, accessToken)); return productSecurityInfo; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java b/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java index 507164bdf..9173d4b9d 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java @@ -33,6 +33,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; import java.util.stream.Collectors; import static com.axonivy.market.constants.MetaConstants.META_FILE; @@ -82,29 +84,6 @@ public static String convertArtifactIdToName(String artifactId) { .collect(Collectors.joining(CommonConstants.SPACE_SEPARATOR)); } - public static String extractMessageFromExceptionMessage(String exceptionMessage) { - String json = extractJson(exceptionMessage); - String key = "\"message\":\""; - int startIndex = json.indexOf(key); - if (startIndex != -1) { - startIndex += key.length(); - int endIndex = json.indexOf("\"", startIndex); - if (endIndex != -1) { - return json.substring(startIndex, endIndex); - } - } - return StringUtils.EMPTY; - } - - public static String extractJson(String text) { - int start = text.indexOf("{"); - int end = text.lastIndexOf("}") + 1; - if (start != -1 && end != -1) { - return text.substring(start, end); - } - return StringUtils.EMPTY; - } - public static int sortMetaJsonFirst(String fileName1, String fileName2) { if (fileName1.endsWith(META_FILE)) return -1; @@ -150,92 +129,95 @@ public static InputStream extractedContentStream(GHContent content) { } public static Dependabot getDependabotAlerts(GHRepository repo, GHOrganization organization, String accessToken) { - Dependabot dependabot = new Dependabot(); - try { - ResponseEntity>> response = fetchApiResponseAsList(accessToken, - String.format(GitHubConstants.Url.REPO_DEPENDABOT_ALERTS_OPEN, organization.getLogin(), repo.getName())); - dependabot.setStatus(com.axonivy.market.enums.AccessLevel.ENABLED); - Map severityMap = new HashMap<>(); - if (response.getBody() != null) { - List> alerts = response.getBody(); - for (Map alert : alerts) { - Object advisoryObj = alert.get(GitHubConstants.Json.SEVERITY_ADVISORY); - if (advisoryObj instanceof Map securityAdvisory) { - String severity = (String) securityAdvisory.get(GitHubConstants.Json.SEVERITY); - if (severity != null) { - severityMap.put(severity, severityMap.getOrDefault(severity, 0) + 1); + return fetchAlerts( + accessToken, + String.format(GitHubConstants.Url.REPO_DEPENDABOT_ALERTS_OPEN, organization.getLogin(), repo.getName()), + (alerts) -> { + Dependabot dependabot = new Dependabot(); + Map severityMap = new HashMap<>(); + for (Map alert : alerts) { + Object advisoryObj = alert.get(GitHubConstants.Json.SEVERITY_ADVISORY); + if (advisoryObj instanceof Map securityAdvisory) { + String severity = (String) securityAdvisory.get(GitHubConstants.Json.SEVERITY); + if (severity != null) { + severityMap.put(severity, severityMap.getOrDefault(severity, 0) + 1); + } } } - } - } - dependabot.setAlerts(severityMap); - } - catch (HttpClientErrorException.Forbidden e) { - log.warn(e); - dependabot.setStatus(com.axonivy.market.enums.AccessLevel.DISABLED); - } - catch (HttpClientErrorException.NotFound e) { - log.warn(e); - dependabot.setStatus(com.axonivy.market.enums.AccessLevel.NO_PERMISSION); - } - return dependabot; + dependabot.setAlerts(severityMap); + return dependabot; + }, + Dependabot::new + ); } - public static SecretScanning getNumberOfSecretScanningAlerts(GHRepository repo, GHOrganization organization, - String accessToken) { - SecretScanning secretScanning = new SecretScanning(); - try { - ResponseEntity>> response = fetchApiResponseAsList(accessToken, - String.format(GitHubConstants.Url.REPO_SECRET_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName())); - secretScanning.setStatus(com.axonivy.market.enums.AccessLevel.ENABLED); - if (response.getBody() != null) { - secretScanning.setNumberOfAlerts(response.getBody().size()); - } - } - catch (HttpClientErrorException.Forbidden e) { - log.warn(e); - secretScanning.setStatus(com.axonivy.market.enums.AccessLevel.DISABLED); - } - catch (HttpClientErrorException.NotFound e) { - log.warn(e); - secretScanning.setStatus(com.axonivy.market.enums.AccessLevel.NO_PERMISSION); - } - return secretScanning; + public static SecretScanning getNumberOfSecretScanningAlerts(GHRepository repo, GHOrganization organization, String accessToken) { + return fetchAlerts( + accessToken, + String.format(GitHubConstants.Url.REPO_SECRET_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName()), + (alerts) -> { + SecretScanning secretScanning = new SecretScanning(); + secretScanning.setNumberOfAlerts(alerts.size()); + return secretScanning; + }, + SecretScanning::new + ); } public static CodeScanning getCodeScanningAlerts(GHRepository repo, GHOrganization organization, String accessToken) { - CodeScanning codeScanning = new CodeScanning(); - try { - ResponseEntity>> response = fetchApiResponseAsList(accessToken, - String.format(GitHubConstants.Url.REPO_CODE_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName())); - codeScanning.setStatus(com.axonivy.market.enums.AccessLevel.ENABLED); - Map codeScanningMap = new HashMap<>(); - if (response.getBody() != null) { - List> alerts = response.getBody(); - for (Map alert : alerts) { - Object ruleObj = alert.get(GitHubConstants.Json.RULE); - if (ruleObj instanceof Map rule) { - String severity = (String) rule.get(GitHubConstants.Json.SECURITY_SEVERITY_LEVEL); - if (severity != null) { - codeScanningMap.put(severity, codeScanningMap.getOrDefault(severity, 0) + 1); + return fetchAlerts( + accessToken, + String.format(GitHubConstants.Url.REPO_CODE_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName()), + (alerts) -> { + CodeScanning codeScanning = new CodeScanning(); + Map codeScanningMap = new HashMap<>(); + for (Map alert : alerts) { + Object ruleObj = alert.get(GitHubConstants.Json.RULE); + if (ruleObj instanceof Map rule) { + String severity = (String) rule.get(GitHubConstants.Json.SECURITY_SEVERITY_LEVEL); + if (severity != null) { + codeScanningMap.put(severity, codeScanningMap.getOrDefault(severity, 0) + 1); + } } } - } - } - codeScanning.setAlerts(codeScanningMap); - } - catch (HttpClientErrorException.Forbidden e) { - log.warn(e); - codeScanning.setStatus(com.axonivy.market.enums.AccessLevel.DISABLED); - } - catch (HttpClientErrorException.NotFound e) { - log.warn(e); - codeScanning.setStatus(com.axonivy.market.enums.AccessLevel.NO_PERMISSION); + codeScanning.setAlerts(codeScanningMap); + return codeScanning; + }, + CodeScanning::new + ); + } + + private static T fetchAlerts( + String accessToken, + String url, + Function>, T> mapAlerts, + Supplier defaultInstanceSupplier + ) { + T instance = defaultInstanceSupplier.get(); + try { + ResponseEntity>> response = fetchApiResponseAsList(accessToken, url); + instance = mapAlerts.apply(response.getBody() != null ? response.getBody() : List.of()); + setStatus(instance, com.axonivy.market.enums.AccessLevel.ENABLED); + } catch (HttpClientErrorException.Forbidden e) { + setStatus(instance, com.axonivy.market.enums.AccessLevel.DISABLED); + } catch (HttpClientErrorException.NotFound e) { + setStatus(instance, com.axonivy.market.enums.AccessLevel.NO_PERMISSION); + } + return instance; + } + + private static void setStatus(Object instance, com.axonivy.market.enums.AccessLevel status) { + if (instance instanceof Dependabot) { + ((Dependabot) instance).setStatus(status); + } else if (instance instanceof SecretScanning) { + ((SecretScanning) instance).setStatus(status); + } else if (instance instanceof CodeScanning) { + ((CodeScanning) instance).setStatus(status); } - return codeScanning; } - public static ResponseEntity>> fetchApiResponseAsList(String accessToken, String url) throws RestClientException { + public static ResponseEntity>> fetchApiResponseAsList(String accessToken, + String url) throws RestClientException { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); HttpEntity entity = new HttpEntity<>(headers); diff --git a/marketplace-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.java b/marketplace-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.java index 15549927a..c86878cfc 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.java @@ -6,13 +6,11 @@ import com.axonivy.market.github.service.GitHubService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.kohsuke.github.GitHub; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.client.RestTemplate; import java.util.Arrays; import java.util.Date; diff --git a/marketplace-ui/src/app/app.routes.ts b/marketplace-ui/src/app/app.routes.ts index 93f3b9b41..1106a8f60 100644 --- a/marketplace-ui/src/app/app.routes.ts +++ b/marketplace-ui/src/app/app.routes.ts @@ -3,6 +3,7 @@ import { GithubCallbackComponent } from './auth/github-callback/github-callback. import { ErrorPageComponent } from './shared/components/error-page/error-page.component'; import { RedirectPageComponent } from './shared/components/redirect-page/redirect-page.component'; import { ERROR_PAGE } from './shared/constants/common.constant'; +import { SecurityMonitorComponent } from './modules/security-monitor/security-monitor.component'; export const routes: Routes = [ { @@ -15,6 +16,10 @@ export const routes: Routes = [ component: ErrorPageComponent, title: ERROR_PAGE }, + { + path: 'security-monitor', + component: SecurityMonitorComponent + }, { path: '', loadChildren: () => import('./modules/home/home.routes').then(m => m.routes) diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html index ded4d56d4..3b3a2f6ae 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html @@ -1,9 +1,3 @@ -@if (isLoading) { -
-
-
-} -

GitHub Repository Security Monitor

@@ -61,15 +55,15 @@

{{ repo.repoName }}

}

🔑Secret Scanning: - @if (repo.secretsScanning.status == 'DISABLED') { + @if (repo.secretScanning.status == 'DISABLED') { Disabled } - @else if (repo.secretsScanning.status == 'NO_PERMISSION') { + @else if (repo.secretScanning.status == 'NO_PERMISSION') { No permission } - @else if (repo.secretsScanning.numberOfAlerts) { + @else if (repo.secretScanning.numberOfAlerts) { - {{ repo.secretsScanning.numberOfAlerts }} alerts + {{ repo.secretScanning.numberOfAlerts }} alerts } @else { @@ -84,7 +78,7 @@

{{ repo.repoName }}

Disabled }

-

⏱️Last Commit: {{ formatCommitDate(repo.lastCommitDate) }}

+

⏱️Last Commit: {{ repo.lastCommitDate | timeAgo | async }}

} diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss index cf3a82c65..6515e7b5a 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss @@ -199,37 +199,6 @@ body { width: 100%; } -.spinner-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(255, 255, 255, 0.8); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.spinner { - border: 4px solid rgba(0, 0, 0, 0.2); - border-top: 4px solid #3498db; - border-radius: 50%; - width: 50px; - height: 50px; - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - .reload-link { text-decoration: none; color: #007bff; diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts index 965783c14..956e81cb7 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts @@ -5,6 +5,8 @@ import { of, throwError } from 'rxjs'; import { FormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { By } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; +import { ProductSecurityInfo } from '../../shared/models/product-security-info-model'; describe('SecurityMonitorComponent', () => { let component: SecurityMonitorComponent; @@ -17,7 +19,8 @@ describe('SecurityMonitorComponent', () => { await TestBed.configureTestingModule({ imports: [CommonModule, FormsModule], providers: [ - { provide: SecurityMonitorService, useValue: spy } + { provide: SecurityMonitorService, useValue: spy }, + { provide: TranslateService, useValue: spy } ] }).compileComponents(); @@ -41,8 +44,16 @@ describe('SecurityMonitorComponent', () => { }); it('should call SecurityMonitorService and display repos when token is valid and response is successful', () => { - const mockRepos = [ - { repoName: 'repo1', visibility: 'public', archived: false, dependabot: { status: 'ACTIVE', alerts: {} }, codeScanning: { status: 'ENABLED', alerts: {} }, secretsScanning: { status: 'ENABLED', numberOfAlerts: 0 }, branchProtectionEnabled: true, lastCommitSHA: '12345', lastCommitDate: '2024-12-04' }, + const mockRepos: ProductSecurityInfo[] = [ + { + repoName: 'repo1', visibility: 'public', + archived: false, dependabot: { status: 'ENABLED', alerts: {} }, + codeScanning: { status: 'ENABLED', alerts: {} }, + secretScanning: { status: 'ENABLED', numberOfAlerts: 0 }, + branchProtectionEnabled: true, + lastCommitSHA: '12345', + lastCommitDate: new Date() + }, ]; securityMonitorService.getSecurityDetails.and.returnValue(of(mockRepos)); @@ -63,7 +74,7 @@ describe('SecurityMonitorComponent', () => { it('should handle error when token is invalid (401 Unauthorized)', () => { const mockError = { status: 401 }; - + securityMonitorService.getSecurityDetails.and.returnValue(throwError(() => mockError)); component.token = 'invalid-token'; @@ -71,8 +82,7 @@ describe('SecurityMonitorComponent', () => { fixture.detectChanges(); - expect(component.errorMessage).toBe('Unauthorized access. (The token should contain the "org:read" scope for authentication)'); - expect(component.isLoading).toBeFalse(); + expect(component.errorMessage).toBe('Unauthorized access.'); }); it('should handle generic error when fetching security data fails', () => { @@ -86,6 +96,5 @@ describe('SecurityMonitorComponent', () => { fixture.detectChanges(); expect(component.errorMessage).toBe('Failed to fetch security data. Check logs for details.'); - expect(component.isLoading).toBeFalse(); }); -}); +}); \ No newline at end of file diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts index 763097605..7a54fa792 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts @@ -1,13 +1,15 @@ import { Component, inject, ViewEncapsulation } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { SecurityMonitorService } from './security-monitor.service'; -import { Repo } from '../../shared/models/security-model'; +import { ProductSecurityInfo } from '../../shared/models/product-security-info-model'; import { CommonModule } from '@angular/common'; +import { TimeAgoPipe } from '../../shared/pipes/time-ago.pipe'; +import { GITHUB_MARKET_ORG_URL, UNAUTHORIZED } from '../../shared/constants/common.constant'; @Component({ selector: 'app-security-monitor', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, TimeAgoPipe], templateUrl: './security-monitor.component.html', styleUrls: ['./security-monitor.component.scss'], encapsulation: ViewEncapsulation.Emulated, @@ -16,17 +18,15 @@ export class SecurityMonitorComponent { isAuthenticated = false; token = ''; errorMessage = ''; - repos: Repo[] = []; - isLoading = false; + repos: ProductSecurityInfo[] = []; private securityMonitorService = inject(SecurityMonitorService); - private readonly githubBaseUrl = 'https://github.com/axonivy-market'; ngOnInit(): void { try { const sessionData = sessionStorage.getItem('security-monitor-data'); if (sessionData) { - this.repos = JSON.parse(sessionData) as Repo[]; + this.repos = JSON.parse(sessionData) as ProductSecurityInfo[]; this.isAuthenticated = true; } } catch (error) { @@ -48,59 +48,31 @@ export class SecurityMonitorComponent { } this.errorMessage = ''; - this.isLoading = true; this.securityMonitorService.getSecurityDetails(this.token).subscribe({ next: (data) => this.handleSuccess(data), error: (err) => this.handleError(err), - complete: () => (this.isLoading = false), }); } - private handleSuccess(data: Repo[]): void { + private handleSuccess(data: ProductSecurityInfo[]): void { this.repos = data; this.isAuthenticated = true; - this.isLoading = false; sessionStorage.setItem('security-monitor-token', this.token); sessionStorage.setItem('security-monitor-data', JSON.stringify(data)); } private handleError(err: any): void { this.errorMessage = - err.status === 401 + err.status === UNAUTHORIZED ? 'Unauthorized access.' : 'Failed to fetch security data. Check logs for details.'; console.error(err); - this.isLoading = false; this.isAuthenticated = false; sessionStorage.removeItem('security-monitor-token'); sessionStorage.removeItem('security-monitor-data'); } - formatCommitDate(date: string): string { - const now = Date.now(); - const targetDate = new Date(date).getTime(); - const diffInSeconds = Math.floor((now - targetDate) / 1000); - - if (diffInSeconds < 60) return 'just now'; - const diffInMinutes = Math.floor(diffInSeconds / 60); - if (diffInMinutes < 60) return this.formatTime(diffInMinutes, 'minute'); - const diffInHours = Math.floor(diffInMinutes / 60); - if (diffInHours < 24) return this.formatTime(diffInHours, 'hour'); - const diffInDays = Math.floor(diffInHours / 24); - if (diffInDays < 7) return this.formatTime(diffInDays, 'day'); - const diffInWeeks = Math.floor(diffInDays / 7); - if (diffInWeeks < 4) return this.formatTime(diffInWeeks, 'week'); - const diffInMonths = Math.floor(diffInDays / 30); - if (diffInMonths < 12) return this.formatTime(diffInMonths, 'month'); - const diffInYears = Math.floor(diffInMonths / 12); - return this.formatTime(diffInYears, 'year'); - } - - private formatTime(value: number, unit: string): string { - return `${value} ${unit}${value > 1 ? 's' : ''} ago`; - } - hasAlerts(alerts: Record): boolean { return Object.keys(alerts).length > 0; } @@ -110,7 +82,7 @@ export class SecurityMonitorComponent { } navigateToPage(repoName: string, path: string, additionalPath: string = ''): void { - const url = `${this.githubBaseUrl}/${repoName}${path}${additionalPath}`; + const url = `${GITHUB_MARKET_ORG_URL}/${repoName}${path}${additionalPath}`; window.open(url, '_blank'); } diff --git a/marketplace-ui/src/app/shared/constants/common.constant.ts b/marketplace-ui/src/app/shared/constants/common.constant.ts index 7631e713b..f2bd52812 100644 --- a/marketplace-ui/src/app/shared/constants/common.constant.ts +++ b/marketplace-ui/src/app/shared/constants/common.constant.ts @@ -236,6 +236,7 @@ export const TOKEN_KEY = 'token'; export const DEFAULT_IMAGE_URL = '/assets/images/misc/axonivy-logo-round.png'; export const DOWNLOAD_URL = 'https://developer.axonivy.com/download'; export const SEARCH_URL = 'https://developer.axonivy.com/search'; +export const GITHUB_MARKET_ORG_URL = 'https://github.com/axonivy-market'; export const SHOW_DEV_VERSION = "showDevVersions"; export const DEFAULT_VENDOR_IMAGE = '/assets/images/misc/axonivy-logo.svg'; export const DEFAULT_VENDOR_IMAGE_BLACK = '/assets/images/misc/axonivy-logo-black.svg'; diff --git a/marketplace-ui/src/app/shared/models/security-model.ts b/marketplace-ui/src/app/shared/models/product-security-info-model.ts similarity index 80% rename from marketplace-ui/src/app/shared/models/security-model.ts rename to marketplace-ui/src/app/shared/models/product-security-info-model.ts index 0cacda4d8..a7b15eabe 100644 --- a/marketplace-ui/src/app/shared/models/security-model.ts +++ b/marketplace-ui/src/app/shared/models/product-security-info-model.ts @@ -1,4 +1,4 @@ -export interface Repo { +export interface ProductSecurityInfo { repoName: string; visibility: string; archived: boolean; @@ -10,11 +10,11 @@ export interface Repo { status: string; alerts: Record; }; - secretsScanning: { + secretScanning: { status: string; numberOfAlerts: number; }; branchProtectionEnabled: boolean; lastCommitSHA: string; - lastCommitDate: string; + lastCommitDate: Date; } \ No newline at end of file diff --git a/marketplace-ui/src/app/shared/pipes/time-ago.pipe.ts b/marketplace-ui/src/app/shared/pipes/time-ago.pipe.ts index d3aa7c9dc..0f38206a5 100644 --- a/marketplace-ui/src/app/shared/pipes/time-ago.pipe.ts +++ b/marketplace-ui/src/app/shared/pipes/time-ago.pipe.ts @@ -18,7 +18,7 @@ import { TimeAgo } from '../enums/time-ago.enum'; }) export class TimeAgoPipe implements PipeTransform { translateService = inject(TranslateService); - async transform(value?: Date, language?: Language, _args?: []): Promise { + async transform(value?: Date, language: Language = Language.EN, _args?: []): Promise { if (value === undefined || language === undefined) { return ''; } diff --git a/marketplace-ui/src/index.html b/marketplace-ui/src/index.html index c62abb7e0..2076a2de0 100644 --- a/marketplace-ui/src/index.html +++ b/marketplace-ui/src/index.html @@ -11,7 +11,6 @@ - \ No newline at end of file diff --git a/marketplace-ui/src/main.ts b/marketplace-ui/src/main.ts index 866d9fbc4..5086f383d 100644 --- a/marketplace-ui/src/main.ts +++ b/marketplace-ui/src/main.ts @@ -3,14 +3,7 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { AppComponent } from './app/app.component'; -import { SecurityMonitorComponent } from './app/modules/security-monitor/security-monitor.component'; -import { provideHttpClient } from '@angular/common/http'; -const currentPath = window.location.pathname; -if (currentPath.startsWith('/security-monitor')) { - bootstrapApplication(SecurityMonitorComponent, { - providers: [provideHttpClient()] - }).catch(err => console.error(err)); -} else { - bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); -} +bootstrapApplication(AppComponent, appConfig).catch(err => { + throw err; +}); \ No newline at end of file From d37e64b6e2c505e62d781ffd820d466f6cb0a4e8 Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Tue, 10 Dec 2024 16:50:36 +0700 Subject: [PATCH 08/13] Update GitHubUtilsTest.java --- .../axonivy/market/util/GitHubUtilsTest.java | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/marketplace-service/src/test/java/com/axonivy/market/util/GitHubUtilsTest.java b/marketplace-service/src/test/java/com/axonivy/market/util/GitHubUtilsTest.java index d6276d841..522dc85e6 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/util/GitHubUtilsTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/util/GitHubUtilsTest.java @@ -40,40 +40,4 @@ void testSortMetaJsonFirst() { result = GitHubUtils.sortMetaJsonFirst(LOGO_FILE, LOGO_FILE); Assertions.assertEquals(0, result); } - - @Test - void testExtractJson() { - // Test case: valid JSON inside a string - String exceptionMessage = "Error occurred: {\"message\":\"An error occurred\"}"; - String json = GitHubUtils.extractJson(exceptionMessage); - Assertions.assertEquals("{\"message\":\"An error occurred\"}", json); - - // Test case: no JSON in string - exceptionMessage = "Error occurred: no json here"; - json = GitHubUtils.extractJson(exceptionMessage); - Assertions.assertEquals(StringUtils.EMPTY, json); - - // Test case: empty string - exceptionMessage = StringUtils.EMPTY; - json = GitHubUtils.extractJson(exceptionMessage); - Assertions.assertEquals(StringUtils.EMPTY, json); - } - - @Test - void testExtractMessageFromExceptionMessage() { - // Test case: valid message extraction - String exceptionMessage = "Some error occurred: {\"message\":\"Invalid input data\"}"; - String extractedMessage = GitHubUtils.extractMessageFromExceptionMessage(exceptionMessage); - Assertions.assertEquals("Invalid input data", extractedMessage); - - // Test case: no message key - exceptionMessage = "Some error occurred: {\"error\":\"Something went wrong\"}"; - extractedMessage = GitHubUtils.extractMessageFromExceptionMessage(exceptionMessage); - Assertions.assertEquals(StringUtils.EMPTY, extractedMessage); - - // Test case: empty exception message - exceptionMessage = ""; - extractedMessage = GitHubUtils.extractMessageFromExceptionMessage(exceptionMessage); - Assertions.assertEquals(StringUtils.EMPTY, extractedMessage); - } } From ff89fb9fafc539f72e0bc8a0138a742368e86e08 Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Tue, 10 Dec 2024 17:04:22 +0700 Subject: [PATCH 09/13] Fix sonar issues --- .../market/github/util/GitHubUtils.java | 18 +++++++++--------- .../security-monitor.component.html | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java b/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java index 9173d4b9d..12e27a640 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java @@ -132,7 +132,7 @@ public static Dependabot getDependabotAlerts(GHRepository repo, GHOrganization o return fetchAlerts( accessToken, String.format(GitHubConstants.Url.REPO_DEPENDABOT_ALERTS_OPEN, organization.getLogin(), repo.getName()), - (alerts) -> { + alerts -> { Dependabot dependabot = new Dependabot(); Map severityMap = new HashMap<>(); for (Map alert : alerts) { @@ -155,7 +155,7 @@ public static SecretScanning getNumberOfSecretScanningAlerts(GHRepository repo, return fetchAlerts( accessToken, String.format(GitHubConstants.Url.REPO_SECRET_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName()), - (alerts) -> { + alerts -> { SecretScanning secretScanning = new SecretScanning(); secretScanning.setNumberOfAlerts(alerts.size()); return secretScanning; @@ -168,7 +168,7 @@ public static CodeScanning getCodeScanningAlerts(GHRepository repo, GHOrganizati return fetchAlerts( accessToken, String.format(GitHubConstants.Url.REPO_CODE_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName()), - (alerts) -> { + alerts -> { CodeScanning codeScanning = new CodeScanning(); Map codeScanningMap = new HashMap<>(); for (Map alert : alerts) { @@ -207,12 +207,12 @@ private static T fetchAlerts( } private static void setStatus(Object instance, com.axonivy.market.enums.AccessLevel status) { - if (instance instanceof Dependabot) { - ((Dependabot) instance).setStatus(status); - } else if (instance instanceof SecretScanning) { - ((SecretScanning) instance).setStatus(status); - } else if (instance instanceof CodeScanning) { - ((CodeScanning) instance).setStatus(status); + if (instance instanceof Dependabot dependabot) { + dependabot.setStatus(status); + } else if (instance instanceof SecretScanning secretScanning) { + secretScanning.setStatus(status); + } else if (instance instanceof CodeScanning codeScanning) { + codeScanning.setStatus(status); } } diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html index 3b3a2f6ae..c348eaa97 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html @@ -5,7 +5,7 @@

Keep track of your repositories' security status at a glance.

@if (isAuthenticated) {
@for (repo of repos; track $index) { From 97be794e98b2dd618644a3e1dc749d39ddcebce8 Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Tue, 10 Dec 2024 18:24:18 +0700 Subject: [PATCH 10/13] Fix sonars --- .../security-monitor.component.ts | 121 ++++++++++-------- .../security-monitor.service.ts | 5 +- 2 files changed, 71 insertions(+), 55 deletions(-) diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts index 7a54fa792..fbd6a47b7 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts @@ -1,8 +1,10 @@ import { Component, inject, ViewEncapsulation } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; + import { SecurityMonitorService } from './security-monitor.service'; import { ProductSecurityInfo } from '../../shared/models/product-security-info-model'; -import { CommonModule } from '@angular/common'; import { TimeAgoPipe } from '../../shared/pipes/time-ago.pipe'; import { GITHUB_MARKET_ORG_URL, UNAUTHORIZED } from '../../shared/constants/common.constant'; @@ -20,57 +22,70 @@ export class SecurityMonitorComponent { errorMessage = ''; repos: ProductSecurityInfo[] = []; - private securityMonitorService = inject(SecurityMonitorService); + private readonly securityMonitorService = inject(SecurityMonitorService); + private readonly sessionKeys = { + data: 'security-monitor-data', + token: 'security-monitor-token', + }; ngOnInit(): void { - try { - const sessionData = sessionStorage.getItem('security-monitor-data'); - if (sessionData) { - this.repos = JSON.parse(sessionData) as ProductSecurityInfo[]; - this.isAuthenticated = true; - } - } catch (error) { - console.error('Failed to parse session data:', error); - sessionStorage.removeItem('security-monitor-data'); - this.repos = []; - this.isAuthenticated = false; - } + this.loadSessionData(); } onSubmit(): void { - this.token = this.token ?? sessionStorage.getItem('security-monitor-token') ?? ''; + this.token = this.token || sessionStorage.getItem(this.sessionKeys.token) || ''; if (!this.token) { - this.errorMessage = 'Token is required'; - this.isAuthenticated = false; - sessionStorage.removeItem('security-monitor-token'); - sessionStorage.removeItem('security-monitor-data'); + this.handleMissingToken(); return; } this.errorMessage = ''; + this.fetchSecurityDetails(); + } + private loadSessionData(): void { + const sessionData = sessionStorage.getItem(this.sessionKeys.data); + if (sessionData) { + try { + this.repos = JSON.parse(sessionData) as ProductSecurityInfo[]; + this.isAuthenticated = true; + } catch (error) { + this.clearSessionData(); + } + } + } + + private handleMissingToken(): void { + this.errorMessage = 'Token is required'; + this.isAuthenticated = false; + this.clearSessionData(); + } + + private fetchSecurityDetails(): void { this.securityMonitorService.getSecurityDetails(this.token).subscribe({ next: (data) => this.handleSuccess(data), - error: (err) => this.handleError(err), + error: (err: HttpErrorResponse) => this.handleError(err), }); } private handleSuccess(data: ProductSecurityInfo[]): void { this.repos = data; this.isAuthenticated = true; - sessionStorage.setItem('security-monitor-token', this.token); - sessionStorage.setItem('security-monitor-data', JSON.stringify(data)); + sessionStorage.setItem(this.sessionKeys.token, this.token); + sessionStorage.setItem(this.sessionKeys.data, JSON.stringify(data)); } - private handleError(err: any): void { - this.errorMessage = - err.status === UNAUTHORIZED - ? 'Unauthorized access.' - : 'Failed to fetch security data. Check logs for details.'; - console.error(err); + private handleError(err: HttpErrorResponse): void { + this.errorMessage = err.status === UNAUTHORIZED + ? 'Unauthorized access.' + : 'Failed to fetch security data. Check logs for details.'; this.isAuthenticated = false; - sessionStorage.removeItem('security-monitor-token'); - sessionStorage.removeItem('security-monitor-data'); + this.clearSessionData(); + } + + private clearSessionData(): void { + sessionStorage.removeItem(this.sessionKeys.token); + sessionStorage.removeItem(this.sessionKeys.data); } hasAlerts(alerts: Record): boolean { @@ -81,32 +96,32 @@ export class SecurityMonitorComponent { return Object.keys(alerts); } - navigateToPage(repoName: string, path: string, additionalPath: string = ''): void { + navigateToPage(repoName: string, path: string, additionalPath = ''): void { const url = `${GITHUB_MARKET_ORG_URL}/${repoName}${path}${additionalPath}`; window.open(url, '_blank'); } - navigateToRepoSecurityPage(repoName: string): void { - this.navigateToPage(repoName, '/security'); - } - - navigateToRepoDependabotPage(repoName: string): void { - this.navigateToPage(repoName, '/security/dependabot'); - } - - navigateToRepoCodeScanningPage(repoName: string): void { - this.navigateToPage(repoName, '/security/code-scanning'); - } - - navigateToRepoSecurityScanningPage(repoName: string): void { - this.navigateToPage(repoName, '/security/security-scanning'); - } - - navigateToRepoLastCommitPage(repoName: string, lastCommitSHA: string): void { - this.navigateToPage(repoName, '/commit/', lastCommitSHA); - } - - navigateToRepoBranchesSettingPage(repoName: string): void { - this.navigateToPage(repoName, '/settings/branches'); + navigateToRepoPage(repoName: string, page: RepoPage, lastCommitSHA?: string): void { + const paths: Record = { + security: '/security', + dependabot: '/security/dependabot', + codeScanning: '/security/code-scanning', + securityScanning: '/security/security-scanning', + branches: '/settings/branches', + lastCommit: `/commit/${lastCommitSHA || ''}`, + }; + + const path = paths[page]; + if (path) { + this.navigateToPage(repoName, path); + } } } + +type RepoPage = + | 'security' + | 'dependabot' + | 'codeScanning' + | 'securityScanning' + | 'branches' + | 'lastCommit'; diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.ts index d7d5a23de..b2b15ffb5 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.ts +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { environment } from '../../../environments/environment'; +import { ProductSecurityInfo } from '../../shared/models/product-security-info-model'; @Injectable({ providedIn: 'root' @@ -11,8 +12,8 @@ export class SecurityMonitorService { private readonly apiUrl = environment.apiUrl + '/api/security-monitor'; private readonly http = inject(HttpClient); - getSecurityDetails(token: string): Observable { + getSecurityDetails(token: string): Observable { const headers = new HttpHeaders().set('Authorization', `Bearer ${token}`); - return this.http.get(this.apiUrl, { headers }); + return this.http.get(this.apiUrl, { headers }); } } From 8c61d56d962c942648614f183b22277f96388e0d Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Tue, 10 Dec 2024 18:34:53 +0700 Subject: [PATCH 11/13] Sonar fixes --- .../security-monitor/security-monitor.component.html | 12 ++++++------ .../security-monitor/security-monitor.component.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html index c348eaa97..7d55ad8a5 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html @@ -11,14 +11,14 @@

Reload Data< @for (repo of repos; track $index) {
-

{{ repo.repoName }}

+

{{ repo.repoName }}

{{ repo.visibility }} @if (repo.archived) { Archived }
-

🤖Dependabot: +

🤖Dependabot: @if (repo.dependabot.status == 'DISABLED') { Disabled } @@ -36,7 +36,7 @@

{{ repo.repoName }}

No vulnerabilities }

-

🖥️Code Scanning: +

🖥️Code Scanning: @if (repo.codeScanning.status == 'DISABLED') { Disabled } @@ -54,7 +54,7 @@

{{ repo.repoName }}

No vulnerabilities }

-

🔑Secret Scanning: +

🔑Secret Scanning: @if (repo.secretScanning.status == 'DISABLED') { Disabled } @@ -70,7 +70,7 @@

{{ repo.repoName }}

No vulnerabilities }

-

🚧Branch Protection: +

🚧Branch Protection: @if (repo.branchProtectionEnabled) { Enabled } @@ -78,7 +78,7 @@

{{ repo.repoName }}

Disabled }

-

⏱️Last Commit: {{ repo.lastCommitDate | timeAgo | async }}

+

⏱️Last Commit: {{ repo.lastCommitDate | timeAgo | async }}

} diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts index fbd6a47b7..6413d6e42 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts @@ -106,7 +106,7 @@ export class SecurityMonitorComponent { security: '/security', dependabot: '/security/dependabot', codeScanning: '/security/code-scanning', - securityScanning: '/security/security-scanning', + secretScanning: '/security/secret-scanning', branches: '/settings/branches', lastCommit: `/commit/${lastCommitSHA || ''}`, }; @@ -122,6 +122,6 @@ type RepoPage = | 'security' | 'dependabot' | 'codeScanning' - | 'securityScanning' + | 'secretScanning' | 'branches' | 'lastCommit'; From 98cf4246f783e3f786cafa038d9755b655a84a98 Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Tue, 10 Dec 2024 18:49:28 +0700 Subject: [PATCH 12/13] Update security-monitor.component.ts --- .../security-monitor.component.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts index 6413d6e42..310bda25a 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts @@ -33,7 +33,7 @@ export class SecurityMonitorComponent { } onSubmit(): void { - this.token = this.token || sessionStorage.getItem(this.sessionKeys.token) || ''; + this.token = this.token ?? sessionStorage.getItem(this.sessionKeys.token) ?? ''; if (!this.token) { this.handleMissingToken(); return; @@ -63,7 +63,7 @@ export class SecurityMonitorComponent { private fetchSecurityDetails(): void { this.securityMonitorService.getSecurityDetails(this.token).subscribe({ - next: (data) => this.handleSuccess(data), + next: data => this.handleSuccess(data), error: (err: HttpErrorResponse) => this.handleError(err), }); } @@ -76,9 +76,12 @@ export class SecurityMonitorComponent { } private handleError(err: HttpErrorResponse): void { - this.errorMessage = err.status === UNAUTHORIZED - ? 'Unauthorized access.' - : 'Failed to fetch security data. Check logs for details.'; + if (err.status === UNAUTHORIZED ) { + this.errorMessage = 'Unauthorized access.'; + } + else { + this.errorMessage = 'Failed to fetch security data. Check logs for details.'; + } this.isAuthenticated = false; this.clearSessionData(); } @@ -108,7 +111,7 @@ export class SecurityMonitorComponent { codeScanning: '/security/code-scanning', secretScanning: '/security/secret-scanning', branches: '/settings/branches', - lastCommit: `/commit/${lastCommitSHA || ''}`, + lastCommit: `/commit/${lastCommitSHA ?? ''}`, }; const path = paths[page]; From b7e4f6f997f0297d8eaddcabad1143933974fdd8 Mon Sep 17 00:00:00 2001 From: Khanh Nguyen Date: Tue, 10 Dec 2024 23:08:39 +0700 Subject: [PATCH 13/13] Update test UI --- .../product-detail.service.spec.ts | 83 +++++++++++++------ .../security-monitor.component.spec.ts | 58 +++++++++---- .../security-monitor.service.spec.ts | 77 +++++++++++++++++ 3 files changed, 178 insertions(+), 40 deletions(-) create mode 100644 marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts index aae29c669..b7f944104 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts @@ -1,47 +1,80 @@ import { TestBed } from '@angular/core/testing'; -import { ProductDetailService } from './product-detail.service'; -import { DisplayValue } from '../../../shared/models/display-value.model'; -import { HttpClient, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { environment } from '../../../../environments/environment'; +import { ProductSecurityInfo } from '../../../shared/models/product-security-info-model'; +import { SecurityMonitorService } from '../../security-monitor/security-monitor.service'; +import { SecurityMonitorComponent } from '../../security-monitor/security-monitor.component'; -describe('ProductDetailService', () => { - let service: ProductDetailService; +describe('SecurityMonitorService', () => { + let service: SecurityMonitorService; let httpMock: HttpTestingController; - let httpClient: jasmine.SpyObj; + + const mockApiUrl = environment.apiUrl + '/api/security-monitor'; beforeEach(() => { TestBed.configureTestingModule({ - providers: [ProductDetailService, + imports: [SecurityMonitorComponent], + providers: [ + SecurityMonitorService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), - { provide: HttpClient, useValue: httpClient } - ] + ], }); - service = TestBed.inject(ProductDetailService); + + service = TestBed.inject(SecurityMonitorService); httpMock = TestBed.inject(HttpTestingController); }); + afterEach(() => { + httpMock.verify(); + }); + it('should be created', () => { expect(service).toBeTruthy(); }); - it('should have a default productId signal', () => { - expect(service.productId()).toBe(''); - }); + it('should call API with token and return security details', () => { + const mockToken = 'valid-token'; + const mockResponse: ProductSecurityInfo[] = [ + { + repoName: 'repo1', + visibility: 'public', + archived: false, + dependabot: { status: 'ENABLED', alerts: {} }, + codeScanning: { status: 'ENABLED', alerts: {} }, + secretScanning: { status: 'ENABLED', numberOfAlerts: 0 }, + branchProtectionEnabled: true, + lastCommitSHA: '12345', + lastCommitDate: new Date(), + }, + ]; - it('should update productId signal', () => { - const newProductId = '12345'; - service.productId.set(newProductId); - expect(service.productId()).toBe(newProductId); - }); + service.getSecurityDetails(mockToken).subscribe((data) => { + expect(data).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(mockApiUrl); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockToken}`); - it('should have a default productNames signal', () => { - expect(service.productNames()).toEqual({} as DisplayValue); + req.flush(mockResponse); }); - it('should update productNames signal', () => { - const newProductNames: DisplayValue = { en: 'en', de: 'de' }; - service.productNames.set(newProductNames); - expect(service.productNames()).toEqual(newProductNames); + it('should handle error response gracefully', () => { + const mockToken = 'invalid-token'; + + service.getSecurityDetails(mockToken).subscribe({ + next: () => fail('Expected an error, but received data.'), + error: (error) => { + expect(error.status).toBe(401); + }, + }); + + const req = httpMock.expectOne(mockApiUrl); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockToken}`); + + req.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); }); -}); \ No newline at end of file +}); diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts index 956e81cb7..e9b2b9de4 100644 --- a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts @@ -5,8 +5,9 @@ import { of, throwError } from 'rxjs'; import { FormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { By } from '@angular/platform-browser'; -import { TranslateService } from '@ngx-translate/core'; import { ProductSecurityInfo } from '../../shared/models/product-security-info-model'; +import { HttpErrorResponse } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; describe('SecurityMonitorComponent', () => { let component: SecurityMonitorComponent; @@ -21,7 +22,7 @@ describe('SecurityMonitorComponent', () => { providers: [ { provide: SecurityMonitorService, useValue: spy }, { provide: TranslateService, useValue: spy } - ] + ], }).compileComponents(); securityMonitorService = TestBed.inject(SecurityMonitorService) as jasmine.SpyObj; @@ -37,7 +38,7 @@ describe('SecurityMonitorComponent', () => { expect(component).toBeTruthy(); }); - it('should show error message when token is empty and onSubmit is called', () => { + it('should show an error message when token is empty and onSubmit is called', () => { component.token = ''; component.onSubmit(); expect(component.errorMessage).toBe('Token is required'); @@ -45,14 +46,16 @@ describe('SecurityMonitorComponent', () => { it('should call SecurityMonitorService and display repos when token is valid and response is successful', () => { const mockRepos: ProductSecurityInfo[] = [ - { - repoName: 'repo1', visibility: 'public', - archived: false, dependabot: { status: 'ENABLED', alerts: {} }, - codeScanning: { status: 'ENABLED', alerts: {} }, - secretScanning: { status: 'ENABLED', numberOfAlerts: 0 }, - branchProtectionEnabled: true, - lastCommitSHA: '12345', - lastCommitDate: new Date() + { + repoName: 'repo1', + visibility: 'public', + archived: false, + dependabot: { status: 'ENABLED', alerts: {} }, + codeScanning: { status: 'ENABLED', alerts: {} }, + secretScanning: { status: 'ENABLED', numberOfAlerts: 0 }, + branchProtectionEnabled: true, + lastCommitSHA: '12345', + lastCommitDate: new Date(), }, ]; @@ -72,8 +75,8 @@ describe('SecurityMonitorComponent', () => { expect(repoCards[0].nativeElement.querySelector('h3').textContent).toBe('repo1'); }); - it('should handle error when token is invalid (401 Unauthorized)', () => { - const mockError = { status: 401 }; + it('should handle 401 Unauthorized error correctly', () => { + const mockError = new HttpErrorResponse({ status: 401 }); securityMonitorService.getSecurityDetails.and.returnValue(throwError(() => mockError)); @@ -85,8 +88,8 @@ describe('SecurityMonitorComponent', () => { expect(component.errorMessage).toBe('Unauthorized access.'); }); - it('should handle generic error when fetching security data fails', () => { - const mockError = { status: 500 }; + it('should handle generic error correctly', () => { + const mockError = new HttpErrorResponse({ status: 500 }); securityMonitorService.getSecurityDetails.and.returnValue(throwError(() => mockError)); @@ -97,4 +100,29 @@ describe('SecurityMonitorComponent', () => { expect(component.errorMessage).toBe('Failed to fetch security data. Check logs for details.'); }); + + it('should navigate to the correct URL for a repo page', () => { + spyOn(window, 'open'); + component.navigateToRepoPage('example-repo', 'secretScanning'); + expect(window.open).toHaveBeenCalledWith( + 'https://github.com/axonivy-market/example-repo/security/secret-scanning', + '_blank' + ); + + component.navigateToRepoPage('example-repo', 'lastCommit', 'abc123'); + expect(window.open).toHaveBeenCalledWith( + 'https://github.com/axonivy-market/example-repo/commit/abc123', + '_blank' + ); + }); + + it('should handle empty alerts correctly in hasAlerts', () => { + expect(component.hasAlerts({})).toBeFalse(); + expect(component.hasAlerts({ alert1: 1 })).toBeTrue(); + }); + + it('should return correct alert keys from alertKeys', () => { + const alerts = { alert1: 1, alert2: 2 }; + expect(component.alertKeys(alerts)).toEqual(['alert1', 'alert2']); + }); }); \ No newline at end of file diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts new file mode 100644 index 000000000..8301aed8d --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts @@ -0,0 +1,77 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { SecurityMonitorService } from './security-monitor.service'; +import { environment } from '../../../environments/environment'; +import { ProductSecurityInfo } from '../../shared/models/product-security-info-model'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; + +describe('SecurityMonitorService', () => { + let service: SecurityMonitorService; + let httpMock: HttpTestingController; + + const mockApiUrl = environment.apiUrl + '/api/security-monitor'; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SecurityMonitorService, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting() + ] + }); + service = TestBed.inject(SecurityMonitorService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call API with token and return security details', () => { + const mockToken = 'valid-token'; + const mockResponse: ProductSecurityInfo[] = [ + { + repoName: 'repo1', + visibility: 'public', + archived: false, + dependabot: { status: 'ENABLED', alerts: {} }, + codeScanning: { status: 'ENABLED', alerts: {} }, + secretScanning: { status: 'ENABLED', numberOfAlerts: 0 }, + branchProtectionEnabled: true, + lastCommitSHA: '12345', + lastCommitDate: new Date(), + }, + ]; + + service.getSecurityDetails(mockToken).subscribe((data) => { + expect(data).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(mockApiUrl); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockToken}`); + + req.flush(mockResponse); + }); + + it('should handle error response gracefully', () => { + const mockToken = 'invalid-token'; + + service.getSecurityDetails(mockToken).subscribe({ + next: () => fail('Expected an error, but received data.'), + error: (error) => { + expect(error.status).toBe(401); + }, + }); + + const req = httpMock.expectOne(mockApiUrl); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockToken}`); + + req.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); + }); +}); \ No newline at end of file