From a50762f32246ff696bcd1d5d9f05eb046dc7e450 Mon Sep 17 00:00:00 2001 From: Dinh Nguyen <127725498+ntqdinh-axonivy@users.noreply.github.com> Date: Fri, 13 Dec 2024 08:51:41 +0700 Subject: [PATCH 1/5] MARP-1577 marketplace cannot install market item on the web browser of the designer (#253) --- marketplace-ui/src/app/app.component.spec.ts | 14 ++-- marketplace-ui/src/app/app.component.ts | 4 +- .../src/app/auth/auth.service.spec.ts | 1 - .../core/interceptors/api.interceptor.spec.ts | 4 +- ...oduct-detail-version-action.component.html | 8 ++- ...product-detail-version-action.component.ts | 2 +- .../product-detail.component.spec.ts | 6 +- .../product-detail.component.ts | 2 +- .../modules/product/product.component.spec.ts | 14 ++-- .../app/modules/product/product.component.ts | 9 +-- .../app/shared/constants/common.constant.ts | 2 +- .../routing.query.param.service.spec.ts | 65 +++++++++---------- .../services/routing.query.param.service.ts | 43 ++++++------ 13 files changed, 90 insertions(+), 84 deletions(-) diff --git a/marketplace-ui/src/app/app.component.spec.ts b/marketplace-ui/src/app/app.component.spec.ts index 776d58bf3..0a55ca2b6 100644 --- a/marketplace-ui/src/app/app.component.spec.ts +++ b/marketplace-ui/src/app/app.component.spec.ts @@ -31,8 +31,8 @@ describe('AppComponent', () => { [ 'getNavigationStartEvent', 'isDesignerEnv', - 'checkCookieForDesignerEnv', - 'checkCookieForDesignerVersion' + 'checkSessionStorageForDesignerEnv', + 'checkSessionStorageForDesignerVersion' ] ); @@ -89,7 +89,7 @@ describe('AppComponent', () => { expect(component).toBeTruthy(); }); - it('should subscribe to query params and check cookies if not in designer environment', () => { + it('should subscribe to query params and check session strorage if not in designer environment', () => { routingQueryParamService.isDesignerEnv.and.returnValue(false); const params = { someParam: 'someValue' }; @@ -103,10 +103,10 @@ describe('AppComponent', () => { navigationStartSubject.next(new NavigationStart(1, 'testUrl')); expect( - routingQueryParamService.checkCookieForDesignerEnv + routingQueryParamService.checkSessionStorageForDesignerEnv ).toHaveBeenCalledWith(params); expect( - routingQueryParamService.checkCookieForDesignerVersion + routingQueryParamService.checkSessionStorageForDesignerVersion ).toHaveBeenCalledWith(params); }); @@ -117,10 +117,10 @@ describe('AppComponent', () => { navigationStartSubject.next(new NavigationStart(1, 'testUrl')); expect( - routingQueryParamService.checkCookieForDesignerEnv + routingQueryParamService.checkSessionStorageForDesignerEnv ).not.toHaveBeenCalled(); expect( - routingQueryParamService.checkCookieForDesignerVersion + routingQueryParamService.checkSessionStorageForDesignerVersion ).not.toHaveBeenCalled(); }); diff --git a/marketplace-ui/src/app/app.component.ts b/marketplace-ui/src/app/app.component.ts index 67b0d9dff..fb13b0105 100644 --- a/marketplace-ui/src/app/app.component.ts +++ b/marketplace-ui/src/app/app.component.ts @@ -40,8 +40,8 @@ export class AppComponent { this.routingQueryParamService.getNavigationStartEvent().subscribe(() => { if (!this.routingQueryParamService.isDesignerEnv()) { this.route.queryParams.subscribe(params => { - this.routingQueryParamService.checkCookieForDesignerEnv(params); - this.routingQueryParamService.checkCookieForDesignerVersion(params); + this.routingQueryParamService.checkSessionStorageForDesignerEnv(params); + this.routingQueryParamService.checkSessionStorageForDesignerVersion(params); }); } }); diff --git a/marketplace-ui/src/app/auth/auth.service.spec.ts b/marketplace-ui/src/app/auth/auth.service.spec.ts index 8d7b07fe9..af558eb4b 100644 --- a/marketplace-ui/src/app/auth/auth.service.spec.ts +++ b/marketplace-ui/src/app/auth/auth.service.spec.ts @@ -4,7 +4,6 @@ import { Router } from '@angular/router'; import { CookieService } from 'ngx-cookie-service'; import { AuthService } from './auth.service'; import { environment } from '../../environments/environment'; -import { of } from 'rxjs'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { TOKEN_KEY } from '../shared/constants/common.constant'; diff --git a/marketplace-ui/src/app/core/interceptors/api.interceptor.spec.ts b/marketplace-ui/src/app/core/interceptors/api.interceptor.spec.ts index d09151d5a..5ef65a1ee 100644 --- a/marketplace-ui/src/app/core/interceptors/api.interceptor.spec.ts +++ b/marketplace-ui/src/app/core/interceptors/api.interceptor.spec.ts @@ -5,7 +5,7 @@ import { ActivatedRoute } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; import { ProductComponent } from '../../modules/product/product.component'; -import { DESIGNER_COOKIE_VARIABLE } from '../../shared/constants/common.constant'; +import { DESIGNER_SESSION_STORAGE_VARIABLE } from '../../shared/constants/common.constant'; import { apiInterceptor } from './api.interceptor'; import { MatomoTestingModule } from 'ngx-matomo-client/testing'; @@ -29,7 +29,7 @@ describe('AuthInterceptor', () => { provide: ActivatedRoute, useValue: { queryParams: of({ - [DESIGNER_COOKIE_VARIABLE.restClientParamName]: true + [DESIGNER_SESSION_STORAGE_VARIABLE.restClientParamName]: true }) } } diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.html b/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.html index 4fb9bca6c..37f94b6f4 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.html +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.html @@ -131,10 +131,14 @@ const selectedItemElement = document.querySelector('.install-designer-dropdown'); if (selectedItemElement) { const metaDataJsonUrl = selectedItemElement.getAttribute('metaDataJsonUrl'); - install(metaDataJsonUrl); + try { + install(metaDataJsonUrl); + } catch (error) { + event.stopImmediatePropagation(); + } } } - installInDesigner();" [ngClass]="themeService.isDarkMode() ? 'btn-light' : 'btn-primary'"> + installInDesigner(event);" [ngClass]="themeService.isDarkMode() ? 'btn-light' : 'btn-primary'"> {{ 'common.product.detail.install.buttonLabelInDesigner' | translate }} diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.ts index 5437585cf..856bf6037 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.ts @@ -234,7 +234,7 @@ export class ProductDetailVersionActionComponent implements AfterViewInit { this.productService .sendRequestToUpdateInstallationCount( this.productId, - this.routingQueryParamService.getDesignerVersionFromCookie() + this.routingQueryParamService.getDesignerVersionFromSessionStorage() ) .subscribe((data: number) => this.installationCount.emit(data)); } diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.spec.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.spec.ts index 1587fc146..d379ea5dc 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.spec.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.spec.ts @@ -45,7 +45,7 @@ describe('ProductDetailComponent', () => { beforeEach(async () => { const routingQueryParamServiceSpy = jasmine.createSpyObj( 'RoutingQueryParamService', - ['getDesignerVersionFromCookie', 'isDesignerEnv'] + ['getDesignerVersionFromSessionStorage', 'isDesignerEnv'] ); const languageServiceSpy = jasmine.createSpyObj( @@ -130,10 +130,10 @@ describe('ProductDetailComponent', () => { expect(component.selectedVersion).toEqual('Version 10.0.0'); }); - it('should get corresponding version from cookie', () => { + it('should get corresponding version from session strorage', () => { const targetVersion = '1.0'; const productId = 'Portal'; - routingQueryParamService.getDesignerVersionFromCookie.and.returnValue( + routingQueryParamService.getDesignerVersionFromSessionStorage.and.returnValue( targetVersion ); component.getProductById(productId, false).subscribe(productDetail => { diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.ts index 6daf53420..eda8701dc 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.ts @@ -204,7 +204,7 @@ export class ProductDetailComponent { } getProductById(productId: string, isShowDevVersion: boolean): Observable { - const targetVersion = this.routingQueryParamService.getDesignerVersionFromCookie(); + const targetVersion = this.routingQueryParamService.getDesignerVersionFromSessionStorage(); let productDetail$: Observable; if (!targetVersion) { productDetail$ = this.productService.getProductDetails(productId, isShowDevVersion); diff --git a/marketplace-ui/src/app/modules/product/product.component.spec.ts b/marketplace-ui/src/app/modules/product/product.component.spec.ts index 31d4414dd..6f5d6841f 100644 --- a/marketplace-ui/src/app/modules/product/product.component.spec.ts +++ b/marketplace-ui/src/app/modules/product/product.component.spec.ts @@ -15,7 +15,7 @@ import { ProductComponent } from './product.component'; import { ProductService } from './product.service'; import { MockProductService } from '../../shared/mocks/mock-services'; import { RoutingQueryParamService } from '../../shared/services/routing.query.param.service'; -import { DESIGNER_COOKIE_VARIABLE } from '../../shared/constants/common.constant'; +import { DESIGNER_SESSION_STORAGE_VARIABLE } from '../../shared/constants/common.constant'; import { ItemDropdown } from '../../shared/models/item-dropdown.model'; import { By } from '@angular/platform-browser'; import { Location } from '@angular/common'; @@ -59,8 +59,8 @@ describe('ProductComponent', () => { 'getNavigationStartEvent', 'isDesigner', 'isDesignerEnv', - 'checkCookieForDesignerEnv', - 'checkCookieForDesignerVersion' + 'checkSessionStorageForDesignerEnv', + 'checkSessionStorageForDesignerVersion' ] ); @@ -75,7 +75,7 @@ describe('ProductComponent', () => { provide: ActivatedRoute, useValue: { queryParams: of({ - [DESIGNER_COOKIE_VARIABLE.restClientParamName]: true + [DESIGNER_SESSION_STORAGE_VARIABLE.restClientParamName]: true }) } }, @@ -210,7 +210,7 @@ describe('ProductComponent', () => { it('should set isRESTClient true based on query params and designer environment', () => { component.route.queryParams = of({ - [DESIGNER_COOKIE_VARIABLE.restClientParamName]: 'resultsOnly', + [DESIGNER_SESSION_STORAGE_VARIABLE.restClientParamName]: 'resultsOnly', }); routingQueryParamService.isDesignerEnv.and.returnValue(true); @@ -222,8 +222,8 @@ describe('ProductComponent', () => { it('should not display marketplace introduction in designer', () => { component.route.queryParams = of({ - [DESIGNER_COOKIE_VARIABLE.restClientParamName]: 'resultsOnly', - [DESIGNER_COOKIE_VARIABLE.searchParamName]: 'search' + [DESIGNER_SESSION_STORAGE_VARIABLE.restClientParamName]: 'resultsOnly', + [DESIGNER_SESSION_STORAGE_VARIABLE.searchParamName]: 'search' }); component.isDesignerEnvironment = true; diff --git a/marketplace-ui/src/app/modules/product/product.component.ts b/marketplace-ui/src/app/modules/product/product.component.ts index 46a8eac23..a041ae197 100644 --- a/marketplace-ui/src/app/modules/product/product.component.ts +++ b/marketplace-ui/src/app/modules/product/product.component.ts @@ -31,7 +31,7 @@ import { RoutingQueryParamService } from '../../shared/services/routing.query.pa import { DEFAULT_PAGEABLE, DEFAULT_PAGEABLE_IN_REST_CLIENT, - DESIGNER_COOKIE_VARIABLE + DESIGNER_SESSION_STORAGE_VARIABLE } from '../../shared/constants/common.constant'; import { ItemDropdown } from '../../shared/models/item-dropdown.model'; @@ -80,12 +80,13 @@ export class ProductComponent implements AfterViewInit, OnDestroy { constructor() { this.route.queryParams.subscribe(params => { this.isRESTClient.set( - DESIGNER_COOKIE_VARIABLE.restClientParamName in params && + DESIGNER_SESSION_STORAGE_VARIABLE.restClientParamName in params && this.isDesignerEnvironment ); - if (params[DESIGNER_COOKIE_VARIABLE.searchParamName] != null) { - this.criteria.search = params[DESIGNER_COOKIE_VARIABLE.searchParamName]; + if (params[DESIGNER_SESSION_STORAGE_VARIABLE.searchParamName] != null) { + this.criteria.search = + params[DESIGNER_SESSION_STORAGE_VARIABLE.searchParamName]; } }); diff --git a/marketplace-ui/src/app/shared/constants/common.constant.ts b/marketplace-ui/src/app/shared/constants/common.constant.ts index 7631e713b..f50652674 100644 --- a/marketplace-ui/src/app/shared/constants/common.constant.ts +++ b/marketplace-ui/src/app/shared/constants/common.constant.ts @@ -182,7 +182,7 @@ export const FEEDBACK_SORT_TYPES: ItemDropdown[] = [ } ]; -export const DESIGNER_COOKIE_VARIABLE = { +export const DESIGNER_SESSION_STORAGE_VARIABLE = { ivyViewerParamName: 'ivy-viewer', ivyVersionParamName: 'ivy-version', defaultDesignerViewer: 'designer-market', diff --git a/marketplace-ui/src/app/shared/services/routing.query.param.service.spec.ts b/marketplace-ui/src/app/shared/services/routing.query.param.service.spec.ts index 2bd01ae23..34ba79e3e 100644 --- a/marketplace-ui/src/app/shared/services/routing.query.param.service.spec.ts +++ b/marketplace-ui/src/app/shared/services/routing.query.param.service.spec.ts @@ -1,20 +1,15 @@ import { TestBed } from '@angular/core/testing'; import { Router, NavigationStart } from '@angular/router'; -import { CookieService } from 'ngx-cookie-service'; import { RoutingQueryParamService } from './routing.query.param.service'; import { Subject } from 'rxjs'; -import { DESIGNER_COOKIE_VARIABLE } from '../constants/common.constant'; +import { DESIGNER_SESSION_STORAGE_VARIABLE } from '../constants/common.constant'; describe('RoutingQueryParamService', () => { let service: RoutingQueryParamService; - let cookieService: jasmine.SpyObj; let eventsSubject: Subject; + let mockStorage: { [key: string]: string }; beforeEach(() => { - const cookieServiceSpy = jasmine.createSpyObj('CookieService', [ - 'get', - 'set' - ]); eventsSubject = new Subject(); const routerSpy = jasmine.createSpyObj('Router', [], { events: eventsSubject.asObservable() @@ -23,59 +18,61 @@ describe('RoutingQueryParamService', () => { TestBed.configureTestingModule({ providers: [ RoutingQueryParamService, - { provide: CookieService, useValue: cookieServiceSpy }, { provide: Router, useValue: routerSpy } ] }); service = TestBed.inject(RoutingQueryParamService); - cookieService = TestBed.inject( - CookieService - ) as jasmine.SpyObj; + mockStorage = { + 'ivy-viewer': 'designer-market', + 'ivy-version': '1.0' + }; + spyOn(sessionStorage, 'getItem').and.callFake((key: string) => { + return mockStorage[key] || null; + }); + + spyOn(sessionStorage, 'setItem').and.callFake((key: string, value: string) => { + mockStorage[key] = value; + }); }); it('should be created', () => { expect(service).toBeTruthy(); }); - it('should check cookie for designer version', () => { - const params = { [DESIGNER_COOKIE_VARIABLE.ivyVersionParamName]: '1.0' }; - service.checkCookieForDesignerVersion(params); - expect(cookieService.set).toHaveBeenCalledWith( - DESIGNER_COOKIE_VARIABLE.ivyVersionParamName, + it('should check session storage for designer version', () => { + const params = { + [DESIGNER_SESSION_STORAGE_VARIABLE.ivyVersionParamName]: '1.0' + }; + service.checkSessionStorageForDesignerVersion(params); + expect(sessionStorage.setItem).toHaveBeenCalledWith( + DESIGNER_SESSION_STORAGE_VARIABLE.ivyVersionParamName, '1.0' ); - expect(service.getDesignerVersionFromCookie()).toBe('1.0'); + expect(service.getDesignerVersionFromSessionStorage()).toBe('1.0'); }); - it('should check cookie for designer env', () => { + it('should check session storage for designer env', () => { const params = { - [DESIGNER_COOKIE_VARIABLE.ivyViewerParamName]: - DESIGNER_COOKIE_VARIABLE.defaultDesignerViewer + [DESIGNER_SESSION_STORAGE_VARIABLE.ivyViewerParamName]: + DESIGNER_SESSION_STORAGE_VARIABLE.defaultDesignerViewer }; - service.checkCookieForDesignerEnv(params); - expect(cookieService.set).toHaveBeenCalledWith( - DESIGNER_COOKIE_VARIABLE.ivyViewerParamName, - DESIGNER_COOKIE_VARIABLE.defaultDesignerViewer + service.checkSessionStorageForDesignerEnv(params); + expect(sessionStorage.setItem).toHaveBeenCalledWith( + DESIGNER_SESSION_STORAGE_VARIABLE.ivyViewerParamName, + DESIGNER_SESSION_STORAGE_VARIABLE.defaultDesignerViewer ); expect(service.isDesignerViewer()).toBeTrue(); }); - it('should get designer version from cookie if not set', () => { - cookieService.get.and.returnValue('1.0'); - expect(service.getDesignerVersionFromCookie()).toBe('1.0'); + it('should get designer version from session storage if not set', () => { + expect(service.getDesignerVersionFromSessionStorage()).toBe('1.0'); }); - it('should set isDesigner to true if cookie matches default designer viewer', () => { - cookieService.get.and.returnValue( - DESIGNER_COOKIE_VARIABLE.defaultDesignerViewer - ); + it('should set isDesigner to true if session storage matches default designer viewer', () => { expect(service.isDesignerViewer()).toBeTrue(); }); it('should listen to navigation start events', () => { - cookieService.get.and.returnValue( - DESIGNER_COOKIE_VARIABLE.defaultDesignerViewer - ); eventsSubject.next(new NavigationStart(1, 'testUrl')); expect(service.isDesignerViewer()).toBeTrue(); }); diff --git a/marketplace-ui/src/app/shared/services/routing.query.param.service.ts b/marketplace-ui/src/app/shared/services/routing.query.param.service.ts index 21568d314..47ccc4eee 100644 --- a/marketplace-ui/src/app/shared/services/routing.query.param.service.ts +++ b/marketplace-ui/src/app/shared/services/routing.query.param.service.ts @@ -1,6 +1,5 @@ import { computed, Injectable, signal } from '@angular/core'; -import { CookieService } from 'ngx-cookie-service'; -import { DESIGNER_COOKIE_VARIABLE } from '../constants/common.constant'; +import { DESIGNER_SESSION_STORAGE_VARIABLE } from '../constants/common.constant'; import { Router, Params, NavigationStart } from '@angular/router'; import { Observable } from 'rxjs'; import { filter } from 'rxjs/operators'; @@ -13,46 +12,51 @@ export class RoutingQueryParamService { designerVersion = signal(''); constructor( - private readonly cookieService: CookieService, private readonly router: Router ) { this.getNavigationStartEvent().subscribe(() => { if (!this.isDesigner()) { this.isDesigner.set( - this.cookieService.get( - DESIGNER_COOKIE_VARIABLE.ivyViewerParamName - ) === DESIGNER_COOKIE_VARIABLE.defaultDesignerViewer + sessionStorage.getItem( + DESIGNER_SESSION_STORAGE_VARIABLE.ivyViewerParamName + ) === DESIGNER_SESSION_STORAGE_VARIABLE.defaultDesignerViewer ); } }); } - checkCookieForDesignerVersion(params: Params) { - const versionParam = params[DESIGNER_COOKIE_VARIABLE.ivyVersionParamName]; + checkSessionStorageForDesignerVersion(params: Params) { + const versionParam = + params[DESIGNER_SESSION_STORAGE_VARIABLE.ivyVersionParamName]; if (versionParam !== undefined) { - this.cookieService.set( - DESIGNER_COOKIE_VARIABLE.ivyVersionParamName, + sessionStorage.setItem( + DESIGNER_SESSION_STORAGE_VARIABLE.ivyVersionParamName, versionParam ); this.designerVersion.set(versionParam); } } - checkCookieForDesignerEnv(params: Params) { - const ivyViewerParam = params[DESIGNER_COOKIE_VARIABLE.ivyViewerParamName]; - if (ivyViewerParam === DESIGNER_COOKIE_VARIABLE.defaultDesignerViewer) { - this.cookieService.set( - DESIGNER_COOKIE_VARIABLE.ivyViewerParamName, + checkSessionStorageForDesignerEnv(params: Params) { + const ivyViewerParam = + params[DESIGNER_SESSION_STORAGE_VARIABLE.ivyViewerParamName]; + if ( + ivyViewerParam === DESIGNER_SESSION_STORAGE_VARIABLE.defaultDesignerViewer + ) { + sessionStorage.setItem( + DESIGNER_SESSION_STORAGE_VARIABLE.ivyViewerParamName, ivyViewerParam ); this.isDesigner.set(true); } } - getDesignerVersionFromCookie() { + getDesignerVersionFromSessionStorage() { if (this.designerVersion() === '') { this.designerVersion.set( - this.cookieService.get(DESIGNER_COOKIE_VARIABLE.ivyVersionParamName) + sessionStorage.getItem( + DESIGNER_SESSION_STORAGE_VARIABLE.ivyVersionParamName + ) ?? '' ); } return this.designerVersion(); @@ -61,8 +65,9 @@ export class RoutingQueryParamService { isDesignerViewer() { if (!this.isDesigner()) { this.isDesigner.set( - this.cookieService.get(DESIGNER_COOKIE_VARIABLE.ivyViewerParamName) === - DESIGNER_COOKIE_VARIABLE.defaultDesignerViewer + sessionStorage.getItem( + DESIGNER_SESSION_STORAGE_VARIABLE.ivyViewerParamName + ) === DESIGNER_SESSION_STORAGE_VARIABLE.defaultDesignerViewer ); } return this.isDesigner(); From d89c12adb16c86141a22eb5139ab2f557daba457 Mon Sep 17 00:00:00 2001 From: Pham Hoang Hung <84316773+phhung-axonivy@users.noreply.github.com> Date: Fri, 13 Dec 2024 14:12:47 +0700 Subject: [PATCH 2/5] MARP-997 recent sorting should be based on first publish date (#255) --- .../constants/RequestMappingConstants.java | 2 +- .../market/controller/ProductController.java | 19 +++ .../com/axonivy/market/entity/Product.java | 1 + .../com/axonivy/market/enums/SortOption.java | 2 +- .../market/service/ProductService.java | 2 +- .../service/impl/ProductServiceImpl.java | 93 +++++++++++--- .../java/com/axonivy/market/BaseSetup.java | 1 + .../controller/ProductControllerTest.java | 28 +++++ .../service/impl/ProductServiceImplTest.java | 113 +++++++++++++++++- 9 files changed, 237 insertions(+), 24 deletions(-) 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..30ac7fe39 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 @@ -13,7 +13,7 @@ public class RequestMappingConstants { public static final String FEEDBACK = API + "/feedback"; public static final String IMAGE = API + "/image"; public static final String SYNC = "sync"; - public static final String SYNC_PRODUCT_VERSION = SYNC + "/product-version"; + public static final String SYNC_FIRST_PUBLISHED_DATE_ALL_PRODUCTS = SYNC + "/first-published-date"; public static final String SYNC_ONE_PRODUCT_BY_ID = "sync/{id}"; public static final String SWAGGER_URL = "/swagger-ui/index.html"; public static final String GIT_HUB_LOGIN = "/github/login"; diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductController.java index 4598b8087..44f767ba2 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductController.java +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductController.java @@ -145,6 +145,25 @@ public ResponseEntity syncOneProduct( return new ResponseEntity<>(message, HttpStatus.OK); } + @PutMapping(SYNC_FIRST_PUBLISHED_DATE_ALL_PRODUCTS) + @Operation(hidden = true) + public ResponseEntity syncFirstPublishedDateOfAllProducts( + @RequestHeader(value = AUTHORIZATION) String authorizationHeader) { + String token = AuthorizationUtils.getBearerToken(authorizationHeader); + gitHubService.validateUserInOrganizationAndTeam(token, GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME, + GitHubConstants.AXONIVY_MARKET_TEAM_NAME); + + var message = new Message(); + var isSuccess = productService.syncFirstPublishedDateOfAllProducts(); + if (isSuccess) { + message.setHelpCode(ErrorCode.SUCCESSFUL.getCode()); + message.setMessageDetails("Sync successfully!"); + } else { + message.setMessageDetails("Sync unsuccessfully!"); + } + return new ResponseEntity<>(message, HttpStatus.OK); + } + @SuppressWarnings("unchecked") private ResponseEntity> generateEmptyPagedModel() { var emptyPagedModel = (PagedModel) pagedResourcesAssembler.toEmptyModel(Page.empty(), diff --git a/marketplace-service/src/main/java/com/axonivy/market/entity/Product.java b/marketplace-service/src/main/java/com/axonivy/market/entity/Product.java index 85fc62925..bfe5eeac5 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/entity/Product.java +++ b/marketplace-service/src/main/java/com/axonivy/market/entity/Product.java @@ -64,6 +64,7 @@ public class Product implements Serializable { @Transient private int installationCount; private Date newestPublishedDate; + private Date firstPublishedDate; private String newestReleaseVersion; @Transient private ProductModuleContent productModuleContent; diff --git a/marketplace-service/src/main/java/com/axonivy/market/enums/SortOption.java b/marketplace-service/src/main/java/com/axonivy/market/enums/SortOption.java index 104e18b0a..d46ba9711 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/enums/SortOption.java +++ b/marketplace-service/src/main/java/com/axonivy/market/enums/SortOption.java @@ -11,7 +11,7 @@ public enum SortOption { POPULARITY("popularity", "marketplaceData.installationCount", Sort.Direction.DESC), ALPHABETICALLY("alphabetically", "names", Sort.Direction.ASC), - RECENT("recent", "newestPublishedDate", Sort.Direction.DESC), + RECENT("recent", "firstPublishedDate", Sort.Direction.DESC), STANDARD("standard", "marketplaceData.customOrder", Sort.Direction.DESC), ID("id", "_id", Sort.Direction.ASC); diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/ProductService.java b/marketplace-service/src/main/java/com/axonivy/market/service/ProductService.java index 3e88d59d9..800b37434 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/ProductService.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/ProductService.java @@ -21,5 +21,5 @@ public interface ProductService { boolean syncOneProduct(String productId, String marketItemPath, Boolean overrideMarketItemPath); - void clearAllProductVersion(); + boolean syncFirstPublishedDateOfAllProducts(); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductServiceImpl.java index 24de86232..c26a41dd2 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductServiceImpl.java @@ -41,6 +41,7 @@ import org.kohsuke.github.GHCommit; import org.kohsuke.github.GHContent; import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GHTag; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -57,15 +58,7 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; +import java.util.*; import static com.axonivy.market.constants.CommonConstants.SLASH; import static com.axonivy.market.constants.MavenConstants.*; @@ -370,8 +363,8 @@ private List syncProductsFromGitHubRepo(Boolean resetSync) { } else if (productRepo.findById(product.getId()).isPresent()) { continue; } - updateProductContentForNonStandardProduct(ghContentEntity.getValue(), product); + updateFirstPublishedDate(product); updateProductFromReleasedVersions(product); transferComputedDataFromDB(product); productMarketplaceDataRepo.checkAndInitProductMarketplaceDataIfNotExist(product.getId()); @@ -412,6 +405,53 @@ private String mapVendorImage(String productId, GHContent ghContent, String imag return EMPTY; } + private void updateFirstPublishedDate(Product product) { + try { + if (StringUtils.isNotBlank(product.getRepositoryName())) { + List gitHubTags = gitHubService.getRepositoryTags(product.getRepositoryName()); + Date firstTagPublishedDate = getFirstTagPublishedDate(gitHubTags); + product.setFirstPublishedDate(firstTagPublishedDate); + } + } catch (IOException e) { + log.error("Get GH Tags failed: ", e); + } + } + + private Date getFirstTagPublishedDate(List gitHubTags) { + Date firstTagPublishedDate = null; + try { + if (!CollectionUtils.isEmpty(gitHubTags)) { + List sortedTags = sortByTagCommitDate(gitHubTags); + GHCommit commit = sortedTags.get(0).getCommit(); + if (commit != null) { + firstTagPublishedDate = commit.getCommitDate(); + } + } + } catch (IOException e) { + log.error("Get first tag published date failed: ", e); + } + + return firstTagPublishedDate; + } + + private List sortByTagCommitDate(List gitHubTags) { + List sortedTags = new ArrayList<>(gitHubTags); + sortedTags.sort(Comparator.comparing(this::sortByCommitDate, Comparator.nullsLast(Comparator.naturalOrder()))); + return sortedTags; + } + + private Date sortByCommitDate(GHTag gitHubTag) { + Date commitDate = null; + try { + if (gitHubTag.getCommit() != null) { + commitDate = gitHubTag.getCommit().getCommitDate(); + } + } catch (IOException e) { + log.error("Get commit date of tag commit failed: ", e); + } + return commitDate; + } + private void updateProductFromReleasedVersions(Product product) { if (ObjectUtils.isEmpty(product.getArtifacts())) { return; @@ -536,7 +576,6 @@ private String createProductArtifactId(Artifact mavenArtifact) { : mavenArtifact.getArtifactId().concat(PRODUCT_ARTIFACT_POSTFIX); } - // Cover 3 cases after removing non-numeric characters (8, 11.1 and 10.0.2) @Override public String getCompatibilityFromOldestVersion(String oldestVersion) { @@ -628,6 +667,7 @@ public boolean syncOneProduct(String productId, String marketItemPath, Boolean o log.info("Update data of product {} from meta.json and logo files", productId); mappingMetaDataAndLogoFromGHContent(gitHubContents, product); updateProductContentForNonStandardProduct(gitHubContents, product); + updateFirstPublishedDate(product); updateProductFromReleasedVersions(product); productMarketplaceDataRepo.checkAndInitProductMarketplaceDataIfNotExist(productId); productRepo.save(product); @@ -640,13 +680,6 @@ public boolean syncOneProduct(String productId, String marketItemPath, Boolean o return false; } - @Override - public void clearAllProductVersion() { - metadataRepo.deleteAll(); - metadataSyncRepo.deleteAll(); - mavenArtifactVersionRepo.deleteAll(); - } - private Product renewProductById(String productId, String marketItemPath, Boolean overrideMarketItemPath) { Product product = new Product(); productRepo.findById(productId).ifPresent(foundProduct -> { @@ -692,4 +725,28 @@ private void updateProductContentForNonStandardProduct(List ghContent productModuleContentRepo.save(initialContent); } } + + @Override + public boolean syncFirstPublishedDateOfAllProducts() { + try { + List products = productRepo.findAll(); + if (!CollectionUtils.isEmpty(products)) { + for (Product product : products) { + if (product.getFirstPublishedDate() == null) { + log.info("sync FirstPublishedDate of product {} is starting ...", product.getId()); + updateFirstPublishedDate(product); + productRepo.save(product); + log.info("Sync FirstPublishedDate of product {} is finished!", product.getId()); + } else { + log.info("FirstPublishedDate of product {} is existing!", product.getId()); + } + } + } + log.info("sync FirstPublishedDate of all products is finished!"); + return true; + } catch (Exception e) { + log.error(e.getStackTrace()); + return false; + } + } } \ No newline at end of file diff --git a/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java b/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java index 0e494e824..971097a62 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java +++ b/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java @@ -38,6 +38,7 @@ public class BaseSetup { protected static final String SAMPLE_PRODUCT_ID = "amazon-comprehend"; protected static final String SAMPLE_PRODUCT_PATH = "/market/connector/amazon-comprehend"; protected static final String SAMPLE_PRODUCT_NAME = "prody Comprehend"; + protected static final String SAMPLE_PRODUCT_REPOSITORY_NAME = "axonivy-market/amazon-comprehend"; protected static final Pageable PAGEABLE = PageRequest.of(0, 20, Sort.by(SortOption.ALPHABETICALLY.getOption()).descending()); protected static final String MOCK_PRODUCT_ID = "bpmn-statistic"; diff --git a/marketplace-service/src/test/java/com/axonivy/market/controller/ProductControllerTest.java b/marketplace-service/src/test/java/com/axonivy/market/controller/ProductControllerTest.java index e830e3018..f46896c40 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/controller/ProductControllerTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/controller/ProductControllerTest.java @@ -214,4 +214,32 @@ private Product createProductMock() { mockProduct.setTags(List.of("AI")); return mockProduct; } + + @Test + void testSyncFirstPublishedDateOfAllProductsInvalidToken() { + 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, + () -> productController.syncFirstPublishedDateOfAllProducts(INVALID_AUTHORIZATION_HEADER)); + + assertEquals(ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText(), exception.getMessage()); + } + + @Test + void testSyncFirstPublishedDateOfAllProductsFailed() { + when(service.syncFirstPublishedDateOfAllProducts()).thenReturn(false); + var response = productController.syncFirstPublishedDateOfAllProducts(AUTHORIZATION_HEADER); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotEquals(ErrorCode.SUCCESSFUL.getCode(), response.getBody().getHelpCode()); + } + + @Test + void testSyncFirstPublishedDateOfAllProductsSuccess() { + when(service.syncFirstPublishedDateOfAllProducts()).thenReturn(true); + var response = productController.syncFirstPublishedDateOfAllProducts(AUTHORIZATION_HEADER); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(ErrorCode.SUCCESSFUL.getCode(), response.getBody().getHelpCode()); + } } diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java index eb87febb1..7896e3893 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java @@ -40,12 +40,14 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.kohsuke.github.GHCommit; import org.kohsuke.github.GHContent; +import org.kohsuke.github.GHTag; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; +import org.mockito.exceptions.base.MockitoException; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -56,6 +58,7 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -576,9 +579,8 @@ void testSyncOneProduct() throws IOException { var mockContents = mockMetaJsonAndLogoList(); when(marketRepoService.getMarketItemByPath(anyString())).thenReturn(mockContents); when(productRepo.save(any(Product.class))).thenReturn(mockProduct); - // Executes - var result = productService.syncOneProduct(SAMPLE_PRODUCT_ID, SAMPLE_PRODUCT_PATH, false); - assertTrue(result); + assertTrue(productService.syncOneProduct(SAMPLE_PRODUCT_ID, SAMPLE_PRODUCT_PATH, false)); + assertTrue(productService.syncOneProduct(SAMPLE_PRODUCT_ID, SAMPLE_PRODUCT_PATH, true)); } private List mockMetaJsonAndLogoList() throws IOException { @@ -590,6 +592,12 @@ private List mockMetaJsonAndLogoList() throws IOException { return new ArrayList<>(List.of(mockContent, mockContentLogo)); } + @Test + void testSyncOneProductFailed() { + when(marketRepoService.getMarketItemByPath(anyString())).thenThrow(new MockitoException("Sync a product failed!")); + assertFalse(productService.syncOneProduct(StringUtils.EMPTY, StringUtils.EMPTY, true)); + } + @Test void testSyncProductsAsUpdateMetaJSONFromGitHub_AddVendorLogo() throws IOException { // Start testing by adding new meta @@ -613,4 +621,103 @@ void testSyncProductsAsUpdateMetaJSONFromGitHub_AddVendorLogo() throws IOExcepti assertNotNull(result); assertTrue(result.isEmpty()); } + + @Test + void testSyncFirstPublishedDateOfAllProducts() throws IOException { + Product mockProduct = new Product(); + mockProduct.setId(SAMPLE_PRODUCT_ID); + mockProduct.setMarketDirectory(SAMPLE_PRODUCT_PATH); + mockProduct.setRepositoryName(SAMPLE_PRODUCT_REPOSITORY_NAME); + List products = Arrays.asList(mockProduct); + when(productRepo.findAll()).thenReturn(products); + when(productRepo.save(any(Product.class))).thenReturn(mockProduct); + GHTag ghTagVersionOne = new GHTag(); + GHTag ghTagVersionTwo = new GHTag(); + List tags = Arrays.asList(ghTagVersionOne, ghTagVersionTwo); + when(gitHubService.getRepositoryTags(SAMPLE_PRODUCT_REPOSITORY_NAME)).thenReturn(tags); + assertTrue(productService.syncFirstPublishedDateOfAllProducts()); + } + + @Test + void testNoSyncFirstPublishedDateForSyncedProducts() { + Product mockProduct = new Product(); + mockProduct.setId(SAMPLE_PRODUCT_ID); + mockProduct.setRepositoryName(SAMPLE_PRODUCT_REPOSITORY_NAME); + mockProduct.setFirstPublishedDate(new Date()); + when(productRepo.findAll()).thenReturn(Arrays.asList(mockProduct)); + assertTrue(productService.syncFirstPublishedDateOfAllProducts()); + } + + @Test + void testSyncFirstPublishedDateWithFindingAllProductsFailed() { + when(productRepo.findAll()).thenThrow(new MockitoException("Sync FirstPublishedDate of all products failed!")); + assertFalse(productService.syncFirstPublishedDateOfAllProducts()); + } + + @Test + void testSyncFirstPublishedDateForNoProduct() { + when(productRepo.findAll()).thenReturn(new ArrayList<>()); + assertTrue(productService.syncFirstPublishedDateOfAllProducts()); + } + + @Test + void testSyncFirstPublishedDateOfAllProductsFailed() throws IOException { + Product mockProduct = new Product(); + mockProduct.setRepositoryName(SAMPLE_PRODUCT_REPOSITORY_NAME); + List products = Arrays.asList(mockProduct); + when(productRepo.findAll()).thenReturn(products); + when(gitHubService.getRepositoryTags(SAMPLE_PRODUCT_REPOSITORY_NAME)).thenThrow( + new IOException("Mocked IOException")); + when(productRepo.save(mockProduct)).thenThrow( + new MockitoException("Mocked IOException")); + assertFalse(productService.syncFirstPublishedDateOfAllProducts()); + } + + @Test + void testSyncFirstPublishedDateOfAllProductsSuccess() throws IOException { + Product mockProduct = new Product(); + mockProduct.setId(SAMPLE_PRODUCT_ID); + mockProduct.setMarketDirectory(SAMPLE_PRODUCT_PATH); + mockProduct.setRepositoryName(SAMPLE_PRODUCT_REPOSITORY_NAME); + List products = Arrays.asList(mockProduct); + when(productRepo.findAll()).thenReturn(products); + when(productRepo.save(any(Product.class))).thenReturn(mockProduct); + GHTag ghTagVersionOne = mock(GHTag.class); + GHCommit commitOfTagVersionOne = mock(GHCommit.class); + GHTag ghTagVersionTwo = mock(GHTag.class); + GHCommit commitOfTagVersionTwo = mock(GHCommit.class); + List tags = Arrays.asList(ghTagVersionOne, ghTagVersionTwo); + when(ghTagVersionOne.getCommit()).thenReturn(commitOfTagVersionOne); + when(commitOfTagVersionOne.getCommitDate()).thenReturn(new Date()); + when(ghTagVersionTwo.getCommit()).thenReturn(commitOfTagVersionTwo); + when(commitOfTagVersionTwo.getCommitDate()).thenReturn(new Date()); + when(gitHubService.getRepositoryTags(SAMPLE_PRODUCT_REPOSITORY_NAME)).thenReturn(tags); + assertTrue(productService.syncFirstPublishedDateOfAllProducts()); + } + + @Test + void testSyncFirstPublishedDateWithGettingTagCommitFailed() throws IOException { + Product mockProduct = new Product(); + mockProduct.setId(SAMPLE_PRODUCT_ID); + mockProduct.setMarketDirectory(SAMPLE_PRODUCT_PATH); + mockProduct.setRepositoryName(SAMPLE_PRODUCT_REPOSITORY_NAME); + List products = Arrays.asList(mockProduct); + when(productRepo.findAll()).thenReturn(products); + GHTag ghTag = mock(GHTag.class); + List tags = Arrays.asList(ghTag); + GHCommit ghCommit = mock(GHCommit.class); + when(ghTag.getCommit()).thenReturn(ghCommit); + when(ghCommit.getCommitDate()).thenThrow( + new IOException("get commit date of tag commit failed!")); + when(gitHubService.getRepositoryTags(SAMPLE_PRODUCT_REPOSITORY_NAME)).thenReturn(tags); + assertTrue(productService.syncFirstPublishedDateOfAllProducts()); + + GHTag ghTag2 = mock(GHTag.class); + List secondTags = Arrays.asList(ghTag, ghTag2); + GHCommit ghCommit2 = mock(GHCommit.class); + when(ghTag2.getCommit()).thenReturn(ghCommit2); + when(ghCommit2.getCommitDate()).thenReturn(new Date()); + when(gitHubService.getRepositoryTags(SAMPLE_PRODUCT_REPOSITORY_NAME)).thenReturn(secondTags); + assertTrue(productService.syncFirstPublishedDateOfAllProducts()); + } } From 4439b5dc64e64156f050a8574c0e0150d9e0c157 Mon Sep 17 00:00:00 2001 From: Dinh Nguyen <127725498+ntqdinh-axonivy@users.noreply.github.com> Date: Fri, 13 Dec 2024 15:33:28 +0700 Subject: [PATCH 3/5] MARP-1642 Application Freezing due to missing product detail content (#258) --- .../product-detail/product-detail.component.html | 2 +- .../product-detail.component.spec.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.html b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.html index 129825a0f..a7a2515f6 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.html +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.html @@ -160,7 +160,7 @@

[id]="displayedTab.value" role="tabpanel" [attr.aria-labelledby]="displayedTab.value + '-tab'"> - @if (displayedTab.value === 'dependency') { + @if (displayedTab.value === 'dependency' && productDetail().productModuleContent) { diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.spec.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.spec.ts index d379ea5dc..b3e14d5ac 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.spec.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.spec.ts @@ -808,4 +808,20 @@ describe('ProductDetailComponent', () => { expect(rateConnector.childNodes[0].nativeNode.textContent).toContain("common.feedback.rateFeedbackForUtilityBtnLabel"); expect(rateConnectorEmptyText.childNodes[0].nativeNode.textContent).toContain("common.feedback.noFeedbackForUtilityLabel"); }); + + it('maven tab should not display when product module content is missing', () => { + const event = { value: 'dependency' }; + component.onTabChange(event.value); + fixture.detectChanges(); + let mavenTab = fixture.debugElement.query( + By.css('app-product-detail-maven-content') + ); + expect(mavenTab).toBeTruthy(); + component.productModuleContent.set({} as any as ProductModuleContent); + fixture.detectChanges(); + mavenTab = fixture.debugElement.query( + By.css('app-product-detail-maven-content') + ); + expect(mavenTab).toBeFalsy(); + }); }); From 6ec5f3ff4b78cc3ead864d0634a8b24e1e9ac92e Mon Sep 17 00:00:00 2001 From: Khanh Nguyen <119989010+ndkhanh-axonivy@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:17:39 +0700 Subject: [PATCH 4/5] MARP-1294 Create a central monitoring reporting for security issues of axonivy marketplace (#251) --- .../market/config/RestTemplateConfig.java | 14 + .../market/constants/GitHubConstants.java | 14 +- .../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 | 24 + .../market/github/model/SecretScanning.java | 14 + .../market/github/service/GitHubService.java | 3 + .../impl/GHAxonIvyProductRepoServiceImpl.java | 3 - .../service/impl/GitHubServiceImpl.java | 237 +++++++-- .../market/github/util/GitHubUtils.java | 23 - .../SecurityMonitorControllerTest.java | 69 +++ .../service/impl/GitHubServiceImplTest.java | 501 +++++++++++++++++- .../axonivy/market/util/GitHubUtilsTest.java | 36 -- marketplace-ui/src/app/app.routes.ts | 5 + .../product-detail.service.spec.ts | 83 ++- .../security-monitor.component.html | 101 ++++ .../security-monitor.component.scss | 220 ++++++++ .../security-monitor.component.spec.ts | 142 +++++ .../security-monitor.component.ts | 141 +++++ .../security-monitor.service.spec.ts | 77 +++ .../security-monitor.service.ts | 19 + .../app/shared/constants/common.constant.ts | 32 +- .../models/product-security-info-model.ts | 20 + marketplace-ui/src/main.ts | 2 +- 27 files changed, 1694 insertions(+), 168 deletions(-) create mode 100644 marketplace-service/src/main/java/com/axonivy/market/config/RestTemplateConfig.java 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-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.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/product-security-info-model.ts diff --git a/marketplace-service/src/main/java/com/axonivy/market/config/RestTemplateConfig.java b/marketplace-service/src/main/java/com/axonivy/market/config/RestTemplateConfig.java new file mode 100644 index 000000000..0dcfd01a7 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.axonivy.market.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file 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..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 @@ -19,15 +19,19 @@ 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 SECURITY_SEVERITY_LEVEL = "security_severity_level"; + 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_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 30ac7fe39..3ed09326f 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..efca212f3 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java @@ -0,0 +1,24 @@ +package com.axonivy.market.github.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Date; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ProductSecurityInfo { + private String repoName; + private boolean isArchived; + private String visibility; + private boolean branchProtectionEnabled; + private Date lastCommitDate; + private String latestCommitSHA; + private Dependabot dependabot; + private SecretScanning secretScanning; + 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/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 293850c62..d8cf56e71 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 @@ -8,37 +8,44 @@ 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.GHContent; -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.kohsuke.github.*; 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; 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.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.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; import static org.apache.commons.lang3.StringUtils.EMPTY; @@ -49,12 +56,14 @@ public class GitHubServiceImpl implements GitHubService { private final RestTemplate restTemplate; private final UserRepository userRepository; private final GitHubProperty gitHubProperty; + private final ThreadPoolTaskScheduler taskScheduler; - public GitHubServiceImpl(RestTemplateBuilder restTemplateBuilder, UserRepository userRepository, - GitHubProperty gitHubProperty) { - this.restTemplate = restTemplateBuilder.build(); + public GitHubServiceImpl(RestTemplate restTemplate, UserRepository userRepository, + GitHubProperty gitHubProperty, ThreadPoolTaskScheduler taskScheduler) { + this.restTemplate = restTemplate; this.userRepository = userRepository; this.gitHubProperty = gitHubProperty; + this.taskScheduler = taskScheduler; } @Override @@ -96,8 +105,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(); } @@ -109,7 +118,6 @@ public GitHubAccessTokenResponse getAccessToken(String code, GitHubProperty gitH HttpHeaders headers = new HttpHeaders(); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); HttpEntity> request = new HttpEntity<>(params, headers); - ResponseEntity responseEntity = restTemplate.postForEntity( GitHubConstants.GITHUB_GET_ACCESS_TOKEN_URL, request, GitHubAccessTokenResponse.class); GitHubAccessTokenResponse response = responseEntity.getBody(); @@ -125,38 +133,21 @@ public GitHubAccessTokenResponse getAccessToken(String code, GitHubProperty gitH @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 { + GHMyself myself = getGitHub(accessToken).getMyself(); + User user = Optional.ofNullable(userRepository.searchByGitHubId(String.valueOf(myself.getId()))) + .orElse(new User()); + user.setGitHubId(String.valueOf(myself.getId())); + 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("GitHub user fetch failed", 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 @@ -172,12 +163,28 @@ 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)); + } + + @Override + public List getSecurityDetailsForAllProducts(String accessToken, String orgName) { + try { + GitHub gitHub = getGitHub(accessToken); + GHOrganization organization = gitHub.getOrganization(orgName); + + return organization.listRepositories().toList().stream() + .map(repo -> CompletableFuture.supplyAsync(() -> fetchSecurityInfoSafe(repo, organization, accessToken), taskScheduler.getScheduledExecutor())) + .map(CompletableFuture::join) + .sorted(Comparator.comparing(ProductSecurityInfo::getRepoName)) + .collect(Collectors.toList()); + } catch (IOException e) { + log.error(e.getStackTrace()); + return Collections.emptyList(); + } } - private boolean isUserInOrganizationAndTeam(GitHub gitHub, String organization, - String teamName) throws IOException { + public boolean isUserInOrganizationAndTeam(GitHub gitHub, String organization, String teamName) throws IOException { if (gitHub == null) { return false; } @@ -188,7 +195,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 +203,136 @@ private boolean isUserInOrganizationAndTeam(GitHub gitHub, String organization, return false; } + + public 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.getBranch(defaultBranch).getSHA1(); + GHCommit latestCommit = repo.getCommit(latestCommitSHA); + productSecurityInfo.setLatestCommitSHA(latestCommitSHA); + productSecurityInfo.setLastCommitDate(latestCommit.getCommitDate()); + productSecurityInfo.setDependabot(getDependabotAlerts(repo, organization, accessToken)); + productSecurityInfo.setSecretScanning(getNumberOfSecretScanningAlerts(repo, organization, + accessToken)); + productSecurityInfo.setCodeScanning(getCodeScanningAlerts(repo, organization, + accessToken)); + return productSecurityInfo; + } + + public Dependabot getDependabotAlerts(GHRepository repo, GHOrganization organization, + String accessToken) { + 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); + return dependabot; + }, + Dependabot::new + ); + } + + public 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 CodeScanning getCodeScanningAlerts(GHRepository repo, + GHOrganization organization, String accessToken) { + 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); + return codeScanning; + }, + CodeScanning::new + ); + } + + private 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 void setStatus(Object instance, com.axonivy.market.enums.AccessLevel 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); + } + } + + public ResponseEntity>> fetchApiResponseAsList( + String accessToken, + String url) throws RestClientException { + 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..2f3d36598 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 @@ -66,29 +66,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; 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..c86878cfc --- /dev/null +++ b/marketplace-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.java @@ -0,0 +1,69 @@ +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +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-service/src/test/java/com/axonivy/market/service/impl/GitHubServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/GitHubServiceImplTest.java index be7f38edc..97d210d65 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/GitHubServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/GitHubServiceImplTest.java @@ -1,63 +1,514 @@ package com.axonivy.market.service.impl; +import com.axonivy.market.constants.GitHubConstants; +import com.axonivy.market.enums.AccessLevel; +import com.axonivy.market.exceptions.model.MissingHeaderException; +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.ProductSecurityInfo; +import com.axonivy.market.github.model.SecretScanning; import com.axonivy.market.github.service.impl.GitHubServiceImpl; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.kohsuke.github.GHContent; -import org.kohsuke.github.GHRepository; +import org.kohsuke.github.*; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; - @ExtendWith(MockitoExtension.class) class GitHubServiceImplTest { - private static final String DUMMY_API_URL = "https://api.github.com"; @Mock - GHRepository ghRepository; + private GitHubProperty gitHubProperty; + + @Mock + RestTemplate restTemplate; + + @Mock + private GitHub gitHub; + + @Mock + private ResponseEntity responseEntity; @Mock - private RestTemplateBuilder restTemplateBuilder; + private GitHubAccessTokenResponse gitHubAccessTokenResponse; @Mock - private RestTemplate restTemplate; + private GHTeam team1; + @Spy @InjectMocks private GitHubServiceImpl gitHubService; @Test - void testGetGithub() throws IOException { - var result = gitHubService.getGitHub(); - assertEquals(DUMMY_API_URL, result.getApiUrl()); + void testGetGitHub_WithValidToken() throws IOException { + when(gitHubProperty.getToken()).thenReturn("validToken"); + assertNotNull(gitHubService.getGitHub()); + verify(gitHubProperty).getToken(); + } + + @Test + void testGetGitHub_WithNullToken() throws IOException { + when(gitHubProperty.getToken()).thenReturn(null); + assertNotNull(gitHubService.getGitHub()); + } + + @Test + void testGetOrganization_WithValidOrgName() throws IOException { + when(gitHubService.getGitHub()).thenReturn(gitHub); + GHOrganization mockOrganization = mock(GHOrganization.class); + when(gitHub.getOrganization("test-org")).thenReturn(mockOrganization); + GHOrganization organization = gitHubService.getOrganization("test-org"); + assertNotNull(organization); + verify(gitHubProperty).getToken(); + verify(gitHubService).getGitHub(); + verify(gitHub).getOrganization("test-org"); + } + + @Test + void testGetDirectoryContent_ValidInputs() throws IOException { + GHRepository mockRepository = mock(GHRepository.class); + String path = "src"; + String ref = "main"; + + List mockContents = List.of(mock(GHContent.class), mock(GHContent.class)); + when(mockRepository.getDirectoryContent(path, ref)).thenReturn(mockContents); + + List contents = gitHubService.getDirectoryContent(mockRepository, path, ref); + + assertNotNull(contents); + assertEquals(2, contents.size()); + verify(mockRepository).getDirectoryContent(path, ref); + } + + @Test + void testGetDirectoryContent_NullRepository() { + String path = "src"; + String ref = "main"; + + assertThrows(IllegalArgumentException.class, + () -> gitHubService.getDirectoryContent(null, path, ref)); + } + + @Test + void testGetRepository_ValidRepositoryPath() throws IOException { + String repositoryPath = "my-org/my-repo"; + GHRepository mockRepository = mock(GHRepository.class); + when(gitHubService.getGitHub()).thenReturn(mock(GitHub.class)); + when(gitHubService.getGitHub().getRepository(repositoryPath)).thenReturn(mockRepository); + + GHRepository repository = gitHubService.getRepository(repositoryPath); + + assertNotNull(repository); + verify(gitHubService.getGitHub()).getRepository(repositoryPath); + } + + @Test + void testGetGHContent_ValidInputs() throws IOException { + GHRepository mockRepository = mock(GHRepository.class); + String path = "README.md"; + String ref = "main"; + GHContent mockContent = mock(GHContent.class); + + when(mockRepository.getFileContent(path, ref)).thenReturn(mockContent); + + GHContent content = gitHubService.getGHContent(mockRepository, path, ref); + + assertNotNull(content); + verify(mockRepository).getFileContent(path, ref); + } + + @Test + void testGetGHContent_NullRepository() { + String path = "README.md"; + String ref = "main"; + + assertThrows(IllegalArgumentException.class, + () -> gitHubService.getGHContent(null, path, ref)); + } + + @Test + void testGetAccessToken_ValidCodeAndGitHubProperty() throws Oauth2ExchangeCodeException, MissingHeaderException { + String code = "validCode"; + String clientId = "clientId"; + String clientSecret = "clientSecret"; + String accessToken = "accessToken"; + + when(gitHubProperty.getOauth2ClientId()).thenReturn(clientId); + when(gitHubProperty.getOauth2ClientSecret()).thenReturn(clientSecret); + + when(responseEntity.getBody()).thenReturn(gitHubAccessTokenResponse); + when(gitHubAccessTokenResponse.getError()).thenReturn(null); + when(gitHubAccessTokenResponse.getAccessToken()).thenReturn(accessToken); + + when(restTemplate.postForEntity( + eq(GitHubConstants.GITHUB_GET_ACCESS_TOKEN_URL), + any(HttpEntity.class), + eq(GitHubAccessTokenResponse.class) + )).thenReturn(responseEntity); + + GitHubAccessTokenResponse result = gitHubService.getAccessToken(code, gitHubProperty); + + assertNotNull(result); + assertEquals(accessToken, result.getAccessToken()); + verify(restTemplate).postForEntity(anyString(), any(HttpEntity.class), eq(GitHubAccessTokenResponse.class)); + } + + @Test + void testGetAccessToken_NullGitHubProperty() { + MissingHeaderException exception = assertThrows(MissingHeaderException.class, () -> + gitHubService.getAccessToken("validCode", null) + ); + assertEquals("Invalid or missing header", exception.getMessage()); + } + + @Test + void testGetAccessToken_GitHubErrorResponse() throws Oauth2ExchangeCodeException { + String code = "validCode"; + String clientId = "clientId"; + String clientSecret = "clientSecret"; + String error = "invalid_grant"; + String errorDescription = "The authorization code is invalid"; + + when(gitHubProperty.getOauth2ClientId()).thenReturn(clientId); + when(gitHubProperty.getOauth2ClientSecret()).thenReturn(clientSecret); + + when(responseEntity.getBody()).thenReturn(gitHubAccessTokenResponse); + when(gitHubAccessTokenResponse.getError()).thenReturn(error); + when(gitHubAccessTokenResponse.getErrorDescription()).thenReturn(errorDescription); + + when(restTemplate.postForEntity( + eq(GitHubConstants.GITHUB_GET_ACCESS_TOKEN_URL), + any(HttpEntity.class), + eq(GitHubAccessTokenResponse.class) + )).thenReturn(responseEntity); + + Oauth2ExchangeCodeException exception = assertThrows(Oauth2ExchangeCodeException.class, () -> + gitHubService.getAccessToken(code, gitHubProperty) + ); + assertEquals(error, exception.getError()); + assertEquals(errorDescription, exception.getErrorDescription()); + } + + @Test + void testGetAccessToken_SuccessfulResponseWithError() throws Oauth2ExchangeCodeException { + String code = "validCode"; + String clientId = "clientId"; + String clientSecret = "clientSecret"; + + when(gitHubProperty.getOauth2ClientId()).thenReturn(clientId); + when(gitHubProperty.getOauth2ClientSecret()).thenReturn(clientSecret); + + when(responseEntity.getBody()).thenReturn(gitHubAccessTokenResponse); + when(gitHubAccessTokenResponse.getError()).thenReturn("error_code"); + when(gitHubAccessTokenResponse.getErrorDescription()).thenReturn("Error description"); + + when(restTemplate.postForEntity( + eq(GitHubConstants.GITHUB_GET_ACCESS_TOKEN_URL), + any(HttpEntity.class), + eq(GitHubAccessTokenResponse.class) + )).thenReturn(responseEntity); + + Oauth2ExchangeCodeException exception = assertThrows(Oauth2ExchangeCodeException.class, () -> + gitHubService.getAccessToken(code, gitHubProperty) + ); + assertEquals("error_code", exception.getError()); + assertEquals("Error description", exception.getErrorDescription()); + } + + @Test + void testValidateUserInOrganizationAndTeam_Valid() throws UnauthorizedException, IOException { + String accessToken = "validToken"; + String organization = "testOrg"; + String team = "devTeam"; + + when(gitHubService.getGitHub(accessToken)).thenReturn(gitHub); + + when(gitHubService.isUserInOrganizationAndTeam(gitHub, organization, team)).thenReturn(true); + + gitHubService.validateUserInOrganizationAndTeam(accessToken, organization, team); + } + + @Test + void testIsUserInOrganizationAndTeam_NullGitHub() throws IOException { + GitHub gitHubNullAble = null; + String organization = "my-org"; + String teamName = "my-team"; + + boolean result = gitHubService.isUserInOrganizationAndTeam(gitHubNullAble, organization, teamName); + + assertFalse(result); + } + + @Test + void testIsUserInOrganizationAndTeam_EmptyTeams() throws IOException { + String organization = "my-org"; + String teamName = "my-team"; + Map> hashMapTeams = new HashMap<>(); + when(gitHub.getMyTeams()).thenReturn(hashMapTeams); + + boolean result = gitHubService.isUserInOrganizationAndTeam(gitHub, organization, teamName); + + assertFalse(result); + } + + @Test + void testIsUserInOrganizationAndTeam_TeamNotFound() throws IOException { + String organization = "my-org"; + String teamName = "my-team"; + Set teams = new HashSet<>(); + teams.add(team1); + Map> hashMapTeams = new HashMap<>(); + hashMapTeams.put(organization, teams); + when(gitHub.getMyTeams()).thenReturn(hashMapTeams); + + boolean result = gitHubService.isUserInOrganizationAndTeam(gitHub, organization, teamName); + + assertFalse(result); + } + + @Test + void testIsUserInOrganizationAndTeam_TeamFound() throws IOException { + String organization = "my-org"; + String teamName = "my-team"; + Set teams = new HashSet<>(); + when(team1.getName()).thenReturn(teamName); + teams.add(team1); + Map> hashMapTeams = new HashMap<>(); + hashMapTeams.put(organization, teams); + when(gitHub.getMyTeams()).thenReturn(hashMapTeams); + + boolean result = gitHubService.isUserInOrganizationAndTeam(gitHub, organization, teamName); + + assertTrue(result); + } + + @Test + void testIsUserInOrganizationAndTeam_TeamListNull() throws IOException { + String organization = "my-org"; + String teamName = "my-team"; + Map> hashMapTeams = new HashMap<>(); + hashMapTeams.put(organization, null); + when(gitHub.getMyTeams()).thenReturn(hashMapTeams); + + boolean result = gitHubService.isUserInOrganizationAndTeam(gitHub, organization, teamName); + + assertFalse(result); + } + + @Test + void testValidateUserInOrganizationAndTeam_throwsUnauthorizedException() throws IOException { + String accessToken = "invalid-token"; + String organization = "orgName"; + String team = "teamName"; + + GitHub gitHubMock = mock(GitHub.class); + when(gitHubService.getGitHub(accessToken)).thenReturn(gitHubMock); + when(gitHubService.isUserInOrganizationAndTeam(gitHubMock, organization, team)).thenReturn(false); + UnauthorizedException exception = assertThrows(UnauthorizedException.class, () -> { + gitHubService.validateUserInOrganizationAndTeam(accessToken, organization, team); + }); + assertEquals("GITHUB_USER_UNAUTHORIZED - User must be a member of team teamName and organization orgName", exception.getMessage()); + } + + @Test + void testSecurityInfo() throws IOException { + String accessToken = "accessToken"; + String orgName = "orgName"; + String repoName = "repoName"; + String branchSHA1 = "branchSHA1"; + GHRepository ghRepository = mock(GHRepository.class); + GHOrganization ghOrganization = mock(GHOrganization.class); + when(ghOrganization.getLogin()).thenReturn(orgName); + when(ghRepository.getName()).thenReturn(repoName); + when(ghRepository.getVisibility()).thenReturn(GHRepository.Visibility.PUBLIC); + GHBranch branch = mock(GHBranch.class); + when(branch.isProtected()).thenReturn(true); + when(branch.getSHA1()).thenReturn(branchSHA1); + GHCommit commit = mock(GHCommit.class); + when(commit.getCommitDate()).thenReturn(new Date()); + when(ghRepository.getCommit(branchSHA1)).thenReturn(commit); + when(ghRepository.getBranch(any())).thenReturn(branch); + String urlSecretScanning = String.format(GitHubConstants.Url.REPO_SECRET_SCANNING_ALERTS_OPEN, orgName, repoName); + List> responseBodySecretScanning = new ArrayList<>(); + responseBodySecretScanning.add(Map.of( + "number", 1 + )); + when(restTemplate.exchange( + eq(urlSecretScanning), + eq(HttpMethod.GET), + any(HttpEntity.class), + any(ParameterizedTypeReference.class)) + ).thenReturn(new ResponseEntity<>(responseBodySecretScanning, HttpStatus.OK)); + String urlCodeScanning = String.format(GitHubConstants.Url.REPO_CODE_SCANNING_ALERTS_OPEN, orgName, repoName); + List> responseBodyCodeScanning = new ArrayList<>(); + responseBodyCodeScanning.add(Map.of( + "number", 1, + "state", "open", + "rule", Map.of("security_severity_level", "high") + )); + when(restTemplate.exchange( + eq(urlCodeScanning), + eq(HttpMethod.GET), + any(HttpEntity.class), + any(ParameterizedTypeReference.class)) + ).thenReturn(new ResponseEntity<>(responseBodyCodeScanning, HttpStatus.OK)); + + String urlDependabotScanning = String.format(GitHubConstants.Url.REPO_DEPENDABOT_ALERTS_OPEN, orgName, repoName); + List> responseBodyDependabotScanning = new ArrayList<>(); + responseBodyDependabotScanning.add(Map.of( + "number", 1, + "state", "open", + "security_advisory", Map.of("severity", "high") + )); + when(restTemplate.exchange( + eq(urlDependabotScanning), + eq(HttpMethod.GET), + any(HttpEntity.class), + any(ParameterizedTypeReference.class)) + ).thenReturn(new ResponseEntity<>(responseBodyDependabotScanning, HttpStatus.OK)); + + ProductSecurityInfo result = gitHubService.fetchSecurityInfoSafe(ghRepository, ghOrganization, accessToken); + assertNotNull(result); + } + + @Test + void testGetNumberOfSecretScanningAlerts_Success() { + String accessToken = "accessToken"; + String orgName = "orgName"; + String repoName = "repoName"; + String url = String.format(GitHubConstants.Url.REPO_SECRET_SCANNING_ALERTS_OPEN, orgName, repoName); + List> responseBody = new ArrayList<>(); + responseBody.add(Map.of( + "number", 1 + )); + GHRepository ghRepository = mock(GHRepository.class); + GHOrganization ghOrganization = mock(GHOrganization.class); + when(ghOrganization.getLogin()).thenReturn(orgName); + when(ghRepository.getName()).thenReturn(repoName); + when(restTemplate.exchange( + eq(url), + eq(HttpMethod.GET), + any(HttpEntity.class), + any(ParameterizedTypeReference.class)) + ).thenReturn(new ResponseEntity<>(responseBody, HttpStatus.OK)); + SecretScanning result = gitHubService.getNumberOfSecretScanningAlerts(ghRepository, ghOrganization, accessToken); + assertEquals(AccessLevel.ENABLED, result.getStatus()); + } + + @Test + void testGetCodeScanningAlerts_Success() { + String accessToken = "accessToken"; + String orgName = "orgName"; + String repoName = "repoName"; + String url = String.format(GitHubConstants.Url.REPO_CODE_SCANNING_ALERTS_OPEN, orgName, repoName); + List> responseBody = new ArrayList<>(); + responseBody.add(Map.of( + "number", 1, + "state", "open", + "rule", Map.of("security_severity_level", "high") + )); + GHRepository ghRepository = mock(GHRepository.class); + GHOrganization ghOrganization = mock(GHOrganization.class); + when(ghOrganization.getLogin()).thenReturn(orgName); + when(ghRepository.getName()).thenReturn(repoName); + when(restTemplate.exchange( + eq(url), + eq(HttpMethod.GET), + any(HttpEntity.class), + any(ParameterizedTypeReference.class)) + ).thenReturn(new ResponseEntity<>(responseBody, HttpStatus.OK)); + CodeScanning result = gitHubService.getCodeScanningAlerts(ghRepository, ghOrganization, accessToken); + assertEquals(AccessLevel.ENABLED, result.getStatus()); } @Test - void testGetGithubContent() throws IOException { - var mockGHContent = mock(GHContent.class); - final String dummyURL = DUMMY_API_URL.concat("/dummy-content"); - when(mockGHContent.getUrl()).thenReturn(dummyURL); - when(ghRepository.getFileContent(any(), any())).thenReturn(mockGHContent); - var result = gitHubService.getGHContent(ghRepository, "", ""); - assertEquals(dummyURL, result.getUrl()); + void testGetDependabotAlerts_Success() { + String accessToken = "accessToken"; + String orgName = "orgName"; + String repoName = "repoName"; + String url = String.format(GitHubConstants.Url.REPO_DEPENDABOT_ALERTS_OPEN, orgName, repoName); + List> responseBody = new ArrayList<>(); + responseBody.add(Map.of( + "number", 1, + "state", "open", + "security_advisory", Map.of("severity", "high") + )); + GHRepository ghRepository = mock(GHRepository.class); + GHOrganization ghOrganization = mock(GHOrganization.class); + when(ghOrganization.getLogin()).thenReturn(orgName); + when(ghRepository.getName()).thenReturn(repoName); + when(restTemplate.exchange( + eq(url), + eq(HttpMethod.GET), + any(HttpEntity.class), + any(ParameterizedTypeReference.class)) + ).thenReturn(new ResponseEntity<>(responseBody, HttpStatus.OK)); + Dependabot result = gitHubService.getDependabotAlerts(ghRepository, ghOrganization, accessToken); + assertEquals(AccessLevel.ENABLED, result.getStatus()); } @Test - void testGetDirectoryContent() throws IOException { - var result = gitHubService.getDirectoryContent(ghRepository, "", ""); - assertEquals(0, result.size()); + void testGetDependabotAlerts_NotFound() { + String accessToken = "accessToken"; + String orgName = "orgName"; + String repoName = "repoName"; + String url = String.format(GitHubConstants.Url.REPO_DEPENDABOT_ALERTS_OPEN, orgName, repoName); + GHRepository ghRepository = mock(GHRepository.class); + GHOrganization ghOrganization = mock(GHOrganization.class); + when(ghOrganization.getLogin()).thenReturn(orgName); + when(ghRepository.getName()).thenReturn(repoName); + when(restTemplate.exchange( + eq(url), + eq(HttpMethod.GET), + any(HttpEntity.class), + any(ParameterizedTypeReference.class)) + ).thenThrow(HttpClientErrorException.NotFound.class); + Dependabot result = gitHubService.getDependabotAlerts(ghRepository, ghOrganization, accessToken); + assertEquals(AccessLevel.NO_PERMISSION, result.getStatus()); } @Test - void testGithubWithToken() throws IOException { - var result = gitHubService.getGitHub("accessToken"); - assertEquals(DUMMY_API_URL, result.getApiUrl()); + void testGetDependabotAlerts_Disabled() { + String accessToken = "accessToken"; + String orgName = "orgName"; + String repoName = "repoName"; + String url = String.format(GitHubConstants.Url.REPO_DEPENDABOT_ALERTS_OPEN, orgName, repoName); + GHRepository ghRepository = mock(GHRepository.class); + GHOrganization ghOrganization = mock(GHOrganization.class); + when(ghOrganization.getLogin()).thenReturn(orgName); + when(ghRepository.getName()).thenReturn(repoName); + when(restTemplate.exchange( + eq(url), + eq(HttpMethod.GET), + any(HttpEntity.class), + any(ParameterizedTypeReference.class)) + ).thenThrow(HttpClientErrorException.Forbidden.class); + Dependabot result = gitHubService.getDependabotAlerts(ghRepository, ghOrganization, accessToken); + assertEquals(AccessLevel.DISABLED, result.getStatus()); } } 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); - } } 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/product/product-detail/product-detail.service.spec.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts index aae29c669..0feb9578c 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: '', + }, + ]; - 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.html b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html new file mode 100644 index 000000000..9c5209a02 --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html @@ -0,0 +1,101 @@ +
+
+

GitHub Repository Security Monitor

+

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

+
+ @if (isAuthenticated) { + +
+ @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 + } +

+

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

+

🚧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..dfae28f51 --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss @@ -0,0 +1,220 @@ +.container { + max-width: 1200px; + margin: 20px auto; + padding: 0 15px; + cursor: default; +} + +.header { + text-align: center; + padding: 20px 0; + color: #333; + + h2 { + font-size: 4rem; + } +} + +.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: default; +} + +.repo-card a:hover { + color: #0056b3; + text-decoration: underline; + cursor: pointer; +} + +.repo-header { + display: flex; + align-items: center; + margin-bottom: 10px; + gap: 5px; + + h3 { + margin: 0; + font-size: 1.6rem; + color: #007acc; + } + + h3:hover { + cursor: pointer; + text-decoration: underline; + } + + .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.5rem; + 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.3rem; + margin-bottom: 10px; + width: 350px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.token-input-container button { + padding: 10px 20px; + font-size: 1.3rem; + 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.2rem; + width: 100%; +} + +.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.spec.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts new file mode 100644 index 000000000..cfb5b22d1 --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts @@ -0,0 +1,142 @@ +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'; +import { ProductSecurityInfo } from '../../shared/models/product-security-info-model'; +import { HttpErrorResponse } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; +import { TIME_UNITS } from '../../shared/constants/common.constant'; + +describe('SecurityMonitorComponent', () => { + let component: SecurityMonitorComponent; + let fixture: ComponentFixture; + let securityMonitorService: jasmine.SpyObj; + + beforeEach(async () => { + const spy = jasmine.createSpyObj('SecurityMonitorService', ['getSecurityDetails']); + + await TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule], + providers: [ + { provide: SecurityMonitorService, useValue: spy }, + { provide: TranslateService, useValue: spy } + ], + }).compileComponents(); + + securityMonitorService = TestBed.inject(SecurityMonitorService) as jasmine.SpyObj; + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SecurityMonitorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + 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'); + }); + + 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: '', + }, + ]; + + securityMonitorService.getSecurityDetails.and.returnValue(of(mockRepos)); + + component.token = 'valid-token'; + component.onSubmit(); + + fixture.detectChanges(); + + expect(securityMonitorService.getSecurityDetails).toHaveBeenCalledWith('valid-token'); + expect(component.repos).toEqual(mockRepos); + expect(component.isAuthenticated).toBeTrue(); + + 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 401 Unauthorized error correctly', () => { + const mockError = new HttpErrorResponse({ status: 401 }); + + securityMonitorService.getSecurityDetails.and.returnValue(throwError(() => mockError)); + + component.token = 'invalid-token'; + component.onSubmit(); + + fixture.detectChanges(); + + expect(component.errorMessage).toBe('Unauthorized access.'); + }); + + it('should handle generic error correctly', () => { + const mockError = new HttpErrorResponse({ status: 500 }); + + securityMonitorService.getSecurityDetails.and.returnValue(throwError(() => mockError)); + + component.token = 'invalid-token'; + component.onSubmit(); + + fixture.detectChanges(); + + 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']); + }); + + it('should return "just now" for dates less than 60 seconds ago', () => { + const recentDate = new Date(new Date().getTime() - 30 * 1000).toISOString(); + const result = component.formatCommitDate(recentDate); + expect(result).toBe('just now'); + }); + + it('should return "1 minute ago" for dates 1 minute ago', () => { + const oneMinuteAgo = new Date(new Date().getTime() - 60 * 1000).toISOString(); + TIME_UNITS[0] = { SECONDS: 60, SINGULAR: 'minute', PLURAL: 'minutes' }; + const result = component.formatCommitDate(oneMinuteAgo); + expect(result).toBe('1 minute ago'); + }); +}); \ 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 new file mode 100644 index 000000000..661708ca0 --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts @@ -0,0 +1,141 @@ +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 { GITHUB_MARKET_ORG_URL, REPO_PAGE_PATHS, SECURITY_MONITOR_MESSAGES, SECURITY_MONITOR_SESSION_KEYS, TIME_UNITS, UNAUTHORIZED } from '../../shared/constants/common.constant'; + +@Component({ + selector: 'app-security-monitor', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './security-monitor.component.html', + styleUrls: ['./security-monitor.component.scss'], + encapsulation: ViewEncapsulation.Emulated, +}) +export class SecurityMonitorComponent { + isAuthenticated = false; + token = ''; + errorMessage = ''; + repos: ProductSecurityInfo[] = []; + + private readonly securityMonitorService = inject(SecurityMonitorService); + + ngOnInit(): void { + this.loadSessionData(); + } + + onSubmit(): void { + this.token = this.token ?? sessionStorage.getItem(SECURITY_MONITOR_SESSION_KEYS.TOKEN) ?? ''; + if (!this.token) { + this.handleMissingToken(); + return; + } + + this.errorMessage = ''; + this.fetchSecurityDetails(); + } + + private loadSessionData(): void { + try { + const sessionData = sessionStorage.getItem(SECURITY_MONITOR_SESSION_KEYS.DATA); + if (sessionData) { + this.repos = JSON.parse(sessionData) as ProductSecurityInfo[]; + this.isAuthenticated = true; + } + } + catch (error) { + this.clearSessionData(); + } + } + + private handleMissingToken(): void { + this.errorMessage = SECURITY_MONITOR_MESSAGES.TOKEN_REQUIRED; + this.isAuthenticated = false; + this.clearSessionData(); + } + + private fetchSecurityDetails(): void { + this.securityMonitorService.getSecurityDetails(this.token).subscribe({ + next: data => this.handleSuccess(data), + error: (err: HttpErrorResponse) => this.handleError(err), + }); + } + + private handleSuccess(data: ProductSecurityInfo[]): void { + this.repos = data; + this.isAuthenticated = true; + sessionStorage.setItem(SECURITY_MONITOR_SESSION_KEYS.TOKEN, this.token); + sessionStorage.setItem(SECURITY_MONITOR_SESSION_KEYS.DATA, JSON.stringify(data)); + } + + private handleError(err: HttpErrorResponse): void { + if (err.status === UNAUTHORIZED) { + this.errorMessage = SECURITY_MONITOR_MESSAGES.UNAUTHORIZED_ACCESS; + } else { + this.errorMessage = SECURITY_MONITOR_MESSAGES.FETCH_FAILURE; + } + + this.isAuthenticated = false; + this.clearSessionData(); + } + + private clearSessionData(): void { + sessionStorage.removeItem(SECURITY_MONITOR_SESSION_KEYS.TOKEN); + sessionStorage.removeItem(SECURITY_MONITOR_SESSION_KEYS.DATA); + } + + hasAlerts(alerts: Record): boolean { + return Object.keys(alerts).length > 0; + } + + alertKeys(alerts: Record): string[] { + return Object.keys(alerts); + } + + navigateToPage(repoName: string, path: string, additionalPath = ''): void { + const url = `${GITHUB_MARKET_ORG_URL}/${repoName}${path}${additionalPath}`; + window.open(url, '_blank'); + } + + navigateToRepoPage(repoName: string, page: keyof typeof REPO_PAGE_PATHS, lastCommitSHA?: string): void { + const path = REPO_PAGE_PATHS[page]; + let additionalPath = ''; + if (page === 'lastCommit') { + additionalPath = lastCommitSHA ?? ''; + } + if (path) { + this.navigateToPage(repoName, path, additionalPath); + } + } + + 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'; + } + + for (const [index, { SECONDS, SINGULAR, PLURAL }] of TIME_UNITS.entries()) { + if (index < TIME_UNITS.length - 1 && diffInSeconds < TIME_UNITS[index + 1].SECONDS) { + const value = Math.floor(diffInSeconds / SECONDS); + if (value === 1) { + return `${value} ${SINGULAR} ago`; + } else { + return `${value} ${PLURAL} ago`; + } + } + } + + const years = Math.floor(diffInSeconds / TIME_UNITS[TIME_UNITS.length - 1].SECONDS); + if (years === 1) { + return `${years} year ago`; + } else { + return `${years} years ago`; + } + } +} 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..ba5e26786 --- /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: '', + }, + ]; + + 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 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..b2b15ffb5 --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.ts @@ -0,0 +1,19 @@ +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' +}) +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/constants/common.constant.ts b/marketplace-ui/src/app/shared/constants/common.constant.ts index f50652674..e4f769869 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'; @@ -247,4 +248,33 @@ export const DAYS_IN_A_WEEK = 7; export const DAYS_IN_A_MONTH = 30; export const DAYS_IN_A_YEAR = 365; -export const MAX_FEEDBACK_LENGTH =250; \ No newline at end of file +export const MAX_FEEDBACK_LENGTH =250; + +export const SECURITY_MONITOR_SESSION_KEYS = { + DATA: 'security-monitor-data', + TOKEN: 'security-monitor-token', +}; + +export const SECURITY_MONITOR_MESSAGES = { + TOKEN_REQUIRED: 'Token is required', + UNAUTHORIZED_ACCESS: 'Unauthorized access.', + FETCH_FAILURE: 'Failed to fetch security data. Check logs for details.', +}; + +export const TIME_UNITS = [ + { SECONDS: 60, SINGULAR: 'minute', PLURAL: 'minutes' }, + { SECONDS: 3600, SINGULAR: 'hour', PLURAL: 'hours' }, + { SECONDS: 86400, SINGULAR: 'day', PLURAL: 'days' }, + { SECONDS: 604800, SINGULAR: 'week', PLURAL: 'weeks' }, + { SECONDS: 2592000, SINGULAR: 'month', PLURAL: 'months' }, + { SECONDS: 31536000, SINGULAR: 'year', PLURAL: 'years' }, +]; + +export const REPO_PAGE_PATHS: Record = { + security: '/security', + dependabot: '/security/dependabot', + codeScanning: '/security/code-scanning', + secretScanning: '/security/secret-scanning', + branches: '/settings/branches', + lastCommit: '/commit/', +}; diff --git a/marketplace-ui/src/app/shared/models/product-security-info-model.ts b/marketplace-ui/src/app/shared/models/product-security-info-model.ts new file mode 100644 index 000000000..f3b6d0ce4 --- /dev/null +++ b/marketplace-ui/src/app/shared/models/product-security-info-model.ts @@ -0,0 +1,20 @@ +export interface ProductSecurityInfo { + repoName: string; + visibility: string; + archived: boolean; + dependabot: { + status: string; + alerts: Record; + }; + codeScanning: { + status: string; + alerts: Record; + }; + secretScanning: { + status: string; + numberOfAlerts: number; + }; + branchProtectionEnabled: boolean; + lastCommitSHA: string; + lastCommitDate: string; +} \ No newline at end of file diff --git a/marketplace-ui/src/main.ts b/marketplace-ui/src/main.ts index 3997d76eb..5086f383d 100644 --- a/marketplace-ui/src/main.ts +++ b/marketplace-ui/src/main.ts @@ -6,4 +6,4 @@ import { AppComponent } from './app/app.component'; bootstrapApplication(AppComponent, appConfig).catch(err => { throw err; -}); +}); \ No newline at end of file From e1a0d8514395af868f6a3fe6061b735599750759 Mon Sep 17 00:00:00 2001 From: quanpham-axonivy Date: Tue, 17 Dec 2024 17:05:17 +0700 Subject: [PATCH 5/5] MARP-1548 Suspicious installation counts (#257) --- marketplace-build/.env | 3 +- marketplace-build/dev/.env | 3 +- marketplace-build/dev/docker-compose.yml | 2 + marketplace-build/docker-compose.yml | 2 + marketplace-build/release/.env | 3 +- marketplace-build/release/docker-compose.yml | 2 + marketplace-service/pom.xml | 6 +- .../market/constants/CommonConstants.java | 1 + .../market/constants/LoggingConstants.java | 22 +++++ .../ProductMarketplaceDataController.java | 16 ++-- .../com/axonivy/market/logging/Loggable.java | 11 +++ .../market/logging/LoggableAspect.java | 92 +++++++++++++++++++ .../com/axonivy/market/util/FileUtils.java | 31 +++++++ .../com/axonivy/market/util/LoggingUtils.java | 54 +++++++++++ .../src/main/resources/application.properties | 3 +- .../market/logging/LoggableAspectTest.java | 89 ++++++++++++++++++ .../service/impl/SchedulingTasksTest.java | 3 +- .../axonivy/market/util/FileUtilsTest.java | 57 ++++++++++++ .../axonivy/market/util/LoggingUtilsTest.java | 78 ++++++++++++++++ 19 files changed, 463 insertions(+), 15 deletions(-) create mode 100644 marketplace-service/src/main/java/com/axonivy/market/constants/LoggingConstants.java create mode 100644 marketplace-service/src/main/java/com/axonivy/market/logging/Loggable.java create mode 100644 marketplace-service/src/main/java/com/axonivy/market/logging/LoggableAspect.java create mode 100644 marketplace-service/src/main/java/com/axonivy/market/util/FileUtils.java create mode 100644 marketplace-service/src/main/java/com/axonivy/market/util/LoggingUtils.java create mode 100644 marketplace-service/src/test/java/com/axonivy/market/logging/LoggableAspectTest.java create mode 100644 marketplace-service/src/test/java/com/axonivy/market/util/FileUtilsTest.java create mode 100644 marketplace-service/src/test/java/com/axonivy/market/util/LoggingUtilsTest.java diff --git a/marketplace-build/.env b/marketplace-build/.env index d1b4b7edb..606142801 100644 --- a/marketplace-build/.env +++ b/marketplace-build/.env @@ -12,4 +12,5 @@ MARKET_GITHUB_OAUTH_APP_CLIENT_ID= MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET= MARKET_JWT_SECRET_KEY= MARKET_CORS_ALLOWED_ORIGIN=* -MARKET_MONGO_LOG_LEVEL=DEBUG \ No newline at end of file +MARKET_MONGO_LOG_LEVEL=DEBUG +MARKET_LOG_PATH=logs \ No newline at end of file diff --git a/marketplace-build/dev/.env b/marketplace-build/dev/.env index e9d068e53..09f4215ad 100644 --- a/marketplace-build/dev/.env +++ b/marketplace-build/dev/.env @@ -12,4 +12,5 @@ MARKET_GITHUB_OAUTH_APP_CLIENT_ID= MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET= MARKET_JWT_SECRET_KEY= MARKET_CORS_ALLOWED_ORIGIN=* -MARKET_MONGO_LOG_LEVEL=DEBUG \ No newline at end of file +MARKET_MONGO_LOG_LEVEL=DEBUG +MARKET_LOG_PATH=logs \ No newline at end of file diff --git a/marketplace-build/dev/docker-compose.yml b/marketplace-build/dev/docker-compose.yml index 260fd1f6c..ae81c5613 100644 --- a/marketplace-build/dev/docker-compose.yml +++ b/marketplace-build/dev/docker-compose.yml @@ -24,6 +24,7 @@ services: volumes: - /home/axonivy/marketplace/data/market-installations.json:/app/data/market-installation.json - marketcache:/app/data/market-cache + - ./logs:/app/logs environment: - MONGODB_HOST=${SERVICE_MONGODB_HOST} - MONGODB_DATABASE=${SERVICE_MONGODB_DATABASE} @@ -36,6 +37,7 @@ services: - MARKET_JWT_SECRET_KEY=${MARKET_JWT_SECRET_KEY} - MARKET_CORS_ALLOWED_ORIGIN=${MARKET_CORS_ALLOWED_ORIGIN} - MARKET_MONGO_LOG_LEVEL=${MARKET_MONGO_LOG_LEVEL} + - MARKET_LOG_PATH=${MARKET_LOG_PATH} build: context: ../../marketplace-service dockerfile: Dockerfile diff --git a/marketplace-build/docker-compose.yml b/marketplace-build/docker-compose.yml index 74011c3a5..d57c8ad8e 100644 --- a/marketplace-build/docker-compose.yml +++ b/marketplace-build/docker-compose.yml @@ -24,6 +24,7 @@ services: volumes: - /home/axonivy/marketplace/data/market-installations.json:/app/data/market-installation.json - marketcache:/app/data/market-cache + - ./logs:/app/logs environment: - MONGODB_HOST=${SERVICE_MONGODB_HOST} - MONGODB_DATABASE=${SERVICE_MONGODB_DATABASE} @@ -36,6 +37,7 @@ services: - MARKET_JWT_SECRET_KEY=${MARKET_JWT_SECRET_KEY} - MARKET_CORS_ALLOWED_ORIGIN=${MARKET_CORS_ALLOWED_ORIGIN} - MARKET_MONGO_LOG_LEVEL=${MARKET_MONGO_LOG_LEVEL} + - MARKET_LOG_PATH=${MARKET_LOG_PATH} build: context: ../marketplace-service dockerfile: Dockerfile diff --git a/marketplace-build/release/.env b/marketplace-build/release/.env index d8dfb5f8a..fd7ac7016 100644 --- a/marketplace-build/release/.env +++ b/marketplace-build/release/.env @@ -13,4 +13,5 @@ MARKET_GITHUB_OAUTH_APP_CLIENT_ID= MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET= MARKET_JWT_SECRET_KEY= MARKET_CORS_ALLOWED_ORIGIN=* -MARKET_MONGO_LOG_LEVEL=DEBUG \ No newline at end of file +MARKET_MONGO_LOG_LEVEL=DEBUG +MARKET_LOG_PATH=logs \ No newline at end of file diff --git a/marketplace-build/release/docker-compose.yml b/marketplace-build/release/docker-compose.yml index f8fc73f25..018614b70 100644 --- a/marketplace-build/release/docker-compose.yml +++ b/marketplace-build/release/docker-compose.yml @@ -22,6 +22,7 @@ services: volumes: - /home/axonivy/marketplace/data/market-installations.json:/app/data/market-installation.json - marketcache:/app/data/market-cache + - ./logs:/app/logs environment: - MONGODB_HOST=${SERVICE_MONGODB_HOST} - MONGODB_DATABASE=${SERVICE_MONGODB_DATABASE} @@ -34,6 +35,7 @@ services: - MARKET_JWT_SECRET_KEY=${MARKET_JWT_SECRET_KEY} - MARKET_CORS_ALLOWED_ORIGIN=${MARKET_CORS_ALLOWED_ORIGIN} - MARKET_MONGO_LOG_LEVEL=${MARKET_MONGO_LOG_LEVEL} + - MARKET_LOG_PATH=${MARKET_LOG_PATH} networks: - marketplace-network diff --git a/marketplace-service/pom.xml b/marketplace-service/pom.xml index 8bdb6e625..ec7ccc0ed 100644 --- a/marketplace-service/pom.xml +++ b/marketplace-service/pom.xml @@ -73,7 +73,11 @@ jaxb-api 2.3.1 - + + org.springframework.boot + spring-boot-starter-aop + + org.springframework.boot spring-boot-starter-validation diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/CommonConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/CommonConstants.java index 2966383f4..e80c2b57d 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/CommonConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/CommonConstants.java @@ -6,6 +6,7 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class CommonConstants { public static final String REQUESTED_BY = "X-Requested-By"; + public static final String USER_AGENT = "user-agent"; public static final String SLASH = "/"; public static final String DOT_SEPARATOR = "."; public static final String PLUS = "+"; diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/LoggingConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/LoggingConstants.java new file mode 100644 index 000000000..2ce721583 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/LoggingConstants.java @@ -0,0 +1,22 @@ +package com.axonivy.market.constants; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class LoggingConstants { + + public static final String ENTRY_FORMAT = " <%s>%s%n"; + public static final String ENTRY_START = " \n"; + public static final String ENTRY_END = " \n"; + public static final String DATE_FORMAT = "yyyy-MM-dd"; + public static final String TIMESTAMP_FORMAT = "yyyy-MM-dd HH:mm:ss"; + public static final String LOG_START = "\n"; + public static final String LOG_END = ""; + public static final String METHOD = "method"; + public static final String ARGUMENTS = "arguments"; + public static final String TIMESTAMP = "timestamp"; + public static final String NO_ARGUMENTS = "No arguments"; + public static final String MARKET_WEBSITE = "marketplace-website"; + +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductMarketplaceDataController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductMarketplaceDataController.java index 728251b33..cd4abe584 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductMarketplaceDataController.java +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductMarketplaceDataController.java @@ -3,13 +3,12 @@ import com.axonivy.market.constants.GitHubConstants; import com.axonivy.market.enums.ErrorCode; import com.axonivy.market.github.service.GitHubService; +import com.axonivy.market.logging.Loggable; import com.axonivy.market.model.Message; import com.axonivy.market.model.ProductCustomSortRequest; import com.axonivy.market.service.ProductMarketplaceDataService; import com.axonivy.market.util.AuthorizationUtils; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.AllArgsConstructor; @@ -36,7 +35,7 @@ public class ProductMarketplaceDataController { private final GitHubService gitHubService; private final ProductMarketplaceDataService productMarketplaceDataService; - + @PostMapping(CUSTOM_SORT) @Operation(hidden = true) public ResponseEntity createCustomSortProducts( @@ -51,15 +50,14 @@ public ResponseEntity createCustomSortProducts( return new ResponseEntity<>(message, HttpStatus.OK); } + @Loggable + @Operation(hidden = true) @PutMapping(INSTALLATION_COUNT_BY_ID) - @Operation(summary = "Update installation count of product", - description = "By default, increase installation count when click download product files by users") public ResponseEntity syncInstallationCount( - @PathVariable(ID) @Parameter(description = "Product id (from meta.json)", example = "approval-decision-utils", - in = ParameterIn.PATH) String productId, - @RequestParam(name = DESIGNER_VERSION, required = false) @Parameter(in = ParameterIn.QUERY, - example = "v10.0.20") String designerVersion) { + @PathVariable(ID) String productId, + @RequestParam(name = DESIGNER_VERSION, required = false) String designerVersion) { int result = productMarketplaceDataService.updateInstallationCountForProduct(productId, designerVersion); return new ResponseEntity<>(result, HttpStatus.OK); } + } diff --git a/marketplace-service/src/main/java/com/axonivy/market/logging/Loggable.java b/marketplace-service/src/main/java/com/axonivy/market/logging/Loggable.java new file mode 100644 index 000000000..3194ee816 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/logging/Loggable.java @@ -0,0 +1,11 @@ +package com.axonivy.market.logging; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Loggable { +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/logging/LoggableAspect.java b/marketplace-service/src/main/java/com/axonivy/market/logging/LoggableAspect.java new file mode 100644 index 000000000..059a19983 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/logging/LoggableAspect.java @@ -0,0 +1,92 @@ +package com.axonivy.market.logging; + +import com.axonivy.market.constants.CommonConstants; +import com.axonivy.market.constants.LoggingConstants; +import com.axonivy.market.exceptions.model.MissingHeaderException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.log4j.Log4j2; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static com.axonivy.market.util.FileUtils.createFile; +import static com.axonivy.market.util.FileUtils.writeToFile; +import static com.axonivy.market.util.LoggingUtils.*; + +@Log4j2 +@Aspect +@Component +public class LoggableAspect { + + @Value("${loggable.log-path}") + public String logFilePath; + + @Before("@annotation(com.axonivy.market.logging.Loggable)") + public void logMethodCall(JoinPoint joinPoint) throws MissingHeaderException { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + HttpServletRequest request = attributes.getRequest(); + Map headersMap = extractHeaders(request, signature, joinPoint); + saveLogToDailyFile(headersMap); + + // block execution if request isn't from Market or Ivy Designer + if (!LoggingConstants.MARKET_WEBSITE.equals(headersMap.get(CommonConstants.REQUESTED_BY))) { + throw new MissingHeaderException(); + } + } + } + + private Map extractHeaders(HttpServletRequest request, MethodSignature signature, + JoinPoint joinPoint) { + return Map.of( + LoggingConstants.METHOD, escapeXml(String.valueOf(signature.getMethod())), + LoggingConstants.TIMESTAMP, escapeXml(getCurrentTimestamp()), + CommonConstants.USER_AGENT, escapeXml(request.getHeader(CommonConstants.USER_AGENT)), + LoggingConstants.ARGUMENTS, escapeXml(getArgumentsString(signature.getParameterNames(), joinPoint.getArgs())), + CommonConstants.REQUESTED_BY, escapeXml(request.getHeader(CommonConstants.REQUESTED_BY)) + ); + } + + // Use synchronized to prevent race condition + private synchronized void saveLogToDailyFile(Map headersMap) { + try { + File logFile = createFile(generateFileName()); + + StringBuilder content = new StringBuilder(); + if (logFile.exists()) { + content.append(new String(Files.readAllBytes(logFile.toPath()))); + } + if (content.isEmpty()) { + content.append(LoggingConstants.LOG_START); + } + int lastLogIndex = content.lastIndexOf(LoggingConstants.LOG_END); + if (lastLogIndex != -1) { + content.delete(lastLogIndex, content.length()); + } + content.append(buildLogEntry(headersMap)); + content.append(LoggingConstants.LOG_END); + + writeToFile(logFile, content.toString()); + } catch (IOException e) { + log.error("Error writing log to file: {}", e.getMessage()); + } + } + + private String generateFileName() { + return Path.of(logFilePath, "log-" + getCurrentDate() + ".xml").toString(); + } + +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/util/FileUtils.java b/marketplace-service/src/main/java/com/axonivy/market/util/FileUtils.java new file mode 100644 index 000000000..6eeb35248 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/util/FileUtils.java @@ -0,0 +1,31 @@ +package com.axonivy.market.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class FileUtils { + + public static File createFile(String fileName) throws IOException { + File file = new File(fileName); + File parentDir = file.getParentFile(); + if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) { + throw new IOException("Failed to create directory: " + parentDir.getAbsolutePath()); + } + if (!file.exists() && !file.createNewFile()) { + throw new IOException("Failed to create file: " + file.getAbsolutePath()); + } + return file; + } + + public static void writeToFile(File file, String content) throws IOException { + try (FileWriter writer = new FileWriter(file, false)) { + writer.write(content); + } + } + +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/util/LoggingUtils.java b/marketplace-service/src/main/java/com/axonivy/market/util/LoggingUtils.java new file mode 100644 index 000000000..e69929bc1 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/util/LoggingUtils.java @@ -0,0 +1,54 @@ +package com.axonivy.market.util; + +import com.axonivy.market.constants.LoggingConstants; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.StringUtils; + +import java.text.SimpleDateFormat; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class LoggingUtils { + + public static String getCurrentDate() { + return new SimpleDateFormat(LoggingConstants.DATE_FORMAT).format(System.currentTimeMillis()); + } + + public static String getCurrentTimestamp() { + return new SimpleDateFormat(LoggingConstants.TIMESTAMP_FORMAT).format(System.currentTimeMillis()); + } + + public static String escapeXml(String value) { + if (StringUtils.isEmpty(value)) { + return StringUtils.EMPTY; + } + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + public static String getArgumentsString(String[] paramNames, Object[] args) { + if (paramNames == null || paramNames.length == 0 || args == null || args.length == 0) { + return LoggingConstants.NO_ARGUMENTS; + } + return IntStream.range(0, paramNames.length) + .mapToObj(i -> paramNames[i] + ": " + args[i]) + .collect(Collectors.joining(", ")); + } + + public static String buildLogEntry(Map headersMap) { + StringBuilder logEntry = new StringBuilder(); + Map map = new TreeMap<>(headersMap); + logEntry.append(LoggingConstants.ENTRY_START); + map.forEach((key, value) -> logEntry.append(String.format(LoggingConstants.ENTRY_FORMAT, key, value, key))); + logEntry.append(LoggingConstants.ENTRY_END); + return logEntry.toString(); + } + +} diff --git a/marketplace-service/src/main/resources/application.properties b/marketplace-service/src/main/resources/application.properties index 3e81b354f..a0ab0a261 100644 --- a/marketplace-service/src/main/resources/application.properties +++ b/marketplace-service/src/main/resources/application.properties @@ -16,4 +16,5 @@ market.github.oauth2-clientSecret=${MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET} jwt.secret=${MARKET_JWT_SECRET_KEY} jwt.expiration=365 logging.level.org.springframework.data.mongodb.core.MongoTemplate=${MARKET_MONGO_LOG_LEVEL} -spring.jackson.serialization.indent_output=true \ No newline at end of file +spring.jackson.serialization.indent_output=true +loggable.log-path=${MARKET_LOG_PATH} \ No newline at end of file diff --git a/marketplace-service/src/test/java/com/axonivy/market/logging/LoggableAspectTest.java b/marketplace-service/src/test/java/com/axonivy/market/logging/LoggableAspectTest.java new file mode 100644 index 000000000..0270dfd60 --- /dev/null +++ b/marketplace-service/src/test/java/com/axonivy/market/logging/LoggableAspectTest.java @@ -0,0 +1,89 @@ +package com.axonivy.market.logging; + +import com.axonivy.market.constants.CommonConstants; +import com.axonivy.market.constants.LoggingConstants; +import com.axonivy.market.exceptions.model.MissingHeaderException; +import com.axonivy.market.util.LoggingUtils; +import jakarta.servlet.http.HttpServletRequest; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoggableAspectTest { + + @Mock + private HttpServletRequest request; + + private LoggableAspect loggableAspect; + + @BeforeEach + void setUp() throws IOException { + MockitoAnnotations.openMocks(this); + loggableAspect = new LoggableAspect(); + loggableAspect.logFilePath = Files.createTempDirectory("logs").toString(); + } + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + void testLogFileCreation() throws Exception { + mockRequestAttributes(LoggingConstants.MARKET_WEBSITE, "test-agent"); + MethodSignature signature = mockMethodSignature(); + + loggableAspect.logMethodCall(mockJoinPoint(signature)); + Path logFilePath = Path.of(loggableAspect.logFilePath, "log-" + LoggingUtils.getCurrentDate() + ".xml"); + assertTrue(Files.exists(logFilePath), "Log file should be created"); + + String content = Files.readString(logFilePath); + assertTrue(content.contains(LoggingConstants.LOG_START), "Log file should contain log"); + assertTrue(content.contains(LoggingConstants.ENTRY_START), "Log file should contain log entry"); + } + + @Test + void testMissingHeaderException() { + mockRequestAttributes("invalid-source", "mock-agent"); + MethodSignature signature = mockMethodSignature(); + + assertThrows(MissingHeaderException.class, () -> + loggableAspect.logMethodCall(mockJoinPoint(signature)) + ); + } + + private JoinPoint mockJoinPoint(MethodSignature signature) { + JoinPoint joinPoint = mock(JoinPoint.class); + when(joinPoint.getSignature()).thenReturn(signature); + when(joinPoint.getArgs()).thenReturn(new Object[]{"arg1", "arg2"}); + return joinPoint; + } + + private void mockRequestAttributes(String requestedBy, String userAgent) { + when(request.getHeader(CommonConstants.REQUESTED_BY)).thenReturn(requestedBy); + when(request.getHeader(CommonConstants.USER_AGENT)).thenReturn(userAgent); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + private MethodSignature mockMethodSignature() { + MethodSignature signature = mock(MethodSignature.class); + when(signature.getMethod()).thenReturn(this.getClass().getMethods()[0]); + return signature; + } + +} diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/SchedulingTasksTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/SchedulingTasksTest.java index 23daae4b9..be16eada3 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/SchedulingTasksTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/SchedulingTasksTest.java @@ -13,7 +13,8 @@ @SpringBootTest(properties = {"MONGODB_USERNAME=user", "MONGODB_PASSWORD=password", "MONGODB_HOST=mongoHost", "MONGODB_DATABASE=product", "MARKET_GITHUB_OAUTH_APP_CLIENT_ID=clientId", "MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET=clientSecret", "MARKET_JWT_SECRET_KEY=jwtSecret", - "MARKET_CORS_ALLOWED_ORIGIN=*", "MARKET_GITHUB_MARKET_BRANCH=master", "MARKET_MONGO_LOG_LEVEL=DEBUG"}) + "MARKET_CORS_ALLOWED_ORIGIN=*", "MARKET_GITHUB_MARKET_BRANCH=master", "MARKET_MONGO_LOG_LEVEL=DEBUG", + "MARKET_LOG_PATH=logs"}) class SchedulingTasksTest { @SpyBean diff --git a/marketplace-service/src/test/java/com/axonivy/market/util/FileUtilsTest.java b/marketplace-service/src/test/java/com/axonivy/market/util/FileUtilsTest.java new file mode 100644 index 000000000..196152d82 --- /dev/null +++ b/marketplace-service/src/test/java/com/axonivy/market/util/FileUtilsTest.java @@ -0,0 +1,57 @@ +package com.axonivy.market.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class FileUtilsTest { + + private static final String FILE_PATH = "src/test/resources/test-file.xml"; + + @Test + void testCreateFile() throws IOException { + File createdFile = FileUtils.createFile(FILE_PATH); + assertTrue(createdFile.exists(), "File should exist"); + assertTrue(createdFile.isFile(), "Should be a file"); + createdFile.delete(); + } + + @Test + void testFailedToCreateDirectory() { + File createdFile = new File("testDirAsFile"); + try { + if (!createdFile.exists()) { + assertTrue(createdFile.createNewFile(), "Setup failed: could not create file"); + } + + IOException exception = assertThrows(IOException.class, () -> + FileUtils.createFile("testDirAsFile/subDir/testFile.txt") + ); + assertTrue(exception.getMessage().contains("Failed to create directory"), + "Exception message does not contain expected text"); + } catch (IOException e) { + fail("Setup failed: " + e.getMessage()); + } finally { + createdFile.delete(); + } + } + + @Test + void testWriteFile() throws IOException { + File createdFile = FileUtils.createFile(FILE_PATH); + String content = "Hello, world!"; + FileUtils.writeToFile(createdFile, content); + String fileContent = Files.readString(createdFile.toPath()); + assertEquals(content, fileContent, "File content should match the written content"); + createdFile.delete(); + + } + +} diff --git a/marketplace-service/src/test/java/com/axonivy/market/util/LoggingUtilsTest.java b/marketplace-service/src/test/java/com/axonivy/market/util/LoggingUtilsTest.java new file mode 100644 index 000000000..be0ad64fa --- /dev/null +++ b/marketplace-service/src/test/java/com/axonivy/market/util/LoggingUtilsTest.java @@ -0,0 +1,78 @@ +package com.axonivy.market.util; + +import com.axonivy.market.constants.LoggingConstants; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +@ExtendWith(MockitoExtension.class) +class LoggingUtilsTest { + + @Test + void testEscapeXmlSuccess() { + String input = ""; + String expectedValue = "<Test'& "Method>"; + String result = LoggingUtils.escapeXml(input); + Assertions.assertEquals(expectedValue, result); + } + + @Test + void testEscapeXmlOnNullValue() { + String expectedValue = ""; + String result = LoggingUtils.escapeXml(null); + Assertions.assertEquals(expectedValue, result); + } + + @Test + void testGetArgumentsString() { + String expectedValue = "a: random, b: sample"; + String result = LoggingUtils.getArgumentsString(new String[]{"a", "b"}, new String[]{"random", "sample"}); + Assertions.assertEquals(expectedValue, result); + } + + @Test + void testGetArgumentsStringOnNullValue() { + String result = LoggingUtils.getArgumentsString(null, null); + Assertions.assertEquals(LoggingConstants.NO_ARGUMENTS, result); + } + + @Test + void testBuildLogEntry() { + Map given = Map.of( + "method", "test", + "timestamp", "15:02:00" + ); + String expected = """ + + test + 15:02:00 + + """.indent(2); + + var result = LoggingUtils.buildLogEntry(given); + Assertions.assertEquals(expected, result); + } + + @Test + void testGetCurrentDate() { + String expectedDate = LocalDate.now().toString(); + String actualDate = LoggingUtils.getCurrentDate(); + Assertions.assertEquals(expectedDate, actualDate, "The returned date does not match the current date"); + } + + @Test + void testGetCurrentTimestamp() { + String expectedTimestamp = LocalDateTime.now() + .format(DateTimeFormatter.ofPattern(LoggingConstants.TIMESTAMP_FORMAT)); + String actualTimestamp = LoggingUtils.getCurrentTimestamp(); + Assertions.assertEquals(expectedTimestamp.substring(0, 19), actualTimestamp.substring(0, 19), + "The returned timestamp does not match the expected format or value"); + } + +}